前端开发中,经常会用到一些 CLI
工具, ts-node
转换ts文件, babel-cli
来解析ES6+语法,还有初始化SPA项目的脚手架, create-react-app
、 vue-cli
、 vite
等等,这些cli工具通过自动化脚本节约了开发者在配置 webpack
, tsconfig
, babel
上面的时间,实现了快捷开发。但是你真的了解cli工具吗,下面就通过打造一个自定义的cli工具来讲讲其中的原理。
CLI使用小窍门
会写 react
的朋友肯定有用过 create-react-app
,脚手架的常见使用步骤会分成两步,第一步是全局安装,第二步执行cli,比如下面命令:
# npm安装:
npm install -g create-react-app
# 或者yarn安装:
yarn global add create-react-app
# 执行cli:
create-react-app my-project
但是如果想避免模块全局安装,可以尝试 npx
, npx
会先检查本地依赖包有没有可执行文件,如果找不到就会去远程仓库下载,并且会下载到临时文件,在使用完就删除,是不是有种阅后即焚的赶脚,比如:
# npx安装:
npx create-react-app my-project
从npm的 6.1
版本开始,使用 npm init
或者 yarn create
命令可以直接省略 create-
前缀,比如 create-react-app
就变成如下:
# npm安装:
npm init react-app my-project
# yarn安装:
yarn create react-app my-project
新的CLI项目
CLI项目起名并不一定要 create-*
前缀,比如vue-cli,但是 create-xxx
确实让人更好理解,一般脚手架项目推荐用create,所以这里用来演示的demo就取名为 create-tst
。
mkdir create-tst && cd create-tst
npm init --yes
--yes 跳过提示,创建默认配置的package.json
接下来再创建一个接收命令,并执行脚本的js文件,常规做法是建一个 bin
目录放对应的js脚本文件,并在 package.json
中配置 "bin"
字段信息。
mkdir bin && cd bin
touch create-tst.js
create-tst.js的内容如下:
#!/usr/bin/env node
require = require('esm')(module /*, options*/);
require('../src/cli').cli(process.argv);
首先是使用本地的node命令,然后 esm
库是为了后面的执行代码能够支持ES6+语法,具体的cli逻辑一般会放在 src/cli.js
里执行, process.argv
是之后命令行执行的所有参数。
在讲cli.js的逻辑之前,这里先列一下整体的目录结构:
|____bin
| |____create-app.js 命令行入口
|____package.json
|____templates
| |____TypeScript js模板
| |____JavaScript ts模板
|____src
|____main.js 执行create任务
|____cli.js 处理用户输入
还有相关的依赖(下文会提到每个依赖的用处):
依赖库 | 作用 | npm地址 |
---|---|---|
arg | 解析原始的命令行参数,返回对象 | 传送门 |
chalk | 让命令行输出支持高亮加粗 | 传送门 |
esm | 让node6+都支持ES6语法 | 传送门 |
execa | 执行其他的命令行语句 | 传送门 |
inquirer | 支持复杂的交互提示,比如选择,确认 | 传送门 |
listr | 管理执行任务,支持串行、并行 | 传送门 |
ncp | 异步执行文件复制 | 传送门 |
pkg-install | 在js中安装npm依赖包 | 传送门 |
交互式的UI
执行脚本前,需要先在当前目录下执行一次 npm link
命令,这个命令会在全局环境下生成一个符号链接文件,文件的名字是package.json中制定的模块名,本地就可以执行 create-tst
命令
解析入参
在src目录下建一个cli.js文件,可以把命令行执行输入的参数打印出来看看:
export async function cli(args) {
console.log(args)
}
cli.js输出了一个数组,有五个元素,第一个和第二个都是固定的,后面三个都是用户自定义输入的参数,所以我们只用解析除了前两个外的所有参数,这时候就可以用arg来解析了。
// 解析输入参数
const parseArgsIntoOptions = (rawArgs) => {
const args = arg({
'--git': Boolean, // 解析成布尔值
'--yes': Boolean,
'--install': Boolean,
'-g': '--git', // 参数映射,-g 等同于 --git
'-y': '--yes',
'-i': '--install',
}, {
argv: rawArgs.slice(2)
})
return {
skipPrompts: args['--yes'] || false,
initGit: args['--git'] || false,
template: args._[0],
runInstall: args['--install'] || false
}
}
当然,用 commander
工具来解析也是一个不错的选择。
如果arg第一个对象参数里没有配置,就会统一进入args._数组内,比如javascript
GUI选择配置
使用过vue-cli的朋友肯定知道创建项目的时候界面会提供vue不同的版本,或者自定义配置,这复杂的交互就需要用到上面说到的inquirer。
这里我们就把通过提示获取相关配置的逻辑写成一个通用函数,如果用户不使用默认配置或没输入相关参数,就会出现提示:
- 选择项目模板
- 选择是否初始化git
- 选择是否安装npm依赖
const promptForOptions = async (options) => {
const defaultTemplate = 'JavaScript';
if (options.skipPrompts) {
return {
...options,
template: options.template || defaultTemplate
}
}
const questions = [];
if (!options.template) {
// 1. 选择项目模板
questions.push({
type: 'list',
name: 'template',
message: '请选择当前新建项目的模板',
choices: ['JavaScript', 'TypeScript'],
default: defaultTemplate
})
}
if (!options.initGit) {
// 2. 选择是否初始化git
questions.push({
type: 'confirm',
name: 'git',
message: '是否初始化git仓库',
default: false
})
}
if (!options.runInstall) {
// 3. 选择是否安装npm依赖
questions.push({
type: 'confirm',
name: 'install',
message: '是否安装依赖',
default: false
})
}
const answers = await inquirer.prompt(questions)
return {
...options,
template: options.template || answers.template,
git: options.initGit || answers.git,
install: options.runInstall || answers.install
}
}
效果不错,和
vue-cli
差不多
最后改下一下cli函数,引入一个 createSpaApp
,具体的逻辑下面会提到。
import createSpaApp from './main'
function cli(args) {
let options = parseArgsIntoOptions(args)
options = await promptForOptions(options)
createSpaApp(options)
}
核心功能
有了配置后就可以考虑如何让配置的参数生效了,假设用户把所有配置都打开,那么就要做三件事情:
- 新建模板文件
- 初始化git仓库
- 安装npm依赖
新建模板文件
新建模板文件其实就是直接复制预设的模板文件到目标目录,目标目录就是用户当前执行命令行的目录,所以这里写一个copy函数。
import ncp from 'ncp'
import { promisify } from 'util'
const copy = promisify(ncp)
// 复制文件
const copyTemplateToTarget = async (options) => {
return copy(options.templateDir, options.targetDir, {
clobber: false // 直接覆盖已有文件
})
}
...
try {
// 检查文件是否存在于当前目录中
await access(templateDir, fs.constants.F_OK);
} catch (e) {
console.error('%s Invalid template name', chalk.red.bold('ERROR'));
process.exit(1);
}
在真正执行copy函数前,还得考虑模板文件是否存在,如果子弹都没有,那就直接退出了。
预设的模板根目录要和上面的选项能够对应起来,模板内容这里就不提供了,可以自行定义。
初始化git仓库
git仓库初始化一般直接运行 git init
即可,所以这里也按着思路,直接利用 execa
来运行git命令行,这里使用async语法让执行逻辑更加清晰:
import execa from 'execa'
const initGit = async (options) => {
const result = await execa('git', ['init'], {
cwd: options.targetDir
})
if (result.failed) {
return Promise.reject(new Error('Failed to initialize git'))
}
return
}
安装npm依赖
前端项目初始化后,可以做的更加自动化一点,直接帮用户把npm包也给安装了,这里也有个现成的工具叫 pkg-install
,支持yarn安装,promise的用法。
import { projectInstall } from 'pkg-install';
await projectInstall({
prefer: 'yarn',
cwd: options.targetDir
})
串行任务
定义好了各个选项对应的功能后,就要把配置对应的功能接上,这里用 listr
进行任务管理,最终的执行函数如下:
export default async function createSpaApp(options) {
options = {
...options,
targetDir: options.targetDir || process.cwd()
}
// 预设模板目录
const templateDir = path.resolve(
new URL(import.meta.url).pathname,
'../../templates',
options.template
)
options.templateDir = templateDir;
try {
// 检查文件是否存在于当前目录中
await access(templateDir, fs.constants.F_OK);
} catch (e) {
console.error('%s Invalid template name', chalk.red.bold('ERROR'));
process.exit(1);
}
const tasks = new Listr([
{
title: 'Copy project files',
task: () => copyTemplateToTarget(options)
},
{
title: 'Initialize git',
task: () => initGit(options),
enabled: () => options.git
},
{
title: 'Install dependencies',
task: () =>
projectInstall({
prefer: 'yarn',
cwd: options.targetDir
})
,
skip: () => {
!options.runInstall
? 'Pass --install to automatically install dependencies'
: undefined
}
}
])
await tasks.run()
console.log('%s Project ready', chalk.green.bold('DONE'));
return true
}
listr提供了页面的进度显示,每一个步骤都会有个loading的效果
最终的效果就是下面的样子,有点低配版 create-react-app
那味儿了
git仓库:github.com/Tinsson/cre…
结束
CLI工具不一定只能拿来做脚手架的事情,还能干很多自动化运维、语法解析、文件监听等等便于开发的事情,这里只是简单通过一个demo来演示其中的原理。
创造不易,希望掘有多多 点赞 + 关注 二连,持续更新中!!!
PS: 文中有任何错误,欢迎掘友指正
往期精彩📌