阅读 1288

手把手带你撸一个cli工具

你有没有遇到过在没有vue-cli、create-react-app这样子的脚手架的时候一个文件一个文件的去拷贝老项目的配置文件。最近,笔者就在为组里的框架去做一套基本的cli工具。通过这边文章,笔者希望大家都能简单的去实现一个属于自己的脚手架工具。

项目地址:github.com/LNoe-lzy/my…

做好准备工作

首先,我们需要去新建一个项目并初始化package.json

mkdir my-cli && cd my-cli
npm init
复制代码

然后我们需要在项目中新建bin文件夹,并在package.json中提供一个bin字段并指向我们的bin文件夹下,这样通过npm我们就可以实现指令的软链了。

"bin": {
  "mycli": "bin/mycli"
},
复制代码

在mycli中,我们要在头部增加这样一句注释,作用是"指定由哪个解释器来执行脚本"

#!/usr/bin/env node

console.log('hello world');
复制代码

接下来,全局安装我们这个包,这样我们就可以直接在本地使用mycli这个指令了。

sudo npm install -g
复制代码

提供基本模版

既然我们要去做一个初始化项目的cli,那么项目模版就必不可少了,笔者在这里提前准备了一个demo的项目目录模版,这里就不展开赘述了。

demo项目模版

编写核心逻辑

其实核心逻辑很简单,就是通过控制台获取到用户的一些自定义选项,然后根据选项去从本地或者远程仓库拿到我们提前准备好的模版,将配置写入模版并最后拷贝模版到本地就行了。

我们在src下新增creator.js文件,这个文件导出一个Creator类。在这个类中现在仅需要三个简单的方法:init用于初始化、ask用于和命令行交互获取用户选择输入的数据、write用于调用模版的构建方法去执行拷贝文件写数据的任务。

class Creator {
  constructor() {
    // 存储命令行获取的数据,作为demo这里只要这两个;
    this.options = {
      name: '',
      description: '',
    };
  }
  // 初始化;
  init() {}
  // 和命令行交互;
  ask() {}
  // 拷贝&写数据;
  write() {}
}

module.exports = Creator;
复制代码

先去完善init方法,这个方法里我们仅需要调用ask方法和命令行交互并做一些提示即可(可以通过chalk这个库去丰富我们的命令行交互)

// ...
init() {
  console.log(chalk.green('my cli 开始'));
  console.log();
  this.ask();
}
// ...
复制代码

接下来是ask方法,在这个方法中,我们需要根据提示引导用户输入问题并获取用户的输入,这里用到inquirer这个库来和命令行交互。

// ...
ask() {
  // 问题
  const prompt = [];

  prompt.push({
    type: 'input',
    name: 'name',
    message: '请输入项目名称',
    validate(input) {
      if (!input) {
        return '请输入项目名称!';
      }

      if (fs.existsSync(input)) {
        return '项目名已重复!'
      }

      return true;
    }
  });

  prompt.push({
    type: 'input',
    name: 'description',
    message: '请输入项目描述',
  });

  // 返回promise
  return inquirer.prompt(prompt);
}
// ...
复制代码

修改刚才的init方法,将ask方法改为Promise调用。

init() {
  console.log(chalk.green('my cli 开始'));
  console.log();
  this.ask().then((answers) => {
    this.options = Object.assign({}, this.options, answers);
    console.log(this.options);
  });
}
复制代码

现在我们去命令行试一下,修改bin/mycli文件,然后去运行mycli命令。

#!/usr/bin/env node

const Creator = require('../src/creator.js');

const project = new Creator();

project.init();
复制代码

执行结果

在和用户交互完毕并获取到数据后,我们要做的就是去调用write方法执行拷贝构建了。考虑到日后可能增加很多的模版目录,不妨我们将每一类的模版拷贝构建工作放到模版中的脚本去做,从而增大可扩展性,新增template/index.js文件。

接下来首先根据项目目录结构创建文件夹(注意区分项目的执行目录和项目目录的关系)。

module.exports = function(creator, options, callback) {
  const { name, description } = options;

  // 获取当前命令的执行目录,注意和项目目录区分
  const cwd = process.cwd();
  
  const projectPath = path.join(cwd, name);
  const buildPath = path.join(projectPath, 'build');
  const pagePath = path.join(projectPath, 'page');
  const srcPath = path.join(projectPath, 'src');

  // 新建项目目录
  // 同步创建目录,以免文件目录不对齐
  fs.mkdirSync(projectPath);
  fs.mkdirSync(buildPath);
  fs.mkdirSync(pagePath);
  fs.mkdirSync(srcPath);

  callback();
}
复制代码

然后回到creator.js文件,在Creator中的write调用这个方法。

// ...
init() {
  console.log(chalk.green('my cli 开始'));
  console.log();
  this.ask().then((answers) => {
    this.options = Object.assign({}, this.options, answers);

    this.write();
  });
}

// ...

write() {
  console.log(chalk.green('my cli 构建开始'));
  const tplBuilder = require('../template/index.js');
  tplBuilder(this, this.options, () => {
    console.log(chalk.green('my cli 构建完成'));
    console.log();
    console.log(chalk.grey(`开始项目:  cd ${this.options.name } && npm install`));
  });
}
// ...
复制代码

在开启文件拷贝写数据之前,我们需要用到两个库mem-fsmem-fs-editor,前者可以帮助我们在内存中创建一个临时的文件store,后者可以通过ejs的语法去编辑我们的文件。

现在在constructor中初始化store。

constructor() {
  // 创建内存store
  const store = memFs.create();
  this.fs = memFsEditor.create(store);

  this.options = {
    name: '',
    description: '',
  };

  // 当前根目录
  this.rootPath = path.resolve(__dirname, '../');
  // 模版目录
  this.tplDirPath = path.join(this.rootPath, 'template');
}
复制代码

接下来在Creator中增加两个方法copy和copyTpl分别用于直接拷贝文件和拷贝文件并注入数据。

getTplPath(file) {
  return path.join(this.tplDirPath, file);
}

copyTpl(file, to, data = {}) {
  const tplPath = this.getTplPath(file);
  this.fs.copyTpl(tplPath, to, data);
}

copy(file, to) {
  const tplPath = this.getTplPath(file);
  this.fs.copy(tplPath, to);
}
复制代码

然后我们根据ejs的语法修改模版中的package.json文件以实现数据注入的功能

{
  "name": "<%= name %>",
  "version": "1.0.0",
  "description": "<%= description %>",
  "main": "index.js",
  "scripts": {},
  "author": "",
  "license": "ISC"
}
复制代码

回到template/index.js中,对模版中的文件进行相应的拷贝和数据注入操作,最后打印一些可视化的信息。

module.exports = function(creator, options, callback) {
  const { name, description } = options;

  // 获取当前命令的执行目录,注意和项目目录区分
  const cwd = process.cwd();
  // 项目目录
  const projectPath = path.join(cwd, name);
  const buildPath = path.join(projectPath, 'build');
  const pagePath = path.join(projectPath, 'page');
  const srcPath = path.join(projectPath, 'src');

  // 新建项目目录
  // 同步创建目录,以免文件目录不对齐
  fs.mkdirSync(projectPath);
  fs.mkdirSync(buildPath);
  fs.mkdirSync(pagePath);
  fs.mkdirSync(srcPath);

  creator.copyTpl('packagejson', path.join(projectPath, 'package.json'), {
    name,
    description,
  });

  creator.copy('build/build.js', path.join(buildPath, 'build.js'));

  creator.copy('page/index.html', path.join(pagePath, 'index.html'));

  creator.copy('src/index.js', path.join(srcPath, 'index.js'));

  creator.fs.commit(() => {
    console.log();
    console.log(`${chalk.grey(`创建项目: ${name}`)} ${chalk.green('✔ ')}`);
    console.log(`${chalk.grey(`创建目录: ${name}/build`)} ${chalk.green('✔ ')}`);
    console.log(`${chalk.grey(`创建目录: ${name}/page`)} ${chalk.green('✔ ')}`);
    console.log(`${chalk.grey(`创建目录: ${name}/src`)} ${chalk.green('✔ ')}`);
    console.log(`${chalk.grey(`创建文件: ${name}/build/build.js`)} ${chalk.green('✔ ')}`);
    console.log(`${chalk.grey(`创建文件: ${name}/page/index.html`)} ${chalk.green('✔ ')}`);
    console.log(`${chalk.grey(`创建文件: ${name}/src/index.js`)} ${chalk.green('✔ ')}`);

    callback();
  });
}
复制代码

执行mycli指令创建项目,一个简单的cli就完成了。

执行指令

结语

到此,一个简单的cli就制作完成了,大家可以参考vue-cli、create-react-app等优秀的cli适当的扩展并开发属于自己的cli工具。