CLI 是 Command-Line Interface 缩写,是命令行交互的意思,使用过 vue-cli 或者 create-react-app 一定会对脚手架给日常开发带来的效率提升印象深刻,这也是 Node.js 的一个典型应用场景——为前端解决代码初始化、构建甚至发布功能

需求分析

一个简单的 react 前端应用目录大概是这样的 https://github.com/Samaritan89/react-project-demo

  1. .
  2. ├── src
  3. ├── app.less
  4. ├── app.tsx
  5. ├── index.html
  6. └── index.tsx
  7. ├── test
  8. └── app.test.js
  9. ├── .eslintrc.js
  10. ├── .gitignore
  11. ├── README.md
  12. ├── babel.config.js
  13. ├── jest.config.js
  14. ├── package.json
  15. ├── tsconfig.json
  16. └── webpack.config.js

除了目录结构本身还有大量的配置文件,每次从头开始非常麻烦,可以使用 Node.js 实现 CLI 工具,通过命令 sly 和用户做简单信息确认后直接生成对应的目录结构和内容,执行过程大概如下
image.png

命令是如何被执行的

在 Linux/Mac 最常用的命令就是 cd

  1. $ cd /home/admin

cd 首先是一个可被 Shell 执行的程序,通过 where 命令可以查看其存储位置

  1. $ where cd
  2. /usr/bin/cd

之所以可以省略路径直接使用 cd 命令,是因为操作系统会尝试在几个预置的目录中匹配可执行程序名称,预置目录的配置在环境变量 $PATH

  1. $ echo $PATH
  2. /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/go/bin

POSIX 多个 PATH 使用 : 分隔,Windows 使用 ; 分隔

npm 通过 package.json 的 bin 字段提供了自动注册环境变量的功能

  1. {
  2. ...
  3. "bin": {
  4. "sly": "src/index.js"
  5. }
  6. }

这样配置后,当用户全局安装 package 就会把 sly 命令添加到 /usr/local/bin 目录

  1. $ where sly
  2. /usr/local/bin/sly

npm init

理解了命令注册后就可以开始动手写命令行工具的实现代码了,首先创建目录后执行 npm init

  1. $ mkdir sly-cli
  2. $ cd sly-cli
  3. $ npm init

在 package.json 文件手工添加 bin 配置

  1. {
  2. ...
  3. "bin": {
  4. "sly": "src/index.js"
  5. }
  6. }

为了让 Shell 知道使用 node 解析执行 src/index.js 需要在文件顶部添加 Shebang

Shebang是一个由井号和叹号构成的字符序列#!,其出现在文本文件的第一行的前两个字符。 在文件中存在Shebang的情况下,类Unix操作系统的程序加载器会分析Shebang后的内容,将这些内容作为解释器指令,并调用该指令,并将载有Shebang的文件路径作为该解释器的参数

src/index.js

  1. #!/usr/bin/env node
  2. // node code

命令行交互

为了在命令行和用户形成问答交互需要两个工具

  1. commander Node.js 命令行工具书写框架,使用可以参考文档
  2. inquirer 在命令行提供和用户问答的交互 ```javascript const inquirer = require(‘inquirer’); const program = require(‘commander’);

const { version } = require(‘../package.json’);

const questions = [ { type: ‘input’, name: ‘name’, message: ‘项目名称’, }, { type: ‘input’, name: ‘version’, message: ‘版本’, default: ‘1.0.0’ }, { type: ‘input’, name: ‘description’, message: ‘项目描述’ }, { type: ‘input’, name: ‘gitUrl’, message: ‘git 地址’, }, { type: ‘input’, name: ‘author’, message: ‘作者’, } ]

program .version(version, ‘-v, —version’) .command(‘init’) .action(async () => { const questions = []; const answers = await inquirer.prompt(questions); // TODO: 更根据 anwsers 渲染模板,复制到本地 });

program.parse(process.argv);

  1. 为了防止用户输入过多信息比较繁琐,可以为每个问题通过 `default` 属性设置默认值
  2. ```javascript
  3. const path = require('path');
  4. const gitRemoteOriginUrl = require('git-remote-origin-url');
  5. const gitRepoInfo = require('git-repo-info');
  6. const defaultName = path.parse(process.cwd()).name;
  7. let author = '';
  8. let repoUrl = '';
  9. try {
  10. const gitInfo = gitRepoInfo();
  11. author = gitInfo.author;
  12. repoUrl = await gitRemoteOriginUrl();
  13. } catch (ex) {
  14. }

这样一个没有实际功能的脚手架就完成了,接下来完善具体动作

模板下载

文章开始展示的 react 项目代码中模块名称、版本号、描述、作者、git 地址等都是固定的,需要稍微修改为变量的方式,结合用户输入,生成正确内容
模板化后 package.json 部分内容

  1. {
  2. "name": "{{name}}",
  3. "version": "{{version}}",
  4. "description": "{{description}}",
  5. "main": "lib/index.js",
  6. "repository": {
  7. "type": "git",
  8. "url": "{{gitUrl}}"
  9. },
  10. "author": "{{author}}",
  11. "license": "ISC",
  12. "dependencies": {
  13. }
  14. }

模板完整代码:https://github.com/Samaritan89/react-project-demo/tree/template

模板使用了 Handlebars 语法,为了让用户可以实时获取最新内容,需要把模板文件存储在网络,每次执行命令的时候下载最新版本,可以借助 download-git-repo 实现

  1. const { promisify } = require('util');
  2. const downloadGitRepo = require('download-git-repo');
  3. const download = promisify(downloadGitRepo);
  4. async (){
  5. const downloadFolder = path.join(process.cwd(), '.tmp');
  6. await download(
  7. 'direct:https://github.com/Samaritan89/react-project-demo.git#template',
  8. downloadFolder,
  9. { clone: true }
  10. );
  11. }

url 地址 hash #template 是因为模板文件在 template 分支

生成本地文件

模板下载完成后使用 Handlerbas 渲染引擎传入用户交互数据即可渲染为最终文件内容,因为涉及多个文件 使用 vinyl-fs 方便处理
src/copy.js

  1. const chalk = require('chalk');
  2. const vfs = require('vinyl-fs');
  3. const through = require('through2');
  4. const Handlebars = require('handlebars');
  5. function tpl(data) {
  6. return through.obj(function (file, encoding, callback) {
  7. console.log(`复制文件 ${chalk.grey(file.path)}`);
  8. if (file.contents) {
  9. const content = file.contents.toString(encoding);
  10. const template = Handlebars.compile(content);
  11. file.contents = Buffer.from(template(data), encoding);
  12. }
  13. this.push(file);
  14. callback();
  15. });
  16. }
  17. function copy(source, dest, data) {
  18. const worker = vfs.src(source)
  19. .pipe(tpl(data))
  20. .pipe(vfs.dest(dest));
  21. return new Promise(resolve => {
  22. worker.on('finish', () => {
  23. resolve();
  24. });
  25. });
  26. }
  27. module.exports = copy;

复制模板功能也可以使用 mem-fs-editor 实现

  1. const memFs = require("mem-fs");
  2. const editor = require("mem-fs-editor");
  3. const store = memFs.create();
  4. const fs = editor.create(store);
  5. fs.copyTpl(from, to, context[, templateOptions [, copyOptions]]);

测试

把主要代码串联一下开发工具就完成了

  1. 和用户交互,收集名称、版本、描述、作者、git 地址等信息
  2. 下载模板
  3. 复制模板到本地

完整代码:https://github.com/Samaritan89/sly-cli

可以在项目根目录通过 npm link 的方式在本地验证

  1. $ npm link
  2. $ sly init

发布到 npm

测试没问题了就可以把工具发布到 npm 了

  1. npm 官网注册一个账户
  2. 在命令行通过 npm login 登录
  3. 在项目根目录执行 npm publish 发布

接下来就可以和使用 vue-cli 一样在全局安装后使用了

  1. $ npm i -g sly-cli # 这个包名被 demo 项目占用了,需要自己用一个新的名称
  2. $ sly init

package 更新

如果 package 有了代码的更新,需要修改版本号后重新发布到 npm,版本号由 x.y.z 三位组成,修改要遵守语义化版本号规则

  • x:break change
  • y:add feature
  • z:bug fix

npm 也提供了命令辅助升级

  1. $ npm version major # x 位 +1
  2. $ npm version minor # y 位 +1
  3. $ npm version patch # z 位 +1

为了防止包的发布造成问题,可以先发不 beta 版本,实际测试一段时间后再发布正式版本

  1. $ npm publish --tag=beta

这时候用户安装的还是原来的版本,内测用户可以通过指定 beta tag 的方式安装测试版本

  1. $ npm i -g sly-cli@beta

当测试完成后可以把内容更新到正式版本

  1. $ npm dist-tag add sly-cli@x.y.z latest

x.y.z 表示实际的版本号

使用 yeoman

个人工具开发使用 commander 就足够了,标准的命令行交互都可以轻松开发,如果是团队工程需要多种脚手架管理可以使用专门做脚手架 & 生态的工具 yeoman

  1. yeoman generator 文档
  2. yeoman 官方教程