前言
随着前端开发越来越复杂,随之出现了大量的 js 框架,几乎每一家都配有配套的构建工具诸如 vue-cli、 create-react-ap、@angular/cli、@nestjs/cli等。这些脚手架可以快速帮助开发者初始化配置、目录结构搭建、项目构建。尽管这些脚手架都是相当优秀,几乎可以满足大部分的开发需求,但在特定的开发场景中可能需要根据业务需求做一定的调整,这就需要对脚手架内部的运行机制有一定的了解。
本文的脚手架已经发布到 npm, 同时也欢迎开发者提供优秀的模板。
t-cli 说明文件
通过这篇文章,能够有这些收获:
- 如何设计属于自己的脚手架工具
- 发布属于自己的 npm 包
- 大前端领域中脚手架的整体架构
CLI
什么是 cli
搜索引擎给出的解释如下:
为什么要使用 cli
- 减少重复性工作(webpack配置,目录、路由等配置信息)
- 规范团队代码风格、目录层级(统一 eslint 、git 配置)
- 统一插件、依赖版本、避免未知依赖错误
基本流程
目前主流的脚手架内部的实现原理可能会有所差别但最终实现的功能相差无几,主要功能包括:
项目搭建
- 与用户交互产生获取项目配置信息
- 下载 / 生成 项目模板
- 生产、开发环境依赖安装
开发环境
- 本地开发(热更新、接口代理等)
- eslint 代码风格检测、修复
项目构建
- build 项目
- 项目部署
- 依赖分析
这个步骤中的 构建、部署也可以采用 Webhook + Jenkins 实现自动化部署,通过给远程仓库配置一定的触发规则,当有用户 push 后, 会自动进行 build、部署。
前期准备
依赖插件准备
- babel:语法转换工具
- commander: 命令行工具,通过它可以读取命令行命令
- inquirer:用户-计算机命令行交互工具
- download-git-repo:git 文件夹下载
- chalk: 颜色插件,用来修改命令行输出样式,通过颜色区分 info、error 日志
- ora: 加载效果插件
- gulp 构建工具
依赖除上述以外还包括其他一些开发环境依赖,完整代参考t-cli-github
工程模板
脚手架可以快速生成项目结构和配置,最常用的方式就是我们提前准备好一套通用的规范的项目模板存放在指定位置,在脚手架执行创建项目命令的时候,直接将准备好的模板复制到目录下。存储模板的位置一般会选择 git 仓库,一是后期方便升级、维护‘二是打出来的 npm 包不至于太大。
npm发包
准备一个 npm 账号,如果没有请到官网注册。
- 项目初始化:创建目录执行
npm init
, 生成 package.json,其中 name 就是包名称,为防止自己喜欢的名称和已有的包名冲突可以采用 scope 方式发包,例如@canyuegongzi/t-cli
- 登录 :
npm login
- 发布:
npm publish
,如果包名是非 scope 可以直接采用这条指令发布,如果是 scope 方式需要添加参数,完整指令如下npm publish --access=public
至此,发布了一个属于自己的 npm 包,只不过内容空空的。
备注:发布包一定要在 npm 源下,taobao、cnpm环境都会报错
环境搭建
一:创建项目 t-cli 目录,执行 npm init
,生成 package.json,修改 name 为 @canyuegongzi/t-cli。
二:修改 修改 package.json 中的 bin 参数,指向 入口文件。
bin": {
"t": "src/cli/bin/t.js"
},
三:搭建 gulp 构建环境
关于为什么采用gulp ,而不是采用 webpack 或 rollupjs:构建工具可以随便,这里只需要 es6 转换和文件复制,可以自由选择。
const gulp = require('gulp');
const babel = require('gulp-babel');
gulp.task("babel", function () {
return gulp.src("./src/**/*.js")
.pipe(babel({
"presets": [
"@babel/preset-env"
],
"plugins": [
"@babel/plugin-proposal-object-rest-spread",
"@babel/plugin-transform-runtime"
]
}))
.pipe(gulp.dest("cli_dist"))
})
gulp.task("copy-config", function () {
return gulp.src(["./src/cli/config/*.json"])
.pipe(gulp.dest("./cli_dist/cli/config"))
})
gulp.task('default', gulp.series('babel', 'copy-config'));
修改 package.json scripts 脚本
"scripts": {
"build": "gulp default",
"test": "jest"
},
目录搭建
├── src
|——|—— cli
|——|—— |—— bin
|——|—— | ——|—— t.js // 系统入口文件
|——|—— |—— config
|——|—— | ——|—— category.json // 系统模板类型配置信息
|——|—— | ——|—— template.json // 系统模板模板信息
|——|—— |—— lib
|——|—— | ——|—— init.js // init 指令
|——|—— | ——|—— list.js // list 指令
|——|—— | ——|—— update.js // update 指令
|——|—— |—— utils
|——|—— | ——|—— download.js // 文件下载
|——|—— | ——|—— error.js
|——|—— | ——|—— log.js
├── test // 测试用例
|—— .npmignore // npm 包发忽略文件
|—— .babelrc // babel 配置
|—— .gulpfile.js // gulp 配置
|—— package.json // 开发配置
|—— jest.config.js // 测试配置
init 指令
入口文件申明命令行,入口文件必须以#!/usr/bin/env node
声明。
采用 commander 来设置不同的命令。command 方法设置命令的名字、description 方法是设置命令的描述、alias 方法设置命令简称、options 设置命令需要的参数。commander官网查看。
命令申明
当用户调用init <app-name>
命令创建工程模板时会调用 action 选项中的回调函数 create 函数。
#!/usr/bin/env node
const program = require('commander')
program
.version(`@canyuegongzi/t-cli ${require('../../../package').version}`)
.usage('<command> [options]')
// 申明 init 命令 并且声明两个参数 -c 和 -t,其中 action 中的回调函数就是用户调用 init 时需要执行的函数
program
.command('init <app-name>')
.description('初始化一个工程')
.option('-c, --category <category>', '工程类型,[web | server]')
.option('-t, --template <template>', '模板名称')
.action((name, options) => {
require('../lib/init').create(name, options).then(r => {})
})
选择模板类型(web OR server)
用户在调用 init 命令时,如果未传入 -c
参数的情况下需要用户选择模板类型。
在需要与用户交互时就需要之前提到的 inquirer 插件了,具体代码实现如下:
/**
* 选择工程类型
* @returns {Promise<void>}
*/
async function selectCategory() {
return new Promise(resolve => {
inquirer.prompt([
{ type: 'list', message: 'please select category:', name: category, choices: categoryList }
]).then((answers) => {
console.log(answers);
resolve(answers[category])
})
})
}
选择模板
用户在调用 init 命令时,如果未传入 -t
(未指定模板)参数的情况下需要用户选择模板。
在用户选择模板前需要根据模板类型对全部的模板过滤一遍,具体代码实现如下:
/**
* 选择工程模板名称
* @returns {Promise<void>}
*/
async function selectTemplate(projectCategory) {
try {
// 根据模板类型筛选模板
const list = templateList.filter(item => item.type === projectCategory).map((item) => item.name)
if (!list.length || !list) {
// 没有模板时给用户一个提示
return log('WARING', 'no template');
}
return new Promise(resolve => {
inquirer.prompt([
{ type: 'list', message: 'please select template:', name: template, choices: list }
]).then((answers) => {
resolve(answers[template])
})
})
}catch (e){
log('ERROR', e);
}
}
项目信息收集
每个项目都有 package.json, 在初始化时也需要用户手动输入,并通过 node 提供的文件系统修改文件信息,该案例中实现较为简单,只需要用户输入 name、version、description。
实现代码如下:
/**
* 用户自己输入一些配置信息
* @param name
* @returns {Promise<void>}
*/
async function getUserInputPackageMessage(name) {
return new Promise(async (resolve, reject) => {
if(isTest) {
return resolve({name, author: '', description: '', version: '1.0.0' })
}
try {
// 提示用户依次输入 name、version、description
const messageInfoList = await Promise.all([
inquirer.prompt([
{ type: 'input', message: "what's your name?", name: 'author', default: '' },
{ type: 'input', message: "please enter version?", name: 'version', default: '1.0.0' },
{ type: 'input', message: "please enter description.", name: 'description', default: '' },
])
]);
resolve({...messageInfoList[0], name});
}catch (e) {
resolve({name, author: '', description: '', version: '1.0.0' })
}
})
}
文件下载实现
这里也是采用的之前提到的 ownload-git-repo 插件进行模板下载。具体实现代码如下:
/**
* 下载文件到目录
* @param url
* @param name
* @param target
* @returns {Promise<void>}
*/
async function downloadFile(url, name, target = process.cwd()) {
return new Promise((resolve, reject) => {
const dir = path.join(target, name);
// 有这个目录名的话直接删除
rimraf.sync(dir, {});
const downLoadCallback = (err) => {
if (err) {
resolve({flag: false, dir, name});
log('ERROR', err);
}
// 下载成功后返回目录
resolve({flag: true, dir, name});
}
download(url, dir, {clone: true}, downLoadCallback);
})
}
init 项目
用户调用 init 命令时大致流程如下,先获取模板信息然后下载再修改文件信息。
init 流程代码实现如下:
/**
* 初始化工程模板
* @param pluginToAdd
* @param options
* @param context
* @returns {Promise<void>}
*/
async function init (pluginToAdd, options = {}, context = process.cwd()) {
let projectCategory = options[category]
let projectTemplate = options[template]
let projectName = pluginToAdd;
// 用户未传参 -c 时 需要用户选择模板类型
if (!options.hasOwnProperty(category)){
projectCategory = await selectCategory()
}
// 用户未传参 -t 时 需要用户选择模板
if (!options.hasOwnProperty(template)){
projectTemplate = await selectTemplate(projectCategory)
}
// 根据用户选择的模板类型和模板获取模板地址
const templateInfo = templateList.find((item) => item.type === projectCategory && item.name === projectTemplate);
if (!templateInfo) {
return log('WARING', 'no template');
}
const {url} = templateInfo;
// 获取用户输入的项目的工程信息
const packageInfo = await getUserInputPackageMessage(projectName);
// 开始一个下载中的图标提示
const downloadSpinner = ora({ text: 'start download template...', color: 'blue'}).start();
// 根据模板地址进行下载到当前模板
const {dir, name, flag} = await downloadFile(url[0], projectName, context)
if (flag) {
// 下载成功后结束下载图标
downloadSpinner.succeed('download success');
const editConfigSpinner = ora({ text: 'start edit config...', color: 'blue'}).start();
// 下载完成后修改配置信息
const successFlag = await downloadSuccess(dir, name, packageInfo);
if (successFlag) {
editConfigSpinner.succeed('create success');
}else {
editConfigSpinner.fail('create fail');
}
} else {
downloadSpinner.fail('download fail');
}
}
脚手架创建项目后需要根据之前收集的项目信息进行修改。
/**
* 模板下载成功
* @param dir
* @param name
* @param packageInfo
* @returns {Promise<void>}
*/
async function downloadSuccess(dir, name, packageInfo) {
return new Promise((resolve) => {
// 读 package.json
fs.readFile(dir + '/package.json', 'utf8', (err, data) => {
if (err) {
resolve(false);
}
const packageFile = {...JSON.parse(data), ...packageInfo}
// 修改配置信息并重新写入
fs.writeFile(dir + '/package.json', JSON.stringify(packageFile, null, 4), 'utf8', (err) => {
if (err) {
resolve(false);
}
resolve(true);
});
})
})
}
list指令
命令申明
当list
命令主要用户查询当前脚手架支持的工程模板,会调用 action 选项中的回调函数,该命令支持 -c参数,可选值包含 web 和 serve。
program
.command('list')
.description('列出项目模板')
.option('-c, --category <category>', '工程类型,[web | server]')
.option('-q, --query <query>', '查询字符串')
.action((options) => {
require('../lib/list')(options)
})
模板查询
/**
* 列出模板列表
* @param options
* @param context
* @returns {Promise<void>}
*/
async function list (options = {}, context = process.cwd()) {
let projectCategory = options[category];
let projectQuery = options[query];
let templateLogList = templateList;
if (projectCategory){
// 根据模板类型进行第一次筛选
templateLogList = templateList.filter(item => item.type === projectCategory);
}
if (projectQuery) {
// 根据模板名查询模板列表
templateLogList = templateLogList.filter(item => item.name.indexOf(projectQuery) > -1)
}
// 打印模板信息
for (let i = 0; i < templateLogList.length; i ++) {
const str = `${templateLogList[i].name}`;
log('TEXT', str );
}
if (!templateLogList.length) {
log('WARING', 'No matching template !!!');
}
// 打印完成后结束掉程序
process.exit(0);
}
update指令
命令申明
update
主要用于模板列表更新, 该命令可以在不用升级脚手架的情况下获取最新的模板。
program
.command('update')
.description('更新配置')
.option('-t, --type <type>', '更新类型,[config]')
.action((options) => {
require('../lib/update')(options)
})
获取最新的配置信息
/**
* 获取模板
* @param options
* @param context
* @returns {Promise<void>}
*/
async function getList(options = {}, context = process.cwd()) {
return new Promise(resolve => {
// 调用 http 服务
https.get(configUrl, (response) => {
let data = '';
response.on('data', (chunk) => {
data += chunk;
});
response.on('end', () => {
resolve(JSON.parse(data));
});
}).on("error", (error) => {
log('ERROR', error.message);
});
})
}
修改配置文件
这部分代码就是些简单的通过 node 进行文件操作,再不一一讲解,源码
最后
文章篇幅有限,不能对每一行代码进行讲解,感兴趣的同学可以克隆代码自己实现一遍。
本文通过以上内容完整的实现了一个配置性较高的脚手架,这个脚手架或许不适合每个开发环境,但通过文章可以梳理出脚手架的工作原理。有了一定的基础,后期可以慢慢扩展功能。
github.com/canyuegongz…
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!