什么是前端脚手架?
脚手架指的就是:有人帮你把这个开发过程中要用到的工具、环境都配置好了,你就可以方便地直接开始做开发,专注你的业务,而不用再花时间去配置这个开发环境,这个开发环境就是脚手架。
比如vue.js就有个vue-cli脚手架,基于node.js的开发环境,作者帮你把开发环境大部分东西都配置好了,你把脚手架下载下来就可以直接开发了,不用再考虑搭建这些工具环境。
再比如react.js也有个create-react-app脚手架,它的优势在于省略了很多涉及配置的地方,能够更加容易上手。
如何用 Node.js 运行 .js 文件
创建一个 index.js 文件, 里面写一段很简单的内容
console.log('Hello, Judson')
在该文件的命令窗口中运行 node index.js
, 我们可以看到输出
Hello, Judson
声明自己的命令
做vue开发的一定都知道 vue-cli 这个脚手架, 我们通常运行 vue create myapp
命令来创建一个 vue 工程.
如果我们没有npm install -g @vue/cli
安装vue-cli
脚手架,在命令窗口直接运行 vue create myapp
, 会报错 “ vue 不是内部或外部命令,也不是可运行的程序或批处理文件.”
由此可见 vue
不是系统命令, vue
只是 vue-cli
脚手架声明的一个命令.
接下来我们自己来做一个类似的脚手架命令.
实现一个自己的脚手架
我这里脚手架名称叫 judson
, 创建 judson-cli
目录并 npm init
初始化, 如下图所示
打开 package.json
文件并添加 bin
配置来声明一个命令, 添加后的代码如下所示
{
"name": "judson-cli",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"judson": "./bin/index.js"
},
"keywords": [],
"author": "",
"license": "ISC"
}
这样我们就声明了一个 judson
命令, 另外 ./bin/index.js
是运行 judson
命令后运行的文件相对路径
接下来在项目目录下添加 bin
文件夹, 并在该文件夹下创建一个 index.js
文件, 文件内添加如下代码:
#!/usr/bin/env node
console.log('Welcome to Judson World');
注意在文件头部添加 #!/usr/bin/env node, 否则运行后会报错
这样我们就完成了一个最简单的脚手架工程, 接下来在命令行窗口输入 judson
命令
系统提示 judson
命令没有找到. 因为我们还未发布和安装此命令
当我们把这个脚手架发布到 npm
上后,由于 judson-cli/package.json
中的 name
值为 judson-cli
, 所以我们运行 npm install -g judson-cli
将脚手架安装到本机后,再运行 judson-cli
命令是否发布成功.
在实际开发脚手架过程中我们不会这么做, 因为每次发布到 npm 后再本机上安装后再运行命令进行测试,效率很低. 所以我们需要使用 npm link
命令来进行快速映射.
npm link
可以帮助我们模拟包安装后的状态,它会在系统中做一个快捷方式映射,让本地的包就好像 install 过一样,可以直接使用。
npm link 的弊端
使用 npm link
来实现本地调试有一个弊端。比如在本地有多个版本的脚手架仓库,在仓库A中修改代码后,运行 judson
命令后,发现更改的代码不生效。这是因为已经在仓库B的脚手架工程中运行 npm link
,导致我们在运行 judson
命令后是执行仓库B中的代码,在仓库A中修改代码能生效才怪。要先在仓库B的脚手架工程中运行 npm unlink
后,然后在仓库A中的脚手架工程中运行 npm link
后,修改仓库A中的代码才能生效。
为了解决这个弊端,我们使用 pnpm
来搭建 monorepo
风格的脚手架工程。
搭建 monorepo 风格的脚手架工程
在 monorepo
风格的工程中可以含有多个子工程,且每个子工程都可以独立编译打包后将产物发成 npm
包,故又称 monorepo
为多包工程。
下面开始使用 pnpm
搭建 monorepo
风格的脚手架工程,首先在命令行窗口中输入以下代码,执行安装 pnpm 。
npm install -g pnpm
重新创建文件夹,我这里创建 judson
文件夹, 进入该文件夹后, 输入pnpm init --yes
初始化工程, pnpm
是使用 workspace
来搭建一个monorepo
风格的工程.
所以我们要在 judson
文件夹中创建 pnpm-workspace.yaml
工作空间配置文件,并在该文件中添加如下配置代码
packages
- 'packages/*'
- 'examples/*'
配置后,声明了 packages
和 examples
文件夹中子工程是同属一个工作空间的,工作空间中的子工程编译打包的产物都可以被其它子工程引用。
在 packages
文件夹中新建 judson-cli
文件夹, 并使用命令进入该文件夹运行pnpm init --yes
来初始化一个工程, 执行完成后会在该文件夹中生产一个 package.json
.
修改 package.json
内容,添加 bin
字段来声明 judson
命令, 添加后的代码如下所示:
{
"name": "judson-cli",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"bin": {
"judson": "./bin/index.ts"
}
}
在 packages/judson-cli
文件夹中新建 bin
文件夹,在 bin
文件夹中新建 index.js
文件,并在该文件中添加如下代码:
#!/usr/bin/env node
console.log('Welcome to Judson World');
在 examples
文件夹中新建 app
文件夹,命令行进入该目录, 运行 pnpm init --yes
命令来初始化一个工程,运行成功后,会在该文件夹中生成一个 pakeage.json
文件。
我们在 pakeage.json
中添加 dependencies
字段,来添加 judson-cli
依赖。再给 scripts
增加一条自定义脚本命令。添加后的代码如下所示:
{
"name": "app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"judson": "judson"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"judson-cli": "workspace:*"
}
}
然后在最外层根目录下运行 pnpm i
命令,安装依赖。安装成功后,在 app
文件夹目录下运行 pnpm judson
,会发现命令行窗口打印出 Welcome to Judson World
,说明你的 monorepo
风格的脚手架工程的搭建成功了。
此时整个工程的目录结构如下图所示
目录结构
脚手架必备模块
一个简单的脚手架通常包含以下几个模块
- 命令参数模块
- 用户交互模块
- 文件拷贝模块
- 动态文件生成模块
- 自动安装依赖模块
接下来我们一一将他们实现
命令参数模块
- 获取命令参数
Node.js中的process
模块提供了当前Node.js进程相关的全局环境信息,比如命令参数, 环境变量, 命令运行路径等
const process = require('process')
// 获取命令参数
console.log(process.argv)
脚手架提供的 judson
命令后面还可以设置参数,标准的脚手架命令参数需要支持两种格式,比如:
judson --name=Login // 等于号赋值
judson --name Login // 空格赋值
如果仅通过 process.argv
来获取,要额外处理两种不同的命令参数格式,不是很方便.
这里推荐 yargs
开源库来解析命令参数.
运行以下命令安装 yargs
pnpm add yargs --F judson-cli
pnpm add
是 pnpm
中安装依赖包的命令, --F judson-cli
,是指定依赖安装到 judson-cli
子工程中。
注意
judson-cli
是取judson-cli
子工程中package.json
中name
字段的值,而不是judson-cli
子工程文件夹的名称。
yargs的使用非常简单,其提供的 argv
属性是对两个格式的命令
参数的处理结果。
在 bin/index.js
添加如下代码:
#!/usr/bin/env node
const yargs = require('yargs');
console.log('name', yargs.argv.name);
可以通过 yargs.argv.name
获取命令参数 name
的值
注意,以上代码是在 Node.js 环境中运行,Node.js 的模块是遵循 CommonJS 规范的,如果要依赖一个模块,要使用 Node.js 内置 require 系统函数引用模块使用。
在 app 文件夹目录下运行 pnpm judson -- --name=LoginPage
注意,在 pnpm judson 后面需要加上两个连字符(--),这是为了告诉 pnpm 后面的参数是传递给命令 judson 本身的,而不是传递给 pnpm 的。
- 设置子命令
假如脚手架要对外提供多个功能,不能将所有的功能都集中在 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
文件中的代码,如下所示:
#!/usr/bin/env node
const yargs = require('yargs');
yargs.command(
['create', 'c'],
'新建一个模版',
function(yarg) {
return yarg.option('name', {
alias: 'n',
demand: true,
describe: '模版名称',
type: 'string'
})
},
function(argv) {
console.log('argv', argv)
}
).argv;
在 app 文件夹目录下分别运行 pnpm judson create -- --name=LoginPage
和 pnpm judson c -- --name=RegisterPage
命令,执行结果如下图所示:
在上面我们配置了子命令
create
的参数 name
的一些参数信息。那这些要怎么展示给用户看呢?其实只要我们输入子命令的参数有错误,就会在命令行窗口中显示这些参数信息。
在 app 文件夹目录下运行 pnpm judson c -- --abc
命令,执行结果如下图所示:
到此为止,我们最简单地实现了脚手架和用户之间的交互能力,但是如果自定义参数过多,那么命令行参数的交互方法对于用户来说是非常不友好的。所以我们还要实现一个用户交互模块,如何实现请看下一小节。
用户交互模块
比较好的用户交互方式是讯问式的交互,比如我们在运行 npm init
,通过询问式的交互完成 package.json
文件内容的填充。
这里推荐使用 inquirer 开源库来实现询问式的交互,运行以下命令安装 inquirer:
pnpm add inquirer@^8.0.0 --F judson-cli
为了使用 require 引入 inquirer ,要使用 8.0.0 版本的 inquirer
官方发布的 inquirer@9.x 是esm模式的。如果想了解更多原生ESM,点击这里
这里我们主要使用了 inquirer
开源库的三个方面的能力:
- 询问用户问题
- 获取并解析用户的输入
- 检测用户的答案是否合法
主要通过 inquirer.prompt()
来实现。prompt
函数接收一个数组,数组的每一项都是一个询问项,询问项有很多配置参数,下面是常用的配置项。
- type:提问的类型,常用的有
- 输入框:
input
- 确认:
confirm
- 单选组:
list
- 多选组:
checkbox
-
name
:存储当前问题答案的变量; -
message
:问题的描述; -
default
:默认值; -
choices
:列表选项,在某些type
下可用; -
validate
:对用户的答案进行校验; -
filter
:对用户的答案进行过滤处理,返回处理后的值。
- 输入框:
比如我们创建一个模板文件,大概会询问用户:模板文件名称、模板类型、使用什么框架开发、使用框架对应的哪个组件库开发等等。下面我们来实现这个功能。
在 bin 文件夹中新建 inquirer.js 文件夹,在里面添加如下代码:
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)
})
} else 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
。
#!/usr/bin/env node
const yargs = require('yargs');
const { inquirerPrompt } = require('./inquirer');
yargs.command(
['create', 'c'],
'新建一个模版',
function(yarg) {
return yarg.option('name', {
alias: 'n',
demand: true,
describe: '模版名称',
type: 'string'
})
},
function(argv) {
inquirerPrompt(argv)
.then(answers => {
console.log(answers)
})
}
).argv;
在 app
文件夹目录下运行 pnpm judson c -- --n Input
命令,执行结果如下图所示:
文件拷贝模块
要生成一个模板文件,最简单的做法就是执行脚手架提供的命令后,把脚手架中的模板文件,拷贝到对应的地方。模板文件可以是单个文件,也可以是一个文件夹。本小节先介绍一下模板文件是文件夹时候如何拷贝。
在 Node.js 中拷贝文件夹并不简单,需要用到递归,这里推荐使用开源库copy-dir
来实现拷贝文件。
运行以下命令安装 copy-dir
。
pnpm add copy-dir --F judson-cli
在 bin
文件夹中新建 copy.js
文件,在里面添加如下代码:
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
,在里面随便写些内容。其目录结构如下所示:
下面来实现把 packages/judson/bin/template/form
这个文件夹拷贝到 examples/app/src/pages/OrderPage
中 。
在 bin/index.js
修改代码,修改后的代码如下所示:
#!/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(yarg) {
return yarg.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
的难点是参数 from
和 to
的赋值。其中 from
表示要拷贝文件的路径,to
表示要把文件拷贝到那里的路径。
脚手架中的路径处理
我们可以用 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
文件所属目录的绝对路径 ~/Downloads/judson/packages/judson-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')
得到的绝对路径是 ~/Downloads/judson/packages/judson-cli/bin/template/form
。
将 copyDir
的参数 to
设置为 path.resolve(process.cwd(), '${name}')
,
其中 process.cwd()
当前 Node.js 进程执行时的文件所属目录的绝对路径。比如在 bin
文件夹目录下运行 node index.js
时,process.cwd()
得到的是 ~/Downloads/judson/packages/judson-cli/bin
。
运行 node index.js
相当运行 judson
命令。而在现代前端工程中都是在 package.json
文件中scripts 定义了脚本命令,如下所示:
{
"name": "app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"judson": "judson"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"judson-cli": "workspace:*"
}
}
运行 pnpm judson
就相当运行 judson
命令,那么执行 pnpm judson
时,当前 Node.js 进程执行时的文件是 package.json
文件。那么 process.cwd()
得到的是 ~/Downloads/judson/examples/app
。
因为要把 packages/judson/bin/template/form
这个文件夹拷贝到 examples/app/src/pages/OrderPage
中,且 process.cwd()
的值是~/Downloads/judson/examples/app
,src/pages
文件夹相对 examples/app
的路径是 ./src/pages
,所以把 path.resolve
的参数 to
设置为 ./src/pages/${name}
,其中 name
是用户所输入的模板名称。
目录守卫
在 app
文件夹目录下运行 pnpm judson create -- --name=OrderPage
,看能不能成功得把 packages/judson/bin/template/form
这个文件夹拷贝到 examples/app/src/pages/OrderPage
中。
报错了, 提示 examples/app/src/pages
文件夹不存在。为了防止这种报错出现,我们要实现一个目录守护的方法 mkdirGuard
,比如 examples/app/src/pages
文件夹不存在,就创建一个 examples/app/src/pages
文件夹。
在 bin/copy.js
文件中,修改代码,如下所示:
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(from, to, options) {
mkdirGuard(to)
copydir.sync(from, to, options)
}
function checkMkdirExists(path) {
return fs.existsSync(path)
}
exports.mkdirGuard = mkdirGuard
exports.checkMkdirExists = checkMkdirExists
exports.copyDir = copyDir
fs.mkdirSync
的语法格式:fs.mkdirSync(path[, options])
,创建文件夹目录。
-
path
:文件夹目录路径; -
options
:recursive
表示是否要创建父目录,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 judson create -- --name=OrderPage
,看这次能不能成功得把 packages/judson/bin/template/form
这个文件夹拷贝到 examples/app/src/pages/OrderPage
中。
成功添加,添加结果如下所示:
添加成功
然后再运行 pnpm judson create -- --name=OrderPage
命令,会发现控制台打印出模板已经存在在提示。
这是为了防止用户修改后的模板文件,运行命令后被重新覆盖到初始状态。所以我们引入一个校验模板文件是否存在的 checkMkdirExists
方法,内部采用 fs.existsSync
来实现。
文件拷贝模块
文件拷贝分三步来实现,使用 fs.readFileSync
读取被拷贝的文件内容,然后创建一个文件,再使用 fs.writeFileSync
写入文件内容。
在 bin/copy.js
文件,在里面添加如下代码:
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
修改代码,修改后的代码如下所示:
#!/usr/bin/env node
const yargs = require('yargs');
const path = require('path');
const { inquirerPrompt } = require('./inquirer');
const { copyDir, checkMkdirExists, copyFile } = require('./copy');
yargs.command(
['create', 'c'],
'新建一个模版',
function(yarg) {
return yarg.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}`)
// )
copyFile(
path.resolve(__dirname, `./template/${type}/index.js`),
path.resolve(process.cwd(), `./src/pages/${name}/index.js`)
)
}
})
}
).argv;
copyFile
和 copyDir
使用的区别在参数,copyFile
要求参数 from
和参数 to
都精确到文件路径。
在 app
文件夹目录下运行 pnpm judson create -- --name=PaymentPage
,执行结果如下图所示:
现在我们修改下模版文件内的内容, 模拟实际业务中的代码,修改bin/template/form/index.js
代码如下:
import React from 'react'
const App = () => {
return (
<div></div>
)
}
export default App
动态文件生成模块
假设脚手架中提供的模板文件中某些信息需要根据用户输入的命令参数来动态生成对应的模板文件。
比如下面模板文件中 App
要动态替换成用户输入的命令参数 name
的值,该如何实现呢?
import React from 'react'
const App = () => { // App 不应写死,应该为用户自定义的名称
return (
<div></div>
)
}
export default App
这里推荐使用开源库mustache
来实现,运行以下命令安装 mustache
。
pnpm add mustache --F judson-cli
我们在 packages/judson-cli/bin/template/form
文件夹中创建一个 index.tpl
文件,内容如下:
import React from 'react'
const {{name}} = () => {
return (
<div></div>
)
}
export default {{name}}
先写一个 readTemplate
方法来读取这个 index.tpl
动态模板文件内容。在 bin/copy.js
文件,在里面添加如下代码:
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
文件,在里面添加如下代码:
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
修改代码,修改后的代码如下所示:
#!/usr/bin/env node
const yargs = require('yargs');
const path = require('path');
const { inquirerPrompt } = require('./inquirer');
const { copyDir, checkMkdirExists, copyFile, copyTemplate } = require('./copy');
yargs.command(
['create', 'c'],
'新建一个模版',
function(yarg) {
return yarg.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}`)
// )
// copyFile(
// path.resolve(__dirname, `./template/${type}/index.js`),
// path.resolve(process.cwd(), `./src/pages/${name}/index.js`)
// )
copyTemplate(
path.resolve(__dirname, `./template/${type}/index.tpl`),
path.resolve(process.cwd(), `./src/pages/${name}/index.js`),
{
name
}
)
}
})
}
).argv;
在 app
文件夹目录下运行 pnpm judson create -- --name=MachinePage
,执行结果如下图所示:
mustache 简介
下面来额外介绍一些常用的使用场景。首先来熟悉一下 mustache 的语法
- {{key}}
- {{#key}} {{/key}}
- {{^key}} {{/key}}
- {{.}}
- {{&key}}
简单绑定
使用 {{key}}
语法,key
要和 Mustache.render
方法中的第二个参数(一个对象)的属性名一致。
例如:
Mustache.render('<span>{{name}}</span>',{name:'张三'})
输出:
<span>张三</span>
绑定子属性
例如:
Mustache.render('<span>{{ifno.name}}</span>', { ifno: { name: '张三' } })
输出:
<span>张三</span>
循环渲染
如果 key
属性值是一个数组,则可以使用 {{#key}} {{/key}}
语法来循环展示。 其中 {{#}}
标记表示从该标记以后的内容全部都要循环展示,{{/}}
标记表示循环结束。
例如:
Mustache.render(
'<span>{{#list}}{{name}}{{/list}}</span>',
{
list: [
{ name: '张三' },
{ name: '李四' },
{ name: '王五' },
]
}
)
输出:
<span>张三李四王五</span>
如果 list
的值是 ['张三','李四','王五']
,要把 {{name}}
替换成 {{.}}
才可以渲染。
Mustache.render(
'<span>{{#list}}{{.}}{{/list}}</span>',
{
list: ['张三','李四','王五']
}
)
循环中二次处理数据
Mustache.render
方法中的第二个参数是个对象,其属性值可以是一个函数,渲染时候会执行函数输出返回值,函数中可以用 this
获取第二个参数的上下文。
例如:
Mustache.render(
'<span>{{#list}}{{info}}{{/list}}</span>',
{
list: [
{ name: '张三' },
{ name: '李四' },
{ name: '王五' },
],
info() {
return this.name + ',';
}
}
)
输出:
<span>张三,李四,王五,</span>
条件渲染
使用 {{#key}} {{/key}}
语法 和 {{^key}} {{/key}}
语法来实现条件渲染,当 key
为 false、0、[]、{}、null
,既是 key == false
为真,{{#key}} {{/key}}
包裹的内容不渲染,{{^key}} {{/key}}
包裹的内容渲染
例如:
Mustache.render(
'<span>{{#show}}显示{{/show}}{{^show}}隐藏{{/show}}</span>',
{
show: false
}
)
输出:
<span>隐藏</span>
不转义 HTML 标签
使用 {{&key}}
语法来实现。
例如:
Mustache.render(
'<span>{{&key}}</span>',
{
key: '<span>标题</span>'
}
)
输出:
<span><span>标题</span></span>
自动安装依赖模块
我们现在新增动态表单模版,创建文件bin/template/dynamicForm/index.tpl
, 文件内容代码如下:
import React from 'react';
import { Button, Form, Input } from 'antd';
const {{name}} = () => {
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 {{name}};
可以看到模板中使用了 react
和 antd
这两个第三方依赖,假如使用模板的工程中没有安装这两个依赖,我们要实现在生成模板过程中就自动安装这两个依赖。
我们使用 Node 中 child_process 子进程这个模块来实现。
在 child_process 子进程中的最常用的语法是:
child_process.exec(command, options, callback)
-
command
:命令,比如pnpm install
-
options
:参数-
cwd
:设置命令运行环境的路径 -
env
:环境变量 -
timeout
:运行执行现在
-
-
callback
:运行命令结束回调,(error, stdout, stderr) =>{ }
,执行成功后error
为null
,执行失败后error
为 Error 实例,stdout
、stderr
为标准输出、标准错误,其格式默认是字符串。
新增文件 bin/manager.js
文件中,在里面添加如下代码:
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, studot, stderr) {
console.log('error', error)
console.log('stdout', studot)
console.log('stderr', stderr)
}
)
})
}
exports.install = install
在 install
方法中 exec
的参数 command
是 pnpm
安装依赖命令,安装多个依赖时使用 &&
拼接。参数 cwd
是所安装依赖工程的 package.json
文件路径,我们可以使用 process.cwd()
获取。已经在上文提到过,process.cwd()
是当前Node.js 进程执行时的文件所属目录的绝对路径。
接下来使用,在 bin/index.js
修改代码,修改后的代码如下所示:
#!/usr/bin/env node
const yargs = require('yargs');
const path = require('path');
const { inquirerPrompt } = require('./inquirer');
const { copyDir, checkMkdirExists, copyFile, copyTemplate } = require('./copy');
const { install } = require('./manager')
yargs.command(
['create', 'c'],
'新建一个模版',
function(yarg) {
return yarg.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}`)
// )
// copyFile(
// path.resolve(__dirname, `./template/${type}/index.js`),
// path.resolve(process.cwd(), `./src/pages/${name}/index.js`)
// )
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 judson create -- --name=AutoInstallPage
,看能不能自动安装依赖。
动态表单模版这里在选择模版类型的时候记得选动态表单
等命令执行完成后,观察 examples\app\package.json
文件中的 dependencies
值是不是添加了 antd
和 react
依赖。
此外,我们在执行命令中会发现命令窗口无输出信息,好像卡住了,其中是依赖在安装。这里我们要引入一个加载动画,来解决这个不友好的现象。
这里推荐使用开源库ora
来实现加载动画。
运行以下命令安装 ora
。
pnpm add ora@5.4.1 --F judson-cli
在 bin/manager.js
修改代码,修改后的代码如下所示:
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, studot, stderr) {
if (error) {
reject();
spinner.fail(`依赖安装失败`);
return;
}
spinner.succeed(`依赖安装成功`);
resolve()
}
)
})
}
exports.install = install
在 app 文件夹目录下运行 pnpm judson create -- --name=StorePage
,看一下执行效果。
发布和安装
在 packages/judson
文件夹目录下运行,运行以下命令安装将脚手架发布到 npm
上。
pnpm publish --F judson-cli
pnpm publish发布到npm
发布成功后。我们在一个任意工程中,执行 pnpm add judson-cli -D
安装 judson-cli
脚手架依赖成功后,在工程中执行 pnpm judson create -- --name=OrderPage
命令即可。
验证
全局安装
npm install judson-cli -g
然后运行命令,如下所示.
结语
上面只教大家实现一个最最简单的脚手架。其功能就只有一个模板文件生成。虽然简单,但是这些都是脚手架的入门功,代码已经上传到 GitHub,大家可以下载下来,自己实践一下,光看不练永远学不会。
学会了,可以总结一些平时的业务代码,形成最佳实践,使用脚手架作为载体展现出来,提升自己的职场竞争力。
网友评论