你会学到什么

其实脚手架并不实现,难的是最佳实践的整理和沉淀。本文不会涉及到最佳实践方面的内容,只是教会你如何实现一个最基础的脚手架,以此作为展示最佳实践的载体。

因为是手把手教妹子写脚手架,所以本文很详细,字数很多,大概有 3 万字左右,读完你会学到:

  • 如何搭建一个脚手架的工程
  • 如何开发和调试一个脚手架
  • 脚手架中如何接收和处理命令参数
  • 脚手架中如何和用户交互
  • 脚手架中如何拷贝一个文件夹或文件
  • 脚手架中如何动态生成一个文件
  • 脚手架中如何处理路径问题
  • 脚手架中如何自动安装模板所需依赖

本文中所有代码已经上传到 GitHub

1、搭建一个 monorepo 风格的脚手架工程

1.1、如何用 Node.js 运行 js

为了避免 Node.js 版本差异导致奇怪的问题,建议安装 16 以上版本的 Node.js 。

随便找个地方建个一个 index.js 文件,并在该文件中添加如下代码:

1
console.log("Welcome to Mortal World");

在该文件的地址栏上输出 cmd 打开命令行窗口,输入 node index.js 命令,回车运行该命令。

image.png

可以在命令行窗口中看到打印了  Welcome to Mortals World

1.2、声明自己的命令

如果你熟悉 Vue , 肯定对 vue-cli 这个脚手架有一定了解,比如运行 vue create myapp 命令来创建一个 Vue 工程。

如果我们没有运行 npm install -g vue-cli 安装 vue-cli 脚手架,在命令行窗口中直接运行 vue create myapp,会报错,报错如下图所示:

image.png

可见 vue 不是系统命令, vue 只是 vue-cli 脚手架声明的一个命令。

那怎么给脚手架声明一个命令,其实非常简单,跟我来操作。

随便找个地方创建 mortal-cli 的文件夹,进入该文件夹后,在其地址栏上输出 cmd 打开命令行窗口,

image.png

运行 npm init 命令来初始化一个脚手架工程,运行成功后,会在该文件夹中生成一个 pakeage.json 文件。

我们在 pakeage.json 中添加 bin 字段,来声明一个命令,添加后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"name": "mortal-cli",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"bin": {
"mortal": "./bin/index.js"
}
}

这样我们就声明了一个 mortal 的命令,另外 ./bin/index.js 是运行 mortal 命令后会运行的 js 文件的相对路径。

接着我们在 mortal-cli 的文件夹中创建一个 bin 文件夹,在 bin 文件夹中创建一个 index.js 文件,并在该文件中添加如下代码:

1
2
3
#!/usr/bin/env node

console.log("Welcome to Mortal World");

注意在文件头部添加 #!/usr/bin/env node,否则运行后会报错!!!

这样就完成了一个最基础的脚手架工程,接下来在命令行窗口输入 mortal 命令,运行该命令。

image.png

会发现,还是报错,提示 mortal 命令不是系统命令,当然不是声明命令的方法错误。

假如你把这个脚手架发布到 npm 上后。由于 mortal-cli/pakeage.jsonname 的值为 mortal-cli,所以我们运行 npm install -g mortal-cli 将脚手架安装到本地后,再运行 mortal 命令,就会发现运行成功。

在实际开发脚手架过程中不可能这么做,所以我们还要实现本地调试脚手架的能力,实现起来也非常简单,一个命令搞定。

这个命令就是 npm link,哈哈想不到吧,在这里就不讲述其原理,如果你们需要的话,可以留言,我开个单章讲述一下。

输入 npm link 命令运行后,再输入 mortal 命令,回车运行,看到的结果如下图所示。

image.png

到此,我们成功的声明了一个 mortal 命令。

使用 npm link 来实现本地调试有一个弊端。比如在本地有多个版本的脚手架仓库,在仓库 A 中修改代码后,运行 mortal 命令后,发现更改的代码不生效。这是因为已经在仓库 B 的脚手架工程中运行 npm link,导致我们在运行 mortal 命令后是执行仓库 B 中的代码,在仓库 A 中修改代码能生效才怪。要先在仓库 B 的脚手架工程中运行 npm unlink 后,然后在仓库 A 中的脚手架工程中运行 npm link 后,修改仓库 A 中的代码才能生效。

为了解决这个弊端,我们使用 pnpm 来搭建 monorepo 风格的脚手架工程。

monorepo 风格的工程中可以含有多个子工程,且每个子工程都可以独立编译打包后将产物发成 npm 包,故又称 monorepo 为多包工程。

由于脚手架发布到 npm 上的包名为 mortal-cli ,修改调试子工程的 package.json 文件中的代码,代码修改部分如下所示:

1
2
3
4
5
6
7
8
{
"scripts": {
"mortal": "mortal --help"
},
"dependencies": {
"mortal-cli": "workspace:*"
}
}

注意,在 dependencies 字段中声明 mortal-cli 依赖包的版本,要用workspace:* 来定义,而不是具体版本号来定义。

在 pnpm 中使用 workspace: 协议定义某个依赖包版本号时,pnpm 将只解析存在工作空间内的依赖包,不会去下载解析 npm 上的依赖包。

mortal-cli 依赖包引入,执行 pnpm i 安装依赖,其效果就跟执行 npm install -g mortal-cli一样,只不过不是全局安装而已,只在调试子工程内安装 mortal-cli 脚手架。然后调试子工程就直接引用脚手架子工程本地编译打包后的产物,而不是发布到 npm 上的产物,彻底做到本地调试。

另外脚手架子工程和调试子工程是在同一个工程中,这样就做一对一的调试,从而解决了使用 npm link 来实现本地调试的弊端。

同时在 scripts 定义了脚本命令,在调试工程中执行 pnpm mortal 既是执行了 mortal 命令,不用脚手架工程中执行 npm link 就可以运行 mortal 命令。

1.4、monorepo 风格的脚手架工程

下面开始使用 pnpm 搭建 monorepo 风格的脚手架工程,首先在命令行窗口中输入以下代码,执行安装 pnpm

1
iwr https://get.pnpm.io/install.ps1 -useb | iex

然后重新找个地方创建 mortal 文件夹,进入该文件夹后,在其地址栏上输出 cmd 打开命令行窗口。

输入 pnpm init 初始化工程,pnpm 是使用 workspace (工作空间) 来搭建一个 monorepo 风格的工程。

所以我们要 mortal 文件夹中创建 pnpm-workspace.yaml 工作空间配置文件,并在该文件中添加如下配置代码。

1
packages: -"packages/*" - "examples/*";

配置后,声明了 packagesexamples 文件夹中子工程是同属一个工作空间的,工作空间中的子工程编译打包的产物都可以被其它子工程引用。

packages 文件夹中新建 mortal-cli 文件夹,在其地址栏上输出 cmd 打开命令行窗口。

输入 pnpm init 命令,执行来初始化一个工程,执行成功后,会在该文件夹中生成一个 pakeage.json 文件。

我们在 pakeage.json 中添加 bin 字段,来声明 mortal 命令,添加后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"name": "mortal-cli",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"bin": {
"mortal": "./bin/index.js"
}
}

packages/mortal-cli 文件夹中新建 bin 文件夹,在 bin 文件夹中新建 index.js 文件,并在该文件中添加如下代码:

1
2
3
#!/usr/bin/env node

console.log("Welcome to Mortal World");

examples 文件夹中新建 app 文件夹,在其地址栏上输出 cmd 打开命令行窗口, 运行 pnpm init 命令来初始化一个工程,运行成功后,会在该文件夹中生成一个 pakeage.json 文件。

我们在 pakeage.json 中添加 dependencies 字段,来添加 mortal-cli 依赖。再给 scripts 增加一条自定义脚本命令。添加后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"name": "app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"mortal": "mortal"
},
"author": "",
"license": "ISC",
"dependencies": {
"mortal-cli": "workspace:*"
}
}

然后在最外层根目录下运行 pnpm i 命令,安装依赖。安装成功后,在 app 文件夹目录下运行 pnpm mortal,会发现命令行窗口打印出 Welcome to Mortal World,说明你的 monorepo 风格的脚手架工程的搭建成功了。

image.png

此时整个工程的目录结构如下所示

1
2
3
4
5
6
7
8
9
10
11
12
|-- mortal
|-- package.json
|-- pnpm-lock.yaml
|-- pnpm-workspace.yaml
|-- examples
| |-- app
| |-- package.json
|-- packages
|-- mortal-cli
|-- package.json
|-- bin
|-- index.js

1.5、脚手架必备的模块

一个最简单的脚手架包含以下几个模块。

  • 命令参数模块
  • 用户交互模块
  • 文件拷贝模块
  • 动态文件生成模块
  • 自动安装依赖模块

下面我们来一一将他们实现。

2、命令参数模块

2.1、获取命令参数

Node.js 中的 process 模块提供了当前 Node.js 进程相关的全局环境信息,比如命令参数、环境变量、命令运行路径等等。

1
2
3
const process = require("process");
// 获取命令参数
console.log(process.argv);

脚手架提供的 mortal 命令后面还可以设置参数,标准的脚手架命令参数需要支持两种格式,比如:

1
2
mortal --name=orderPage
mortal --name orderPage

如果通过 process.argv 来获取,要额外处理两种不同的命令参数格式,不方便。

这里推荐 yargs 开源库来解析命令参数。运行以下命令安装 yargs

1
pnpm add yargs --F mortal-cli

pnpm addpnpm 中安装依赖包的命令, --F mortal-cli,是指定依赖安装到 mortal-cli 子工程中。

这里要注意,mortal-cli 是取 mortal-cli 子工程中 package.jsonname 字段的值,而不是 mortal-cli 子工程文件夹的名称。

yargs 的使用非常简单,其提供的 argv 属性是对两个格式的命令参数的处理结果。

bin/index.js 添加如下代码:

1
2
3
4
#!/usr/bin/env node
const yargs = require("yargs");

console.log("name", yargs.argv.name);

注意,以上代码是在 Node.js 环境中运行,Node.js 的模块是遵循 CommonJS 规范的,如果要依赖一个模块,要使用 Node.js 内置 require 系统函数引用模块使用。

app 文件夹目录下运行 pnpm mortal -- --name=orderPage

注意,在 pnpm mortal 后面需要加上两个连字符(–),这是为了告诉 pnpm 后面的参数是传递给命令mortal 本身的,而不是传递给 pnpm 的。

结果如下图所示:

image.png

可以通过 yargs.argv.name 获取命令参数 name 的值。

2.2、设置子命令

假如脚手架要对外提供多个功能,不能将所有的功能都集中在 mortal 命令中实现。

可以通过 yargs 提供的 command 方法来设置一些子命令,让每个子命令对应各自功能,各司其职。

yargs.command 的用法是 **yargs.command(cmd, desc, builder, handler)**。

  • cmd:字符串,子命令名称,也可以传递数组,如 ['create', 'c'],表示子命令叫 create,其别名是 c
  • desc:字符串,子命令描述信息;
  • builder:一个返回数组的函数,子命令参数信息配置,比如可以设置参数:
    • alias:别名;
    • demand:是否必填;
    • default:默认值;
    • describe:描述信息;
    • type:参数类型,string | boolean | number
  • handler: 函数,可以在这个函数中专门处理该子命令参数。

下面我们来设置一个用来生成一个模板的子命令,把这个子命令命名为create

修改在 bin/index.js 文件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env node

const yargs = require("yargs");
yargs.command(
["create", "c"],
"新建一个模板",
function (yargs) {
return yargs.option("name", {
alias: "n",
demand: true,
describe: "模板名称",
type: "string",
});
},
function (argv) {
console.log("argv", argv);
}
).argv;

app 文件夹目录下分别运行 pnpm mortal create -- --name=orderPagepnpm mortal c -- --name=orderPage 命令,执行结果如下图所示:

image.png

在上面我们配置了子命令 create 的参数 name 的一些参数信息。那这些要怎么展示给用户看呢?其实只要我们输入子命令的参数有错误,就会在命令行窗口中显示这些参数信息。

app 文件夹目录下运行 pnpm mortal c -- --abc 命令,执行结果如下图所示:

image.png

到此为止,我们最简单地实现了脚手架和用户之间的交互能力,但是如果自定义参数过多,那么命令行参数的交互方法对于用户来说是非常不友好的。所以我们还要实现一个用户交互模块,如何实现请看下一小节。

3、用户交互模块

我认为比较好的用户交互方式是讯问式的交互,比如我们在运行 npm init,通过询问式的交互完成 package.json 文件内容的填充。

image.png

这里推荐使用 inquirer 开源库来实现询问式的交互,运行以下命令安装 inquirer

1
pnpm add inquirer@8.2.5 --F mortal-cli

为了使用 require 引入 inquirer ,要使用 8.2.5 版本的 inquirer

这里我们主要使用了 inquirer 开源库的三个方面的能力:

  • 询问用户问题
  • 获取并解析用户的输入
  • 检测用户的答案是否合法

主要通过 inquirer.prompt() 来实现。prompt 函数接收一个数组,数组的每一项都是一个询问项,询问项有很多配置参数,下面是常用的配置项。

  • type:提问的类型,常用的有
    • 输入框:input
    • 确认:confirm
    • 单选组:list
    • 多选组:checkbox
  • name:存储当前问题答案的变量;
  • message:问题的描述;
  • default:默认值;
  • choices:列表选项,在某些type下可用;
  • validate:对用户的答案进行校验;
  • filter:对用户的答案进行过滤处理,返回处理后的值。

比如我们创建一个模板文件,大概会询问用户:模板文件名称、模板类型、使用什么框架开发、使用框架对应的哪个组件库开发等等。下面我们来实现这个功能。

bin 文件夹中新建 inquirer.js 文件夹,在里面添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
const inquirer = require("inquirer");

function inquirerPrompt(argv) {
const { name } = argv;
return new Promise((resolve, reject) => {
inquirer
.prompt([
{
type: "input",
name: "name",
message: "模板名称",
default: name,
validate: function (val) {
if (!/^[a-zA-Z]+$/.test(val)) {
return "模板名称只能含有英文";
}
if (!/^[A-Z]/.test(val)) {
return "模板名称首字母必须大写";
}
return true;
},
},
{
type: "list",
name: "type",
message: "模板类型",
choices: ["表单", "动态表单", "嵌套表单"],
filter: function (value) {
return {
表单: "form",
动态表单: "dynamicForm",
嵌套表单: "nestedForm",
}[value];
},
},
{
type: "list",
message: "使用什么框架开发",
choices: ["react", "vue"],
name: "frame",
},
])
.then((answers) => {
const { frame } = answers;
if (frame === "react") {
inquirer
.prompt([
{
type: "list",
message: "使用什么UI组件库开发",
choices: ["Ant Design"],
name: "library",
},
])
.then((answers1) => {
resolve({
...answers,
...answers1,
});
})
.catch((error) => {
reject(error);
});
}

if (frame === "vue") {
inquirer
.prompt([
{
type: "list",
message: "使用什么UI组件库开发",
choices: ["Element"],
name: "library",
},
])
.then((answers2) => {
resolve({
...answers,
...answers2,
});
})
.catch((error) => {
reject(error);
});
}
})
.catch((error) => {
reject(error);
});
});
}

exports.inquirerPrompt = inquirerPrompt;

其中 inquirer.prompt() 返回的是一个 Promise,我们可以用 then 获取上个询问的答案,根据答案再发起对应的内容。

bin/index.js 中引入 inquirerPrompt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/usr/bin/env node

const yargs = require("yargs");
const { inquirerPrompt } = require("./inquirer");

yargs.command(
["create", "c"],
"新建一个模板",
function (yargs) {
return yargs.option("name", {
alias: "n",
demand: true,
describe: "模板名称",
type: "string",
});
},
function (argv) {
inquirerPrompt(argv).then((answers) => {
console.log(answers);
});
}
).argv;

app 文件夹目录下运行 pnpm mortal c -- --n Input 命令,执行结果如下图所示:

image.png

image.png

可以很清楚地看到“在使用什么框架开发”的询问中回答不同,下一个“使用什么 UI 组件库的开发”的询问可选项不一样。

回答完成后,可以在下图中清楚地看到答案格式

image.png

4、文件夹拷贝模块

要生成一个模板文件,最简单的做法就是执行脚手架提供的命令后,把脚手架中的模板文件,拷贝到对应的地方。模板文件可以是单个文件,也可以是一个文件夹。本小节先介绍一下模板文件是文件夹时候如何拷贝。

Node.js 中拷贝文件夹并不简单,需要用到递归,这里推荐使用开源库copy-dir来实现拷贝文件。

运行以下命令安装 copy-dir

1
pnpm add copy-dir --F mortal-cli

bin 文件夹中新建 copy.js 文件,在里面添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
const copydir = require("copy-dir");
const fs = require("fs");

function copyDir(from, to, options) {
copydir.sync(from, to, options);
}

function checkMkdirExists(path) {
return fs.existsSync(path);
}

exports.checkMkdirExists = checkMkdirExists;
exports.copyDir = copyDir;

copyDir 方法实现非常简单,难的是如何使用,下面创建一个场景来介绍一下如何使用。

我们在 bin 文件夹中新建 template 文件夹,用来存放模板文件,比如在 template 文件夹中创建一个 form 文件夹来存放表单模板,这里不介绍表单模板的内容,我们随意在 form 文件夹中创建一个 _index.js_,在里面随便写些内容。其目录结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|-- mortal
|-- package.json
|-- pnpm-lock.yaml
|-- pnpm-workspace.yaml
|-- examples
| |-- app
| |-- package.json
|-- packages
|-- mortal
|-- package.json
|-- bin
|-- template
|-- form
|-- index.js
|-- copy.js
|-- index.js
|-- inquirer.js

下面来实现把 packages/mortal/bin/template/form 这个文件夹拷贝到 examples/app/src/pages/OrderPage 中 。

bin/index.js 修改代码,修改后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#!/usr/bin/env node

const yargs = require("yargs");
const path = require("path");
const { inquirerPrompt } = require("./inquirer");
const { copyDir, checkMkdirExists } = require("./copy");

yargs.command(
["create", "c"],
"新建一个模板",
function (yargs) {
return yargs.option("name", {
alias: "n",
demand: true,
describe: "模板名称",
type: "string",
});
},
function (argv) {
inquirerPrompt(argv).then((answers) => {
const { name, type } = answers;
const isMkdirExists = checkMkdirExists(
path.resolve(process.cwd(), `./src/pages/${name}`)
);
if (isMkdirExists) {
console.log(`${name}文件夹已经存在`);
} else {
copyDir(
path.resolve(__dirname, `./template/${type}`),
path.resolve(process.cwd(), `./src/pages/${name}`)
);
}
});
}
).argv;

使用拷贝文件方法 copyDir 的难点是参数 fromto 的赋值。其中 from 表示要拷贝文件的路径,to 表示要把文件拷贝到那里的路径。这里的路径最好使用绝对路径,因为在 Node.js 中使用相对路径会出现一系列奇奇怪怪的问题。

4.1、脚手架中的路径处理

我们可以用 Node.js 中的 path 模块提供的 path.resolve( [from…], to ) 方法将路径转成绝对路径,就是将参数 to 拼接成一个绝对路径,[from … ] 为选填项,可以设置多个路径,如 path.resolve('./aaa', './bbb', './ccc') ,使用时要注意path.resolve 的路径拼接规则:

  • 从后向前拼接路径;
  • to/ 开头,不会拼接到前面的路径;
  • to../ 开头,拼接前面的路径,且不含最后一节路径;
  • to./ 开头或者没有符号,则拼接前面路径。

从以上拼接规则来看,使用 path.resolve 时,要特别注意参数 to 的设置。

下面来介绍一下,使用 copyDir 方法时,参数如何设置:

  • copyDir 的参数 from 设置为 path.resolve(__dirname, `./template/${type}`)

    其中 __dirname 是用来动态获取当前文件模块所属目录的绝对路径。比如在 bin/index.js 文件中使用 __dirname__dirname 表示就是 bin/index.js 文件所属目录的绝对路径 D:\mortal\packages\mortal-cli\bin

    因为模板文件存放在 bin/template 文件夹中 ,copyDir 是在 bin/index.js 中使用,bin/template 文件夹相对 bin/index.js 文件的路径是 ./template,所以把 path.resolve 的参数 to 设置为 ./template/${type},其中 type 是用户所选的模板类型。

    假设 type 的模板类型是 form,那么 path.resolve(__dirname, `./template/form`) 得到的绝对路径是 D:\mortal\packages\mortal-cli\bin\template\form

  • copyDir 的参数 to 设置为 path.resolve(process.cwd(), `${name}`)

    其中 process.cwd() 当前 Node.js 进程执行时的文件所属目录的绝对路径。比如在 bin 文件夹目录下运行 node index.js 时,process.cwd() 得到的是 D:\mortal\packages\mortal-cli\bin

    运行 node index.js 相当运行 mortal 命令。而在现代前端工程中都是在 package.json 文件中scripts  定义了脚本命令,如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    {
    "name": "app",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
    "mortal": "mortal"
    },
    "author": "",
    "license": "ISC",
    "dependencies": {
    "mortal-cli": "workspace:*"
    }
    }

    运行 pnpm mortal 就相当运行 mortal 命令,那么执行 pnpm mortal 时,当前 Node.js 进程执行时的文件是 package.json 文件。那么 process.cwd() 得到的是 D:\mortal\examples\app

    因为要把 packages/mortal/bin/template/form 这个文件夹拷贝到 examples/app/src/pages/OrderPage 中,且 process.cwd() 的值是D:\mortal\examples\appsrc/pages 文件夹相对 examples/app 的路径是 ./src/pages ,所以把 path.resolve 的参数 to 设置为 ./src/pages/${name},其中 name 是用户所输入的模板名称。

4.2、目录守卫

app 文件夹目录下运行 pnpm mortal create -- --name=OrderPage,看能不能成功得把 packages/mortal/bin/template/form 这个文件夹拷贝到 examples/app/src/pages/OrderPage 中。

image.png

报错了,提示 examples/app/src/pages 文件夹不存在。为了防止这种报错出现,我们要实现一个目录守护的方法 mkdirGuard ,比如 examples/app/src/pages 文件夹不存在,就创建一个 examples/app/src/pages 文件夹。

bin/copy.js 文件中,修改代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const copydir = require("copy-dir");
const fs = require("fs");
const path = require("path");

function mkdirGuard(target) {
try {
fs.mkdirSync(target, { recursive: true });
} catch (e) {
mkdirp(target);
function mkdirp(dir) {
if (fs.existsSync(dir)) {
return true;
}
const dirname = path.dirname(dir);
mkdirp(dirname);
fs.mkdirSync(dir);
}
}
}

function copyDir(form, to, options) {
mkdirGuard(to);
copydir.sync(form, to, options);
}

function checkMkdirExists(path) {
return fs.existsSync(path);
}

exports.checkMkdirExists = checkMkdirExists;
exports.mkdirGuard = mkdirGuard;
exports.copyDir = copyDir;

fs.mkdirSync 的语法格式:fs.mkdirSync(path[, options]),创建文件夹目录。

  • path:文件夹目录路径;
  • optionsrecursive 表示是否要创建父目录,true 要。

fs.existsSync 的语法格式:fs.existsSync(pach),检测目录是否存在,如果目录存在返回  true ,如果目录不存在返回false

  • path:文件夹目录路径。

path.dirname 的语法格式:path.dirname(path),用于获取给定路径的目录名。

  • path:文件路径。

mkdirGuard 方法内部,当要创建的目录 target 父级目录不存在时,调用fs.mkdirSync(target),会报错走 catch 部分逻辑,在其中递归创建父级目录,使用 fs.existsSync(dir) 来判断父级目录是否存在,来终止递归。这里要特别注意 fs.mkdirSync(dir) 创建父级目录要在 mkdirp(dirname) 之前调用,才能形成一个正确的创建顺序,否则创建父级目录过程会因父级目录的父级目录不存在报错。

我们再次在 app 文件夹目录下运行 pnpm mortal create -- --name=OrderPage,看这次能不能成功得把 packages/mortal/bin/template/form 这个文件夹拷贝到 examples/app/src/pages/OrderPage 中。

成功添加,添加结果如下所示:

image.png

然后再运行 pnpm mortal create -- --name=OrderPage 命令,会发现控制台打印出模板已经存在在提示。

image.png

这是为了防止用户修改后的模板文件,运行命令后被重新覆盖到初始状态。所以我们引入一个校验模板文件是否存在的 checkMkdirExists 方法,内部采用 fs.existsSync 来实现。

5、文件拷贝模块

文件拷贝分三步来实现,使用 fs.readFileSync 读取被拷贝的文件内容,然后创建一个文件,再使用 fs.writeFileSync 写入文件内容。

bin/copy.js 文件,在里面添加如下代码:

1
2
3
4
5
6
7
8
9
10
function copyFile(from, to) {
const buffer = fs.readFileSync(from);
const parentPath = path.dirname(to);

mkdirGuard(parentPath);

fs.writeFileSync(to, buffer);
}

exports.copyFile = copyFile;

接下来我们使用 copyFile 方法,在 bin/index.js 修改代码,修改后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#!/usr/bin/env node

const yargs = require("yargs");
const path = require("path");
const { inquirerPrompt } = require("./inquirer");
const { copyDir } = require("./copy");

yargs.command(
["create", "c"],
"新建一个模板",
function (yargs) {
return yargs.option("name", {
alias: "n",
demand: true,
describe: "模板名称",
type: "string",
});
},
function (argv) {
inquirerPrompt(argv).then((answers) => {
const { name, type } = answers;
const isMkdirExists = checkMkdirExists(
path.resolve(process.cwd(), `./src/pages/${name}/index.js`)
);
if (isMkdirExists) {
console.log(`${name}/index.js文件已经存在`);
} else {
copyFile(
path.resolve(__dirname, `./template/${type}/index.js`),
path.resolve(process.cwd(), `./src/pages/${name}/index.js`),
{
name,
}
);
}
});
}
).argv;

copyFilecopyDir 使用的区别在参数,copyFile 要求参数 from 和参数 to 都精确到文件路径。

app 文件夹目录下运行 pnpm mortal create -- --name=OrderPage,执行结果如下图所示:

image.png

6、动态文件生成模块

假设脚手架中提供的模板文件中某些信息需要根据用户输入的命令参数来动态生成对应的模板文件。

比如下面模板文件中 App 要动态替换成用户输入的命令参数 name 的值,该如何实现呢?

1
2
3
4
5
import React from "react";
const App = () => {
return <div></div>;
};
export default App;

这里推荐使用开源库mustache来实现,运行以下命令安装 copy-dir

1
pnpm add mustache --F mortal-cli

我们在 packages/mortal-cli/bin/template/form 文件夹中创建一个 index.tpl 文件,内容如下:

1
2
3
4
5
6
7
import React from 'react';
const {{name}} = () => {
return (
<div></div>
);
};
export default {{name}};

先写一个 readTemplate 方法来读取这个 index.tpl 动态模板文件内容。在 bin/copy.js 文件,在里面添加如下代码:

1
2
3
4
5
6
7
8
const Mustache = require("mustache");

function readTemplate(path, data = {}) {
const str = fs.readFileSync(path, { encoding: "utf8" });
return Mustache.render(str, data);
}

exports.readTemplate = readTemplate;

readTemplate 方法接收两个参数,path 动态模板文件的相对路径,data 动态模板文件的配置数据。

使用 Mustache.render(str, data) 生成模板文件内容返回,因为 Mustache.render 的第一个参数类型是个字符串,所以在调用 fs.readFileSync 时要指定 encoding 类型为 utf8,否则 fs.readFileSync 返回 Buffer 类型数据。

在写一个 copyTemplate 方法来拷贝模板文件到对应的地方,跟 copyFile 方法非常相似。在 bin/copy.js 文件,在里面添加如下代码:

1
2
3
4
5
6
7
8
function copyTemplate(from, to, data = {}) {
if (path.extname(from) !== ".tpl") {
return copyFile(from, to);
}
const parentToPath = path.dirname(to);
mkdirGuard(parentToPath);
fs.writeFileSync(to, readTemplate(from, data));
}

path.extname(from) 返回文件扩展名,比如 path.extname(index.tpl) 返回 .tpl

bin/index.js 修改代码,修改后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#!/usr/bin/env node

const yargs = require("yargs");
const path = require("path");
const { inquirerPrompt } = require("./inquirer");
const { copyTemplate } = require("./copy");

yargs.command(
["create", "c"],
"新建一个模板",
function (yargs) {
return yargs.option("name", {
alias: "n",
demand: true,
describe: "模板名称",
type: "string",
});
},
function (argv) {
inquirerPrompt(argv).then((answers) => {
const { name, type } = answers;
const isMkdirExists = checkMkdirExists(
path.resolve(process.cwd(), `./src/pages/${name}/index.js`)
);
if (isMkdirExists) {
console.log(`${name}/index.js文件已经存在`);
} else {
copyTemplate(
path.resolve(__dirname, `./template/${type}/index.tpl`),
path.resolve(process.cwd(), `./src/pages/${name}/index.js`),
{
name,
}
);
}
});
}
).argv;

app 文件夹目录下运行 pnpm mortal create -- --name=OrderPage,执行结果如下图所示:

image.png

6.1、mustache 简介

以上的案例是 mustache 最简单的使用,下面来额外介绍一些常用的使用场景。

首先来熟悉一下 mustache 的语法,下面来介绍一些场景来使用这些语法

  • {{key}}
  • {{#key}} {{/key}}
  • {{^key}} {{/key}}
  • {{.}}
  • {{&key}}

6.1.1、简单绑定

使用 {{key}} 语法,key 要和 Mustache.render 方法中的第二个参数(一个对象)的属性名一致。

例如:

1
Mustache.render("<span>{{name}}</span>", { name: "张三" });

输出:

1
<span>张三</span>

6.1.2、绑定子属性

例如:

1
Mustache.render("<span>{{ifno.name}}</span>", { ifno: { name: "张三" } });

输出:

1
<span>张三</span>

6.1.3、循环渲染

如果 key 属性值是一个数组,则可以使用 {{#key}} {{/key}} 语法来循环展示。 其中 {{#}} 标记表示从该标记以后的内容全部都要循环展示,{{/}}标记表示循环结束。

例如:

1
2
3
Mustache.render("<span>{{#list}}{{name}}{{/list}}</span>", {
list: [{ name: "张三" }, { name: "李四" }, { name: "王五" }],
});

输出:

1
<span>张三李四王五</span>

如果 list 的值是 ['张三','李四','王五'],要把 {{name}} 替换成 {{.}} 才可以渲染。

1
2
3
Mustache.render("<span>{{#list}}{{.}}{{/list}}</span>", {
list: ["张三", "李四", "王五"],
});

6.1.4、循环中二次处理数据

Mustache.render 方法中的第二个参数是个对象,其属性值可以是一个函数,渲染时候会执行函数输出返回值,函数中可以用 this 获取第二个参数的上下文。

例如:

1
2
3
4
5
6
Mustache.render("<span>{{#list}}{{info}}{{/list}}</span>", {
list: [{ name: "张三" }, { name: "李四" }, { name: "王五" }],
info() {
return this.name + ",";
},
});

输出:

1
<span>张三,李四,王五,</span>

6.1.5、条件渲染

使用 {{#key}} {{/key}} 语法 和 {{^key}} {{/key}} 语法来实现条件渲染,当 keyfalse0[]{}null,既是 key == false 为真,{{#key}} {{/key}} 包裹的内容不渲染,{{^key}} {{/key}} 包裹的内容渲染

例如:

1
2
3
Mustache.render("<span>{{#show}}显示{{/show}}{{^show}}隐藏{{/show}}</span>", {
show: false,
});

输出:

1
<span>隐藏</span>

6.1.6、不转义 HTML 标签

使用 {{&key}} 语法来实现。

例如:

1
2
3
Mustache.render("<span>{{&key}}</span>", {
key: "<span>标题</span>",
});

输出:

1
<span><span>标题</span></span>

7、自动安装依赖模块

假设模板是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React from "react";
import { Button, Form, Input } from "antd";

const App = () => {
const onFinish = (values) => {
console.log("Success:", values);
};
return (
<Form onFinish={onFinish} autoComplete="off">
<Form.Item label="Username" name="username">
<Input />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
提交
</Button>
</Form.Item>
</Form>
);
};
export default App;

可以看到模板中使用了 reactantd 这两个第三方依赖,假如使用模板的工程中没有安装这两个依赖,我们要实现在生成模板过程中就自动安装这两个依赖。

我们使用 Nodechild_process 子进程这个模块来实现。

child_process 子进程中的最常用的语法是:

child_process.exec(command, options, callback)

  • command:命令,比如 pnpm install
  • options:参数
    • cwd:设置命令运行环境的路径
    • env:环境变量
    • timeout:运行执行现在
  • callback:运行命令结束回调,(error, stdout, stderr) =>{ },执行成功后 errornull,执行失败后 error 为 Error 实例,stdoutstderr 为标准输出、标准错误,其格式默认是字符串。

bin 文件夹中新建 inquirer.js 文件夹,在里面添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const path = require("path");
const { exec } = require("child_process");

const LibraryMap = {
"Ant Design": "antd",
iView: "view-ui-plus",
"Ant Design Vue": "ant-design-vue",
Element: "element-plus",
};

function install(cmdPath, options) {
const { frame, library } = options;
const command = `pnpm add ${frame} && pnpm add ${LibraryMap[library]}`;
return new Promise(function (resolve, reject) {
exec(
command,
{
cwd: path.resolve(cmdPath),
},
function (error, stdout, stderr) {
console.log("error", error);
console.log("stdout", stdout);
console.log("stderr", stderr);
}
);
});
}

exports.install = install;

install 方法中 exec 的参数 commandpnpm 安装依赖命令,安装多个依赖时使用 && 拼接。参数 cwd 是所安装依赖工程的 package.json 文件路径,我们可以使用 process.cwd() 获取。已经在上文提到过,process.cwd() 是当前Node.js 进程执行时的文件所属目录的绝对路径。

接下来使用,在 bin/index.js 修改代码,修改后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#!/usr/bin/env node

const yargs = require("yargs");
const path = require("path");
const { inquirerPrompt } = require("./inquirer");
const { copyTemplate, checkMkdirExists } = require("./copy");
const { install } = require("./manager");

yargs.command(
["create", "c"],
"新建一个模板",
function (yargs) {
return yargs.option("name", {
alias: "n",
demand: true,
describe: "模板名称",
type: "string",
});
},
function (argv) {
inquirerPrompt(argv).then((answers) => {
const { name, type } = answers;
const isMkdirExists = checkMkdirExists(
path.resolve(process.cwd(), `./src/pages/${name}/index.js`)
);
if (isMkdirExists) {
console.log(`${name}/index.js文件已经存在`);
} else {
copyTemplate(
path.resolve(__dirname, `./template/${type}/index.tpl`),
path.resolve(process.cwd(), `./src/pages/${name}/index.js`),
{
name,
}
);
install(process.cwd(), answers);
}
});
}
).argv;

当执行完 copyTemplate 方法后,就开始执行 install(process.cwd(), answers) 自动安装模板中所需的依赖。

app 文件夹目录下运行 pnpm mortal create -- --name=OrderPage,看能不能自动安装依赖。

等命令执行完成后,观察 examples\app\package.json 文件中的 dependencies 值是不是添加了 antdreact 依赖。

image.png

此外,我们在执行命令中会发现,如下图所示的现象,光标一直在闪烁,好像卡住了,其中是依赖在安装。这里我们要引入一个加载动画,来解决这个不友好的现象。

image.png

这里推荐使用开源库ora来实现加载动画。

运行以下命令安装 ora

1
pnpm add ora@5.4.1 --F mortal-cli

bin/inquirer.js 修改代码,修改后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const path = require("path");
const { exec } = require("child_process");
const ora = require("ora");

const LibraryMap = {
"Ant Design": "antd",
iView: "view-ui-plus",
"Ant Design Vue": "ant-design-vue",
Element: "element-plus",
};

function install(cmdPath, options) {
const { frame, library } = options;
const command = `pnpm add ${frame} && pnpm add ${LibraryMap[library]}`;
return new Promise(function (resolve, reject) {
const spinner = ora();
spinner.start(`正在安装依赖,请稍等`);
exec(
command,
{
cwd: path.resolve(cmdPath),
},
function (error) {
if (error) {
reject();
spinner.fail(`依赖安装失败`);
return;
}
spinner.succeed(`依赖安装成功`);
resolve();
}
);
});
}

exports.install = install;

app 文件夹目录下运行 pnpm mortal create -- --name=OrderPage,看一下执行效果。

image.png

image.png

8、发布和安装

packages/mortal 文件夹目录下运行,运行以下命令安装将脚手架发布到 npm 上。

1
pnpm publish --F mortal-cli

发布成功后。我们在一个任意工程中,执行 pnpm add mortal-cli -D 安装 mortal-cli 脚手架依赖成功后,在工程中执行 pnpm mortal create -- --name=OrderPage 命令即可。

结语

上面只教大家实现一个最最简单的脚手架。其功能就只有一个模板文件生成。虽然简单,但是这些都是脚手架的入门功,代码已经上传到 GitHub,大家可以下载下来,自己实践一下,光看不练永远学不会。

学会了,可以总结一些平时的业务代码,形成最佳实践,使用脚手架作为载体展现出来,提升自己的职场竞争力。