1 写在前面的话
搭一个脚手架,考验了你的 nodejs 水平、工程化能力、以及工具服务的设计能力,是前端进阶不可或缺的过程
笔者在开发 cli 的过程中,调研流行的 cli 并形成最佳实践,本文旨在用最短的篇幅实现主要功能,揭露核心原理,同时提供 demo 仓库与大家学习探讨。
通篇阅读大约需要 10 分钟,基于本教程自己撸一个 cli 大约需要花费 15 分钟
仓库清单:文章博客 | 脚手架-全局命令包 | 脚手架-模板插件包 | 脚手架-构建插件包
2 脚手架的雏形
其实脚手架的初衷,就是提供一个最佳实践的基础模板,因此模板拷贝是其核心功能
几年前我曾写过一个极简的脚手架,大该干了这么一件事儿
-
npm publish 一个全局安装的包
-
执行命令时,wget 我云服务上的一个压缩包,并在当前文件夹下解压
一个命令,就可以把我预设的完整的工程目录创建好,特别方便效率。
我想,这应该算是一个雏形脚手架吧
3 脚手架需要考虑的
上面雏形脚手架可以很好的服务于个人需求,但是毕竟过于干瘪和简陋,要想成为被大家广泛接受的工具,还需要完善。
大家熟知的 vue-cli
create-react-app
@tarojs/cli
umi
最基本功能:首先提出一些列问题选项,然后为你的新建项目提供一份模板并安装依赖,再提供调试构建命令
没错,最核心的部分就是这个思路;但如果要做成一个可伸缩的、用户友好的,还需考虑这些需求:
-
模板支持版本管理
-
支持扩展新模板
-
自动检测版本更新
-
根据用户选择,生成个性化模板
-
友好的UI界面
-
构建功能独立,可因模板而异 (如区分H5/PC/weapp/RN)
-
多人合作项目,能确保构建结果一致
看起来信息量有点大,但其实都并不晦涩,我们一一说明一下意图
3.1 模板支持版本管理
比如用户使用 v1.0.0 的模板创建了项目,半年后,已经迭代升级到了 v2.0.0。我们需要依旧能够找到 v1.0.0 版本,因为老用户不想或者不方便升级。
像我之前的雏形脚手架,将模板打一个压缩包放在云服务器上是不可行的,一旦更新就全量替换了
npm 仓库天然支持版本管理,因此将模板发布到 npm 上自然解决了这个问题 (非开源项目,可考虑自建仓库或者私有的仓库)
3.2 支持扩展新模板
比如我们一开始我们的脚手架支持 H5 的模板。
半年后,随着业务发展,需支持微信小程序的模板。
此时,我们无需额外再开发一个 cli,而是让 cli 一开始设计的就支持扩展,这符合了开放封闭的设计原则
3.3 自动检测版本更新
npm 提供了一些命令来检测包的版本,比如你 npm view react version
返回 16.9.0
,告知你最新版本
借此,可以判断用户目前安装的是否最新版本,并提示用户更新
3.4 根据用户选择,生成个性化模板
模板虽说是为了统一,但也要在统一中支持差异,可通过问询用户,来提供差异化支持,比如:
这些问询的结果,将影响我们最终的模板,比如我们根据是否 TypeScript 会在两套预设的模板中选一个套,将用户输入的「项目介绍」插入 package.json 的 description 字段等等
3.5 友好的UI界面
合适的格式、颜色、字体、进图条等,给与用户良好的信息反馈
下文会介绍一些常用的库,来提供这些功能
3.6 构建功能独立,可因模板而异
我们通常使用 webpack 来构建/调试,对于不同的模板,构建流程存在较大差异,我们需要支持为不同的模板配置不同的构建
因此构建能力也被抽离成单独的 npm 包,模板中可指定其构建包
3.7 多人合作项目,能确保构建结果一致
因为存在多版本,我们需要约束,让所有项目的贡献者的产出是一致的
其核心原则就是:针对那些可能导致差异的因素,我们都收录到工程中,让 git 仓库记录,从而实现同样,因此,现在流行的脚手架,如 umi
taro
,都将 构建能力 local 化到本地工程中,后续会做详细阐明
4 脚手架的三类包
一个被实践检验,能够符合上述需求的脚手架架构,其实非常简单,首先我们拆分成三类 npm 包:
包 | 功能 | 安装位置 | 备注 |
---|---|---|---|
全局命令包 | 就像一个大脑,负责响应全局命令,并进行调度 | 全局包路径 | global 安装,提供全局命令 |
模板插件包 | 初始化工程所拷贝的模板 | 某个约定路径,如 ~/.maoda | 模板可随业务扩展 |
构建插件包 | 提供构建(webpack)能力 | 工程内 (目前主流脚手架都改用此方案) | 不同模板可使用同一构建包,也可不同 |
注:构建插件包,早期很多脚手架都把它放在工程外,比如放在全局,优势是多工程可复用一套 webpack 能力,但弊端也暴露出来,即在多人协同开发的项目中,由于构建插件包不在工程里没能被 git 仓库收录,导致一些不可预期的差异结果。
其调度关系如下:
5 全局命令包
功能:负责接收全局命令,并调度。
比如我做的 cli 的模板 demo cli-tpl
npm i cli-tpl -g
# 或 yarn global add cli-tpl
全局安装后,暴露出一个 dcli
命令 (自己随便取的名字),该命令有以下典型功能:
命令 | 效果 |
---|---|
dcli install[pkgName] | 安装一个「模板插件包」到 ~/.maoda 路径,如果已经安装再执行,则询问更新到最新版,如安装 dcli install gen-tpl |
dcli init | 以某个模板初始化一个新工程,执行后会让你从已装模板里选择 |
dcli build | 在工程根目录执行 (或写进工程的 scripts 里),尝试读取工程依赖的「构建插件包」并执行构建 |
dcli dev | 与 dcli build 类似,只不过是执行调试 |
5.1 cli 开发中值得收藏的一些第三方调料包
重要性 | 包名称 | 功能 |
---|---|---|
必要 | minimist | 解析用户命令,将 process.argv 解析成对象 |
必要 | fs-extra | 对 fs 库的扩展,支持 promise |
必要 | chalk | 让你 console.log 出来的字带颜色,比如成功时的绿色字 |
必要 | import-from | 类似 require,但支持指定目录,让你可以跨工程目录进行 require,比如全局包想引用工程路径下的内容 |
必要 | resolve-from | 同上,只不过是 require.resolve |
必要 | inquirer | 询问用户并记录反馈结果,界面互动的神器 |
必要 | yeoman-environment | 【核心】用于执行一个「模板插件包」,后文详细描述 |
锦上添花 | easy-table | 类似 console.table,输出漂亮的表格 |
锦上添花 | ora | 提供 loading 菊花 |
锦上添花 | semver | 提供版本比较 |
锦上添花 | figlet | console.log出一个漂亮的大logo |
锦上添花 | cross-spawn | 跨平台的child_process (跨 Windows/Mac) |
锦上添花 | osenv | 跨平台的系统信息 |
锦上添花 | open | 跨平台打开 app,比如调试的时候开打 chrome |
5.2 命令解析与分发
命令的解析与分发,是「全局命令包」的核心功能,其过程比较简单。大家也可以直接看仓库 cli-tpl (全部功能压缩到大约300行代码)
-
cli 版本更新判断:
-
先获取本 package.json 中的 version
-
再通过
npm view cli-tpl version
命令查询当前 npm 库最新版本 -
两者比较得出结论,提醒用户更新
-
-
解析用户命令
-
通过 process.argv[2] 获取到用户执行的实际命令,比如
dcli install
可拿到install
(正式版推荐使用 minimist 解析参数)
-
-
处理命令
-
比如 install 命令,则通过 require 动态映射
install.js
文件来处理该逻辑 -
注:require 支持动态名称,如
require('./scripts/'+command)
这样,如果 command 是install
则映射执行script/install.js
文件
-
接下来我们看下 4 个核心命令,主要是:
命令 | 效果 |
---|---|
install | 帮用户安装/升级一个「模板插件包」 |
init | 帮用户初始化一个工程,并拷贝模板 |
build | 调用工程中的「构建插件包」,帮用户webpack构建 |
dev | 帮用户启动 devServer 进行调试 |
下面逐一阐述每个命令的实现过程以及效果:
5.3 install命令:安装一个「模板插件包」
install 意思就是把这个模板插件包下载到硬盘;此处我做了一个最小功能的 demo 包 gen-tpl (后文详细分解)来辅助讲解
-
dcli install gen-tpl
核心处理流程如下:
-
先判断是否硬盘缓存目录
~/.maoda
下是否已经有安装过gen-tpl
包-
如果没有,则接下来进行安装 (相当于在
~/.maoda
目录下执行 npm install) -
如果有,且版本低,则提示升级
-
如果有,且版本最新,则不作为
-
-
安装过程即
execSync('npm i gen-tpl@latest -S',{cwd:'~/.maoda'})
我们可以为「模板插件包」的名称做一个约定,即具备固定的前缀,诸如
gen-xxx
5.4 init命令: 选一个「模板插件包」来初始化一个新工程
这是一个脚手架高频而核心的功能
-
dcli init
此时会分发去执行 script/init.js
文件,我们看看其逻辑
-
查询硬盘缓存目录
~/.maoda
下的package.json
文件,读取其中dependacies
字段,拿到已安装的「模板插件包」-
如果一个都没安装,则提示用户要先 install
-
-
让用户选择一套模板
-
利用
inquery
库发起对话,罗列出已装模板,让用户选择,比如上图的gen-pc
gen-h5
gen-tpl
-
-
触发模板初始化流程
-
比如用户选择了
gen-tpl
这个模板,则用yeoman-environment
这个库去执行缓存目录里的这个包~/.maoda/gen-tpl/index.js
-
注:这里相当于跨目录的两个 js 文件引用执行,用到了之前说的
import-from
这个库
-
-
「模板插件包」被执行,则启动了常规的模板拷贝过程 (后面展开细说)
这里直接用包名称做选项,为了演示更直观,实际通常用包的 description 做选项,更友好一些,比如
gen-pc
包可能描述为生成PC模板
5.5 build命令:在工程里执行构建
dcli build
-
确定工程目录
-
工程目录即执行目录,通过 process.cwd() 获取
-
-
读取该工程所用的构建插件
-
读取工程中约定的配置文件,本demo中为
maoda.js
(采用约定式的配置,类似webpack.config.js
.babelrc
.prettierrc
) -
读取
maoda.js
中builder
配置项 (即指定的构建插件包),比如本 demo 中指定为build-tpl
-
如果有的话,读取自定义 webpack 配置 (约定为 webpackCustom 字段,后续会被合并/覆盖到默认 webpack 配置上)
-
-
使用制定的构建插件包来进行 webpack 打包
-
判断工程中是否已经安装
build-tpl
-
未安装,则在工程中路径中执行
npm install
(或 yarn add,此处有个小技巧,可根据用户工程中 lock 文件的类型,判断用户使用的 npm 还是 yarn) -
已安装,则直接执行
build-tpl
-
通常,我们用配置文件指明「构建插件包」,也可以直接在命令里指明,比如 dcli build --builder=build-h5;后者往往适用于一套代码打包出多种结果,如京东的 Taro cli
平时大家用惯了 npm run build
yarn build
,只需在我们的模板中的 package.json
添加一行:
{
"script": {
++ "build": "dcli build"
}
}
5.6 dev命令:启动 devServer 进行调试
类似 build 只不过 webpack 配置不同,此处略
6 模板插件包
核心功能:提供模板文件夹 + 文件夹的拷贝。这里同样提供了一个样例工程 gen-tpl (仅 50 行代码)
处理流程如下:
-
询问用户,并获取反馈的答案
-
比如工程名是什么,描述一下你的工程,是否使用 TypeScript,是否使用 Sass/Less/Stylus 等
-
-
根据用户的答案,拷贝对应的模板,细分两种拷贝
-
直接拷贝,直接把模板插件包里的文件夹/文件,拷贝到用户工程目录
-
填充模板拷贝,将用户答案,填充到文档的对应位置,类似 WebpackHTMLPlugin、ejs,如将
name:<%=packageName%>
填充成name:我的工程
-
-
在工程中执行 npm 依赖的安装
【重点来了】看似流程蛮多,其实只用一个现成的轮子即可搞定,即 yeoman-generator,它帮我们把这些过程都封装好了,我们只需继承基类,并写几个预设的生命周期函数即可,无脑到令人发指 (细节处理,可参考模板仓库)
module.exports = class extends Generator {
// 【问询环节】
prompting() {
return this.prompt([
{
type: 'input',
name: 'appName',
message: '请输入项目名称:',
},
{
type: 'list',
choices: ['Javascript', 'TypeScript'],
name: 'language',
message: '请选择项目语言',
default: 'TypeScript',
},
]).then(answers => {
this.answers = answers
})
}
// 【模板拷贝】
writing() {
// 从模板路径拷贝到工程路径
this.fs.copy(this.templatePath(), this.destinationPath())
}
// 【安装依赖】
install() {
this.installDependencies()
}
end() {
this.log('happy coding!')
}
}
很明显,「模板插件包」导出的是一个 class,我们需要通过上文提到的「全局命令包」里的 yeoman-environment
来启动:
// 【节选自 全局命令包 init 命令,略修改以增加可读性】
yoemanEnv.register(resolveFrom('./maoda', 'gen-tpl'), 'gen-tpl')
yoemanEnv.run('gen-tpl', (e, d) => {
d && this.console('happy coding', 'green')
})
这里同样用到前文提到的 resolve-from
包,进行跨目录的引用解析
yeoman 是一个比较完善的生态,模板插件包可用 yeoman 提供的全局命令 yo 来创建,但并非必要,此处就不展开说了
7 构建插件包
同样我们提供了一个构建插件包的模板 build-tpl (20行代码,启动 webpack),webpack 配置都是空的,大家在开发过程中可自行定制
构建插件包其实核心就是 webpack 能力,webpack 能力这里就不展开说了,这里只描述一下调用关系
以 dcli build
为例,「全局命令包」在收到 build 命令后,启动「构建插件包」
importFrom(process.cwd(), 'build-tpl')
没错,就是这么简单,import-from 库能跨文件目录,指定使用特定目录的文件;使得全局包可以直接去执行工程目录的包 效果与同工程下 require('build-tpl')
一样
此处也可以使用 import-cwd 库
而 build-tpl 这个构建插件包,负责将内置的 webpack.config.js 与用户工程下自定义的 webpackCustom 进行 merge,然后执行 webpack 流程
当然,构建工具不一定非要使用 webpack,比如可以选择 rollup 或者像 Taro 在构建小程序代码时候,自己创建一套工具
8 写在最后的话
笔者认为,只有够精简,才能降低入门门槛,才能强化记忆;因此,本文的案例,在成熟的脚手架上进行不断删减,剔除掉哪些徒增记忆负担的部分,只保留精髓和核心,旨在快速在脑海里建模出一个企业级脚手架
同时提供了脚手架 3 个组成部分的 仓库/npm 包,以增加可操作性
如需引用与实际开发中,我们需要继续丰满其血肉,包括但不限于:
-
异常处理 (如一些边界情况)
-
webpack 配置部分需完善 (本 demo 中 webpack.config 是空的)
-
UI 和提升语可更友好
-
根据业务需求,扩展额外的命令,比如卸载包,发布cdn等