来吧!一起肝一个CLI工具

「这是我参与11月更文挑战的第3天,活动详情查看:2021最后一次更文挑战

前言

我们平时开发都是会直接安装诸如:create-react-app、vue-cli等命令行工具来初始化项目,我们来分析一下他们比较常用的功能:

  1. 通过用户输入的命令获取参数,根据参数创建项目
  2. 创建完项目后自动运行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对象创建命令,其中有三个主要的参数:

  1. command:命令的名称,后面跟着必传参数projectName
  2. description:描述
  3. action:当输入了create命令则会触发action的回调函数,将命令中的参数作为回调函数的第一个参数回传。

然后我们需要使用program.parse去解析命令中的参数,如果不使用的话,action被触发时是没有参数的。

到这一步,创建命令和获取参数我们都完成了。

选择模板

1637303328601.jpg

我们想要这种效果,那么需要安装第三方插件: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属性则是选项,里面有两个属性namevaluename值是展示给用户看的,当用户选择完后则会返回相应的value值。

到这里我们完成了通过命令行选择项目模板的功能。

创建项目

到这一步,我们已经拿到了命令行中提供的两个参数:

  1. 创建的项目名称
  2. 项目模板名称

关于拉取项目模板,通过笔者研究了一下,基本上有两种:

  1. 将项目模板上传到github上,然后通过第三方插件:download-git-repo下载拉取到本地。
  2. 将项目模板直接添加在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来判断文件夹是否存在。

复制文件到创建的项目中

这里我们分为这几步:

  1. 当创建的项目不存在时,我们需要先创建项目文件夹
  2. 获取模板项目与webpack配置项目的路径
  3. 将模板项目与webpack配置项目的文件copy到创建的项目下
  4. 合并模板项目与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
}
复制代码

这里我们主要分析两个点:

  1. 合并package.json文件:通过fs.readFileSync读取package.json文件,获取的是一个json字符串,然后使用JSON.parsejson字符串转换为js对象

  2. 复制文件:在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运行命令相当于开启了一个子进程,在这个子进程中运行的命令,所以在主进程中不会有日志打印出来,我们根本不知道安装依赖到底开始没有。

我们可以通过进程对象中的stdoutstderr两个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提示用户。

美化界面

  1. chalk插件:可以修改控制台文字颜色

1637309078598.jpg

  1. ora插件:显示loading

1637309011163.jpg

  1. figlet:打印文字

1637309226702.jpg

总结

  1. 通过commander插件创建命令,以及获取命令行中的参数
  2. 通过inquirer插件实现选择模板功能
  3. 将模板项目存放在cli项目内部,使用node的文件系统:fs进行项目的创建:
    1. 使用fs.existsSync判断项目是否存在
    2. 使用fs.mkdirSync创建文件夹
    3. 使用fs.readdirSync读取项目目录
    4. 使用fs.statSync(path).isFile()判断是否是文件
    5. 使用fs.copyFileSync复制文件,在node中只能复制文件,无法复制文件夹。
    6. 使用fs.readFileSync读取文件内容,返回字符串
    7. 使用fs.writeFileSync将内容写入文件中,内容必须是string类型
  4. 应为node提供的child_process中的spawn在windows中有兼容性问题,通过使用cross-spawn插件来实现运行命令。由于使用spawn运行命令相当于开启了子进程,在主进程中无法显示日志,通过stdoutstderr将正常的日志与错误日志输出到主进程中显示。

文章中只是展示了部分代码,主要是梳理整个cli实现的过程与思路,需要查看完成源码的同学请点击这里

おすすめ

転載: juejin.im/post/7032194362964443166