为什么使用脚手架
在前端开发中,一个项目的基础建设需要投入大量的时间和精力,并且还需要至少两套的环境配置:线上环境和开发环境。这些最佳实践都是需要日积月累的试错得出,对于开发者的工程水平有一定的要求。脚手架就是可以搭建好项目的基础架构,然后开发者就可以将精力集中在业务代码的开发中了,这样可以极大程度地提高前期的开发效率。也就是说,前端脚手架要做的事情就是搭建基础架构代码,通常包含了项目开发流程中所需的工作目录内的通用基础设施。然后开发同学只需要在脚手架上“添砖加瓦”就可以了。
脚手架的作用
- 方便快捷:我们只需要下载对应的脚手架依赖然后在命令行执行一行命令或者一些初始化信息,就可以将项目的框架搭建完成。
- 最佳实践:不管是使用官方提供的脚手架还是我们团队内部的脚手架,其中的配置和模板肯定都是经过反复的试错之后提炼出来的最佳实践。
- 统一规范:团队的内部使用相同的脚手架创建出来的项目,具有相同的组织结构、依赖模块、工具配置,有利于项目的维护和团队的合作。
脚手架的工作流程
在实现自己的脚手架之前,需要先了解下脚手架的工作流程。这里拿 vue-cli 为例,如下图:
- vue-cli 会先判断你的模板在远程 github 仓库上还是在你的本地某个文件里面,若是本地文件夹则会立即跳到第 3 步,反之则走第 2 步。
- 第 2 步会判断是否为官方模板,是官方模板则会从官方 github 仓库中下载模板到本地的默认仓库下。
- 第 3 步则读取模板目录下 meta.js 或者 meta.json 文件,根据里面的内容会询问开发者,根据开发者的回答,确定一些修改。
- 根据模板内容以及开发者的回答,渲染出项目结构并生成到指定目录。
为什么要自定义脚手架
虽然官方提供的默认脚手架模板已经代表了对应技术栈的通用最佳实践,但是在实际开发中,我们还是时常有定制化的需求,比如:
- 针对构建环节的 webpack 配置优化,来提升开发环境的效率和生产环境的性能;
- 定制团队内部规范的代码检测规则配置;
- 定制单元测试等辅助工具代码的配置项;
- 定制目录结构与通用业务模板,例如业务组件库、辅助工具类、页面模板等。
- ...
所以为了满足这些定制化的需求,团队就需要去搭建自己的脚手架。
如何开发自己的脚手架
一直很认同这么一句话:“脚手架的灵魂从来不在于脚手架的搭建方式,更不在于自动化,而是在于脚手架的模板”。所以这篇文章只是写了一下怎么去搭建一个简单的脚手架,及搭建的流程。目的是可以通过 cz-cli init projectName 自定义命令和命令行交互去初始化一个模板项目。如图:
- 准备模板文件
为了方便,我已经准备了一个简单的模板项目,目标:执行自定义命令,拉取模板替换 index.html 中的 title,根据用户自定的内容更新 package.json。项目初始化完成后安装依赖,运行项目。
- 创建脚手架项目
新建文件夹(如:demo-cli),进入该文件夹并执行 npm init 初始化项目。包括项目名称、版本、作者、依赖等相关信息。他会在当前目录下生成一个 package.json 文件。然后在 package.json 中添加 bin 字段,并指向需要执行的文件。
许多软件包都具有一个或多个要安装到 PATH 中的可执行文件。bin 字段是命令名到本地文件名的映射。在安装时,npm 会将文件符号链接到 prefix/bin 以进行全局安装或 ./node_modules/.bin/ 本地安装。当我们使用 npm 或者 yarn 命令安装包时,如果该包的 package.json 文件有 bin 字段,就会在 node_modules 文件夹下面的 .bin 目录中复制了 bin 字段链接的执行文件。我们在调用执行文件时,可以不带路径,直接使用命令名来执行相对应的执行文件。可参考官网文档。
执行 npm link (该命令可以将一个任意位置的 npm 包链接到全局执行环境,从而在任意位置使用命令行都可以直接运行该 npm 包)。
在当前目录新建 cli.js 文件 在首行添加 #!/usr/bin/env node(为了防止操作系统用户没有将node装在默认的 /usr/bin 路径里。当系统看到这一行的时候,首先会到 env 设置里查找 node 的安装路径,再调用对应路径下的解释器程序完成操作。)
安装一些好用的包:
npm install commander download-git-repo inquirer ejs ora chalk -S
在 cli.js 中引入
解析命令行参数
调用 program.version(chalk.green('version: 1.0.0'), '-v, --version') 会将 -v 和 –version 添加到命令行中,调用时可通过带上该参数获取该脚手架的版本号(命令 -v/–version),调用 comand('init ') 定义初始化命令,name 参数必传,作为项目的文件夹名,如 cz-cli init Name action 是执行 command 命令时发生的回调,参数为命令行中输入的 name,即 init 中的 name,项目生成过程便发生在回调函数中。其中:program.parse(process.argv)解析命令行中的参数,解析出 name,并传入 action 回调。
命令行交互
通过 inquirer 模块的 prompt 方法可以实现命令行与用户的交互,并且在回调函数中可以进行后续的处理。问题的类型为 input 就是输入类型(不填默认input),name 就是作为 answers 对象中的 key,message 就是值,用户输入的答案就在后面的回调返回的参数中。
后续的处理,只是涉及到 Node 中的一些文件处理,这里就不一一展示了,直接上完整代码
#!/usr/bin/env node
const inquirer = require('inquirer'); // 用于与命令行交互
const fs = require('fs')
const ejs = require('ejs'); // 用于解析 ejs 模板
const chalk = require("chalk") // 给终端的字体加上颜色。
const ora = require("ora") // 显示下载中的动画效果
const download = require("download-git-repo") // 用于下载项目模板
const program = require('commander') // 可以自动的解析命令和参数,用于处理用户输入的命令。
program.version(chalk.green('version: 1.0.0'), '-v, --version').
command('init <name>').
action((name)=>{
if(!fs.existsSync(name)) {
inquirer.prompt([
{
type: 'input',
name: 'description',
message: '请输入项目描述?'
},
{
type: 'input',
name: 'author',
message: '请输入作者名称?'
},
{
type: 'input',
name: 'version',
message: '请输入版本号?',
default: '1.0.0'
}
]).then(answers => {
console.log(answers)
const spinner = ora('正在初始化...\n');
spinner.start();
const description = answers.description;
const author = answers.author;
const version = answers.version;
// download(github用户名/仓库名)
download("daxiancheng/template-color", name, (err)=>{
if (err) {
spinner.fail('模板下载失败 (*>﹏<*)');
} else {
spinner.succeed('模板下载完成 ♫♫♫');
// 处理index.heml
const indexPath = name + '/public/index.html'
if (fs.existsSync(indexPath)) {
ejs.renderFile(indexPath, {
title: name
},(err, data)=>{
if (err) {
console.log(chalk.red('初始化index.html失败 (*>﹏<*)′\n'), err)
} else {
fs.writeFile(indexPath, data,(err)=>{
if(err) {
console.log(chalk.red('初始化index.html失败 (*>﹏<*)′\n'), err)
}
})
}
})
} else {
console.log(chalk.red('index.html不存在 (*>﹏<*)′\n'), err)
}
// 处理package.json
const pgPath = name + '/package.json'
if (fs.existsSync(pgPath)) {
const content = fs.readFileSync(pgPath).toString()
const data = JSON.parse(content)
data.description = description
data.author = author
data.version = version
fs.writeFile(pgPath, JSON.stringify(data, null, '\t'), 'utf-8',(err)=>{
if(err){
console.log(chalk.red('初始化package.json失败 (*>﹏<*)′\n'), err)
} else {
console.log(chalk.green(`初始化完成 ♫♫♫\n 请运行:\n cd ${name}\n npm install\n npm run serve`))
}
})
} else {
console.log(chalk.red('package.json不存在 (*>﹏<*)′\n'), err)
}
}
})
})
} else {
console.log(chalk.red(`此项目 ${name} 已存在`))
}
})
program.parse(process.argv);
这样一个简单的脚手架就搭建好了。