<dfn id="hx5t3"><strike id="hx5t3"><em id="hx5t3"></em></strike></dfn>

    <thead id="hx5t3"></thead><nobr id="hx5t3"><font id="hx5t3"><rp id="hx5t3"></rp></font></nobr>

    <listing id="hx5t3"></listing>

    <var id="hx5t3"></var>
    <big id="hx5t3"></big>

      
      

      <output id="hx5t3"><ruby id="hx5t3"></ruby></output>
      <menuitem id="hx5t3"><dfn id="hx5t3"></dfn></menuitem>

      <big id="hx5t3"></big>

        譚光志

        譚光志 查看完整檔案

        天津編輯  |  填寫畢業院校  |  填寫所在公司/組織 github.com/woai3c 編輯
        編輯

        公眾號:前端編程技術分享

        知乎:https://www.zhihu.com/people/...

        github: https://github.com/woai3c

        個人動態

        譚光志 發布了文章 · 2月24日

        手把手教你寫一個腳手架

        最近在學習 vue-cli 的源碼,獲益良多。為了讓自己理解得更加深刻,我決定模仿它造一個輪子,爭取盡可能多的實現原有的功能。

        我將這個輪子分成三個版本:

        1. 盡可能用最少的代碼實現一個最簡版本的腳手架。
        2. 在 1 的基礎上添加一些輔助功能,例如選擇包管理器、npm 源等等。
        3. 實現插件化,可以自由的進行擴展。在不影響內部源碼的情況下,添加功能。

        有人可能不懂腳手架是什么。按我的理解,腳手架就是幫助你把項目的基礎架子搭好。例如項目依賴、模板、構建工具等等。讓你不用從零開始配置一個項目,盡可能快的進行業務開發。

        建議在閱讀本文時,能夠結合項目源碼一起配合使用,效果更好。這是項目地址 mini-cli。項目中的每一個分支都對應一個版本,例如第一個版本對應的 git 分支為 v1。所以在閱讀源碼時,記得要切換到對應的分支。

        第一個版本 v1

        第一個版本的功能比較簡單,大致為:

        1. 用戶輸入命令,準備創建項目。
        2. 腳手架解析用戶命令,并彈出交互語句,詢問用戶創建項目需要哪些功能。
        3. 用戶選擇自己需要的功能。
        4. 腳手架根據用戶的選擇創建 package.json 文件,并添加對應的依賴項。
        5. 腳手架根據用戶的選擇渲染項目模板,生成文件(例如 index.html、main.js、App.vue 等文件)。
        6. 執行 npm install 命令安裝依賴。

        項目目錄樹:

        ├─.vscode
        ├─bin 
        │  ├─mvc.js # mvc 全局命令
        ├─lib
        │  ├─generator # 各個功能的模板
        │  │  ├─babel # babel 模板
        │  │  ├─linter # eslint 模板
        │  │  ├─router # vue-router 模板
        │  │  ├─vue # vue 模板
        │  │  ├─vuex # vuex 模板
        │  │  └─webpack # webpack 模板
        │  ├─promptModules # 各個模塊的交互提示語
        │  └─utils # 一系列工具函數
        │  ├─create.js # create 命令處理函數
        │  ├─Creator.js # 處理交互提示
        │  ├─Generator.js # 渲染模板
        │  ├─PromptModuleAPI.js # 將各個功能的提示語注入 Creator
        └─scripts # commit message 驗證腳本 和項目無關 不需關注

        處理用戶命令

        腳手架第一個功能就是處理用戶的命令,這需要使用 commander.js。這個庫的功能就是解析用戶的命令,提取出用戶的輸入交給腳手架。例如這段代碼:

        #!/usr/bin/env node
        const program = require('commander')
        const create = require('../lib/create')
        
        program
        .version('0.1.0')
        .command('create <name>')
        .description('create a new project')
        .action(name => { 
            create(name)
        })
        
        program.parse()

        它使用 commander 注冊了一個 create 命令,并設置了腳手架的版本和描述。我將這段代碼保存在項目下的 bin 目錄,并命名為 mvc.js。然后在 package.json 文件添加這段代碼:

        "bin": {
          "mvc": "./bin/mvc.js"
        },

        再執行 npm link,就可以將 mvc 注冊成全局命令。這樣在電腦上的任何地方都能使用 mvc 命令了。實際上,就是用 mvc 命令來代替執行 node ./bin/mvc.js。

        假設用戶在命令行上輸入 mvc create demo(實際上執行的是 node ./bin/mvc.js create demo),commander 解析到命令 create 和參數 demo。然后腳手架可以在 action 回調里取到參數 name(值為 demo)。

        和用戶交互

        取到用戶要創建的項目名稱 demo 之后,就可以彈出交互選項,詢問用戶要創建的項目需要哪些功能。這需要用到 [
        Inquirer.js](https://github.com/SBoudrias/...。Inquirer.js 的功能就是彈出一個問題和一些選項,讓用戶選擇。并且選項可以指定是多選、單選等等。

        例如下面的代碼:

        const prompts = [
            {
                "name": "features", // 選項名稱
                "message": "Check the features needed for your project:", // 選項提示語
                "pageSize": 10,
                "type": "checkbox", // 選項類型 另外還有 confirm list 等
                "choices": [ // 具體的選項
                    {
                        "name": "Babel",
                        "value": "babel",
                        "short": "Babel",
                        "description": "Transpile modern JavaScript to older versions (for compatibility)",
                        "link": "https://babeljs.io/",
                        "checked": true
                    },
                    {
                        "name": "Router",
                        "value": "router",
                        "description": "Structure the app with dynamic pages",
                        "link": "https://router.vuejs.org/"
                    },
                ]
            }
        ]
        
        inquirer.prompt(prompts)

        彈出的問題和選項如下:

        問題的類型 "type": "checkbox"checkbox 說明是多選。如果兩個選項都進行選中的話,返回來的值為:

        { features: ['babel', 'router'] }

        其中 features 是上面問題中的 name 屬性。features 數組中的值則是每個選項中的 value。

        Inquirer.js 還可以提供具有相關性的問題,也就是上一個問題選擇了指定的選項,下一個問題才會顯示出來。例如下面的代碼:

        {
            name: 'Router',
            value: 'router',
            description: 'Structure the app with dynamic pages',
            link: 'https://router.vuejs.org/',
        },
        {
            name: 'historyMode',
            when: answers => answers.features.includes('router'),
            type: 'confirm',
            message: `Use history mode for router? ${chalk.yellow(`(Requires proper server setup for index fallback in production)`)}`,
            description: `By using the HTML5 History API, the URLs don't need the '#' character anymore.`,
            link: 'https://router.vuejs.org/guide/essentials/history-mode.html',
        },

        第二個問題中有一個屬性 when,它的值是一個函數 answers => answers.features.includes('router')。當函數的執行結果為 true,第二個問題才會顯示出來。如果你在上一個問題中選擇了 router,它的結果就會變為 true。彈出第二個問題:問你路由模式是否選擇 history 模式。

        大致了解 Inquirer.js 后,就可以明白這一步我們要做什么了。主要就是將腳手架支持的功能配合對應的問題、可選值在控制臺上展示出來,供用戶選擇。獲取到用戶具體的選項值后,再渲染模板和依賴。

        有哪些功能

        先來看一下第一個版本支持哪些功能:

        • vue
        • vue-router
        • vuex
        • babel
        • webpack
        • linter(eslint)

        由于這是一個 vue 相關的腳手架,所以 vue 是默認提供的,不需要用戶選擇。另外構建工具 webpack 提供了開發環境和打包的功能,也是必需的,不用用戶進行選擇。所以可供用戶選擇的功能只有 4 個:

        • vue-router
        • vuex
        • babel
        • linter

        現在我們先來看一下這 4 個功能對應的交互提示語相關的文件。它們全部放在 lib/promptModules 目錄下:

        -babel.js
        -linter.js
        -router.js
        -vuex.js

        每個文件包含了和它相關的所有交互式問題。例如剛才的示例,說明 router 相關的問題有兩個。下面再看一下 babel.js 的代碼:

        module.exports = (api) => {
            api.injectFeature({
                name: 'Babel',
                value: 'babel',
                short: 'Babel',
                description: 'Transpile modern JavaScript to older versions (for compatibility)',
                link: 'https://babeljs.io/',
                checked: true,
            })
        }

        只有一個問題,就是問下用戶需不需要 babel 功能,默認為 checked: true,也就是需要。

        注入問題

        用戶使用 create 命令后,腳手架需要將所有功能的交互提示語句聚合在一起:

        // craete.js
        const creator = new Creator()
        // 獲取各個模塊的交互提示語
        const promptModules = getPromptModules()
        const promptAPI = new PromptModuleAPI(creator)
        promptModules.forEach(m => m(promptAPI))
        // 清空控制臺
        clearConsole()
        
        // 彈出交互提示語并獲取用戶的選擇
        const answers = await inquirer.prompt(creator.getFinalPrompts())
            
        function getPromptModules() {
            return [
                'babel',
                'router',
                'vuex',
                'linter',
            ].map(file => require(`./promptModules/${file}`))
        }
        
        // Creator.js
        class Creator {
            constructor() {
                this.featurePrompt = {
                    name: 'features',
                    message: 'Check the features needed for your project:',
                    pageSize: 10,
                    type: 'checkbox',
                    choices: [],
                }
        
                this.injectedPrompts = []
            }
        
            getFinalPrompts() {
                this.injectedPrompts.forEach(prompt => {
                    const originalWhen = prompt.when || (() => true)
                    prompt.when = answers => originalWhen(answers)
                })
            
                const prompts = [
                    this.featurePrompt,
                    ...this.injectedPrompts,
                ]
            
                return prompts
            }
        }
        
        module.exports = Creator
        
        
        // PromptModuleAPI.js
        module.exports = class PromptModuleAPI {
            constructor(creator) {
                this.creator = creator
            }
        
            injectFeature(feature) {
                this.creator.featurePrompt.choices.push(feature)
            }
        
            injectPrompt(prompt) {
                this.creator.injectedPrompts.push(prompt)
            }
        }

        以上代碼的邏輯如下:

        1. 創建 creator 對象
        2. 調用 getPromptModules() 獲取所有功能的交互提示語
        3. 再調用 PromptModuleAPI 將所有交互提示語注入到 creator 對象
        4. 通過 const answers = await inquirer.prompt(creator.getFinalPrompts()) 在控制臺彈出交互語句,并將用戶選擇結果賦值給 answers 變量。

        如果所有功能都選上,answers 的值為:

        {
          features: [ 'vue', 'webpack', 'babel', 'router', 'vuex', 'linter' ], // 項目具有的功能
          historyMode: true, // 路由是否使用 history 模式
          eslintConfig: 'airbnb', // esilnt 校驗代碼的默認規則,可被覆蓋
          lintOn: [ 'save' ] // 保存代碼時進行校驗
        }

        項目模板

        獲取用戶的選項后就該開始渲染模板和生成 package.json 文件了。先來看一下如何生成 package.json 文件:

        // package.json 文件內容
        const pkg = {
            name,
            version: '0.1.0',
            dependencies: {},
            devDependencies: {},
        }

        先定義一個 pkg 變量來表示 package.json 文件,并設定一些默認值。

        所有的項目模板都放在 lib/generator 目錄下:

        ├─lib
        │  ├─generator # 各個功能的模板
        │  │  ├─babel # babel 模板
        │  │  ├─linter # eslint 模板
        │  │  ├─router # vue-router 模板
        │  │  ├─vue # vue 模板
        │  │  ├─vuex # vuex 模板
        │  │  └─webpack # webpack 模板

        每個模板的功能都差不多:

        1. pkg 變量注入依賴項
        2. 提供模板文件

        注入依賴

        下面是 babel 相關的代碼:

        module.exports = (generator) => {
            generator.extendPackage({
                babel: {
                    presets: ['@babel/preset-env'],
                },
                dependencies: {
                    'core-js': '^3.8.3',
                },
                devDependencies: {
                    '@babel/core': '^7.12.13',
                    '@babel/preset-env': '^7.12.13',
                    'babel-loader': '^8.2.2',
                },
            })
        }

        可以看到,模板調用 generator 對象的 extendPackage() 方法向 pkg 變量注入了 babel 相關的所有依賴。

        extendPackage(fields) {
            const pkg = this.pkg
            for (const key in fields) {
                const value = fields[key]
                const existing = pkg[key]
                if (isObject(value) && (key === 'dependencies' || key === 'devDependencies' || key === 'scripts')) {
                    pkg[key] = Object.assign(existing || {}, value)
                } else {
                    pkg[key] = value
                }
            }
        }

        注入依賴的過程就是遍歷所有用戶已選擇的模板,并調用 extendPackage() 注入依賴。

        渲染模板

        腳手架是怎么渲染模板的呢?用 vuex 舉例,先看一下它的代碼:

        module.exports = (generator) => {
            // 向入口文件 `src/main.js` 注入代碼 import store from './store'
            generator.injectImports(generator.entryFile, `import store from './store'`)
            
            // 向入口文件 `src/main.js` 的 new Vue() 注入選項 store
            generator.injectRootOptions(generator.entryFile, `store`)
            
            // 注入依賴
            generator.extendPackage({
                dependencies: {
                    vuex: '^3.6.2',
                },
            })
            
            // 渲染模板
            generator.render('./template', {})
        }

        可以看到渲染的代碼為 generator.render('./template', {})。./template 是模板目錄的路徑:

        所有的模板代碼都放在 template 目錄下,vuex 將會在用戶創建的目錄下的 src 目錄生成 store 文件夾,里面有一個 index.js 文件。它的內容為:

        import Vue from 'vue'
        import Vuex from 'vuex'
        
        Vue.use(Vuex)
        
        export default new Vuex.Store({
            state: {
            },
            mutations: {
            },
            actions: {
            },
            modules: {
            },
        })

        這里簡單描述一下 generator.render() 的渲染過程。

        第一步, 使用 globby 讀取模板目錄下的所有文件:

        const _files = await globby(['**/*'], { cwd: source, dot: true })

        第二步,遍歷所有讀取的文件。如果文件是二進制文件,則不作處理,渲染時直接生成文件。否則讀取文件內容,再調用 ejs 進行渲染:

        // 返回文件內容
        const template = fs.readFileSync(name, 'utf-8')
        return ejs.render(template, data, ejsOptions)

        使用 ejs 的好處,就是可以結合變量來決定是否渲染某些代碼。例如 webpack 的模板中有這樣一段代碼:

        module: {
              rules: [
                  <%_ if (hasBabel) { _%>
                  {
                      test: /\.js$/,
                      loader: 'babel-loader',
                      exclude: /node_modules/,
                  },
                  <%_ } _%>
              ],
          },

        ejs 可以根據用戶是否選擇了 babel 來決定是否渲染這段代碼。如果 hasBabelfalse,則這段代碼:

        {
            test: /\.js$/,
            loader: 'babel-loader',
            exclude: /node_modules/,
        },

        將不會被渲染出來。hasBabel 的值是調用 render() 時用參數傳過去的:

        generator.render('./template', {
            hasBabel: options.features.includes('babel'),
            lintOnSave: options.lintOn.includes('save'),
        })

        第三步,注入特定代碼?;叵胍幌聞偛?vuex 中的:

        // 向入口文件 `src/main.js` 注入代碼 import store from './store'
        generator.injectImports(generator.entryFile, `import store from './store'`)
        
        // 向入口文件 `src/main.js` 的 new Vue() 注入選項 store
        generator.injectRootOptions(generator.entryFile, `store`)

        這兩行代碼的作用是:在項目入口文件 src/main.js 中注入特定的代碼。

        vuexvue 的一個狀態管理庫,屬于 vue 全家桶中的一員。如果創建的項目沒有選擇 vuexvue-router。則 src/main.js 的代碼為:

        import Vue from 'vue'
        import App from './App.vue'
        
        Vue.config.productionTip = false
        
        new Vue({
            render: (h) => h(App),
        }).$mount('#app')

        如果選擇了 vuex,它會注入上面所說的兩行代碼,現在 src/main.js 代碼變為:

        import Vue from 'vue'
        import store from './store' // 注入的代碼
        import App from './App.vue'
        
        Vue.config.productionTip = false
        
        new Vue({
          store, // 注入的代碼
          render: (h) => h(App),
        }).$mount('#app')

        這里簡單描述一下代碼的注入過程:

        1. 使用 vue-codemod 將代碼解析成語法抽象樹 AST。
        2. 然后將要插入的代碼變成 AST 節點插入到上面所說的 AST 中。
        3. 最后將新的 AST 重新渲染成代碼。

        提取 package.json 的部分選項

        一些第三方庫的配置項可以放在 package.json 文件,也可以自己獨立生成一份文件。例如 babelpackage.json 中注入的配置為:

        babel: {
            presets: ['@babel/preset-env'],
        }

        我們可以調用 generator.extractConfigFiles() 將內容提取出來并生成 babel.config.js 文件:

        module.exports = {
            presets: ['@babel/preset-env'],
        }

        生成文件

        渲染好的模板文件和 package.json 文件目前還是在內存中,并沒有真正的在硬盤上創建。這時可以調用 writeFileTree() 將文件生成:

        const fs = require('fs-extra')
        const path = require('path')
        
        module.exports = async function writeFileTree(dir, files) {
            Object.keys(files).forEach((name) => {
                const filePath = path.join(dir, name)
                fs.ensureDirSync(path.dirname(filePath))
                fs.writeFileSync(filePath, files[name])
            })
        }

        這段代碼的邏輯如下:

        1. 遍歷所有渲染好的文件,逐一生成。
        2. 在生成一個文件時,確認它的父目錄在不在,如果不在,就先生成父目錄。
        3. 寫入文件。

        例如現在一個文件路徑為 src/test.js,第一次寫入時,由于還沒有 src 目錄。所以會先生成 src 目錄,再生成 test.js 文件。

        webpack

        webpack 需要提供開發環境下的熱加載、編譯等服務,還需要提供打包服務。目前 webpack 的代碼比較少,功能比較簡單。而且生成的項目中,webpack 配置代碼是暴露出來的。這留待 v3 版本再改進。

        添加新功能

        添加一個新功能,需要在兩個地方添加代碼:分別是 lib/promptModuleslib/generator。在 lib/promptModules 中添加的是這個功能相關的交互提示語。在 lib/generator 中添加的是這個功能相關的依賴和模板代碼。

        不過不是所有的功能都需要添加模板代碼的,例如 babel 就不需要。在添加新功能時,有可能會對已有的模板代碼造成影響。例如我現在需要項目支持 ts。除了添加 ts 相關的依賴,還得在 webpackvuevue-routervuexlinter 等功能中修改原有的模板代碼。

        舉個例子,在 vue-router 中,如果支持 ts,則這段代碼:

        const routes = [ // ... ]

        需要修改為:

        <%_ if (hasTypeScript) { _%>
        const routes: Array<RouteConfig> = [ // ... ]
        <%_ } else { _%>
        const routes = [ // ... ]
        <%_ } _%>

        因為 ts 的值有類型。

        總之,添加的新功能越多,各個功能的模板代碼也會越來越多。并且還需要考慮到各個功能之間的影響。

        下載依賴

        下載依賴需要使用 execa,它可以調用子進程執行命令。

        const execa = require('execa')
        
        module.exports = function executeCommand(command, cwd) {
            return new Promise((resolve, reject) => {
                const child = execa(command, [], {
                    cwd,
                    stdio: ['inherit', 'pipe', 'inherit'],
                })
        
                child.stdout.on('data', buffer => {
                    process.stdout.write(buffer)
                })
        
                child.on('close', code => {
                    if (code !== 0) {
                        reject(new Error(`command failed: ${command}`))
                        return
                    }
        
                    resolve()
                })
            })
        }
        
        // create.js 文件
        console.log('\n正在下載依賴...\n')
        // 下載依賴
        await executeCommand('npm install', path.join(process.cwd(), name))
        console.log('\n依賴下載完成! 執行下列命令開始開發:\n')
        console.log(`cd ${name}`)
        console.log(`npm run dev`)

        調用 executeCommand() 開始下載依賴,參數為 npm install 和用戶創建的項目路徑。為了能讓用戶看到下載依賴的過程,我們需要使用下面的代碼將子進程的輸出傳給主進程,也就是輸出到控制臺:

        child.stdout.on('data', buffer => {
            process.stdout.write(buffer)
        })

        下面我用動圖演示一下 v1 版本的創建過程:

        創建成功的項目截圖:

        第二個版本 v2

        第二個版本在 v1 的基礎上添加了一些輔助功能:

        1. 創建項目時判斷該項目是否已存在,支持覆蓋和合并創建。
        2. 選擇功能時提供默認配置和手動選擇兩種模式。
        3. 如果用戶的環境同時存在 yarn 和 npm,則會提示用戶要使用哪個包管理器。
        4. 如果 npm 的默認源速度比較慢,則提示用戶是否要切換到淘寶源。
        5. 如果用戶是手動選擇功能,在結束后會詢問用戶是否要將這次的選擇保存為默認配置。

        覆蓋和合并

        創建項目時,先提前判斷一下該項目是否存在:

        const targetDir = path.join(process.cwd(), name)
        // 如果目標目錄已存在,詢問是覆蓋還是合并
        if (fs.existsSync(targetDir)) {
            // 清空控制臺
            clearConsole()
        
            const { action } = await inquirer.prompt([
                {
                    name: 'action',
                    type: 'list',
                    message: `Target directory ${chalk.cyan(targetDir)} already exists. Pick an action:`,
                    choices: [
                        { name: 'Overwrite', value: 'overwrite' },
                        { name: 'Merge', value: 'merge' },
                    ],
                },
            ])
        
            if (action === 'overwrite') {
                console.log(`\nRemoving ${chalk.cyan(targetDir)}...`)
                await fs.remove(targetDir)
            }
        }

        如果選擇 overwrite,則進行移除 fs.remove(targetDir)。

        默認配置和手動模式

        先在代碼中提前把默認配置的代碼寫好:

        exports.defaultPreset = {
            features: ['babel', 'linter'],
            historyMode: false,
            eslintConfig: 'airbnb',
            lintOn: ['save'],
        }

        這個配置默認使用 babeleslint。

        然后生成交互提示語時,先調用 getDefaultPrompts() 方法獲取默認配置。

        getDefaultPrompts() {
            const presets = this.getPresets()
            const presetChoices = Object.entries(presets).map(([name, preset]) => {
                let displayName = name
        
                return {
                    name: `${displayName} (${preset.features})`,
                    value: name,
                }
            })
        
            const presetPrompt = {
                name: 'preset',
                type: 'list',
                message: `Please pick a preset:`,
                choices: [
                    // 默認配置
                    ...presetChoices,
                    // 這是手動模式提示語
                    {
                        name: 'Manually select features',
                        value: '__manual__',
                    },
                ],
            }
        
            const featurePrompt = {
                name: 'features',
                when: isManualMode,
                type: 'checkbox',
                message: 'Check the features needed for your project:',
                choices: [],
                pageSize: 10,
            }
        
            return {
                presetPrompt,
                featurePrompt,
            }
        }

        這樣配置后,在用戶選擇功能前會先彈出這樣的提示語:

        包管理器

        vue-cli 創建項目時,會生成一個 .vuerc 文件,里面會記錄一些關于項目的配置信息。例如使用哪個包管理器、npm 源是否使用淘寶源等等。為了避免和 vue-cli 沖突,本腳手架生成的配置文件為 .mvcrc。

        這個 .mvcrc 文件保存在用戶的 home 目錄下(不同操作系統目錄不同)。我的是 win10 操作系統,保存目錄為 C:\Users\bin。獲取用戶的 home 目錄可以通過以下代碼獲?。?/p>

        const os = require('os')
        os.homedir()

        .mvcrc 文件還會保存用戶創建項目的配置,這樣當用戶重新創建項目時,就可以直接選擇以前創建過的配置,不用再一步步的選擇項目功能。

        在第一次創建項目時,.mvcrc 文件是不存在的。如果這時用戶還安裝了 yarn,腳手架就會提示用戶要使用哪個包管理器:

        // 讀取 `.mvcrc` 文件
        const savedOptions = loadOptions()
        // 如果沒有指定包管理器并且存在 yarn
        if (!savedOptions.packageManager && hasYarn) {
            const packageManagerChoices = []
        
            if (hasYarn()) {
                packageManagerChoices.push({
                    name: 'Use Yarn',
                    value: 'yarn',
                    short: 'Yarn',
                })
            }
        
            packageManagerChoices.push({
                name: 'Use NPM',
                value: 'npm',
                short: 'NPM',
            })
        
            otherPrompts.push({
                name: 'packageManager',
                type: 'list',
                message: 'Pick the package manager to use when installing dependencies:',
                choices: packageManagerChoices,
            })
        }

        當用戶選擇 yarn 后,下載依賴的命令就會變為 yarn;如果選擇了 npm,下載命令則為 npm install

        const PACKAGE_MANAGER_CONFIG = {
            npm: {
                install: ['install'],
            },
            yarn: {
                install: [],
            },
        }
        
        await executeCommand(
            this.bin, // 'yarn' or 'npm'
            [
                ...PACKAGE_MANAGER_CONFIG[this.bin][command],
                ...(args || []),
            ],
            this.context,
        )

        切換 npm 源

        當用戶選擇了項目功能后,會先調用 shouldUseTaobao() 方法判斷是否需要切換淘寶源:

        const execa = require('execa')
        const chalk = require('chalk')
        const request = require('./request')
        const { hasYarn } = require('./env')
        const inquirer = require('inquirer')
        const registries = require('./registries')
        const { loadOptions, saveOptions } = require('./options')
          
        async function ping(registry) {
            await request.get(`${registry}/vue-cli-version-marker/latest`)
            return registry
        }
          
        function removeSlash(url) {
            return url.replace(/\/$/, '')
        }
          
        let checked
        let result
          
        module.exports = async function shouldUseTaobao(command) {
            if (!command) {
                command = hasYarn() ? 'yarn' : 'npm'
            }
          
            // ensure this only gets called once.
            if (checked) return result
            checked = true
          
            // previously saved preference
            const saved = loadOptions().useTaobaoRegistry
            if (typeof saved === 'boolean') {
                return (result = saved)
            }
          
            const save = val => {
                result = val
                saveOptions({ useTaobaoRegistry: val })
                return val
            }
          
            let userCurrent
            try {
                userCurrent = (await execa(command, ['config', 'get', 'registry'])).stdout
            } catch (registryError) {
                try {
                // Yarn 2 uses `npmRegistryServer` instead of `registry`
                    userCurrent = (await execa(command, ['config', 'get', 'npmRegistryServer'])).stdout
                } catch (npmRegistryServerError) {
                    return save(false)
                }
            }
          
            const defaultRegistry = registries[command]
            if (removeSlash(userCurrent) !== removeSlash(defaultRegistry)) {
                // user has configured custom registry, respect that
                return save(false)
            }
          
            let faster
            try {
                faster = await Promise.race([
                    ping(defaultRegistry),
                    ping(registries.taobao),
                ])
            } catch (e) {
                return save(false)
            }
          
            if (faster !== registries.taobao) {
                // default is already faster
                return save(false)
            }
          
            if (process.env.VUE_CLI_API_MODE) {
                return save(true)
            }
          
            // ask and save preference
            const { useTaobaoRegistry } = await inquirer.prompt([
                {
                    name: 'useTaobaoRegistry',
                    type: 'confirm',
                    message: chalk.yellow(
                        ` Your connection to the default ${command} registry seems to be slow.\n`
                    + `   Use ${chalk.cyan(registries.taobao)} for faster installation?`,
                    ),
                },
            ])
            
            // 注冊淘寶源
            if (useTaobaoRegistry) {
                await execa(command, ['config', 'set', 'registry', registries.taobao])
            }
        
            return save(useTaobaoRegistry)
        }

        上面代碼的邏輯為:

        1. 先判斷默認配置文件 .mvcrc 是否有 useTaobaoRegistry 選項。如果有,直接將結果返回,無需判斷。
        2. 向 npm 默認源和淘寶源各發一個 get 請求,通過 Promise.race() 來調用。這樣更快的那個請求會先返回,從而知道是默認源還是淘寶源速度更快。
        3. 如果淘寶源速度更快,向用戶提示是否切換到淘寶源。
        4. 如果用戶選擇淘寶源,則調用 await execa(command, ['config', 'set', 'registry', registries.taobao]) 將當前 npm 的源改為淘寶源,即 npm config set registry https://registry.npm.taobao.org。如果是 yarn,則命令為 yarn config set registry https://registry.npm.taobao.org。

        一點疑問

        其實 vue-cli 是沒有這段代碼的:

        // 注冊淘寶源
        if (useTaobaoRegistry) {
            await execa(command, ['config', 'set', 'registry', registries.taobao])
        }

        這是我自己加的。主要是我沒有在 vue-cli 中找到顯式注冊淘寶源的代碼,它只是從配置文件讀取出是否使用淘寶源,或者將是否使用淘寶源這個選項寫入配置文件。另外 npm 的配置文件 .npmrc 是可以更改默認源的,如果在 .npmrc 文件直接寫入淘寶的鏡像地址,那 npm 就會使用淘寶源下載依賴。但 npm 肯定不會去讀取 .vuerc 的配置來決定是否使用淘寶源。

        對于這一點我沒搞明白,所以在用戶選擇了淘寶源之后,手動調用命令注冊一遍。

        將項目功能保存為默認配置

        如果用戶創建項目時選擇手動模式,在選擇完一系列功能后,會彈出下面的提示語:

        詢問用戶是否將這次的項目選擇保存為默認配置,如果用戶選擇是,則彈出下一個提示語:

        讓用戶輸入保存配置的名稱。

        這兩句提示語相關的代碼為:

        const otherPrompts = [
            {
                name: 'save',
                when: isManualMode,
                type: 'confirm',
                message: 'Save this as a preset for future projects?',
                default: false,
            },
            {
                name: 'saveName',
                when: answers => answers.save,
                type: 'input',
                message: 'Save preset as:',
            },
        ]

        保存配置的代碼為:

        exports.saveOptions = (toSave) => {
            const options = Object.assign(cloneDeep(exports.loadOptions()), toSave)
            for (const key in options) {
                if (!(key in exports.defaults)) {
                    delete options[key]
                }
            }
            cachedOptions = options
            try {
                fs.writeFileSync(rcPath, JSON.stringify(options, null, 2))
                return true
            } catch (e) {
                error(
                    `Error saving preferences: `
              + `make sure you have write access to ${rcPath}.\n`
              + `(${e.message})`,
                )
            }
        }
        
        exports.savePreset = (name, preset) => {
            const presets = cloneDeep(exports.loadOptions().presets || {})
            presets[name] = preset
        
            return exports.saveOptions({ presets })
        }

        以上代碼直接將用戶的配置保存到 .mvcrc 文件中。下面是我電腦上的 .mvcrc 的內容:

        {
          "packageManager": "npm",
          "presets": {
            "test": {
              "features": [
                "babel",
                "linter"
              ],
              "eslintConfig": "airbnb",
              "lintOn": [
                "save"
              ]
            },
            "demo": {
              "features": [
                "babel",
                "linter"
              ],
              "eslintConfig": "airbnb",
              "lintOn": [
                "save"
              ]
            }
          },
          "useTaobaoRegistry": true
        }

        下次再創建項目時,腳手架就會先讀取這個配置文件的內容,讓用戶決定是否使用已有的配置來創建項目。

        至此,v2 版本的內容就介紹完了。

        小結

        由于 vue-cli 關于插件的源碼我還沒有看完,所以這篇文章只講解前兩個版本的源碼。v3 版本等我看完 vue-cli 的源碼再回來填坑,預計在 3 月初就可以完成。

        如果你想了解更多關于前端工程化的文章,可以看一下我寫的《帶你入門前端工程》。 這里是全文目錄:

        1. 技術選型:如何進行技術選型?
        2. 統一規范:如何制訂規范并利用工具保證規范被嚴格執行?
        3. 前端組件化:什么是模塊化、組件化?
        4. 測試:如何寫單元測試和 E2E(端到端) 測試?
        5. 構建工具:構建工具有哪些?都有哪些功能和優勢?
        6. 自動化部署:如何利用 Jenkins、Github Actions 自動化部署項目?
        7. 前端監控:講解前端監控原理及如何利用 sentry 對項目實行監控。
        8. 性能優化(一):如何檢測網站性能?有哪些實用的性能優化規則?
        9. 性能優化(二):如何檢測網站性能?有哪些實用的性能優化規則?
        10. 重構:為什么做重構?重構有哪些手法?
        11. 微服務:微服務是什么?如何搭建微服務項目?
        12. Severless:Severless 是什么?如何使用 Severless?

        參考資料

        查看原文

        贊 54 收藏 36 評論 0

        譚光志 贊了文章 · 2月20日

        手寫一個webpack,看看AST怎么用

        本文開始我會圍繞webpackbabel寫一系列的工程化文章,這兩個工具我雖然天天用,但是對他們的原理理解的其實不是很深入,寫這些文章的過程其實也是我深入學習的過程。由于webpackbabel的體系太大,知識點眾多,不可能一篇文章囊括所有知識點,目前我的計劃是從簡單入手,先實現一個最簡單的可以運行的webpack,然后再看看plugin, loadertree shaking等功能。目前我計劃會有這些文章:

        1. 手寫最簡webpack,也就是本文
        2. webpackplugin實現原理
        3. webpackloader實現原理
        4. webpacktree shaking實現原理
        5. webpackHMR實現原理
        6. babelast原理

        所有文章都是原理或者源碼解析,歡迎關注~

        本文可運行代碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/mini-webpack

        注意:本文主要講webpack原理,在實現時并不嚴謹,而且只處理了importexportdefault情況,如果你想在生產環境使用,請自己添加其他情況的處理和邊界判斷。

        為什么要用webpack

        筆者剛開始做前端時,其實不知道什么webpack,也不懂模塊化,都是html里面直接寫script,引入jquery直接干。所以如果一個頁面的JS需要依賴jquerylodash,那html可能就長這樣:

        <!DOCTYPE html>
        <html>
          <head>
            <meta charset="utf-8" />
            <script data-original="https://unpkg.com/jquery@3.5.1"></script>
            <script data-original="https://unpkg.com/lodash@4.17.20"></script>
            <script data-original="./src/index.js"></script>
          </head>
          <body>
          </body>
        </html>

        這樣寫會導致幾個問題:

        1. 單獨看index.js不能清晰的找到他到底依賴哪些外部庫
        2. script的順序必須寫正確,如果錯了就會導致找不到依賴,直接報錯
        3. 模塊間通信困難,基本都靠往window上注入變量來暴露給外部
        4. 瀏覽器嚴格按照script標簽來下載代碼,有些沒用到的代碼也會下載下來
        5. 當前端規模變大,JS腳本會顯得很雜亂,項目管理混亂

        webpack的一個最基本的功能就是來解決上述的情況,允許在JS里面通過import或者require等關鍵字來顯式申明依賴,可以引用第三方庫,自己的JS代碼間也可以相互引用,這樣在實質上就實現了前端代碼的模塊化。由于歷史問題,老版的JS并沒有自己模塊管理方案,所以社區提出了很多模塊管理方案,比如ES2015import,CommonJSrequire,另外還有AMD,CMD等等。就目前我見到的情況來說,import因為已經成為ES2015標準,所以在客戶端廣泛使用,而requireNode.js的自帶模塊管理機制,也有很廣泛的用途,而AMDCMD的使用已經很少見了。

        但是webpack作為一個開放的模塊化工具,他是支持ES6,CommonJSAMD等多種標準的,不同的模塊化標準有不同的解析方法,本文只會講ES6標準的import方案,這也是客戶端JS使用最多的方案。

        簡單例子

        按照業界慣例,我也用hello world作為一個簡單的例子,但是我將這句話拆成了幾部分,放到了不同的文件里面。

        先來建一個hello.js,只導出一個簡單的字符串:

        const hello = 'hello';
        
        export default hello;

        然后再來一個helloWorld.js,將helloworld拼成一句話,并導出拼接的這個方法:

        import hello from './hello';
        
        const world = 'world';
        
        const helloWorld = () => `${hello} ${world}`;
        
        export default helloWorld;

        最后再來個index.js,將拼好的hello world插入到頁面上去:

        import helloWorld from "./helloWorld";
        
        const helloWorldStr = helloWorld();
        
        function component() {
          const element = document.createElement("div");
        
          element.innerHTML = helloWorldStr;
        
          return element;
        }
        
        document.body.appendChild(component());

        現在如果你直接在html里面引用index.js是不能運行成功的,因為大部分瀏覽器都不支持import這種模塊導入。而webpack就是來解決這個問題的,它會將我們模塊化的代碼轉換成瀏覽器認識的普通JS來執行。

        引入webpack

        我們印象中webpack的配置很多,很麻煩,但那是因為我們需要開啟的功能很多,如果只是解析轉換import,配置起來非常簡單。

        1. 先把依賴裝上吧,這沒什么好說的:

          // package.json
          {
            "devDependencies": {
              "webpack": "^5.4.0",
              "webpack-cli": "^4.2.0"
            },
          }
        2. 為了使用方便,再加個build腳本吧:

          // package.json
          {
            "scripts": {
              "build": "webpack"
            },
          }
        3. 最后再簡單寫下webpack的配置文件就好了:

          // webpack.config.js
          
          const path = require("path");
          
          module.exports = {
            mode: "development",
            devtool: 'source-map',
            entry: "./src/index.js",
            output: {
              filename: "main.js",
              path: path.resolve(__dirname, "dist"),
            },
          };

          這個配置文件里面其實只要指定了入口文件entry和編譯后的輸出文件目錄output就可以正常工作了,這里這個配置的意思是讓webpack./src/index.js開始編譯,編譯后的文件輸出到dist/main.js這個文件里面。

          這個配置文件上還有兩個配置modedevtool只是我用來方便調試編譯后的代碼的,mode指定用哪種模式編譯,默認是production,會對代碼進行壓縮和混淆,不好讀,所以我設置為development;而devtool是用來控制生成哪種粒度的source map,簡單來說,想要更好調試,就要更好的,更清晰的source map,但是編譯速度變慢;反之,想要編譯速度快,就要選擇粒度更粗,更不好讀的source map,webpack提供了很多可供選擇的source map,具體的可以看他的文檔。

        4. 然后就可以在dist下面建個index.html來引用編譯后的代碼了:

          // index.html
          
          <!DOCTYPE html>
          <html>
            <head>
              <meta charset="utf-8" />
            </head>
            <body>
              <script data-original="main.js"></script>
            </body>
          </html>
        5. 運行下yarn build就會編譯我們的代碼,然后打開index.html就可以看到效果了。

          image-20210203154111168

        深入原理

        前面講的這個例子很簡單,一般也滿足不了我們實際工程中的需求,但是對于我們理解原理卻是一個很好的突破口,畢竟webpack這么龐大的一個體系,我們也不能一口吃個胖子,得一點一點來。

        webpack把代碼編譯成了啥?

        為了弄懂他的原理,我們可以直接從編譯后的代碼入手,先看看他長啥樣子,有的朋友可能一提到去看源碼,心理就沒底,其實我以前也是這樣的。但是完全沒有必要懼怕,他編譯后的代碼瀏覽器能夠執行,那肯定就是普通的JS代碼,不會藏著這么黑科技。

        下面是編譯完的代碼截圖:

        image-20210203155553091

        雖然我們只有三個簡單的JS文件,但是加上webpack自己的邏輯,編譯后的文件還是有一百多行代碼,所以即使我把具體邏輯折疊起來了,這個截圖還是有點長,為了能夠看清楚他的結構,我將它分成了4個部分,標記在了截圖上,下面我們分別來看看這幾個部分吧。

        1. 第一部分其實就是一個對象__webpack_modules__,這個對象里面有三個屬性,屬性名字是我們三個模塊的文件路徑,屬性的值是一個函數,我們隨便展開一個./src/helloWorld.js看下:

          image-20210203161613636

          我們發現這個代碼內容跟我們自己寫的helloWorld.js非常像:

          image-20210203161902647

          他只是在我們的代碼前先調用了__webpack_require__.r__webpack_require__.d,這兩個輔助函數我們在后面會看到。

          然后對我們的代碼進行了一點修改,將我們的import關鍵字改成了__webpack_require__函數,并用一個變量_hello__WEBPACK_IMPORTED_MODULE_0__來接收了import進來的內容,后面引用的地方也改成了這個,其他跟這個無關的代碼,比如const world = 'world';還是保持原樣的。

          這個__webpack_modules__對象存了所有的模塊代碼,其實對于模塊代碼的保存,在不同版本的webpack里面實現的方式并不一樣,我這個版本是5.4.0,在4.x的版本里面好像是作為數組存下來,然后在最外層的立即執行函數里面以參數的形式傳進來的。但是不管是哪種方式,都只是轉換然后保存一下模塊代碼而已。

        2. 第二塊代碼的核心是__webpack_require__,這個代碼展開,瞬間給了我一種熟悉感:

          image-20210203162542359

          來看一下這個流程吧:

          1. 先定義一個變量__webpack_module_cache__作為加載了的模塊的緩存
          2. __webpack_require__其實就是用來加載模塊的
          3. 加載模塊時,先檢查緩存中有沒有,如果有,就直接返回緩存
          4. 如果緩存沒有,就從__webpack_modules__將對應的模塊取出來執行
          5. __webpack_modules__就是上面第一塊代碼里的那個對象,取出的模塊其實就是我們自己寫的代碼,取出執行的也是我們每個模塊的代碼
          6. 每個模塊執行除了執行我們的邏輯外,還會將export的內容添加到module.exports上,這就是前面說的__webpack_require__.d輔助方法的作用。添加到module.exports上其實就是添加到了__webpack_module_cache__緩存上,后面再引用這個模塊就直接從緩存拿了。

          這個流程我太熟悉了,因為他簡直跟Node.jsCommonJS實現思路一模一樣,具體的可以看我之前寫的這篇文章:深入Node.js的模塊加載機制,手寫require函數。

        3. 第三塊代碼其實就是我們前面看到過的幾個輔助函數的定義,具體干啥的,其實他的注釋已經寫了:

          1. __webpack_require__.d:核心其實是Object.defineProperty,主要是用來將我們模塊導出的內容添加到全局的__webpack_module_cache__緩存上。

            image-20210203164427116

          2. __webpack_require__.o:其實就是Object.prototype.hasOwnProperty的一個簡寫而已。

            image-20210203164450385

          3. __webpack_require__.r:這個方法就是給每個模塊添加一個屬性__esModule,來表明他是一個ES6的模塊。

            image-20210203164658054

          4. 第四塊就一行代碼,調用__webpack_require__加載入口模塊,啟動執行。

        這樣我們將代碼分成了4塊,每塊的作用都搞清楚,其實webpack干的事情就清晰了:

        1. import這種瀏覽器不認識的關鍵字替換成了__webpack_require__函數調用。
        2. __webpack_require__在實現時采用了類似CommonJS的模塊思想。
        3. 一個文件就是一個模塊,對應模塊緩存上的一個對象。
        4. 當模塊代碼執行時,會將export的內容添加到這個模塊對象上。
        5. 當再次引用一個以前引用過的模塊時,會直接從緩存上讀取模塊。

        自己實現一個webpack

        現在webpack到底干了什么事情我們已經清楚了,接下來我們就可以自己動手實現一個了。根據前面最終生成的代碼結果,我們要實現的代碼其實主要分兩塊:

        1. 遍歷所有模塊,將每個模塊代碼讀取出來,替換掉importexport關鍵字,放到__webpack_modules__對象上。
        2. 整個代碼里面除了__webpack_modules__和最后啟動的入口是變化的,其他代碼,像__webpack_require__,__webpack_require__.r這些方法其實都是固定的,整個代碼結構也是固定的,所以完全可以先定義好一個模板。

        使用AST解析代碼

        由于我們需要將import這種代碼轉換成瀏覽器能識別的普通JS代碼,所以我們首先要能夠將代碼解析出來。在解析代碼的時候,可以將它讀出來當成字符串替換,也可以使用更專業的AST來解析。AST全稱叫Abstract Syntax Trees,也就是抽象語法樹,是一個將代碼用樹來表示的數據結構,一個代碼可以轉換成AST,AST又可以轉換成代碼,而我們熟知的babel其實就可以做這個工作。要生成AST很復雜,涉及到編譯原理,但是如果僅僅拿來用就比較簡單了,本文就先不涉及復雜的編譯原理,而是直接將babel生成好的AST拿來使用。

        注意:webpack源碼解析AST并不是使用的babel,而是使用的acorn。webpack自己實現了一個JavascriptParser類,這個類里面用到了acorn。本文寫作時采用了babel,這也是一個大家更熟悉的工具。

        比如我先將入口文件讀出來,然后用babel轉換成AST可以直接這樣寫:

        const fs = require("fs");
        const parser = require("@babel/parser");
        
        const config = require("../webpack.config"); // 引入配置文件
        
        // 讀取入口文件
        const fileContent = fs.readFileSync(config.entry, "utf-8");
        
        // 使用babel parser解析AST
        const ast = parser.parse(fileContent, { sourceType: "module" });
        
        console.log(ast);   // 把ast打印出來看看

        上面代碼可以將生成好的ast打印在控制臺:

        image-20210207153459699

        這雖然是一個完整的AST,但是看起來并不清晰,關鍵數據其實是body字段,這里的body也只是展示了類型名字。所以照著這個寫代碼其實不好寫,這里推薦一個在線工具https://astexplorer.net/,可以很清楚的看到每個節點的內容:

        image-20210207154116026

        從這個解析出來的AST我們可以看到,body主要有4塊代碼:

        1. ImportDeclaration:就是第一行的import定義
        2. VariableDeclaration:第三行的一個變量申明
        3. FunctionDeclaration:第五行的一個函數定義
        4. ExpressionStatement:第十三行的一個普通語句

        你如果把每個節點展開,會發現他們下面又嵌套了很多其他節點,比如第三行的VariableDeclaration展開后,其實還有個函數調用helloWorld()

        image-20210207154741847

        使用traverse遍歷AST

        對于這樣一個生成好的AST,我們可以使用@babel/traverse來對他進行遍歷和操作,比如我想拿到ImportDeclaration進行操作,就直接這樣寫:

        // 使用babel traverse來遍歷ast上的節點
        traverse(ast, {
          ImportDeclaration(path) {
            console.log(path.node);
          },
        });

        上面代碼可以拿到所有的import語句:

        image-20210207162114290

        import轉換為函數調用

        前面我們說了,我們的目標是將ES6的import

        import helloWorld from "./helloWorld";

        轉換成普通瀏覽器能識別的函數調用:

        var _helloWorld__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/helloWorld.js");

        為了實現這個功能,我們還需要引入@babel/types,這個庫可以幫我們創建新的AST節點,所以這個轉換代碼寫出來就是這樣:

        const t = require("@babel/types");
        
        // 使用babel traverse來遍歷ast上的節點
        traverse(ast, {
          ImportDeclaration(p) {
            // 獲取被import的文件
            const importFile = p.node.source.value;
        
            // 獲取文件路徑
            let importFilePath = path.join(path.dirname(config.entry), importFile);
            importFilePath = `./${importFilePath}.js`;
        
            // 構建一個變量定義的AST節點
            const variableDeclaration = t.variableDeclaration("var", [
              t.variableDeclarator(
                t.identifier(
                  `__${path.basename(importFile)}__WEBPACK_IMPORTED_MODULE_0__`
                ),
                t.callExpression(t.identifier("__webpack_require__"), [
                  t.stringLiteral(importFilePath),
                ])
              ),
            ]);
        
            // 將當前節點替換為變量定義節點
            p.replaceWith(variableDeclaration);
          },
        });

        上面這段代碼我們用了很多@babel/types下面的API,比如t.variableDeclaration,t.variableDeclarator,這些都是用來創建對應的節點的,具體的API可以看這里。注意這個代碼里面我有很多寫死的地方,比如importFilePath生成邏輯,還應該處理多種后綴名的,還有最終生成的變量名_${path.basename(importFile)}__WEBPACK_IMPORTED_MODULE_0__,最后的數字我也是直接寫了0,按理來說應該是根據不同的import順序來生成的,但是本文主要講webpack的原理,這些細節上我就沒花過多時間了。

        上面的代碼其實是修改了我們的AST,修改后的AST可以用@babel/generator又轉換為代碼:

        const generate  = require('@babel/generator').default;
        
        const newCode = generate(ast).code;
        console.log(newCode);

        這個打印結果是:

        image-20210207172310114

        可以看到這個結果里面import helloWorld from "./helloWorld";已經被轉換為var __helloWorld__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/helloWorld.js");。

        替換import進來的變量

        前面我們將import語句替換成了一個變量定義,變量名字也改為了__helloWorld__WEBPACK_IMPORTED_MODULE_0__,自然要將調用的地方也改了。為了更好的管理,我們將AST遍歷,操作以及最后的生成新代碼都封裝成一個函數吧。

        function parseFile(file) {
          // 讀取入口文件
          const fileContent = fs.readFileSync(file, "utf-8");
        
          // 使用babel parser解析AST
          const ast = parser.parse(fileContent, { sourceType: "module" });
        
          let importFilePath = "";
        
          // 使用babel traverse來遍歷ast上的節點
          traverse(ast, {
            ImportDeclaration(p) {
              // 跟之前一樣的
            },
          });
        
          const newCode = generate(ast).code;
        
          // 返回一個包含必要信息的新對象
          return {
            file,
            dependcies: [importFilePath],
            code: newCode,
          };
        }

        然后啟動執行的時候就可以調這個函數了

        parseFile(config.entry);

        拿到的結果跟之前的差不多:

        image-20210207173744463

        好了,現在需要將使用import的地方也替換了,因為我們已經知道了這個地方是將它作為函數調用的,也就是要將

        const helloWorldStr = helloWorld();

        轉為這個樣子:

        const helloWorldStr = (0,_helloWorld__WEBPACK_IMPORTED_MODULE_0__.default)();

        這行代碼的效果其實跟_helloWorld__WEBPACK_IMPORTED_MODULE_0__.default()是一樣的,為啥在前面包個(0, ),我也不知道,有知道的大佬告訴下我唄。

        所以我們在traverse里面加一個CallExpression

          traverse(ast, {
            ImportDeclaration(p) {
              // 跟前面的差不多,省略了
            },
            CallExpression(p) {
              // 如果調用的是import進來的函數
              if (p.node.callee.name === importVarName) {
                // 就將它替換為轉換后的函數名字
                p.node.callee.name = `${importCovertVarName}.default`;
              }
            },
          });

        這樣轉換后,我們再重新生成一下代碼,已經像那么個樣子了:

        image-20210207175649607

        遞歸解析多個文件

        現在我們有了一個parseFile方法來解析處理入口文件,但是我們的文件其實不止一個,我們應該依據模塊的依賴關系,遞歸的將所有的模塊都解析了。要實現遞歸解析也不復雜,因為前面的parseFile的依賴dependcies已經返回了:

        1. 我們創建一個數組存放文件的解析結果,初始狀態下他只有入口文件的解析結果
        2. 根據入口文件的解析結果,可以拿到入口文件的依賴
        3. 解析所有的依賴,將結果繼續加到解析結果數組里面
        4. 一直循環這個解析結果數組,將里面的依賴文件解析完
        5. 最后將解析結果數組返回就行

        寫成代碼就是這樣:

        function parseFiles(entryFile) {
          const entryRes = parseFile(entryFile); // 解析入口文件
          const results = [entryRes]; // 將解析結果放入一個數組
        
          // 循環結果數組,將它的依賴全部拿出來解析
          for (const res of results) {
            const dependencies = res.dependencies;
            dependencies.map((dependency) => {
              if (dependency) {
                const ast = parseFile(dependency);
                results.push(ast);
              }
            });
          }
        
          return results;
        }

        然后就可以調用這個方法解析所有文件了:

        const allAst = parseFiles(config.entry);
        console.log(allAst);

        看看解析結果吧:

        image-20210208152330212

        這個結果其實跟我們最終需要生成的__webpack_modules__已經很像了,但是還有兩塊沒有處理:

        1. 一個是import進來的內容作為變量使用,比如

          import hello from './hello';
          
          const world = 'world';
          
          const helloWorld = () => `${hello} ${world}`;
        2. 另一個就是export語句還沒處理

        替換import進來的變量(作為變量調用)

        前面我們已經用CallExpression處理過作為函數使用的import變量了,現在要處理作為變量使用的其實用Identifier處理下就行了,處理邏輯跟之前的CallExpression差不多:

          traverse(ast, {
            ImportDeclaration(p) {
              // 跟以前一樣的
            },
            CallExpression(p) {
                    // 跟以前一樣的
            },
            Identifier(p) {
              // 如果調用的是import進來的變量
              if (p.node.name === importVarName) {
                // 就將它替換為轉換后的變量名字
                p.node.name = `${importCovertVarName}.default`;
              }
            },
          });

        現在再運行下,import進來的變量名字已經變掉了:

        image-20210208153942630

        替換export語句

        從我們需要生成的結果來看,export需要進行兩個處理:

        1. 如果一個文件有export default,需要添加一個__webpack_require__.d的輔助方法調用,內容都是固定的,加上就行。
        2. export語句轉換為普通的變量定義。

        對應生成結果上的這兩個:

        image-20210208154959592

        要處理export語句,在遍歷ast的時候添加ExportDefaultDeclaration就行了:

          traverse(ast, {
            ImportDeclaration(p) {
              // 跟以前一樣的
            },
            CallExpression(p) {
                    // 跟以前一樣的
            },
            Identifier(p) {
              // 跟以前一樣的
            },
            ExportDefaultDeclaration(p) {
              hasExport = true; // 先標記是否有export
        
              // 跟前面import類似的,創建一個變量定義節點
              const variableDeclaration = t.variableDeclaration("const", [
                t.variableDeclarator(
                  t.identifier("__WEBPACK_DEFAULT_EXPORT__"),
                  t.identifier(p.node.declaration.name)
                ),
              ]);
        
              // 將當前節點替換為變量定義節點
              p.replaceWith(variableDeclaration);
            },
          });

        然后再運行下就可以看到export語句被替換了:

        image-20210208160244276

        然后就是根據hasExport變量判斷在AST轉換為代碼的時候要不要加__webpack_require__.d輔助函數:

        const EXPORT_DEFAULT_FUN = `
        __webpack_require__.d(__webpack_exports__, {
           "default": () => (__WEBPACK_DEFAULT_EXPORT__)
        });\n
        `;
        
        function parseFile(file) {
          // 省略其他代碼
          // ......
          
          let newCode = generate(ast).code;
        
          if (hasExport) {
            newCode = `${EXPORT_DEFAULT_FUN} ${newCode}`;
          }
        }

        最后生成的代碼里面export也就處理好了:

        image-20210208161030554

        __webpack_require__.r的調用添上吧

        前面說了,最終生成的代碼,每個模塊前面都有個__webpack_require__.r的調用

        image-20210208161321401

        這個只是拿來給模塊添加一個__esModule標記的,我們也給他加上吧,直接在前面export輔助方法后面加點代碼就行了:

        const ESMODULE_TAG_FUN = `
        __webpack_require__.r(__webpack_exports__);\n
        `;
        
        function parseFile(file) {
          // 省略其他代碼
          // ......
          
          let newCode = generate(ast).code;
        
          if (hasExport) {
            newCode = `${EXPORT_DEFAULT_FUN} ${newCode}`;
          }
          
          // 下面添加模塊標記代碼
          newCode = `${ESMODULE_TAG_FUN} ${newCode}`;
        }

        再運行下看看,這個代碼也加上了:

        image-20210208161721369

        創建代碼模板

        到現在,最難的一塊,模塊代碼的解析和轉換我們其實已經完成了。下面要做的工作就比較簡單了,因為最終生成的代碼里面,各種輔助方法都是固定的,動態的部分就是前面解析的模塊和入口文件。所以我們可以創建一個這樣的模板,將動態的部分標記出來就行,其他不變的部分寫死。這個模板文件的處理,你可以將它讀進來作為字符串處理,也可以用模板引擎,我這里采用ejs模板引擎:

        // 模板文件,直接從webpack生成結果抄過來,改改就行
        /******/ (() => { // webpackBootstrap
        /******/     "use strict";
        // 需要替換的__TO_REPLACE_WEBPACK_MODULES__
        /******/     var __webpack_modules__ = ({
                        <% __TO_REPLACE_WEBPACK_MODULES__.map(item => { %>
                            '<%- item.file %>' : 
                            ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
                                <%- item.code %>
                            }),
                        <% }) %>
                    });
        // 省略中間的輔助方法
            /************************************************************************/
            /******/     // startup
            /******/     // Load entry module
        // 需要替換的__TO_REPLACE_WEBPACK_ENTRY
            /******/     __webpack_require__('<%- __TO_REPLACE_WEBPACK_ENTRY__ %>');
            /******/     // This entry module used 'exports' so it can't be inlined
            /******/ })()
            ;
            //# sourceMappingURL=main.js.map

        生成最終的代碼

        生成最終代碼的思路就是:

        1. 模板里面用__TO_REPLACE_WEBPACK_MODULES__來生成最終的__webpack_modules__
        2. 模板里面用__TO_REPLACE_WEBPACK_ENTRY__來替代動態的入口文件
        3. webpack代碼里面使用前面生成好的AST數組來替換模板的__TO_REPLACE_WEBPACK_MODULES__
        4. webpack代碼里面使用前面拿到的入口文件來替代模板的__TO_REPLACE_WEBPACK_ENTRY__
        5. 使用ejs來生成最終的代碼

        所以代碼就是:

        // 使用ejs將上面解析好的ast傳遞給模板
        // 返回最終生成的代碼
        function generateCode(allAst, entry) {
          const temlateFile = fs.readFileSync(
            path.join(__dirname, "./template.js"),
            "utf-8"
          );
        
          const codes = ejs.render(temlateFile, {
            __TO_REPLACE_WEBPACK_MODULES__: allAst,
            __TO_REPLACE_WEBPACK_ENTRY__: entry,
          });
        
          return codes;
        }

        大功告成

        最后將ejs生成好的代碼寫入配置的輸出路徑就行了:

        const codes = generateCode(allAst, config.entry);
        
        fs.writeFileSync(path.join(config.output.path, config.output.filename), codes);

        然后就可以使用我們自己的webpack來編譯代碼,最后就可以像之前那樣打開我們的html看看效果了:

        image-20210218160539306

        總結

        本文使用簡單質樸的方式講述了webpack的基本原理,并自己手寫實現了一個基本的支持importexportdefaultwebpack。

        本文可運行代碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/mini-webpack

        下面再就本文的要點進行下總結:

        1. webpack最基本的功能其實是將JS的高級模塊化語句,importrequire之類的轉換為瀏覽器能認識的普通函數調用語句。
        2. 要進行語言代碼的轉換,我們需要對代碼進行解析。
        3. 常用的解析手段是AST,也就是將代碼轉換為抽象語法樹。
        4. AST是一個描述代碼結構的樹形數據結構,代碼可以轉換為AST,AST也可以轉換為代碼。
        5. babel可以將代碼轉換為AST,但是webpack官方并沒有使用babel,而是基于acorn自己實現了一個JavascriptParser。
        6. 本文從webpack構建的結果入手,也使用AST自己生成了一個類似的代碼。
        7. webpack最終生成的代碼其實分為動態和固定的兩部分,我們將固定的部分寫入一個模板,動態的部分在模板里面使用ejs占位。
        8. 生成代碼動態部分需要借助babel來生成AST,并對其進行修改,最后再使用babel將其生成新的代碼。
        9. 在生成AST時,我們從配置的入口文件開始,遞歸的解析所有文件。即解析入口文件的時候,將它的依賴記錄下來,入口文件解析完后就去解析他的依賴文件,在解析他的依賴文件時,將依賴的依賴也記錄下來,后面繼續解析。重復這種步驟,直到所有依賴解析完。
        10. 動態代碼生成好后,使用ejs將其寫入模板,以生成最終的代碼。
        11. 如果要支持require或者AMD,其實思路是類似的,最終生成的代碼也是差不多的,主要的差別在AST解析那一塊。

        參考資料

        1. babel操作AST文檔
        2. webpack源碼
        3. webpack官方文檔

        文章的最后,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支持是作者持續創作的動力。

        歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~

        “前端進階知識”系列文章源碼地址: https://github.com/dennis-jiang/Front-End-Knowledges

        1270_300二維碼_2.png

        查看原文

        贊 55 收藏 45 評論 10

        譚光志 發布了文章 · 2月18日

        入門 Serverless——簡介與實踐

        Serverless,即無服務架構。是指由第三方云計算供應商以服務的方式為開發者提供所需功能,例如數據庫、消息,以及身份驗證等。它的核心思想是讓開發者專注構建和運行應用,而無需管理服務器。

        Serverless 技術的應用一般有兩種:Faas(Function as a Service) 函數即服務和 Baas(Backend as a Service) 后端即服務。

        優點

        Serverless 最大的優點就是自動擴展伸縮、無需自己管理。

        在以往部署一個應用時,需要經歷購買服務器、安裝操作系統、購買域名等等一系列步驟,應用才能真正的上線。后來有了云服務器,我們就省去了購買服務器、安裝操作系統這些操作步驟。只需要在云服務器上搭建環境、安裝數據庫就可以部署應用了。

        但是這仍然有個問題,當網站訪問量過大時,你需要增加服務器;訪問量過小時,需要減少服務器。如果使用 Serverless,你就不需要考慮這些,云服務商會幫你管理這一切。云服務商會根據你的訪問量自動調整所需的資源。

        缺點

        當應用部署在云上,并且使用云存儲或云數據庫,那可能會讓我們的應用訪問速度變得比較慢。因為網絡的訪問速度比內存和硬盤差了一到兩個數量級。

        Faas

        什么是函數即服務?

        一個函數通常用于處理某種業務邏輯,例如一個 abs() 函數,它將返回所傳參數的絕對值。我們可以把這個函數托管到 Faas 平臺,由平臺提供容器并運行這個函數。當執行函數時,只需要提供函數所需的參數,就可以在不部署應用的情況下得到函數的執行結果。

        無狀態

        Faas 運行函數的容器是無狀態的,上一次的運行效果和下一次的運行效果是無關的。如果需要存儲狀態,則需要使用云儲存或者云數據庫。

        冷啟動

        Faas 函數如果長時間未使用,容器就會對其進行回收。所以函數在首次調用或長時間未使用時,容器就需要重新創建該函數的實例,這個過程稱為冷啟動,一般耗時為數百毫秒。

        既然有冷啟動,就有熱啟動。例如容器剛剛調用完函數,過一會又有新的事件觸發。這時由于函數仍未被回收,所以可以直接復用原有的函數實例,這被稱為熱啟動。

        事件驅動

        Faas 函數需要通過觸發事件來運行。我們可以指定不同的觸發器:

        • HTTP 觸發器
        • 對象存儲
        • 定時觸發
        • CDN 觸發

        ...

        其中 HTTP 觸發器是最常見的,即通過 HTTP 請求觸發。

        低成本、按需收費

        像以往我們購買的云服務器一般是采取包月、包年的計費方式,即使你買了不用也要收取費用。Faas 采取的是按需付費的方式,云服務商會根據你的實際使用量來收取費用,不使用不收費(一般來說,Baas 可按需付費,也可包年包月)。

        需要配合 Baas 使用

        Faas 如果單獨使用的話,那它只適合部署一些工具類函數。因為它是無狀態的,每次運行都可能是在不同的容器上,它不知道上一個函數的運行結果。所以如果要使用 Serverless 來部署整個應用,還得額外購買 OSS 云存儲或者云數據庫來提供數據存儲服務(也就是需要配合 Baas 來使用)。

        Baas

        什么是后端即服務?

        假設你是一個前端,現在要開發一個網站。前端部分你可以自己完成,但后端部分怎么辦呢?這個時候就可以使用 Baas 了。也就是說,你只需編寫和維護前端頁面。其他的一切,例如數據庫、身份驗證、對象存儲等等都由云服務商提供。你只需要在前端通過 API 調用它們就可以使用所需的服務。

        Faas 和 Baas 的區別

        Faas 其實是一個云計算平臺,用戶可以將自己寫的函數托管到平臺上運行。而 Baas 則是提供一系列的服務給用戶運用,用戶通過 API 調用。

        其他不同點:

        • Faas 無狀態,Baas 有狀態。
        • Faas 運行的是函數,由開發者自己編寫;Baas 提供的是服務,不需要開發者自己開發。

        可以說 Faas 和 Baas 是兩個不同的東西,但它們有一個共同點,就是無需自己管理服務器和資源的分配、整理,所以都屬于 Serverless。

        阿里云 Faas 實踐

        現在的阿里云、騰訊云都可以免費體驗 Faas,下面以阿里云為例,演示一下如何使用 Faas。

        打開阿里云 serverless,點擊立即開通:

        開通后(阿里云每個月提供一定額度的免費流量,可以利用這一點來學習如何使用 Serverless)如果沒打開函數計算頁面,請點擊控制臺搜索函數計算:

        然后會提示是否授權,授權成功后,就可以查看剛才創建的函數。

        箭頭所指處是一個 API 地址,調用它可以觸發你設置的函數。

        使用 Serverless 框架

        從剛才的示例可以發現,要想編寫 Faas 函數只能在線編寫,或者提前寫好復制到阿里云。為了改善這種情況,現在有很多 Serverless 框架,可以讓你在本地進行開發,開發完后再部署到阿里云或其他云上。

        在這里推薦一下 midway 框架,主要是國人開發,具有非常詳細的中文文檔。根據文檔的快速指引,就可以成功將函數部署到阿里云或其他云上。

        小結

        Serverless 最大的優點就是彈性擴容和無需親自管理服務器。即使它也有不少缺點,但相對于優點來說,是可以忽略的,近幾年來 Serverless 技術的火熱程度也證實了這一點。目前 Serverless 技術仍有很大的發展空間值得我們去探索,畢竟還是一門“出生”不久的新技術。

        參考資料

        帶你入門前端工程 全文目錄:

        1. 技術選型:如何進行技術選型?
        2. 統一規范:如何制訂規范并利用工具保證規范被嚴格執行?
        3. 前端組件化:什么是模塊化、組件化?
        4. 測試:如何寫單元測試和 E2E(端到端) 測試?
        5. 構建工具:構建工具有哪些?都有哪些功能和優勢?
        6. 自動化部署:如何利用 Jenkins、Github Actions 自動化部署項目?
        7. 前端監控:講解前端監控原理及如何利用 sentry 對項目實行監控。
        8. 性能優化(一):如何檢測網站性能?有哪些實用的性能優化規則?
        9. 性能優化(二):如何檢測網站性能?有哪些實用的性能優化規則?
        10. 重構:為什么做重構?重構有哪些手法?
        11. 微服務:微服務是什么?如何搭建微服務項目?
        12. Severless:Severless 是什么?如何使用 Severless?
        查看原文

        贊 2 收藏 2 評論 0

        譚光志 發布了文章 · 2月15日

        可視化拖拽組件庫一些技術要點原理分析(三)

        本文是可視化拖拽系列的第三篇,之前的兩篇文章一共對 17 個功能點的技術原理進行了分析:

        1. 編輯器
        2. 自定義組件
        3. 拖拽
        4. 刪除組件、調整圖層層級
        5. 放大縮小
        6. 撤消、重做
        7. 組件屬性設置
        8. 吸附
        9. 預覽、保存代碼
        10. 綁定事件
        11. 綁定動畫
        12. 導入 PSD
        13. 手機模式
        14. 拖拽旋轉
        15. 復制粘貼剪切
        16. 數據交互
        17. 發布

        本文在此基礎上,將對以下幾個功能點的技術原理進行分析:

        1. 多個組件的組合和拆分
        2. 文本組件
        3. 矩形組件
        4. 鎖定組件
        5. 快捷鍵
        6. 網格線
        7. 編輯器快照的另一種實現方式

        如果你對我之前的兩篇文章不是很了解,建議先把這兩篇文章看一遍,再來閱讀此文:

        雖然我這個可視化拖拽組件庫只是一個 DEMO,但對比了一下市面上的一些現成產品(例如 processon、墨刀),就基礎功能來說,我這個 DEMO 實現了絕大部分的功能。

        如果你對于低代碼平臺有興趣,但又不了解的話。強烈建議將我的三篇文章結合項目源碼一起閱讀,相信對你的收獲絕對不小。另附上項目、在線 DEMO 地址:

        18. 多個組件的組合和拆分

        組合和拆分的技術點相對來說比較多,共有以下 4 個:

        • 選中區域
        • 組合后的移動、旋轉
        • 組合后的放大縮小
        • 拆分后子組件樣式的恢復

        選中區域

        在將多個組件組合之前,需要先選中它們。利用鼠標事件可以很方便的將選中區域展示出來:

        1. mousedown 記錄起點坐標
        2. mousemove 將當前坐標和起點坐標進行計算得出移動區域
        3. 如果按下鼠標后往左上方移動,類似于這種操作則需要將當前坐標設為起點坐標,再計算出移動區域
        // 獲取編輯器的位移信息
        const rectInfo = this.editor.getBoundingClientRect()
        this.editorX = rectInfo.x
        this.editorY = rectInfo.y
        
        const startX = e.clientX
        const startY = e.clientY
        this.start.x = startX - this.editorX
        this.start.y = startY - this.editorY
        // 展示選中區域
        this.isShowArea = true
        
        const move = (moveEvent) => {
            this.width = Math.abs(moveEvent.clientX - startX)
            this.height = Math.abs(moveEvent.clientY - startY)
            if (moveEvent.clientX < startX) {
                this.start.x = moveEvent.clientX - this.editorX
            }
        
            if (moveEvent.clientY < startY) {
                this.start.y = moveEvent.clientY - this.editorY
            }
        }

        mouseup 事件觸發時,需要對選中區域內的所有組件的位移大小信息進行計算,得出一個能包含區域內所有組件的最小區域。這個效果如下圖所示:

        這個計算過程的代碼:

        createGroup() {
          // 獲取選中區域的組件數據
          const areaData = this.getSelectArea()
          if (areaData.length <= 1) {
              this.hideArea()
              return
          }
        
          // 根據選中區域和區域中每個組件的位移信息來創建 Group 組件
          // 要遍歷選擇區域的每個組件,獲取它們的 left top right bottom 信息來進行比較
          let top = Infinity, left = Infinity
          let right = -Infinity, bottom = -Infinity
          areaData.forEach(component => {
              let style = {}
              if (component.component == 'Group') {
                  component.propValue.forEach(item => {
                      const rectInfo = $(`#component${item.id}`).getBoundingClientRect()
                      style.left = rectInfo.left - this.editorX
                      style.top = rectInfo.top - this.editorY
                      style.right = rectInfo.right - this.editorX
                      style.bottom = rectInfo.bottom - this.editorY
        
                      if (style.left < left) left = style.left
                      if (style.top < top) top = style.top
                      if (style.right > right) right = style.right
                      if (style.bottom > bottom) bottom = style.bottom
                  })
              } else {
                  style = getComponentRotatedStyle(component.style)
              }
        
              if (style.left < left) left = style.left
              if (style.top < top) top = style.top
              if (style.right > right) right = style.right
              if (style.bottom > bottom) bottom = style.bottom
          })
        
          this.start.x = left
          this.start.y = top
          this.width = right - left
          this.height = bottom - top
            
          // 設置選中區域位移大小信息和區域內的組件數據
          this.$store.commit('setAreaData', {
              style: {
                  left,
                  top,
                  width: this.width,
                  height: this.height,
              },
              components: areaData,
          })
        },
                
        getSelectArea() {
            const result = []
            // 區域起點坐標
            const { x, y } = this.start
            // 計算所有的組件數據,判斷是否在選中區域內
            this.componentData.forEach(component => {
                if (component.isLock) return
                const { left, top, width, height } = component.style
                if (x <= left && y <= top && (left + width <= x + this.width) && (top + height <= y + this.height)) {
                    result.push(component)
                }
            })
            
            // 返回在選中區域內的所有組件
            return result
        }

        簡單描述一下這段代碼的處理邏輯:

        1. 利用 getBoundingClientRect() 瀏覽器 API 獲取每個組件相對于瀏覽器視口四個方向上的信息,也就是 lefttoprightbottom。
        2. 對比每個組件的這四個信息,取得選中區域的最左、最上、最右、最下四個方向的數值,從而得出一個能包含區域內所有組件的最小區域。
        3. 如果選中區域內已經有一個 Group 組合組件,則需要對它里面的子組件進行計算,而不是對組合組件進行計算。

        組合后的移動、旋轉

        為了方便將多個組件一起進行移動、旋轉、放大縮小等操作,我新創建了一個 Group 組合組件:

        <template>
            <div class="group">
                <div>
                     <template v-for="item in propValue">
                        <component
                            class="component"
                            :is="item.component"
                            :style="item.groupStyle"
                            :propValue="item.propValue"
                            :key="item.id"
                            :id="'component' + item.id"
                            :element="item"
                        />
                    </template>
                </div>
            </div>
        </template>
        
        <script>
        import { getStyle } from '@/utils/style'
        
        export default {
            props: {
                propValue: {
                    type: Array,
                    default: () => [],
                },
                element: {
                    type: Object,
                },
            },
            created() {
                const parentStyle = this.element.style
                this.propValue.forEach(component => {
                    // component.groupStyle 的 top left 是相對于 group 組件的位置
                    // 如果已存在 component.groupStyle,說明已經計算過一次了。不需要再次計算
                    if (!Object.keys(component.groupStyle).length) {
                        const style = { ...component.style }
                        component.groupStyle = getStyle(style)
                        component.groupStyle.left = this.toPercent((style.left - parentStyle.left) / parentStyle.width)
                        component.groupStyle.top = this.toPercent((style.top - parentStyle.top) / parentStyle.height)
                        component.groupStyle.width = this.toPercent(style.width / parentStyle.width)
                        component.groupStyle.height = this.toPercent(style.height / parentStyle.height)
                    }
                })
            },
            methods: {
                toPercent(val) {
                    return val * 100 + '%'
                },
            },
        }
        </script>
        
        <style lang="scss" scoped>
        .group {
            & > div {
                position: relative;
                width: 100%;
                height: 100%;
        
                .component {
                    position: absolute;
                }
            }
        }
        </style>

        Group 組件的作用就是將區域內的組件放到它下面,成為子組件。并且在創建 Group 組件時,獲取每個子組件在 Group 組件內的相對位移和相對大?。?/p>

        created() {
            const parentStyle = this.element.style
            this.propValue.forEach(component => {
                // component.groupStyle 的 top left 是相對于 group 組件的位置
                // 如果已存在 component.groupStyle,說明已經計算過一次了。不需要再次計算
                if (!Object.keys(component.groupStyle).length) {
                    const style = { ...component.style }
                    component.groupStyle = getStyle(style)
                    component.groupStyle.left = this.toPercent((style.left - parentStyle.left) / parentStyle.width)
                    component.groupStyle.top = this.toPercent((style.top - parentStyle.top) / parentStyle.height)
                    component.groupStyle.width = this.toPercent(style.width / parentStyle.width)
                    component.groupStyle.height = this.toPercent(style.height / parentStyle.height)
                }
            })
        },
        methods: {
                toPercent(val) {
                    return val * 100 + '%'
                },
            },

        也就是將子組件的 lefttopwidthheight 等屬性轉成以 % 結尾的相對數值。

        為什么不使用絕對數值?

        如果使用絕對數值,那么在移動 Group 組件時,除了對 Group 組件的屬性進行計算外,還需要對它的每個子組件進行計算。并且 Group 包含子組件太多的話,在進行移動、放大縮小時,計算量會非常大,有可能會造成頁面卡頓。如果改成相對數值,則只需要在 Group 創建時計算一次。然后在 Group 組件進行移動、旋轉時也不用管 Group 的子組件,只對它自己計算即可。

        組合后的放大縮小

        組合后的放大縮小是個大問題,主要是因為有旋轉角度的存在。首先來看一下各個子組件沒旋轉時的放大縮?。?/p>

        從動圖可以看出,效果非常完美。各個子組件的大小是跟隨 Group 組件的大小而改變的。

        現在試著給子組件加上旋轉角度,再看一下效果:

        為什么會出現這個問題?

        主要是因為一個組件無論旋不旋轉,它的 topleft 屬性都是不變的。這樣就會有一個問題,雖然實際上組件的 topleftwidthheight 屬性沒有變化。但在外觀上卻發生了變化。下面是兩個同樣的組件:一個沒旋轉,一個旋轉了 45 度。

        可以看出來旋轉后按鈕的 topleftwidthheight 屬性和我們從外觀上看到的是不一樣的。

        接下來再看一個具體的示例:

        上面是一個 Group 組件,它左邊的子組件屬性為:

        transform: rotate(-75.1967deg);
        width: 51.2267%;
        height: 32.2679%;
        top: 33.8661%;
        left: -10.6496%;

        可以看到 width 的值為 51.2267%,但從外觀上來看,這個子組件最多占 Group 組件寬度的三分之一。所以這就是放大縮小不正常的問題所在。

        一個不可行的解決方案(不想看的可以跳過)

        一開始我想的是,先算出它相對瀏覽器視口的 topleftwidthheight 屬性,再算出這幾個屬性在 Group 組件上的相對數值。這可以通過 getBoundingClientRect() API 實現。只要維持外觀上的各個屬性占比不變,這樣 Group 組件在放大縮小時,再通過旋轉角度,利用旋轉矩陣的知識(這一點在第二篇有詳細描述)獲取它未旋轉前的 topleftwidthheight 屬性。這樣就可以做到子組件動態調整了。

        但是這有個問題,通過 getBoundingClientRect() API 只能獲取組件外觀上的 topleftrightbottomwidthheight 屬性。再加上一個角度,參數還是不夠,所以無法計算出組件實際的 topleftwidthheight 屬性。

        就像上面的這張圖,只知道原點 O(x,y)wh 和旋轉角度,無法算出按鈕的寬高。

        一個可行的解決方案

        這是無意中發現的,我在對 Group 組件進行放大縮小時,發現只要保持 Group 組件的寬高比例,子組件就能做到根據比例放大縮小。那么現在問題就轉變成了如何讓 Group 組件放大縮小時保持寬高比例。我在網上找到了這一篇文章,它詳細描述了一個旋轉組件如何保持寬高比來進行放大縮小,并配有源碼示例。

        現在我嘗試簡單描述一下如何保持寬高比對一個旋轉組件進行放大縮?。ńㄗh還是看看原文)。下面是一個已旋轉一定角度的矩形,假設現在拖動它左上方的點進行拉伸。

        第一步,算出組件寬高比,以及按下鼠標時通過組件的坐標(無論旋轉多少度,組件的 topleft 屬性不變)和大小算出組件中心點:

        // 組件寬高比
        const proportion = style.width / style.height
                    
        const center = {
            x: style.left + style.width / 2,
            y: style.top + style.height / 2,
        }

        第二步,用當前點擊坐標和組件中心點算出當前點擊坐標的對稱點坐標:

        // 獲取畫布位移信息
        const editorRectInfo = document.querySelector('#editor').getBoundingClientRect()
        
        // 當前點擊坐標
        const curPoint = {
            x: e.clientX - editorRectInfo.left,
            y: e.clientY - editorRectInfo.top,
        }
        
        // 獲取對稱點的坐標
        const symmetricPoint = {
            x: center.x - (curPoint.x - center.x),
            y: center.y - (curPoint.y - center.y),
        }

        第三步,摁住組件左上角進行拉伸時,通過當前鼠標實時坐標和對稱點計算出新的組件中心點:

        const curPositon = {
            x: moveEvent.clientX - editorRectInfo.left,
            y: moveEvent.clientY - editorRectInfo.top,
        }
        
        const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
        
        // 求兩點之間的中點坐標
        function getCenterPoint(p1, p2) {
            return {
                x: p1.x + ((p2.x - p1.x) / 2),
                y: p1.y + ((p2.y - p1.y) / 2),
            }
        }

        由于組件處于旋轉狀態,即使你知道了拉伸時移動的 xy 距離,也不能直接對組件進行計算。否則就會出現 BUG,移位或者放大縮小方向不正確。因此,我們需要在組件未旋轉的情況下對其進行計算。

        第四步,根據已知的旋轉角度、新的組件中心點、當前鼠標實時坐標可以算出當前鼠標實時坐標currentPosition 在未旋轉時的坐標 newTopLeftPoint。同時也能根據已知的旋轉角度、新的組件中心點、對稱點算出組件對稱點sPoint 在未旋轉時的坐標 newBottomRightPoint。

        對應的計算公式如下:

        /**
         * 計算根據圓心旋轉后的點的坐標
         * @param   {Object}  point  旋轉前的點坐標
         * @param   {Object}  center 旋轉中心
         * @param   {Number}  rotate 旋轉的角度
         * @return  {Object}         旋轉后的坐標
         * https://www.zhihu.com/question/67425734/answer/252724399 旋轉矩陣公式
         */
        export function calculateRotatedPointCoordinate(point, center, rotate) {
            /**
             * 旋轉公式:
             *  點a(x, y)
             *  旋轉中心c(x, y)
             *  旋轉后點n(x, y)
             *  旋轉角度θ                tan ??
             * nx = cosθ * (ax - cx) - sinθ * (ay - cy) + cx
             * ny = sinθ * (ax - cx) + cosθ * (ay - cy) + cy
             */
        
            return {
                x: (point.x - center.x) * Math.cos(angleToRadian(rotate)) - (point.y - center.y) * Math.sin(angleToRadian(rotate)) + center.x,
                y: (point.x - center.x) * Math.sin(angleToRadian(rotate)) + (point.y - center.y) * Math.cos(angleToRadian(rotate)) + center.y,
            }
        }

        上面的公式涉及到線性代數中旋轉矩陣的知識,對于一個沒上過大學的人來說,實在太難了。還好我從知乎上的一個回答中找到了這一公式的推理過程,下面是回答的原文:

        通過以上幾個計算值,就可以得到組件新的位移值 topleft 以及新的組件大小。對應的完整代碼如下:

        function calculateLeftTop(style, curPositon, pointInfo) {
            const { symmetricPoint } = pointInfo
            const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
            const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)
            const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
          
            const newWidth = newBottomRightPoint.x - newTopLeftPoint.x
            const newHeight = newBottomRightPoint.y - newTopLeftPoint.y
            if (newWidth > 0 && newHeight > 0) {
                style.width = Math.round(newWidth)
                style.height = Math.round(newHeight)
                style.left = Math.round(newTopLeftPoint.x)
                style.top = Math.round(newTopLeftPoint.y)
            }
        }

        現在再來看一下旋轉后的放大縮?。?/p>

        第五步,由于我們現在需要的是鎖定寬高比來進行放大縮小,所以需要重新計算拉伸后的圖形的左上角坐標。

        這里先確定好幾個形狀的命名:

        • 原圖形:  紅色部分
        • 新圖形:  藍色部分
        • 修正圖形: 綠色部分,即加上寬高比鎖定規則的修正圖形

        在第四步中算出組件未旋轉前的 newTopLeftPointnewBottomRightPointnewWidthnewHeight 后,需要根據寬高比 proportion 來算出新的寬度或高度。

        上圖就是一個需要改變高度的示例,計算過程如下:

        if (newWidth / newHeight > proportion) {
            newTopLeftPoint.x += Math.abs(newWidth - newHeight * proportion)
            newWidth = newHeight * proportion
        } else {
            newTopLeftPoint.y += Math.abs(newHeight - newWidth / proportion)
            newHeight = newWidth / proportion
        }

        由于現在求的未旋轉前的坐標是以沒按比例縮減寬高前的坐標來計算的,所以縮減寬高后,需要按照原來的中心點旋轉回去,獲得縮減寬高并旋轉后對應的坐標。然后以這個坐標和對稱點獲得新的中心點,并重新計算未旋轉前的坐標。

        經過修改后的完整代碼如下:

        function calculateLeftTop(style, curPositon, proportion, needLockProportion, pointInfo) {
            const { symmetricPoint } = pointInfo
            let newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
            let newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)
            let newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
          
            let newWidth = newBottomRightPoint.x - newTopLeftPoint.x
            let newHeight = newBottomRightPoint.y - newTopLeftPoint.y
        
            if (needLockProportion) {
                if (newWidth / newHeight > proportion) {
                    newTopLeftPoint.x += Math.abs(newWidth - newHeight * proportion)
                    newWidth = newHeight * proportion
                } else {
                    newTopLeftPoint.y += Math.abs(newHeight - newWidth / proportion)
                    newHeight = newWidth / proportion
                }
        
                // 由于現在求的未旋轉前的坐標是以沒按比例縮減寬高前的坐標來計算的
                // 所以縮減寬高后,需要按照原來的中心點旋轉回去,獲得縮減寬高并旋轉后對應的坐標
                // 然后以這個坐標和對稱點獲得新的中心點,并重新計算未旋轉前的坐標
                const rotatedTopLeftPoint = calculateRotatedPointCoordinate(newTopLeftPoint, newCenterPoint, style.rotate)
                newCenterPoint = getCenterPoint(rotatedTopLeftPoint, symmetricPoint)
                newTopLeftPoint = calculateRotatedPointCoordinate(rotatedTopLeftPoint, newCenterPoint, -style.rotate)
                newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
            
                newWidth = newBottomRightPoint.x - newTopLeftPoint.x
                newHeight = newBottomRightPoint.y - newTopLeftPoint.y
            }
        
            if (newWidth > 0 && newHeight > 0) {
                style.width = Math.round(newWidth)
                style.height = Math.round(newHeight)
                style.left = Math.round(newTopLeftPoint.x)
                style.top = Math.round(newTopLeftPoint.y)
            }
        }

        保持寬高比進行放大縮小的效果如下:

        Group 組件有旋轉的子組件時,才需要保持寬高比進行放大縮小。所以在創建 Group 組件時可以判斷一下子組件是否有旋轉角度。如果沒有,就不需要保持寬度比進行放大縮小。

        isNeedLockProportion() {
            if (this.element.component != 'Group') return false
            const ratates = [0, 90, 180, 360]
            for (const component of this.element.propValue) {
                if (!ratates.includes(mod360(parseInt(component.style.rotate)))) {
                    return true
                }
            }
        
            return false
        }

        拆分后子組件樣式的恢復

        將多個組件組合在一起只是第一步,第二步是將 Group 組件進行拆分并恢復各個子組件的樣式。保證拆分后的子組件在外觀上的屬性不變。

        計算代碼如下:

        // store
        decompose({ curComponent, editor }) {
            const parentStyle = { ...curComponent.style }
            const components = curComponent.propValue
            const editorRect = editor.getBoundingClientRect()
        
            store.commit('deleteComponent')
            components.forEach(component => {
                decomposeComponent(component, editorRect, parentStyle)
                store.commit('addComponent', { component })
            })
        }
                
        // 將組合中的各個子組件拆分出來,并計算它們新的 style
        export default function decomposeComponent(component, editorRect, parentStyle) {
            // 子組件相對于瀏覽器視口的樣式
            const componentRect = $(`#component${component.id}`).getBoundingClientRect()
            // 獲取元素的中心點坐標
            const center = {
                x: componentRect.left - editorRect.left + componentRect.width / 2,
                y: componentRect.top - editorRect.top + componentRect.height / 2,
            }
        
            component.style.rotate = mod360(component.style.rotate + parentStyle.rotate)
            component.style.width = parseFloat(component.groupStyle.width) / 100 * parentStyle.width
            component.style.height = parseFloat(component.groupStyle.height) / 100 * parentStyle.height
            // 計算出元素新的 top left 坐標
            component.style.left = center.x - component.style.width / 2
            component.style.top = center.y - component.style.height / 2
            component.groupStyle = {}
        }

        這段代碼的處理邏輯為:

        1. 遍歷 Group 的子組件并恢復它們的樣式
        2. 利用 getBoundingClientRect() API 獲取子組件相對于瀏覽器視口的 lefttopwidthheight 屬性。
        3. 利用這四個屬性計算出子組件的中心點坐標。
        4. 由于子組件的 widthheight 屬性是相對于 Group 組件的,所以將它們的百分比值和 Group 相乘得出具體數值。
        5. 再用中心點 center(x, y) 減去子組件寬高的一半得出它的 lefttop 屬性。

        至此,組合和拆分就講解完了。

        19. 文本組件

        文本組件 VText 之前就已經實現過了,但不完美。例如無法對文字進行選中?,F在我對它進行了重寫,讓它支持選中功能。

        <template>
            <div v-if="editMode == 'edit'" class="v-text" @keydown="handleKeydown" @keyup="handleKeyup">
                <!-- tabindex >= 0 使得雙擊時聚集該元素 -->
                <div :contenteditable="canEdit" :class="{ canEdit }" @dblclick="setEdit" :tabindex="element.id" @paste="clearStyle"
                    @mousedown="handleMousedown" @blur="handleBlur" ref="text" v-html="element.propValue" @input="handleInput"
                    :style="{ verticalAlign: element.style.verticalAlign }"
                ></div>
            </div>
            <div v-else class="v-text">
                <div v-html="element.propValue" :style="{ verticalAlign: element.style.verticalAlign }"></div>
            </div>
        </template>
        
        <script>
        import { mapState } from 'vuex'
        import { keycodes } from '@/utils/shortcutKey.js'
        
        export default {
            props: {
                propValue: {
                    type: String,
                    require: true,
                },
                element: {
                    type: Object,
                },
            },
            data() {
                return {
                    canEdit: false,
                    ctrlKey: 17,
                    isCtrlDown: false,
                }
            },
            computed: {
                ...mapState([
                    'editMode',
                ]),
            },
            methods: {
                handleInput(e) {
                    this.$emit('input', this.element, e.target.innerHTML)
                },
        
                handleKeydown(e) {
                    if (e.keyCode == this.ctrlKey) {
                        this.isCtrlDown = true
                    } else if (this.isCtrlDown && this.canEdit && keycodes.includes(e.keyCode)) {
                        e.stopPropagation()
                    } else if (e.keyCode == 46) { // deleteKey
                        e.stopPropagation()
                    }
                },
        
                handleKeyup(e) {
                    if (e.keyCode == this.ctrlKey) {
                        this.isCtrlDown = false
                    }
                },
        
                handleMousedown(e) {
                    if (this.canEdit) {
                        e.stopPropagation()
                    }
                },
        
                clearStyle(e) {
                    e.preventDefault()
                    const clp = e.clipboardData
                    const text = clp.getData('text/plain') || ''
                    if (text !== '') {
                        document.execCommand('insertText', false, text)
                    }
        
                    this.$emit('input', this.element, e.target.innerHTML)
                },
        
                handleBlur(e) {
                    this.element.propValue = e.target.innerHTML || '&nbsp;'
                    this.canEdit = false
                },
        
                setEdit() {
                    this.canEdit = true
                    // 全選
                    this.selectText(this.$refs.text)
                },
        
                selectText(element) {
                    const selection = window.getSelection()
                    const range = document.createRange()
                    range.selectNodeContents(element)
                    selection.removeAllRanges()
                    selection.addRange(range)
                },
            },
        }
        </script>
        
        <style lang="scss" scoped>
        .v-text {
            width: 100%;
            height: 100%;
            display: table;
        
            div {
                display: table-cell;
                width: 100%;
                height: 100%;
                outline: none;
            }
        
            .canEdit {
                cursor: text;
                height: 100%;
            }
        }
        </style>

        改造后的 VText 組件功能如下:

        1. 雙擊啟動編輯。
        2. 支持選中文本。
        3. 粘貼時過濾掉文本的樣式。
        4. 換行時自動擴充文本框的高度。

        20. 矩形組件

        矩形組件其實就是一個內嵌 VText 文本組件的一個 DIV。

        <template>
            <div class="rect-shape">
                <v-text :propValue="element.propValue" :element="element" />
            </div>
        </template>
        
        <script>
        export default {
            props: {
                element: {
                    type: Object,
                },
            },
        }
        </script>
        
        <style lang="scss" scoped>
        .rect-shape {
            width: 100%;
            height: 100%;
            overflow: auto;
        }
        </style>

        VText 文本組件有的功能它都有,并且可以任意放大縮小。

        21. 鎖定組件

        鎖定組件主要是看到 processon 和墨刀有這個功能,于是我順便實現了。鎖定組件的具體需求為:不能移動、放大縮小、旋轉、復制、粘貼等,只能進行解鎖操作。

        它的實現原理也不難:

        1. 在自定義組件上加一個 isLock 屬性,表示是否鎖定組件。
        2. 在點擊組件時,根據 isLock 是否為 true 來隱藏組件上的八個點和旋轉圖標。
        3. 為了突出一個組件被鎖定,給它加上透明度屬性和一個鎖的圖標。
        4. 如果組件被鎖定,置灰上面所說的需求對應的按鈕,不能被點擊。

        相關代碼如下:

        export const commonAttr = {
            animations: [],
            events: {},
            groupStyle: {}, // 當一個組件成為 Group 的子組件時使用
            isLock: false, // 是否鎖定組件
        }
        <el-button @click="decompose" 
        :disabled="!curComponent || curComponent.isLock || curComponent.component != 'Group'">拆分</el-button>
        
        <el-button @click="lock" :disabled="!curComponent || curComponent.isLock">鎖定</el-button>
        <el-button @click="unlock" :disabled="!curComponent || !curComponent.isLock">解鎖</el-button>
        <template>
            <div class="contextmenu" v-show="menuShow" :style="{ top: menuTop + 'px', left: menuLeft + 'px' }">
                <ul @mouseup="handleMouseUp">
                    <template v-if="curComponent">
                        <template v-if="!curComponent.isLock">
                            <li @click="copy">復制</li>
                            <li @click="paste">粘貼</li>
                            <li @click="cut">剪切</li>
                            <li @click="deleteComponent">刪除</li>
                            <li @click="lock">鎖定</li>
                            <li @click="topComponent">置頂</li>
                            <li @click="bottomComponent">置底</li>
                            <li @click="upComponent">上移</li>
                            <li @click="downComponent">下移</li>
                        </template>
                        <li v-else @click="unlock">解鎖</li>
                    </template>
                    <li v-else @click="paste">粘貼</li>
                </ul>
            </div>
        </template>

        22. 快捷鍵

        支持快捷鍵主要是為了提升開發效率,用鼠標點點點畢竟沒有按鍵盤快。目前快捷鍵支持的功能如下:

        const ctrlKey = 17, 
            vKey = 86, // 粘貼
            cKey = 67, // 復制
            xKey = 88, // 剪切
        
            yKey = 89, // 重做
            zKey = 90, // 撤銷
        
            gKey = 71, // 組合
            bKey = 66, // 拆分
        
            lKey = 76, // 鎖定
            uKey = 85, // 解鎖
        
            sKey = 83, // 保存
            pKey = 80, // 預覽
            dKey = 68, // 刪除
            deleteKey = 46, // 刪除
            eKey = 69 // 清空畫布

        實現原理主要是利用 window 全局監聽按鍵事件,在符合條件的按鍵觸發時執行對應的操作:

        // 與組件狀態無關的操作
        const basemap = {
            [vKey]: paste,
            [yKey]: redo,
            [zKey]: undo,
            [sKey]: save,
            [pKey]: preview,
            [eKey]: clearCanvas,
        }
        
        // 組件鎖定狀態下可以執行的操作
        const lockMap = {
            ...basemap,
            [uKey]: unlock,
        }
        
        // 組件未鎖定狀態下可以執行的操作
        const unlockMap = {
            ...basemap,
            [cKey]: copy,
            [xKey]: cut,
            [gKey]: compose,
            [bKey]: decompose,
            [dKey]: deleteComponent,
            [deleteKey]: deleteComponent,
            [lKey]: lock,
        }
        
        let isCtrlDown = false
        // 全局監聽按鍵操作并執行相應命令
        export function listenGlobalKeyDown() {
            window.onkeydown = (e) => {
                const { curComponent } = store.state
                if (e.keyCode == ctrlKey) {
                    isCtrlDown = true
                } else if (e.keyCode == deleteKey && curComponent) {
                    store.commit('deleteComponent')
                    store.commit('recordSnapshot')
                } else if (isCtrlDown) {
                    if (!curComponent || !curComponent.isLock) {
                        e.preventDefault()
                        unlockMap[e.keyCode] && unlockMap[e.keyCode]()
                    } else if (curComponent && curComponent.isLock) {
                        e.preventDefault()
                        lockMap[e.keyCode] && lockMap[e.keyCode]()
                    }
                }
            }
        
            window.onkeyup = (e) => {
                if (e.keyCode == ctrlKey) {
                    isCtrlDown = false
                }
            }
        }

        為了防止和瀏覽器默認快捷鍵沖突,所以需要加上 e.preventDefault()。

        23. 網格線

        網格線功能使用 SVG 來實現:

        <template>
            <svg class="grid" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
                <defs>
                    <pattern id="smallGrid" width="7.236328125" height="7.236328125" patternUnits="userSpaceOnUse">
                        <path 
                            d="M 7.236328125 0 L 0 0 0 7.236328125" 
                            fill="none" 
                            stroke="rgba(207, 207, 207, 0.3)" 
                            stroke-width="1">
                        </path>
                    </pattern>
                    <pattern id="grid" width="36.181640625" height="36.181640625" patternUnits="userSpaceOnUse">
                        <rect width="36.181640625" height="36.181640625" fill="url(#smallGrid)"></rect>
                        <path 
                            d="M 36.181640625 0 L 0 0 0 36.181640625" 
                            fill="none" 
                            stroke="rgba(186, 186, 186, 0.5)" 
                            stroke-width="1">
                        </path>
                    </pattern>
                </defs>
                <rect width="100%" height="100%" fill="url(#grid)"></rect>
            </svg>
        </template>
        
        <style lang="scss" scoped>
        .grid {
            position: absolute;
            top: 0;
            left: 0;
        }
        </style>

        對 SVG 不太懂的,建議看一下 MDN 的教程。

        24. 編輯器快照的另一種實現方式

        在系列文章的第一篇中,我已經分析過快照的實現原理。

        snapshotData: [], // 編輯器快照數據
        snapshotIndex: -1, // 快照索引
                
        undo(state) {
            if (state.snapshotIndex >= 0) {
                state.snapshotIndex--
                store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
            }
        },
        
        redo(state) {
            if (state.snapshotIndex < state.snapshotData.length - 1) {
                state.snapshotIndex++
                store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
            }
        },
        
        setComponentData(state, componentData = []) {
            Vue.set(state, 'componentData', componentData)
        },
        
        recordSnapshot(state) {
            // 添加新的快照
            state.snapshotData[++state.snapshotIndex] = deepCopy(state.componentData)
            // 在 undo 過程中,添加新的快照時,要將它后面的快照清理掉
            if (state.snapshotIndex < state.snapshotData.length - 1) {
                state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1)
            }
        },

        用一個數組來保存編輯器的快照數據。保存快照就是不停地執行 push() 操作,將當前的編輯器數據推入 snapshotData 數組,并增加快照索引 snapshotIndex。

        由于每一次添加快照都是將當前編輯器的所有組件數據推入 snapshotData,保存的快照數據越多占用的內存就越多。對此有兩個解決方案:

        1. 限制快照步數,例如只能保存 50 步的快照數據。
        2. 保存快照只保存差異部分。

        現在詳細描述一下第二個解決方案。

        假設依次往畫布上添加 a b c d 四個組件,在原來的實現中,對應的 snapshotData 數據為:

        // snapshotData
        [
          [a],
          [a, b],
          [a, b, c],
          [a, b, c, d],
        ]

        從上面的代碼可以發現,每一相鄰的快照中,只有一個數據是不同的。所以我們可以為每一步的快照添加一個類型字段,用來表示此次操作是添加還是刪除。

        那么上面添加四個組件的操作,所對應的 snapshotData 數據為:

        // snapshotData
        [
          [{ type: 'add', value: a }],
          [{ type: 'add', value: b }],
          [{ type: 'add', value: c }],
          [{ type: 'add', value: d }],
        ]

        如果我們要刪除 c 組件,那么 snapshotData 數據將變為:

        // snapshotData
        [
          [{ type: 'add', value: a }],
          [{ type: 'add', value: b }],
          [{ type: 'add', value: c }],
          [{ type: 'add', value: d }],
          [{ type: 'remove', value: c }],
        ]

        那如何使用現在的快照數據呢?

        我們需要遍歷一遍快照數據,來生成編輯器的組件數據 componentData。假設在上面的數據基礎上執行了 undo 撤銷操作:

        // snapshotData
        // 快照索引 snapshotIndex 此時為 3
        [
          [{ type: 'add', value: a }],
          [{ type: 'add', value: b }],
          [{ type: 'add', value: c }],
          [{ type: 'add', value: d }],
          [{ type: 'remove', value: c }],
        ]
        1. snapshotData[0] 類型為 add,將組件 a 添加到 componentData 中,此時 componentData[a]
        2. 依次類推 [a, b]
        3. [a, b, c]
        4. [a, b, c, d]

        如果這時執行 redo 重做操作,快照索引 snapshotIndex 變為 4。對應的快照數據類型為 type: 'remove', 移除組件 c。則數組數據為 [a, b, d]。

        這種方法其實就是時間換空間,雖然每一次保存的快照數據只有一項,但每次都得遍歷一遍所有的快照數據。兩種方法都不完美,要使用哪種取決于你,目前我仍在使用第一種方法。

        總結

        從造輪子的角度來看,這是我目前造的第四個比較滿意的輪子,其他三個為:

        造輪子是一個很好的提升自己技術水平的方法,但造輪子一定要造有意義、有難度的輪子,并且同類型的輪子只造一個。造完輪子后,還需要寫總結,最好輸出成文章分享出去。

        參考資料

        查看原文

        贊 46 收藏 25 評論 7

        譚光志 發布了文章 · 1月28日

        帶你入門前端工程(十一):微前端

        什么是微服務?先看看維基百科的定義:

        微服務(英語:Microservices)是一種軟件架構風格,它是以專注于單一責任與功能的小型功能區塊 (Small Building Blocks) 為基礎,利用模塊化的方式組合出復雜的大型應用程序,各功能區塊使用與語言無關 (Language-Independent/Language agnostic)的API集相互通信。

        換句話說,就是將一個大型、復雜的應用分解成幾個服務,每個服務就像是一個組件,組合起來一起構建成整個應用。

        想象一下,一個上百個功能、數十萬行代碼的應用維護起來是個什么場景?

        1. 牽一發而動全身,僅僅修改一處代碼,就需要重新部署整個應用。經常有“修改一分鐘,編譯半小時”的情況發生。
        2. 代碼模塊錯綜復雜,互相依賴。更改一處地方的代碼,往往會影響到應用的其他功能。

        如果使用微服務來重構整個應用有什么好處?

        一個應用分解成多個服務,每個服務獨自服務內部的功能。例如原來的應用有 abcd 四個頁面,現在分解成兩個服務,第一個服務有 ab 兩個頁面,第二個服務有 cd 兩個頁面,組合在一起就和原來的應用一樣。

        當應用其中一個服務出故障時,其他服務仍可以正常訪問。例如第一個服務出故障了, ab 頁面將無法訪問,但 cd 頁面仍能正常訪問。

        好處:不同的服務獨立運行,服務與服務之間解耦。我們可以把服務理解成組件,就像本小書第 3 章《前端組件化》中所說的一樣。每個服務可以獨自管理,修改一個服務不影響整體應用的運行,只影響該服務提供的功能。

        另外在開發時也可以快速的添加、刪除功能。例如電商網站,在不同的節假日時推出的活動頁面,活動過后馬上就可以刪掉。

        難點:不容易確認服務的邊界。當一個應用功能太多時,往往多個功能點之間的關聯會比較深。因而就很難確定這一個功能應該歸屬于哪個服務。

        PS:微前端就是微服務在前端的應用,也就是前端微服務。

        微服務實踐

        現在我們將使用微前端框架 qiankun 來構建一個微前端應用。之所以選用 qiankun 框架,是因為它有以下幾個優點:

        • 技術棧無關,任何技術棧的應用都能接入。
        • 樣式隔離,子應用之間的樣式互不干擾。
        • 子應用的 JavaScript 作用域互相隔離。
        • 資源預加載,在瀏覽器空閑時間預加載未打開的微應用資源,加速微應用打開速度。

        樣式隔離

        樣式隔離的原理是:每次切換子應用時,都會加載該子應用對應的 css 文件。同時會把原先的子應用樣式文件移除掉,這樣就達到了樣式隔離的效果。

        我們可以自己模擬一下這個效果:

        <!-- index.html -->
        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Document</title>
            <link rel="stylesheet" href="index.css">
        <body>
            <div>移除樣式文件后將不會變色</div>
        </body>
        </html>
        /* index.css */
        body {
            color: red;
        }

        現在我們加一段 JavaScript 代碼,在加載完樣式文件后再將樣式文件移除掉:

        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Document</title>
            <link rel="stylesheet" href="index.css">
        <body>
            <div>移除樣式文件后將不會變色</div>
            <script>
                setTimeout(() => {
                    const link = document.querySelector('link')
                    link.parentNode.removeChild(link)
                }, 3000)
            </script>
        </body>
        </html>

        這時再打開頁面看一下,可以發現 3 秒后字體樣式就沒有了。

        JavaScript 作用域隔離

        主應用在切換子應用之前會記錄當前的全局狀態,然后在切出子應用之后恢復全局狀態。假設當前的全局狀態如下所示:

        const global = { a: 1 }

        在進入子應用之后,無論全局狀態如何變化,將來切出子應用時都會恢復到原先的全局狀態:

        // global
        { a: 1 }

        官方還提供了一張圖來幫助我們理解這個機制:

        好了,現在我們來創建一個微前端應用吧。這個微前端應用由三部分組成:

        • main:主應用,使用 vue-cli 創建。
        • vue:子應用,使用 vue-cli 創建。
        • react: 子應用,使用的 react 16 版本。

        對應的目錄如下:

        -main
        -vue
        -react

        創建主應用

        我們使用 vue-cli 創建主應用(然后執行 npm i qiankun 安裝 qiankun 框架):

        vue create main

        如果主應用只是起到一個基座的作用,即只用于切換子應用。那可以不需要安裝 vue-router 和 vuex。

        改造 App.vue 文件

        主應用必須提供一個能夠安裝子應用的元素,所以我們需要將 App.vue 文件改造一下:

        <template>
            <div class="mainapp">
                <!-- 標題欄 -->
                <header class="mainapp-header">
                    <h1>QianKun</h1>
                </header>
                <div class="mainapp-main">
                    <!-- 側邊欄 -->
                    <ul class="mainapp-sidemenu">
                        <li @click="push('/vue')">Vue</li>
                        <li @click="push('/react')">React</li>
                    </ul>
                    <!-- 子應用  -->
                    <main class="subapp-container">
                        <h4 v-if="loading" class="subapp-loading">Loading...</h4>
                        <div id="subapp-viewport"></div>
                    </main>
                </div>
            </div>
        </template>
        
        <script>
        export default {
            name: 'App',
            props: {
                loading: Boolean,
            },
            methods: {
                push(subapp) { history.pushState(null, subapp, subapp) }
            }
        }
        </script>

        可以看到我們用于安裝子應用的元素為 #subapp-viewport,另外還有切換子應用的功能:

        <!-- 側邊欄 -->
        <ul class="mainapp-sidemenu">
            <li @click="push('/vue')">Vue</li>
            <li @click="push('/react')">React</li>
        </ul>

        改造 main.js

        根據 qiankun 文檔說明,需要使用 registerMicroApps()start() 方法注冊子應用及啟動主應用:

        import { registerMicroApps, start } from 'qiankun';
        registerMicroApps([
          {
            name: 'react app', // app name registered
            entry: '//localhost:7100',
            container: '#yourContainer',
            activeRule: '/yourActiveRule',
          },
          {
            name: 'vue app',
            entry: { scripts: ['//localhost:7100/main.js'] },
            container: '#yourContainer2',
            activeRule: '/yourActiveRule2',
          },
        ]);
        start();

        所以現在需要將 main.js 文件改造一下:

        import Vue from 'vue'
        import App from './App'
        import { registerMicroApps, runAfterFirstMounted, setDefaultMountApp, start, initGlobalState } from 'qiankun'
        
        let app = null
        
        function render({ loading }) {
            if (!app) {
                app = new Vue({
                    el: '#app',
                    data() {
                        return {
                            loading,
                        }
                    },
                    render(h) {
                        return h(App, {
                            props: {
                                loading: this.loading
                            }
                        })
                    }
                });
            } else {
                app.loading = loading
            }
        }
        
        /**
         * Step1 初始化應用(可選)
         */
        render({ loading: true })
        
        const loader = (loading) => render({ loading })
        
        /**
         * Step2 注冊子應用
         */
        
        registerMicroApps(
            [
                {
                    name: 'vue', // 子應用名稱
                    entry: '//localhost:8001', // 子應用入口地址
                    container: '#subapp-viewport',
                    loader,
                    activeRule: '/vue', // 子應用觸發路由
                },
                {
                    name: 'react',
                    entry: '//localhost:8002',
                    container: '#subapp-viewport',
                    loader,
                    activeRule: '/react',
                },
            ],
            // 子應用生命周期事件
            {
                beforeLoad: [
                    app => {
                        console.log('[LifeCycle] before load %c%s', 'color: green', app.name)
                    },
                ],
                beforeMount: [
                    app => {
                        console.log('[LifeCycle] before mount %c%s', 'color: green', app.name)
                    },
                ],
                afterUnmount: [
                    app => {
                        console.log('[LifeCycle] after unmount %c%s', 'color: green', app.name)
                    },
                ],
            },
        )
        
        // 定義全局狀態,可以在主應用、子應用中使用
        const { onGlobalStateChange, setGlobalState } = initGlobalState({
            user: 'qiankun',
        })
        
        // 監聽全局狀態變化
        onGlobalStateChange((value, prev) => console.log('[onGlobalStateChange - master]:', value, prev))
        
        // 設置全局狀態
        setGlobalState({
            ignore: 'master',
            user: {
                name: 'master',
            },
        })
        
        /**
         * Step3 設置默認進入的子應用
         */
        setDefaultMountApp('/vue')
        
        /**
         * Step4 啟動應用
         */
        start()
        
        runAfterFirstMounted(() => {
            console.log('[MainApp] first app mounted')
        })
        

        這里有幾個注意事項要注意一下:

        1. 子應用的名稱 name 必須和子應用下的 package.json 文件中的 name 一樣。
        2. 每個子應用都有一個 loader() 方法,這是為了應對用戶直接從子應用路由進入頁面的情況而設的。進入子頁面時判斷一下是否加載了主應用,沒有則加載,有則跳過。
        3. 為了防止在切換子應用時顯示空白頁面,應該提供一個 loading 配置。
        4. 設置子應用的入口地址時,直接填入子應用的訪問地址。

        更改訪問端口

        vue-cli 的默認訪問端口一般為 8080,為了和子應用保持一致,需要將主應用端口改為 8000(子應用分別為 8001、8002)。創建 vue.config.js 文件,將訪問端口改為 8000:

        module.exports = {
            devServer: {
                port: 8000,
            }
        }

        至此,主應用就已經改造完了。

        創建子應用

        子應用不需要引入 qiankun 依賴,只需要暴露出幾個生命周期函數就可以:

        1. bootstrap,子應用首次啟動時觸發。
        2. mount,子應用每次啟動時都會觸發。
        3. unmount,子應用切換/卸載時觸發。

        現在將子應用的 main.js 文件改造一下:

        import Vue from 'vue'
        import VueRouter from 'vue-router'
        import App from './App.vue'
        import routes from './router'
        import store from './store'
        
        Vue.config.productionTip = false
        
        let router = null
        let instance = null
        
        function render(props = {}) {
            const { container } = props
            router = new VueRouter({
                // hash 模式不需要下面兩行
                base: window.__POWERED_BY_QIANKUN__ ? '/vue' : '/',
                mode: 'history',
                routes,
            })
        
            instance = new Vue({
                router,
                store,
                render: h => h(App),
            }).$mount(container ? container.querySelector('#app') : '#app')
        }
        
        if (window.__POWERED_BY_QIANKUN__) {
            // eslint-disable-next-line no-undef
            __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
        } else {
            render()
        }
        
        function storeTest(props) {
            props.onGlobalStateChange &&
                props.onGlobalStateChange(
                    (value, prev) => console.log(`[onGlobalStateChange - ${props.name}]:`, value, prev),
                    true,
                )
            props.setGlobalState &&
                props.setGlobalState({
                    ignore: props.name,
                    user: {
                        name: props.name,
                    },
                })
        }
        
        export async function bootstrap() {
            console.log('[vue] vue app bootstraped')
        }
        
        export async function mount(props) {
            console.log('[vue] props from main framework', props)
            storeTest(props)
            render(props)
        }
        
        export async function unmount() {
            instance.$destroy()
            instance.$el.innerHTML = ''
            instance = null
            router = null
        }

        可以看到在文件的最后暴露出了 bootstrapmountunmount 三個生命周期函數。另外在掛載子應用時還需要注意一下,子應用是在主應用下運行還是自己獨立運行:container ? container.querySelector('#app') : '#app'。

        配置打包項

        根據 qiankun 文檔提示,需要對子應用的打包配置項作如下更改:

        const packageName = require('./package.json').name;
        module.exports = {
          output: {
            library: `${packageName}-[name]`,
            libraryTarget: 'umd',
            jsonpFunction: `webpackJsonp_${packageName}`,
          },
        };

        所以現在我們還需要在子應用目錄下創建 vue.config.js 文件,輸入以下代碼:

        // vue.config.js
        const { name } = require('./package.json')
        
        module.exports = {
            configureWebpack: {
                output: {
                    // 把子應用打包成 umd 庫格式
                    library: `${name}-[name]`,
                    libraryTarget: 'umd',
                    jsonpFunction: `webpackJsonp_${name}`
                }
            },
            devServer: {
                port: 8001,
                headers: {
                    'Access-Control-Allow-Origin': '*'
                }
            }
        }

        vue.config.js 文件有幾個注意事項:

        1. 主應用、子應用運行在不同端口下,所以需要設置跨域頭 'Access-Control-Allow-Origin': '*'。
        2. 由于在主應用配置了 vue 子應用需要運行在 8001 端口下,所以也需要在 devServer 里更改端口。

        另外一個子應用 react 的改造方法和 vue 是一樣的,所以在此不再贅述。

        部署

        我們將使用 express 來部署項目,除了需要在子應用設置跨域外,沒什么需要特別注意的地方。

        主應用服務器文件 main-server.js

        const fs = require('fs')
        const express = require('express')
        const app = express()
        const port = 8000
        
        app.use(express.static('main-static'))
        
        app.get('*', (req, res) => {
            fs.readFile('./main-static/index.html', 'utf-8', (err, html) => {
                res.send(html)
            })
        })
        
        app.listen(port, () => {
            console.log(`main app listening at http://localhost:${port}`)
        })

        vue 子應用服務器文件 vue-server.js

        const fs = require('fs')
        const express = require('express')
        const app = express()
        const cors = require('cors')
        const port = 8001
        
        // 設置跨域
        app.use(cors())
        app.use(express.static('vue-static'))
        
        app.get('*', (req, res) => {
            fs.readFile('./vue-static/index.html', 'utf-8', (err, html) => {
                res.send(html)
            })
        })
        
        app.listen(port, () => {
            console.log(`vue app listening at http://localhost:${port}`)
        })

        react 子應用服務器文件 react-server.js

        const fs = require('fs')
        const express = require('express')
        const app = express()
        const cors = require('cors')
        const port = 8002
        
        // 設置跨域
        app.use(cors())
        app.use(express.static('react-static'))
        
        app.get('*', (req, res) => {
            fs.readFile('./react-static/index.html', 'utf-8', (err, html) => {
                res.send(html)
            })
        })
        
        app.listen(port, () => {
            console.log(`react app listening at http://localhost:${port}`)
        })

        另外需要將這三個應用打包后的文件分別放到 main-static、vue-static、react-static 目錄下。然后分別執行命令 node main-server.js、node vue-server.js、node react-server.js 即可查看部署后的頁面?,F在這個項目目錄如下:

        -main
        -main-static // main 主應用靜態文件目錄
        -react
        -react-static // react 子應用靜態文件目錄
        -vue
        -vue-static // vue 子應用靜態文件目錄
        -main-server.js // main 主應用服務器
        -vue-server.js // vue 子應用服務器
        -react-server.js // react 子應用服務器

        我已經將這個微前端應用的代碼上傳到了 github,建議將項目克隆下來配合本章一起閱讀,效果更好。下面放一下 DEMO 的運行效果圖:

        小結

        對于大型應用的開發和維護,使用微前端能讓我們變得更加輕松。不過如果是小應用,建議還是單獨建一個項目開發。畢竟微前端也有額外的開發、維護成本。

        參考資料

        帶你入門前端工程 全文目錄:

        1. 技術選型:如何進行技術選型?
        2. 統一規范:如何制訂規范并利用工具保證規范被嚴格執行?
        3. 前端組件化:什么是模塊化、組件化?
        4. 測試:如何寫單元測試和 E2E(端到端) 測試?
        5. 構建工具:構建工具有哪些?都有哪些功能和優勢?
        6. 自動化部署:如何利用 Jenkins、Github Actions 自動化部署項目?
        7. 前端監控:講解前端監控原理及如何利用 sentry 對項目實行監控。
        8. 性能優化(一):如何檢測網站性能?有哪些實用的性能優化規則?
        9. 性能優化(二):如何檢測網站性能?有哪些實用的性能優化規則?
        10. 重構:為什么做重構?重構有哪些手法?
        11. 微服務:微服務是什么?如何搭建微服務項目?
        12. Severless:Severless 是什么?如何使用 Severless?
        查看原文

        贊 22 收藏 13 評論 0

        譚光志 發布了文章 · 1月27日

        帶你入門前端工程(十):重構

        《重構2》一書中對重構進行了定義:

        所謂重構(refactoring)是這樣一個過程:在不改變代碼外在行為的前提下,對代碼做出修改,以改進程序的內部結構。重構是一種經千錘百煉形成的有條不紊的程序整理方法,可以最大限度地減小整理過程中引入錯誤的概率。本質上說,重構就是在代碼寫好之后改進它的設計。

        重構和性能優化有相同點,也有不同點。

        相同的地方是它們都在不改變程序功能的情況下修改代碼;不同的地方是重構為了讓代碼變得更加容易理解、易于修改,性能優化則是為了讓程序運行得更快。這里還得重點提一句,由于側重點不同,重構可能使程序運行得更快,也可能使程序運行得更慢。

        重構可以一邊寫代碼一邊重構,也可以在程序寫完后,拿出一段時間專門去做重構。沒有說哪個方式更好,視個人情況而定。如果你專門拿一段時間來做重構,則建議在重構一段代碼后,立即進行測試。這樣可以避免修改代碼太多,在出錯時找不到錯誤點。

        重構的原則

        1. 事不過三,三則重構。即不能重復寫同樣的代碼,在這種情況下要去重構。
        2. 如果一段代碼讓人很難看懂,那就該考慮重構了。
        3. 如果已經理解了代碼,但是非常繁瑣或者不夠好,也可以重構。
        4. 過長的函數,需要重構。
        5. 一個函數最好對應一個功能,如果一個函數被塞入多個功能,那就要對它進行重構了。(4 和 5 不沖突)
        6. 重構的關鍵在于運用大量微小且保持軟件行為的步驟,一步步達成大規模的修改。每個單獨的重構要么很小,要么由若干小步驟組合而成。

        重構的手法

        《重構2》這本書中,介紹了多達上百種重構手法。但我覺得以下八種是比較常用的:

        1. 提取重復代碼,封裝成函數
        2. 拆分功能太多的函數
        3. 變量/函數改名
        4. 替換算法
        5. 以函數調用取代內聯代碼
        6. 移動語句
        7. 折分嵌套條件表達式
        8. 將查詢函數和修改函數分離

        提取重復代碼,封裝成函數

        假設有一個查詢數據的接口 /getUserData?age=17&city=beijing?,F在需要做的是把用戶數據:{ age: 17, city: 'beijing' } 轉成 URL 參數的形式:

        let result = ''
        const keys = Object.keys(data)  // { age: 17, city: 'beijing' }
        keys.forEach(key => {
            result += '&' + key + '=' + data[key]
        })
        
        result.substr(1) // age=17&city=beijing

        如果只有這一個接口需要轉換,不封裝成函數是沒問題的。但如果有多個接口都有這種需求,那就得把它封裝成函數了:

        function JSON2Params(data) {
            let result = ''
            const keys = Object.keys(data)
            keys.forEach(key => {
                result += '&' + key + '=' + data[key]
            })
        
            return result.substr(1)
        }

        拆分功能太多的函數

        下面是一個打印賬單的程序:

        function printBill(data = []) {
            // 匯總數據
            const total = {}
            data.forEach(item => {
                if (total[item.department] === undefined) {
                    total[item.department] = 0
                }
        
                total[item.department] += item.value
            })
            // 打印匯總后的數據
            const keys = Object.keys(total)
            keys.forEach(key => {
                console.log(`${key} 部門:${total[key]}`)
            })
        }
        
        printBill([
            {
                department: '銷售部',
                value: 89,
            },
            {
                department: '后勤部',
                value: 132,
            },
            {
                department: '財務部',
                value: 78,
            },
            {
                department: '總經辦',
                value: 90,
            },
            {
                department: '后勤部',
                value: 56,
            },
            {
                department: '總經辦',
                value: 120,
            },
        ])

        可以看到這個 printBill() 函數實際上包含有兩個功能:匯總和打印。我們可以把匯總數據的代碼提取出來,封裝成一個函數。這樣 printBill() 函數就只需要關注打印功能了。

        function printBill(data = []) {
            const total = calculateBillData(data)
            const keys = Object.keys(total)
            keys.forEach(key => {
                console.log(`${key} 部門:${total[key]}`)
            })
        }
        
        function calculateBillData(data) {
            const total = {}
            data.forEach(item => {
                if (total[item.department] === undefined) {
                    total[item.department] = 0
                }
        
                total[item.department] += item.value
            })
        
            return total
        }

        變量/函數改名

        無論是變量命名,還是函數命名,都要盡量讓別人明白你這個變量/函數是干什么的。變量命名的規則著重于描述“是什么”,函數命名的規則著重于描述“做什么”。

        變量

        const a = width * height

        上面這個變量就不太好,a 很難讓人看出來它是什么。

        const area = width * height

        改成這樣就很好理解了,原來這個變量是表示面積。

        函數

        function cache(data) {
            const result = []
            data.forEach(item => {
                if (item.isCache) {
                    result.push(item)
                }
            })
        
            return result
        }

        這個函數名稱會讓人很疑惑,cache 代表什么?是設置緩存還是刪除緩存?再一細看代碼,噢,原來是獲取緩存數據。所以這個函數名稱改成 getCache() 更加合適。

        替換算法

        function foundPersonData(person) {
            if (person == 'Tom') {
                return {
                    name: 'Tom',
                    age: 18,
                    id: 21,
                }
            }
        
            if (person == 'Jim') {
                return {
                    name: 'Jim',
                    age: 20,
                    id: 111,
                }
            }
        
            if (person == 'Lin') {
                return {
                    name: 'Lin',
                    age: 19,
                    id: 10,
                }
            }
        
            return null
        }

        上面這個函數的功能是根據用戶姓名查找用戶的詳細信息,可以看到這個函數做了三次 if 判斷,如果沒找到數據就返回 null。這個函數不利于擴展,每多一個用戶就得多寫一個 if 語句,我們可以用更方便的“查找表”來替換它。

        function foundPersonData(person) {
            const data = {
                'Tom': {
                    name: 'Tom',
                    age: 18,
                    id: 21,
                },
                'Jim': {
                    name: 'Jim',
                    age: 20,
                    id: 111,
                },
                'Lin': {
                    name: 'Lin',
                    age: 19,
                    id: 10,
                },
            }
        
            return data[person] || null
        }

        修改后代碼結構看起來更加清晰,也方便未來做擴展。

        以函數調用取代內聯代碼

        如果一些代碼所做的事情和已有函數的功能重復,那就最好用函數調用來取代這些代碼。

        let hasApple = false
        for (const fruit of fruits) {
            if (fruit == 'apple') {
                hasApple = true
                break
            }
        }

        例如上面的代碼,可以用數組的 includes() 方法代替:

        const hasApple = fruits.includes('apple')

        修改后代碼更加簡潔。

        移動語句

        讓存在關聯的東西一起出現,可以使代碼更容易理解。如果有一些代碼都是作用在一個地方,那么最好是把它們放在一起,而不是夾雜在其他的代碼中間。最簡單的情況下,只需使用移動語句就可以讓它們聚集起來。就像下面的示例一樣:

        const name = getName()
        const age = getAge()
        let revenue
        const address = getAddress()
        // ...
        const name = getName()
        const age = getAge()
        const address = getAddress()
        
        let revenue
        // ...

        由于兩塊數據區域的功能是不同的,所以除了移動語句外,我還在它們之間空了一行,這樣讓人更容易區分它們之間的不同。

        折分嵌套條件表達式

        當很多的條件表達式嵌套在一起時,會讓代碼變得很難閱讀:

        function getPayAmount() {
            if (isDead) {
                return deadAmount()
            } else {
                if (isSeparated) {
                    return separatedAmount()
                } else if (isRetired) {
                    return retireAmount()
                } else {
                    return normalAmount()
                }
            }
        }
        function getPayAmount() {
            if (isDead) return deadAmount()
            if (isSeparated) return separatedAmount()
            if (isRetired) return retireAmount()
            return normalAmount()
        }

        將條件表達式拆分后,代碼的可閱讀性大大增強了。

        將查詢函數和修改函數分離

        一般的查詢函數都是用于取值的,例如 getUserData()、getAget()、getName() 等等。有時候,我們可能為了方便,在查詢函數上附加其他功能。例如下面的函數:

        function getValue() {
            let result = 0
            this.data.forEach(val => result += val)
            // 這里插入了一個奇怪的操作
            sendBill()
            return result
        }

        千萬不要這樣做,函數很重要的功能是職責分離。所以我們要將它們分開:

        function getValue() {
            let result = 0
            this.data.forEach(val => result += val)
            return result
        }
        
        function sendBill() {
            // ...
        }

        這樣函數的功能就很清晰了。

        小結

        古人云:盡信書,不如無書?!吨貥?》也不例外,在看這本書的時候一定要帶著批判性的目光去閱讀它。

        里面介紹的重構手法有很多,多達上百種,但這些手法不一定適用所有人。所以一定要有取舍,將里面有用的手法摘抄下來,時不時的看幾遍。這樣在寫代碼時,重構才能像呼吸一樣自然,即使用了你也不知道。

        參考資料

        帶你入門前端工程 全文目錄:

        1. 技術選型:如何進行技術選型?
        2. 統一規范:如何制訂規范并利用工具保證規范被嚴格執行?
        3. 前端組件化:什么是模塊化、組件化?
        4. 測試:如何寫單元測試和 E2E(端到端) 測試?
        5. 構建工具:構建工具有哪些?都有哪些功能和優勢?
        6. 自動化部署:如何利用 Jenkins、Github Actions 自動化部署項目?
        7. 前端監控:講解前端監控原理及如何利用 sentry 對項目實行監控。
        8. 性能優化(一):如何檢測網站性能?有哪些實用的性能優化規則?
        9. 性能優化(二):如何檢測網站性能?有哪些實用的性能優化規則?
        10. 重構:為什么做重構?重構有哪些手法?
        11. 微服務:微服務是什么?如何搭建微服務項目?
        12. Severless:Severless 是什么?如何使用 Severless?
        查看原文

        贊 2 收藏 1 評論 0

        譚光志 發布了文章 · 1月26日

        帶你入門前端工程(四):測試

        什么是測試

        維基百科的定義:

        在規定的條件下對程序進行操作,以發現程序錯誤,衡量軟件質量,并對其是否能滿足設計要求進行評估的過程。

        也可以這樣理解:測試的作用是為了提高代碼質量和可維護性。

        1. 提高代碼質量:測試就是找 BUG,找出 BUG,然后解決它。BUG 少了,代碼質量自然就高了。
        2. 可維護性:對現有代碼進行修改、新增功能從而造成的成本越低,可維護性就越高。

        什么時候寫測試

        如果你的程序非常簡單,可以不用寫測試。例如下面的程序,功能簡單,只有十幾行代碼:

        function add(a, b) {
            return a + b
        }
        
        function sum(data = []) {
            let result = 0
            data.forEach(val => {
                result = add(result, val)
            })
        
            return result
        }
        
        console.log(sum([1,2,3,4,5,6,7,8,9,10])) // 55

        如果你的程序有數百行代碼,但封裝得很好,完美的踐行了模塊化的理念。每個模塊功能單一、代碼少,也可以不用寫測試。

        如果你的程序有成千上萬行代碼,數十個模塊,模塊與模塊之間的交互錯綜復雜。在這種情況下,就需要寫測試了。試想一下,在你對一個非常復雜的項目進行修改后,如果沒有測試會是什么情況?你需要將跟這次修改有關的每個功能都手動測一邊,以防止有 BUG 出現。但如果你寫了測試,只需執行一條命令就能知道結果,省時省力。

        測試類型與框架

        測試類型有很多種:單元測試、集成測試、白盒測試...

        測試框架也有很多種:Jest、Jasmine、LambdaTest...

        本章將只講解單元測試和 E2E 測試(end-to-end test 端到端測試)。其中單元測試使用的測試框架為 Jest,E2E 使用的測試框架為 Cypress。

        Jest

        安裝

        npm i -D jest

        打開 package.json 文件,在 scripts 下添加測試命令:

        "scripts": {
            "test": "jest",
         }

        然后在項目根目錄下新建 test 目錄,作為測試目錄。

        單元測試

        什么是單元測試?維基百科中給出的定義為:

        單元測試(英語:Unit Testing)又稱為模塊測試,是針對程序模塊(軟件設計的最小單位)來進行正確性檢驗的測試工作。

        從前端角度來看,單元測試就是對一個函數、一個組件、一個類做的測試,它針對的粒度比較小。

        單元測試應該怎么寫呢?

        1. 根據正確性寫測試,即正確的輸入應該有正常的結果。
        2. 根據錯誤性寫測試,即錯誤的輸入應該是錯誤的結果。

        對一個函數做測試

        例如一個取絕對值的函數 abs(),輸入 1,2,結果應該與輸入相同;輸入 -1,-2,結果應該與輸入相反。如果輸入非數字,例如 "abc",應該拋出一個類型錯誤。

        // main.js
        function abs(a) {
            if (typeof a != 'number') {
                throw new TypeError('參數必須為數值型')
            }
        
            if (a < 0) return -a
            return a
        }
        
        // test.spec.js
        test('abs', () => {
            expect(abs(1)).toBe(1)
            expect(abs(0)).toBe(0)
            expect(abs(-1)).toBe(1)
            expect(() => abs('abc')).toThrow(TypeError) // 類型錯誤
        })

        現在我們需要測試一下 abs() 函數:在 src 目錄新建一個 main.js 文件,在 test 目錄新建一個 test.spec.js 文件。然后將上面的兩個函數代碼寫入對應的文件,執行 npm run test,就可以看到測試效果了。

        對一個類做測試

        假設有這樣一個類:

        class Math {
            abs() {
        
            }
        
            sqrt() {
        
            }
        
            pow() {
        
            }
            ...
        }

        我們必須把這個類的所有方法都測一遍。

        test('Math.abs', () => {
            // ...
        })
        
        test('Math.sqrt', () => {
            // ...
        })
        
        test('Math.pow', () => {
            // ...
        })

        對一個組件做測試

        組件測試比較難,因為很多組件都涉及了 DOM 操作。

        例如一個上傳圖片組件,它有一個將圖片轉成 base64 碼的方法,那要怎么測試呢?一般測試都是跑在 node 環境下的,而 node 環境沒有 DOM 對象。

        我們先來回顧一下上傳圖片的過程:

        1. 點擊 <input type="file" />,選擇圖片上傳。
        2. 觸發 inputchange 事件,獲取 file 對象。
        3. FileReader 將圖片轉換成 base64 碼。

        這個過程和下面的代碼是一樣的:

        document.querySelector('input').onchange = function fileChangeHandler(e) {
            const file = e.target.files[0]
            const reader = new FileReader()
            reader.onload = (res) => {
                const fileResult = res.target.result
                console.log(fileResult) // 輸出 base64 碼
            }
        
            reader.readAsDataURL(file)
        }

        上面的代碼只是模擬,真實情況下應該是這樣使用:

        document.querySelector('input').onchange = function fileChangeHandler(e) {
            const file = e.target.files[0]
            tobase64(file)
        }
        
        function tobase64(file) {
            return new Promise((resolve, reject) => {
                const reader = new FileReader()
                reader.onload = (res) => {
                    const fileResult = res.target.result
                    resolve(fileResult) // 輸出 base64 碼
                }
        
                reader.readAsDataURL(file)
            })
        }

        可以看到,上面的代碼出現了 window 的事件對象 event、FileReader。也就是說,只要我們能夠提供這兩個對象,就可以在任何環境下運行它。所以我們可以在測試環境下加上這兩個對象:

        // 重寫 File
        window.File = function () {}
        
        // 重寫 FileReader
        window.FileReader = function () {
            this.readAsDataURL = function () {
                this.onload
                    && this.onload({
                        target: {
                            result: fileData,
                        },
                    })
            }
        }

        然后測試可以這樣寫:

        // 提前寫好文件內容
        const fileData = 'data:image/test'
        
        // 提供一個假的 file 對象給 tobase64() 函數
        function test() {
            const file = new File()
            const event = { target: { files: [file] } }
            file.type = 'image/png'
            file.name = 'test.png'
            file.size = 1024
        
            it('file content', (done) => {
                tobase64(file).then(base64 => {
                    expect(base64).toEqual(fileData) // 'data:image/test'
                    done()
                })
            })
        }
        
        // 執行測試
        test()

        通過這種 hack 的方式,我們就實現了對涉及 DOM 操作的組件的測試。我的 vue-upload-imgs 庫就是通過這種方式寫的單元測試,有興趣可以了解一下(測試文件放在 test 目錄)。

        測試覆蓋率

        什么是測試覆蓋率?用一個公式來表示:代碼覆蓋率 = 已執行的代碼數 / 代碼總數。Jest 如果要開啟測試覆蓋率統計,只需要在 Jest 命令后面加上 --coverage 參數:

        "scripts": {
            "test": "jest --coverage",
        }

        現在我們用剛才的測試用例再試一遍,看看測試覆蓋率。

        // main.js
        function abs(a) {
            if (typeof a != 'number') {
                throw new TypeError('參數必須為數值型')
            }
        
            if (a < 0) return -a
            return a
        }
        
        // test.spec.js
        test('abs', () => {
            expect(abs(1)).toBe(1)
            expect(abs(0)).toBe(0)
            expect(abs(-1)).toBe(1)
            expect(() => abs('abc')).toThrow(TypeError) // 類型錯誤
        })

        上圖表示每一項覆蓋率都是 100%。

        現在我們把測試類型錯誤的那一行代碼注釋掉,再試試。

        // test.spec.js
        test('abs', () => {
            expect(abs(1)).toBe(1)
            expect(abs(0)).toBe(0)
            expect(abs(-1)).toBe(1)
            // expect(() => abs('abc')).toThrow(TypeError)
        })

        可以看到測試覆蓋率下降了,為什么會這樣呢?因為 abs() 函數中判斷類型錯誤的那個分支的代碼沒有執行。

        // 就是這一個分支語句
        if (typeof a != 'number') {
            throw new TypeError('參數必須為數值型')
        }

        覆蓋率統計項

        從覆蓋率的圖片可以看到一共有 4 個統計項:

        1. Stmts(statements):語句覆蓋率,程序中的每個語句是否都已執行。
        2. Branch:分支覆蓋率,是否執行了每個分支。
        3. Funcs:函數覆蓋率,是否執行了每個函數。
        4. Lines:行覆蓋率,是否執行了每一行代碼。

        可能有人會有疑問,1 和 4 不是一樣嗎?其實不一樣,因為一行代碼可以包含好幾個語句。

        if (typeof a != 'number') {
            throw new TypeError('參數必須為數值型')
        }
        
        if (typeof a != 'number') throw new TypeError('參數必須為數值型')

        例如上面兩段代碼,它們對應的測試覆蓋率就不一樣?,F在把測試類型錯誤的那一行代碼注釋掉,再試試:

        // expect(() => abs('abc')).toThrow(TypeError)

        第一段代碼對應的覆蓋率

        第二段代碼對應的覆蓋率

        它們未執行的語句都是一樣,但第一段代碼 Lines 覆蓋率更低,因為它有一行代碼沒執行。而第二段代碼未執行的語句和判斷語句是在同一行,所以 Lines 覆蓋率為 100%。

        TDD 測試驅動開發

        TDD(Test-Driven Development) 就是根據需求提前把測試代碼寫好,然后根據測試代碼實現功能。

        TDD 的初衷是好的,但如果你的需求經常變(你懂的),那就不是一件好事了。很有可能你天天都在改測試代碼,業務代碼反而沒怎么動。

        所以 TDD 用不用還得取決于業務需求是否經常變更,以及你對需求是否有清晰的認識。

        E2E 測試

        端到端測試,主要是模擬用戶對頁面進行一系列操作并驗證其是否符合預期。本章將使用 Cypress 講解 E2E 測試。

        Cypress 在進行 E2E 測試時,會打開 Chrome 瀏覽器,然后根據測試代碼對頁面進行操作,就像一個正常的用戶在操作頁面一樣。

        安裝

        npm i -D cypress

        打開 package.json 文件,在 scripts 新增一條命令:

        "cypress": "cypress open"

        然后執行 npm run cypress 就可以打開 Cypress。首次打開會自動創建 Cypress 提供的默認測試腳本。

        點擊右邊的 Run 19 integration specs 就會開始執行測試。

        第一次測試

        打開 cypress 目錄,在 integration 目錄下新建一個 e2e.spec.js 測試文件:

        describe('The Home Page', () => {
            it('successfully loads', () => {
                cy.visit('http://localhost:8080')
            })
        })

        運行它,如無意外應該會看到一個測試失敗的提示。

        因為測試文件要求訪問 http://localhost:8080 服務器,但現在還沒有。所以我們需要使用 express 創建一個服務器,新建 server.js 文件,輸入以下代碼:

        // server.js
        const express = require('express')
        const app = express()
        const port = 8080
        
        app.get('/', (req, res) => {
            res.send('Hello World!')
        })
        
        app.listen(port, () => {
            console.log(`Example app listening at http://localhost:${port}`)
        })

        執行 node server.js,重新運行測試,這次就可以看到正確的結果了。

        PS: 如果你使用了 ESlint 來校驗代碼,則需要下載 eslint-plugin-cypress 插件,否則 Cypress 的全局命令會報錯。下載插件后,打開 .eslintrc 文件,在 plugins 選項中加上 cypress

        "plugins": [
            "cypress"
        ]

        模仿用戶登錄

        上一個測試實在是有點小兒科,這次我們來寫一個稍微復雜一點的測試,模仿用戶登錄:

        1. 用戶打開登錄頁 /login.html
        2. 輸入賬號密碼(都是 admin
        3. 登錄成功后,跳轉到 /index.html

        首先需要重寫服務器,修改一下 server.js 文件的代碼:

        // server.js
        const bodyParser = require('body-parser')
        const express = require('express')
        const app = express()
        const port = 8080
        app.use(express.static('public'))
        app.use(bodyParser.urlencoded({ extended: false }))
        app.use(bodyParser.json())
        
        app.post('/login', (req, res) => {
            const { account, password } = req.body
            // 由于沒有注冊功能,所以假定賬號密碼都為 admin
            if (account == 'admin' && password == 'admin') {
                res.send({
                    msg: '登錄成功',
                    code: 0,
                })
            } else {
                res.send({
                    msg: '登錄失敗,請輸入正確的賬號密碼',
                    code: 1,
                })
            }
        })
        
        app.listen(port, () => {
            console.log(`Example app listening at http://localhost:${port}`)
        })

        由于沒有注冊功能,所以暫時在后端寫死賬號密碼為 admin。然后新建兩個 html 文件:login.htmlindex.html,放在 public 目錄。

        <!-- login.html  -->
        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>login</title>
            <style>
                div {
                    text-align: center;
                }
        
                button {
                    display: inline-block;
                    line-height: 1;
                    white-space: nowrap;
                    cursor: pointer;
                    text-align: center;
                    box-sizing: border-box;
                    outline: none;
                    margin: 0;
                    transition: 0.1s;
                    font-weight: 500;
                    padding: 12px 20px;
                    font-size: 14px;
                    border-radius: 4px;
                    color: #fff;
                    background-color: #409eff;
                    border-color: #409eff;
                    border: 0;
                }
        
                button:active {
                    background: #3a8ee6;
                    border-color: #3a8ee6;
                    color: #fff;
                }
        
                input {
                    display: block;
                    margin: auto;
                    margin-bottom: 10px;
                    -webkit-appearance: none;
                    background-color: #fff;
                    background-image: none;
                    border-radius: 4px;
                    border: 1px solid #dcdfe6;
                    box-sizing: border-box;
                    color: #606266;
                    font-size: inherit;
                    height: 40px;
                    line-height: 40px;
                    outline: none;
                    padding: 0 15px;
                    transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
                }
              </style>
        </head>
        <body>
            <div>
                <input type="text" placeholder="請輸入賬號" class="account">
                <input type="password" placeholder="請輸入密碼" class="password">
                <button>登錄</button>
            </div>
            <script data-original="https://cdn.bootcdn.net/ajax/libs/axios/0.21.0/axios.min.js"></script>
            <script>
                document.querySelector('button').onclick = () => {
                    axios.post('/login', {
                        account: document.querySelector('.account').value,
                        password: document.querySelector('.password').value,
                    })
                    .then(res => {
                        if (res.data.code == 0) {
                            location.href = '/index.html'
                        } else {
                            alert(res.data.msg)
                        }
                    })
                }
            </script>
        </body>
        </html>
        <!-- index.html  -->
        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>index</title>
        </head>
        <body>
            Hello World!
        </body>
        </html>

        login.html 靜態頁

        index.html 靜態頁

        然后把測試文件內容改一下:

        describe('The Home Page', () => {
            it('login', () => {
                cy.visit('http://localhost:8080/login.html')
                // 輸入賬號密碼
                cy.get('.account').type('admin')
                cy.get('.password').type('admin')
        
                cy.get('button').click()  
                // 重定向到 /index
                cy.url().should('include', 'http://localhost:8080/index.html')
        
                // 斷言 index.html 頁面是包含 Hello World! 文本
                cy.get('body').should('contain', 'Hello World!')
            })
        })

        現在重新運行服務器 node server.js,再執行 npm run cypress,點擊右邊的 Run... 開始測試。

        測試結果正確。為了統一腳本的使用規范,最好將 node server.js 命令替換為 npm run start

        "scripts": {
            "test": "jest --coverage test/",
            "lint": "eslint --ext .js test/ src/",
            "start": "node server.js",
            "cypress": "cypress open"
         }

        小結

        本章所有的測試用例都可以在我的 github 上找到,建議把項目克隆下來,親自運行一遍。

        參考資料

        帶你入門前端工程 全文目錄:

        1. 技術選型:如何進行技術選型?
        2. 統一規范:如何制訂規范并利用工具保證規范被嚴格執行?
        3. 前端組件化:什么是模塊化、組件化?
        4. 測試:如何寫單元測試和 E2E(端到端) 測試?
        5. 構建工具:構建工具有哪些?都有哪些功能和優勢?
        6. 自動化部署:如何利用 Jenkins、Github Actions 自動化部署項目?
        7. 前端監控:講解前端監控原理及如何利用 sentry 對項目實行監控。
        8. 性能優化(一):如何檢測網站性能?有哪些實用的性能優化規則?
        9. 性能優化(二):如何檢測網站性能?有哪些實用的性能優化規則?
        10. 重構:為什么做重構?重構有哪些手法?
        11. 微服務:微服務是什么?如何搭建微服務項目?
        12. Severless:Severless 是什么?如何使用 Severless?
        查看原文

        贊 5 收藏 4 評論 0

        譚光志 發布了文章 · 1月26日

        帶你入門前端工程(三):前端組件化

        在了解模塊化、組件化之前,最好先了解一下什么是高內聚,低耦合。它能更好的幫助你理解模塊化、組件化。

        高內聚,低耦合

        高內聚,低耦合是軟件工程中的概念,它是判斷代碼好壞的一個重要指標。高內聚,就是指一個函數盡量只做一件事。低耦合,就是兩個模塊之間的關聯程度低。

        僅看文字可能不太好理解,下面來看一個簡單的示例。

        // math.js
        export function add(a, b) {
            return a + b
        }
        
        export function mul(a, b) {
            return a * b
        }
        // test.js
        import { add, mul } from 'math'
        add(1, 2)
        mul(1, 2)
        mul(add(1, 2), add(1, 2))

        上面的 math.js 就是高內聚,低耦合的典型示例。add()、mul() 一個函數只做一件事,它們之間也沒有直接聯系。如果要將這兩個函數聯系在一起,也只能通過傳參和返回值來實現。

        既然有好的示例,那就有壞的示例,下面再看一個不好的示例。

        // 母公司
        class Parent {
            getProfit(...subs) {
                let profit = 0
                subs.forEach(sub => {
                    profit += sub.revenue - sub.cost
                })
        
                return profit
            }
        }
        
        // 子公司
        class Sub {
            constructor(revenue, cost) {
                this.revenue = revenue
                this.cost = cost
            }
        }
        
        const p = new Parent()
        const s1 = new Sub(100, 10)
        const s2 = new Sub(200, 150)
        console.log(p.getProfit(s1, s2)) // 140

        上面的代碼是一個不太好的示例,因為母公司在計算利潤時,直接操作了子公司的數據。更好的做法是,子公司直接將利潤返回給母公司,然后母公司做一個匯總。

        class Parent {
            getProfit(...subs) {
                let profit = 0
                subs.forEach(sub => {
                    profit += sub.getProfit()
                })
        
                return profit
            }
        }
        
        class Sub {
            constructor(revenue, cost) {
                this.revenue = revenue
                this.cost = cost
            }
        
            getProfit() {
                return this.revenue - this.cost
            }
        }
        
        const p = new Parent()
        const s1 = new Sub(100, 10)
        const s2 = new Sub(200, 150)
        console.log(p.getProfit(s1, s2)) // 140

        這樣改就好多了,子公司增加了一個 getProfit() 方法,母公司在做匯總時直接調用這個方法。

        高內聚,低耦合在業務場景中的運用

        理想很美好,現實很殘酷。剛才的示例是高內聚、低耦合比較經典的例子。但在業務場景中寫代碼不可能做到這么完美,很多時候會出現一個函數要處理多個邏輯的情況。

        舉個例子,用戶注冊。一般注冊會在按鈕上綁定一個點擊事件回調函數 register(),用于處理注冊邏輯。

        function register(data) {
            // 1. 驗證用戶數據是否合法
            /**
            * 驗證賬號
            * 驗證密碼
            * 驗證短信驗證碼
            * 驗證身份證
            * 驗證郵箱
            */
            // 省略一大堆串 if 判斷語句...
        
            // 2. 如果用戶上傳了頭像,則將用戶頭像轉成 base64 碼保存
            /**
            * 新建 FileReader 對象
            * 將圖片轉換成 base64 碼
            */
            // 省略轉換代碼...
        
            // 3. 調用注冊接口
            // 省略注冊代碼...
        }

        這個示例屬于很常見的需求,點擊一個按鈕處理多個邏輯。從代碼中也可以發現,這樣寫的結果就是三個功能耦合在一起。

        按照高內聚、低耦合的要求,一個函數應該盡量只做一件事。所以我們可以將函數中的另外兩個功能:驗證和轉換單獨提取出來,封裝成一個函數。

        function register(data) {
            // 1. 驗證用戶數據是否合法
            verifyUserData()
        
            // 2. 如果用戶上傳了頭像,則將用戶頭像轉成 base64 碼保存
            toBase64()
        
            // 3. 調用注冊接口
            // 省略注冊代碼...
        }
        
        function verifyUserData() {
            /**
            * 驗證賬號
            * 驗證密碼
            * 驗證短信驗證碼
            * 驗證身份證
            * 驗證郵箱
            */
            // 省略一大堆串 if 判斷語句...
        }
        
        function toBase64() {
            /**
            * 新建 FileReader 對象
            * 將圖片轉換成 base64 碼
            */
            // 省略轉換代碼...
        }

        這樣修改以后,就比較符合高內聚、低耦合的要求了。以后即使要修改或移除、新增功能,也非常方便。

        模塊化、組件化

        模塊化

        模塊化,就是把一個個文件看成一個模塊,它們之間作用域相互隔離,互不干擾。一個模塊就是一個功能,它們可以被多次復用。另外,模塊化的設計也體現了分治的思想。什么是分治?維基百科的定義如下:

        字面上的解釋是“分而治之”,就是把一個復雜的問題分成兩個或更多的相同或相似的子問題,直到最后子問題可以簡單的直接求解,原問題的解即子問題的解的合并。

        從前端方面來看,單獨的 JavaScript 文件、CSS 文件都算是一個模塊。

        例如一個 math.js 文件,它就是一個數學模塊,包含了和數學運算相關的函數:

        // math.js
        export function add(a, b) {
            return a + b
        }
        
        export function mul(a, b) {
            return a * b
        }
        
        export function abs() { ... }
        ...

        一個 button.css 文件,包含了按鈕相關的樣式:

        /* 按鈕樣式 */
        button {
            ...
        }

        組件化

        那什么是組件化呢?我們可以認為組件就是頁面里的 UI 組件,一個頁面可以由很多組件構成。例如一個后臺管理系統頁面,可能包含了 Header、Sidebar、Main 等各種組件。

        一個組件又包含了 template(html)、script、style 三部分,其中 script、style 可以由一個或多個模塊組成。

        從上圖可以看到,一個頁面可以分解成一個個組件,每個組件又可以分解成一個個模塊,充分體現了分治的思想(如果忘了分治的定義,請回頭再看一遍)。

        由此可見,頁面成為了一個容器,組件是這個容器的基本元素。組件與組件之間可以自由切換、多次復用,修改頁面只需修改對應的組件即可,大大的提升了開發效率。

        最理想的情況就是一個頁面元素全部由組件構成,這樣前端只需要寫一些交互邏輯代碼。雖然這種情況很難完全實現,但我們要盡量往這個方向上去做,爭取實現全面組件化。

        Web Components

        得益于技術的發展,目前三大框架在構建工具(例如 webpack、vite...)的配合下都可以很好的實現組件化。例如 Vue,使用 *.vue 文件就可以把 template、script、style 寫在一起,一個 *.vue 文件就是一個組件。

        <template>
            <div>
                {{ msg }}
            </div>
        </template>
        
        <script>
        export default {
            data() {
                return {
                    msg: 'Hello World!'
                }
            }
        }
        </script>
        
        <style>
        body {
            font-size: 14px;
        }
        </style>

        如果不使用框架和構建工具,還能實現組件化嗎?

        答案是可以的,組件化是前端未來的發展方向,Web Components 就是瀏覽器原生支持的組件化標準。使用 Web Components API,瀏覽器可以在不引入第三方代碼的情況下實現組件化。

        實戰

        現在我們來創建一個 Web Components 按鈕組件,點擊它將會彈出一個消息 Hello World!。點擊這可以看到 DEMO 效果。

        Custom elements(自定義元素)

        瀏覽器提供了一個 customElements.define() 方法,允許我們定義一個自定義元素和它的行為,然后在頁面中使用。

        class CustomButton extends HTMLElement {
            constructor() {
                // 必須首先調用 super方法 
                super()
        
                // 元素的功能代碼寫在這里
                const templateContent = document.getElementById('custom-button').content
                const shadowRoot = this.attachShadow({ mode: 'open' })
        
                shadowRoot.appendChild(templateContent.cloneNode(true))
        
                shadowRoot.querySelector('button').onclick = () => {
                    alert('Hello World!')
                }
            }
        
            connectedCallback() {
                console.log('connected')
            }
        }
        
        customElements.define('custom-button', CustomButton)

        上面的代碼使用 customElements.define() 方法注冊了一個新的元素,并向其傳遞了元素的名稱 custom-button、指定元素功能的類 CustomButton。然后我們可以在頁面中這樣使用:

        <custom-button></custom-button>

        這個自定義元素繼承自 HTMLElement(HTMLElement 接口表示所有的 HTML 元素),表明這個自定義元素具有 HTML 元素的特性。

        使用 <template> 設置自定義元素內容

        <template id="custom-button">
            <button>自定義按鈕</button>
            <style>
                button {
                    display: inline-block;
                    line-height: 1;
                    white-space: nowrap;
                    cursor: pointer;
                    text-align: center;
                    box-sizing: border-box;
                    outline: none;
                    margin: 0;
                    transition: .1s;
                    font-weight: 500;
                    padding: 12px 20px;
                    font-size: 14px;
                    border-radius: 4px;
                    color: #fff;
                    background-color: #409eff;
                    border-color: #409eff;
                    border: 0;
                }
        
                button:active {
                    background: #3a8ee6;
                    border-color: #3a8ee6;
                    color: #fff;
                }
              </style>
        </template>

        從上面的代碼可以發現,我們為這個自定義元素設置了內容 <button>自定義按鈕</button> 以及樣式,樣式放在 <style> 標簽里??梢哉f <template> 其實就是一個 HTML 模板。

        Shadow DOM(影子DOM)

        設置了自定義元素的名稱、內容以及樣式,現在就差最后一步了:將內容、樣式掛載到自定義元素上。

        // 元素的功能代碼寫在這里
        const templateContent = document.getElementById('custom-button').content
        const shadowRoot = this.attachShadow({ mode: 'open' })
        
        shadowRoot.appendChild(templateContent.cloneNode(true))
        
        shadowRoot.querySelector('button').onclick = () => {
            alert('Hello World!')
        }

        元素的功能代碼中有一個 attachShadow() 方法,它的作用是將影子 DOM 掛到自定義元素上。DOM 我們知道是什么意思,就是指頁面元素。那“影子”是什么意思呢?“影子”的意思就是附加到自定義元素上的 DOM 功能是私有的,不會與頁面其他元素發生沖突。

        attachShadow() 方法還有一個參數 mode,它有兩個值:

        1. open 代表可以從外部訪問影子 DOM。
        2. closed 代表不可以從外部訪問影子 DOM。
        // open,返回 shadowRoot
        document.querySelector('custom-button').shadowRoot
        // closed,返回 null
        document.querySelector('custom-button').shadowRoot

        生命周期

        自定義元素有四個生命周期:

        1. connectedCallback: 當自定義元素第一次被連接到文檔 DOM 時被調用。
        2. disconnectedCallback: 當自定義元素與文檔 DOM 斷開連接時被調用。
        3. adoptedCallback: 當自定義元素被移動到新文檔時被調用。
        4. attributeChangedCallback: 當自定義元素的一個屬性被增加、移除或更改時被調用。

        生命周期在觸發時會自動調用對應的回調函數,例如本次示例中就設置了 connectedCallback() 鉤子。

        最后附上完整代碼:

        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="utf-8">
            <title>Web Components</title>
        </head>
        <body>
            <custom-button></custom-button>
        
            <template id="custom-button">
                <button>自定義按鈕</button>
                <style>
                    button {
                        display: inline-block;
                        line-height: 1;
                        white-space: nowrap;
                        cursor: pointer;
                        text-align: center;
                        box-sizing: border-box;
                        outline: none;
                        margin: 0;
                        transition: .1s;
                        font-weight: 500;
                        padding: 12px 20px;
                        font-size: 14px;
                        border-radius: 4px;
                        color: #fff;
                        background-color: #409eff;
                        border-color: #409eff;
                        border: 0;
                    }
        
                    button:active {
                        background: #3a8ee6;
                        border-color: #3a8ee6;
                        color: #fff;
                    }
                  </style>
            </template>
        
            <script>
                class CustomButton extends HTMLElement {
                    constructor() {
                        // 必須首先調用 super方法 
                        super()
        
                        // 元素的功能代碼寫在這里
                        const templateContent = document.getElementById('custom-button').content
                        const shadowRoot = this.attachShadow({ mode: 'open' })
        
                        shadowRoot.appendChild(templateContent.cloneNode(true))
        
                        shadowRoot.querySelector('button').onclick = () => {
                            alert('Hello World!')
                        }
                    }
        
                    connectedCallback() {
                        console.log('connected')
                    }
                }
        
                customElements.define('custom-button', CustomButton)
            </script>
        </body>
        </html>

        小結

        用過 Vue 的同學可能會發現,Web Components 標準和 Vue 非常像。我估計 Vue 在設計時有參考過 Web Components(個人猜想,未考證)。

        如果你想了解更多 Web Components 的信息,請參考 MDN 文檔。

        參考資料

        帶你入門前端工程 全文目錄:

        1. 技術選型:如何進行技術選型?
        2. 統一規范:如何制訂規范并利用工具保證規范被嚴格執行?
        3. 前端組件化:什么是模塊化、組件化?
        4. 測試:如何寫單元測試和 E2E(端到端) 測試?
        5. 構建工具:構建工具有哪些?都有哪些功能和優勢?
        6. 自動化部署:如何利用 Jenkins、Github Actions 自動化部署項目?
        7. 前端監控:講解前端監控原理及如何利用 sentry 對項目實行監控。
        8. 性能優化(一):如何檢測網站性能?有哪些實用的性能優化規則?
        9. 性能優化(二):如何檢測網站性能?有哪些實用的性能優化規則?
        10. 重構:為什么做重構?重構有哪些手法?
        11. 微服務:微服務是什么?如何搭建微服務項目?
        12. Severless:Severless 是什么?如何使用 Severless?
        查看原文

        贊 5 收藏 3 評論 0

        譚光志 發布了文章 · 1月25日

        帶你入門前端工程(二):統一規范

        代碼規范

        代碼規范是指程序員在編碼時要遵守的規則,規范的目的就是為了讓程序員編寫易于閱讀、可維護的代碼。

        試想一下,一個幾十萬行代碼的項目,存在幾種不同的代碼規范,閱讀起來是什么感受?連代碼縮進使用空格還是 Tab 都能引發不少程序員的爭論,可以說統一代碼規范是非常重要的事情。

        統一代碼規范除了剛才所說的兩點外,還有其他好處:

        • 規范的代碼可以促進團隊合作
        • 規范的代碼可以降低維護成本
        • 規范的代碼有助于 code review(代碼審查)
        • 養成代碼規范的習慣,有助于程序員自身的成長

        當團隊的成員都嚴格按照代碼規范來寫代碼時,可以保證每個人的代碼看起來都像是一個人寫的,看別人的代碼就像是在看自己的代碼(代碼一致性),閱讀起來更加順暢。更重要的是我們能夠認識到規范的重要性,并堅持規范的開發習慣。

        如何制訂代碼規范

        代碼規范一般包含了代碼格式規范、變量和函數命名規范、文檔注釋規范等等。

        代碼格式

        一般是指代碼縮進使用空格還是 Tab、每行結尾要不要加分號、左花括號需不需要換行等等。

        命名規范

        命名規范一般指命名是使用駝峰式、匈牙利式還是帕斯卡式;用名詞、名詞組或動賓結構來命名。

        const smallObject = {} // 駝峰式,首字母小寫
        const SmallObject = {} // 帕斯卡式,首字母大寫
        const strName = 'strName' // 匈牙利式,前綴表示了變量是什么。這個前綴 str 表示了是一個字符串

        變量命名和函數命名的側重點不同。

        變量命名的重點是表明這個變量“是什么”,傾向于用名詞命名。而函數命名的重點是表明這個函數“做什么”,傾向于用動賓結構來命名(動賓結構就是 doSomething)。

        // 變量命名示例
        const appleNum = 1
        const sum = 10
        
        // 函數命名示例
        function formatDate() { ... }
        function toArray() { ... }

        由于拼音同音字太多,千萬不要使用拼音來命名。

        文檔注釋

        文檔注釋比較簡單,例如單行注釋使用 //,多行注釋使用 /**/。

        /**
         * 
         * @param {number} a 
         * @param {number} b 
         * @return {number}
         */
        function add(a, b) {
            return a + b
        }
        
        // 單行注釋
        const active = true

        如果要讓團隊從頭開始制訂一份代碼規范,工作量會非常大,也不現實。所以強烈建議找一份比較好的開源代碼規范,在此基礎上結合團隊的需求作個性化修改。

        下面列舉一些比較出名的 JavaScript 代碼規范:

        CSS 代碼規范也有不少,例如:

        注釋規范

        有同學可能會聽過這樣一種說法:好的代碼是不需要寫注釋的。其實這種說法有點片面。

        如果你寫的函數類似于以下這種:

        function timestampToDate(timestamp = 0) {
            if (/\s/.test(timestamp)) {
                return timestamp
            }
        
            let date = new Date(timestamp)
            return date.toLocaleDateString().replace(/\//g, '-') + ' ' + date.toTimeString().split(' ')[0]
        }
        
        function objToUrlParam(obj = {}) {
            let param = ''
            for (let key in obj) {
                param += '&' + key + '=' + obj[key]
            }
            
            return param? '?' + param.substr(1) : ''
        }

        那不寫注釋很正常,代碼邏輯簡單,變量、函數命名完全契合代碼邏輯。

        但在工作中還有很多業務邏輯很復雜的需求,很有可能一個函數要寫很多代碼,再好的函數命名、變量命名也不一定能看懂代碼邏輯。并且有些業務邏輯會跨多個模塊,需要跟不同模塊的函數打交道。

        像這種復雜的代碼,還有繞來繞去的業務邏輯,如果不寫注釋,分分鐘變成傳說中的“屎山”。

        我們平時強調的代碼規范、項目規范、重構等等,不就是為了減少溝通,提高開發效率嗎。寫注釋的目的也是為了讓代碼更加容易理解,以后出問題了,也能快速定位問題,從而解決問題。

        所以我覺得這個說法應該這樣理解:不是不寫注釋,而是不寫垃圾注釋。

        什么是垃圾注釋?羅里吧嗦一大堆講不到重要的就是垃圾注釋,注釋應該著重描述“做了什么”而不是“怎么做”。

        function objToUrlParam(obj = {}) {
            let param = ''
            for (let key in obj) {
                param += '&' + key + '=' + obj[key]
            }
            
            return param? '?' + param.substr(1) : ''
        }

        例如上面這個函數,你可以這樣寫注釋:“將對象轉化為 URL 參數”。也可以這樣寫:“首先遍歷對象,獲取每一個鍵值對,將它們拼在一起,最后在前面補個問號,變成 URL 參數”。

        第一個注釋雖然描述做了什么,但對于這么簡單的函數來說是不用注釋的。第二個注釋是垃圾注釋的典型示例,描述了怎么做。

        下面再看一個辣眼睛的:

        public class Program  
        {  
            static void Main(string[] args)  
            {  
                /* 這個程序是用來在屏幕上  
                 * 循環打印1百萬次”I Rule!”  
                 * 每次輸出一行。循環計數  
                 * 從0開始,每次加1。  
                 * 當計數器等于1百萬時,  
                 * 循環就會停止運行*/  
         
                for (int i = 0; i < 1000000; i++)  
                {  
                    Console.WriteLine(“I Rule!”);  
                }  
            }  
        }

        總的來說,注釋是必要的,并且要寫好注釋,著重描述代碼做了什么。如果還有人說不寫注釋,讓他看看 linux 項目去,每一個文件都有注釋。

        如何檢查代碼規范

        規范制訂下來了,那怎么確保它被嚴格執行呢?目前有兩個方法:

        1. 使用工具校驗代碼格式。
        2. 利用 code review 審查變量命名、注釋。

        建議使用這兩個方法雙管齊下,確保代碼規范被嚴格執行。

        下面讓我們來看一下,如何使用工具來校驗代碼格式。

        ESLint

        ESLint最初是由Nicholas C. Zakas 于2013年6月創建的開源項目。它的目標是提供一個插件化的javascript代碼檢測工具。
        1. 下載依賴
        // eslint-config-airbnb-base 使用 airbnb 代碼規范
        npm i -D babel-eslint eslint eslint-config-airbnb-base eslint-plugin-import
        1. 配置 .eslintrc 文件
        {
            "parserOptions": {
                "ecmaVersion": 2019
            },
            "env": {
                "es6": true,
            },
            "parser": "babel-eslint",
            "extends": "airbnb-base",
        }
        1. package.jsonscripts 加上這行代碼 "lint": "eslint --ext .js test/ src/"。然后執行 npm run lint 即可開始驗證代碼。代碼中的 test/ src/ 是要進行校驗的代碼目錄,這里指明了要檢查 test、src 目錄下的代碼。

        不過這樣檢查代碼效率太低,每次都得手動檢查。并且報錯了還得手動修改代碼。

        為了改善以上缺點,我們可以使用 VSCode。使用它并加上適當的配置可以在每次保存代碼的時候,自動驗證代碼并進行格式化,省去了動手的麻煩(下一節講如何使用 VSCode 自動格式化代碼)。

        stylelint

        stylelint 是一個開源的、用于檢查 CSS 代碼格式的開源工具。具體如何使用請看下一節。

        使用 VSCode 自動格式化代碼

        格式化 JavaScript 代碼

        安裝 VSCode,然后安裝插件 ESLint。

        選擇 File -> Preference-> Settings(如果裝了中文插件包應該是 文件 -> 選項 -> 設置),搜索 eslint,點擊 Edit in setting.json。

        在這里插入圖片描述

        將以下選項添加到配置文件

            "editor.codeActionsOnSave": {
                "source.fixAll": true,
            },

        配置完之后,VSCode 會根據你當前項目下的 .eslintrc 文件的規則來驗證和格式化代碼。

        TypeScript

        下載插件

        npm install --save-dev typescript @typescript-eslint/parser @typescript-eslint/eslint-plugin

        .eslintrc 配置文件,添加以下兩個配置項:

        module.exports = {
            parser: '@typescript-eslint/parser',
            plugins: ['@typescript-eslint'],
        }

        在根目錄下的 package.json 文件的 scripts 選項里添加以下配置項:

        "scripts": {
          "lint": "eslint --ext .js,.ts,.tsx test/ src/",
        },

        test/src/ 是你要校驗的目錄。修改完后,現在 ts 文件也可以自動格式化了。

        擴展

        如何格式化 HTML、Vue(或其他后綴) 文件中的 HTML 和 CSS?

        這需要利用 VSCode 自帶的格式化,快捷鍵是 shift + alt + f。假設當前 VSCode 打開的是一個 Vue 文件,按下 shift + alt + f 會提示你選擇一種格式化規范。如果沒提示,那就是已經有默認的格式化規范了(一般是 vetur 插件),然后 Vue 文件的所有代碼都會格式化,并且格式化規則還可以自己配置。

        具體規則如下圖所示,可以根據自己的喜好來選擇格式化規則。

        在這里插入圖片描述

        因為之前已經設置過 ESlint 的格式化規則了,所以 Vue 文件只需要格式化 HTML 和 CSS 中的代碼,不需要格式化 JavaScript 代碼,所以我們需要禁止 vetur 格式化 JavaScript 代碼:

        在這里插入圖片描述

        根據上圖配置完成后,回到剛才的 Vue 文件。隨意打亂代碼的格式,再按下 shift + alt + f ,會發現 HTML 和 CSS 中的代碼已經格式化了,但是 JavaScript 的代碼并沒格式化。沒關系,因為已經設置了 ESlint 格式化,所以只要執行保存操作,JavaScript 的代碼也會自動格式化。

        同理,其他類型的文件也可以這樣設置格式化規范。

        格式化 CSS 代碼

        下載依賴

        npm install --save-dev stylelint stylelint-config-standard

        在項目根目錄下新建一個 .stylelintrc.json 文件,并輸入以下內容:

        {
            "extends": "stylelint-config-standard"
        }

        VSCode 添加 stylelint 插件:

        在這里插入圖片描述

        然后就可以看到效果了。

        在這里插入圖片描述

        如果你想修改插件的默認規則,可以看官方文檔,它提供了 170 項規則修改。例如我想要用 4 個空格作為縮進,可以這樣配置:

        {
            "extends": "stylelint-config-standard",
            "rules": {
                "indentation": 4
            }
        }

        Code Review 代碼審查

        代碼審查是指讓其他人來審查自己代碼的一種行為。審查有多種方式:例如結對編程(一個人寫,一個人看)或者統一某個時間點大家互相做審查(單人或多人)。

        代碼審查的目的是為了檢查代碼是否符合代碼規范以及是否有錯誤,另外也能讓評審人了解被審人所寫的功能。經?;ハ鄬彶?,能讓大家都能更清晰地了解整個項目的功能,這樣就不會因為某個核心開發人員離職了而引起項目延期。

        當然,代碼審查也是有缺點的:一是代碼審查非常耗時,二是有可能引發團隊成員爭吵。據我了解,目前國內很多開發團隊都沒有代碼審查,包括很多大廠。

        個人建議在找工作時,可以詢問一下對方團隊是否有測試規范、測試流程、代碼審查等。如果同時擁有以上幾點,說明是一個靠譜的團隊,可以優先選擇。

        git 規范

        git 規范一般包括兩點:分支管理規范和 git commit 規范。

        分支管理規范

        一般項目分主分支(master)和其他分支。

        當有團隊成員要開發新功能或改 BUG 時,就從 master 分支開一個新的分支。例如項目要從客戶端渲染改成服務端渲染,就開一個分支叫 SSR,開發完了再合并回 master 分支。

        如果要改一個重大的 BUG,也可以從 master 分支開一個新分支,并用 BUG 號命名。

        # 新建分支并切換到新分支
        git checkout -b test
        # 切換回主分支,合并新分支
        git checkout master
        git merge test

        注意,在將一個新分支合并回 master 分支時,如果新分支中有一些意義不明確的 commit,建議先對它們進行合并(使用 git rebase)。合并后,再將新分支合并回 master 分支。

        git commit 規范

        git 在每次提交時,都需要填寫 commit message。

        git commit -m 'this is a test'

        commit message 就是對你這次的代碼提交進行一個簡單的說明,好的提交說明可以讓人一眼就明白這次代碼提交做了什么。

        既然明白了 commit message 的重要性,那我們就更要好好的學習一下 commit message 規范。下面讓我們看一下 commit message 的格式:

        <type>(<scope>): <subject>
        <BLANK LINE>
        <body>
        <BLANK LINE>
        <footer>

        我們可以發現,commit message 分為三個部分(使用空行分割):

        1. 標題行(subject): 必填, 描述主要修改類型和內容。
        2. 主題內容(body): 描述為什么修改, 做了什么樣的修改, 以及開發的思路等等。
        3. 頁腳注釋(footer): 可以寫注釋,放 BUG 號的鏈接。

        type

        commit 的類型:

        • feat: 新功能、新特性
        • fix: 修改 bug
        • perf: 更改代碼,以提高性能
        • refactor: 代碼重構(重構,在不影響代碼內部行為、功能下的代碼修改)
        • docs: 文檔修改
        • style: 代碼格式修改, 注意不是 css 修改(例如分號修改)
        • test: 測試用例新增、修改
        • build: 影響項目構建或依賴項修改
        • revert: 恢復上一次提交
        • ci: 持續集成相關文件修改
        • chore: 其他修改(不在上述類型中的修改)
        • release: 發布新版本
        • workflow: 工作流相關文件修改

        scope

        commit message 影響的功能或文件范圍, 比如: route, component, utils, build...

        subject

        commit message 的概述

        body

        具體修改內容, 可以分為多行.

        footer

        一些備注, 通常是 BREAKING CHANGE 或修復的 bug 的鏈接.

        示例

        fix(修復BUG)

        每次 git commit 最好加上范圍描述。

        例如這次 BUG 修復影響到全局,可以加個 global。如果影響的是某個目錄或某個功能,可以加上該目錄的路徑,或者對應的功能名稱。

        // 示例1
        fix(global):修復checkbox不能復選的問題
        // 示例2 下面圓括號里的 common 為通用管理的名稱
        fix(common): 修復字體過小的BUG,將通用管理下所有頁面的默認字體大小修改為 14px
        // 示例3
        fix(test): value.length -> values.length
        feat(添加新功能或新頁面)
        feat: 添加網站主頁靜態頁面
        
        這是一個示例,假設對任務靜態頁面進行了一些描述。
         
        這里是備注,可以是放 BUG 鏈接或者一些重要性的東西。
        chore(其他修改)

        chore 的中文翻譯為日常事務、例行工作。顧名思義,即不在其他 commit 類型中的修改,都可以用 chore 表示。

        chore: 將表格中的查看詳情改為詳情

        其他類型的 commit 和上面三個示例差不多,在此不再贅述。

        驗證 git commit 規范

        利用 git hook 能在特定的重要動作發生時觸發自定義腳本。

        驗證 git commit 規范也不例外,我們需要通過 git 的 pre-commit 鉤子函數來進行。當然,你還需要下載一個輔助插件 husky 來幫助你進行驗證。

        pre-commit 鉤子在鍵入提交信息前運行,它用于檢查即將提交的快照。

        husky 是一個開源的工具,使用它我們可以在 package.json 里配置 git hook 腳本。下面讓我們看一下如何使用:

        下載

        npm i -D husky

        package.json 加上下面的代碼:

        "husky": {
          "hooks": {
            "pre-commit": "npm run lint",
            "commit-msg": "node script/verify-commit.js",
            "pre-push": "npm test"
          }
        }

        然后在你項目根目錄下新建一個文件夾 script,并在下面新建一個文件 verify-commit.js,輸入以下代碼:

        const msgPath = process.env.HUSKY_GIT_PARAMS
        const msg = require('fs')
        .readFileSync(msgPath, 'utf-8')
        .trim()
        
        // 提前定義好 commit message 的格式,如果不符合格式就退出程序。
        const commitRE = /^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)(\(.+\))?: .{1,50}/
        
        if (!commitRE.test(msg)) {
            console.error(`
                不合法的 commit 消息格式。
                請查看 git commit 提交規范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md
            `)
        
            process.exit(1)
        }

        現在來解釋下各個鉤子的含義:

        1. "pre-commit": "npm run lint",在 git commit 前執行 npm run lint 檢查代碼格式。
        2. "commit-msg": "node script/verify-commit.js",在 git commit 時執行腳本 verify-commit.js 驗證 commit 消息。如果不符合腳本中定義的格式,將會報錯。
        3. "pre-push": "npm test",在你執行 git push 將代碼推送到遠程倉庫前,執行 npm test 進行測試。如果測試失敗,將不會執行這次推送。

        通過工具,我們可以很好的管理團隊成員的 git commit 格式,無需使用人力來檢查,大大提高了開發效率。

        另外,我提供了一個簡單的工程化 DEMO。它包含了自動格式化代碼和 git 驗證,如果看完文章還是不知道如何配置,可以參考一下。

        項目規范

        項目規范主要是指項目文件的組織方式和命名方式。統一項目規范是為了方便管理與修改,不會出現同樣性質的文件出現在不同的地方。例如同樣是圖片,一個出現在 assets 目錄,一個出現在 img 目錄。

        創建目錄,需要按照用途來劃分。例如較常見的目錄有:文檔 doc、資源 src、測試 test...

        ├─doc
        ├─src
        ├─test

        src 資源目錄又可以細分:

        ├─api
        ├─asset
        ├─component
        ├─style
        ├─router
        ├─store
        ├─util
        └─view

        現在文件命名有很多種方式(是否簡寫 imgimage、是否復數 imgimgs、文件名過長是用駝峰還是用-連接 oneTwoone-two)。其實用哪種方式不重要,最重要的是命名方式一定要統一。

        例如團隊成員有人命名目錄喜歡用復數形式(apis),有人喜歡用單數(api),這樣是不允許的,一定要統一。

        UI 規范

        注意,這里的 UI 規范是指項目里常用 UI 組件的表現方式以及組件的命名方式,而不是指 UI 組件如何設計。

        表現方式

        現在開源的 UI 組件庫有很多,不同的組件庫的組件表現方式也不一樣。例如有些按鈕組件點擊時顏色變深,有些組件則是變淺。所以建議在 PC 端和移動端都使用統一的 UI 組件庫(PC 端、移動端各一個),或者同一個項目里只使用一個 UI 組件庫。

        另外,項目里常用的組件表現方式也需要通過文檔確定下來。例如收縮展開的動畫效果,具體到動畫持續時間、動畫是緩進快出還是快進緩出等等。

        如果不把這些表現方式的規范確定下來,就有可能出現以下這種情況:

        1. 同樣的組件,在不同的頁面有不同的表現方式(例如動畫效果)。因為沒有規范,開發根據個人喜好添加表現效果。
        2. 同樣的二次確認彈窗,提示語不一樣,按鈕類型也不一樣。

        統一命名

        統一命名,也是為了減少溝通成本。

        舉個例子,現在的日期組件可以選單個日期、也可以選擇范圍日期,有的還可以選擇時間。這樣一來,一個日期組件就有四種情況:

        1. 單個日期帶時間
        2. 單個日期不帶時間
        3. 日期范圍帶時間
        4. 日期范圍不帶時間

        如果這種情況不區分好,開發在看產品文檔的時候就會疑惑,從而增加了開發與產品的溝通成本。

        綜上所述,我們可以發現制定 UI 規范的好處有兩點:

        1. 統一頁面 UI 標準,節省 UI 設計時間。
        2. 減少溝通成本,提高前端開發效率。

        小結

        其實統一規范的最根本目的就是為了保證團隊成員的一致性,從而減少溝通成本,提高開發效率。我以前就經歷過因為規范不標準,造成產品與開發理解有偏差、開發各寫各的代碼,導致各種 BUG 不斷,最后項目延期的事。

        所以說為了提高開發效率,減少加班,請一定要統一規范。

        參考資料

        帶你入門前端工程 全文目錄:

        1. 技術選型:如何進行技術選型?
        2. 統一規范:如何制訂規范并利用工具保證規范被嚴格執行?
        3. 前端組件化:什么是模塊化、組件化?
        4. 測試:如何寫單元測試和 E2E(端到端) 測試?
        5. 構建工具:構建工具有哪些?都有哪些功能和優勢?
        6. 自動化部署:如何利用 Jenkins、Github Actions 自動化部署項目?
        7. 前端監控:講解前端監控原理及如何利用 sentry 對項目實行監控。
        8. 性能優化(一):如何檢測網站性能?有哪些實用的性能優化規則?
        9. 性能優化(二):如何檢測網站性能?有哪些實用的性能優化規則?
        10. 重構:為什么做重構?重構有哪些手法?
        11. 微服務:微服務是什么?如何搭建微服務項目?
        12. Severless:Severless 是什么?如何使用 Severless?
        查看原文

        贊 11 收藏 10 評論 1

        譚光志 發布了文章 · 1月25日

        帶你入門前端工程(一):技術選型

        技術選型應該對很多程序員都不陌生了,無論是大到技術框架、編程語言,還是小到工具庫的選擇,都屬于技術選型的范圍。個人認為技術選型應該按照以下四個指標進行選擇:

        1. 可控性
        2. 穩定性
        3. 適用性
        4. 易用性

        由于沒有統一的叫法,所以以上四個指標的名稱是我自己定的。下面就讓我們一起來深入了解一下如何進行技術選型吧。

        可控性

        可控性是技術選型中非常重要的一個指標??煽?,就是指如果這門技術因為 BUG 對項目造成了影響,團隊中有人能夠解決它,而不是等待官方修復。作為技術團隊的負責人,一定要是能夠兜底的那個人。如果團隊解決不了,你必須能夠解決。

        例如一些公司內部獨有的由于“個性化”需求產生的各種魔改版 Vue、React,就完美體現了可控性。

        穩定性

        穩定性,表示一門技術更新迭代比較穩定,不會有特別大的修改,比較靠譜。即使有,也很容易做到向后兼容(遷移簡單、成本?。?。

        做為一名程序員,我想大家都有過這種想法。希望自己在做項目時能用上最新、最熱門的技術,這樣就可以一邊工作一邊學習了??墒抢硐牒苊篮?,現實卻是骨感的。新技術往往意味著不確定性,很有可能一步一坑。所以不建議在核心項目中使用新技術。

        使用成熟穩定的技術,意味著你的項目比較安全。在這一點上有兩個很典型的反例,那就是 Angular 和 python。例如 python2 升級到 python3,除了語法、API 不兼容之外,python3 的各個版本之間也有差異,直到現在才逐漸穩定下來。

        從穩定性上來看,該如何進行技術造型呢?可以根據以下四點來進行選擇:

        1. 社區是否活躍、配套插件是否豐富。
        2. 是否經常維護,可以通過 git commit 查看。
        3. 官方文檔是否齊全。
        4. 更新是穩定、小步的迭代,而不是非常激進的更新。

        剛才說到不建議在核心項目中使用新技術,但為了團隊成員自身的發展抑或為了其他原因,是可以嘗試一下新技術的。但一定要在邊緣項目或者小項目上進行嘗試,嘗試完如果發現這門新技術非常適合你們的項目,那就可以進一步考慮是否在核心項目中使用了。

        適用性

        適用性,是指需要根據業務場景和團隊成員來選擇技術。

        業務場景

        生命周期

        從項目的生命周期來看,并不是所有的項目都需要做到滴水不漏的。例如節假日特定的活動頁面,生命周期只有一兩天。這種頁面就算用 JQuery 寫也是可以的,唯一的要求就是快。

        與之相反的是,公司需要長期維護的核心項目。它們需要使用成熟穩定的技術棧,在開發語言上也要使用 TypeScript 而不是 JavaScript。

        兼容性

        由于項目必須在各種各樣的設備上運行,所以兼容性也是一個需要考慮的點。

        web 項目需要考慮不同瀏覽器的兼容性,app 需要考慮 IOS 和 Android 的兼容性。除了必須保證不能有死機、白屏、卡頓等明顯 BUG 外,樣式也需要盡量保持一致。

        團隊成員

        團隊成員不一定所有人都使用相同的技術棧,在這一點上需要權衡大家的長短處進行選擇。

        但我建議盡量將團隊成員的權重放到比較低的位置,選擇約束性比較強的技術是一個更好的選擇(如果團隊成員不會,就讓他學)。要用長遠的眼光來為團隊考慮,太過自由的技術,往往會造成災難。例如使用 TypeScript 已經被很多大公司和開源項目證明過是一個更好的選擇。

        易用性

        易用性,顧名思義就是這門技術好不好上手,容不容易理解。如果兩門技術各方面指標及應用場景差不多,易用性強的將成為贏家。

        最典型的例子就是 Angular 和 Vue。Angular 學習曲線陡峭,需要比較長的學習時間;而 Vue 在熟練掌握 JavaScript 的情況下,看文檔一兩天就能上手干活。

        從 Google、百度的趨勢圖就能看出來,它們在國內的受歡迎程度有相當大的差別。

        易用性,決定了你的團隊好不好招人,這一點對小公司和不在一線城市的公司來說非常重要。我在天津(二線城市)工作快 4 年了,招前端的基本上每個公司都要求會 Vue,而要求會 Angular 的基本沒有。

        小結

        如果同時綜合以上 4 點來考慮,該如何做技術選型呢?我建議按照以下順序來做選擇:

        1. 必須可控。
        2. 核心項目必須使用成熟穩定、可靠的技術棧,邊緣小項目可以使用新技術給團隊成員練手或者踩坑。
        3. 在第 2 點的基礎上,再按適用性做選擇。
        4. 在第 3 點的基礎上,再按易用性做選擇。

        為了幫助大家理解,我畫了一個流程圖:

        雖然說前端目前只有三大框架可以選擇,但技術永遠是在發展的,框架也是在不斷的更新迭代。學會如何進行技術選型,則不管當下流行的是什么技術,都可以減少你在進行技術選型時可能會犯的失誤。

        參考資料

        帶你入門前端工程 全文目錄:

        1. 技術選型:如何進行技術選型?
        2. 統一規范:如何制訂規范并利用工具保證規范被嚴格執行?
        3. 前端組件化:什么是模塊化、組件化?
        4. 測試:如何寫單元測試和 E2E(端到端) 測試?
        5. 構建工具:構建工具有哪些?都有哪些功能和優勢?
        6. 自動化部署:如何利用 Jenkins、Github Actions 自動化部署項目?
        7. 前端監控:講解前端監控原理及如何利用 sentry 對項目實行監控。
        8. 性能優化(一):如何檢測網站性能?有哪些實用的性能優化規則?
        9. 性能優化(二):如何檢測網站性能?有哪些實用的性能優化規則?
        10. 重構:為什么做重構?重構有哪些手法?
        11. 微服務:微服務是什么?如何搭建微服務項目?
        12. Severless:Severless 是什么?如何使用 Severless?
        查看原文

        贊 7 收藏 6 評論 0

        譚光志 發布了文章 · 1月22日

        《帶你入門前端工程》開源了

        這是一本關于前端工程化的小書(4W 字左右 )。項目地址:

        https://github.com/woai3c/int...

        前端工程化,其實是軟件工程在前端方面的應用。什么是軟件工程?來看一下百度百科的定義:

        軟件工程是一門研究用工程化方法構建和維護有效的、實用的和高質量的軟件的學科

        換句話說,工程化的目的就是為了提升團隊的開發效率。例如大家所熟悉的構建打包、性能優化、自動化部署等知識,都屬于工程化的內容。

        我寫這本小書的原因,是想對過去兩年的工程化實踐經驗和學習心得做一個總結。希望能全面地、系統地對前端工程化知識做一個總結。

        小書大部分的內容都是以理論知識 + 代碼示例 + 圖片的方式來講解的,努力爭取讓讀者更容易理解。另外還有小部分的章節在講解完理論知識后,還有相應的實踐教程。例如前端監控這一節,在講解完前端監控原理后,將會教你如何利用現有的監控工具對項目實行監控。

        可能有人會問,什么時候開始做工程化?我認為在需求評審階段就可以做工程化了,根據需求選用適當的技術棧(技術選型),然后制定相關規范...

        在線訪問

        目錄

        1. 技術選型:如何進行技術選型?
        2. 統一規范:如何制訂規范并利用工具保證規范被嚴格執行?
        3. 前端組件化:什么是模塊化、組件化?
        4. 測試:如何寫單元測試和 E2E(端到端) 測試?
        5. 構建工具:構建工具有哪些?都有哪些功能和優勢?
        6. 自動化部署:如何利用 Jenkins、Github Actions 自動化部署項目?
        7. 前端監控:講解前端監控原理及如何利用 sentry 對項目實行監控。
        8. 性能優化(一):如何檢測網站性能?有哪些實用的性能優化規則?
        9. 性能優化(二):如何檢測網站性能?有哪些實用的性能優化規則?
        10. 重構:為什么做重構?重構有哪些手法?
        11. 微服務:微服務是什么?如何搭建微服務項目?
        12. Severless:Severless 是什么?如何使用 Severless?
        13. 參考資料

        微服務、Severless 按理說不屬于工程化的內容,但從提升開發效率的角度來看,也可以歸納到這一范圍。

        目錄的順序是以一個項目的生命周期來分配的:

        1. 接到新需求,進行需求評審后根據具體情況做技術選型。
        2. 開發前需要統一規范。
        3. 學會模塊化、組件化,對于寫代碼很有好處。
        4. 開發完,需要對代碼進行測試。
        5. 構建打包。
        6. 部署上線。
        7. 對項目進行監控,隨時發現問題。
        8. 根據項目運行情況決定是否要做性能優化。
        9. 項目越來越復雜,需要重構以提高可維護性。
        10. 項目越來越大,可以考慮是否用微服務對其進行拆分(或者使用 git submodule 和 monorepo 的方式管理項目)。
        11. 不想自己管理服務器或數據庫,可以考慮使用 Serverless。

        注意

        本書的定位是入門級教程,主要對前端能接觸到的工程知識做一個較全面的介紹。適合對前端工程化不了解或了解得不多的“菜鳥”同學。如果你是個“老鳥”,那本書可能不太適合你。

        另外,建議讀者在閱讀本書時,能夠配合書本的實踐部分去做實踐。如果讀者能夠嚴格按照指示去做實踐,在閱讀完本書后,不僅會收獲前端工程化的理論知識,還會獲得對應的實踐經驗。

        你會學到什么?

        • 對前端工程化有一個全面、清晰的了解
        • 為架構師之路打下扎實的基礎

        適宜人群

        • 想學習工程化的前端
        • 具備基礎的 HTML、CSS、JavaScript 知識

        License

        MIT

        查看原文

        贊 21 收藏 13 評論 2

        譚光志 贊了文章 · 1月18日

        前端也能學算法:由淺入深講解動態規劃

        動態規劃是一種常用的算法思想,很多朋友覺得不好理解,其實不然,如果掌握了他的核心思想,并且多多練習還是可以掌握的。下面我們由淺入深的來講講動態規劃。

        斐波拉契數列

        首先我們來看看斐波拉契數列,這是一個大家都很熟悉的數列:

        // f = [1, 1, 2, 3, 5, 8]
        f(1) = 1;
        f(2) = 1;
        f(n) = f(n-1) + f(n -2); // n > 2

        有了上面的公式,我們很容易寫出計算f(n)的遞歸代碼:

        function fibonacci_recursion(n) {
          if(n === 1 || n === 2) {
            return 1;
          }
          
          return fibonacci_recursion(n - 1) + fibonacci_recursion(n - 2);
        }
        
        const res = fibonacci_recursion(5);
        console.log(res);   // 5

        現在我們考慮一下上面的計算過程,計算f(5)的時候需要f(4)與f(3)的值,計算f(4)的時候需要f(3)與f(2)的值,這里f(3)就重復算了兩遍。在我們已知f(1)和f(2)的情況下,我們其實只需要計算f(3),f(4),f(5)三次計算就行了,但是從下圖可知,為了計算f(5),我們總共計算了8次其他值,里面f(3), f(2), f(1)都有多次重復計算。如果n不是5,而是一個更大的數,計算次數更是指數倍增長,這個遞歸算法的時間復雜度是$O(2^n)$。

        image-20200121174402790

        非遞歸的斐波拉契數列

        為了解決上面指數級的時間復雜度,我們不能用遞歸算法了,而要用一個普通的循環算法。應該怎么做呢?我們只需要加一個數組,里面記錄每一項的值就行了,為了讓數組與f(n)的下標相對應,我們給數組開頭位置填充一個0

        const res = [0, 1, 1];
        f(n) = res[n];

        我們需要做的就是給res數組填充值,然后返回第n項的值就行了:

        function fibonacci_no_recursion(n) {
          const res = [0, 1, 1];
          for(let i = 3; i <= n; i++){
            res[i] = res[i-1] + res[i-2];
          }
          
          return res[n];
        }
        
        const num = fibonacci_no_recursion(5);
        console.log(num);   // 5

        上面的方法就沒有重復計算的問題,因為我們把每次的結果都存到一個數組里面了,計算f(n)的時候只需要將f(n-1)和f(n-2)拿出來用就行了,因為是從小往大算,所以f(n-1)和f(n-2)的值之前就算好了。這個算法的時間復雜度是O(n),比$O(2^n)$好的多得多。這個算法其實就用到了動態規劃的思想。

        動態規劃

        動態規劃主要有如下兩個特點

        1. 最優子結構:一個規模為n的問題可以轉化為規模比他小的子問題來求解。換言之,f(n)可以通過一個比他規模小的遞推式來求解,在前面的斐波拉契數列這個遞推式就是f(n) = f(n-1) + f(n -2)。一般具有這種結構的問題也可以用遞歸求解,但是遞歸的復雜度太高。
        2. 子問題的重疊性:如果用遞歸求解,會有很多重復的子問題,動態規劃就是修剪了重復的計算來降低時間復雜度。但是因為需要存儲中間狀態,空間復雜度是增加了。

        其實動態規劃的難點是歸納出遞推式,在斐波拉契數列中,遞推式是已經給出的,但是更多情況遞推式是需要我們自己去歸納總結的。

        鋼條切割問題

        image-20200121181228767

        先看看暴力窮舉怎么做,以一個長度為5的鋼條為例:

        image-20200121182429181

        上圖紅色的位置表示可以下刀切割的位置,每個位置可以有切和不切兩種狀態,總共是$2^4 = 16$種,對于長度為n的鋼條,這個情況就是$2^{n-1}$種。窮舉的方法就不寫代碼了,下面直接來看遞歸的方法:

        遞歸方案

        還是以上面那個長度為5的鋼條為例,假如我們只考慮切一刀的情況,這一刀的位置可以是1,2,3,4中的任意位置,那切割之后,左右兩邊的長度分別是:

        // [left, right]: 表示切了后左邊,右邊的長度
        [1, 4]: 切1的位置
        [2, 3]: 切2的位置
        [3, 2]: 切3的位置
        [4, 1]: 切4的位置

        分成了左右兩部分,那左右兩部分又可以繼續切,每部分切一刀,又變成了兩部分,又可以繼續切。這不就將一個長度為5的問題,分解成了4個小問題嗎,那最優的方案就是這四個小問題里面最大的那個值,同時不要忘了我們也可以一刀都不切,這是第五個小問題,我們要的答案其實就是這5個小問題里面的最大值。寫成公式就是,對于長度為n的鋼條,最佳收益公式是:

        image-20200122135927576

        • $r_n$ : 表示我們求解的目標,長度為n的鋼條的最大收益
        • $p_n$: 表示鋼條完全不切的情況
        • $r_1 + r_{n-1}$: 表示切在1的位置,分為了左邊為1,右邊為n-1長度的兩端,他們的和是這種方案的最優收益
        • 我們的最大收益就是不切和切在不同情況的子方案里面找最大值

        上面的公式已經可以用遞歸求解了:

        const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下標表示鋼條長度,值表示對應價格
        
        function cut_rod(n) {
          if(n === 1) return 1;
          
          let max = p[n];
          for(let i = 1; i < n; i++){
            let sum = cut_rod(i) + cut_rod(n - i);
            if(sum > max) {
              max = sum;
            }
          }
          
          return max;
        }
        
        cut_rod(9);  // 返回 25

        上面的公式還可以簡化,假如我們長度9的最佳方案是切成2 3 2 2,用前面一種算法,第一刀將它切成2 75 4,然后兩邊再分別切最終都可以得到2 3 2 2,所以5 4方案最終結果和2 7方案是一樣的,都會得到2 3 2 2,如果這兩種方案,兩邊都繼續切,其實還會有重復計算。那長度為9的切第一刀,左邊的值肯定是1 -- 9,我們從1依次切過來,如果后面繼續對左邊的切割,那繼續切割的那個左邊值必定是我們前面算過的一個左邊值。比如5 4切割成2 3 4,其實等價于第一次切成2 7,第一次如果是3 6,如果繼續切左邊,切為1 2 6,其實等價于1 8,都是前面切左邊為1的時候算過的。所以如果我們左邊依次是從1切過來的,那么就沒有必要再切左邊了,只需要切右邊。所以我們的公式可以簡化為:

        $$ r_n = \max_{1<=i<=n}(pi+r_{n-i}) $$

        繼續用遞歸實現這個公式:

        const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下標表示鋼條長度,值表示對應價格
        
        function cut_rod2(n) {
          if(n === 1) return 1;
          
          let max = p[n];
          for(let i = 1; i <= n; i++){
            let sum = p[i] + cut_rod2(n - i);
            if(sum > max) {
              max = sum;
            }
          }
          
          return max;
        }
        
        cut_rod2(9);  // 結果還是返回 25

        上面的兩個公式都是遞歸,復雜度都是指數級的,下面我們來講講動態規劃的方案。

        動態規劃方案

        動態規劃方案的公式和前面的是一樣的,我們用第二個簡化了的公式:

        $$ r_n = \max_{1<=i<=n}(pi+r_{n-i}) $$

        動態規劃就是不用遞歸,而是從底向上計算值,每次計算上面的值的時候,下面的值算好了,直接拿來用就行。所以我們需要一個數組來記錄每個長度對應的最大收益。

        const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下標表示鋼條長度,值表示對應價格
        
        function cut_rod3(n) {
          let r = [0, 1];   // r數組記錄每個長度的最大收益
          
          for(let i = 2; i <=n; i++) {
            let max = p[i];
            for(let j = 1; j <= i; j++) {
              let sum = p[j] + r[i - j];
              
              if(sum > max) {
                max = sum;
              }
            }
            
            r[i] = max;
          }
          
          console.log(r);
          return r[n];
        }
        
        cut_rod3(9);  // 結果還是返回 25

        我們還可以把r數組也打出來看下,這里面存的是每個長度對應的最大收益:

        r = [0, 1, 5, 8, 10, 13, 17, 18, 22, 25]

        使用動態規劃將遞歸的指數級復雜度降到了雙重循環,即$O(n^2)$的復雜度。

        輸出最佳方案

        上面的動態規劃雖然計算出來最大值,但是我們并不是知道這個最大值對應的切割方案是什么,為了知道這個方案,我們還需要一個數組來記錄切割一次時左邊的長度,然后在這個數組中回溯來找出切割方案?;厮莸臅r候我們先取目標值對應的左邊長度,然后右邊剩下的長度又繼續去這個數組找最優方案對應的左邊切割長度。假設我們左邊記錄的數組是:

        leftLength = [0, 1, 2, 3, 2, 2, 6, 1, 2, 3]

        我們要求長度為9的鋼條的最佳切割方案:

        1. 找到leftLength[9], 發現值為3,記錄下3為一次切割
        2. 左邊切了3之后,右邊還剩6,又去找leftLength[6],發現值為6,記錄下6為一次切割長度
        3. 又切了6之后,發現還剩0,切完了,結束循環;如果還剩有鋼條繼續按照這個方式切
        4. 輸出最佳長度為[3, 6]

        改造代碼如下:

        const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下標表示鋼條長度,值表示對應價格
        
        function cut_rod3(n) {
          let r = [0, 1];   // r數組記錄每個長度的最大收益
          let leftLength = [0, 1];  // 數組leftLength記錄切割一次時左邊的長度
          let solution = [];
          
          for(let i = 2; i <=n; i++) {
            let max = p[i];
            leftLength[i] = i;     // 初始化左邊為整塊不切
            for(let j = 1; j <= i; j++) {
              let sum = p[j] + r[i - j];
              
              if(sum > max) {
                max = sum;
                leftLength[i] = j;  // 每次找到大的值,記錄左邊的長度
              } 
            }
            
            r[i] = max;
          }
          
          // 回溯尋找最佳方案
          let tempN = n;
          while(tempN > 0) {
            let left = leftLength[tempN];
            solution.push(left);
            tempN = tempN - left;
          }
          
          console.log(leftLength);  // [0, 1, 2, 3, 2, 2, 6, 1, 2, 3]
          console.log(solution);    // [3, 6]
          console.log(r);           // [0, 1, 5, 8, 10, 13, 17, 18, 22, 25]
          return {max: r[n], solution: solution};
        }
        
        cut_rod3(9);  // {max: 25, solution: [3, 6]}

        最長公共子序列(LCS)

        image-20200202214347127

        上敘問題也可以用暴力窮舉來求解,先列舉出X字符串所有的子串,假設他的長度為m,則總共有$2^m$種情況,因為對于X字符串中的每個字符都有留著和不留兩種狀態,m個字符的全排列種類就是$2^m$種。那對應的Y字符串就有$2^n$種子串, n為Y的長度。然后再遍歷找出最長的公共子序列,這個復雜度非常高,我這里就不寫了。

        我們觀察兩個字符串,如果他們最后一個字符相同,則他們的LCS(最長公共子序列簡寫)就是兩個字符串都去掉最后一個字符的LCS再加一。因為最后一個字符相同,所以最后一個字符是他們的子序列,把他去掉,子序列就少了一個,所以他們的LCS是他們去掉最后一個字符的字符串的LCS再加一。如果他們最后一個字符不相同,那他們的LCS就是X去掉最后一個字符與Y的LCS,或者是X與Y去掉最后一個字符的LCS,是他們兩個中較長的那一個。寫成數學公式就是:

        image-20200202220405084

        看著這個公式,一個規模為(i, j)的問題轉化為了規模為(i-1, j-1)的問題,這不就又可以用遞歸求解了嗎?

        遞歸方案

        公式都有了,不廢話,直接寫代碼:

        function lcs(str1, str2) {
          let length1 = str1.length;
          let length2 = str2.length;
          
          if(length1 === 0 || length2 === 0) {
            return 0;
          }
          
          let shortStr1 = str1.slice(0, -1);
          let shortStr2 = str2.slice(0, -1);
          if(str1[length1 - 1] === str2[length2 -  1]){
            return lcs(shortStr1, shortStr2) + 1;
          } else {
            let lcsShort2 = lcs(str1, shortStr2);
            let lcsShort1 = lcs(shortStr1, str2);
            
            return lcsShort1 > lcsShort2 ? lcsShort1 : lcsShort2;
          }
        }
        
        let result = lcs('ABBCBDE', 'DBBCD');
        console.log(result);   // 4

        動態規劃

        遞歸雖然能實現我們的需求,但是復雜度是在太高,長一點的字符串需要的時間是指數級增長的。我們還是要用動態規劃來求解,根據我們前面講的動態規劃原理,我們需要從小的往大的算,每算出一個值都要記下來。因為c(i, j)里面有兩個變量,我們需要一個二維數組才能存下來。注意這個二維數組的行數是X的長度加一,列數是Y的長度加一,因為第一行和第一列表示X或者Y為空串的情況。代碼如下:

        function lcs2(str1, str2) {
          let length1 = str1.length;
          let length2 = str2.length;
          
          // 構建一個二維數組
          // i表示行號,對應length1 + 1
          // j表示列號, 對應length2 + 1
          // 第一行和第一列全部為0
          let result = [];
          for(let i = 0; i < length1 + 1; i++){
            result.push([]); //初始化每行為空數組
            for(let j = 0; j < length2 + 1; j++){
              if(i === 0) {
                result[i][j] = 0; // 第一行全部為0
              } else if(j === 0) {
                result[i][j] = 0; // 第一列全部為0
              } else if(str1[i - 1] === str2[j - 1]){
                // 最后一個字符相同
                result[i][j] = result[i - 1][j - 1] + 1;
              } else{
                // 最后一個字符不同
                result[i][j] = result[i][j - 1] > result[i - 1][j] ? result[i][j - 1] : result[i - 1][j];
              }
            }
          }
          
          console.log(result);
          return result[length1][length2]
        }
        
        let result = lcs2('ABCBDAB', 'BDCABA');
        console.log(result);   // 4

        上面的result就是我們構造出來的二維數組,對應的表格如下,每一格的值就是c(i, j),如果$X_i = Y_j$,則它的值就是他斜上方的值加一,如果$X_i \neq Y_i$,則它的值是上方或者左方較大的那一個。

        image-20200202224206267

        輸出最長公共子序列

        要輸出LCS,思路還是跟前面切鋼條的類似,把每一步操作都記錄下來,然后再回溯。為了記錄操作我們需要一個跟result二維數組一樣大的二維數組,每個格子里面的值是當前值是從哪里來的,當然,第一行和第一列仍然是0。每個格子的值要么從斜上方來,要么上方,要么左方,所以:

        1. 我們用1來表示當前值從斜上方來
        2. 我們用2表示當前值從左方來
        3. 我們用3表示當前值從上方來

        看代碼:

        function lcs3(str1, str2) {
          let length1 = str1.length;
          let length2 = str2.length;
          
          // 構建一個二維數組
          // i表示行號,對應length1 + 1
          // j表示列號, 對應length2 + 1
          // 第一行和第一列全部為0
          let result = [];
          let comeFrom = [];   // 保存來歷的數組
          for(let i = 0; i < length1 + 1; i++){
            result.push([]); //初始化每行為空數組
            comeFrom.push([]);
            for(let j = 0; j < length2 + 1; j++){
              if(i === 0) {
                result[i][j] = 0; // 第一行全部為0
                comeFrom[i][j] = 0;
              } else if(j === 0) {
                result[i][j] = 0; // 第一列全部為0
                comeFrom[i][j] = 0;
              } else if(str1[i - 1] === str2[j - 1]){
                // 最后一個字符相同
                result[i][j] = result[i - 1][j - 1] + 1;
                comeFrom[i][j] = 1;      // 值從斜上方來
              } else if(result[i][j - 1] > result[i - 1][j]){
                // 最后一個字符不同,值是左邊的大
                result[i][j] = result[i][j - 1];
                comeFrom[i][j] = 2;
              } else {
                // 最后一個字符不同,值是上邊的大
                result[i][j] = result[i - 1][j];
                comeFrom[i][j] = 3;
              }
            }
          }
          
          console.log(result);
          console.log(comeFrom);
          
          // 回溯comeFrom數組,找出LCS
          let pointerI = length1;
          let pointerJ = length2;
          let lcsArr = [];   // 一個數組保存LCS結果
          while(pointerI > 0 && pointerJ > 0) {
            console.log(pointerI, pointerJ);
            if(comeFrom[pointerI][pointerJ] === 1) {
              lcsArr.push(str1[pointerI - 1]);
              pointerI--;
              pointerJ--;
            } else if(comeFrom[pointerI][pointerJ] === 2) {
              pointerI--;
            } else if(comeFrom[pointerI][pointerJ] === 3) {
              pointerJ--;
            }
          }
          
          console.log(lcsArr);   // ["B", "A", "D", "B"]
          //現在lcsArr順序是反的
          lcsArr = lcsArr.reverse();
          
          return {
            length: result[length1][length2], 
            lcs: lcsArr.join('')
          }
        }
        
        let result = lcs3('ABCBDAB', 'BDCABA');
        console.log(result);   // {length: 4, lcs: "BDAB"}

        最短編輯距離

        這是leetcode上的一道題目,題目描述如下:

        image-20200209114557615

        這道題目的思路跟前面最長公共子序列非常像,我們同樣假設第一個字符串是$X=(x_1, x_2 ... x_m)$,第二個字符串是$Y=(y_1, y_2 ... y_n)$。我們要求解的目標為$r$, $r[i][j]$為長度為$i$的$X$和長度為$j$的$Y$的解。我們同樣從兩個字符串的最后一個字符開始考慮:

        1. 如果他們最后一個字符是一樣的,那最后一個字符就不需要編輯了,只需要知道他們前面一個字符的最短編輯距離就行了,寫成公式就是:如果$Xi = Y_j$,$r[i][j] = r[i-1][j-1]$。
        2. 如果他們最后一個字符是不一樣的,那最后一個字符肯定需要編輯一次才行。那最短編輯距離就是$X$去掉最后一個字符與$Y$的最短編輯距離,再加上最后一個字符的一次;或者是是$Y$去掉最后一個字符與$X$的最短編輯距離,再加上最后一個字符的一次,就看這兩個數字哪個小了。這里需要注意的是$X$去掉最后一個字符或者$Y$去掉最后一個字符,相當于在$Y$上進行插入和刪除,但是除了插入和刪除兩個操作外,還有一個操作是替換,如果是替換操作,并不會改變兩個字符串的長度,替換的時候,距離為$r[i][j]=r[i-1][j-1]+1$。最終是在這三種情況里面取最小值,寫成數學公式就是:如果$Xi \neq Y_j$,$r[i][j] = \min(r[i-1][j], r[i][j-1],r[i-1][j-1]) + 1$。
        3. 最后就是如果$X$或者$Y$有任意一個是空字符串,那為了讓他們一樣,就往空的那個插入另一個字符串就行了,最短距離就是另一個字符串的長度。數學公式就是:如果$i=0$,$r[i][j] = j$;如果$j=0$,$r[i][j] = i$。

        上面幾種情況總結起來就是

        $$ r[i][j]= \begin{cases} j, & \text{if}\ i=0 \\ i, & \text{if}\ j=0 \\ r[i-1][j-1], & \text{if}\ X_i=Y_j \\ \min(r[i-1][j], r[i][j-1], r[i-1][j-1]) + 1, & \text{if} \ X_i\neq Y_j \end{cases} $$

        遞歸方案

        老規矩,有了遞推公式,我們先來寫個遞歸:

        const minDistance = function(str1, str2) {
            const length1 = str1.length;
            const length2 = str2.length;
        
            if(!length1) {
                return length2;
            }
        
            if(!length2) {
                return length1;
            }
        
            const shortStr1 = str1.slice(0, -1);
            const shortStr2 = str2.slice(0, -1); 
        
            const isLastEqual = str1[length1-1] === str2[length2-1];
        
            if(isLastEqual) {
                return minDistance(shortStr1, shortStr2);
            } else {
                const shortStr1Cal = minDistance(shortStr1, str2);
                const shortStr2Cal = minDistance(str1, shortStr2);
                const updateCal = minDistance(shortStr1, shortStr2);
        
                const minShort = shortStr1Cal <= shortStr2Cal ? shortStr1Cal : shortStr2Cal;
                const minDis = minShort <= updateCal ? minShort : updateCal;
        
                return minDis + 1;
            }
        }; 
        
        //測試一下
        let result = minDistance('horse', 'ros');
        console.log(result);  // 3
        
        result = minDistance('intention', 'execution');
        console.log(result);  // 5

        動態規劃

        上面的遞歸方案提交到leetcode會直接超時,因為復雜度太高了,指數級的。還是上我們的動態規劃方案吧,跟前面類似,需要一個二維數組來存放每次執行的結果。

        const minDistance = function(str1, str2) {
            const length1 = str1.length;
            const length2 = str2.length;
        
            if(!length1) {
                return length2;
            }
        
            if(!length2) {
                return length1;
            }
        
            // i 為行,表示str1
            // j 為列,表示str2
            const r = [];
            for(let i = 0; i < length1 + 1; i++) {
                r.push([]);
                for(let j = 0; j < length2 + 1; j++) {
                    if(i === 0) {
                        r[i][j] = j;
                    } else if (j === 0) {
                        r[i][j] = i;
                    } else if(str1[i - 1] === str2[j - 1]){ // 注意下標,i,j包括空字符串,長度會大1
                        r[i][j] = r[i - 1][j - 1];
                    } else {
                        r[i][j] = Math.min(r[i - 1][j ], r[i][j - 1], r[i - 1][j - 1]) + 1;
                    }
                }
            }
        
            return r[length1][length2];
        };
        
        //測試一下
        let result = minDistance('horse', 'ros');
        console.log(result);  // 3
        
        result = minDistance('intention', 'execution');
        console.log(result);  // 5

        上述代碼因為是雙重循環,所以時間復雜度是$O(mn)$。

        總結

        動態規劃的關鍵點是要找出遞推式,有了這個遞推式我們可以用遞歸求解,也可以用動態規劃。用遞歸時間復雜度通常是指數級增長,所以我們有了動態規劃。動態規劃的關鍵點是從小往大算,將每一個計算記過的值都記錄下來,這樣我們計算大的值的時候直接就取到前面計算過的值了。動態規劃可以大大降低時間復雜度,但是增加了一個存計算結果的數據結構,空間復雜度會增加。這也算是一種用空間換時間的策略了。

        文章的最后,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支持是作者持續創作的動力。

        歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~

        “前端進階知識”系列文章源碼地址: https://github.com/dennis-jiang/Front-End-Knowledges

        1270_300二維碼_2.png

        查看原文

        贊 20 收藏 12 評論 0

        譚光志 發布了文章 · 1月18日

        可視化拖拽組件庫一些技術要點原理分析(二)

        本文是對《可視化拖拽組件庫一些技術要點原理分析》的補充。上一篇文章主要講解了以下幾個功能點:

        1. 編輯器
        2. 自定義組件
        3. 拖拽
        4. 刪除組件、調整圖層層級
        5. 放大縮小
        6. 撤消、重做
        7. 組件屬性設置
        8. 吸附
        9. 預覽、保存代碼
        10. 綁定事件
        11. 綁定動畫
        12. 導入 PSD
        13. 手機模式

        現在這篇文章會在此基礎上再補充 4 個功能點,分別是:

        • 拖拽旋轉
        • 復制粘貼剪切
        • 數據交互
        • 發布

        和上篇文章一樣,我已經將新功能的代碼更新到了 github:

        友善提醒:建議結合源碼一起閱讀,效果更好(這個 DEMO 使用的是 Vue 技術棧)。

        14. 拖拽旋轉

        在寫上一篇文章時,原來的 DEMO 已經可以支持旋轉功能了。但是這個旋轉功能還有很多不完善的地方:

        1. 不支持拖拽旋轉。
        2. 旋轉后的放大縮小不正確。
        3. 旋轉后的自動吸附不正確。
        4. 旋轉后八個可伸縮點的光標不正確。

        這一小節,我們將逐一解決這四個問題。

        拖拽旋轉

        拖拽旋轉需要使用 Math.atan2() 函數。

        Math.atan2() 返回從原點(0,0)到(x,y)點的線段與x軸正方向之間的平面角度(弧度值),也就是Math.atan2(y,x)。Math.atan2(y,x)中的y和x都是相對于圓點(0,0)的距離。

        簡單的說就是以組件中心點為原點 (centerX,centerY),用戶按下鼠標時的坐標設為 (startX,startY),鼠標移動時的坐標設為 (curX,curY)。旋轉角度可以通過 (startX,startY)(curX,curY) 計算得出。

        那我們如何得到從點 (startX,startY) 到點 (curX,curY) 之間的旋轉角度呢?

        第一步,鼠標點擊時的坐標設為 (startX,startY)

        const startY = e.clientY
        const startX = e.clientX

        第二步,算出組件中心點:

        // 獲取組件中心點位置
        const rect = this.$el.getBoundingClientRect()
        const centerX = rect.left + rect.width / 2
        const centerY = rect.top + rect.height / 2

        第三步,按住鼠標移動時的坐標設為 (curX,curY)

        const curX = moveEvent.clientX
        const curY = moveEvent.clientY

        第四步,分別算出 (startX,startY)(curX,curY) 對應的角度,再將它們相減得出旋轉的角度。另外,還需要注意的就是 Math.atan2() 方法的返回值是一個弧度,因此還需要將弧度轉化為角度。所以完整的代碼為:

        // 旋轉前的角度
        const rotateDegreeBefore = Math.atan2(startY - centerY, startX - centerX) / (Math.PI / 180)
        // 旋轉后的角度
        const rotateDegreeAfter = Math.atan2(curY - centerY, curX - centerX) / (Math.PI / 180)
        // 獲取旋轉的角度值, startRotate 為初始角度值
        pos.rotate = startRotate + rotateDegreeAfter - rotateDegreeBefore

        放大縮小

        組件旋轉后的放大縮小會有 BUG。

        從上圖可以看到,放大縮小時會發生移位。另外伸縮的方向和我們拖動的方向也不對。造成這一 BUG 的原因是:當初設計放大縮小功能沒有考慮到旋轉的場景。所以無論旋轉多少角度,放大縮小仍然是按沒旋轉時計算的。

        下面再看一個具體的示例:

        從上圖可以看出,在沒有旋轉時,按住頂點往上拖動,只需用 y2 - y1 就可以得出拖動距離 s。這時將組件原來的高度加上 s 就能得出新的高度,同時將組件的 top、left 屬性更新。

        現在旋轉 180 度,如果這時拖住頂點往下拖動,我們期待的結果是組件高度增加。但這時計算的方式和原來沒旋轉時是一樣的,所以結果和我們期待的相反,組件的高度將會變?。ㄈ绻焕斫膺@個現象,可以想像一下沒有旋轉的那張圖,按住頂點往下拖動)。

        如何解決這個問題呢?我從 github 上的一個項目 snapping-demo 找到了解決方案:將放大縮小和旋轉角度關聯起來。

        解決方案

        下面是一個已旋轉一定角度的矩形,假設現在拖動它左上方的點進行拉伸。

        現在我們將一步步分析如何得出拉伸后的組件的正確大小和位移。

        第一步,按下鼠標時通過組件的坐標(無論旋轉多少度,組件的 topleft 屬性不變)和大小算出組件中心點:

        const center = {
            x: style.left + style.width / 2,
            y: style.top + style.height / 2,
        }

        第二步,用當前點擊坐標和組件中心點算出當前點擊坐標的對稱點坐標:

        // 獲取畫布位移信息
        const editorRectInfo = document.querySelector('#editor').getBoundingClientRect()
        
        // 當前點擊坐標
        const curPoint = {
            x: e.clientX - editorRectInfo.left,
            y: e.clientY - editorRectInfo.top,
        }
        
        // 獲取對稱點的坐標
        const symmetricPoint = {
            x: center.x - (curPoint.x - center.x),
            y: center.y - (curPoint.y - center.y),
        }

        第三步,摁住組件左上角進行拉伸時,通過當前鼠標實時坐標和對稱點計算出新的組件中心點:

        const curPositon = {
            x: moveEvent.clientX - editorRectInfo.left,
            y: moveEvent.clientY - editorRectInfo.top,
        }
        
        const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
        
        // 求兩點之間的中點坐標
        function getCenterPoint(p1, p2) {
            return {
                x: p1.x + ((p2.x - p1.x) / 2),
                y: p1.y + ((p2.y - p1.y) / 2),
            }
        }

        由于組件處于旋轉狀態,即使你知道了拉伸時移動的 xy 距離,也不能直接對組件進行計算。否則就會出現 BUG,移位或者放大縮小方向不正確。因此,我們需要在組件未旋轉的情況下對其進行計算。

        第四步,根據已知的旋轉角度、新的組件中心點、當前鼠標實時坐標可以算出當前鼠標實時坐標currentPosition 在未旋轉時的坐標 newTopLeftPoint。同時也能根據已知的旋轉角度、新的組件中心點、對稱點算出組件對稱點sPoint 在未旋轉時的坐標 newBottomRightPoint。

        對應的計算公式如下:

        /**
         * 計算根據圓心旋轉后的點的坐標
         * @param   {Object}  point  旋轉前的點坐標
         * @param   {Object}  center 旋轉中心
         * @param   {Number}  rotate 旋轉的角度
         * @return  {Object}         旋轉后的坐標
         * https://www.zhihu.com/question/67425734/answer/252724399 旋轉矩陣公式
         */
        export function calculateRotatedPointCoordinate(point, center, rotate) {
            /**
             * 旋轉公式:
             *  點a(x, y)
             *  旋轉中心c(x, y)
             *  旋轉后點n(x, y)
             *  旋轉角度θ                tan ??
             * nx = cosθ * (ax - cx) - sinθ * (ay - cy) + cx
             * ny = sinθ * (ax - cx) + cosθ * (ay - cy) + cy
             */
        
            return {
                x: (point.x - center.x) * Math.cos(angleToRadian(rotate)) - (point.y - center.y) * Math.sin(angleToRadian(rotate)) + center.x,
                y: (point.x - center.x) * Math.sin(angleToRadian(rotate)) + (point.y - center.y) * Math.cos(angleToRadian(rotate)) + center.y,
            }
        }

        上面的公式涉及到線性代數中旋轉矩陣的知識,對于一個沒上過大學的人來說,實在太難了。還好我從知乎上的一個回答中找到了這一公式的推理過程,下面是回答的原文:

        通過以上幾個計算值,就可以得到組件新的位移值 topleft 以及新的組件大小。對應的完整代碼如下:

        function calculateLeftTop(style, curPositon, pointInfo) {
            const { symmetricPoint } = pointInfo
            const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
            const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)
            const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
          
            const newWidth = newBottomRightPoint.x - newTopLeftPoint.x
            const newHeight = newBottomRightPoint.y - newTopLeftPoint.y
            if (newWidth > 0 && newHeight > 0) {
                style.width = Math.round(newWidth)
                style.height = Math.round(newHeight)
                style.left = Math.round(newTopLeftPoint.x)
                style.top = Math.round(newTopLeftPoint.y)
            }
        }

        現在再來看一下旋轉后的放大縮?。?/p>

        自動吸附

        自動吸附是根據組件的四個屬性 topleftwidthheight 計算的,在將組件進行旋轉后,這些屬性的值是不會變的。所以無論組件旋轉多少度,吸附時仍然按未旋轉時計算。這樣就會有一個問題,雖然實際上組件的 topleftwidthheight 屬性沒有變化。但在外觀上卻發生了變化。下面是兩個同樣的組件:一個沒旋轉,一個旋轉了 45 度。

        可以看出來旋轉后按鈕的 height 屬性和我們從外觀上看到的高度是不一樣的,所以在這種情況下就出現了吸附不正確的 BUG。

        解決方案

        如何解決這個問題?我們需要拿組件旋轉后的大小及位移來做吸附對比。也就是說不要拿組件實際的屬性來對比,而是拿我們看到的大小和位移做對比。

        從上圖可以看出,旋轉后的組件在 x 軸上的投射長度為兩條紅線長度之和。這兩條紅線的長度可以通過正弦和余弦算出,左邊的紅線用正弦計算,右邊的紅線用余弦計算:

        const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)

        同理,高度也是一樣:

        const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)

        新的寬度和高度有了,再根據組件原有的 topleft 屬性,可以得出組件旋轉后新的 topleft 屬性。下面附上完整代碼:

        translateComponentStyle(style) {
            style = { ...style }
            if (style.rotate != 0) {
                const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)
                const diffX = (style.width - newWidth) / 2
                style.left += diffX
                style.right = style.left + newWidth
        
                const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)
                const diffY = (newHeight - style.height) / 2
                style.top -= diffY
                style.bottom = style.top + newHeight
        
                style.width = newWidth
                style.height = newHeight
            } else {
                style.bottom = style.top + style.height
                style.right = style.left + style.width
            }
        
            return style
        }

        經過修復后,吸附也可以正常顯示了。

        光標

        光標和可拖動的方向不對,是因為八個點的光標是固定設置的,沒有隨著角度變化而變化。

        解決方案

        由于 360 / 8 = 45,所以可以為每一個方向分配 45 度的范圍,每個范圍對應一個光標。同時為每個方向設置一個初始角度,也就是未旋轉時組件每個方向對應的角度。

        pointList: ['lt', 't', 'rt', 'r', 'rb', 'b', 'lb', 'l'], // 八個方向
        initialAngle: { // 每個點對應的初始角度
            lt: 0,
            t: 45,
            rt: 90,
            r: 135,
            rb: 180,
            b: 225,
            lb: 270,
            l: 315,
        },
        angleToCursor: [ // 每個范圍的角度對應的光標
            { start: 338, end: 23, cursor: 'nw' },
            { start: 23, end: 68, cursor: 'n' },
            { start: 68, end: 113, cursor: 'ne' },
            { start: 113, end: 158, cursor: 'e' },
            { start: 158, end: 203, cursor: 'se' },
            { start: 203, end: 248, cursor: 's' },
            { start: 248, end: 293, cursor: 'sw' },
            { start: 293, end: 338, cursor: 'w' },
        ],
        cursors: {},

        計算方式也很簡單:

        1. 假設現在組件已旋轉了一定的角度 a。
        2. 遍歷八個方向,用每個方向的初始角度 + a 得出現在的角度 b。
        3. 遍歷 angleToCursor 數組,看看 b 在哪一個范圍中,然后將對應的光標返回。

        經常上面三個步驟就可以計算出組件旋轉后正確的光標方向。具體的代碼如下:

        getCursor() {
            const { angleToCursor, initialAngle, pointList, curComponent } = this
            const rotate = (curComponent.style.rotate + 360) % 360 // 防止角度有負數,所以 + 360
            const result = {}
            let lastMatchIndex = -1 // 從上一個命中的角度的索引開始匹配下一個,降低時間復雜度
            pointList.forEach(point => {
                const angle = (initialAngle[point] + rotate) % 360
                const len = angleToCursor.length
                while (true) {
                    lastMatchIndex = (lastMatchIndex + 1) % len
                    const angleLimit = angleToCursor[lastMatchIndex]
                    if (angle < 23 || angle >= 338) {
                        result[point] = 'nw-resize'
                        return
                    }
        
                    if (angleLimit.start <= angle && angle < angleLimit.end) {
                        result[point] = angleLimit.cursor + '-resize'
                        return
                    }
                }
            })
        
            return result
        },

        從上面的動圖可以看出來,現在八個方向上的光標是可以正確顯示的。

        15. 復制粘貼剪切

        相對于拖拽旋轉功能,復制粘貼就比較簡單了。

        const ctrlKey = 17, vKey = 86, cKey = 67, xKey = 88
        let isCtrlDown = false
        
        window.onkeydown = (e) => {
            if (e.keyCode == ctrlKey) {
                isCtrlDown = true
            } else if (isCtrlDown && e.keyCode == cKey) {
                this.$store.commit('copy')
            } else if (isCtrlDown && e.keyCode == vKey) {
                this.$store.commit('paste')
            } else if (isCtrlDown && e.keyCode == xKey) {
                this.$store.commit('cut')
            }
        }
        
        window.onkeyup = (e) => {
            if (e.keyCode == ctrlKey) {
                isCtrlDown = false
            }
        }

        監聽用戶的按鍵操作,在按下特定按鍵時觸發對應的操作。

        復制操作

        在 vuex 中使用 copyData 來表示復制的數據。當用戶按下 ctrl + c 時,將當前組件數據深拷貝到 copyData。

        copy(state) {
            state.copyData = {
                data: deepCopy(state.curComponent),
                index: state.curComponentIndex,
            }
        },

        同時需要將當前組件在組件數據中的索引記錄起來,在剪切中要用到。

        粘貼操作

        paste(state, isMouse) {
            if (!state.copyData) {
                toast('請選擇組件')
                return
            }
        
            const data = state.copyData.data
        
            if (isMouse) {
                data.style.top = state.menuTop
                data.style.left = state.menuLeft
            } else {
                data.style.top += 10
                data.style.left += 10
            }
        
            data.id = generateID()
            store.commit('addComponent', { component: data })
            store.commit('recordSnapshot')
            state.copyData = null
        },

        粘貼時,如果是按鍵操作 ctrl+v。則將組件的 topleft 屬性加 10,以免和原來的組件重疊在一起。如果是使用鼠標右鍵執行粘貼操作,則將復制的組件放到鼠標點擊處。

        剪切操作

        cut(state) {
            if (!state.curComponent) {
                toast('請選擇組件')
                return
            }
        
            if (state.copyData) {
                store.commit('addComponent', { component: state.copyData.data, index: state.copyData.index })
                if (state.curComponentIndex >= state.copyData.index) {
                    // 如果當前組件索引大于等于插入索引,需要加一,因為當前組件往后移了一位
                    state.curComponentIndex++
                }
            }
        
            store.commit('copy')
            store.commit('deleteComponent')
        },

        剪切操作本質上還是復制,只不過在執行復制后,需要將當前組件刪除。為了避免用戶執行剪切操作后,不執行粘貼操作,而是繼續執行剪切。這時就需要將原先剪切的數據進行恢復。所以復制數據中記錄的索引就起作用了,可以通過索引將原來的數據恢復到原來的位置中。

        右鍵操作

        右鍵操作和按鍵操作是一樣的,一個功能兩種觸發途徑。

        <li @click="copy" v-show="curComponent">復制</li>
        <li @click="paste">粘貼</li>
        <li @click="cut" v-show="curComponent">剪切</li>
        
        cut() {
            this.$store.commit('cut')
        },
        
        copy() {
            this.$store.commit('copy')
        },
        
        paste() {
            this.$store.commit('paste', true)
        },

        16. 數據交互

        方式一

        提前寫好一系列 ajax 請求API,點擊組件時按需選擇 API,選好 API 再填參數。例如下面這個組件,就展示了如何使用 ajax 請求向后臺交互:

        <template>
            <div>{{ propValue.data }}</div>
        </template>
        
        <script>
        export default {
            // propValue: {
            //     api: {
            //             request: a,
            //             params,
            //      },
            //     data: null
            // }
            props: {
                propValue: {
                    type: Object,
                    default: () => {},
                },
            },
            created() {
                this.propValue.api.request(this.propValue.api.params).then(res => {
                    this.propValue.data = res.data
                })
            },
        }
        </script>

        方式二

        方式二適合純展示的組件,例如有一個報警組件,可以根據后臺傳來的數據顯示對應的顏色。在編輯頁面的時候,可以通過 ajax 向后臺請求頁面能夠使用的 websocket 數據:

        const data = ['status', 'text'...]

        然后再為不同的組件添加上不同的屬性。例如有 a 組件,它綁定的屬性為 status。

        // 組件能接收的數據
        props: {
            propValue: {
                type: String,
            },
            element: {
                type: Object,
            },
            wsKey: {
                type: String,
                default: '',
            },
        },

        在組件中通過 wsKey 獲取這個綁定的屬性。等頁面發布后或者預覽時,通過 weboscket 向后臺請求全局數據放在 vuex 上。組件就可以通過 wsKey 訪問數據了。

        <template>
            <div>{{ wsData[wsKey] }}</div>
        </template>
        
        <script>
        import { mapState } from 'vuex'
        
        export default {
            props: {
                propValue: {
                    type: String,
                },
                element: {
                    type: Object,
                },
                wsKey: {
                    type: String,
                    default: '',
                },
            },
            computed: mapState([
                'wsData',
            ]),
        </script>

        和后臺交互的方式有很多種,不僅僅包括上面兩種,我在這里僅提供一些思路,以供參考。

        17. 發布

        頁面發布有兩種方式:一是將組件數據渲染為一個單獨的 HTML 頁面;二是從本項目中抽取出一個最小運行時 runtime 作為一個單獨的項目。

        這里說一下第二種方式,本項目中的最小運行時其實就是預覽頁面加上自定義組件。將這些代碼提取出來作為一個項目單獨打包。發布頁面時將組件數據以 JSON 的格式傳給服務端,同時為每個頁面生成一個唯一 ID。

        假設現在有三個頁面,發布頁面生成的 ID 為 a、b、c。訪問頁面時只需要把 ID 帶上,這樣就可以根據 ID 獲取每個頁面對應的組件數據。

        www.test.com/?id=a
        www.test.com/?id=c
        www.test.com/?id=b

        按需加載

        如果自定義組件過大,例如有數十個甚至上百個。這時可以將自定義組件用 import 的方式導入,做到按需加載,減少首屏渲染時間:

        import Vue from 'vue'
        
        const components = [
            'Picture',
            'VText',
            'VButton',
        ]
        
        components.forEach(key => {
            Vue.component(key, () => import(`@/custom-component/${key}`))
        })

        按版本發布

        自定義組件有可能會有更新的情況。例如原來的組件使用了大半年,現在有功能變更,為了不影響原來的頁面。建議在發布時帶上組件的版本號:

        - v-text
          - v1.vue
          - v2.vue

        例如 v-text 組件有兩個版本,在左側組件列表區使用時就可以帶上版本號:

        {
          component: 'v-text',
          version: 'v1'
          ...
        }

        這樣導入組件時就可以根據組件版本號進行導入:

        import Vue from 'vue'
        import componentList from '@/custom-component/component-list`
        
        componentList.forEach(component => {
            Vue.component(component.name, () => import(`@/custom-component/${component.name}/${component.version}`))
        })

        參考資料

        查看原文

        贊 28 收藏 20 評論 0

        譚光志 贊了文章 · 1月4日

        SegmentFault 思否 2020 年度 Top Writer

        日新月異的技術革命,數字經濟的新一輪爆發,背后是無數開發者夜以繼日的付出。他們信奉技術力量,敢于技術創新,踐行技術信仰,他們是技術先鋒,探索改變世界的方向。

        SegmentFault 思否作為中國領先的新一代開發者社區,在 2020 展開了第二屆“中國技術先鋒”年度評選,并先后發布《中國技術品牌影響力企業》、《中國開源先鋒 33 人》及《最受開發者歡迎的技術活動》系列榜單。

        而在這些引領著時代變革的先鋒力量中,有一股力量不容忽視 —— 他們是社區的基石,也是行業發展、技術發展的源動力。他們是一群活躍在 SegmentFault 思否社區的一群卓越的開發者,他們熱衷于分享知識與經驗,他們布道技術與未來,他們讓眾多開發者受益,他們叫「Top Writer」。

        SegmentFault 思否根據社區用戶行為大數據(如文章 & 問答發布數量、獲得聲望 & 點贊量等)綜合分析,從「技術問答」和「專欄文章」兩個維度進行了2020年度「Top Writer」的評選。

        話不多說,讓我們來一同揭曉評選結果~

        image

        TopWriter·問答作者積累聲望值高票問答
        然后去遠足15948git所謂的分布式體現在什么地方?
        linong17915vue回車聚焦下一個input,動態綁定ref出現,refs拿到為undefined
        fefe9695promise then 的回調函數是在什么時候進入微任務隊列的?
        GhostOfYou3748Linux crontab 沒有效果
        Meathill12308閱讀源碼重要嗎?有多重要?
        木馬啊9087用純css怎么實現A元素+B元素,A是綠色背景,A元素+C元素,A是紅色背景?
        唯一丶10723null undefined區別
        zangeci3264chrome控制臺 這種怎么輸出的?
        asseek8967怎么簡寫下面的賦值語句
        hfhan13061element-ui 中 Cascader 級聯選擇器有沒有什么辦法判斷它是否被全選
        madRain3616js中多個時間,怎么取最小值
        水不涼4199關于class中的函數問題
        邊城42037js 數組內嵌對象(json結構),知道路徑怎么去修改內容?
        TNT4020java 字符串去掉多余空格和空行
        程序媛兔子1333vue項目如何實現導航欄中的前進和后退都要刷新頁面?vue項目如何實現導航欄中的前進和后退都要刷新頁面?
        TopWriter·文章作者積累聲望值高票文章
        民工哥16954小姐姐用動畫圖解Git命令,一看就懂!
        譚光志4666前端性能優化 24 條建議(2020)
        前端小智54379能解決 80% 需求的 10個 CSS動畫庫
        瘋狂的技術宅410612020最新:100道有答案的前端面試題(上)
        lzg95272036分享8個非常實用的Vue自定義指令
        Jason302807-SpringBoot+MyBatis+Spring 技術整合實現商品模塊的CRUD操作
        杜尼卜9987聽說你熟練使用Vue,那這9種Vue技術你掌握了嗎?不信你全知道!
        Peter譚老師13076深度:從零編寫一個微前端框架
        敖丙2640Redis 緩存雪崩、擊穿、穿透
        flydean661八張圖徹底了解JDK8 GC調優秘籍-附PDF下載
        阿寶哥14032「1.8W字」一份不可多得的 TS 學習指南
        小傅哥243012天,這本《重學Java設計模式》PDF書籍下載量9k,新增粉絲1400人,Github上全球推薦榜!
        codecraft11291聊聊golang的panic與recover
        iyacontrol1236服務網格平臺探索性指南
        蔣鵬飛3443速度提高幾百倍,記一次數據結構在實際工作中的運用

        恭喜以上上榜的技術內容創作者!請入選的作者們添加下方思否小姐姐的微信,我們為每位「Top Writer」準備了定制證書和 SegmentFault 2021 限量版衛衣。

        也歡迎更多開發者在 SegmentFault 思否社區分享自己的經驗與技能,為更多「同路人」答疑解惑、互動交流。如果你希望!自己的內容更快被更多用戶看見和關注,歡迎加入思否社區創作者群,交流技術、分享寫作經驗、獲得更多流量。(入群請添加小姐姐微信并發送你的社區賬號)

        掃我↓?添加?vivian

        image

        最后思否小姐姐為各位 Top Writer 和社區活躍的開發者點贊,在 SegmentFault 思否社區活躍的開發者最可愛!2021,我們繼續在一起鴨!

        查看原文

        贊 22 收藏 4 評論 17

        譚光志 發布了文章 · 2020-12-21

        可視化拖拽組件庫一些技術要點原理分析

        本文主要對以下技術要點進行分析:

        1. 編輯器
        2. 自定義組件
        3. 拖拽
        4. 刪除組件、調整圖層層級
        5. 放大縮小
        6. 撤消、重做
        7. 組件屬性設置
        8. 吸附
        9. 預覽、保存代碼
        10. 綁定事件
        11. 綁定動畫
        12. 導入 PSD
        13. 手機模式

        為了讓本文更加容易理解,我將以上技術要點結合在一起寫了一個可視化拖拽組件庫 DEMO:

        建議結合源碼一起閱讀,效果更好(這個 DEMO 使用的是 Vue 技術棧)。

        1. 編輯器

        先來看一下頁面的整體結構。

        這一節要講的編輯器其實就是中間的畫布。它的作用是:當從左邊組件列表拖拽出一個組件放到畫布中時,畫布要把這個組件渲染出來。

        這個編輯器的實現思路是:

        1. 用一個數組 componentData 維護編輯器中的數據。
        2. 把組件拖拽到畫布中時,使用 push() 方法將新的組件數據添加到 componentData。
        3. 編輯器使用 v-for 指令遍歷 componentData,將每個組件逐個渲染到畫布(也可以使用 JSX 語法結合 render() 方法代替)。

        編輯器渲染的核心代碼如下所示:

        <component 
          v-for="item in componentData"
          :key="item.id"
          :is="item.component"
          :style="item.style"
          :propValue="item.propValue"
        />

        每個組件數據大概是這樣:

        {
            component: 'v-text', // 組件名稱,需要提前注冊到 Vue
            label: '文字', // 左側組件列表中顯示的名字
            propValue: '文字', // 組件所使用的值
            icon: 'el-icon-edit', // 左側組件列表中顯示的名字
            animations: [], // 動畫列表
            events: {}, // 事件列表
            style: { // 組件樣式
                width: 200,
                height: 33,
                fontSize: 14,
                fontWeight: 500,
                lineHeight: '',
                letterSpacing: 0,
                textAlign: '',
                color: '',
            },
        }

        在遍歷 componentData 組件數據時,主要靠 is 屬性來識別出真正要渲染的是哪個組件。

        例如要渲染的組件數據是 { component: 'v-text' },則 <component :is="item.component" /> 會被轉換為 <v-text />。當然,你這個組件也要提前注冊到 Vue 中。

        如果你想了解更多 is 屬性的資料,請查看官方文檔。

        2. 自定義組件

        原則上使用第三方組件也是可以的,但建議你最好封裝一下。不管是第三方組件還是自定義組件,每個組件所需的屬性可能都不一樣,所以每個組件數據可以暴露出一個屬性 propValue 用于傳遞值。

        例如 a 組件只需要一個屬性,你的 propValue 可以這樣寫:propValue: 'aaa'。如果需要多個屬性,propValue 則可以是一個對象:

        propValue: {
          a: 1,
          b: 'text'
        }

        在這個 DEMO 組件庫中我定義了三個組件。

        圖片組件 Picture

        <template>
            <div style="overflow: hidden">
                <img :data-original="propValue">
            </div>
        </template>
        
        <script>
        export default {
            props: {
                propValue: {
                    type: String,
                    require: true,
                },
            },
        }
        </script>

        按鈕組件 VButton:

        <template>
            <button class="v-button">{{ propValue }}</button>
        </template>
        
        <script>
        export default {
            props: {
                propValue: {
                    type: String,
                    default: '',
                },
            },
        }
        </script>

        文本組件 VText:

        <template>
            <textarea 
                v-if="editMode == 'edit'"
                :value="propValue"
                class="text textarea"
                @input="handleInput"
                ref="v-text"
            ></textarea>
            <div v-else class="text disabled">
                <div v-for="(text, index) in propValue.split('\n')" :key="index">{{ text }}</div>
            </div>
        </template>
        
        <script>
        import { mapState } from 'vuex'
        
        export default {
            props: {
                propValue: {
                    type: String,
                },
                element: {
                    type: Object,
                },
            },
            computed: mapState([
                'editMode',
            ]),
            methods: {
                handleInput(e) {
                    this.$emit('input', this.element, e.target.value)
                },
            },
        }
        </script>

        3. 拖拽

        從組件列表到畫布

        一個元素如果要設為可拖拽,必須給它添加一個 draggable 屬性。另外,在將組件列表中的組件拖拽到畫布中,還有兩個事件是起到關鍵作用的:

        1. dragstart 事件,在拖拽剛開始時觸發。它主要用于將拖拽的組件信息傳遞給畫布。
        2. drop 事件,在拖拽結束時觸發。主要用于接收拖拽的組件信息。

        先來看一下左側組件列表的代碼:

        <div @dragstart="handleDragStart" class="component-list">
            <div v-for="(item, index) in componentList" :key="index" class="list" draggable :data-index="index">
                <i :class="item.icon"></i>
                <span>{{ item.label }}</span>
            </div>
        </div>
        handleDragStart(e) {
            e.dataTransfer.setData('index', e.target.dataset.index)
        }

        可以看到給列表中的每一個組件都設置了 draggable 屬性。另外,在觸發 dragstart 事件時,使用 dataTransfer.setData() 傳輸數據。再來看一下接收數據的代碼:

        <div class="content" @drop="handleDrop" @dragover="handleDragOver" @click="deselectCurComponent">
            <Editor />
        </div>
        handleDrop(e) {
            e.preventDefault()
            e.stopPropagation()
            const component = deepCopy(componentList[e.dataTransfer.getData('index')])
            this.$store.commit('addComponent', component)
        }

        觸發 drop 事件時,使用 dataTransfer.getData() 接收傳輸過來的索引數據,然后根據索引找到對應的組件數據,再添加到畫布,從而渲染組件。

        組件在畫布中移動

        首先需要將畫布設為相對定位 position: relative,然后將每個組件設為絕對定位 position: absolute。除了這一點外,還要通過監聽三個事件來進行移動:

        1. mousedown 事件,在組件上按下鼠標時,記錄組件當前的位置,即 xy 坐標(為了方便講解,這里使用的坐標軸,實際上 xy 對應的是 css 中的 lefttop。
        2. mousemove 事件,每次鼠標移動時,都用當前最新的 xy 坐標減去最開始的 xy 坐標,從而計算出移動距離,再改變組件位置。
        3. mouseup 事件,鼠標抬起時結束移動。
        handleMouseDown(e) {
            e.stopPropagation()
            this.$store.commit('setCurComponent', { component: this.element, zIndex: this.zIndex })
        
            const pos = { ...this.defaultStyle }
            const startY = e.clientY
            const startX = e.clientX
            // 如果直接修改屬性,值的類型會變為字符串,所以要轉為數值型
            const startTop = Number(pos.top)
            const startLeft = Number(pos.left)
        
            const move = (moveEvent) => {
                const currX = moveEvent.clientX
                const currY = moveEvent.clientY
                pos.top = currY - startY + startTop
                pos.left = currX - startX + startLeft
                // 修改當前組件樣式
                this.$store.commit('setShapeStyle', pos)
            }
        
            const up = () => {
                document.removeEventListener('mousemove', move)
                document.removeEventListener('mouseup', up)
            }
        
            document.addEventListener('mousemove', move)
            document.addEventListener('mouseup', up)
        }

        4. 刪除組件、調整圖層層級

        改變圖層層級

        由于拖拽組件到畫布中是有先后順序的,所以可以按照數據順序來分配圖層層級。

        例如畫布新增了五個組件 abcde,那它們在畫布數據中的順序為 [a, b, c, d, e],圖層層級和索引一一對應,即它們的 z-index 屬性值是 01234(后來居上)。用代碼表示如下:

        <div v-for="(item, index) in componentData" :zIndex="index"></div>

        如果不了解 z-index 屬性的,請看一下 MDN 文檔。

        理解了這一點之后,改變圖層層級就很容易做到了。改變圖層層級,即是改變組件數據在 componentData 數組中的順序。例如有 [a, b, c] 三個組件,它們的圖層層級從低到高順序為 abc(索引越大,層級越高)。

        如果要將 b 組件上移,只需將它和 c 調換順序即可:

        const temp = componentData[1]
        componentData[1] = componentData[2]
        componentData[2] = temp

        同理,置頂置底也是一樣,例如我要將 a 組件置頂,只需將 a 和最后一個組件調換順序即可:

        const temp = componentData[0]
        componentData[0] = componentData[componentData.lenght - 1]
        componentData[componentData.lenght - 1] = temp

        刪除組件

        刪除組件非常簡單,一行代碼搞定:componentData.splice(index, 1)。

        5. 放大縮小

        細心的網友可能會發現,點擊畫布上的組件時,組件上會出現 8 個小圓點。這 8 個小圓點就是用來放大縮小用的。實現原理如下:

        1. 在每個組件外面包一層 Shape 組件,Shape 組件里包含 8 個小圓點和一個 <slot> 插槽,用于放置組件。

        <!--頁面組件列表展示-->
        <Shape v-for="(item, index) in componentData"
            :defaultStyle="item.style"
            :style="getShapeStyle(item.style, index)"
            :key="item.id"
            :active="item === curComponent"
            :element="item"
            :zIndex="index"
        >
            <component
                class="component"
                :is="item.component"
                :style="getComponentStyle(item.style)"
                :propValue="item.propValue"
            />
        </Shape>

        Shape 組件內部結構:

        <template>
            <div class="shape" :class="{ active: this.active }" @click="selectCurComponent" @mousedown="handleMouseDown"
            @contextmenu="handleContextMenu">
                <div
                    class="shape-point"
                    v-for="(item, index) in (active? pointList : [])"
                    @mousedown="handleMouseDownOnPoint(item)"
                    :key="index"
                    :style="getPointStyle(item)">
                </div>
                <slot></slot>
            </div>
        </template>

        2. 點擊組件時,將 8 個小圓點顯示出來。

        起作用的是這行代碼 :active="item === curComponent"。

        3. 計算每個小圓點的位置。

        先來看一下計算小圓點位置的代碼:

        const pointList = ['t', 'r', 'b', 'l', 'lt', 'rt', 'lb', 'rb']
        
        getPointStyle(point) {
            const { width, height } = this.defaultStyle
            const hasT = /t/.test(point)
            const hasB = /b/.test(point)
            const hasL = /l/.test(point)
            const hasR = /r/.test(point)
            let newLeft = 0
            let newTop = 0
        
            // 四個角的點
            if (point.length === 2) {
                newLeft = hasL? 0 : width
                newTop = hasT? 0 : height
            } else {
                // 上下兩點的點,寬度居中
                if (hasT || hasB) {
                    newLeft = width / 2
                    newTop = hasT? 0 : height
                }
        
                // 左右兩邊的點,高度居中
                if (hasL || hasR) {
                    newLeft = hasL? 0 : width
                    newTop = Math.floor(height / 2)
                }
            }
        
            const style = {
                marginLeft: hasR? '-4px' : '-3px',
                marginTop: '-3px',
                left: `${newLeft}px`,
                top: `${newTop}px`,
                cursor: point.split('').reverse().map(m => this.directionKey[m]).join('') + '-resize',
            }
        
            return style
        }

        計算小圓點的位置需要獲取一些信息:

        • 組件的高度 height、寬度 width

        注意,小圓點也是絕對定位的,相對于 Shape 組件。所以有四個小圓點的位置很好確定:

        1. 左上角的小圓點,坐標 left: 0, top: 0
        2. 右上角的小圓點,坐標 left: width, top: 0
        3. 左下角的小圓點,坐標 left: 0, top: height
        4. 右下角的小圓點,坐標 left: width, top: height

        另外的四個小圓點需要通過計算間接算出來。例如左邊中間的小圓點,計算公式為 left: 0, top: height / 2,其他小圓點同理。

        4. 點擊小圓點時,可以進行放大縮小操作。

        handleMouseDownOnPoint(point) {
            const downEvent = window.event
            downEvent.stopPropagation()
            downEvent.preventDefault()
        
            const pos = { ...this.defaultStyle }
            const height = Number(pos.height)
            const width = Number(pos.width)
            const top = Number(pos.top)
            const left = Number(pos.left)
            const startX = downEvent.clientX
            const startY = downEvent.clientY
        
            // 是否需要保存快照
            let needSave = false
            const move = (moveEvent) => {
                needSave = true
                const currX = moveEvent.clientX
                const currY = moveEvent.clientY
                const disY = currY - startY
                const disX = currX - startX
                const hasT = /t/.test(point)
                const hasB = /b/.test(point)
                const hasL = /l/.test(point)
                const hasR = /r/.test(point)
                const newHeight = height + (hasT? -disY : hasB? disY : 0)
                const newWidth = width + (hasL? -disX : hasR? disX : 0)
                pos.height = newHeight > 0? newHeight : 0
                pos.width = newWidth > 0? newWidth : 0
                pos.left = left + (hasL? disX : 0)
                pos.top = top + (hasT? disY : 0)
                this.$store.commit('setShapeStyle', pos)
            }
        
            const up = () => {
                document.removeEventListener('mousemove', move)
                document.removeEventListener('mouseup', up)
                needSave && this.$store.commit('recordSnapshot')
            }
        
            document.addEventListener('mousemove', move)
            document.addEventListener('mouseup', up)
        }

        它的原理是這樣的:

        1. 點擊小圓點時,記錄點擊的坐標 xy。
        2. 假設我們現在向下拖動,那么 y 坐標就會增大。
        3. 用新的 y 坐標減去原來的 y 坐標,就可以知道在縱軸方向的移動距離是多少。
        4. 最后再將移動距離加上原來組件的高度,就可以得出新的組件高度。
        5. 如果是正數,說明是往下拉,組件的高度在增加。如果是負數,說明是往上拉,組件的高度在減少。

        6. 撤消、重做

        撤銷重做的實現原理其實挺簡單的,先看一下代碼:

        snapshotData: [], // 編輯器快照數據
        snapshotIndex: -1, // 快照索引
                
        undo(state) {
            if (state.snapshotIndex >= 0) {
                state.snapshotIndex--
                store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
            }
        },
        
        redo(state) {
            if (state.snapshotIndex < state.snapshotData.length - 1) {
                state.snapshotIndex++
                store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
            }
        },
        
        setComponentData(state, componentData = []) {
            Vue.set(state, 'componentData', componentData)
        },
        
        recordSnapshot(state) {
            // 添加新的快照
            state.snapshotData[++state.snapshotIndex] = deepCopy(state.componentData)
            // 在 undo 過程中,添加新的快照時,要將它后面的快照清理掉
            if (state.snapshotIndex < state.snapshotData.length - 1) {
                state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1)
            }
        },

        用一個數組來保存編輯器的快照數據。保存快照就是不停地執行 push() 操作,將當前的編輯器數據推入 snapshotData 數組,并增加快照索引 snapshotIndex。目前以下幾個動作會觸發保存快照操作:

        • 新增組件
        • 刪除組件
        • 改變圖層層級
        • 拖動組件結束時

        ...

        撤銷

        假設現在 snapshotData 保存了 4 個快照。即 [a, b, c, d],對應的快照索引為 3。如果這時進行了撤銷操作,我們需要將快照索引減 1,然后將對應的快照數據賦值給畫布。

        例如當前畫布數據是 d,進行撤銷后,索引 -1,現在畫布的數據是 c。

        重做

        明白了撤銷,那重做就很好理解了,就是將快照索引加 1,然后將對應的快照數據賦值給畫布。

        不過還有一點要注意,就是在撤銷操作中進行了新的操作,要怎么辦呢?有兩種解決方案:

        1. 新操作替換當前快照索引后面所有的數據。還是用剛才的數據 [a, b, c, d] 舉例,假設現在進行了兩次撤銷操作,快照索引變為 1,對應的快照數據為 b,如果這時進行了新的操作,對應的快照數據為 e。那 e 會把 cd 頂掉,現在的快照數據為 [a, b, e]。
        2. 不頂掉數據,在原來的快照中新增一條記錄。用剛才的例子舉例,e 不會把 cd 頂掉,而是在 cd 之前插入,即快照數據變為 [a, b, e, c, d]。

        我采用的是第一種方案。

        7. 吸附

        什么是吸附?就是在拖拽組件時,如果它和另一個組件的距離比較接近,就會自動吸附在一起。

        吸附的代碼大概在 300 行左右,建議自己打開源碼文件看(文件路徑:src\\components\\Editor\\MarkLine.vue)。這里不貼代碼了,主要說說原理是怎么實現的。

        標線

        在頁面上創建 6 條線,分別是三橫三豎。這 6 條線的作用是對齊,它們什么時候會出現呢?

        1. 上下方向的兩個組件左邊、中間、右邊對齊時會出現豎線
        2. 左右方向的兩個組件上邊、中間、下邊對齊時會出現橫線

        具體的計算公式主要是根據每個組件的 xy 坐標和寬度高度進行計算的。例如要判斷 ab 兩個組件的左邊是否對齊,則要知道它們每個組件的 x 坐標;如果要知道它們右邊是否對齊,除了要知道 x 坐標,還要知道它們各自的寬度。

        // 左對齊的條件
        a.x == b.x
        
        // 右對齊的條件
        a.x + a.width == b.x + b.width

        在對齊的時候,顯示標線。

        另外還要判斷 ab 兩個組件是否“足夠”近。如果足夠近,就吸附在一起。是否足夠近要靠一個變量來判斷:

        diff: 3, // 相距 dff 像素將自動吸附

        小于等于 diff 像素則自動吸附。

        吸附

        吸附效果是怎么實現的呢?

        假設現在有 ab 組件,a 組件坐標 xy 都是 0,寬高都是 100?,F在假設 a 組件不動,我們正在拖拽 b 組件。當把 b 組件拖到坐標為 x: 0, y: 103 時,由于 103 - 100 <= 3(diff),所以可以判定它們已經接近得足夠近。這時需要手動將 b 組件的 y 坐標值設為 100,這樣就將 ab 組件吸附在一起了。

        優化

        在拖拽時如果 6 條標線都顯示出來會不太美觀。所以我們可以做一下優化,在縱橫方向上最多只同時顯示一條線。實現原理如下:

        1. a 組件在左邊不動,我們拖著 b 組件往 a 組件靠近。
        2. 這時它們最先對齊的是 a 的右邊和 b 的左邊,所以只需要一條線就夠了。
        3. 如果 ab 組件已經靠近,并且 b 組件繼續往左邊移動,這時就要判斷它們倆的中間是否對齊。
        4. b 組件繼續拖動,這時需要判斷 a 組件的左邊和 b 組件的右邊是否對齊,也是只需要一條線。

        可以發現,關鍵的地方是我們要知道兩個組件的方向。即 ab 兩個組件靠近,我們要知道到底 b 是在 a 的左邊還是右邊。

        這一點可以通過鼠標移動事件來判斷,之前在講解拖拽的時候說過,mousedown 事件觸發時會記錄起點坐標。所以每次觸發 mousemove 事件時,用當前坐標減去原來的坐標,就可以判斷組件方向。例如 x 方向上,如果 b.x - a.x 的差值為正,說明是 b 在 a 右邊,否則為左邊。

        // 觸發元素移動事件,用于顯示標線、吸附功能
        // 后面兩個參數代表鼠標移動方向
        // currY - startY > 0 true 表示向下移動 false 表示向上移動
        // currX - startX > 0 true 表示向右移動 false 表示向左移動
        eventBus.$emit('move', this.$el, currY - startY > 0, currX - startX > 0)

        8. 組件屬性設置

        每個組件都有一些通用屬性和獨有的屬性,我們需要提供一個能顯示和修改屬性的地方。

        // 每個組件數據大概是這樣
        {
            component: 'v-text', // 組件名稱,需要提前注冊到 Vue
            label: '文字', // 左側組件列表中顯示的名字
            propValue: '文字', // 組件所使用的值
            icon: 'el-icon-edit', // 左側組件列表中顯示的名字
            animations: [], // 動畫列表
            events: {}, // 事件列表
            style: { // 組件樣式
                width: 200,
                height: 33,
                fontSize: 14,
                fontWeight: 500,
                lineHeight: '',
                letterSpacing: 0,
                textAlign: '',
                color: '',
            },
        }

        我定義了一個 AttrList 組件,用于顯示每個組件的屬性。

        <template>
            <div class="attr-list">
                <el-form>
                    <el-form-item v-for="(key, index) in styleKeys" :key="index" :label="map[key]">
                        <el-color-picker v-if="key == 'borderColor'" v-model="curComponent.style[key]"></el-color-picker>
                        <el-color-picker v-else-if="key == 'color'" v-model="curComponent.style[key]"></el-color-picker>
                        <el-color-picker v-else-if="key == 'backgroundColor'" v-model="curComponent.style[key]"></el-color-picker>
                        <el-select v-else-if="key == 'textAlign'" v-model="curComponent.style[key]">
                            <el-option
                                v-for="item in options"
                                :key="item.value"
                                :label="item.label"
                                :value="item.value"
                            ></el-option>
                        </el-select>
                        <el-input type="number" v-else v-model="curComponent.style[key]" />
                    </el-form-item>
                    <el-form-item label="內容" v-if="curComponent && curComponent.propValue && !excludes.includes(curComponent.component)">
                        <el-input type="textarea" v-model="curComponent.propValue" />
                    </el-form-item>
                </el-form>
            </div>
        </template>

        代碼邏輯很簡單,就是遍歷組件的 style 對象,將每一個屬性遍歷出來。并且需要根據具體的屬性用不同的組件顯示出來,例如顏色屬性,需要用顏色選擇器顯示;數值類的屬性需要用 type=number 的 input 組件顯示等等。

        為了方便用戶修改屬性值,我使用 v-model 將組件和值綁定在一起。

        9. 預覽、保存代碼

        預覽和編輯的渲染原理是一樣的,區別是不需要編輯功能。所以只需要將原先渲染組件的代碼稍微改一下就可以了。

        <!--頁面組件列表展示-->
        <Shape v-for="(item, index) in componentData"
            :defaultStyle="item.style"
            :style="getShapeStyle(item.style, index)"
            :key="item.id"
            :active="item === curComponent"
            :element="item"
            :zIndex="index"
        >
            <component
                class="component"
                :is="item.component"
                :style="getComponentStyle(item.style)"
                :propValue="item.propValue"
            />
        </Shape>

        經過剛才的介紹,我們知道 Shape 組件具備了拖拽、放大縮小的功能?,F在只需要將 Shape 組件去掉,外面改成套一個普通的 DIV 就可以了(其實不用這個 DIV 也行,但為了綁定事件這個功能,所以需要加上)。

        <!--頁面組件列表展示-->
        <div v-for="(item, index) in componentData" :key="item.id">
            <component
                class="component"
                :is="item.component"
                :style="getComponentStyle(item.style)"
                :propValue="item.propValue"
            />
        </div>

        保存代碼的功能也特別簡單,只需要保存畫布上的數據 componentData 即可。保存有兩種選擇:

        1. 保存到服務器
        2. 本地保存

        在 DEMO 上我使用的 localStorage 保存在本地。

        10. 綁定事件

        每個組件有一個 events 對象,用于存儲綁定的事件。目前我只定義了兩個事件:

        • alert 事件
        • redirect 事件
        // 編輯器自定義事件
        const events = {
            redirect(url) {
                if (url) {
                    window.location.href = url
                }
            },
        
            alert(msg) {
                if (msg) {
                    alert(msg)
                }
            },
        }
        
        const mixins = {
            methods: events,
        }
        
        const eventList = [
            {
                key: 'redirect',
                label: '跳轉事件',
                event: events.redirect,
                param: '',
            },
            {
                key: 'alert',
                label: 'alert 事件',
                event: events.alert,
                param: '',
            },
        ]
        
        export {
            mixins,
            events,
            eventList,
        }

        不過不能在編輯的時候觸發,可以在預覽的時候觸發。

        添加事件

        通過 v-for 指令將事件列表渲染出來:

        <el-tabs v-model="eventActiveName">
            <el-tab-pane v-for="item in eventList" :key="item.key" :label="item.label" :name="item.key" style="padding: 0 20px">
                <el-input v-if="item.key == 'redirect'" v-model="item.param" type="textarea" placeholder="請輸入完整的 URL" />
                <el-input v-if="item.key == 'alert'" v-model="item.param" type="textarea" placeholder="請輸入要 alert 的內容" />
                <el-button style="margin-top: 20px;" @click="addEvent(item.key, item.param)">確定</el-button>
            </el-tab-pane>
        </el-tabs>

        選中事件時將事件添加到組件的 events 對象。

        觸發事件

        預覽或真正渲染頁面時,也需要在每個組件外面套一層 DIV,這樣就可以在 DIV 上綁定一個點擊事件,點擊時觸發我們剛才添加的事件。

        <template>
            <div @click="handleClick">
                <component
                    class="conponent"
                    :is="config.component"
                    :style="getStyle(config.style)"
                    :propValue="config.propValue"
                />
            </div>
        </template>
        handleClick() {
            const events = this.config.events
            // 循環觸發綁定的事件
            Object.keys(events).forEach(event => {
                this[event](events[event])
            })
        }

        11. 綁定動畫

        動畫和事件的原理是一樣的,先將所有的動畫通過 v-for 指令渲染出來,然后點擊動畫將對應的動畫添加到組件的 animations 數組里。同事件一樣,執行的時候也是遍歷組件所有的動畫并執行。

        為了方便,我們使用了 animate.css 動畫庫。

        // main.js
        import '@/styles/animate.css'

        現在我們提前定義好所有的動畫數據:

        export default [
            {
                label: '進入',
                children: [
                    { label: '漸顯', value: 'fadeIn' },
                    { label: '向右進入', value: 'fadeInLeft' },
                    { label: '向左進入', value: 'fadeInRight' },
                    { label: '向上進入', value: 'fadeInUp' },
                    { label: '向下進入', value: 'fadeInDown' },
                    { label: '向右長距進入', value: 'fadeInLeftBig' },
                    { label: '向左長距進入', value: 'fadeInRightBig' },
                    { label: '向上長距進入', value: 'fadeInUpBig' },
                    { label: '向下長距進入', value: 'fadeInDownBig' },
                    { label: '旋轉進入', value: 'rotateIn' },
                    { label: '左順時針旋轉', value: 'rotateInDownLeft' },
                    { label: '右逆時針旋轉', value: 'rotateInDownRight' },
                    { label: '左逆時針旋轉', value: 'rotateInUpLeft' },
                    { label: '右逆時針旋轉', value: 'rotateInUpRight' },
                    { label: '彈入', value: 'bounceIn' },
                    { label: '向右彈入', value: 'bounceInLeft' },
                    { label: '向左彈入', value: 'bounceInRight' },
                    { label: '向上彈入', value: 'bounceInUp' },
                    { label: '向下彈入', value: 'bounceInDown' },
                    { label: '光速從右進入', value: 'lightSpeedInRight' },
                    { label: '光速從左進入', value: 'lightSpeedInLeft' },
                    { label: '光速從右退出', value: 'lightSpeedOutRight' },
                    { label: '光速從左退出', value: 'lightSpeedOutLeft' },
                    { label: 'Y軸旋轉', value: 'flip' },
                    { label: '中心X軸旋轉', value: 'flipInX' },
                    { label: '中心Y軸旋轉', value: 'flipInY' },
                    { label: '左長半徑旋轉', value: 'rollIn' },
                    { label: '由小變大進入', value: 'zoomIn' },
                    { label: '左變大進入', value: 'zoomInLeft' },
                    { label: '右變大進入', value: 'zoomInRight' },
                    { label: '向上變大進入', value: 'zoomInUp' },
                    { label: '向下變大進入', value: 'zoomInDown' },
                    { label: '向右滑動展開', value: 'slideInLeft' },
                    { label: '向左滑動展開', value: 'slideInRight' },
                    { label: '向上滑動展開', value: 'slideInUp' },
                    { label: '向下滑動展開', value: 'slideInDown' },
                ],
            },
            {
                label: '強調',
                children: [
                    { label: '彈跳', value: 'bounce' },
                    { label: '閃爍', value: 'flash' },
                    { label: '放大縮小', value: 'pulse' },
                    { label: '放大縮小彈簧', value: 'rubberBand' },
                    { label: '左右晃動', value: 'headShake' },
                    { label: '左右扇形搖擺', value: 'swing' },
                    { label: '放大晃動縮小', value: 'tada' },
                    { label: '扇形搖擺', value: 'wobble' },
                    { label: '左右上下晃動', value: 'jello' },
                    { label: 'Y軸旋轉', value: 'flip' },
                ],
            },
            {
                label: '退出',
                children: [
                    { label: '漸隱', value: 'fadeOut' },
                    { label: '向左退出', value: 'fadeOutLeft' },
                    { label: '向右退出', value: 'fadeOutRight' },
                    { label: '向上退出', value: 'fadeOutUp' },
                    { label: '向下退出', value: 'fadeOutDown' },
                    { label: '向左長距退出', value: 'fadeOutLeftBig' },
                    { label: '向右長距退出', value: 'fadeOutRightBig' },
                    { label: '向上長距退出', value: 'fadeOutUpBig' },
                    { label: '向下長距退出', value: 'fadeOutDownBig' },
                    { label: '旋轉退出', value: 'rotateOut' },
                    { label: '左順時針旋轉', value: 'rotateOutDownLeft' },
                    { label: '右逆時針旋轉', value: 'rotateOutDownRight' },
                    { label: '左逆時針旋轉', value: 'rotateOutUpLeft' },
                    { label: '右逆時針旋轉', value: 'rotateOutUpRight' },
                    { label: '彈出', value: 'bounceOut' },
                    { label: '向左彈出', value: 'bounceOutLeft' },
                    { label: '向右彈出', value: 'bounceOutRight' },
                    { label: '向上彈出', value: 'bounceOutUp' },
                    { label: '向下彈出', value: 'bounceOutDown' },
                    { label: '中心X軸旋轉', value: 'flipOutX' },
                    { label: '中心Y軸旋轉', value: 'flipOutY' },
                    { label: '左長半徑旋轉', value: 'rollOut' },
                    { label: '由小變大退出', value: 'zoomOut' },
                    { label: '左變大退出', value: 'zoomOutLeft' },
                    { label: '右變大退出', value: 'zoomOutRight' },
                    { label: '向上變大退出', value: 'zoomOutUp' },
                    { label: '向下變大退出', value: 'zoomOutDown' },
                    { label: '向左滑動收起', value: 'slideOutLeft' },
                    { label: '向右滑動收起', value: 'slideOutRight' },
                    { label: '向上滑動收起', value: 'slideOutUp' },
                    { label: '向下滑動收起', value: 'slideOutDown' },
                ],
            },
        ]

        然后用 v-for 指令渲染出來動畫列表。

        添加動畫

        <el-tabs v-model="animationActiveName">
            <el-tab-pane v-for="item in animationClassData" :key="item.label" :label="item.label" :name="item.label">
                <el-scrollbar class="animate-container">
                    <div
                        class="animate"
                        v-for="(animate, index) in item.children"
                        :key="index"
                        @mouseover="hoverPreviewAnimate = animate.value"
                        @click="addAnimation(animate)"
                    >
                        <div :class="[hoverPreviewAnimate === animate.value && animate.value + ' animated']">
                            {{ animate.label }}
                        </div>
                    </div>
                </el-scrollbar>
            </el-tab-pane>
        </el-tabs>

        點擊動畫將調用 addAnimation(animate) 將動畫添加到組件的 animations 數組。

        觸發動畫

        運行動畫的代碼:

        export default async function runAnimation($el, animations = []) {
            const play = (animation) => new Promise(resolve => {
                $el.classList.add(animation.value, 'animated')
                const removeAnimation = () => {
                    $el.removeEventListener('animationend', removeAnimation)
                    $el.removeEventListener('animationcancel', removeAnimation)
                    $el.classList.remove(animation.value, 'animated')
                    resolve()
                }
                    
                $el.addEventListener('animationend', removeAnimation)
                $el.addEventListener('animationcancel', removeAnimation)
            })
        
            for (let i = 0, len = animations.length; i < len; i++) {
                await play(animations[i])
            }
        }

        運行動畫需要兩個參數:組件對應的 DOM 元素(在組件使用 this.$el 獲?。┖退膭赢嫈祿?animations。并且需要監聽 animationend 事件和 animationcancel 事件:一個是動畫結束時觸發,一個是動畫意外終止時觸發。

        利用這一點再配合 Promise 一起使用,就可以逐個運行組件的每個動畫了。

        12. 導入 PSD

        由于時間關系,這個功能我還沒做?,F在簡單的描述一下怎么做這個功能。那就是使用 psd.js 庫,它可以解析 PSD 文件。

        使用 psd 庫解析 PSD 文件得出的數據如下:

        { children: 
           [ { type: 'group',
               visible: false,
               opacity: 1,
               blendingMode: 'normal',
               name: 'Version D',
               left: 0,
               right: 900,
               top: 0,
               bottom: 600,
               height: 600,
               width: 900,
               children: 
                [ { type: 'layer',
                    visible: true,
                    opacity: 1,
                    blendingMode: 'normal',
                    name: 'Make a change and save.',
                    left: 275,
                    right: 636,
                    top: 435,
                    bottom: 466,
                    height: 31,
                    width: 361,
                    mask: {},
                    text: 
                     { value: 'Make a change and save.',
                       font: 
                        { name: 'HelveticaNeue-Light',
                          sizes: [ 33 ],
                          colors: [ [ 85, 96, 110, 255 ] ],
                          alignment: [ 'center' ] },
                       left: 0,
                       top: 0,
                       right: 0,
                       bottom: 0,
                       transform: { xx: 1, xy: 0, yx: 0, yy: 1, tx: 456, ty: 459 } },
                    image: {} } ] } ],
            document: 
               { width: 900,
                 height: 600,
                 resources: 
                  { layerComps: 
                     [ { id: 692243163, name: 'Version A', capturedInfo: 1 },
                       { id: 725235304, name: 'Version B', capturedInfo: 1 },
                       { id: 730932877, name: 'Version C', capturedInfo: 1 } ],
                    guides: [],
                    slices: [] } } }

        從以上代碼可以發現,這些數據和 css 非常像。根據這一點,只需要寫一個轉換函數,將這些數據轉換成我們組件所需的數據,就能實現 PSD 文件轉成渲染組件的功能。目前 quark-h5luban-h5 都是這樣實現的 PSD 轉換功能。

        13. 手機模式

        由于畫布是可以調整大小的,我們可以使用 iphone6 的分辨率來開發手機頁面。

        這樣開發出來的頁面也可以在手機下正常瀏覽,但可能會有樣式偏差。因為我自定義的三個組件是沒有做適配的,如果你需要開發手機頁面,那自定義組件必須使用移動端的 UI 組件庫?;蛘咦约洪_發移動端專用的自定義組件。

        總結

        由于 DEMO 的代碼比較多,所以在講解每一個功能點時,我只把關鍵代碼貼上來。所以大家會發現 DEMO 的源碼和我貼上來的代碼會有些區別,請不必在意。

        另外,DEMO 的樣式也比較簡陋,主要是最近事情比較多,沒太多時間寫好看點,請見諒。

        參考資料

        查看原文

        贊 69 收藏 52 評論 2

        譚光志 贊了文章 · 2020-11-27

        前端黑科技:美團網頁首幀優化實踐

        前言

        自JavaScript誕生以來,前端技術發展非常迅速。移動端白屏優化是前端界面體驗的一個重要優化方向,Web 前端誕生了 SSR 、CSR、預渲染等技術。在美團支付的前端技術體系里,通過預渲染提升網頁首幀優化,從而優化了白屏問題,提升用戶體驗,并形成了最佳實踐。

        在前端渲染領域,主要有以下幾種方式可供選擇:

        CSR預渲染SSR同構
        優點不依賴數據FP 時間最快客戶端用戶體驗好內存數據共享不依賴數據FCP 時間比 CSR 快客戶端用戶體驗好內存數據共享SEO 友好首屏性能高,FMP 比 CSR 和預渲染快SEO 友好首屏性能高,FMP 比 CSR 和預渲染快客戶端用戶體驗好內存數據共享客戶端與服務端代碼公用,開發效率高
        缺點SEO 不友好FCP 、FMP 慢SEO 不友好FMP 慢客戶端數據共享成本高模板維護成本高Node 容易形成性能瓶頸

        通過對比,同構方案集合 CSR 與 SSR 的優點,可以適用于大部分業務場景。但由于在同構的系統架構中,連接前后端的 Node 中間層處于核心鏈路,系統可用性的瓶頸就依賴于 Node ,一旦作為短板的 Node 掛了,整個服務都不可用。

        結合到我們團隊負責的支付業務場景里,由于支付業務追求極致的系統穩定性,服務不可用直接影響到客訴和資損,因此我們采用瀏覽器端渲染的架構。在保證系統穩定性的前提下,還需要保障用戶體驗,所以采用了預渲染的方式。

        那么究竟什么是預渲染呢?什么是 FCP/FMP 呢?我們先從最常見的 CSR 開始說起。

        以 Vue 舉例,常見的 CSR 形式如下:

        一切看似很美好。然而,作為以用戶體驗為首要目標的我們發現了一個體驗問題:首屏白屏問題。

        為什么會首屏白屏

        瀏覽器渲染包含 HTML 解析、DOM 樹構建、CSSOM 構建、JavaScript 解析、布局、繪制等等,大致如下圖所示:

        要搞清楚為什么會有白屏,就需要利用這個理論基礎來對實際項目進行具體分析。通過 DevTools 進行分析:

        • 等待 HTML 文檔返回,此時處于白屏狀態。
        • 對 HTML 文檔解析完成后進行首屏渲染,因為項目中對 id="app加了灰色的背景色,因此呈現出灰屏。
        • 進行文件加載、JS 解析等過程,導致界面長時間出于灰屏中。
        • 當 Vue 實例觸發了 mounted 后,界面顯示出大體框架。
        • 調用 API 獲取到時機業務數據后才能展示出最終的頁面內容。

        由此得出結論,因為要等待文件加載、CSSOM 構建、JS 解析等過程,而這些過程比較耗時,導致用戶會長時間出于不可交互的首屏灰白屏狀態,從而給用戶一種網頁很“慢”的感覺。那么一個網頁太“慢”,會造成什么影響呢?

        “慢”的影響

        Global Web Performance Matters for ecommerce的報告中指出:

        • 57%的用戶更在乎網頁在3秒內是否完成加載。
        • 52%的在線用戶認為網頁打開速度影響到他們對網站的忠實度。
        • 每慢1秒造成頁面 PV 降低11%,用戶滿意度也隨之降低降低16%。
        • 近半數移動用戶因為在10秒內仍未打開頁面從而放棄。

        我們團隊主要負責美團支付相關的業務,如果網站太慢會影響用戶的支付體驗,會造成客訴或資損。既然網站太“慢”會造成如此重要的影響,那要如何優化呢?

        優化思路

        User-centric Performance Metrics一文中,共提到了4個頁面渲染的關鍵指標:

        基于這個理論基礎,再回過頭來看看之前項目的實際表現:

        可見在 FP 的灰白屏界面停留了很長時間,用戶不清楚網站是否有在正常加載,用戶體驗很差。

        試想:如果我們可以將 FCP 或 FMP 完整的 HTML 文檔提前到 FP 時機預渲染,用戶看到頁面框架,能感受到頁面正在加載而不是冷冰冰的灰白屏,那么用戶更愿意等待頁面加載完成,從而降低了流失率。并且這種改觀在弱網環境下更明顯。

        通過對比 FP、FCP、FMP 這三個時期 DOM 的差異,發現區別在于:



        • FP:僅有一個 div 根節點。
        • FCP:包含頁面的基本框架,但沒有數據內容。
        • FMP:包含頁面所有元素及數據。

        仍然以 Vue 為例, 在其生命周期中,mounted 對應的是 FCP,updated 對應的是 FMP。那么具體應該使用哪個生命周期的 HTML 結構呢?

        mounted (FCP)updated (FMP)
        缺點只是視覺體驗將 FCP 提前,實際的 TTI 時間變化不大構建時需要獲取數據,編譯速度慢構建時與運行時的數據存在差異性有復雜交互的頁面,仍需等待,實際的 TTI 時間變化不大
        優點不受數據影響,編譯速度快首屏體驗好對于純展示類型的頁面,FP 與 TTI 時間近乎一致

        通過以上的對比,最終選擇在 mounted 時觸發構建時預渲染。由于我們采用的是 CSR 的架構,沒有 Node 作為中間層,因此要實現 DOM 內容的預渲染,就需要在項目構建編譯時完成對原始模板的更新替換。

        至此,我們明確了構建時預渲染的大體方案。

        構建時預渲染方案

        構建時預渲染流程:

        配置讀取

        由于 SPA 可以由多個路由構成,需要根據業務場景決定哪些路由需要用到預渲染。因此這里的配置文件主要是用于告知編譯器需要進行預渲染的路由。

        在我們的系統架構里,腳手架是基于 Webpack 自研的,在此基礎上可以自定義自動化構建任務和配置。

        觸發構建

        項目中主要是使用 TypeScript,利用 TS 的裝飾器,我們封裝了統一的預渲染構建的鉤子方法,從而只用一行代碼即可完成構建時預渲染的觸發。

        裝飾器:

        使用:

        構建編譯

        從流程圖上,需要在發布機上啟動模擬的瀏覽器環境,并通過預渲染的事件鉤子獲取當前的頁面內容,生成最終的 HTML 文件。

        由于我們在預渲染上的嘗試比較早,當時還沒有 Headless Chrome 、 Puppeteer、Prerender SPA Plugin等,因此在選型上使用的是 phantomjs-prebuilt(Prerender SPA Plugin 早期版本也是基于 phantomjs-prebuilt 實現的)。

        通過 phantom 提供的 API 可獲得當前 HTML,示例如下:

        為了提高構建效率,并行對配置的多個頁面或路由進行預渲染構建,保證在 5S 內即可完成構建,流程圖如下:

        方案優化

        理想很豐滿,現實很骨感。在實際投產中,構建時預渲染方案遇到了一個問題。

        我們梳理一下簡化后的項目上線過程:

        開發 -> 編譯 -> 上線

        假設本次修改了靜態文件中的一個 JS 文件,這個文件會通過 CDN 方式在 HTML 里引用,那么最終在 HTML 文檔中的引用方式是 <script data-original="http://cdn.com/index.js"></script>。然而由于項目還沒有上線,所以其實通過完整 URL 的方式是獲取不到這個文件的;而預渲染的構建又是在上線動作之前,所以問題就產生了:

        構建時預渲染無法正常獲取文件,導致編譯報錯

        怎么辦?

        請求劫持

        因為在做預渲染時,我們使用啟動了一個模擬的瀏覽器環境,根據 phantom 提供的 API,可以對發出的請求加以劫持,將獲取 CDN 文件的請求劫持到本地,從而在根本上解決了這個問題。示例代碼如下:

        構建時預渲染研發流程及效果

        最終,構建時預渲染研發流程如下:

        開發階段:

        • 通過 TypeScript 的裝飾器單行引入預渲染構建觸發的方法。
        • 發布前修改編譯構建的配置文件。

        發布階段:

        • 先進行常規的項目構建。
        • 若有預渲染相關配置,則觸發預渲染構建。
        • 通過預渲染得到最終的文件,并完成發布上線動作。

        完整的用戶請求路徑如下:

        通過構建時預渲染在項目中的使用,FCP 的時間相比之前減少了 75%。

        作者簡介

        寒陽,美團資深研發工程師,多年前端研發經歷,負責美團支付錢包團隊和美團支付前端基礎技術。

        招聘信息

        我們美團金融服務平臺大前端研發組在高速成長中,我們歡迎更多優秀的 Web 前端研發工程師加入,感興趣的朋友可以將簡歷發送到郵箱:shanghanyang@meituan.com。

        查看原文

        贊 141 收藏 97 評論 16

        譚光志 關注了用戶 · 2020-11-19

        Peter譚老師 @jerrytanjinjie

        前端架構師

        微信公眾號:前端巔峰

        歡迎技術探討~

        個人微信:CALASFxiaotan

        關注 4390

        譚光志 關注了用戶 · 2020-11-19

        高陽Sunny @sunny

        SegmentFault 思否 CEO
        C14Z.Group Founder
        Forbes China 30U30

        獨立思考 敢于否定

        曾經是個話癆... 要做一個有趣的人!

        任何問題可以給我發私信或者發郵件 sunny@sifou.com

        關注 2173

        譚光志 發布了文章 · 2020-11-18

        chrome 開發者工具——前端實用功能總結

        1. 查看元素偽類 css 樣式

        例如我想查看元素觸發 hover 時的 css 樣式。先選中該元素,然后按下圖操作:

        2. 臨時增刪元素 class

        3. document.body.contentEditable="true"

        在控制臺輸入 document.body.contentEditable="true",就可以對頁面直接進行編輯。

        4. 查看 placeholder 樣式

        現在可以查看元素的 placeholder 樣式了:

        5. 測試頁面性能和 SEO

        下面是測試報告:

        參考資料:

        6. Network 顯示資源的其他信息

        一般 Network 會顯示加載資源的詳細信息,但它默認只顯示部分信息。如果我想查詢網頁資源是通過 HTTP1.1 還是 HTTP2 加載的,要怎么做呢?

        從 GIF 中可以看出,除了 HTTP 協議版本外,還可以查看其他信息,例如 HTTP 請求的方法、域名等等。

        7. 查看元素綁定事件

        鼠標移到 handler 上,可查看具體的函數代碼。

        8. 全局搜索代碼

        打開開發者工具,點擊 Console 標簽,按 ESC 彈出:

        點擊左邊豎形排列的三個小點,選擇 Search

        點擊搜索結果,會跳到具體的源碼文件。它會搜索該網頁下所有引入的文件。

        9. 利用 Performance 檢查運行時性能

        打開開發者工具,點擊 Performance 標簽:

        點擊左上角的 Record 按鈕開始記錄,然后你模擬正常用戶使用網頁。測試完畢后,點擊 Stop。

        可以看到右上角分別有 FPS、CPU、NET、HEAP:

        1. FPS 對應的是幀率,紅色代表幀率低,可能會降低用戶體驗;綠色代表幀率正常,綠色條越高,FPS 越高。
        2. CPU 部分上有黃色、紫色等色塊,它們的釋義請看圖的左下角。誰的占比高,說明 CPU 主要的時間花在哪里。
        3. HEAP 就是堆內存占用。

        NET 最好點擊下面的 Network 查看,可以看到具體的加載資源等。

        一般根據這些信息就能判斷出網頁性能問題出在哪。

        如果想了解更多,請查看下面的參考資料,需要翻 qiang?;蛘哂盟阉饕嫠阉?chrome performance,也有很多講解使用方法的文章。

        參考資料

        10. Rendering 實時檢測網頁變化

        打開開發者工具,點擊 Console 標簽,按 ESC 彈出:

        點擊左邊豎形排列的三個小點,選擇 Rendering

        下面是比較實用的功能:

        1. Paint flashing,實時高亮重繪區域(綠色)。
        2. Layout Shift Regions,實時高亮重排(重新布局)區域(藍色)。
        3. Layer borders,將合成層用邊框標出來(橙色、橄欖色、青色)。
        4. Frame Rendering Stats,顯示 GPU 的信息,舊版本還有實時 FPS 顯示,但新版本不知道為何沒有(chrome 86)。

        11. Application 查看應用信息

        從圖中看到,在 Application 標簽下可以查到本頁面很多信息。拿 localStorage 舉例,現在我執行代碼 localStorage.setItem('token', '123'),然后打開 Application

        不出意外,能看到新增的 localStorage 信息。

        查看原文

        贊 64 收藏 46 評論 2

        譚光志 發布了文章 · 2020-11-16

        而立之年——回顧我的前端轉行之路

        為什么轉行

        因為混得不好。

        在成為程序員之前,我干過很多工作。由于學歷的問題(高中),我的工作基本上都是體力活。包括但不限于:工廠普工、銷售(沒有干銷售的才能)、搬運工、擺地攤等,轉行前最后一份工作是修電腦。這么多年,月薪沒高過 3300...

        后來偶然一個機會我發現了知乎這個網站,在上面了解到程序員的各種優點。于是,我下定決心轉行(2016 年,當時 28 了),辭職在家自學編程。并且也得到了媳婦的支持,感謝我的媳婦。

        轉行準備

        轉行選擇前端也是在知乎上看網友分析的,比后端好入門。

        如何選擇教程?

        最好在網上多查查資料,找評價高的或者去豆瓣上找評分高的書。

        我在網上查了很多資料,最終確定 HTML、CSS 在 w3cschool 學習。JavaScript 則選擇了JavaScript 高級程序設計第三版(俗稱紅寶書,現在已經有第四版了)。

        光看不練是學不好編程的,我非常幸運的遇到了百度前端技術學院。它從易到難設置了 52 個任務,共分為四個階段。任務難度循序漸進,每一個任務都有清晰的講解和學習參考資料。它還怕你不會做,允許你查看其他人上傳的任務答案。

        我先學習了 HTML、CSS,做完了第一階段任務。再看完紅寶書前十三章,做完了第二階段任務。然后把紅寶石剩下的全看完,做到第三階段的任務四十五。后面的任務對于當時的我來說實在太難了,就沒往下做。在 1 月的時候,又學習了 ajax,了解了前后端如何相互通信。

        我從 16 年 11 月開始自學前端,一直到 17 年 2 月。歷時 3 個月,平均每天學習 3-4 個小時。中間有好幾次因為太難想過放棄,不過最后還是堅持下來了。

        找工作的過程非常艱難,我在網上各大招聘平臺投了很多簡歷,但由于沒學歷、沒經驗,所以一個回復都沒有。最后還是我媳婦工作的公司在招前端,給了我一個內推的機會,才有了第一次面試。并且第一次面試也很順利,居然過了,這是我沒想到的。直到多年后我和面試官又在一個公司的時候,才知道原因。他的意思是:看在我這么努力自學編程的份上,愿意給我一個機會。

        雖然人生很艱難,但很有可能,遇到一個愿意給你機會的人,就能改變你的命運。

        正式工作

        第一年

        在正式的項目中寫代碼和在學習時寫代碼是不一樣的。你必須得考慮這樣寫安不安全,會不會引起 BUG,會不會引起性能問題。在工作的第一年,寫業務代碼對我的提升非常大。

        第一年的主要任務,就是提升前端基礎能力。因此我看了很多 JavaScript 的書籍來提升自己的水平:

        1. JavaScript高級程序設計(第三版)
        2. 高性能JavaScript
        3. JavaScript語言精粹
        4. 你不知道的JavaScript(上中下三卷)
        5. ES6標準入門
        6. 深入淺出Node.js

        這些書都是非常經典的書籍,有幾本我還看了好幾篇。

        除了看書外,我還做了百度前端技術學院 2017 年的任務,它比 2016 年的任務(轉行時做的是 2016 年的任務)更有難度和深度,非常適合進階。

        另外還學習了 jquery 和 nodejs。jquery 是工作中要用,nodejs 則是出于興趣學習的,沒有多深入。

        第二年

        到了第二年,寫業務代碼對于我來說,已經提升不大了,就像一個熟練工一樣。而且感覺前端方面掌握的知識已經足夠把工作做好了。于是我就想,為了成為一名頂尖的程序員,還需要做什么。我在網上查了很多資料,看了很多前輩的回答,最后決定自學計算機專業。

        我制定了一個自學計算機專業的計劃,并且減少花在前端上的時間。因為說到底,基礎是地基?;A打好了,樓才能建得高。

        計算機系統要素

        計算機系統要素是我制訂計劃后開始學習的第一本書。它主要講解了計算機原理(1-5章)、編譯原理(6-11章)、操作系統相關知識(12章)。不要看內容這么多,其實這本書的內容非常通俗易懂,翻譯也很給力。每一章后面都有相關的實驗,需要你手寫代碼去完成,堪稱理論與實踐結合的經典。

        這里引用一下書里的簡介,大家可以感受一下:

        本書通過展現簡單但功能強大的計算機系統之構建過程,為讀者呈現了一幅完整、嚴格的計算機應用科學大圖景。本書作者認為,理解計算機工作原理的最好方法就是親自動手,從零開始構建計算機系統。

        通過12個章節和項目來引領讀者從頭開始,本書逐步地構建一個基本的硬件平臺和現代軟件階層體系。在這個過程中,讀者能夠獲得關于硬件體系結構、操作系統、編程語言、編譯器、數據結構、算法以及軟件工程的詳實知識。通過這種逐步構造的方法,本書揭示了計算機科學知識中的重要成分,并展示其它課程中所介紹的理論和應用技術如何融入這幅全局大圖景當中去。

        全書基于“先抽象再實現”的闡述模式,每一章都介紹一個關鍵的硬件或軟件抽象,一種實現方式以及一個實際的項目。完成這些項目所必要的計算機科學知識在本書中都有涵蓋,只要求讀者具備程序設計經驗。本書配套的支持網站提供了書中描述的用于構建所有硬件和軟件系統所必需的工具和資料,以及用于12個項目的200個測試程序。

        全書內容廣泛、涉獵全面,適合計算機及相關專業本科生、研究生、技術開發人員、教師以及技術愛好者參考和學習。

        做完這些實驗,讓我有了一個質的提升。以前感覺計算機就是一個黑盒,但現在不一樣了。我開始了解計算機內部是如何運作的。明白了自己寫的代碼是怎么經過編譯變成指令,最后在 CPU 中執行的。也明白了指令、數據怎么在 CPU 和內存之間流轉的。

        這本書所有實驗的答案我都放在了 github 上,有興趣不妨了解一下。

        Vue

        這一年還學會了 Vue。除了熟讀文檔外,還為了研究源碼而模仿 Vue1.0 版本寫了一個 mini-vue。不過學習源碼對于我寫業務代碼并沒有什么幫助。如果不是出于興趣去研究源碼,最好不要去學,熟讀文檔就能完全應付工作了。如果是為了面試,那也不需要閱讀源碼。只需要在網上找一些質量高的源碼分析文章看一看,作一下筆記就 OK 了。

        為什么我不建議閱讀源碼?因為閱讀源碼效率太低,而且相對于你的付出,收益并不大。到后面 Vue 出了 3.0 版本時,我也是有選擇地閱讀部分源碼。

        第三年

        第三年有大半年的時間浪費在王者榮耀上,那會天天只想著沖榮耀,根本沒心思學習。后來終于醒悟過來了,王者榮耀是我成為頂級程序員的阻礙。于是痛定思痛,給戒掉了。

        由于打王者的原因,第三年沒學習多少新知識?;旧现蛔隽巳拢?/p>

        1. 寫了幾個 Vue 相關的插件和項目。
        2. 將過去所學的前端知識,整理了一下放在 github 上,有空就復習一下。
        3. 學習數據結構與算法。

        數據結構與算法

        數據結構和算法有什么用?學了算法后,我覺得至少會懂得去分析程序的性能問題。

        一個程序的性能有問題,需要你去優化。如果學過數據結構和算法,你會從時間復雜度和空間復雜度去分析代碼,然后解決問題。如果沒學過,你只能靠猜、碰運氣來解決問題。

        理論知識上,我主要看的是算法這本書,課后習題沒做,改成用刷 leetcode 代替。目前已經刷了 300+ 道題,還在繼續刷。不過由于數學差,稍微復雜一點的算法知識都看不懂,效果不是很好。

        第四年

        第四年,也就是今年(2020),是我重新奮斗的一年。今年比以往的任何一年都要努力,每天保證 3 小時以上的學習時間。如果實在太忙了,達不到要求,那就改天把時間補上。附上我今年的學習時長圖(記錄軟件為 Now Then):

        今年我做了非常多的事情:

        1. 研究前端工程化。
        2. 學習操作系統。
        3. 學習計算機網絡。
        4. 學習軟件工程。
        5. 學習 C++。
        6. 學英語。

        前端工程化

        研究前端工程化的目的,就是為了提高團隊的開發效率。為此我看了很多書和資料:

        ...

        研究了一年的時間,寫了一篇質量較高的入門教程——手把手帶你入門前端工程化——超詳細教程。除此之外,還有其他工程化相關的一系列文章:

        操作系統

        操作系統是管理計算機硬件與軟件資源的計算機程序。通常情況下,程序是運行在操作系統上的,而不是直接和硬件交互。一個程序如果想和硬件交互就得通過操作系統。

        如果你掌握了操作系統的知識,你就知道程序是怎么和硬件交互的。

        例如你知道申請內存,釋放內存的內部過程是怎樣的;當你按下 k 鍵,你也知道 k 是怎么出現在屏幕上的;知道文件是怎么讀出、寫入的。

        對于操作系統,我主要學習了以下書籍:

        1. x86匯編語言:從實模式到保護模式
        2. xv6-chinese
        3. 操作系統導論

        然后做 MIT6.828 的實驗,實現了一個簡單的操作系統內核。

        計算機網絡

        計算機網絡的作用主要是解決計算機之間如何通信的問題。

        例如 A 地區和 B 地區的的計算機怎么通信?同一局域網的兩臺電腦又如何通信?學習計算機網絡知識就是了解它們是怎么通信的以及怎么將它們聯通起來。

        對于計算機網絡,我主要學習了以下書籍:

        1. 計算機網絡--自頂向下
        2. 計算機網絡
        3. HTTP權威指南
        4. HTTP/2基礎教程

        并且做了計算機網絡--自頂向下的實驗。

        軟件工程

        軟件工程是一門研究用工程化方法構建和維護有效的、實用的和高質量的軟件的學科。它涉及程序設計語言、數據庫、軟件開發工具、系統平臺、標準、設計模式等方面。

        學習以下書籍:

        1. 代碼大全(第2版)
        2. 重構(第2版)
        3. 軟件工程

        軟件工程是一門非常龐大的學科,我只學習了一點皮毛。主要學習的是關于代碼怎么寫得更好、結構組織更合理的知識,這需要一邊學習一邊在工作中運用。

        C++

        學習 C++ 其實是為了研究 nodejs 源碼用的,看的這本書C++ Primer 中文版(第 5 版)。

        英語

        我從轉行開始就一直在學習英語,不過今年花的時間比較多。

        英語對于程序員的好處非常非常多,就我知道的有:

        1. 可以用 google 和 stackoverflow 來解決問題。
        2. 知道怎么給變量、函數起一個好的命名。
        3. 很多流行的軟件都是國外程序員寫的,有問題你可以直接看文檔以及和別人交流。

        在我轉行前英語詞匯量只有幾百,三年多過去了,現在詞匯量有 6000(都是用百詞斬測的)。

        寫作

        寫作的好處是非常多的,越早寫越好。我還記得第一篇文章是 2017 年 2 月發表的,是我工作后的第 13 天,發表在 CSDN 上。

        個人認為寫作的好處有三點:

        1. 鍛煉你的寫作能力。一般情況下,寫得越多,寫作能力越好。這個好,不是說你的文章遣詞造句有多好,而是指文章條理清晰,通俗易懂,容易讓人理解。
        2. 寫作其實是費曼學習法的運用,幫助自己加深理解所學的知識。有沒有試過,學完一個知識點后,覺得自己懂了。但讓你向別人講述這個知識點時,反而吞吞吐吐不知道怎么講。其實這是沒理解透才會這樣的,要讓別人明白你在表達什么,首先你得非常熟悉這個知識點。一知半解是不可能把它講明白的,所以寫作也是在幫你梳理知識。
        3. 增加自己的曝光度。在我三年多的程序員生涯中,一共寫了 50 多篇文章,因此在一些平臺上也收獲了不少贊和粉絲。因為我寫的某些文章質量還行,不少大廠的程序員找過我,給我內推。不過由于個人學歷問題,基本上都沒下文...

        總之一句話,寫作對你只有好處,沒有壞處。

        學習

        有選擇的學習

        我覺得學習一定要有非常清晰的目標,知道你要學什么,怎么學。對于前端來說,我認為很多框架和庫都是不用學的。例如前端三大框架,沒有必要三個都學,把你工作中要用的那個掌握好就行。

        比如你公司用的是 Vue,就深入學習 Vue,如果要看源碼就只看重點部分的源碼。例如模板編譯、Diff 算法、Vue 原生組件實現、指令實現等等。

        剩下的兩個框架 React、Angular 做個 DEMO 熟悉一下就行,畢竟原理都是相通的。等你公司要上這兩個再深入學習,不過也不建議閱讀源碼了,太累??磩e人寫的現成的源碼分析文章就好。

        其他的,像 easyui、Backbone.js、各種小程序... 用不到的堅決不學,浪費時間。用的時候看文檔就行了,當然,如果有興趣了解如何實現也是可以的。

        學習方法

        我覺得好的學習方法非常重要,對我比較有用的兩個是:

        1. 費曼學習法。
        2. 學習一個知識點,最好把它吃透。

        費曼學習法在《寫作》一節中已經說過了,這里著重說說第二個。

        你有沒有過這種感覺:覺得自己會的東西很多,但其實掌握的知識很多都停留在表面上,別人要是往深一問,就懵逼了。

        我以前就有過這種感覺,主要問題出在對知識的學習僅停留在淺嘗即止的狀態。就是學習新知識,能寫個 DEMO,就覺得自己學得差不多了。這種學習方法是很有害的,首先知識存留度不高,其次是浪費時間,因為很快就會忘掉。

        后來我嘗試改正這種狀態,在學習新的知識點時,時常問自己三個問題:

        1. 這是什么?
        2. 為什么要這樣?可以不這樣嗎?
        3. 有沒有更好的方式?

        當然,不是所有問題都能適用靈魂三問,但它適用大多數情況。

        舉個例子:看過性能優化相關文章的同學應該知道有這么一條規則,要減少頁面上的 HTTP 請求。

        這是什么?

        先了解一下 HTTP 請求是啥,查資料發現原來是向服務器請求資源用的。

        為什么要減少 HTTP 請求?

        查資料發現:HTTP 請求需要經歷 DNS 查找,TCP 握手,SSL 握手(如果有的話)等一系列過程,才能真正發出這個請求。并且現代瀏覽器對于 TCP 并發數也是有限制的,超過 TCP 并發數的 HTTP 請求只能等前面的請求完成了才能繼續發送。

        我們可以打開 chrome 開發者工具看一下一個 HTTP 請求所花費的具體時間。

        在這里插入圖片描述

        這是一個 HTTP 請求,請求的文件大小為 28.4KB。

        名詞解釋:

        1. Queueing: 在請求隊列中的時間。
        2. Stalled: 從TCP 連接建立完成,到真正可以傳輸數據之間的時間差,此時間包括代理協商時間。
        3. Proxy negotiation: 與代理服務器連接進行協商所花費的時間。
        4. DNS Lookup: 執行DNS查找所花費的時間,頁面上的每個不同的域都需要進行DNS查找。
        5. Initial Connection / Connecting: 建立連接所花費的時間,包括TCP握手/重試和協商SSL。
        6. SSL: 完成SSL握手所花費的時間。
        7. Request sent: 發出網絡請求所花費的時間,通常為一毫秒的時間。
        8. Waiting(TFFB): TFFB 是發出頁面請求到接收到應答數據第一個字節的時間總和,它包含了 DNS 解析時間、 TCP 連接時間、發送 HTTP 請求時間和獲得響應消息第一個字節的時間。
        9. Content Download: 接收響應數據所花費的時間。

        從這個例子可以看出,真正下載數據的時間占比為 13.05 / 204.16 = 6.39%。文件越小,這個比例越小,文件越大,比例就越高。這就是為什么要建議將多個小文件合并為一個大文件,從而減少 HTTP 請求次數的原因。

        有沒有更好的方式?

        使用 HTTP2,所有的請求都可以放在一個 TCP 連接上發送。HTTP2 還有好多東西要學,這里不深入講解了。

        經過靈魂三問后,是不是這條優化規則的來龍去脈全都理清了,并且在你查資料動手的過程中,知識會理解得更加深刻。

        掌握了這種學習方法,并且時刻運用在學習中、工作中,突破瓶頸只是時間的問題。

        總結

        下面提前回答一下可能會有的問題。

        百度前端技術學院

        百度前端技術學院 2017 年及往后的任務,如果沒有報名,那就只能做部分任務。2016 年的任務則由于百度服務器的問題,很多題的示例圖都裂了。這個其實是有解決方案的,那就是看別人的答案。把別人的源碼下載下來,用瀏覽器打開 html 文件當示例圖看。這兩年的任務我都做了大部分,附上答案:

        1. 百度前端技術學院2016任務
        2. 百度前端技術學院2017任務

        學歷提升

        我從 18 年開始,已經報考了成人高考大專,19 年報了自考本科。大專明年 1 月就能畢業,自考本科比較難,可能 2021 年或 2022 年才能考下來。

        寫在最后

        從轉行到現在,已經過去 3 年多了。不得不說轉行當程序員給了我人生第二次機會,我也很喜歡這個職業。不過這幾年一直都是在小公司,導致自己的技術和視野得不到很大的提升。所以現在的目標除了學習計算機專業外,就是進大廠,希望有一天能實現。

        雖然今年已經 32 了,但我對未來仍然充滿希望。努力地學習,努力地提升自己,為了成為一名頂尖的程序員而努力。

        查看原文

        贊 62 收藏 28 評論 18

        一本到在线是免费观看_亚洲2020天天堂在线观看_国产欧美亚洲精品第一页_最好看的2018中文字幕