3

当你使用vue-cli快速创建一个vue工程的时候,有没有想过如何实现vue-cli这样一个命令行工具呢?开发这样一个工具会涉及大量的知识点(如果你希望它有实际作用而不只是一个简单的demo),还是有需要一定基础的。网上太多的教程只讲了最终的结果,而忽略了如何找到这个结果的过程,因此本文希望在结合实际需求的基础上把思考的过程呈现出来,让大家看到这个从0到1的过程,看到其中的一些决策与取舍,这是本文希望做到的与其它文章不同的地方。

本文同步发布在我的个人博客:手把手教你写Node.js命令行程序

个人博客的评论系统加了邮件通知,有什么想和作者说的建议在个人博客留言哦

后续如有更新将仅在个人博客修改,恕此处不再变更。

ps:segmentfault的markdown似乎优点问题,导致内容不能正确解析,建议前往个人博客查看。

前言

我们默认你对以下概念有所了解:

  • npm
  • package.json
  • Node.js
  • git
  • Promise

笔者使用的开发环境如下:

  • Nodejs v12.12.0
  • npm 6.11.3
  • Chrome 80.0.3987.132
  • Mac OS 10.14.6

需求分析

团队负责的N个项目之间,虽然业务需求不同,但是总有一些诸如初始化打点目录标准代码检查规则打包规则等每个项目都会用到的东西,或者一些都需要依赖的npm包、一些沉淀的通用业务逻辑等(不是所有逻辑都适合抽出去作为单独的npm包存在)。我们可以创建一个项目模板,这个模板里包含了所有需要进行的初始化操作,当大家新建一个项目的时候,都去拷贝这个模板, 就可以降低初始化项目的成本,当你的初始化和积累的业务逻辑足够多的时候,这个收益会非常大(是的,就像vue-cli做的那样,只不过它的模板更通用)。

那我们为什么不只创建一个模板项目,然后大家拉取这个模板呢?首先是每个项目里有项目名称、项目作者等信息肯定是不同的,如果手动拉取模板代码就需要手动修改,凡是手动修改就有可能出错,而且也是一种重复劳动。当有多个模板时候,你很难记住每个模板在哪儿。所以我们通过一个命令行工具来拉取模板,并用用户输入的数据来替换其中一些变量,以实现快速初始化一个符合团队规范的项目的目标。

经过上面的讨论我们已经明确,需要一个cli工具,以及至少一个用来复制的模板工程。那么我们的cli和模板是放在一个项目里还是分开放在不同的项目里呢?

  • 放在一起

    如果放在一个项目里,好处是你永远维护这一个项目就可以,无论是修改模板还是cli工具,坏处就是cli工具与模板耦合在一起,如果无法自定义模板(这在大公司的不同部门之间经常遇到,即使大家技术栈相同,但是初始化需求都可能有差异)。而且一旦模板更新,用户就得更新整个cli工具。

  • 分开存放

    分开存放的好处是解耦cli工具与模板,给自定义模板提供了可能,而且用户安装cli工具后即使模板有更新,也不用再更新cli工具。缺点就是我们需要一种方式将二者联系起来,cli工具的开发难度会变大。

我们选择将模板和cli分开的架构,尽量实现一个可复用的脚手架工具。当然,学习完本教程你也可以根据自身需求来定制自己的工具。

技术实现

准备工作

创建模板工程

前面提到了我们选择将cli和templates分开,所以我们需要用一个单独的仓库或npm包来存放我们的templates。我这边已经创建了对应的github仓库及npm包,大家可以直接使用。注意,在实际工作中,模板里一定包含了很多业务相关的东西,这里我们只是用一个vue-cli生成的工程做示例。仓库地址及npm名如下:

[github地址]
https://github.com/lianlilin/...

[npm包名]
may-templates-demo

初始化一个npm包

找一个目录,创建一个名为may-cli的文件夹,然后在这个文件夹下执行npm init命令,按照提示输入各项信息,项目名称我们就叫它may-cli。这个命令是为了得到package.json文件,它是一切npm包的基础,你也可以自己手动生成这个文件。

### 创建可执行文件
首先我们在项目目录下创建`packages/bin/may-cli.js`文件,并在其中输入一行代码:

console.log('may-cli');

然后我们在package.json文件的首行添加用于`指定执行环境`的如下内容:

! /usr/bin/env node

最后我们修改package.json文件,在其中添加字段:

{

"bin": {
    "may": "./packages/bin/may-cli.js"
}

}

这个字段是干什么用的呢?我们看看package.json的官方文档是怎么描述的:

A lot of packages have one or more executable files that they’d like to install into the PATH. npm makes this pretty easy (in fact, it uses this feature to install the “npm” executable.)

To use this, supply a bin field in your package.json which is a map of command name to local file name. On install, npm will symlink that file into prefix/bin for global installs, or ./node_modules/.bin/ for local installs.

注意第一段括号内的话:就是利用这个feature安装npm可执行文件。添加这个字段的作用其实就是当你全局运行may命令时它就会去执行你指定的文件。

### 将npm包链接到全局

将来我们的cli工具发包后别人安装,就可以像`vue-cli`一样在任何目录下使用,那么我们在开发阶段怎么实现这个效果呢?

我们在项目目录下执行 `npm link` 命令,这个命令将在全局文件夹中创建一个符号链接,该链接链接到 npm link 执行命令的包,就好像你全局安装了这个包一样,注意,包名是上一步在package.json中配置的名称,而不是当前文件夹的名称。

现在我们在项目目录之外找一个目录,在终端内执行`may`命令,已经可以成功打印字符串 'may-cli' 了。
## 功能开发

### 解析命令行参数
以vue-cli初始化的命令为例:

vue create XXXXX

**vue** 字段告诉我们这是一个vue命令,create 告诉我们这是要创建一个名为 XXXXX 的项目,我们使用 `may init`来创建项目,当用户执行此命令时候,让用户回答一些问题并依据这些问题的答案来初始化项目,当然,你也可以在初始化的时候直接指定项目名称等信息(作为init命令的参数),那么解析用户在命令行输入的参数呢?我们可以通过`commander`模块实现这个功能。

我们先为项目添加`commander`的依赖:

npm install -s commander

并在 may-cli.js 中引入它:

const program = require('commander');

然后可以按照如下方式使用它

program

.version(version, '-v, --version')
.description('may-cli脚手架')
.usage('<command> [options]');

// 初始化命令s
program

.command('init')
.action(() => {
    console.log('执行了初始化命令');
});

program.parse(process.argv);

更详细的使用方法可以参考它的文档,这里简单解释一下这些API:
- .version()定义版本;.description()指定描述;.usage()指定使用方法
- 使用.command()方法添加命令名称,并可以指定命令的简写方式、必选或可选参数、命令描述等信息
- 使用.option()方法定义commander的选项options(暂时我们没有用到,但是当你的命令需要参数的时候就可以用到了)
- 使用.action()方法定义命令的回调,如果通过.option()方法指定了参数,可以在.action()的回调函数里拿到这些参数
- program.parse(process.argv)必须在最后被调用,解析命令行参数

如果.option()方法在program下面调用,它指定的参数对所有二级命令生效,在.command()下面调用,则只对该条命令生效。如果我们把.action()在program下面调用也是可以的,只是我们的命令就变成了 `may` 而不是 `may init` ,采用二级命令的模式方便以后扩展其它命令。
### 询问用户一些问题
当你通过cli工具执行初始化操作的时候,通常会在控制台询问你一些问题,例如作者信息、一些插件的选择、代码检查工具选择等。需要问的通常是基于我们的模板创建的每个项目彼此之间有可能不同的部分,你可以根据你的实际需求来询问用户一些问题,这里我们询问以下几个问题:
- 项目名称
- 项目描述
- 作者信息

那么如果通过在命令行提问并将用户输入的参数传递给后面的逻辑呢?我们使用 `inquirer` 模块实现这个需求,这个模块可以提问 多选、单选、输入字符串、输入数字、密码等多种类型的问题,并可以为问题指定默认答案。在用户回答完成后会返回一个Promise对象,在其then方法中可以获取到用户输入的所有回答。

我们先为项目添加`inquirer`的依赖:

npm install -s inquirer

并在 may-cli.js 中引入它:

const inquirer = require('inquirer');


然后在 may-cli.js 中定义一组问题:

let userQuestions = [

{
    type: 'input',
    name: 'projectName',
    message: 'Project name',
    default() {
        return 'may-demo';
    }
},
{
    type: 'input',
    name: 'projectDescription',
    message: 'Project description',
    default() {
        return 'A vue project';
    }
},
{
    type: 'input',
    name: 'projectAuthor',
    message: 'Author',
    default() {
        return 'Your Name <you@example.com>';
    }
}

];

最后我们在上一步.action()方法的回调里调用如下方法:

program
.command('init')
.action(() => {

inquirer.prompt(userQuestions)
.then(answers => {
    return pacote.extract(packageName, './mayCache', {
        cache: false
    }).then(() => {
        console.log(answers)
        return answers;
    });
})
现在我们在测试目录下执行 `may init` 命令,就会看到控制台依次提问上面我们定义的三个问题,当你回答完所有问题后会在控制台打印出答案,如下例:

XXXXXXXXXXX:test XXXXXX$ may
? Project name may-demo
? Project description A vue project
? Author Your Name <you@example.com>
{
projectName: 'may-demo',
projectDescription: 'A vue project',
projectAuthor: 'Your Name <you@example.com>'
}

### 下载模板工程
接下来我们要做的事情就是下载指定的模板npm包到本地,这一步我们使用 `pacote` 模块来完成这个功能。

我们先为项目添加`pacote`的依赖:

npm install -s pacote

并在 may-cli.js 中引入它:

const pacote = require('pacote');

我们可以按照如下方式使用它

program
.command('init')
.action(() => {

inquirer.prompt(userQuestions)
.then(answers => {
    return pacote.extract(packageName, './mayCache', {
        cache: false
    }).then(() => {
        return answers;
    });
})
.then(answers => {
    ncp(cacheDir, answers.projectName, err => {
        if (err) {
            console.log(err);
        } else {
            console.log('copy success');
        }
    });
});

});

其中`packageName`是我们定义的包名变量,目前它就是 may-templates-demo 。第二个参数 cacheDir 表示将npm包下载到当前路径下的`cacheDir`目录下,我让它等于 'mayCache'。第三个参数表示下载的一些配置,这里不再赘述。
### 复制模板
接下来我们将模板从缓存目录复制到刚才用户指定的项目名称文件夹下,这一步我们使用`ncp`模块:

program
.command('init')
.action(() => {

inquirer.prompt(userQuestions)
.then(answers => {
    return pacote.extract(packageName, './mayCache', {
        cache: false
    }).then(() => {
        return answers;
    });
})
.then(answers => {
    return new Promise((resolve, reject) => {
        ncp(cacheDir, answers.projectName, err => {
            if (err) {
                reject(err);
            } else {
                console.log('tes', answers);
                resolve(answers);
            }
        });
    });
});

});

ncp()方法的第一个参数是复制源,这里是我们刚才下载到缓存目录的模板,第二个参数是复制到哪儿去,这里用用户指定的项目名称作为目录,第三个参数就是回调函数。那么我们为什么不直接把模板下载到项目名称目录下去呢?目前我们的模板就是一个完整的项目,这样做是可以的,但是假设模板库里有不止一个项目模板,我们就需要下载到缓存目录里再选择用户指定的模板进行复制,所以这里选择下载到缓存目录里。
### 删除缓存
我们已经将模板复制到了项目目录里,那么原来的缓存目录就没有必要存在了,需要我们删除它。相信我们都使用过`rm -rf`来删除文件,我们可以通过 `shelljs` 模块来在Node.js中执行这个命令。

program
.command('init')
.action(() => {

inquirer.prompt(userQuestions)
.then(answers => {
    return pacote.extract(packageName, './mayCache', {
        cache: false
    }).then(() => {
        return answers;
    });
})
.then(answers => {
    return new Promise((resolve, reject) => {
        ncp(cacheDir, answers.projectName, err => {
            if (err) {
                reject(err);
            } else {
                shell.rm('-rf', cacheDir);
                resolve(answers);
            }
        });
    });
});

});

### 处理模板
这一步我们使用第一步用得到的问题的答案来处理复制好的模板。打开刚才执行init命令得到的代码,可以看到有以下几个地方需要处理:
- package.json 里的author字段
- package.json 里的name字段
- package.json 里的version字段
- READEME.md 里的 PROJECT_DESCRIPTION
- .npmrc.text文件重命名为.npmrc
- .gitignore.text文件重命名为.gitignore

上述只是一些简单的示例,其中 `.npmrc.text` 和 `.gitignore.text` 是因为npm发包的时候会忽略 `.npmrc` 和 `.gitignore`文件,所以我们通过命名为`.text`文件使得发包时候可以带着这两个文件,然后再重命名它们。我们可以看到这些处理大概分为三类:
- 写入package.json文件
- 重命名文件
- 变量替换

这部分功能主要涉及是Node.js的文件操作:

program
.command('init')
.action(() => {

inquirer.prompt(userQuestions)
.then(answers => {
    return pacote.extract(packageName, './mayCache', {
        cache: false
    }).then(() => {
        return answers;
    });
})
.then(answers => {
    return new Promise((resolve, reject) => {
        ncp(cacheDir, answers.projectName, err => {
            if (err) {
                reject(err);
            } else {
                shell.rm('-rf', cacheDir);
                resolve(answers);
            }
        });
    });
})
.then(answers => {
    // 修改package.json
    let packagejsonPath = path.resolve(process.cwd(), `./${answers.projectName}/package.json`);
    const packageJson = Object.assign(
        require(packagejsonPath),
        {
            name: answers.projectName,
            author: answers.projectAuthor,
            version: '0.0.1'
        }
    );
    fs.writeFileSync(packagejsonPath, JSON.stringify(packageJson, null, 4));
    // 重命名文件
    fs.renameSync(
        `${answers.projectName}/.npmrc.text`,
        `${answers.projectName}/.npmrc`
    );
    fs.renameSync(
        `${answers.projectName}/.gitignore.text`,
        `${answers.projectName}/.gitignore`
    );
    // 替换变量
    let readmePath = `./${answers.projectName}/README.md`;
    let data = fs.readFileSync(readmePath)
    .toString()
    .replace('PROJECT_DESCRIPTION', answers.projectDescription);
    fs.writeFileSync(readmePath, data);
});

});

### 提示用户
截止目前我们已经完成了这个`may-cli`的基本功能,但是有一些可以改进的地方,例如下载模板是个异步的过程,目前没有任何提示,如果我们能为用户显示一个loading的状态,并在下载完成的时候变成完成状态就会友好很多。目前我们只是把一些异常打印在了控制台,如果我们用红色表示err信息,就会显眼很多。完成这两项功能需要用到下面两个库:
- ora 显示loading等各种图标
- chalk 修改控制台中字符串的样式,包括颜色、加粗等等

首先安装这两个包:

npm install -s ora
npm install -s chalk

然后引入它们:

const chalk = require('chalk');
const ora = require('ora');
const spinner = ora();

可以在代码里这样使用:

// 初始化命令
program
.command('init')
.action(() => {

inquirer.prompt(userQuestions)
.then(answers => {
    spinner.start('Start download template');
    return pacote.extract(packageName, './mayCache', {
        cache: false
    }).then(() => {
        spinner.succeed('Download template succeed');
        return answers;
    });
})
.then(answers => {
    return new Promise((resolve, reject) => {
        spinner.start('Start copy template');
        ncp(cacheDir, answers.projectName, err => {
            if (err) {
                spinner.fail('Copy copy failed');
                reject(err);
            } else {
                shell.rm('-rf', cacheDir);
                spinner.succeed('Copy template succeed');
                resolve(answers);
            }
        });
    });
})
.then(answers => {
    // 修改package.json
    let packagejsonPath = path.resolve(process.cwd(), `./${answers.projectName}/package.json`);
    const packageJson = Object.assign(
        require(packagejsonPath),
        {
            name: answers.projectName,
            author: answers.projectAuthor,
            version: '0.0.1'
        }
    );
    fs.writeFileSync(packagejsonPath, JSON.stringify(packageJson, null, 4));
    // 重命名文件
    fs.renameSync(
        `${answers.projectName}/.npmrc.text`,
        `${answers.projectName}/.npmrc`
    );
    fs.renameSync(
        `${answers.projectName}/.gitignore.text`,
        `${answers.projectName}/.gitignore`
    );
    // 替换变量
    let readmePath = `./${answers.projectName}/README.md`;
    let data = fs.readFileSync(readmePath)
    .toString()
    .replace('PROJECT_DESCRIPTION', answers.projectDescription);
    fs.writeFileSync(readmePath, data);
    return answers;
})
.then(answers => {
    console.log(chalk.green('\nCreated an project'));
    console.log(
        `\n you can: ${chalk.green(
            `cd ${answers.projectName}`
        )} && ${chalk.green(
            'npm i \n'
        )}`
    );
})
.catch(err => {
    console.warn(chalk.red('\n [error]'));
    spinner.fail(chalk.red(err.toString()));
    console.log(err);
});

});

我们可以看到如下类似的效果:

![](./ora.png)

![](./err.png)

![](./done.png)

### 发布你的npm包
截止到这一步我们已经开发完了一个具备完整功能的npm包,可以尝试将它发布,然后通过 `npm install` 下载安装后使用。使用之前记得在刚才的开发目录下执行如下命令来删除全局链接.

npm unlink

具体的发包流程这里就不再赘述了,教程很多。
# 进阶
前面我们实现了一个简单的cli工具,这个工具已经能在一定程度上用于生产环境了(是的,就是这个简易的脚手架工具支持了我们团队10+项目的初始化工作)。那么它还在哪些方面可以做的更好呢?
- 项目初始化后如果模板添加了新功能,我们只能手动复制文件到初始化好的项目
- 我们永远下载的都是新模板,这其实是有点不合理的,就像npm install允许你安装不同版本的npm包一样,我们也应当允许用户使用指定版本的模板
- 目前模板库地址是写死在cli工具内的,如果我们想更换模板仓库地址就得升级cli工具
- 目前我们只能下载一个模板,实际业务中有可能需要从不同模板来初始化项目
- 目前的结构只支持一个远程模板库,无法处理有多个初始化模板的场景
- 目前只在最后的catch里打印错误,不知道错误来自哪一步
- ......

`may-cli` 和我们业务实际使用脚手架虽然不完全相同,但是也涵盖了核心功能。所以上面分析的问题也客观存在,事实上,也正是这些问题让我升级了我们团队的脚手架工具。如果你对后续的升级方案有兴趣,欢迎留言。

# 附录
## 参考文献
- [Node.js 命令行程序开发教程-阮一峰](https://www.ruanyifeng.com/blog/2015/05/command-line-with-node.html)
- [从 1 到完美,用 node 写一个命令行工具](https://segmentfault.com/a/1190000016555129)
- [用Node.js开发一个Command Line Interface (CLI)](https://zhuanlan.zhihu.com/p/38730825)
- 还有一些文献不记得了……
## 代码清单
- [may-cli](https://github.com/lianlilin/may-cli)
- [may-templates-demo](https://github.com/lianlilin/may-templates-demo.git)

LLLsf
30 声望0 粉丝

人活着总要有点念想