原文地址:https://www.yuque.com/docs/share/c6dddfdf-5a18-4024-8604-5e619cb9845d
cli工具是什么?
在正文之前先大致描述下什么是cli工具,
cli工具英文名command-line interface,也就是命令行交互接口,比较典型的几个case例如,create-react-app,vue-cli,具体可以去百度一下,下面gif是小打卡目前用的一套自动化发布工具🔧
可以看到整个发布流程大致是以选择或默认项的形式实现,大致分析下面几步
- 选择打包形式 开发模式/debug模式/发布模式
- 设置版本号
- 填写发布信息
- 选择环境
- 是否提交版本commit
是不是非常无脑?是不是再也不用担心线上发错环境了?有了它就算不同项目间,就算一天发n次版本还需要担心什么呢?
当然除了简单的发布功能还,还可以做很多的事情,比如创建page/component模版等一些更多有趣的事情
为了节约版面就不贴图了,具体可以看下仓库 https://github.com/jinxuanzheng01/xdk-cli(目前该工具是从小打卡现有的cli库中抽离的部分功能)
明确痛点
也就是我为什么要做这么一个工具,其实最开始我只是为了解决一个问题,就是在整个发布流程中需要人工去改动/确认发布环境和版本信息,大致可以想象下把线下环境发布到线上的尴尬处境
后续发现从cli角度触发,很多东西都变得简单了,大致列了下:
- 环境变量切换(线上环境,线下环境)
- 创建启动模版,包括页面,组件
- 自动化发布
- …
准备工作
本文会以快速创建页面模版文件为例教你怎么快速撸一个属于自己的cli工具,
如果觉得自己做比较麻烦,可以clone下我的仓库自己改装下
需要了解的三方库
中间会用到一些第三方库
- commander, 一个解析命令行命令和参数工具
- inquirer,常用交互式命令行用户界面的集合
- chalk,美化你的终端输出样式
- fuzzy,字符串模糊匹配的插件,根据输入关键词进行模糊匹配
- json-format,json美化/格式化工具
其他的一些小知识:比如path模块,fs模块,大家可以去node官网自行查看:https://nodejs.org/api/
搭建开发环境
创建一个空文件夹,并且npm初始化, 并且创建一个index.js页面,这个index.js将作为你整个包的入口文件
npm init -y
安装上述的三方包,当然也可以后续按需安装,这样更能清楚每个包是做什么的
npm install @moyuyc/inquirer-autocomplete-prompt commander chalk commander fuzzy inquirer json-format --save
在package.json里添加bin字段, 将自定义的命令软连到全局环境,同时执行npm link创建链接,这里如果报错{code EACCES,errno:13,…},是因为权限不足,可以尝试sudo npm link
"bin": {
"cli-demo": "./index.js"
}
在入口文件,index.js 行首加入一行#!/usr/bin/env node
指定当前脚本由node.js进行解析
#!/usr/bin/env node
// 输出文本
console.log('Hello World!!!');
这时可以在命令行中执行cli-demo
验收一下成果了
ok,可以看到当在全局状态下输入自定义命令时,正确运行了入口文件,也就意味着的开发玩具已经搭建完成
Let‘ Go
整理逻辑
以快速创建页面模版文件为例,就需要考虑需要哪些逻辑:
- 设置页面名称
- 找到已有模版文件
- copy到项目中
- 修改app.json
识别命令行
在刚才的Hello World!!!
环节,已经可以正确识别cli-demo,但是需要在一个cli工具中集成更多功能,可能需要有不同的执行策略,以git为例:git clone, git status,git push
,所以需要识别不同的命令和参数,
是时候就需要用到commander
这个第三方包帮助解析命令行参数了,当然你也可以自己撸一个lib,本质上还是方便解析process.argv
index.js (本质上这个js就是一个路由)
#!/usr/bin/env node
const version = require('./package').version; // 版本号
/* = package import
-------------------------------------------------------------- */
const program = require('commander'); // 命令行解析
/* = task events
-------------------------------------------------------------- */
const createProgramFs = require('./lib/create-program-fs'); // 创建项目文件
/* = config
-------------------------------------------------------------- */
// 设置版本号
program.version(version, '-v, --version');
/* = deal receive command
-------------------------------------------------------------- */
program
.command('create')
.description('创建页面或组件')
.action((cmd, options) => createProgramFs(cmd));
/* 后续可以根据不同的命令进行不同的处理,可以简单的理解为路由 */
// program
// .command('build [cli]')
// .description('执行打包构建')
// .action((cmd, env) => callback);
/* = main entrance
-------------------------------------------------------------- */
program.parse(process.argv)
这时候当键入cli-demo create
时会自动执行createProgramFs
createProgramFs.js
module.exports = function () {
console.log('Hi, create-program-fs.js');
};
命令行输入 cli-demo create
可以看到已经成功的开辟出了一块独立的业务模块,后续就只需要依据需求填补相应的内容即可
创建交互命令
收到执行命令,这个时候按第一张图,是需要开始一系列QA(当然你也可以不做交互式,直接配置命令行参数),
引入三方包 inquirer
,来指定问题队列
const question = [
// 选择模式使用 page -> 创建页面 | component -> 创建组件
{
type: 'list',
name: 'mode',
message: '选择想要创建的模版',
choices: [
'page',
'component',
]
},
// 设置名称
{
type: 'input',
name: 'name',
message: answer => `设置 ${answer.mode} 名称 (e.g: index):`,
},
];
module.exports = function() {
// 问题执行
inquirer.prompt(question).then(answers => {
console.log(answers);
});
};
、
可以看到通过一系列QA交互,实际输出拿到的是一个json对象,第一步已完成
创建模版文件
创建一个存放模版文件的文件夹template,并准备好你希望的模版
项目中创建模版文件
为了方便阅读,下面的代码,需要明确下面变量的定义, Config.dir_root = 命令行执行目录 Config.root = cli项目根目录 Config.appRoot = 小程序项目路径 Config.template = 模版目录
这里有两个点,一个是执行路径的问题,另一个是分包的问题,具体如下:
执行路径
这里一定要弄明白__dirname, process.cwd()的区别,同时还有一些小程序是自己搭的gulp/webpack,可能小程序项目是在src目录下,一定要分清楚
dirname: 被执行js文件的绝对路径,一般在index.js执行时缓存起来作为项目的全局路径,比如找到template文件夹就会使用 `${dirname}/template`
process.cwd():当前命令行运行时的工作目录,比如在/Users/xuan/Documents/cli-demo
如果当前项目在src,或其他文件夹里怎么办?可以提供一个给用户项目中的配置文件,类似于gulpfile.js或是webpack.config.js的形式,内容例如(具体可以看git仓库)
module.exports = {
// 小程序路径
app: './src',
// 模版文件夹
template: './template'
};
可以看到对象中app属性,可以指定你当前小程序项目的路径
分包
因为小程序的分包机制会导致页面实际路径与在主包的路径不相符,例如:
- 主包:pages/index/index
- 分包:pages/main_module/pages/habit_enlist/habit_enlist
解决这个问题一方面是要有页面创建要有一定的规范,统一格式,另一方面需要根据规则解析app.json,
上面的主包,分包路径差不多是我目前使用的规范
解析app.json
// 获取app.json
function getAppJson() {
let appJsonRoot = path.join(Config.appRoot, '/app.json');
try {
return require(appJsonRoot);
}catch (e) {
Log.error(`未找到app.json, 请检查当前文件目录是否正确,path: ${appJsonRoot}`);
process.exit(1); // 异常退出
}
}
// 解析app.json
let parseAppJson = () => {
// app Json 原文件
let appJson = __Data__.appJson = getAppJson();
// 获取主包页面
appJson.pages.forEach(path => __Data__.appPagesList[getPathSubSting(path)] = '');
// 获取分包,页面列表
appJson.subPackages.forEach(item => {
__Data__.appModuleList[getPathSubSting(item.root)] = item.root;
item.pages.forEach(path => __Data__.appPagesList[getPathSubSting(path)] = item.root);
});
};
// __Data__.appPagesList = 小程序全部页面
// __Data__.appModuleList = 小程序全部分包页面
// item结构 {util_module: 'pages/util_module/'},这么定义结构是为了方便后续取数
question队列里,增加删选分包的选项
// 设置page所属module
{
type: 'autocomplete',
name: 'modulePath',
message: 'Set page ownership module',
choices: [],
suggestOnly: false,
source(answers, input) {
// none 代表放在主包
return Promise.resolve(fuzzy.filter(input, ['none', ...Object.keys(__Data__.appModuleList)]).map(el => el.original));
},
filter(input) {
if (input === 'none') {
return '';
}
return __Data__.appModuleList[input];
},
when(answer) {
return answer.mode === 'page';
}
}
autocomplete类型本质上是个列表,但是可以进行模糊查询,非常方便,像小打卡有接近30个分包的情况下效果尤为明显
有了文件名,有了分包路径,有了可供copy的模版,接下来就很简单了,把模版文件塞进项目就可以了,下面是一串从仓库里copy的代码,利用async/await很方便的写出一维代码,基本上的流程:
获取路径 -> 校验 -> 获取文件信息 -> 复制文件 -> 修改app.json -> 输出结果信息
async function createPage(name, modulePath = '') {
// 获取模版文件路径
let templateRoot = path.join(Config.template, '/page');
if (!Util.checkFileIsExists(templateRoot)) {
Log.error(`未找到模版文件, 请检查当前文件目录是否正确,path: ${templateRoot}`);
return;
}
// 获取业务文件夹路径
let page_root = path.join(Config.appRoot, modulePath, '/pages', name);
// 查看文件夹是否存在
let isExists = await Util.checkFileIsExists(page_root);
if (isExists) {
Log.error(`当前页面已存在,请重新确认, path: ` + page_root);
return;
}
// 创建文件夹
await Util.createDir(page_root);
// 获取文件列表
let files = await Util.readDir(templateRoot);
// 复制文件
await Util.copyFilesArr(templateRoot, `${page_root}/${name}`, files);
// 填充app.json
await writePageAppJson(name, modulePath);
// 成功提示
Log.success(`createPage success, path: ` + page_root);
}
扩展
一个基本的快速创建页面模版的cli工具就这样完成,但是有可能需要更多的一些功能
自定义模版
比如说每个项目的模版都有可能不太一样,很大程度上需要根据项目进行定制,这时候可能就需要前文提到的给用户开放config文件的插槽了
项目中的config:
// xdk.config.js
module.exports = {
// 小程序路径
app: './',
// 模版文件夹
template: './template'
};
// create-program-fs.js
module.exports = function() {
// 校验:当前是否存在配置文件
let customConfPath = `${Config.dir_root}/xdk.config.js`;
if (!Util.checkFileIsExists(customConfPath)) {
Log.error('当前项目尚未创建xdk.config.js文件');
return;
}
// 获取用户配置项
let {app, template = ''} = require(customConfPath);
// 小程序目录
Config.appRoot = path.resolve(path.join(Config.dir_root, app));
// 模版文件目录(默认使用cli提供的默认模版,当config文件有设置template路径时,使用自定义路径)
!!template && (Config.template = path.resolve(path.join(Config.dir_root, template))));
// 问题执行
inquirer.prompt(question).then(answers => {
console.log(answers);
});
};
发布的npm仓库
目前从开发到调试本质上是在本地提供服务,利用npm link提供软连接到全局PATH,
其实也可以直接发到npm上,让其他使用的该cli的成员一建安装,比如npm install -g xxxxxxx
教程的话百度,google有很多,作者表示很懒,遇到问题下面留言吧。。
最后
可以看到整个功能逻辑相对于平时写的复杂的业务逻辑来说相对简单,主要是工具库的一些使用方面的东西,中间的难点可能就是node中概念性的一些东西,然而这些多看一下文档基本就可以解决,希望大家可以从本文中了解到如何快速搭建一个属于自己的cli工具
顺便预告下后续的话可能会更新一些如何利用cli工具做到自动化发布,版本号控制,环境变量切换,自动生成文档等一系列有趣的功能