【Vuejs】1454- 深入了解 vue-cli

637次阅读  |  发布于1年以前

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

文章将分为两个部分,第一个部分是对 @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 提供了三个参数。

index.js - service 部分

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

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

}

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 create

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

在这个流程中,最值得关注的是 @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-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/cli 源码的一些心得。若有不足之处,欢迎在评论中指出。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8