Vue-cli@3.0 外掛系統簡析

滴滴WebApp架構組發表於2019-03-04

作者:肖磊

Vue-cli@3.0 是一個全新的 Vue 專案腳手架。不同於 1.x/2.x 基於模板的腳手架,Vue-cli@3.0 採用了一套基於外掛的架構,它將部分核心功能收斂至 CLI 內部,同時對開發者暴露可擴充的 API 以供開發者對 CLI 的功能進行靈活的擴充和配置。接下來我們就通過 Vue-cli@3.0 的原始碼來看下這套外掛架構是如何設計的。

整個外掛系統當中包含2個重要的組成部分:

  • @vue/cli,提供 cli 命令服務,例如vue create建立一個新的專案;
  • @vue/cli-service,提供了本地開發構建服務。

@vue/cli-service

當你使用 vue create <project-name>建立一個新的 Vue 專案,你會發現生成的專案相較於 1.x/2.x 初始化一個專案時從遠端拉取的模板發生了很大的變化,其中關於 webpack 相關的配置以及 npm script 都沒有在模板裡面直接暴露出來,而是提供了新的 npm script:

// package.json
"scripts": {
  "serve": "vue-cli-service serve",
  "build": "vue-cli-service build",
  "lint": "vue-cli-service lint"
}
複製程式碼

前 2 個指令碼命令是專案本地安裝的 @vue/cli-service 所提供的基於 webpack 及相關的外掛進行封裝的本地開發/構建的服務。@vue/cli-service 將 webpack 及相關外掛提供的功能都收斂到 @vue/cli-service 內部來實現。

這 2 個命令對應於 node_modules/@vue/cli-service/lib/commands 下的 serve.js 和 build/index.js。

在 serve.js 和 build/index.js 的內部分別暴露了一個函式及一個 defaultModes 屬性供外部來使用。事實上這兩者都是作為 built-in(內建)外掛來供 vue-cli-service 來使用的

說到這裡那麼就來看看 @vue/cli-service 內部是如何搭建整個外掛系統的。就拿執行npm run serve啟動本地開發服務來說,大概流程是這樣的:

run-serve 流程圖

首先來看下 @vue/cli-service 提供的 cli 啟動入口服務(@vue/cli-service/bin/vue-cli-service.js):

#!/usr/bin/env node

const semver = require(`semver`)
const { error } = require(`@vue/cli-shared-utils`)

const Service = require(`../lib/Service`)   // 引入 Service 基類
const service = new Service(process.env.VUE_CLI_CONTEXT || process.cwd())   // 例項化 service

const rawArgv = process.argv.slice(2)
const args = require(`minimist`)(rawArgv)
const command = args._[0]

service.run(command, args, rawArgv).catch(err => {  // 開始執行對應的 service 服務
  error(err)
  process.exit(1)
})
複製程式碼

看到這裡你會發現在 bin 裡面並未提供和本地開發 serve 相關的服務,事實上在專案當中本地安裝的 @vue/cli-service 提供的不管是內建的還是外掛提供的服務都是動態的去完成相關 CLI 服務的註冊。

在 lib/Service.js 內部定義了一個核心的類 Service,它作為 @vue/cli 的執行時的服務而存在。在執行npm run serve後,首先完成 Service 的例項化工作:

class Service {
  constructor(context) {
    ...
    this.webpackChainFns = []  // 陣列內部每項為一個fn
    this.webpackRawConfigFns = []  // 陣列內部每項為一個 fn 或 webpack 物件字面量配置項
    this.devServerConfigFns = []
    this.commands = {}  // 快取動態註冊 CLI 命令

    ...
    this.plugins = this.resolvePlugins(plugins, useBuiltIn)   // 完成外掛的載入
    this.modes = this.plugins.reduce((modes, { apply: { defaultModes }}) => {   // 快取不同 CLI 命令執行時所對應的mode值
      return Object.assign(modes, defaultModes)
    }, {})   
  }
}
複製程式碼

在例項化 Service 的過程當中完成了兩個比較重要的工作:

  • 載入外掛
  • 將外掛提供的不同命令服務所使用的 mode 進行快取

當 Service 例項化完成後,呼叫例項上的 run 方法來啟動對應的 CLI 命令所提供的服務。

async run (name, args = {}, rawArgv = []) {
  const mode = args.mode || (name === `build` && args.watch ? `development` : this.modes[name])

  // load env variables, load user config, apply plugins
  // 執行所有被載入進來的外掛
  this.init(mode)

  ...
  const { fn } = command
  return fn(args, rawArgv)  // 開始執行對應的 cli 命令服務
}

init (mode = process.env.VUE_CLI_MODE) {
  ...
  // 執行plugins
  // apply plugins.
  this.plugins.forEach(({ id, apply }) => {
    // 傳入一個例項化的PluginAPI例項,外掛名作為外掛的id標識,在外掛內部完成註冊 cli 命令服務和 webpack 配置的更新的工作
    apply(new PluginAPI(id, this), this.projectOptions)
  })

  ...
  // apply webpack configs from project config file
  if (this.projectOptions.chainWebpack) {
    this.webpackChainFns.push(this.projectOptions.chainWebpack)
  }
  if (this.projectOptions.configureWebpack) {
    this.webpackRawConfigFns.push(this.projectOptions.configureWebpack)
  }
}
複製程式碼

接下來我們先看下 @vue/cli-service 當中的 Service 例項化的過程:通過 resolvePlugins 方法去完成外掛的載入工作:

 resolvePlugins(inlinePlugins, useBuiltIn) {
    const idToPlugin = id => ({
      id: id.replace(/^.//, `built-in:`),
      apply: require(id)    // 載入對應的外掛
    })

    let plugins

    // @vue/cli-service內部提供的外掛
    const builtInPlugins = [
      `./commands/serve`,
      `./commands/build`,
      `./commands/inspect`,
      `./commands/help`,
      // config plugins are order sensitive
      `./config/base`,
      `./config/css`,
      `./config/dev`,
      `./config/prod`,
      `./config/app`
    ].map(idToPlugin)

    if (inlinePlugins) {
      plugins = useBuiltIn !== false
        ? builtInPlugins.concat(inlinePlugins)
        : inlinePlugins
    } else {
      // 載入專案當中使用的外掛
      const projectPlugins = Object.keys(this.pkg.devDependencies || {})
        .concat(Object.keys(this.pkg.dependencies || {}))
        .filter(isPlugin)
        .map(idToPlugin)
      plugins = builtInPlugins.concat(projectPlugins)
    }

    // Local plugins
    if (this.pkg.vuePlugins && this.pkg.vuePlugins.service) {
      const files = this.pkg.vuePlugins.service
      if (!Array.isArray(files)) {
        throw new Error(`Invalid type for option `vuePlugins.service`, expected `array` but got ${typeof files}.`)
      }
      plugins = plugins.concat(files.map(file => ({
        id: `local:${file}`,
        apply: loadModule(file, this.pkgContext)
      })))
    }

    return plugins
 }
複製程式碼

在這個 resolvePlugins 方法當中,主要完成了對於 @vue/cli-service 內部提供的外掛以及專案應用(package.json)當中需要使用的外掛的載入,並將對應的外掛進行快取。在其提供的內部外掛當中又分為兩類:

`./commands/serve`
`./commands/build`
`./commands/inspect`
`./commands/help`
複製程式碼

這一類外掛在內部動態註冊新的 CLI 命令,開發者即可通過 npm script 的形式去啟動對應的 CLI 命令服務。

`./config/base`
`./config/css`
`./config/dev`
`./config/prod`
`./config/app`
複製程式碼

這一類外掛主要是完成 webpack 本地編譯構建時的各種相關的配置。@vue/cli-service 將 webpack 的開發構建功能收斂到內部來完成。

外掛載入完成,開始呼叫 service.run 方法,在這個方法內部開始執行所有被載入的外掛:

this.plugins.forEach(({ id, apply }) => {
    apply(new PluginAPI(id, this), this.projectOptions)
  })
複製程式碼

在每個外掛執行的過程中,接收到的第一個引數都是 PluginAPI 的例項,PluginAPI 也是整個 @vue/cli-service 服務當中一個核心的基類:

class PluginAPI {
  constructor (id, service) {
    this.id = id            // 對應這個外掛名
    this.service = service  // 對應 Service 類的例項(單例)
  }
  ...
  registerCommand (name, opts, fn) {  // 註冊自定義 cli 命令
    if (typeof opts === `function`) {
      fn = opts
      opts = null
    }
    this.service.commands[name] = { fn, opts: opts || {}}
  }
  chainWebpack (fn) {     // 快取變更的 webpack 配置
    this.service.webpackChainFns.push(fn)
  }
  configureWebpack (fn) {   // 快取變更的 webpack 配置
    this.service.webpackRawConfigFns.push(fn)
  }
  ...
}
複製程式碼

每個由 PluginAPI 例項化的 api 例項都提供了:

  • 註冊 cli 命令服務(api.registerCommand)
  • 通過 api 形式去更新的 webpack 配置(api.chainWebpack)
  • 通過 raw 配置形式去更新的 webpack 配置(api.configureWebpack),與api.chainWebpack提供的鏈式 api 操作 webpack 配置的方式不同,api.configureWebpack可接受raw式的配置形式,並通過 webpack-merge 對 webpack 配置進行合併。
  • resolve wepack 配置(api.resolveWebpackConfig),呼叫之前通過 chainWebpack 和 configureWebpack 上完成的對於 webpack 配置的改造,並生成最終的 webpack 配置

首先我們來看下 @vue/cli-service 提供的關於動態註冊 CLI 服務的外掛,拿 serve 服務(./commands/serve)來說:

// commands/serve
module.exports = (api, options) => {
  api.registerCommand(
    `serve`,
    {
      description: `start development server`,
      usage: `vue-cli-service serve [options] [entry]`,
      options: {
        `--open`: `open browser on server start`,
        `--copy`: `copy url to clipboard on server start`,
        `--mode`: `specify env mode (default: development)`,
        `--host`: `specify host (default: ${defaults.host})`,
        `--port`: `specify port (default: ${defaults.port})`,
        `--https`: `use https (default: ${defaults.https})`,
        `--public`: `specify the public network URL for the HMR client`
      }
    },
    async function serve(args) {
      // do something
    }
  )
}
複製程式碼

./commands/serve 對外暴露一個函式,接收到的第一個引數 PluginAPI 的例項 api,並通過 api 提供的 registerCommand 方法來完成 CLI 命令(即 serve 服務)的註冊。

再來看下 @vue/cli-service 內部提供的關於 webpack 配置的外掛(./config/base):

module.exports = (api, options) => {
  api.chainWebpack(webpackConfig => {
    webpackConfig.module
      .rule(`vue`)
      .test(/.vue$/)
      .use(`cache-loader`)
      .loader(`cache-loader`)
      .options(vueLoaderCacheConfig)
      .end()
      .use(`vue-loader`)
      .loader(`vue-loader`)
      .options(
        Object.assign(
          {
            compilerOptions: {
              preserveWhitespace: false
            }
          },
          vueLoaderCacheConfig
        )
      )
  })
}
複製程式碼

這個外掛完成了 webpack 的基本配置內容,例如 entry、output、載入不同檔案型別的 loader 的配置。不同於之前使用的配置式的 webpack 使用方式,@vue/cli-service 預設使用 webpack-chain(連結請戳我) 來完成 webpack 配置的修改。這種方式也使得 webpack 的配置更加靈活,當你的專案遷移至 @vue/cli@3.0,使用的 webpack 外掛也必須要使用 API 式的配置,同時外掛不僅僅要提供外掛自身的功能,同時也需要幫助呼叫方完成外掛的註冊等工作。

@vue/cli-service 將基於 webpack 的本地開發構建配置收斂至內部來實現,當你沒有特殊的開發構建需求的時候,內部配置可以開箱即用,不用開發者去關心一些細節。當然在實際團隊開發當中,內部配置肯定是無法滿足的,得益於 @vue-cli@3.0 的外掛構建設計,開發者不需要將內部的配置進行 Eject,而是直接使用 @vue/cli-service 暴露出來的 API 去完成對於特殊的開發構建需求。

以上介紹了 @vue/cli-service 外掛系統當中幾個核心的模組,即:

Service.js 提供服務的基類,它提供了 @vue/cli 生態當中本地開發構建時:外掛載入(包括內部外掛和專案應用外掛)、外掛的初始化,它的單例被所有的外掛所共享,外掛使用它的單例去完成 webpack 的更新。

PluginAPI.js 提供供外掛使用的物件介面,它和外掛是一一對應的關係。所有供 @vue/cli-service 使用的本地開發構建的外掛接收的第一個引數都是 PluginAPI 的例項(api),外掛使用這個例項去完成 CLI 命令的註冊及對應服務的執行、webpack 配置的更新等。

以上就是 @vue/cli-service 外掛系統簡單的分析,感興趣的同學可以深入閱讀相關原始碼(連結請戳我)進行學習。

@vue/cli

不同於之前 1.x/2.x 的 vue-cli 工具都是基於遠端模板去完成專案的初始化的工作,它屬於那種大而全的方式,當你需要完成自定義的腳手架工具時,你可能要對 vue-cli 進行原始碼級別的改造,或者是在遠端模板裡面幫開發者將所有的配置檔案初始化完成好。而 @vue/cli@3.0 主要是基於外掛的 generator 去完成專案的初始化的工作,它將原來的大而全的模板拆解為現在基於外掛系統的工作方式,每個外掛完成自己所要對於專案應用的模板擴充工作。

@vue/cli 提供了終端裡面的 vue 命令,例如:

  • vue create <project> 建立一個新的 vue 專案
  • vue ui 開啟 vue-cli 的視覺化配置

當你需要對 vue-cli 進行改造,自定義符合自己開發要求的腳手架的時候,那麼你需要通過開發 vue-cli 外掛來對 vue-cli 提供的服務進行擴充來滿足相關的要求。vue-cli 外掛始終包含一個 Service 外掛作為其主要匯出,且可選的包含一個 Generator 和一個 Prompt 檔案。這裡不細講如何去開發一個 vue-cli 外掛了,大家感興趣的可以閱讀vue-cli-plugin-eslint

這裡主要是來看下 vue-cli 是如何設計整個外掛系統以及整個外掛系統是如何工作的。

@vue/cli@3.0 提供的外掛安裝方式為一個 cli 服務:vue add <plugin>

install a plugin and invoke its generator in an already created project

執行這條命令後,@vue/cli 會幫你完成外掛的下載,安裝以及執行外掛所提供的 generator。整個流程的執行順序可通過如下的流程圖去概括:

vue-cli add 流程圖

我們來看下具體的程式碼邏輯:

// @vue/cli/lib/add.js

async function add (pluginName, options = {}, context = process.cwd()) {

  ...

  const packageManager = loadOptions().packageManager || (hasProjectYarn(context) ? `yarn` : `npm`)
  // 開始安裝這個外掛
  await installPackage(context, packageManager, null, packageName)

  log(`${chalk.green(`✔`)}  Successfully installed plugin: ${chalk.cyan(packageName)}`)
  log()

  // 判斷外掛是否提供了 generator 
  const generatorPath = resolveModule(`${packageName}/generator`, context)
  if (generatorPath) {
    invoke(pluginName, options, context)
  } else {
    log(`Plugin ${packageName} does not have a generator to invoke`)
  }
}
複製程式碼

首先 cli 內部會安裝這個外掛,並判斷這個外掛是否提供了 generator,若提供了那麼去執行對應的 generator。

// @vue/cli/lib/invoke.js

async function invoke (pluginName, options = {}, context = process.cwd()) {
  const pkg = getPkg(context)

  ...
  // 從專案應用package.json中獲取外掛名
  const id = findPlugin(pkg.devDependencies) || findPlugin(pkg.dependencies)

  ...

  // 載入對應外掛提供的generator方法
  const pluginGenerator = loadModule(`${id}/generator`, context)

  ...
  const plugin = {
    id,
    apply: pluginGenerator,
    options
  }

  // 開始執行generator方法
  await runGenerator(context, plugin, pkg)
}

async function runGenerator (context, plugin, pkg = getPkg(context)) {
  ...
  // 例項化一個Generator例項
  const generator = new Generator(context, {
    pkg
    plugins: [plugin],    // 外掛提供的generator方法
    files: await readFiles(context),  // 將專案當中的檔案讀取為字串的形式儲存到記憶體當中,被讀取的檔案規則具體見readFiles方法
    completeCbs: createCompleteCbs,
    invoking: true
  })

  ...
  // resolveFiles 將記憶體當中的所有快取的 files 輸出到檔案當中
  await generator.generate({
    extractConfigFiles: true,
    checkExisting: true
  })
}
複製程式碼

和 @vue/cli-service 類似,在 @vue/cli 內部也有一個核心的類Generator,每個@vue/cli的外掛對應一個Generator的例項。在例項化Generator方法的過程當中,完成外掛提供的 generator 的執行。

// @vue/cli/lib/Generator.js

module.exports = class Generator {
  constructor (context, {
    pkg = {},
    plugins = [],
    completeCbs = [],
    files = {},
    invoking = false
  } = {}) {
    this.context = context
    this.plugins = plugins
    this.originalPkg = pkg
    this.pkg = Object.assign({}, pkg)
    this.imports = {}
    this.rootOptions = {}
    ...
    this.invoking = invoking
    // for conflict resolution
    this.depSources = {}
    // virtual file tree
    this.files = files
    this.fileMiddlewares = []
    this.postProcessFilesCbs = []

    ...
    const cliService = plugins.find(p => p.id === `@vue/cli-service`)
    const rootOptions = cliService
      ? cliService.options
      : inferRootOptions(pkg)
    // apply generators from plugins
    // 每個外掛對應生成一個 GeneratorAPI 例項,並將例項 api 傳入外掛暴露出來的 generator 函式
    plugins.forEach(({ id, apply, options }) => {
      const api = new GeneratorAPI(id, this, options, rootOptions)
      apply(api, options, rootOptions, invoking)
    })
  }
}
複製程式碼

和 @vue/cli-service 所使用的外掛類似,@vue/cli 外掛所提供的 generator 也是向外暴露一個函式,接收的第一個引數 api,然後通過該 api 提供的方法去完成應用的擴充工作。

開發者利用這個 api 例項去完成專案應用的擴充工作,這個 api 例項提供了:

  • 擴充 package.json 配置方法(api.extendPackage)
  • 利用 ejs 渲染模板檔案的方法(api.render)
  • 記憶體中儲存的檔案字串全部被寫入檔案後的回撥函式(api.onCreateComplete)
  • 向檔案當中注入import語法的方法(api.injectImports)

例如 @vue/cli-plugin-eslint 外掛的 generator 方法主要是完成了:vue-cli-service cli lint 服務命令的新增、相關 lint 標準庫的依賴新增等工作:

module.exports = (api, { config, lintOn = [] }, _, invoking) => {
  if (typeof lintOn === `string`) {
    lintOn = lintOn.split(`,`)
  }

  const eslintConfig = require(`./eslintOptions`).config(api)

  const pkg = {
    scripts: {
      lint: `vue-cli-service lint`
    },
    eslintConfig,
    devDependencies: {}
  }

  if (config === `airbnb`) {
    eslintConfig.extends.push(`@vue/airbnb`)
    Object.assign(pkg.devDependencies, {
      `@vue/eslint-config-airbnb`: `^3.0.0-rc.10`
    })
  } else if (config === `standard`) {
    eslintConfig.extends.push(`@vue/standard`)
    Object.assign(pkg.devDependencies, {
      `@vue/eslint-config-standard`: `^3.0.0-rc.10`
    })
  } else if (config === `prettier`) {
    eslintConfig.extends.push(`@vue/prettier`)
    Object.assign(pkg.devDependencies, {
      `@vue/eslint-config-prettier`: `^3.0.0-rc.10`
    })
  } else {
    // default
    eslintConfig.extends.push(`eslint:recommended`)
  }

  ...

  api.extendPackage(pkg)

  ...

  // lint & fix after create to ensure files adhere to chosen config
  if (config && config !== `base`) {
    api.onCreateComplete(() => {
      require(`./lint`)({ silent: true }, api)
    })
  }
}
複製程式碼

以上介紹了 @vue/cli 和外掛系統相關的幾個核心的模組,即:

add.js 提供了外掛下載的 cli 命令服務和安裝的功能;

invoke.js 完成外掛所提供的 generator 方法的載入和執行,同時將專案當中的檔案轉化為字串快取到記憶體當中;

Generator.js 和外掛進行橋接,@vue/cli 每次 add 一個外掛時,都會例項化一個 Generator 例項與之對應;

GeneratorAPI.js 和外掛一一對應,是 @vue/cli 暴露給外掛的 api 物件,提供了很多專案應用的擴充工作。


總結

以上是對 Vue-cli@3.0 的外掛系統當中兩個主要部分:@vue/cli 和 @vue/cli-service 簡析。

  • @vue/cli 提供 vue cli 命令,負責偏好設定,生成模板、安裝外掛依賴的工作,例如 vue create <projectName>vue add <pluginName>
  • @vue/cli-service 作為 @vue/cli 整個外掛系統當中的內部核心外掛,提供了 webpack 配置更新,本地開發構建服務

前者主要完成了對於外掛的依賴管理,專案模板的擴充等,後者主要是提供了在執行時本地開發構建的服務,同時後者也作為 @vue/cli 整個外掛系統當中的內部核心外掛而存在。在外掛系統內部也對核心功能進行了外掛化的拆解,例如 @vue/cli-service 內建的基礎 webpack 配置,npm script 命令等。二者使用約定式的方式向開發者提供外掛的擴充能力,具體到如何開發 @vue/cli 的外掛,請參考官方文件

相關文章