封装一个自己的前端脚手架cli工具
一、创建 cli 项目
1.创建一个文件夹存放 cli 项目文件
给这个 cli 文件夹取名(这里我们取名mycli),cd 到 cli文件夹中,使用 npm init
简单的创建一个项目结构。创建 bin 文件夹,添加启动文件 cli.js,这个文件主要写 Commander.js
的命令。
mycli
|— bin
|— cli.js
|— README.md
|— package.json
|— node_modules
|— .gitignore
2.编辑 package.json
文件
补充版本、启动入口、作者、规范等(如果要发 npm 包,需要再配置一下keywords
、repository
、homepage
)
{
"name": "mycli",
"version": "1.0.0",
"description": "脚手架",
"main": "index.js",
"bin": {
"mycli": "./bin/cli.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": {
"name": "ShayCormac"
},
"license": "MIT",
"keywords": ["mycli"],
"repository": "git@gitee.com:shay_cormac/mycli.git",
"homepage": "git@gitee.com:shay_cormac/mycli.git"
}
3.创建本地调试链接
在mycli项目文件夹使用 yarn link
命令创建链接。在需要使用mycli的其他文件夹是用命令 yarn link mycli
进行本地依赖链接。
需要断开链接时,先在目标文件夹使用 yarn unlink mycli
命令,即可断开本地依赖。需要关闭mycli项目的链接,可以在 mycli 文件夹中使用 yarn unlink
命令关闭。
接下来则进行cli命令的编写(注意:在 cli.js 文件开头要加上识别 #! /usr/bin/env node
)
二、编写脚手架cli命令
1.编写命令
我们使用 Commander.js 插件来完成命令的编写。首先安装 Command.js
$ npm install commander
然后就可以进行命令的编写了
#! /usr/bin/env node
const {Command} = require('commander');
const program = new Command()
// 定义创建项目
program
.command('create <projectName>')
.description('create a new project, 创建一个新项目')
.option('-f, --force', '如果创建的目录存在则直接覆盖')
.action((name, option) => {
// 打印结果,输出用户手动输入的项目名字
console.log('name:',name, 'option:', option)
})
// 配置版本信息
program
.version(`v${require('../package.json').version}`)
.description('使用说明')
.usage('<command> [option]')
// 解析用户执行命令传入参数
program.parse(process.argv)
输出可以拿到
> mycli create projectname123
name: projectname123 option: {} // 执行结果
> mycli create projectname123 -f
name: projectname123 option: { force: true } // 执行结果
> mycli create projectname123 --force
name: projectname123 option: { force: true } // 执行结果
2.编写 create 逻辑
在跟目录下创建 lib 文件夹,cd 到 lib 文件夹中,创建 create.js 文件
mycli
|— bin
|— cli.js
|— lib
|— create.js
|— README.md
|— package.json
|— node_modules
|— .gitignore
在 create.js 文件中接收上一步的值
module.exports = async function (name, options) {
// 验证是否正常取到值
console.log('create success:', name, options);
}
在 bin/cli.js 中使用 create.js
// 定义创建项目
program
.command('create <projectName>')
.description('create a new project, 创建一个新项目')
.option('-f, --force', '如果创建的目录存在则直接覆盖')
.action((name, option) => {
// 引入create.js 模块并传递参数
require('../lib/create')(name, option)
})
> mycli create projectname123 -f
create success: projectname123 { force: true } // 执行结果
在拿到用户提供的答案后,我们需要判断当前目录下是否已有同名文件夹,如果已有同名文件夹,那么需要进行什么样的操作(删除已有、替换、取消…)。这里我们需要两个依赖,一个是询问用户的依赖 inquirer
,一个是对文件、文件夹的操作 node.js
的 fs
模块,对于 fs
模块的使用,我们知道稍微有点不方便,这里我们使用 fs-extra
依赖,它是 fs
模块的扩展。
先安装这两个依赖
$ npm install fs-extra
$ npm install inquirer
在 lib/create.js 文件中开始写逻辑
const path = require('path');
const extra = require('fs-extra'); // fs-extra 是 node fs 的扩展
const inquirer = require('inquirer'); // 命令行交互
module.exports = async function (name, options) {
// 验证是否正常取到值
// console.log('create success', name, options);
const cwd = process.cwd(); // 执行目录
const targetAir = path.join(cwd, name); // 需要创建的目录地址
// 判断目录是否已经存在?
if (extra.existsSync(targetAir)) {
// 是否为强制创建?
if (options.force) {
await extra.remove(targetAir)
// TODO
}
else {
// 在终端输出询问用户是否覆盖:
const inquirerParams = [{
name: 'aboutProject',
type: 'list',
message: '目标文件目录已经存在,请选择如下操作:',
choices: [
{ name: '替换当前目录', value: 'replace' },
{ name: '移除已有目录', value: 'remove' },
{ name: '取消当前操作', value: 'cancel' }
]
}]
let inquirerData = await inquirer.prompt(inquirerParams);
// console.log('inquirerData', inquirerData)
if (!inquirerData?.aboutProject) {
return
}
else if (inquirerData.aboutProject === 'remove') {
// 移除已存在的目录
await extra.remove(targetAir)
console.log('目录 <' + name + '> 已移除')
}
else if (inquirerData.aboutProject === 'replace') {
await extra.remove(targetAir)
// TODO
}
}
}
};
注:这里我们需要滤清一下逻辑和地址,在使用 extra.remove()
方法时,文件地址要弄清,process.cwd()
方法可以拿到当前执行命令的地址。
此时我们可以创建一个名称为 projectname123 的文件夹,再使用 mycli create projectname123
来查看命令行中的交互询问
3.创建 CreateGenerator.js
在 lib 目录下创建 CreateGenerator.js 文件,写创建项目时具体的操作,例如下载模板地址、对模板进行替换处理等。
( 选修 )这里我们可以使用一个加载的动画效果,使用 ora
依赖,在控制台显示当前加载状态的。
$ npm install ora
在 CreateGenerator.js 中定义 wrapLoading() 方法,来使用 ora
的动画效果
// lib/CreateGenerator.js
const ora = require('ora'); // Ora 在控制台显示当前加载状态的(下载5.x版本)
// 使用 ora 初始化,传入提示信息 message
const spinner = ora()
/**
* @wrapLoading 交互加载动画
* @param {*} fn 在 wrapLoading 函数中执行的方法
* @param {*} message 执行动画时的提示信息
* @param {...any} args 传递给 fn 方法的参数
* @returns
*/
async function wrapLoading(fn, message, ...args) {
spinner.text = message.loadingMsg
// 开始加载动画
spinner.start()
try {
// 执行传入的方法 fn
const result = await fn(...args)
// 动画修改为成功
spinner.succeed(message.seccessfulMsg)
return result
} catch (error) {
// 动画修改为失败
spinner.fail(message.failedMsg + ': ', error)
}
}
注意:ora
、chalk
等依赖最新版只支持ESModule格式,是使用export default导出的之前的版本支持CommomJs格式,是使用module.exports导出的。最新版使用 require引入所以报错。
动画函数可以单独封装,这里暂时放在 lib/CreateGenerator.js 中
(必修)接下来我们就正式开始写 CreateGenerator
创建 CreateGenerator 构造函数,在 constructor
中定义我们需要的 项目名称( this.name )、目标地址( this.targetDir )、拉取代码的方法( this.downloadGitRepo )。定义 download() 方法在里面写拉取仓库代码的逻辑
// lib/CreateGenerator.js
const ora = require('ora'); // Ora 在控制台显示当前加载状态的(下载5.x版本)
const inquirer = require('inquirer'); // 用户与命令行交互
const chalk = require('chalk'); // ‘粉笔’ 用于设置终端字体颜色的库(下载4.x版本)
const path = require('path');
const child_process = require('child_process');
const extra = require('fs-extra'); // fs-extra 是 node fs 的扩展
/**
*ora、chalk 等依赖最新版只支持ESModule格式,是使用export default导出的,
*之前的版本支持CommomJs格式,是使用module.exports导出的。
*最新版使用 require引入所以报错。
*/
// 使用 ora 初始化,传入提示信息 message
const spinner = ora()
/**
* @wrapLoading 交互加载动画
* @param {*} fn 在 wrapLoading 函数中执行的方法
* @param {*} message 执行动画时的提示信息
* @param {...any} args 传递给 fn 方法的参数
* @returns
*/
async function wrapLoading(fn, message, ...args) {
spinner.text = message.loadingMsg
// 开始加载动画
spinner.start()
try {
// 执行传入的方法 fn
const result = await fn(...args)
// 动画修改为成功
spinner.succeed(message.seccessfulMsg)
return result
} catch (error) {
// 动画修改为失败
spinner.fail(message.failedMsg + ': ', error)
}
}
class CreateGenerator {
constructor(name, targetDir) {
// 目录名称
this.name = name;
// 创建位置
this.targetDir = targetDir;
/**
* 对 download-git-repo 进行 promise 化改造
* 使用 child_process 的 execSync 方法拉取仓库模板
*/
this.downloadGitRepo = child_process.execSync
}
/**
* @download 下载远程模板
*/
async download() {
// 设置模板下载地址
const modelUrl = `https://gitee.com/shay_cormac/react-antd-tempy.git`
// child_process.spawn 参数
/**
* @param masterBranch master分支
* @param projectBranch project-demo分支
* @param pagesBranch pages-demo分支
*/
const masterBranch = `git clone -b master ${modelUrl} ${path.resolve(process.cwd(), this.targetDir)}`
const projectBranch = `git clone -b project-demo ${modelUrl} ${path.resolve(process.cwd(), this.targetDir, 'src', 'pages', this.name)}`
// 调用动画加载效果,加载master分支
await wrapLoading(
this.downloadGitRepo,
{ loadingMsg: '加载模板中...', seccessfulMsg: 'master加载成功', failedMsg: 'master加载失败' },
masterBranch
// modelUrl,
// path.resolve(process.cwd(), this.targetDir),
)
// 调用动画加载效果,加载project-demo分支
await wrapLoading(
this.downloadGitRepo,
{ loadingMsg: '加载模板中...', seccessfulMsg: 'project-demo加载成功', failedMsg: 'project-demo加载失败' },
projectBranch
// modelUrl,
// path.resolve(process.cwd(), this.targetDir),
)
}
// 核心创建逻辑
async create() {
// console.log(chalk.yellow(`name: ${this.name}`))
// console.log(chalk.yellow(`targetDir: ${this.targetDir}`))
await this.download()
// 写环境变量文件(用在模板编译启动、打包时使用)
extra.outputFileSync(path.resolve(this.targetDir, '.env'), `PROJECT = ${this.name}`)
}
}
module.exports = CreateGenerator;
注释:child_process
中的 execSync
方法,可以使我们在代码中使用命令,这样我们就可以使用 git 命令来拉取代码了
child_process.execSync(`git clone -b 分支 仓库地址 输出地址`)
讨论:模板可以放在线上仓库管理,也可以将模板放在cli项目中管理。如果放在项目中,则不需要使用 child_process.execSync
。
三、babel 编译修改模板内容
这个部分根据各自使用的模板,或各自的思路不同,会有不同的处理方式。我们在这里使用babel 的方式,解析代码进行修改。
这里我们以模板中的路由文件为例(模板文件为umi创建的项目)
1.读取文件
使用fs-extra
的 readFile()
方法读取目标文件内容readFile(路径,编码,回调)
,参考 node
的 fs
模块
// lib/CreateGenerator.js
const ora = require('ora'); // Ora 在控制台显示当前加载状态的(下载5.x版本)
const inquirer = require('inquirer'); // 用户与命令行交互
const chalk = require('chalk'); // ‘粉笔’ 用于设置终端字体颜色的库(下载4.x版本)
const path = require('path');
const child_process = require('child_process');
const extra = require('fs-extra'); // fs-extra 是 node fs 的扩展
const babelparser = require('@babel/parser'); // 将JS源码转换成语法树
const traverse = require('@babel/traverse').default; // 遍历和更新节点
const generator = require('@babel/generator').default; // 把AST抽象语法树反解,生成我们常规的代码
/**
*ora、chalk 等依赖最新版只支持ESModule格式,是使用export default导出的,
*之前的版本支持CommomJs格式,是使用module.exports导出的。
*最新版使用 require引入所以报错。
*/
// 使用 ora 初始化,传入提示信息 message
const spinner = ora()
/**
* @wrapLoading 交互加载动画
* @param {*} fn 在 wrapLoading 函数中执行的方法
* @param {*} message 执行动画时的提示信息
* @param {...any} args 传递给 fn 方法的参数
* @returns
*/
async function wrapLoading(fn, message, ...args) {
...
}
class CreateGenerator {
constructor(name, targetDir) {
// 目录名称
this.name = name;
// 创建位置
this.targetDir = targetDir;
/**
* 对 download-git-repo 进行 promise 化改造
* 使用 child_process 的 execSync 方法拉取仓库模板
*/
this.downloadGitRepo = child_process.execSync
}
/**
* @download 下载远程模板
*/
async download() {
...
}
// 核心创建逻辑
async create() {
await this.download()
// 写环境变量文件
extra.outputFileSync(path.resolve(this.targetDir, '.env'), `PROJECT = ${this.name}`)
this.readRoute()
}
/**
* 读取路由
* 统一修改路由中项目路径
*/
readRoute = async () => {
const routePath = path.resolve(this.targetDir, 'src', 'pages', this.name, 'route', 'index.ts')
extra.readFile(routePath, 'utf8', (err, data) => {
if (err) {
throw err
}
console.log(data) // 读取的文件内容
})
}
}
module.exports = CreateGenerator;
2.使用 @babel/parser
将JS代码转换成语法树
先安装依赖
$ npm install --save @babel/parser
babelParser.parse(code, [options])
具体使用请参照 babel 文档
将读取到的JS代码转换成语法树
// lib/CreateGenerator.js
/**
* 读取路由
* 统一修改路由中项目路径
*/
readRoute = async () => {
const routePath = path.resolve(this.targetDir, 'src', 'pages', this.name, 'route', 'index.ts')
extra.readFile(routePath, 'utf8', (err, data) => {
if (err) {
throw err
}
// 将JS源码转换成语法树
let routeDataTree = babelparser.parse(data, {
sourceType: 'module',
plugins: [
"typescript", // 编译tsx文件
// "jsx", // 编译jsx文件
// "flow", // 流通过静态类型注释检查代码中的错误。这些类型允许您告诉Flow您希望您的代码如何工作,而Flow将确保它按照这种方式工作。
]
})
})
}
@babel/parse
会将读取的代码转变为语法树,打印到控制台可以看到语法树结构。这里推荐一个在线 AST 语法树解析 AST Explorer,可以将需要转译的代码放上去,查看解析后的语法树结构。
3.使用 @babel/traverse
遍历和更新节点
先安装依赖
$ npm install --save @babel/traverse
traverse(AST, {options})
,第一个参数为数据流语法树,第二个参数是属性方法,例如 enter: (path, state) =>{}
、exit:(path) => {}
。
@babel/traverse
会遍历输出每一个node节点,可以通过控制台 console.log() 打印 path 查看每一个节点。
这里我们的 route.ts 文件内容为:
// 模板中 route.ts 内容
module.exports = [
{ path: '/', redirect: '/adjustTheRecord' },
{
path: '/adjustTheRecord',
exact: true,
name: '列表',
component: '@/pages/pricing/pages/adjustTheRecord',
layout: {
hideNav: true,
}
},
// {
// path: '/nested',
// name: '嵌套页面',
// component: '@/pages/pricing/pages/nested',
// routes: [
// { path: '/nested/new', component: '@/pages/pricing/pages/nested/new' },
// { path: '/nested/war', component: '@/pages/pricing/pages/nested/war' },
// { path: '/nested/tech', component: '@/pages/pricing/pages/nested/tech' },
// { path: '/nested/auto', component: '@/pages/pricing/pages/nested/auto' },
// { path: '/nested/ent', component: '@/pages/pricing/pages/nested/ents' },
// { path: '/nested/money', component: '@/pages/pricing/pages/nested/money' },
// {
// path: '/nested/jiankang',
// component: '@/pages/pricing/pages/nested/jiankang',
// },
// { path: '/nested/travel', component: '@/pages/pricing/pages/nested/travel' },
// ],
// },
];
我们使用 @babel/traverse
来解析,打印查看 node 节点信息,观察以什么条件选出我们要用的 node 节点(node 节点会比较多,需要耐心查看)。也可以使用 AST Explorer 来查看结构
// lib/CreateGenerator.js
/**
* 读取路由
* 统一修改路由中项目路径
*/
readRoute = async () => {
const routePath = path.resolve(this.targetDir, 'src', 'pages', this.name, 'route', 'index.ts')
extra.readFile(routePath, 'utf8', (err, data) => {
if (err) {
throw err
}
// 将JS源码转换成语法树
let routeDataTree = babelparser.parse(data, {
sourceType: 'module',
plugins: [
"typescript", // 编译tsx文件
// "jsx", // 编译jsx文件
// "flow", // 流通过静态类型注释检查代码中的错误。这些类型允许您告诉Flow您希望您的代码如何工作,而Flow将确保它按照这种方式工作。
]
})
// 遍历和更新节点
traverse(routeDataTree, {
ObjectProperty: (path, state) => {
console.log(path)
}
})
})
}
我们这里修改 route.ts 里 路由的 component 属性的值,找到 node 节点中的 key 来选择出需要修改的节点 path.node.key.name === ‘component’
// 遍历和更新节点
traverse(routeDataTree, {
ObjectProperty: (path, state) => {
if (path.node.key.name === 'component') {
path.node.value.value = `@/pages/${this.name}/pages/adjustTheRecord`
}
}
})
4.使用 @babel/generator
把AST抽象语法树反解,生成我们常规的代码
上一步中修改了 node 节点的值,修改之后我们需要将AST语法树反解为常规代码,并写入到 route.ts 文件中
先安装依赖
npm install @babel/generator
/**
* 读取路由
* 统一修改路由中项目路径
*/
readRoute = async () => {
const routePath = path.resolve(this.targetDir, 'src', 'pages', this.name, 'route', 'index.ts')
extra.readFile(routePath, 'utf8', (err, data) => {
if (err) {
throw err
}
// 将JS源码转换成语法树
let routeDataTree = babelparser.parse(data, {
sourceType: 'module',
plugins: [
"typescript", // 编译tsx文件
// "jsx", // 编译jsx文件
// "flow", // 流通过静态类型注释检查代码中的错误。这些类型允许您告诉Flow您希望您的代码如何工作,而Flow将确保它按照这种方式工作。
]
})
// 遍历和更新节点
traverse(routeDataTree, {
ObjectProperty: (path, state) => {
if (path.node.key.name === 'component') {
path.node.value.value = `@/pages/${this.name}/pages/adjustTheRecord`
}
}
})
// 把AST抽象语法树反解,生成我们常规的代码
const routeCode = generator(routeDataTree).code
extra.outputFileSync(routePath, routeCode)
// console.log(routeCode)
})
}
在使用 fs-extra.outputFileSync()
写入文件前,可以打印查看反译后的代码
四、将 CreateGenerator.js 引入到 create.js 使用
const path = require('path');
const extra = require('fs-extra'); // fs-extra 是 node fs 的扩展
const inquirer = require('inquirer'); // 命令行交互
const chalk = require('chalk'); // ‘粉笔’
const CreateGenerator = require('./CreateGenerator')
module.exports = async function (name, options) {
// 验证是否正常取到值
// console.log('create success', name, options);
const cwd = process.cwd(); // 执行目录
const targetAir = path.join(cwd, name); // 需要创建的目录地址
// 判断目录是否已经存在?
if (extra.existsSync(targetAir)) {
// 是否为强制创建?
if (options.force) {
await extra.remove(targetAir)
makeGenerator(name, targetAir)
}
else {
// 在终端输出询问用户是否覆盖:
const inquirerParams = [{
name: 'aboutProject',
type: 'list',
message: '目标文件目录已经存在,请选择如下操作:',
choices: [
{ name: '替换当前目录', value: 'replace' },
{ name: '移除已有目录', value: 'remove' },
{ name: '取消当前操作', value: 'cancel' }
]
}]
let inquirerData = await inquirer.prompt(inquirerParams);
// console.log('inquirerData', inquirerData)
if (!inquirerData?.aboutProject) {
return
}
else if (inquirerData.aboutProject === 'remove') {
// 移除已存在的目录
await extra.remove(targetAir)
console.log('目录 <' + chalk.green(name) + '> 已移除')
}
else if (inquirerData.aboutProject === 'replace') {
await extra.remove(targetAir)
makeGenerator(name, targetAir)
}
}
}
else {
makeGenerator(name, targetAir)
}
};
/**
* 创建项目
* @param {string} name 项目名称
* @param {string} targetAir 需要创建的目录地址
*/
const makeGenerator = (name, targetAir) => {
const generator = new CreateGenerator(name, targetAir)
generator.create()
}
到此,mycli create <project>
的命令就已经完成了。
下一节 分享 mycli newPage <pageName>
命令的编写