封装一个自己的前端脚手架cli工具(二)
上一节 我们编写了第一个 cli 的命令 mycli create <project>
来创建项目,这一节我们根据项目结构丰富一个新建页面的命令 mycli newPage <pageName>
。具体请参照上一节
需要的依赖
commander
、fs-extra
、inquirer
、@babel/parser
、@babel/traverse
、@babel/generator
$ npm install commander
$ npm install fs-extra
$ npm install inquirer
$ npm install --save @babel/parser
$ npm install --save @babel/traverse
$ npm install --save @babel/generator
一、编写 mycli newPage <pageName>
命令
先在 bin/cli.js 文件中添加 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) => {
// 引入create.js 模块并传递参数
require('../lib/create')(name, option)
})
// 新建页面,并添加type、store、route等配置
program
.command('newPage <pageName>')
.description('创建新页面,并配置type、store、route')
.action((pageName) => {
// 引入newPage.js 模块并传递参数
require('../lib/newPage')(pageName)
})
// 配置版本信息
program
.version(`v${require('../package.json').version}`)
.description('使用说明')
.usage('<command> [option]')
program.parse(process.argv)
与编写创建项目的命令类似,如果需要添加其他配置,可以自行配置
二、编写命令逻辑
1.创建 newPage.js 文件
在 mycli / lib 下创建newPage.js 文件,与 create.js 文件类似,通过交互式命令行的形式,获得用户的答案。这里根据 umi 模板的结构,我们需要先获取到每一个子项目的文件夹名称
// 模板文件结构
project
|— config
|— mock
|— node_modules
|— src
|— locales
|— pages
|— childproject1
|— component
|— config
|— models
|— connect.d.ts
|— pages
|— anypages
|— index.tsx
|— index.less
|— route
|— service
|— type
|— index.ts
|— childproject2
|— public
|— package.json
|— README.md
|— tsconfig.json
因为这种特殊的目录结构,我们先要获取子项目的目录,用来给用户选择,是在哪一个子项目中添加新页面。使用 fs-extra.readdirSync()
得到一个子项目名称的列表
使用 inquirer 中的 rawlist
类型,可以让用户进行选择式的交互
// lib/newPage.js
const path = require('path');
const extra = require('fs-extra'); // fs-extra 是 node fs 的扩展
const inquirer = require('inquirer'); // 命令行交互
const chalk = require('chalk'); // ‘粉笔’ 用于设置终端字体颜色的库(下载4.x版本)
module.exports = async function (name) {
const cwd = process.cwd(); // 项目根目录地址
const targetAir = path.join(cwd, 'src', 'pages'); // 需要创建的目录地址
// 读取项目文件夹目录
const projectCatalogue = extra.readdirSync(targetAir)
// 交互式提问获取用户选择的项目
const inquirerAnswer = await inquirer.prompt({
name: 'chooseProject',
type: 'rawlist',
message: '请选择需要添加页面的项目文件夹:',
choices: [...projectCatalogue]
})
console.log(inquirerAnswer)
}
这里就可以得到用户的答案,用作接下来的操作。
我们现在分析一下路由文件
// 模板中 route.ts 内容
module.exports = [
{ path: '/', redirect: '/adjustTheRecord' },
{
path: '/adjustTheRecord',
exact: true,
name: '商品价格列表',
component: "@/pages/testProject/pages/adjustTheRecord",
layout: {
hideNav: true
}
}
];
由此结构我们添加新路由则需要按照这个模板增加,我们尝试添加一个testPage的路由
{
path: '/testPage',
exact: true,
name: '测试新增页面',
component: '@/pages/testProject/pages/testPage',
layout: {
hideNav: true,
}
}
path: '/testPage',
component: '@/pages/testProject/pages/testPage'
从上面的代码分析,path 和 component 中的 ‘testPage’ 可以从 mycli newPage <pageName>
命令获取到页面名称,component 中的 ‘testProject’ 可以从 inquirerAnswer 用户的回答中获得,这里还有一个 name 的值 ‘测试新增页面’,也需要通过用户输入来获取,所以再添加一个 inquirer 询问
// lib/newPage.js
// 询问用户新路由页面名称
const inquirerAnswerOfName = await inquirer.prompt({
name: 'inputRouteName',
type: 'input',
message: '请输入新页面路由名称:',
validate: (value) => {
// 只含有汉字、数字、字母、下划线,下划线位置不限
const reg = new RegExp(/^[a-zA-Z0-9_\u4e00-\u9fff]+$/)
if (value.match(reg)) {
return true
}
return '请输入汉字、数字、字母、下划线,下划线位置不限'
}
})
这样,我们就可以获得所有需要的值了,接下来需要判断现有的子项目下是否已有同名的页面如果没有才继续创建的工作
// lib/newPage.js
// 判断页面文件是否存在
if (extra.existsSync(path.join(projectDir, 'pages', name))) {
return console.log(`页面 ${chalk.green(name)} 已存在`)
}
2.创建 NewPageGenerator.js 文件
判断用户想创建的页面不存在后,就可以开始创建的工作了,在 lib 文件夹下创建 NewPageGenerator.js 文件
// NewPageGenerator.js
const path = require('path');
const child_process = require('child_process');
const extra = require('fs-extra'); // fs-extra 是 node fs 的扩展
class NewPageGenerator {
constructor(name, targetDir, chooseProject, inputRouteName) {
// 目录名称
this.name = name;
// 创建位置
this.targetDir = targetDir;
// 用户选择的项目名称
this.chooseProject = chooseProject
// 用户输入路由名称
this.inputRouteName = inputRouteName
/**
* 使用 child_process 的 execSync 方法拉取仓库模板
*/
this.downloadGitRepo = child_process.execSync
}
// 核心创建逻辑
async create() {
console.log(this.inputRouteName)
}
}
module.exports = NewPageGenerator;
创建 NewPageGenerator.js 文件后,在 newPage.js 中进行引用
// newPage.js
...
// 引用 NewPageGenerator.js
const NewPageGenerator = require('./NewPageGenerator');
...
module.exports = async function (name) {
const cwd = process.cwd(); // 项目根目录地址
const targetAir = path.join(cwd, 'src', 'pages'); // 需要创建的目录地址
// 读取项目文件夹目录
const projectCatalogue = extra.readdirSync(targetAir)
// 交互式提问获取用户选择的项目
const inquirerAnswer = await inquirer.prompt({
...
})
// 询问用户新路由页面名称
const inquirerAnswerOfName = await inquirer.prompt({
...
})
// 具体项目路径
const projectDir = path.join(targetAir, inquirerAnswer.chooseProject)
// 判断页面文件是否存在
if (extra.existsSync(path.join(projectDir, 'pages', name))) {
return console.log(`页面 ${chalk.green(name)} 已存在`)
}
makeGenerator(name, projectDir, inquirerAnswer.chooseProject, inquirerAnswerOfName.inputRouteName)
}
/**
* 创建页面
* @param {string} name 新页面名称
* @param {string} targetAir 需要创建页面的项目地址
* @param {string} chooseProject 用户选择的项目
* @param {string} inputRouteName 新路由页面名称
*/
const makeGenerator = (name, targetAir, chooseProject, inputRouteName) => {
const generator = new NewPageGenerator(name, targetAir, chooseProject, inputRouteName)
generator.create()
}
3.读取路由文件,并添加新路由
读取和写入我们使用 fs-extra
,读取路由文件后,使用 @babel/parser
进行解析,得到 AST 语法树(可使用 AST Explorer 查看语法树)
使用 @babel/traverse
遍历语法树的每一个 node 节点,找到导出的数组节点,往数组节点中 push 一个 路由对象节点,对象节点中的键值对由 identifier()
节点属性生成键,值由 stringLiteral()
booleanLiteral()
等生成。
使用 @babel/generator
将添加好新路由节点的数组节点反解成代码,然后写入到路由文件中
// NewPageGenerator.js
/**
* 读取路由
* 创建新路由节点
*/
readRoute = async () => {
const routePath = path.resolve(this.targetDir, '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, {
/**
* 进入节点,可以打印 path.node.type 查看文件中所有节点类型
* 可以一层一层往里打印,查看各个类型下的 node 节点内容
* 这里找到 ExpressionStatement 里的是文件的内容
* path.node.type = ArrayExpression 中的是需要修改的内容
*/
enter: (path, state) => {
// console.log(path.node.type)
if (path.node.type === 'ArrayExpression') {
// 给新 object 项添加路由属性
// console.log(this.inputRouteName, this.chooseProject)
const newRouteObj = babeltypes.objectExpression([
babeltypes.objectProperty(
babeltypes.identifier('path'),
babeltypes.stringLiteral(`/${this.name}`),
),
babeltypes.objectProperty(
babeltypes.identifier('exact'),
babeltypes.booleanLiteral(true),
),
babeltypes.objectProperty(
babeltypes.identifier('name'),
babeltypes.stringLiteral(this.inputRouteName),
),
babeltypes.objectProperty(
babeltypes.identifier('component'),
babeltypes.stringLiteral(`@/pages/${this.chooseProject}/pages/${this.name}`),
),
babeltypes.objectProperty(
babeltypes.identifier('layout'),
babeltypes.objectExpression([
babeltypes.objectProperty(
babeltypes.identifier('hideNav'),
babeltypes.booleanLiteral(true),
)
]),
)
])
// 将新路由object添加到路由数组中
path.node.elements.push(newRouteObj)
}
},
// 退出节点
// exit(path) {
// console.log(` exit ${path.type}(${path.key})`)
// }
})
// 把AST抽象语法树反解,生成我们常规的代码
const routeCode = generator(routeDataTree, { jsescOption: { minimal: true } }, "").code
extra.outputFileSync(routePath, routeCode)
})
}
生成的新路由文件中,我们可以得到这样的代码
// 模板中 route.ts 内容
module.exports = [
{ path: '/', redirect: '/adjustTheRecord' },
{
path: '/adjustTheRecord',
exact: true,
name: '商品价格列表',
component: "@/pages/testProject/pages/adjustTheRecord",
layout: {
hideNav: true
}
},
{
path: '/testPage',
exact: true,
name: '测试新增页面',
component: '@/pages/testProject/pages/testPage',
layout: {
hideNav: true,
}
}
];
4.创建 testPage 文件夹,拉取模板代码
根据目录格式,先创建 testPage 文件夹,从仓库拉取新建页面的模板放入到 testPage 文件夹中(也可以将页面模板放在cli中,则不需要拉取线上代码)。
// NewPageGenerator.js
const path = require('path');
const child_process = require('child_process');
const extra = require('fs-extra'); // fs-extra 是 node fs 的扩展
class NewPageGenerator {
constructor(name, targetDir, chooseProject, inputRouteName) {
...
}
/**
* @download 下载远程模板(与创建项目相同,注意输出路径)
*/
async download() {
...
}
// 核心创建逻辑
async create() {
// 创建页面文件夹
extra.ensureDirSync(path.join(this.targetDir, 'pages', this.name))
// 下载页面模板
await this.download()
}
}
module.exports = NewPageGenerator;
5.编写模板文件(index.tsx 、 index.less)
我们这里写的模板使用的是模板引擎,将坑挖好之后,再使用 ejs
依赖将坑填补
index.less 文件
.<%= name %> {
width: 100%;
padding: 0 20px;
height: calc(100vh);
// min-height: 100vh;
background-color: #f1f4f5;
.<%= name %>_body {
width: 100%;
height: 100%;
background-color: #fff;
}
}
index.tsx
import React from 'react';
import { ConnectState } from '../../models/connect';
import { connect, Dispatch } from 'umi';
import DocumentTitle from 'react-document-title';
import { <%= fileName %>State } from '../../type';
import styles from './index.less';
interface IProps extends <%= fileName %>State {
dispatch: Dispatch;
}
const <%= fileName %>: React.FC<IProps> = (props) => {
return (
<DocumentTitle title="调价记录">
<div className={styles.<%= name %>}>
<div className={styles.<%= name %>_body}>
</div>
</div>
</DocumentTitle>
);
};
export default connect(({ ...state }: ConnectState) => {
// console.log('state', state);
return {
...state.<%= name %>Store,
};
})(<%= fileName %>);
6.使用 ejs
填补模板中的坑位
下载依赖
$ npm install ejs
准备好模板,并拉取到目标文件夹后,我们使用 ejs
将模板坑位填补
// NewPageGenerator.js
// 使用 ejs 模板引擎读取文件内容,并写入到输出目录
ejsModel = () => {
// 修改首字母大写
let fileName = this.name.replace(/^[a-z]/g,(L) => L.toUpperCase())
const ejsParams = {
name: this.name,
fileName,
}
// 替换 .tsx 模板
ejs.renderFile(path.join(this.targetDir, 'pages', this.name, 'index.tsx'), ejsParams, (err, result) => {
if (err) {
throw err
}
extra.writeFileSync(path.join(this.targetDir, 'pages', this.name, 'index.tsx'), result)
})
// 替换 .less 模板
ejs.renderFile(path.join(this.targetDir, 'pages', this.name, 'index.less'), ejsParams, (err, result) => {
if (err) {
throw err
}
extra.writeFileSync(path.join(this.targetDir, 'pages', this.name, 'index.less'), result)
})
}
7.生成 type 类型约束文件
在 lib 目录下创建 NewTypeGenerator.js 文件
type 类型约束文件的目录结构,我们这样放置单个模块的 type 类型约束文件
|— type
|— index.ts
|— projectName
|— index.ts
// type/index.ts
export * from './User';
export * from './pricingSystem';
与路由文件类似,也是先读取,再遍历查找,最后修改生成代码
这里在添加新导出节点时,翻看 @babel/types 文档使用 @babel/types.exportAllDeclaration()
方法添加新的全部导出节点。
// NewTypeGenerator.js
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抽象语法树反解,生成我们常规的代码
const babeltypes = require('@babel/types');
class NewTypeGenerator {
constructor(name, targetDir) {
// 目录名称
this.name = name;
// 创建位置
this.targetDir = targetDir;
/**
* 对 download-git-repo 进行 promise 化改造
* 使用 child_process 的 execSync 方法拉取仓库模板
*/
this.downloadGitRepo = child_process.execSync
}
/**
* 生成type相关文件
*/
createType = async() => {
this.readType()
this.makeTypeFile()
}
/**
* 读取type
* 创建新的type 引入
*/
readType = async () => {
const typePath = path.resolve(this.targetDir, 'type', 'index.ts')
extra.readFile(typePath, 'utf8', (err, data) => {
if (err) {
throw err
}
// 将JS源码转换成语法树
let typeDataTree = babelparser.parse(data, {
sourceType: 'module',
plugins: [
"typescript", // 编译tsx文件
// "jsx", // 编译jsx文件
// "flow", // 流通过静态类型注释检查代码中的错误。这些类型允许您告诉Flow您希望您的代码如何工作,而Flow将确保它按照这种方式工作。
]
})
// 遍历和更新节点
traverse(typeDataTree, {
enter: (path, state) => {
if (path.node.type === 'Program') {
/**
* 创建全部导出节点
* export * from './pricingSystem';
*/
const newExportDeclaration = babeltypes.exportAllDeclaration(
babeltypes.stringLiteral(`./${this.name}`)
)
path.node.body.push(newExportDeclaration)
}
}
})
// 把AST抽象语法树反解,生成我们常规的代码
const typeCode = generator(typeDataTree).code
extra.outputFileSync(typePath, typeCode)
})
}
/**
* 在 type 文件夹中创建页面类型文件
*/
makeTypeFile = async() => {
// 修改首字母大写
let fileName = this.name.replace(/^[a-z]/g,(L) => L.toUpperCase())
let dirPath = path.resolve(this.targetDir, 'type', fileName)
// 创建文件夹
extra.ensureDirSync(dirPath)
// 类型文件模板
const typeTemp = `
/**
* 调价记录
* @param tableLoad 列表load
* @param qureyInfo 请求数据
* @param resdata 调价列表响应数据
*/
export interface ${fileName}State {
tableLoad?: boolean | undefined;
qureyInfo?: any;
resdata?: any;
[name: string]: any;
}
`
// 输出type文件
extra.outputFileSync(path.resolve(dirPath, 'index.ts'), typeTemp)
}
}
module.exports = NewTypeGenerator;
8.生成 models 文件
在 lib 目录下创建 NewModelsGenerator.js 文件
查看 models 目录结构,我们这样放置单个模块的 store 文件
|— models
|— connect.d.ts
|— projectNameStore.ts
// models/connect.d.ts
import { MenuDataItem, Settings as ProSettings } from '@ant-design/pro-layout';
import {
UserTypeState,
AdjustTheRecordState,
<加入位置>
} from '../type';
/**
* 此处声明用于在请求时调用全局的loading
*/
export interface Loading {
effects: { [key: string]: boolean | undefined };
models: {
setting?: boolean;
adjustTheRecordStore?: AdjustTheRecordState;
};
}
/**
* 此处声明是用于
* 1.在connect处找到store
* 2.在models里面找到store
*/
export interface ConnectState {
loading: Loading;
settings: ProSettings;
mainUserStore: UserTypeState;
adjustTheRecordStore?: AdjustTheRecordState;
<加入位置>
}
export interface Route extends MenuDataItem {
routes?: Route[];
}
与 type 类似,也是处理导入导出的节点添加,在上面的 connect.d.ts 文件中,标明了需要加入的位置,接下来则去 @babel/types 文档找可以使用的方法
通过 @babel/types.importSpecifier()
添加导入节点
通过 @babel/types.objectTypeProperty()
添加单个导出节点
// NewModelsGenerator.js
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抽象语法树反解,生成我们常规的代码
const babeltypes = require('@babel/types');
class NewModelsGenerator {
constructor(name, targetDir) {
// 目录名称
this.name = name;
// 创建位置
this.targetDir = targetDir;
/**
* 使用 child_process 的 execSync 方法拉取仓库模板
*/
this.downloadGitRepo = child_process.execSync
}
/**
* 生成Model相关文件
*/
createModel = async () => {
this.readModel()
this.makeModelFile()
}
/**
* 读取Model
* 创建新的Model 引入
*/
readModel = async () => {
const modelPath = path.resolve(this.targetDir, 'models', 'connect.d.ts')
extra.readFile(modelPath, 'utf8', (err, data) => {
if (err) {
throw err
}
// 将JS源码转换成语法树
let modeldataTree = babelparser.parse(data, {
sourceType: 'module',
plugins: [
'typescript'
]
})
// 首字母大写
let fileName = this.name.replace(/^[a-z]/g, (L) => L.toUpperCase())
traverse(modeldataTree, {
enter: (path, state) => {
// 加上引入的类型文件
if (path.node.type === 'ImportDeclaration' && path.node.source.value === '../type') {
const newImportSpecifier = babeltypes.importSpecifier(
babeltypes.identifier(`${fileName}State`),
babeltypes.identifier(`${fileName}State`),
)
path.node.specifiers.push(newImportSpecifier)
}
// 加上导出的类型约束
if (path.node.type === 'ExportNamedDeclaration' && path.node.declaration.id.name === 'ConnectState') {
// 创建对象类型属性
const newObjectTypeProperty = babeltypes.objectTypeProperty(
// 创建节点名称
babeltypes.identifier(`${this.name}Store`),
// 创建泛型类型注释
babeltypes.genericTypeAnnotation(
// 创建泛型类型注释节点名称
babeltypes.identifier(`${fileName}State`)
),
)
path.node.declaration.body.body.push(newObjectTypeProperty)
}
}
})
// 把AST抽象语法树反解,生成我们常规的代码
const modelCode = generator(modeldataTree).code
// console.log('mode:', modelCode)
extra.outputFileSync(modelPath, modelCode)
})
}
/**
* 在 Model 文件夹中创建页面类型文件
*/
makeModelFile = async () => {
// let dirPath = path.resolve(this.targetDir, 'models', this.name)
// 创建文件夹
// extra.ensureDirSync(dirPath)
// 首字母大写
let fileName = this.name.replace(/^[a-z]/g, (L) => L.toUpperCase())
// model模板
const modelTemp = `
import { Reducer, Effect } from 'umi';
import { ConnectState } from './connect';
import { ${fileName}State } from '../type'
export interface ${fileName}Type {
namespace: '${this.name}Store';
state: ${fileName}State;
effects: {};
reducers: {};
}
// store 模板
const ${fileName}: ${fileName}Type = {
namespace: '${this.name}Store',
state: {
},
effects: {
},
reducers: {
},
};
export default ${fileName};
`
// 输出model文件
extra.outputFileSync(path.resolve(this.targetDir, 'models', `${this.name}Store.ts`), modelTemp)
}
}
module.exports = NewModelsGenerator;
到此,mycli newPage <pageName>
的命令功能基本完成,动画等其他效果可自行添加。
难点在于对 babel 的理解,添加节点时有很多方法和配置需要查看。