简介

脚手架CLI(command-line-interface)是一类快速形成工程化目录的工具。

开发过程中,如果需要新建前端项目,我们经常都会用到脚手架来创建工程,通过命令行式的交互,可快速选择选项并完成初始项目的搭建。而CV大法往往会带来很多重复的删减工作,且会导致项目分散、架构不统一等等弊端。

常见的主流框架都有自己的脚手架:

通过本文内容实践,可以开发出一套较基础的脚手架工具,满足日常工作中搭建项目的同时,持续对脚手架和模版内容做优化。

搭建

按照惯例,先附上代码 thjjames/th-cli 并整理下知识点:

  • 如何声明全局命令
  • 如何实现命令行交互
  • 如何实现用户选项交互
  • 如何创建项目(预设和远程)

依赖包

在此之前,还需要先访问一下 package.json 文件,看下需要安装的依赖并简单了解这些依赖对应的功能:

  • 核心工具库

    • commander TJ大神写的 Nodejs 命令行交互工具
    • inquirer 选项命令行交互工具,也可以用 prompts
    • fs-extra fs 的扩展包,添加了部分方法和 promise 支持
    • mem-fs / mem-fs-editor 基于 ejs 的文件编辑助手
    • download-git-repo 远程模板下载工具
  • 辅助工具库

    • minimist 轻量级的命令行参数解析引擎
    • semver 语义化版本号规则
    • validate-npm-package-name 验证项目名称
    • chalk 美化终端输出
    • ora 终端加载动画
    • figlet 输出终端艺术字

声明全局命令

为何安装完脚手架包后,就能在任意地方执行脚手架命令呢?

// 安装
npm i -g thjjames/th-cli
// 使用
th-cli create <project-name>

先看 package.json 文件:

// 当只有一个可执行文件且命令名就是包名称,例如这里可以简写为
// "bin": "bin/th-cli.js"
{
  "name": "th-cli",
  "version": "1.0.0",
  "description": "Scaffold for Creating Projects.",
  "author": "田豪峻 James<thjjames@163.com>",
  "bin": {
    "th-cli": "bin/th-cli.js"
  },
  ...
}

bin 字段指定了命令名到本地文件名的映射,安装包时会在 node_modules 文件夹下面的 .bin 目录中复制可执行文件,这样就可以在安装的目录下执行自定义脚手架命令。当全局安装时,会映射到全局存储的位置(LinuxmacOS 系统默认目录 /usr/local/lib/)。

image.png

如果想直接执行 th-cli 命令,会直接从全局目录查找,必须全局安装。

npm run[-script] 执行命令时的查找顺序为: 先从当前项目目录的 node_modules 文件夹下查找,然后是全局安装目录,最后再是 Node 根目录。

如何实现命令行交互

理解完上述的命令查找关系链,我们看下命令名 th-cli 对应的 bin/th-cli.js 文件,是开始执行脚手架内容的关键,先看第一行:

#! /usr/bin/env node

#! 是一种在 Linux 系统中使用的特殊注释,通常用于指定脚本文件的解释器。/usr/bin/env 代表解释器目录, 用 node 执行。更多相关内容可以通过 Shebang 了解。

执行前可以做些校验内容,用 semver 判断当前环境的 node 版本是否满足脚手架需要的最低版本、validate-npm-package-name 判断新建的包名是否符合命名规范,这些都是锦上添花的功能,不花篇幅去讲了,请自行撸代码。

接下去就是核心内容 commander 部分,这里看下我们使用到的一些功能:

  • option 定义选项
  • command 创建 create 和 list 等自定义命令

    • alias 给命令添加别名
    • description 给命令添加描述
    • argument 给命令声明参数
    • option 给命令定义选项
    • action 给命令添加执行函数
  • version 提供版本号

具体用法文档上已经写的很详细了,照着文档创建一个自定义的create命令:

program
  .command('create <project-name>')
  .description('create a new project by th-cli')
  .option('-d, --default', 'skip prompts and use default preset')
  .option('-f, --force', 'overwrite target directory if it exists')
  .option('-c, --clone', 'use git clone when fetching remote preset')
  .action((name, options) => {
    if (minimist(process.argv.slice(3))._.length > 1) {
      console.warn(chalk.yellow('You provided more than one argument.'))
      console.warn(chalk.yellow('The first one will be used as the project name, the rest are ignored.'))
    }
    require('../lib/create')(name, options);
  });

image.png

通过上述代码完成了需要通过命令行交互来搭建项目的主体,里面的参数例如 -f 作用是在我们创建的项目目录有冲突时提供的强制覆盖选项:

if (fse.existsSync(targetDir)) {
  if (options.force) {
    fse.removeSync(targetDir);
  } else {
    console.error(chalk.red(`Folder ${targetDir} is already in use, please rename or overwrite it by using option -f.`));
    process.exit(1);
  }
}

image.png

执行自定义命令并通过验证后可以看到直接进入用户选项交互:

th-cli create <project-name>

image.png

如何实现用户选项交互

 title=

inquirer 是交互式命令行美化工具,提供了一系列常用组件(如input、list、checkbox等),解析输入并收集、验证答案。经过一系列的交互操作后,在命令的执行目录生成了新的项目。

const inquirer = require('inquirer');
const prompts = [
  {
    name: 'type',
    type: 'list',
    message: '请选择获取模板方式',
    default: 'preset',
    choices: [
      { value: 'preset', name: '预设模板' },
      { value: 'remote', name: '远程模板' }
    ]
  },
  ...
];
const answers = await inquirer.prompt(prompts);

image.png

这里交互是帮助我们可以通过一系列选项来确认最终想要的项目,是本地预设还是远程模板、是vue还是react项目、是babel还是esbuild打包、是否需要ts等等,这些都可以通过交互得到答案并体现在最终的项目上。

如何创建项目

如果选择远程模板,需要借助 download-git-repo 从远程 git 仓库上下载,上述的 -c 参数对应此包的clone参数用来区分 git clonehttp下载,下载后脚手架需要帮助执行 git init 以初始化现有仓库;而选择预设模板的话,将会从脚手架代码中的template文件夹复制到本地,当然了,此处并不是简单的 fs.copy 复制,会将模板中的文件基于 ejs 模板引擎和用户交互选项的答案anwsers生成新的模板文件。

const templateDir = answers.type === 'preset' ? path.resolve(TEMPLATE_DIR, answers.preset) : path.resolve(os.tmpdir(), `cli-tmp-${name}`);
const spinner = ora(`Creating a new project in ${targetDir}, please wait...\n`).start();

if (answers.type === 'preset') {
  await copy(name, answers, targetDir, templateDir);
} else {
  await downloadGitRepoAsync(answers.repository, templateDir, {
    clone: options.clone
  });
  await copy(name, answers, targetDir, templateDir);
  fse.removeSync(templateDir);
}
execSync('git init', {
  cwd: targetDir
});
spinner.succeed('Create successfully');

由于上述过程需要消耗点时间,在过程中我们通过 ora 来展现加载效果,完善整个交互过程。

看到这里思考下一个问题,远程模板的下载速度要比本地预设慢很多,那为何还需要这个选项呢?原因有以下,一是当自己开发的脚手架需要给其他部门使用时,不太适合将别人的定制模板放到本地预设中,这种情况下通过 git clone 的方式是最合适的,二是当本地预设模板还不够完善需要频繁改动时,会频繁的更新版本号导致开发者频繁的更新版本,这种情况下也可以考虑使用远程模板来规避。

最后想下,整个用户交互的过程还是略微有一些些成本的,如果我们团队的项目风格比较固定即大部分情况下的选项答案都是相同的,有没有更快速便捷的创建方式呢?往上看选项 -d 就是起这个作用,带上这个参数时,所有的交互选项都会选择默认答案并跳过用户交互环节,直接生成默认项目模板!

结语

到这里,我们实现了一个简易的脚手架,可以选择模板,也有自定义的快捷命令,但回看简介里主流的脚手架代码,可以看出还有很多功能点可以完善,例如:

  • 模板中如何根据选项(例如是否移动端、是否需要pinia、是否需要ts)动态定制成多套?
  • 脚手架中的模板更新后如何同步更新到以前已经生成的项目中?

就卖个关子🤪留给大家思考吧,前端脚手架并没有一成不变的最佳实践,一切还得根据团队实际情况定制。但不管怎么说,完成了从0到1的MVP版本后,再到MDP版本就简单多了。


小皇帝James
600 声望7 粉丝

IT吴彦祖