手把手教你寫一個腳手架

最近在學習 vue-cli 的源碼,獲益良多。爲了讓本身理解得更加深入,我決定模仿它造一個輪子,爭取儘量多的實現原有的功能。html

我將這個輪子分紅三個版本:前端

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

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

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

第一個版本 v1

第一個版本的功能比較簡單,大體爲:webpack

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

項目目錄樹:git

├─.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。這個庫的功能就是解析用戶的命令,提取出用戶的輸入交給腳手架。例如這段代碼:github

#!/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 文件添加這段代碼:web

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

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

假設用戶在命令行上輸入 mvc create demo(實際上執行的是 node ./bin/mvc.js create demo),commander 解析到命令 create 和參數 demo。而後腳手架能夠在 action 回調裏取到參數 name(值爲 demo)。vuex

和用戶交互

取到用戶要建立的項目名稱 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 相關的依賴,還得在 webpack vue vue-router vuex linter 等功能中修改原有的模板代碼。

舉個例子,在 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 目錄能夠經過如下代碼獲取:

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?

參考資料

相關文章
相關標籤/搜索