「这是我参与11月更文挑战的第3天,活动详情查看:2021最后一次更文挑战」
前言
我们平时开发都是会直接安装诸如:create-react-app、vue-cli等命令行工具来初始化项目,我们来分析一下他们比较常用的功能:
- 通过用户输入的命令获取参数,根据参数创建项目
- 创建完项目后自动运行
npm install
正文
分析了基本的功能,那么现在我们也来实现一个自己的cli命令行工具。
初始化项目
首先创建一个文件夹fight-react-cli
,然后使用npm初始化:
npm init -y
复制代码
现在我们发现文件夹下多了一个package.json
文件。
然后我们在根目录再创建一个bin
文件夹,然后在bin
文件夹下创建一个www.js
文件。
我们想要node去运行www.js
文件,那么必须在文件内声明一下:
// bin/www.js
#! /usr/bin/env node
console.log('hello cli');
复制代码
在文件顶部添加#! /usr/bin/env node
。
然后我们还需要在package.js
中声明运行的命令及文件:
{
"name": "fight-react-cli",
"version": "1.0.3",
"description": "",
"main": "index.js",
...
"bin": {
"fight-react-cli": "./bin/www.js"
},
...
}
复制代码
添加bin
属性,当我们在命令行中使用fight-react-cli
命令时,node就会去运行bin目录
下的www.js
文件。
到这里基本架构搭好了。
使用npm link
但是我们想要fight-react-cli
命令在全局使用,那么还需要在项目根目录下运行:
npm link
复制代码
mac电脑如果没有成功,则需要授权:
sudo npm link
复制代码
这时我们打开终端,输入fight-react-cli
命令,发现运行成功,终端中打印了hello cli
。
创建命令
我们在项目的根目录下创建index.js
文件,然后在www.js
中引入index.js
。
www.js
#! /usr/bin/env node
require('../index');
复制代码
接下来,我们就将代码写在index.js
中。
我们需要创建命令,需要用到第三方包:commander
安装:
npm install commander
复制代码
使用:
const { program } = require('commander');
// 创建create命令,用于创建项目
program
.command('create [projectName]')
.description('create a projet')
.action:((projectName, options) => {
console.log(projectName);
});
// 解析命令行中的参数
program.parse(process.argv);
复制代码
首先使用commander
中的program
对象创建命令,其中有三个主要的参数:
command
:命令的名称,后面跟着必传参数projectName
description
:描述action
:当输入了create命令则会触发action的回调函数,将命令中的参数作为回调函数的第一个参数回传。
然后我们需要使用program.parse
去解析命令中的参数,如果不使用的话,action
被触发时是没有参数的。
到这一步,创建命令和获取参数我们都完成了。
选择模板
我们想要这种效果,那么需要安装第三方插件:inquirer
安装:
npm install inquirer
复制代码
使用:
const inquirer = require('inquirer');
const questions = [
{
type: 'list',
name: 'template',
message: '请选择项目模板:',
choices: [
{ name: 'javascript', value: 'fcc-template-javascript' },
{ name: 'typescript', value: 'fcc-template-typescript' },
],
}
];
const answers = await inquirer.prompt(questions);
console.log(answers); // {template: 'javascript' | 'typescript'}
复制代码
这里我们创建了一个选择项目模板的选项,提供了两个选择:js模板和ts模板。
choices
属性则是选项,里面有两个属性name
和value
,name
值是展示给用户看的,当用户选择完后则会返回相应的value
值。
到这里我们完成了通过命令行选择项目模板的功能。
创建项目
到这一步,我们已经拿到了命令行中提供的两个参数:
创建的项目名称
项目模板名称
关于拉取项目模板,通过笔者研究了一下,基本上有两种:
- 将项目模板上传到github上,然后通过第三方插件:
download-git-repo
下载拉取到本地。 - 将项目模板直接添加在cli项目内,在用户使用npm安装好cli工具后,其中就已经包含了所有的项目模板,只需要将项目模板文件拷贝到创建的项目下就行了。
这里fight-react-cli
使用了第二种方式。
在fight-react-cli
中将项目模板放在了packages
下:
- fight-react-cli
- packages
- fcc-template-javascript // js项目模板
- fcc-template-typescript // ts项目模板
- fcc-template-webpack // webpack配置
复制代码
在js项目模板与ts项目模板中是不包含webpack配置的,只会包含自身的特有的配置。
在创建项目的时候,会根据项目模板名称将指定项目的文件复制到创建的项目下,然后再将fcc-template-webpack
中的文件也复制到创建的项目下,然后通过读取指定的模板项目和webpack项目的package.json
,将两者的依赖包进行合并,并且修改package.json
中的项目名称为创建项目的名称,然后将修改后的package.json
的信息写入到创建的项目中的package.json
中。
判断创建的项目是否已存在
首先我们需要判断创建的项目是否存在,存在的话则需要提示用户并且退出进程:
const fs = require('fs');
if(!fs.existsSync(projectDir)) {
// 不存在,创建项目
}else {
console.log(`${projectName}项目已存在`);
process.exit(); // 退出进程
}
复制代码
我们这里使用了node
提供的fs
包,用于文件操作。
使用fs.existsSync
来判断文件夹是否存在。
复制文件到创建的项目中
这里我们分为这几步:
- 当创建的项目不存在时,我们需要先创建项目文件夹
- 获取模板项目与webpack配置项目的路径
- 将模板项目与webpack配置项目的文件copy到创建的项目下
- 合并模板项目与webpack配置项目的package.json,并修改项目名称, 然后将修改后的package.json信息写入到创建的项目中的
package.json
中
if(!fs.existsSync(projectDir)) {
// 1. 当创建的项目不存在时,我们需要先创建项目文件夹
fs.mkdirSync(projectDir);
// 2.获取模板项目与webpack配置项目的路径
const templateDir = `${packagePath}/${templateName}`; // 模板项目路径
const templateWebpackPackagePath = `${templateWebpackPath}/package.json`; // webpack配置项目的package.json路径
const templatePackagePath = `${templateDir}/package.json`; // 模板项目的package.json路径
// 3. 将模板项目与webpack配置项目的文件copy到创建的项目下
copyFiles(templateDir, projectDir); // 将项目模板文件复制到创建的项目中
copyFiles(templateWebpackPath, projectDir); // 将webpack配置文件复制到创建的项目中
// 4. 合并模板项目与webpack配置项目的package.json,并修改项目名称, 然后将修改后的package.json信息写入到创建的项目中的`package.json`中
createPackageJson(projectName, projectDir, templateName);
}else {
console.log(`${projectName}项目已存在`);
process.exit(); // 退出进程
}
function copyFiles(sourcePath, targetPath) {
let paths = fs.readdirSync(sourcePath);
paths.forEach((fileName) => {
const filePath = `${sourcePath}/${fileName}`;
const targetFilePath = `${targetPath}/${fileName}`
const stat = fs.statSync(filePath);
//判断是否是文件还是文件夹
if(stat.isFile()) {
fs.copyFileSync(filePath, targetFilePath);
} else {
// 需要在目标文件夹下面创建资源文件夹
mkDir(targetFilePath);
copyFiles(filePath, targetFilePath);
}
});
}
function mkDir(projectDir) {
if(!fs.existsSync(projectDir)) {
fs.mkdirSync(projectDir);
}
}
function createPackageJson(projectName, projectDir, templateName) {
const templateDir = `${packagePath}/${templateName}`; // 模板项目路径
const templateWebpackPackagePath = `${templateWebpackPath}/package.json`;
const templatePackagePath = `${templateDir}/package.json`;
const projectPackagePath = `${projectDir}/package.json`;
const packageInfoStr = fs.readFileSync(templatePackagePath, {encoding: 'utf8'});
const templateWebpackPackageInfoStr = fs.readFileSync(templateWebpackPackagePath, {encoding: 'utf8'});
const packageJsonInfo = JSON.parse(templateWebpackPackageInfoStr);
packageJsonInfo.dependencies = {
...packageJsonInfo.dependencies,
...JSON.parse(packageInfoStr).dependencies,
}; // 合并webpack模板与用户指定模板的package.json
packageJsonInfo.name = projectName; // 修改项目名称
fs.writeFileSync(projectPackagePath, JSON.stringify(packageJsonInfo, null, 2)); // 修改创建的项目中的package.json
}
复制代码
这里我们主要分析两个点:
-
合并package.json文件:通过
fs.readFileSync
读取package.json
文件,获取的是一个json
字符串,然后使用JSON.parse
将json字符串
转换为js对象
-
复制文件:在node中只能复制文件,不能复制文件夹,所以我们首先通过
fs.readdirSync
读取模板项目中的文件目录,返回一个文件目录的数组,对该数组进行遍历,然后使用fs.statSync
来判断是文件还是文件夹,如果是文件就使用fs.copyFileSync
直接复制,如果是文件夹,需要先在创建的项目中创建该文件夹,然后又需要进行上面的步骤:1.读取目录,2.进行遍历判断是否是文件。为了方便递归调用,将这部分代码抽取出来写成了一个单独的方法。
到这里创建项目就完成了。
自动安装依赖
我们需要创建完项目后自动安装依赖,可以使用在node中提供的child_process
包:
const {spawn} = require('child_process');
const childProcess = spawn('npm', ['install'], { cwd: `./${projectName}` });
复制代码
cwd
的作用是进入新创建的项目下运行命令,类似于:
cd projectName && npm install
复制代码
但是它在windows中有兼容性问题,所以我们使用第三方可以跨平台运行命令的包:cross-spawn
安装:
npm install cross-spawn
复制代码
使用方法与child_process.spawn
是一样的:
const spawn = require('cross-spawn');
const childProcess = spawn('npm', ['install'], { cwd: `./${projectName}` });
复制代码
这时也完成了自动安装依赖的功能,但是在运行命令的过程中没有安装日志打印,这时为啥?因为我们使用spawn
运行命令相当于开启了一个子进程,在这个子进程中运行的命令,所以在主进程中不会有日志打印出来,我们根本不知道安装依赖到底开始没有。
我们可以通过进程对象中的stdout
和stderr
两个api使子进程的日志在主进程中打印出来:
const spawn = require('cross-spawn');
const childProcess = spawn('npm', ['install'], { cwd: `./${projectName}` });
childProcess.stdout.pipe(process.stdout); // 传输正常日志
childProcess.stderr.pipe(process.stderr); // 传输错误日志
// 监听结束事件,当安装依赖完成,可以打印log提示用户
childProcess.on('close', code => {
if (code !== 0) {
return;
}
console.log(chalk.green('success'));
console.log(chalk.green(`启动项目运行: cd ${projectName} && yarn start`));
console.log(chalk.green(`打包项目: yarn run build 或者 npm run build`));
});
复制代码
在最后我们监听了子进程的关闭事件,当安装依赖完成,可以打印log提示用户。
美化界面
- chalk插件:可以修改控制台文字颜色
- ora插件:显示loading
- figlet:打印文字
总结
- 通过
commander
插件创建命令,以及获取命令行中的参数 - 通过
inquirer
插件实现选择模板功能 - 将模板项目存放在cli项目内部,使用node的文件系统:
fs
进行项目的创建:- 使用
fs.existsSync
判断项目是否存在 - 使用
fs.mkdirSync
创建文件夹 - 使用
fs.readdirSync
读取项目目录 - 使用
fs.statSync(path).isFile()
判断是否是文件 - 使用
fs.copyFileSync
复制文件,在node中只能复制文件,无法复制文件夹。 - 使用
fs.readFileSync
读取文件内容,返回字符串 - 使用
fs.writeFileSync
将内容写入文件中,内容必须是string
类型
- 使用
- 应为node提供的
child_process
中的spawn
在windows中有兼容性问题,通过使用cross-spawn
插件来实现运行命令。由于使用spawn
运行命令相当于开启了子进程,在主进程中无法显示日志,通过stdout
和stderr
将正常的日志与错误日志输出到主进程中显示。
文章中只是展示了部分代码,主要是梳理整个cli实现的过程与思路,需要查看完成源码的同学请点击这里。