从零构建自己的脚手架spike-cli

402 阅读5分钟

前言

日常工作中经常需要搭建项目结构。如果将搭建好的项目封装为模板,再次需要搭建时,通过简单的命令即可完成项目初始化。便可快速搭建项目的基本结构并提供项目规范和约定,极大提高团队效率。

设计脚手架

在开发之前,我们需要根据需求设计出自己的脚手架流程。笔者的需求是,要经常搭建Vue、React项目。所以设计方案核心流程为:

  1. 输入spike-cli init [projectName]命令
  2. 用户输入项目名称(如输入projectName合法则跳过)
  3. 用户输入项目版本号
  4. 用户选择项目模板
  5. 下载模板
  6. 安装模板
  7. 启动项目

我们根据核心流程,再增加一些前置校验:检查node版本检查是否root启动检查用户主目录检查是否需要更新

笔者的习惯是将流程梳理清楚之后,绘制成流程图。之后开发时,按照流程图上的逻辑编写代码可以保持思路清晰。脚手架流程图如下:

spike-cli流程图.png

脚手架雏形

我们现在需要做的就是输入spike-cli init [projectName]命令之后,启动我们的脚手架。

1.新建入口文件src/core/cli/bin/index.js
#! /usr/bin/env node
require('../lib')(process.argv.slice(2))

这段代码的作用是使用node来执行src/core/cli/lib/index.js文件的内容。我们的脚手架代码会放在src/core/cli/lib/index.js中,后面会详细介绍里面的内容。

2.package.json文件添加bin属性
{
  "name": "spike-cli",
  "version": "1.0.0",
  "bin": {
    "spike-cli": "src/core/cli/bin/index.js"
  },
  ...
}

当用户npm install spike-cli -g时,就会在/usr/local/bin目录下生成一个spike-cli的命令 --> /usr/local/lib/node_modules/spike-cli/src/core/cli/bin/index.js。这样当用户直接使用spike-cli时,就可以执行我们的文件。

3.npm link

我们在开发时,为了方便调试脚手架。可以输入npm link,也可以将package.jsonbin属性,链接到/usr/local/bin目录下。当不需要时,可以通过npm unlink,取消链接即可。

架手架准备阶段

从这开始,我们编写架手架的具体逻辑。首先我们来做前置校验:检查node版本检查是否root启动检查用户主目录检查是否需要更新

1.新建src/core/cli/lib/index.js
async function core() {
  try {
    await prepare();
  } catch (e) {
    log.error(e.message);
  }
}

// 准备阶段
async function prepare() {
  checkRoot();
  checkUserHome();
  await checkGlobalUpdate();
}

// 检查node版本
function checkNodeVersion() {
    const currentVersion = process.version;
    if (semver.lt(currentVersion, LOWEST_NODE_VERSION)) {
      log.error(colors.red(`spike-cli 需要安装 v${LOWEST_NODE_VERSION} 以上版本的 Node.js`));
      process.exit(1);
    }
}

// 检查是否是否root账户启动
function checkRoot() {
  const rootCheck = require('root-check');
  rootCheck();
}

// 检查是否用户主目录
function checkUserHome() {
  if (!userHome || !pathExists(userHome)) {
    throw new Error(colors.red('当前登录用户主目录不存在!'))
  }
}

// 检查是否需要更新版本
async function checkGlobalUpdate() {
  const currentVersion = pkg.version;
  const npmName = pkg.name;
  const lastVersion = await getNpmSemverVersion(currentVersion,npmName);
  if(lastVersion) {
    log.warn('更新提示', colors.yellow(`请手动更新 ${npmName}, 当前版本:${currentVersion}, 最新版本:${lastVersion}\n更新命令:npm install -g ${npmName}`))
  }
}

module.exports = core;

上面的代码的作用就是做一些检查功能。其中和接下来会用很多第三方package,我们先来了解一下它们的作用:

名称简介
npmlog自定义级别和彩色log输出
colors自定义log输出颜色和样式
user-home获得用户主目录
root-checkroot账户启动
semver项目版本相关操作
fs-extra系统fs模块的扩展
commander命令行自定义指令
inquire命令行询问用户问题,记录回答结果

脚手架命令注册

上面的准备工作全部通过之后,我们开始注册命令。这里我们使用commander来帮助我们注册一个init [projectName]命令和option:forceoption:debug两个option。这样我们就可以使用下面命名进行交互

spike-cli init testProject --debug --force

其中--debug--force可不传。--debug参数可开启debug模式。--force参数可强制初始化项目。

下面我们来看看命令注册代码:

async function core() {
  try {
    await prepare();
+   registerCommand();
  } catch (e) {
    log.error(e.message);
  }
}

// 注册command命令
function registerCommand() {
  program
    .name(Object.keys(pkg.bin)[0])
    .usage('<command> [options]')
    .version(pkg.version)
    .option('-d, --debug', '是否开启调试模式', false)

  // 注册 init command
  program
    .command('init [projectName]')
    .option('-f --force', '是否强制初始化项目')
    .action(init)

  // 监听 degub option
  program.on('option:debug', function() {
    if (program._optionValues.debug) {
      process.env.LOG_LEVEL = 'verbose';
    }
    log.level = process.env.LOG_LEVEL;
    log.verbose('开启debug模式')
  })

  // 监听未知命令
  program.on('command:*', function (obj) {
    const availableCommands = program.commands.map(cmd => cmd.name());
    console.log(colors.red('未知的命令:' + obj[0]));
    if (availableCommands.length > 0) {
      console.log(colors.green('可用的命令:' + availableCommands.join(',')));
    }
    if (program.args && program.args.length < 1) {
      program.outputHelp();
    }
  })

  program.parse(process.argv);
}

脚手架命令执行

当我们监听到init [projectName]命令时,我们会执行init函数。下面我们来看看init函数的代码:

function init() {
  const argv = Array.from(arguments);
  const cmd = argv[argv.length - 1];
  const o = Object.create(null);
  Object.keys(cmd).forEach(key => {
    if(key === '_optionValues') {
      o[key] = cmd[key];
    }
  })
  argv[argv.length - 1] = o;
  return new InitCommand(argv);
}

init函数会接受command参数,由于command参数内容过多,只留下有用的optionValues参数。再执行InitCommand并传入简化过的参数。

下面我们来看看InitCommand内部代码:

class InitCommand {
  constructor(argv) {
    if (!Array.isArray(argv)) {
      throw new Error('参数必须为数组!');
    }
    if(!argv || argv.length < 1) {
      throw new Error('参数不能为空!');
    }
    this._argv = argv;
    new Promise((resolve, reject) => {
      let chain = Promise.resolve();
      chain = chain.then(() => this.initArgs());
      chain = chain.then(() => this.exec());
      chain.catch(e => log.error(e.message));
    })
  }

	initArgs() {
    this._cmd = this._argv[this._argv.length - 1];
    this._argv = this._argv.slice(0, this._argv.length - 1);
    this.projectName = this._argv[0] || '';
    this.force = !!this._cmd._optionValues.force;
  }
  
  async exec() {
    try {
      // 1.交互阶段
      const projectInfo = await this.interaction();
      this.projectInfo = projectInfo;
      if (projectInfo) {
        log.verbose('projectInfo', projectInfo);
       // 2.下载模板
        await this.downloadTemplate();
        // 3.安装模板
        await this.installTemplate();
      }
    } catch (e) {
      log.error(e.message);
    }
  }
}

这段代码是命令执行的核心流程。首先进行参数的校验和参数的整合,之后就进入到交互阶段下载模板阶段安装模板阶段

交互阶段

进入到交互阶段,我们首先要判断当前目录是否为空,如果为空是否启动强制更新,再二次确认是否清空当前目录。这里我们需要用到inquire,让他来帮助我们解决命令行交互的问题。下面我们来看代码:

async interaction() {
  // 1.判断当前目录是否为空
  const localPath = process.cwd();
  if (!this.isDirEmpty(localPath)) {
    // 2.是否启动强制更新
    let isContinue = false;
    if(!this.force) {
      isContinue = (await inquirer.prompt({
        type: 'confirm',
        name: 'isContinue',
        message: '当前文件夹不为空,是否继续创建项目?',
        default: false
      })).isContinue;

      if (!isContinue) return;
    }

    if (isContinue || this.force) {
      // 3.二次确认是否清空当前目录
      const { confirmDelete } = await inquirer.prompt({
        type: 'confirm',
        name: 'confirmDelete',
        default: false,
        message: '是否确认清空当前目录下的文件?'
      })
      if (confirmDelete) {
        fse.emptyDirSync(localPath);
      }
    }
  }

  return this.getProjectInfo();
}

如果用户继续选择创建时,我们就要执行getProjectInfo函数。它会询问用户项目名称、项目版本和选择的模板,并返回项目信息。

我们需要提前将模板上传到npm上,并准备好模板信息。

const template = [
  {
    name: 'react标准模板',
    npmName: 'spike-cli-template-react',
    version: '1.0.0',
    installCommand: 'npm install',
    startCommand: 'npm run start'
  },
  {
    name: 'react+redux模板',
    npmName: 'spike-cli-template-react-redux',
    version: '1.0.0',
    installCommand: 'npm install',
    startCommand: 'npm run start'
  },
  {
    name: 'vue3标准模板',
    npmName: 'spike-cli-template-vue3',
    version: '1.0.0',
    installCommand: 'npm install',
    startCommand: 'npm run serve'
  }
]

module.exports = template;

下来我们来看看具体代码:

async getProjectInfo() {
  function isValidName(v) {
    return /^[a-zA-Z]+([-][a-zA-Z][a-zA-Z0-0]*|[_][a-zA-Z][a-zA-Z0-0]*|[a-zA-Z0-9]*)$/.test(v);
  }
  console.log('getProjectInfo');
  let projectInfo = {};
  const projectPrompts = [];
  let isProjectNameValid = false;
  if (isValidName(this.projectName)) {
    isProjectNameValid = true;
    projectInfo.projectName = this.projectName;
  }

  if (!isProjectNameValid) {
    projectPrompts.push({
      type: 'input',
      name: 'projectName',
      message: '请输入项目名称',
      default: '',
      validate: function (v) {
        const done = this.async();
        setTimeout(() => {
          // 1.首字母必须为英文字母
          // 2.尾字母必须为英文或数字,不能为字符
          // 3.字符仅允许 "-_"
          // 合法:a, a-b, a_b, a-b-c, a_b_c, a-b1-c1, a_b1_c1
          // 不合法:1, a_, a-, a_1, a-1
          if (!isValidName(v)) {
            done('请输入合法的项目名称');
            return;
          }
          done(null, true);
        }, 0);
      },
      filter: function (v) {
        return v;
      }
    })
  }

  projectPrompts.push({
    type: 'input',
    name: 'projectVersion',
    message: '请输入项目版本号',
    default: '1.0.0',
    validate: function (v) {
      const done = this.async();
      setTimeout(() => {
        if (!semver.valid(v)) {
          done('请输入合法的版本号');
          return;
        }
        done(null, true);
      }, 0);
    },
    filter: function (v) {
      if (!!semver.valid(v)) {
        return semver.valid(v);
      } else {
        return v;
      }

    }
  })

  projectPrompts.push({
    type: 'list',
    name: 'projectTemplate',
    message: '请选择项目模板',
    choices: this.createTemplateChoices()
  })

  const project = await inquirer.prompt(projectPrompts);

  projectInfo = {
    ...projectInfo,
    ...project
  }

  if (projectInfo.projectName) {
    projectInfo.name = projectInfo.projectName;
    projectInfo.version = projectInfo.projectVersion;
    projectInfo.className = require('kebab-case')(projectInfo.projectName);
  }

  return projectInfo
}

下载模板

和用户进行交互之后,我们就开始下载选择的项目模板。这里借助了npmInstall,来帮我们下载。下面我们来看看代码:

async downloadTemplate() {
  const { projectTemplate } = this.projectInfo;
  this.templateInfo = template.find(item => item.npmName === projectTemplate);
  const spinner = spinnerStart('正在下载模板...');
  await sleep();
  try {
    await npmInstall({
      root: process.cwd(),
      registry: getDefaultRegistry(),
      pkgs: [{ name: this.templateInfo.npmName, version: this.templateInfo.version }]
    })
  } catch (e) {
    throw new Error(e);
  }finally {
    spinner.stop(true);
    this.templatePath = path.resolve(process.cwd(), 'node_modules', this.templateInfo.npmName, 'template');
    if (pathExists(this.templatePath)) {
      log.success('下载模板成功!');
    }
  }
}

这里的模板会被下载到node_modules下,我们提前拼接好模板路径,为接下来拷贝模板至当前目录做准备。

安装模板

我们首先拷贝模板代码至当前目录。然后使用ejs将packageName和version渲染到package.json中。再执行安装命令和启动命令。

下面我们来看看代码:

async installTemplate() {
  let spinner = spinnerStart('正在安装模板...');
  await sleep();
  try {
    // 拷贝模板代码至当前目录
    fse.copySync(this.templatePath, process.cwd());
  } catch (e) {
    throw new Error(e);
  }finally {
    spinner.stop(true);
    log.success('模板安装成功!');
  }

  const options = {
    ignore: ['node_modules/**', 'public/**']
  }
  await this.ejsRender(options);

  const { installCommand, startCommand } = this.templateInfo;
  // 安装命令
  await this.exexCommand(installCommand, '依赖安装过程中失败!');
  // 启动命令
  await this.exexCommand(startCommand, '依赖安装过程中失败!')
}

async ejsRender(options) {
  const dir = process.cwd();
  return new Promise((resolve, reject) => {
    glob('**', {
      cwd: dir,
      ignore: options.ignore || '',
      nodir: true
    }, (err, files) => {
      if (err) {
        reject(err);
      }
      Promise.all(files.map(file => {
        const filePath = path.join(dir, file);
        return new Promise((resolve1, reject1) => {
          ejs.renderFile(filePath, this.projectInfo, {}, (err, result) => {
            if (err) {
              reject1(err);
            } else {          
              fse.writeFileSync(filePath, result);
              resolve1(result);
            }
          })
        })
      }))
        .then(() => resolve())
        .catch(err => reject(err))
    })
  })
}

这里代码看上去稍多,但是核心逻辑上面已经讲明了。其中只有exexCommand函数没有细讲。它的作用是执行传入的node命令,其核心是使用node内置child_process模块中的spawn方法。

下面我们来看看具体代码:

async exexCommand(command, errMsg) {
  let ret;
  if(command) {
    const cmdArray = command.split(' ');
    const cmd = this.checkCommand(cmdArray[0]);
    if(!cmd) {
      throw new Error('命令不存在!命令:' + cmd);
    }
    const args = cmdArray.slice(1);
    ret = await execAsync(cmd, args, { stdio: 'inherit', cwd: process.cwd() });
  }
  if(ret !== 0) {
    throw new Error(errMsg)
  }
  return ret;
}

function execAsync(command, args, options) {
  return new Promise((resolve, reject) => {
    const p = exec(command, args, options);
    p.on('error', e => {
      reject(e);
    })
    p.on('exit', c => {
      resolve(c)
    })
  })
}

function exec(command, args, options) {
  const win32 = process.platform === 'win32';
  const cmd = win32 ? 'cmd' : command;
  const cmdArgs = win32 ? ['/c'].concat(command, args) : args;
  return require('child_process').spawn(cmd, cmdArgs, options || {});
}

到这为止,我们的脚手架就开发完了,之后就可以发布到npm上了。

总结

笔者已经将spike-cli发布到npm上了。有兴趣的读者可以使用下面的命令体验一下:

npm install spike-cli -g
spike-cli init testProject

spike-cli脚手架是笔者根据自己的需求设计的,各位读者也可以根据自己的需求设计相应的环节。

希望本文可以帮助读者搭建自己的脚手架,提高开发效率。