譚光志

譚光志 查看完整檔案

天津編輯  |  填寫畢業院校  |  填寫所在公司/組織 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?

參考資料

查看原文

贊 35 收藏 23 評論 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

查看原文

贊 43 收藏 35 評論 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?
查看原文

贊 1 收藏 1 評論 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]。

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

總結

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

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

參考資料

查看原文

贊 44 收藏 24 評論 6

譚光志 發布了文章 · 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?
查看原文

贊 20 收藏 11 評論 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?
查看原文

贊 4 收藏 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

認證與成就

  • 獲得 1584 次點贊
  • 獲得 15 枚徽章 獲得 1 枚金徽章, 獲得 9 枚銀徽章, 獲得 5 枚銅徽章

擅長技能
編輯

開源項目 & 著作
編輯

(??? )
暫時沒有

注冊于 2018-11-30
個人主頁被 38.4k 人瀏覽

一本到在线是免费观看_亚洲2020天天堂在线观看_国产欧美亚洲精品第一页_最好看的2018中文字幕 <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <文本链> <文本链> <文本链> <文本链> <文本链> <文本链>