在建筑领域,脚手架是为了保证各施工过程顺利进行而搭设的工作平台。在软件开发领域,如果把搭建项目想象成建造大型建筑的话,脚手架就是为了方便大家快速进入业务逻辑的开发,一个好的脚手架能显著提升工程效率,例如三大前端框架都提供了自己的脚手架工具:
Angular 中的 @angular/cli
Vue 中的 @vue/cli
React 中的 create-react-app
上述工具虽好,但相信很多公司为了满足自身业务需要,也造了不少自己的轮子,约定使用自己的那一套配置,如果没有脚手架,就只能把原项目代码复制过来,删除无用的逻辑,只保留基础能力,这个过程琐碎且耗时。
因此,在这种情况下,就需要定制自己的开发模板,搭建一套属于自己的前端脚手架了。
预备知识要写一个脚手架首先要掌握 node.js 的各种 API,然后还要充分利用别人写好的一些类库,例如下面就是必备的:
commander :TJ 大神的又一神作,脚手架必备工具,能够帮我们解析命令行的各种参数,通过回调完成具体逻辑实现。
inquirer:强大的交互式命令行工具,用户可以在命令行进行单选或多选,也可以用 prompts 这个库,用法和效果都是类似的。
chalk :能够在命令行中给文本上色,从而突出重点,例如 error 用红色,warning 用黄色,success 用绿色,视觉效果非常好。
metalsmith :静态网站生成器,可以读取指定文件夹下面的模板文件,经过一系列的插件处理,把文件输出到新的目录下。
掌握了上面的工具之后,就可以写一个自己的脚手架了。我们后端采用了 feathersjs 库,但是不太喜欢它提供的脚手架,于是自己定制了一个,效果如下:
制作脚手架制作脚手架整个过程分如下 5 个步骤(简称 cpcar):
cli 项目初始化
parse 命令行参数
clone 脚手架模板
ask 用户项目配置
render 项目文件
接下来逐一介绍:
cli 项目初始化
首先创建空目录并进行初始化:
$ mkdir feathers-cli
$ cd feathers-cli
$ npm init -y
复制代码
然后用 vscode 打开,为 package.json 添加 bin 字段如下:
{
"name": "feathers-cli",
"main": "index.js",
"bin": {
"feat": "./bin/feat.js"
}
}
复制代码
然后创建 bin 文件夹,在里面新建一个 feat.js 文件,内容是:
#! /usr/bin/env node
console.log('My custom feathers scaffold')
复制代码
然后在根目录下执行:
$ npm link
$ feat
My custom feathers scaffold
复制代码
到这里,项目初始化就完成了。此时,npm 会在全局下创建一个 feat 可执行文件,它是一个软链接,指向 bin/feat.js
,所以后面每次修改内容,都会输出最新的结果,不需要重新执行 npm link
命令。
parse 命令行参数
接下来需要利用 commander 来解析命令行参数,例如当用户输入 feat --help
的时候能够输出帮助提示,首先安装依赖包:
$ npm i commander
复制代码
然后修改 bin/feat.js
内容为:
#! /usr/bin/env node
const program = require('commander')
program.parse(process.argv)
复制代码
此时输入命令就能看到提示消息了:
$ feat --help
Usage: feat [options]
Options:
-h, --help display help for command
复制代码
这是 commander 默认帮我们添加的帮助信息,目前还没有配置任何的命令,接下来完善代码如下:
#! /usr/bin/env node
const program = require('commander')
const pkg = require('../package.json')
program
.command('create <app-name>')
.description('create a new project powered by feathers-cli')
.option('-f, --force', 'override')
.action((name, cmd) => {
console.log('name', name)
console.log('cmd.options', cmd.options)
console.log('cmd.args', cmd.args)
})
program.version(pkg.version).usage(`<command> [options]`)
program.parse(process.argv)
复制代码
此时输出内容就丰富多了:
$ feat --help
Usage: feat <command> [options]
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
create [options] <app-name> create a new project powered by feathers-cli
help [command] display help for command
复制代码
输入 feat create xxx
的时候可以在回调里面获取到相关参数:
name hello-world
cmd.options [
Option {
flags: '-f, --force',
required: false,
optional: false,
variadic: false,
mandatory: false,
short: '-f',
long: '--force',
negate: false,
description: 'override',
defaultValue: undefined
}
]
cmd.args [ 'hello-world' ]
复制代码
接下来就是完善回调函数里面的逻辑了。
clone 脚手架模板
我们根据业务需求自己定义了一套模板 feathers-template-default,用脚手架创建项目的本质上就是把这套模板下载下来,然后再根据用户的喜好,按照模板生成不同结构的工程文件而已。
一般来讲,模板都是放到用户根目录下的一个隐藏文件中的,我们定义的目录名为 ~/.feat-templates
,首次通过 feat create xxx
的时候通过 git clone
把这套模板下载到上面定义的目录中,后面再创建项目只需 git pull
更新即可,所以接下来就是实现仓库的下载和更新方法了,其实就是利用 spawn
对 git 命令进行封装:
git clone 的封装
// 克隆仓库
function clone(repo, opts) {
return new Promise((resolve, reject) => {
const args = ['clone']
args.push(repo)
args.push(opts.targetPath)
const proc = spawn('git', args, {cwd: opts.workdir})
proc.stdout.pipe(process.stdout)
proc.stderr.pipe(process.stderr)
proc.on('close', (status) => {
if (status == 0) return resolve()
reject(new Error(`'git clone' failed with status ${status}\n`))
})
})
}
复制代码
git pull 的封装
async function pull(cwd) {
return new Promise((resolve, reject) => {
const process = spawn('git', ['pull'], { cwd })
process.on('close', (status) => {
if (status == 0) return resolve()
reject(new Error(`'git pull' failed with status ${status}`))
})
})
}
复制代码
ask 用户项目配置
有了模板,项目主体结构就定下来了,接下来就是定义一些问题,让用户自己选择项目配置:
const questions = {
projectName: {
type: 'text',
message: '项目名',
validate: (answer) => (answer.trim() ? true : '项目名不能为空'),
initial: 'my-project',
},
projectDescription: {
type: 'text',
message: '项目描述',
initial: 'My Awesome Project!',
},
needCacher: {
type: 'toggle',
message: '需要缓存吗?',
initial: true,
active: '是',
inactive: '否',
},
cacher: {
type: 'select',
message: '请选择缓存方案',
choices: [
{ title: 'Memory', value: 'Memory' },
{ title: 'Redis', value: 'Redis' },
],
when(answers) {
return answers.needCacher
},
initial: 1,
},
needWebsocket: {
type: 'toggle',
message: '需要 websocket 吗?',
initial: false,
active: '是',
inactive: '否',
},
needLint: {
type: 'toggle',
message: '需要 ESLint 吗?',
initial: true,
active: '是',
inactive: '否',
},
needJest: {
type: 'toggle',
message: '需要 Jest 吗?',
initial: true,
active: '是',
inactive: '否',
},
}
复制代码
然后通过一个循环进行遍历,挨个询问:
async function ask(questions, data) {
const names = Object.keys(questions)
for (let i = 0; i < names.length; i++) {
const name = names[i]
const value = questions[name]
// 拿到问题,然后组装成 Inquirer 或 prompts 所需要的格式
const question = { /* 省略组装代码 */ }
const answer = await prompts(question)
Object.assign(data, answer)
}
}
复制代码
render 项目文件
模板引擎有很多,例如 ejs、handlebars 等都可以用,在这里以 handlebars 为例,先定义两个帮助函数:
Handlebars.registerHelper('if_eq', function (a, b, opts) {
return a === b
? opts.fn(this)
: opts.inverse(this)
})
Handlebars.registerHelper('unless_eq', function (a, b, opts) {
return a === b
? opts.inverse(this)
: opts.fn(this)
})
复制代码
然后通过 metalsmith 插件进行渲染:
Metalsmith(process.cwd())
.metadata({
projectName: '项目名称',
projectDescription: '项目描述',
// 这里的数据实际上是上一步 ask 获得的
})
.source('~/.feat-templates/feathers-template-default/templates/app') // 模板文件位置
.destination(process.cwd()) // 项目位置
.use(msPlugins.filterFiles(options.filters)) // 过滤文件
.use(msPlugins.renderTemplateFiles()) // 渲染模板
.build((err) => {
if (err) {
log(`Metalsmith build error: ${err}`)
}
})
复制代码