关于vue-cli 的使用,我在这里就不再赘述了。相信大家使用已经很多了,今天我们就来逐步分析 vue-cli的架构,学习企业级的大型脚手架工具的开发方案。当然阅读这篇文章需要一些node和cli命令行工具的前置知识,这些内容我在:前端cli + 通用前端项目cicd设计今天我们主要讨论3方面内容: 如何搭建比较高效好用的monorepo工程 前端如 - 掘金 这篇文章中比较详细的介绍,建议大家可以先去看这一篇文章。今天的文章可以看作是对于这一篇文章的进阶和实战。
今天我们的内容主要聚焦于以下:
- vue-cli 脚手架初始化过程分析以及如何调试vue-cli
- create 命令执行流程总体分析
- vue-cli 插件化机制以及add命令分析(重点)
- 插件化设计实战:更加优雅的 command 插件化实现方案(高能扩展)
最重要的强调:
vue-cli 源码本身内容是超级多的,我们分析源码,梳理它的业务流程,并不是重点。都是为一个内容的论述进行铺垫:插件化。理解插件化机制的设计方案以及好处,无论是自定义脚手架 command 方案还是整个vue-cli分析,插件化的深度理解都是最重要的。
寻找脚手架的入口:
从上图可以看出,我们其实最关注的就是两个主要的文件夹:
docs:基于 vite-press 搭建的cli文档 packages:vue-cli多包存放目录
打开 packages 目录,我们可以看到最核心的目录 @vue 目录,这个目录里面存放了vue-cli所有的核心package:
其中,cli的核心包就是cli这个子包,我们将其打开后查看它的package.json 文件:
实际上当我们使用命令:
npm i -g @vue/cli
安装之后,安装的就是这个包,这也是整个vue-cli脚手架的入口。 既然是脚手架工具,那么它的入口就是:
也就是当我们在控制台中执行 vue 命令的时候,实际上执行的就是: bin/vue.js 这个可执行文件:
至此我们就找到了整个 vue-cli 的入口文件。剩下的我们实际上就是沿着这个入口文件去分析 vue-cli的核心实现。
cli命令行解析实现:
对于脚手架项目来说,最核心的实际上就是:接收控制台命令,并且解析命令,做出响应。要实现这一点,目前社区使用最多的三个工具分别是:
yargs(上一篇文章中搭建的cli工程就是使用这个工具解析的命令行参数)
commander
minimist(功能和使用相对比较简单,适合搭建简单点的cli工具)。
vue-cli 使用的就是 commander 这个库来定义定义命令行响应程序。
首先使用 require 导入 program 对象:
const program = require('commander')
紧接着定义了cli的基本信息:
program
.version(`@vue/cli ${require('../package').version}`)
.usage('<command> [options]')
然后最重要的一步,
使用 program.parse 来解析命令行参数。正是因为上述的定义,vue-cli 才可以正常的响应用户的命令行,并且输出了基本信息:
我们已经明白了vue-cli工具基本的实现原理,就可以开始来准备调试cli源码了。
cli 调试方法:
通过上面的分析,我们已经知道了,vue-cli 本质上就是执行 @vue/cli 这个包中的 vue.js 文件,那么我们要调试cli脚手架,本质上就是利用 node 命令来执行这个文件,所以,我们可以在root 项目的 package.json 中添加一个启动命令:
我们加入了一个脚本,利用 node 直接执行这个文件,并且传入了 create 命令,希望创建一个项目。 紧接着,我们加上断点:
最后我们以调试模式执行刚刚加入的命令:
我们可以看到程序停在了我们刚才设置的断点的地方,并且可以清晰的看到相关执行上下文堆栈的信息了。 所以vue相关的官方开源项目确实是非常优秀,这也是一个体现,它们的项目结构普遍都非常清晰,并且很容易就可以在本地调试起来,包括vue源码以及其他生态源码都是这样的。相比于其他很多开源项目,源码的调试阅读体验确实要强很多。
command 的定义:
对于cli而言,除了响应用户的命令之外,最重要的就是提供一系列开箱即用的 command 命令。基于commander 这个库,很容易就可以定义各种 command:
上面就是我们最熟悉的 vue create 这个我们最熟悉的 command 的定义。它最核心的其实就是:
// 定义命令名称
.command('create <app-name>')
// 定义命令可以接收的参数
.option('-d, --default', 'Skip prompts and use default preset')
.action((name, options) => {
// 定义命令行处理程序
})
当我们执行 vue create 的时候,就会触发 action 函数的执行。并且和jquery很像,commander 这个库所有的核心函数都是链式调用的。对于前端开发者非常的友好。 而在 action 内部,最核心的就是执行每一个 commander 关联的功能模块,执行方式很简单,就是直接 require 这个模块导出的函数,并且执行进行执行,注入 command 名字以及相关参数:
require('../lib/create')(name, options)
而所有的 command 执行函数都统一定义在lib文件夹中:
核心就是导出了一个统一接口和格式的入口函数:
结构还是非常清晰的。
vue-cli 源码本身是通过原生js编写的,如果通过ts编写的话,可以通过定义统一的 interface 来更好的规范每一个cli的结构。
vue-cli 提供的命令还是比较多的,我们在这里主要分析三个命令:
- create:比较高阶的项目初始化以及模板加载实现
- add:插件化机制分析
- vue-service: 企业级 webpack 操作和设计
今天这篇文章们看前两个命令,并且聚焦于 vue-cli 插件化机制。
vue-cli create 命令实现方案:
核心流程解析:
插件安装之前的初始化
我们在这里没有必要一一去对着源码口细节,我在这里主要是给出 create 命令整体的流程:
基于上面的流程图,我们总体上看一下create 的核心实现源码,整个 create command 最核心的操作实际上就是创建了一个 Create 对象,然后调用了 create 方法:
而在 create 方法中最核心的步骤是:
- 进入用户交互程序,收集用户创建项目的元数据:
而在 romptAndResolvePreset 函数内部最核心的逻辑就是利用 inquire 构造命令行交互界面,收集需要创建的应用的元数据信息:
当用户完成应用信息的交互之后,我们就可以看到应用的元数据信息就已经被记录下来了:
接下来就是根据元数据信息来加载对应的vue-cli插件:
分别根据用户的交互来确定需要安装的 vue-cli 插件。 紧接着确定用户使用的包管理工具:
紧接着基于上面确定的包管理工具来创建出包管理工具的操作对象:
这个对象会抹平不同的包管理工具的差异,基于底层的包管理工具,暴露出统一的操作方法:
比如安装依赖的 install 方法。
紧接着会派发一个生命周期事件:
这个事件在 cli 底层不同模块之间交互将起到至关重要的作用。这个我们后面文章会详细介绍。
紧接着会获取插件的最新版本以及初始化 package.json 配置:
在初始化完毕基本配置之后,会遍历之前已经生成的 vue-cli 插件配置,将配置信息写入到 package.json 对象中:
并且将生成的package.json 对象写入项目根目录下的 package.json 文件中:
紧接着开始利用之前已经生成的包管理对象来安装cli插件:
而这些vue-cli创建和初始化的项目模板实际上就是集成在这些插件中的。
vue-cli 插件化机制分析(高能重点):
已 vuex 插件为例:
vue-cli会基于vuex模板来初始化项目的vuex相关内容。而这个包的入口函数就是按照vue-cli统一的插件协议来定义的导出函数:
vue-router也是类的插件化设计:
包括eslint等其他工程化配置:
包括整个项目的模板:
也是一致的插件化配置。基于这套插件化管理机制,vue-cli可以轻松的利用自己的加载机制轻松的接入各类新兴工具,具有极强的扩展性。将来在模板渲染的时候,vue-cli只需要调用这个入口函数,然后注入上下文数据,插件就会自动运作,基于数据安装和渲染模板了。
另外我们此时我们的项目依赖就这么几个:
但是却已经安装了这么多插件。实际上vue-cli这里也是借鉴了和babel类似的预设机制,通过预设各类场景,将各个插件以及相关的庞大依赖全部关联起来。这也是在工程化基建中非常值得借鉴的方案。
vue-cli 基于模板渲染初始化最终的项目内容:
在获取到各类插件的模板以及项目模板之后,vue-cli 将基于 ejs 模板引擎进行模板渲染:
首先依然是创建出generator对象,然后基于元数据以及相关插件来创建项目以及基于项目模板渲染最终的文件,至于 generator 生成器细节的源码我们就不一行一行的去看了,总的来说就是两个核心操作
- 调用插件的入口函数,注入元数据,获取插件的返回结果(各类模板文件)。
- 基于得到的模板文件,利用模板元数据以及ejs引擎动态渲染出最终的文件。 :
当执行完这个操作之后,我们再一次看项目,就会发现项目已经被创建并且正确渲染出来了:
我为什么这么喜欢用渲染这个词?因为 ejs 模板的工作原理其实和vue虚拟dom渲染没有本质区别,都是数据驱动的,通过向项目模板中注入数据来动态渲染出最终的内容。实际上模板引擎就是 mvvm 框架的萌芽。 最后就是安装最终的项目依赖了:
至此,我们就从总体上完成了对于 vue-cli create command 核心实现的分析,实际上逻辑比较清晰,也比较简单。大家基于这个学习,就可以非常轻松的完成一个自定义创建醒目模板的cli命令了。这里面的插件化设计机制以及与项目依赖预设的设计,值得我们好好揣摩与学习。
vue-cli add 命令实现原理(进一步展开cli插件化实现机制)
插件安装的准备工作
add 命令实际上也是我们上面分析过的 vue-cli 插件化机制的很好的体现,我们从这个命令的介绍中也可以看到,它的作用实际上就是方便的安装 vue-cli 插件。
vue-cli 所有的子命令的入口函数和主要内容都是统一的风格,所以我们很容易就可以找到它的入口函数。
我们可以看到这个处理函数接收一个剩余参数,这个参数可以接收n多个参数,并且将其组成数组。由于vue-cli 源码本身不是用 ts 编写的,所以我们无法一眼看出来这些参数的类型以及含义。但是我们可以通过这个函数的调用,就可以推断出来这个参数传入的内容:
我们添加一个调试语句并且加上断点,然后我们在脚本中加入一个调试命令,并且以调试模式来执行这个命令:
执行到断点地方之后我们可以看到参数的内容了:
数组的第一位是我们期待安装的第一个插件的名字,第二位是 _,它也是一个数组,内容就是由 minimist 这个工具通过解析 add 命令的入参得到的结果。
继续往下执行,我们就可以看到进入了 add 函数的调用了,并且将需要添加的插件的内容注入了进来,而且通过 process.cwd 获取到了需要添加插件的项目的目录。
紧接着会校验当前用户当前的项目是否存在git,并且是否有未提交的更改,并且进行确认。因为插件的安装过程可能会导致用户未提交的更改会被覆盖掉。我们在这个地方选择继续执行下去。
紧接着,会针对 vuex 以及 vue-router 这些内置插件进行特殊的安装处理,我们这里不是这些内置插件,所以可以继续走下去。
紧接着会开始解析用户传入 add 命令的第一个参数,我们这里直接传入了 pina 这个参数,实际上通过源码我们也可以看到它这里是可以指定安装的插件的具体的版本的。
最后会按照特定的插件命名规范生成插件的包名,至此插件安装前的准备工作就完成了。
插件的安装操作
紧接着开始安装差价,而安装方式和我们先前安装项目依赖的实现是一样的,也是直接通过构造一个包管理工具对象,然后通过调用统一暴露的接口来实现:
因为我们这里的插件目前并不是官方插件,也没有指定版本,所以会默认直接进行安装:
当插件成功安装之后,就进入了整个 add 函数最重要的内容了,会直接查找到插件的调用接口,注入上下文信息,执行这个调用接口,执行插件内置的逻辑。这也和我们上面分析的vue-cli插件化成功呼应上了:
而且当我们要扩展 vue-cli的能力的时候也是特别简单,直接根据vue-cli的插件协议来定义插件的导出接口,然后发布到npm上去就可以了。这也是插件化非常易于扩展的体现。
所以我们今天整个 vue-cli 源码分析,实际上最有价值的就是学习它精妙的插件化机制,并且可以用到我们自己的项目开发中去,这是整个 vue-cli 源码的精华。
(高能扩展):比vue-cli更加优质的 command 插件化定义和实现方案:
前面我们花费了不少精力,理解了 vue-cli 插件化机制,相信大家已经感受到插件化的强大了,实际上插件化设计的核心就是两个部分的设计:
- 插件调度中心的设计,负责按照协议接入插件,通过统一的接口执行插件
- 插件接入协议以及调度协议的定义
只要把这两步做好,就可以利用插件化让我们的系统变得更加开放,更加灵活,更加规范。下面我们就来实操一下。
而要深入了解插件的调度协议的设计方案,大家可以参考 webpack tapable 这个任务调度库,实际上早期还有一个 gulp 的工具,它本质上也是一个插件调度器,可以定义各类任务流来调度插件,目前还是有些项目用它来控制自动化构建流程。
上面我们看了vue-cli的 commander 定义方式,实际上还有优化空间:那就是让脚手架的command的设计和定义更加插件化。下面的方案是我结合lerna等其他cli工具的设计之后给出的一些优化思路,供大家参考(这一块更加偏向于项目初期架构设计时候需要考虑的内容,大家可以不要过于关注细节,而只需要把握大致设计方案和思考的方向以及一些设计模式(插件化)的应用):
1. 提出优化方向:
- 现在这个主文件参杂了过多具体command的实现细节了,其实是不利于阅读和维护的,我们一眼看上去,很难知道这个脚手架有哪些核心命令。我们仔细思考,就会发现,其实关于具体 command 的参数以及其他细节定义,是和具体的 command 实现有关的,其实和cli主流程没有太大的关系,cli主流程只需要将这些command注册进来就可以了。
- 我现在加入需要添加以及删除一个脚手架稍显繁琐,我需要编写和删除很多内容,我们希望,整个cli就是一个聚合和调度各类插件的容器,而每一个command就是一个具体的插件。我们可以通过调用cli主程序暴露的命令就可以快速接入和下架指定的command,甚至,因为执行command本身就是一个可执行的nodejs程序,我们也可以进一步将command抽象为一个vue-cli的子包,甚至是一个第三方的npm包。只要是按照我们cli定义的command接口规范定义的command程序,就可以轻松的无缝接入,从而将整个脚手架打造成一个拥有广泛插件市场的庞大工具。这也是大型团队非常重要的要求。
- 基于上面的需求进一步思考,我们的脚手架已经是一个拥有大型的插件市场的庞大工具链了,那么,如果我们的脚手架导入action的时候还是直接使用:
require('xxx')
的形式的话,那么假如我们的脚手架已经接入了几十个 command,每一个command的背后都是一个比较大的npm包,那么这样导入,就会导致用户在初始化安装我们的脚手架的时候,就会将这些npm包以及它们的依赖全部安装下来,这样就会造成巨大的资源浪费而且初次安装以及升级cli的成本都会很费时费力,所以插件化的中级目标就是用户在安装cli的时候只会安装cli的core包,也就是主体程序。至于对应的command的包,需要让用户按需安装,等到用户需要用到这个包的时候,我们的cli会自动帮他安装并且自动升级对应的command,然后自动执行command。
综上所述:
- 插件化
- 动态按需加载command
就成为我们优化cli架构的核心发力点。
2. cli 插件协议的定义
我们首先从宏观上将插件的协议确定下来,我们希望在创建cli命令行程序的时候是这样的
const program = require('commander')
program.parse(process.argv)
当我们需要加载指定的 command 的时候,假如我们需要定义 create 的 command,我们这样做:
const createCommand = require('./command/create')
在导入 create 的 command 之后,我们就将其注册到脚手架中:
// 注册 create command
registry(createCommand)
// 注册其他更多 command
// ...
这样之后,我们的cli的入口代码就可以变更更加简洁明了了,而且符合插件化的设计原则,非常容易就可以新增下架制定 command 命令。
紧接着我们给出核心注册函数 registry 的入参定义:
interface registryArg {
(command: Command) => void
}
基于这个定义,我们给出核心实现:
const Command = require('./cliModel')
const registry = (commandFn: registryArg) => {
// 初始化 command 实例
const commandIns = new Command()
// 将 command 实例注入 command 处理函数
commandFn(commandIns)
}
registry 函数中是一个典型的构造者和依赖注入模式。首先会初始化一个基础的 Command 实例,然后再对应的 command 功能函数中会逐步丰富这个实例,添加这个command独有的需求。
接下来我们给出 create 这个 command 的核心实现:
const createCommand = (command: Command) => {
// 定义command的名称
command.commandName('create')
.desc('创建一个项目,初始化安装依赖,初始化启动项目')
.prev(() => {
// 定义 command 预处理钩子,将在 command 初始化阶段执行
})
.runner(() => {
// 定义 command 执行勾子,是command真正的执行函数
})
.after(() => {
// 定义 command 后处理器,将在 command 核心内容执行完毕i之后进行收尾工作
})
}
这个 command 函数在接收到了 Command 实例对象之后,会针对性的定义 create command 自定义内容,最核心的就是定义出 create command 执行的勾子函数。这个勾子函数其实参照了 axios 拦截器的设计方案,其核心原理其实是一样的。
然后我们给出核心的 Command 类的核心代码:
const program = require('commander')
class Command {
// 勾子函数
private commandEvents = []
commandName(name: string) {
program.command(name)
return this
}
desc(message: string) {
program.description(message)
return this
}
// 定义 command 勾子函数加载器
prev(prevCallback) {
this.commandEvents[0] = prevCallback
}
runner(runnerCallback) {
this.commandEvents[1] = runnerCallback
}
after(afterCallback) {
this.commandEvents[2] = afterCallback
}
// 勾子函数调度器
async scheduler() {
// 异步依次执行三个勾子函数,详细实现可以参考 axios 拦截器
// 在这里还会为所有的command统一定制错误处理机制以及错误打印机制等
}
constructor() {
// 进行一系列属性初始化
// 将调度器注册到 program command 处理函数中
program.action(async (cmd) => {
this.scheduler()
})
}
// command 类上还会提供各类方便的公共函数和工具方法
}
其实核心就是会在 Command 类初始化的时候将命令的调度器 scheduler 注册到 program 的 action 函数中并且立即执行。关于 axios 拦截器的实现原理可以参考我的这一篇文章:axios设计原理今天我们来一起看一下 axios 这个前端请求库设计。我们将主要从以下几个方面来一起看一下: axio - 掘金,只要我们深入理解了js异步编程,其实比较简单。
至此我们就完成了 command 插件化注册协议的架构设计。
3. 动态加载 command 核心函数的协议设计:
根据上面的架构设计,我们已经为 create command 定义了一个 runner 的勾子函数。按照一般的设计,接下来我们直接在 runner 函数中将 create 的核心逻辑实现出来就ok啦。所以我们很容易就可以写出这样的代码:
const createRunner = require('@xxx/createRunner')
.runner((program) => {
// 定义 command 执行勾子,是command真正的执行函数
createRunner(program)
})
这样写当然可以,但是他也会导致,无法实现 createRunner 这个 command 执行函数的懒加载。因为我们在cli初始化的时候就直接将其导入了,createRunner 将作为当前cli的直接依赖被安装进来。所以我们需要进行改造:
const { exec } require('@xxx/exec')
.runner((program) => {
// 导入 command 执行器
exec('@xxx/createRunner')
})
在这里,我们不再直接触发对应 command 的执行,而是将需要执行的包注入到一个称之为 exec 的函数中, exec 会动态帮助我们下载 @xxx/createRunner 包,并且执行这个包的入口函数。 而且因为我们现在的command命令完全是动态的了,那么我们也可以实现一个大型前端团队中可能会遇到的需求,就是不同项目以及不同团队的前端,同样是执行 create 命令,但是最终执行的包名需要根据团队和项目的区别进行动态注入:
const { exec } require('@xxx/exec')
.runner((program) => {
// 导入 command 执行器
exec(() => {
if (xx1) {
return '@xxx/createRunner1'
} else if (xxx2) {
return '@xxx/createRunner2'
} else {
return '@xxx/createRunner3'
}
})
})
exec 函数还可以接收一个函数,这个函数内部通过策略模式来根据当前的团队以及项目信息动态返回包名。
最后我们给出 exec 这个函数的大致实现:
// 定义所有 command 包的 load 协议
interface LoadCommandPkg {
(...) => xxx
}
const exec = async (pkgName: string | () => string) => {
// 首先进行参数归一化
if (typeof pkgName !== 'function') {
pkgName = () => pkgName
}
// 通过调用函数获取最终需要加载的报名
const loadPackageName = pkgName()
// 开始加载对应的包
const packageResult = await loadPackage(loadPackageName) as LoadCommandPkg
if (packageResult) {
// 将 command上下文注入到当前进程的环境变量中
// ...
// 表示包加载成功,开始执行该包
return packageResult(...)
}
// 包加载失败
// 发聩错误 ...
}
上面的核心实现中最核心的是以下三点:
-
LoadCommandPkg 我们定义了所有 command 执行函数的统一接口,凡是需要接入我们cli的子包都必须按照这个接口导出制定格式的包的入口函数。
-
我们需要实现一个 loadPackag 的函数,这个函数的核心功能是以下三个:
- 判断当前用户的电脑上是否已经下载过指定的 command 子包了,如果下载了,那么就需要校验当前子包是否最新,并且针对性进行初次下载以及更新。
- 查找当前子包的入口函数,我们约定所有command子包的入口有以下两种,我们会依次进行查找:main、module,通过package.json进行定义,我们会按照node.js 模块加载的规则进行查找并且加载入口文件的内容。
- 执行该入口文件,获取到包导出的函数。
-
在获取到入口函数之后,我们就会开始执行该入口函数,并且会通过两种方式向入口函数注入cli执行的上下文:
1.函数的入参 2.环境变量
所有的 command 子包可以通过这两种方式获取到 command 执行过程中相关的上下文信息。
至于 loadPackage 以及其他的细节我们就不再过多赘述了,因为在进行项目整体结构设计的时候不需要过多关注细节,只需要定义目标。其实只要我们node的基础比较扎实,实现起来并不复杂。
至此我们就明确了一个相比于 vue-cli,更加适合大型脚手架项目的插件化架构设计。
总结与后续
今天我们对vue-cli源码的分析就先到这里了,不要忘记我们今天最大的主题其实并不是cli具体的源码以及业务细节,这些都是铺垫,实际上最重要的是理解插件化的设计方案。
后面的一篇文章我们就针对性的深度分析 vue-cli-service 这个脚手架的设计与实现,进一步深度探讨 webpack。