深入了解 vue-cli

5,817 阅读12分钟

转转内部脚手架的 Webpack 部分,是基于 @vue/cli 进行二次封装的。选择二次封装而不是自己搞一套 Webpack 配置,是为了减少维护的成本。比如最近新出的 Vue2.7 版本,如果自行维护 Webpack 配置,可能还要对 vue-loader 进行一些调整。遇到重难点问题,还需要去看 @vue/cli 的源码作为参考,重新实现一遍它里面的逻辑。 在看任何开源库的源码之前,必须先了解它有哪些功能,这样才能针对性地分模块阅读源码。根据 @vue/cli 的文档,它大体上分为两块功能:

  • 项目模板生成 npm 包 @vue/cli 是这个功能的入口包,提供了 vue create 命令生成项目模板。
  • 开发阶段功能 这个功能对应 npm 包 @vue/cli-service,包含了一些开发时实用的命令。
    • vue-cli-service serve 用于启动开发服务器。
    • vue-cli-service lint 命令对代码进行 lint。
    • vue-cli-service inspect 查看被所有 cli 插件修改后的 Webpack 配置。

文章将分为两个部分,第一个部分是对 @vue/cli plugin 和 preset 的介绍,第二个部分是@vue/cli 的关键部分源码实现,包括插件系统实现,Webpack 配置处理等内容。

plugin 插件

cli 插件的组成

@vue/cli 设计了插件系统,一个插件是一个 npm 包,总共由 generator (模板) 和 service (服务) 两个部分组成。一个简单的插件目录是这样的:

.
├── generator.js
├── index.js
├── package.json
├── pnpm-lock.yaml

generator.js 文件对应上文的 generator 部分,负责说明该插件希望对生成的模板做出哪些改动。 index.js 文件对应上文的 service 部分,可以为 vue-cli-service 这个主命令注册新的副命令,或者对 @vue/cli 自带的一些命令做出修改。

cli 生成项目模板流程

@vue/cli 在生成项目时,会在目标目录下新建一个 package.json 文件,并在 devDependencies 中列出所有使用到的 cli 插件。此时会执行第一次 npm install ,来安装 cli 插件,@vue/cli 会调用这些插件的 generator.js,得到最终输出到目标目录的项目结构,并写入硬盘。由于 cli 插件会向 package.json 中声明一些新的依赖(比如 vue、vue-router),所以此时 @vue/cli 会执行第二次 npm install,确保这些依赖被全部安装。此时项目已经基本上创建完成,@vue/cli 调用每个插件 tempalte 部分注册的 onCreateComplete 钩子函数,执行一些项目创建完成后的逻辑。创建流程到此就结束了。

generator.js - generator 部分

接下来简单介绍 generator.js 该怎么写,它的签名如下:

/**
 * @type {import('@vue/cli').GeneratorPlugin}
 */
module.exports = function generator(api, pluginOptions, preset) {
  // 这里写插件的代码
}

generator.js 文件的逻辑很简单,只需要导出一个函数即可。@vue/cli 为 generator.js 提供了三个参数。

  • api 是 @vue/cli 内部一个名为 GeneratorAPI 的类的实例,提供了各种操作模板的方法。
  • pluginOptions @vue/cli 有一个预设的概念,预设可以指定每个 cli 插件的选项。详见下文 preset 部分。
  • preset 预设对象,详见下文 preset 部分。 其中 api 这个参数最为关键。它是一个对象,常用的属性和方法有:
  • api.extendPackage()package.json 文件进行扩展和修改。
  • api.render() 将一个文件夹 render 到创建项目的目录。可以简单地理解为,将一个文件夹复制到目标文件夹中。与复制不同的是,render 的对象支持 ejs 语法,可以在里面写部分 JS 逻辑。比如希望根据 pluginOptions 选项,来 render 不同的内容。

index.js - service 部分

generator.js 类似,index.js 同样导出一个函数。

/**
 * @type {import('@vue/cli-service').ServicePlugin}
 */
module.exports = function service(api, projectOptions) {

}
  • api @vue/cli 内部名为 ServiceAPI 的类的实例。需要注意与 GeneratorAPI 进行区分。
  • projectOptions 即 vue.config.js 文件中的选项。 api 参数常用的方法有:
  • api.configureWepback 使用 webpack-merge 修改 Webpack 配置
  • api.chainConfig 使用 webpack-chain 修改 Webpack 配置
  • api.registerCommandvue-cli-service 注册新的命令。

preset 预设

在使用 vue create 命令创建项目时,需要使用者做出几个选择,包含 Vue 版本、是否使用 TS 和 Babel 等选项。这些选项会被合并成一个对象,@vue/cli 将这个对象称为 preset。如果你曾经使用 @vue/cli 创建过项目,并选择将选项保存为一个预设,那么可以通过 cat ~/.vuerc 命令来找到保存的配置。这个配置一般长这样:

{
  // 是否使用淘宝源
  "useTaobaoRegistry": false,
  // 使用 cli 创建项目时,使用哪个包管理器安装依赖。
  "packageManager": "npm",
  // 被保存的 cli 预设
  "presets": {
    "vue3-preset": {
      "useConfigFiles": true,
      // 创建模板时,使用哪些 cli 插件。
      "plugins": {
        // key 为插件的名称,value 是插件的配置。
        "@vue/cli-plugin-babel": {},
        "@vue/cli-plugin-typescript": {
          "classComponent": false,
          "useTsWithBabel": true
        },
        "@vue/cli-plugin-router": {
          "historyMode": false
        },
        "@vue/cli-plugin-vuex": {},
        "@vue/cli-plugin-eslint": {
          "config": "prettier",
          "lintOn": [
            "save"
          ]
        }
      },
      // 新项目使用vue2还是vue3
      "vueVersion": "3",
      // 新项目使用什么css预处理器
      "cssPreprocessor": "less"
    }
  },
  // @vue/cli 的最新版本
  "latestVersion": "5.0.8",
  // 上次检查 @vue/cli 最新版本的时间
  "lastChecked": 1657541617415
}

如果 ~/.vuerc 文件中保存了历史预设,下次使用 vue create 时,就可以选择这些预设,跳过一堆问题的选择。如果希望对预设有更深入的定制,可以仿照 .vuerc 文件的格式,将预设的内容写在一个 json 文件中。比如这样一份文件:

{
  "useConfigFiles": true,
  "plugins": {
    // 为了自定义 @vue/cli 而编写的插件
    "@zz-common/vue-cli-plugin-zz": {
      "version": "^0.0.7"
    },
    "@vue/cli-plugin-babel": {},
    "@vue/cli-plugin-typescript": {
      "classComponent": false,
      "useTsWithBabel": true
    },
    "@vue/cli-plugin-router": {
      "historyMode": true
    },
    "@vue/cli-plugin-vuex": {},
    "@vue/cli-plugin-eslint": {
      "config": "prettier",
      "lintOn": ["save"]
    }
  },
  "vueVersion": "2",
  "cssPreprocessor": "dart-sass"
}

假设这个文件的名字是 vueCliPreset.json ,那么可以通过 vue create <project-name> --preset ./vueCliPreset.json 命令,来使用这个预设文件,创建对应的项目模板。

@vue/cli 运行流程

仓库概览

vue-cli 是一个基于 yarn 的 monorepo,核心包都位于 packages/@vue 文件夹下,包含:

  • @vue/cli 核心包
  • @vue/cli-service 核心包
  • @vue/cli-plugin-babel 插件
  • @vue/cli-plugin-typescript 插件
  • @vue/cli-plugin-vuex 插件
  • @vue/cli-plugin-router 插件 其中, @vue/cli 对外暴露了 vue 命令,并统筹各个 cli 插件的运作,可以将其称为入口包。它负责命令行交互、预设存取、插件统筹等工作。

@vue/cli 包含这些功能:

  • vue create 创建一个模板项目
  • vue invoke 调用某个 cli 插件的 generator 部分
  • vue add 添加一个 cli 插件
  • vue upgrade 升级一个 cli 插件
  • vue inspect 查看当前项目的 Webpack 配置

@vue/cli-service 包含这些功能:

  • vue-cli-service serve
  • vue-cli-service build 由于篇幅的原因,这里仅介绍 vue createvue-cli-service build/serve 这两个核心功能。

vue create

通常使用 vue create <project-name> 来创建一个新的项目。

  • Creator 类: @vue/cli 包中使用 commander 这个包,声明了 create 命令和对应的参数。项目创建由名为 Creator 的 class 负责,Creator 实例中的 create 方法,接收了 commander 传递的所有命令行选项,进行项目的创建。
  • prompt: 在上文提到,vue create 支持使用一个 json 文件作为预设。如果使用 json 文件预设,那么 create 命令的 prompt 询问阶段会被跳过,否则会问使用者一些问题,来生成 preset 对象。
  • 包管理器: @vue/cli 支持使用命令行参数指定包管理器。如果没有指定,则会依次降级到 .vuerc 文件、yarn、pnpm、npm。另外由于创建项目的过程中,与包管理器相关的命令调用非常多,且需要抹平不同包管理器之间的区别,所以源码中使用 PackageManager 这个类来封装包管理器操作。这是值得学习的一点。
  • 第一次安装依赖: 在一次项目创建的过程中,需要使用各种官方和非官方的插件,所以 cli 会首先根据 preset 对象中指定的插件名,来创建 package.json 文件,并使用 PackageManager.install 方法,来进行第一次安装。
  • 调用 cli 插件的 generator 部分: 在第一次安装完成后,所有的 cli 插件都被安装了。@vue/cli 此时会调用所有插件的 generator 部分,生成最终需要输出到硬盘的文件内容。
  • 调用 hook 函数: cli 会调用插件注册的一些函数,在项目创建完成后运行。
  • 完成创建

在这个流程中,最值得关注的是 @vue/cli 与 cli 插件的交互部分。在一个拥有插件系统的设计中,有插件容器和插件两个部分。容器需要将上下文内容和用户选项,提供给插件,让插件实现它的功能。所以于上下文和用户选项的整合尤为关键。以插件的 generator 部分为例,@vue/cli 使用单独的类 GeneratorAPI,为插件提供 render 文件夹、扩展 package.json 等各种实用的功能。
@vue/cli 使用 files 对象来记录最终输出到硬盘的文件内容,key 是文件路径,value 是文件内容。GeneratorAPI.render 作用是将插件指定的文件夹,render 到最终生成的项目中去。这个 API 实质是在读取 render 方法指定的文件夹,使用 ejs 模板引擎处理源文件内容,并将处理后的内容记录在 files 对象中。最后只需要根据 files 对象,将文件一一写入硬盘即可。
除了 files 对象,cli 中还有一个 pkg 对象来记录 package.json 中的内容,当插件调用 GeneratorAPI.extendPackage 时,实际上是在修改 pkg 对象。之所以 pkg 不在 files 对象中,是因为 package.json 与其它文件差异较大,cli 插件需要对它有更细粒度的操作。

总结下插件的交互部分,一共有三个关键点:

  • 合适的数据结构 在 @vue/cli 是两个对象,也可视情况采用 Set Map WeakMap WeakSet 等。
  • 操作数据结构的接口 这个情景下对应的是 GeneratorAPI,其中的方法都是在用不同的方式操作数据结构。事实上这里有两种选择,一种是直接将 files 对象暴露给插件,让插件自由发挥。优点是插件的上限更高,可以完成更复杂的功能;缺点是操作不便,files 对象的 value 是字符串,通常需要使用正则或者 ast 来操作。如果希望使用 ast,还需要根据字符串的内容,来选择不同的 parser。另一种是对 files 对象操作的方法进行封装,就像 GeneratorAPI 这个类一样。这样做的优点是,插件的代码量大大下降,出 bug 的几率更低,行为更加统一。@vue/cli 则同时采用了这两种做法,向插件传递 GeneratorAPI 实例的同时,也暴露了 files 对象。
  • hook 设计 理论上来说,插件容器暴露的 hook 数量越多,插件的上限就越高。@vue/cli 中插件暴露的 hook 并不多,比如 eslint 等配置文件的转换、项目创建结束后的 hook 等。原因之一是创建工程模板这个需求的复杂度不够高,也就不需要过多的 hook。良好的插件容器,需要将内部所有关键流程的都暴露给插件,比如配置的合并策略、插件的执行顺序、数据结构的便捷修改方法、原始数据结构等。

vue-cli-service build/serve

build 与 serve 的原理是类似的,它们都由 @vue/cli-service 这个包实现。@vue/cli-service 是一个官方的 @vue/cli 插件,它通过 ServiceAPI.registerCommand 注册了 servebuild 命令,处理 Webpack 相关的操作。这同时体现了插件系统的好处,可以将打包逻辑提取到单独的插件中,不必与 @vue/cli 的代码放在同一个包中。
build 的主体逻辑比较简单,加载 vue.config.js 文件,调用 cli 插件,得到修改后的 Webpack 配置,并使用 Webpack 进行打包。有一个点是,@vue/cli 支持 modern 模式的构建。当 modern 模式开启时,它会进行两次构建,第一次构建会通过 script 标签进行模块加载,第二次构建基于浏览器模块系统(type="module" VS nomodule)。

@vue/cli 的不足之处

@vue/cli 是一个优秀的脚手架,但仍有一些令人遗憾的设计存在。比如它对 JS API 的支持度较差,配置与 vue.config.js 文件强绑定。在进行 modern 模式的打包时,它的内部使用子进程的形式,递归地调用自身来完成功能。这会导致通过 JS API 传入的参数,被 vue.config.js 文件内容覆盖,造成意料外的行为。

vue.config.js 不支持 ts 写法,需要使用类型注释,来获得类型提示。如果希望使用 esm 格式,需要使用 .mjs 后缀,且通过环境变量传入 vue.config.mjs ,来覆盖默认的文件名。

另外,插件的 service 函数部分,返回的 Promise 没有被 await。基于 Promise 的 API 都不适合在 插件的 service 部分使用,比如 fs.readFile fs.writeFile,需要使用同步版本的 API 代替。 如果这个部分使用 cjs 代码编写,依赖了一个 esm 格式的库,那么这个库需要使用 import() 函数来导入。由于 top level await 的存在,import() 函数是一个异步函数,可能导致一部分 cli 插件代码,实际上被没有被执行完,但 @vue/cli 却误认为它已经执行完了,从而产生报错。并且报错的信息通常和 Webpack 相关,不容易注意到这是一个异步相关的问题。

最后

尽管文章中提到了 @vue/cli 的一些设计缺陷,但多少有些吹毛求疵的成分。如果将时间倒回到 @vue/cli 被创建的时间点,这样一个

  • 拥有插件系统
  • 采用了最佳实践的同时,仍保留高度自定义 Webpack 配置的能力的脚手架,给 Vue 开发者一个方便、快捷启动项目的途径,已经十分优秀且值得借鉴了。

本文是笔者在实现公司内部脚手架的 Webpack 部分时,看 @vue/cli 源码的一些心得。若有不足之处,欢迎在评论中指出。