前言
笔者最近在倒腾项目上一些工程化的业务,之前基于create-react-app搭好了两个工程架子,一个面向的是pc web,一个是已经做了移动端适配面向mobile h5,所以存在两个项目挂在gitlab上面,如果新来了个业务需求,需要做pc或者mobile的业务,这个时候会打开终端,复制相应的gitlab地址 git clone xxxx一下,一个已经打造好的工程就拉取下来了,可以美滋滋的开发了。
这样的做法还是比较原始,面对一些场景难免clone好了项目还得修修改改,比如拉了个mobile的工程,有的业务需要引入wx sdk.js,有的又不需要,以此类推,如果把这类的引入需求在clone之前就做好了,那么我们才算真正可以美滋滋的开发。
那么如何能在clone之前就做好呢,这便是cli工具诞生的由来。先来看看笔者的cli工具是怎么工作的👇
实现
看着好像很高大上的样子,其实一个cli无非就是做到以下几件事。
咱们一步一步来。
获取命令参数+新建
// 根据输入,获取项目名称
let projectName = program.args[0];
// 返回 Node.js 进程的当前工作目录
let rootName = path.basename(process.cwd());
fs.mkdirSync(projectName); // 根据输入创造一个文件夹
配置模版
先新建一个文件保存模版的数据
// template.json
{
"cra": {
"name": "create react app模版1",
"value": "tmp1",
"git": "gitlab:xxxxxxxx",
"options": []
},
"mini": {
"name": "Taro小程序",
"value": "mini",
"git": "gitlab:xxxxxxxx",
"options": []
}
}
选择模版类型
这里用的是inquirer这个库实现的常见的交互式命令行用户接口
/**
* 模板选择
*/
function selectTemplate() {
return new Promise((resolve, reject) => {
let choices = Object.values(templateConfig).map(item => {
return {
name: item.name,
value: item.value
};
});
let config = {
// type: 'checkbox',
type: "list",
message: "请选择创建项目类型",
name: "select",
choices: [new inquirer.Separator("模板类型"), ...choices]
};
inquirer.prompt(config).then(data => {
let { select } = data;
let { value, git } = templateConfig[select];
resolve({
git,
// templateValue: value
});
});
});
}
// 选择模板 拿到了git地址
let { git } = await selectTemplate();
下载模版
这里的核心功能用的是download-git-repo这个库
function download (target, url) {
// 这里先把模版下载到download-temp
// 以备后续ejs合成使用
target = path.join('./download-temp');
return new Promise((resolve,reject) => {
download(`direct:${url}`,
target, { clone: true }, (err) => {
if (err) {
console.log(chalk.red("模板下载失败:("));
reject(err)
} else {
console.log(chalk.green("模板下载完毕:)"));
resolve(target)
}
})
})
}
templateName = await download(rootName, git);
获取本地配置
function getCustomizePrompt(target, fileName) {
return new Promise((resolve) => {
const filePath = path.join(process.cwd(), target, fileName)
if (fs.existsSync(filePath)) {
console.log('读取模板配置文件')
let file = require(filePath)
resolve(file)
} else {
console.log('该文件没有配置文件')
resolve([])
}
})
}
// 获取模版中自定义的配置项目
// cli提供基础的配置项目:项目名称/作者/描述
// 而业务自己需要的配置项则保存在模版项目中
let customizePrompt = await getCustomizePrompt(templateName, 'customize_prompt.js')
配置项合成
function render(projectRoot, templateName, customizePrompt) {
return new Promise(async (resolve, reject) => {
try {
let context = {
name: projectRoot, // 项目文件名
root: projectRoot, // 项目文件路径
downloadTemp: templateName // 模板位置
};
// 获取默认配置
const promptArr = configDefault.getDefaultPrompt(context);
// 添加模板自定义配置
promptArr.push(...customizePrompt);
let answer = await inquirer.prompt(promptArr); // 获取自定义配置
let generatorParam = {
metadata: {
...answer
},
src: context.downloadTemp,
dest: context.root
};
// 获取完配置后传入合成方法
await generator(generatorParam);
resolve();
} catch (err) {
reject(err);
}
});
}
ejs合成
这里的核心功能使用的metalsmith这里,负责遍历文件模块,并且把文件模块复制到我们的目标目录下,同时通过use我们可以操作转移的文件内容
const rm = require("rimraf").sync;
const Metalsmith = require("metalsmith");
const ejs = require("ejs");
const path = require("path");
const fs = require("fs");
function generator(config) {
// ejs配置
let { metadata, src, dest } = config;
if (!src) {
return Promise.reject(new Error(`无效的source:${src}`));
}
// 官方模板
return new Promise((resolve, reject) => {
// 声明metalsmith实例
const metalsmith = Metalsmith(process.cwd())
.metadata(metadata)
.clean(false)
.source(src)
.destination(dest);
// 提供需要忽略的文件模块名单
// 可以根据自定义的配置忽略不想被复制的文件
// 比如不需要用于微信环境的web,则可以忽略到模版中的wx.d.ts声明文件
const ignoreFile = path.resolve(process.cwd(), src, '.fileignore');
if (fs.existsSync(ignoreFile)) {
// 定义一个用于移除模板中被忽略文件的metalsmith插件
metalsmith.use((files, metalsmith, done) => {
const meta = metalsmith.metadata();
// 先对ignore文件进行渲染,然后按行切割ignore文件的内容,拿到被忽略清单
const ignores = ejs
.render(fs.readFileSync(ignoreFile).toString(), meta)
.split("\n")
.filter(item => !!item.length);
Object.keys(files).forEach(fileName => {
// 移除被忽略的文件
ignores.forEach(ignorePattern => {
if (fileName.includes(ignorePattern)) {
delete files[fileName];
}
});
});
done();
});
}
metalsmith
.use((files, metalsmith, done) => {
const meta = metalsmith.metadata();
// 编译模板
Object.keys(files).forEach(fileName => {
try {
const t = files[fileName].contents.toString();
if (/(<%.*%>)/g.test(t)) {
// 对文件的内容进行ejs合成
files[fileName].contents = new Buffer.from(ejs.render(t, meta));
}
} catch (err) {
console.log("fileName------------", fileName);
console.log("er -------------", err);
}
});
done();
})
.build(err => {
rm(src); // 都完成了之后可以把临时存放模版的download-temp删掉
err ? reject(err) : resolve();
});
});
};
await render(projectRoot, templateName, customizePrompt);
依赖安装
/**
* 模板渲染后执行
*/
function afterBuild(name) {
inquirer.prompt({
type: "confirm",
name: "install",
message: "是否需要安装依赖",
}).then(data => {
if (!data.install) {
return
}
const ls = spawn('yarn', [], {
cwd: path.resolve(process.cwd(), path.join(".", name))
});
ls.stdout.on('data', (data) => {
console.log(`${data}`);
});
ls.stderr.on('data', (data) => {
console.error(`${data}`);
});
ls.on('close', (code) => {
console.log(`安装完毕`);
});
});
}
// 构建结束
afterBuild(projectRoot);
配置命令
把以上所有的代码都放在index.js文件中,通过
node index.js
应该是跑的起来的(我们先假设可以跑起来吧^_^。这种做法不够方便 也不好维护。于是我们可以把整个项目当作一个npm包,可以随时随地更新项目发布版本。
npm init
package json中配置
"bin": {
"lemon": "bin/lemon",
"lemon-init": "bin/lemon-init"
},
这里的核心功能借助commander实现命令,此时我们把以上的index.js内容放到lemon-init中,至于lemon内容则借助commander帮我们触发init命令执行index脚本
// lemon
#!/usr/bin/env node
const program = require('commander')
console.log('version', require('../package').version)
program
.version(require('../package').version)
.usage('<command> [项目名称]')
.command('init', '创建新项目') // lemon init wxApp
.parse(process.argv)
此时工程目录如下
.
├── bin
│ ├── lemon
│ └── lemon-init
├── .gitignore
├── template.json
├── package.json
├── README.md
等我们需要使用的时候可以直接命令行初始化
lemon init wxApp
总结
cli工具其实做的内容相对还比较简单些,至于后续的工作就是根据不同的配置去编写不同的ejs模版代码。笔者一开始也摸不着头脑,但是翻看了allen-cli的源码,了解了大体的流程,进行了bug修复和功能删减改进。一个适合自己项目的脚手架工具就呼之欲出了。