理解 Vue CLI 的目的
Vue CLI 架构上的一些设计,值得前端开发者去了解和学习。
前端开发过程中用到的很多的工具,基本上都是基于类似的架构去设计。
通过理解 Vue CLI 的原理和实践,了解一个可扩展性、适应能力更强的工具是怎样去设计和架构的。
vue-cli
早期面貌
Vue CLI 3 之前的模块是 vue-cli
,npmjs 上已经被标记为弃用。
近几年开发都使用的 @vue/cli
。
@vue/cli
也集成了早期 vue-cli
的功能,需要安装 @vue/cli-init
,内部还是依赖 vue-cli@2.9.6
这个模块。
npm install -g @vue/cli-init
# vue init [模板名称] [项目名称]
vue init webpack old-vue-cli
不仅仅可以创建 Vue.js 项目
早期的 vue-cli
的实现方式是基于模板创建项目。
- 从 github(vuejs-templates) 下载模板
- 询问一系列配置问题
- 把模板中的文件经过渲染,替换一些询问后的内容
- 输出到目标目录,并安装依赖
根据不同的模板可以创建不同的项目,所以早期的 vue-cli
并不仅仅给 Vue.js 项目用的,还可以创建其他各种类型的项目,只需要找到对应的模板。
只是一个「单纯」的脚手架工具
vue-cli
只是一个「单纯」的脚手架工具
脚手架只是创建项目的一个工具。
它作为前端工程化的起始点,就是用来自动创建项目所需要的一个必需的结构。
创建完结构之后,脚手架就和项目没有什么关系了。
「单纯」脚手架的不足
「单纯」脚手架 的不足就是用完即丢。
创建项目的过程倒没有什么。
核心是在于创建完项目后,把所需要用到的一系列配置,例如 webpack、babel等,全部以原始文件的方式存在了项目里面。
如果某个模块过时了,或者模块中使用的 API 已经删除了,开发者就需要维护这些配置中细节上的变化。
维护这些不属于业务范围的代码,对开发者的维护成本都是一种挑战。
解决思路:把这些繁琐的工具和配置装进一个「黑盒子」,把它交出去,不再由自身去维护。
就是把与框架相关的公共的配置和依赖的工具,全部抽象,封装成一个个的模块,一方维护,多方共用。
Vue CLI 升级后,就是一个黑盒子,由 Vue 专门的团队负责Vue CLI 工具链的维护。
目前主流的框架都采用这种方案提供自己的开发工具链:
- @vue/cli
- @angular/cli
- create-react-app
create-react-app 不足
create-react-app 相比其他两个框架不足的是,要么完全听它的,要么完全自己配置。
它安装一个 react-scripts
的模块,所有的配置都包含在其中。
例如,如果想要自定义 webpack 的配置或一些优化,就要把它的配置 eject
出来。
执行eject
命令时会警告,这是一个不可逆的操作。
执行后,会发现项目中多出了很多文件,最核心的就是一些配置和构建命令。
其实就是回到了早期 vue-cli
创建的项目的样子,所有的配置都在项目本地,需要自己维护。
对于一些想自定义一些配置,但又不想影响其他配置的开发者来说就不太友好。
相比下来,Vue CLI 几乎可以做到完全可配置,Angular CLI 也是类似。
不过 React 也可以使用 react-app-rewired
来解决。
Vue CLI 工具链
新版 Vue CLI 已经不仅仅是一个脚手架,它整体是一个工具链,里面包含了基于 Vue 开发的所有功能。
相当于提供了 Vue.js 类型的项目整体工程化的绝大部分。
Vue CLI 核心功能
Vue CLI 最核心的两个功能:
- 脚手架工具 - CLI 交互自动化创建项目基础机构
- 没有像早期那样在一开始就下载模板,而是集成在了模块中。
- 开发工具 - 提供 Vue.js 开发环境的 CLI 服务
- 本地测试、生产环境构建等
**可扩展性:**除了最核心的两功能,还有很多可扩展的空间,可以通过插件,提供更多的功能。
Vue CLI 的优势
使用 Vue CLI 相比 webpack 的优势:
- 开箱即用
- 默认情况下不需要任何的配置,所有配置和工具都包含在
@vue/cli-service
中
- 默认情况下不需要任何的配置,所有配置和工具都包含在
- 渐进主义
- 可以傻瓜式使用,也可以完全自定义,中间存在所有的灰度
- 零维护成本(几乎)
- 对开发者而言,开发所用的工具和配置都被包装到
@vue/cli-service
内部,一旦这些工具和配置需要升级,只要@vue/cli-service
有人维护,开发者只需要在项目中升级这个模块即可。 - 开发者只需要维护自定义的部分即可
- 对开发者而言,开发所用的工具和配置都被包装到
@vue/cli-service
Vue CLI 创建的项目使用的构建命令 vue-cli-service
是 @vue/cli-service
提供的。
它内部做了两件事情:
- 提供一个适用于大多数 Vue.js 项目的 Webpack 配置
- 把 Webpack 包装进来
开发一个开箱即用的 webpack
开发一个开箱即用的打包工具,里面有一些默认的工具和配置。
例如,将 webpack 的配置都放在模块内部,在模块内部调用 webpack,使用模块内的配置。
创建CLI工具项目结构
安装 webpack
# 注意这里 webpack 要作为这个工具的生产依赖全装
npm install webpack --save
编写 webpack 配置
// /lib/index.js
// 多个项目中公用的 Webpack 配置
/** @type {import('webpack').Configuration} */
module.exports = {
entry: './src/main.js',
output: {
filename: 'bundle.js'
}
}
编写 cli 命令
在 cli 文件中使用 webpack。
webpack 除了作为命令行方式被调用,也对外暴露了API,可以以编程的方式去使用。
它作为一个模块被导入的时候,实际是一个函数。
#!/usr/bin/env node
// /bin/cli.js
const webpack = require('webpack')
// 默认会寻找 package.json 中的 main 字段指向的文件
const config = require('..')
webpack(config, (err,stats) => {
// err 是构建过程中发生的错误
// stats 包含打包的文件信息,其中也包含错误信息
if (err || stats.hasErrors()) {
return console.log('build failed')
}
console.log('success')
})
链接到全局
npm link
创建测试项目的结构
# 返回上一级目录
cd ../
# 创建项目目录
mkdir mypack-demo
项目只需要包含 /src/main.js
文件:
// /src/main.js
console.log('Hellor Mypack~')
测试
# 直接在mypack-demo目录下运行工具命令
mypack
# success
所以只要这些公共的配置能够适用于当下的项目,都可以封装到这个工具中使用。
甚至可以发布到 NPM 上,在公司其他项目中安装并使用,不再需要额外的配置。
@vue/cli-service
做的就是类似的事情。
Vue CLI 架构
基于插件的架构
开发一个适用于大多数 Vue.js 项目的通用 Webpack 配置的模块,还需要考虑对于使用它的开发者有哪些可能性。
例如使用 less 还是 sass,是否使用 postcss,使用 typescript 或者 ES6新特性。
这时就会想到把所有有可能用到的配置全部加进去。
事实上这些可能用到的配置也并没有很多。
但是如果这样做,这个模块内部就会依赖很多其他模块,它将会变得超级重。
那些没有用到的模块和配置,就显得很多余。
为了解决这样的问题,Vue CLI 就采用了一套基于插件的架构,使得 Vue CLI 更加灵活、更容易扩展、适应能力更强。
图解
- 最外层就是
@vue/cli
,它仅仅是用来创建项目基础结构的脚手架工具。 - 创建的项目包含:
- Application 应用代码
@vue/cli-service
- 插件
- 另一个核心功能 - 提供 Vue.js 开发环境的 CLI 服务
@vue/cli-service
只包含最通用的配置- 例如Vue.js项目必然用到的
vue-loader
- 例如Vue.js项目必然用到的
- 个性化的配置,单独做成一个插件
- 例如
@vue/cli-plugin-typescript
包含 TypeScript 相关配置
- 例如
@vue/cli 源码
通过源码查看 Vue CLI 如何实现的这个架构(看源码建议看 tag 版本)。
Vue CLI 也是采用的 Monorepo 的方式,将相关模块放在同一个仓库中管理。
所有模块都放在 packages
目录下。
@vue/cli/bin/vue.js
首先查看 @vue/cli
模块的 vue create
命令入口文件
@vue/cli/lib/create.js
create 模块导出一个函数,函数返回调用 create
方法的结果。
create
方法的主要内容:
validateProjectName
校验项目名称validate-npm-package-name
- 用 npm 模块包的约定校验项目名称
- 判断输出的目标目录是否存在
- 创建 Creator 对象,接收参数:
- 项目名称
- 目标目录
- 用于 CLI 交互提问的模块
getPromptModules()
获取所有用于 CLI 交互提问的模块
- 调用 Creator 对象的
create
方法。
@vue/cli/lib/Creator.js
- Creator 构造函数中初始化问题变量,收集 CLI 交互提问的模块的问题
create
方法中收集所有问题,包括注册的插件,获取答案,生成preset
- 之后会向
preset
中添加一些插件,如@vue/cli-service
- 从
preset
中收集要安装的依赖
- 之后会向
- 开始创建项目所需的文件
- 根据用户回答添加插件
- 执行插件中的 Generator
- 最终分别创建所需文件
收集问题
- 首先构造函数中初始化几个空数组,用于存放问题。
- 然后创建
PromptModuleAPI
对象,它提供一个接口injectPrompt
,用于向injectedPrompts
添加问题。 - 遍历 CLI 交互提问的模块,每个模块都接收
PromptModuleAPI
对象,如果有问题,就会调用injectPrompt
方法,添加问题。 - 在 create 方法中进行提问的时候,通过
resolveFinalPrompts
方法合并所有的问题,包括插件的。
其他注册的插件,会提供一个提问的模块(如果需要提问)。
可以打印 prompts
看看。
Vue CLI 内部使用了 debug 模块。
开发应用的时候,一般会使用 debug 这样的工具做一些内部的日志输出。
每个操作系统使用会不一样,可以查看文档。
# PowerShell
$env:DEBUG='vue-cli:prompts'
vue create demo
收集问题的答案
create
方法中会把所有内部、外部插件等模块的问题进行合并,并提问,然后把答案收集起来,最终整理出一个 preset
对象。
create() -> promptAndResolvePreset() -> resolveFinalPrompts() -> prompts -> preset
创建项目所需的文件
查看 Vue CLI 创建的项目,会发现它会先创建一个初始的 package.json
文件,没有其他任何文件,然后立即 npm install
。
然后才会扩展修改 package.json
,一点一点生成其他文件,例如 App.vue
。
开始创建的 package.json
(选择了默认预设):
{
"name": "demo",
"version": "0.1.0",
"private": true,
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-service": "~4.5.0"
}
}
创建完成后的 package.json
:
{
"name": "demo",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"core-js": "^3.6.5",
"vue": "^2.6.11"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"vue-template-compiler": "^2.6.11"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
cli 本身只是用来在项目根目录中安装插件,真正负责在项目目录下生成文件的是 cli 所安装的插件。
每个插件目录下都有一个 generator
,它会在 create
方法的 invoking-generators
环节执行,进行生成文件,修改 package.json
等操作。
以 cli-service
为例:
为什么要在插件内设计一个 generator?
使用 Babel ,就要有 babel.config 文件;使用 TypeScript 就要有 tsconfig 文件…
选择不同的功能,可能会用到不同的插件,让插件自己去决定它要生成哪些文件更合理一些。
各司其职,而不是由 cli 主观去决定需要什么文件。
所以插件内部不仅仅是做一些额外的配置,还要承担生成一部分文件的工作。
插件中的 Generator 才是真正生成文件的地方。
@vue/cli
本身只是在安装插件。
@vue/cli 过程总结
- 先准备命令行交互的问题
- 根据用户的回答决定是否使用某个插件(这里都是内置/官方插件,第三方插件需要后续自己手动加到项目中)
- 调用 npm / yarn 自动在项目本地安装这些插件
- 调用每个插件内部的 generator
- generator 内部创建所有文件、修改package.json
@vue/cli-service 源码
@vue/cli
负责创建项目,@vue/cli-service
负责构建环节(CLI 服务)。
项目使用 vue-cli-service
命令进行运行开发环境、打包项目操作。
@vue/cli-service/vue-cli-service
内部主要导入 Service 模块,创建了一个 Service
实例,调用了它的 run
方法。
@vue/cli-service/lib/Service.js
获取全部插件列表
Service 构造函数中主要工作就是获取所有的插件列表 resolvePlugins
。
run()
内部主要根据命令行参数,决定执行哪个命令。
有的插件也会注册一些命令,例如 eslint 插件注册了 lint 命令。
vue-cli-service serve
以 serve
命令为例:
每个插件内部其实都对 webpack 进行了一些修改,例如 eslint:
@vue/cli-service 过程总结
- 创建 Service 对象
- 获取项目中所有已安装 cli 插件
- 执行
run
方法- 执行
init
方法- 加载
.env
文件 vue.config.js
用户项目配置文件- 执行所有 cli 插件,加载这个插件对应的 webpack 配置
- 加载
- 根据命令行参数执行命令
- 执行
cli 插件
- 创建项目环节
- generator - 生成所需要的文件
- Generator API
- 构建环节
- 注入对应功能的一些必要的 Webpack 配置
- Plugin API
开发本地插件
参考文档:
命令插件
项目中可以开发一个本地插件,通过 package.json
的vuePlugins
字段注册使用。
{
"vuePlugins": {
"service": [
"clean-commander.js"
]
},
"scripts": {
"clean": "vue-cli-service clean"
},
}
项目根目录下创建插件文件 clean-commander.js
。
// /clean-commander.js
/**@type {import('@vue/cli-service').ServicePlugin} */
module.exports = (api, options) => {
// 注册命令
api.registerCommand('clean', (args, rawArgs) => {
console.log('clean-commander')
})
}
执行命令:
npm run clean
# or
# npx vue-cli-service clean
# clean-commander
继续扩展该插件,实现删除 dist 目录的功能。
为避免出现文件被占用无法删除的情况,可以安装 rimraf
模块来删除。
rimraf
模块可以在 node 环境执行强制删除命令 rm -rf
。
# 由于开发的是构建命令插件,所以依赖安装在开发环境
npm install rimraf --save-dev
修改 clean-commander.js
// /clean-commander.js
const rimraf = require('rimraf')
/**@type {import('@vue/cli-service').ServicePlugin} */
module.exports = (api, options) => {
// 注册命令
api.registerCommand('clean', (args, rawArgs) => {
// console.log('clean-commander')
rimraf('./dist', err => {
if (err) return console.log('failed')
console.log('success')
})
})
}
devServer 插件
Vue CLI 运行的 devServer,在路由跳转时默认不会有任何日志。
这里开发一个插件,修改 devServer 配置。
参考:Plugin API - configureDevServer
创建文件 server-log.js
// /server-log.js
/** @type {import('@vue/cli-service').ServicePlugin} */
module.exports = (api, options) => {
// app 是 express 实例
api.configureDevServer(app => {
// 添加一个中间件
app.use((req, res, next) => {
console.log(`${req.method.toUpperCase()} ${req.url}`)
next()
})
})
}
添加到 vuePlugins
{
"vuePlugins": {
"service": [
"clean-commander.js",
"server-log.js"
]
}
}
运行项目 npm run serve
,点击页面路由跳转,查看效果
Vue CLI 打包优化
- 使用
vue-cli-service inspect >> webpack.config.js
命令将项目最终构建采用的 Webpack 配置输出 - 根据项目实际需要,找到用不到的 多余 的配置
- 在
vue.config.js
中通过 api 删除或禁用这些配置
不过优化空间并不大。