从0到1实现脚手架开发

127 阅读12分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第2天,点击查看活动详情

前言

随着前端项目的复杂性越来越高,涌现出了大量的优秀的脚手架,例如 vue-clicreate-react-appng-cli。通过脚手架我们可以快速的根据脚手架给的配置去创建一个项目。虽然这些脚手架十分优秀,但是在绝大多数情况下,通过脚手架创建的项目并不符合我们的需求,为此我们还需要对项目进行大量的改造。下次再次创建项目的时候,我们又得再一次改造项目,周而反复,我们在创建项目上就浪费了大量的时间,因此我们需要定制一个属于我们自己的脚手架。

你能学到什么

在阅读完本文后,你可以了学习到设计一款脚手架需要哪些能力以及如何设计出一款脚手架

本文开发环境

本文的开发环境的操作系统版本是 MacOS Monterey 12.1 ,运行环境是 NodeJS v16.10.0

使用代码示例

本文所有代码在上述描述的环境中测试过,且有效,你可以 点击此处 获取本文源码。

概述

一款优秀的脚手架要想能够在一个团队或一个组织中迅速地推广起来,那么就需要设计脚手架的工程师需要有高素质的nodejs水平和工程化能力,足够的经验以及一定的设计能力。

在开发一款脚手架时,至少需要考虑模板管理、版本检测、个性化模板和友好的交互。

模板管理

在脚手架中有两种模板管理方式,第一种是直接把模板放在脚手架里面,第二种是把模板放在远端,例如github、gitlab或服务器。因此这两种方式的优缺点也显而易见,使用第一种方式当你安装完脚手架后,你的脚手架里就一定带有模板,这样每次使用脚手架创建项目时,速度就非常快;当使用第二种方式,由于模板都存放在远端,那么创建项目时,就需要从远端下载这个模板,所以速度会比第一种慢。

采用哪种模板管理方式取决于你的模板是否成熟。如果你经常需要改动模板,那么就将模板放在远端,否则直接放在脚手架里面。就像知名脚手架 vue-cli 一样,在vue-cli 1.0~2.0 版本中, 其模板就放在 github 上,从 3.0 开始,就把模板直接放在脚手架中。

版本检测

在版本检测的时候,对于不同的模板管理方式,版本检测的方式会有所不同。对于第一种模板管理方式,版本检测时只需要检测脚手架的版本即可。而对于第二种模板管理方式,版本检测时不仅需要检测脚手架的版本,还需要检测模板的版本。

读到这儿,你可能会有疑问,前面不是说在使用第二种方式时,直接从远端下载模板,那为什么还要检测模板的版本呢?为了提高项目的创建速度,并不会每次都去远端下载模板,因为这样每次创建项目实在是太慢,而且也没有必要。而且获取远端版本的开销要远远比获取模板的开销要小得多。所以在下载完模板后,都存放在缓存文件中,下次在创建项目时,先和远端模板的版本进行比较,如果远端版本比较新,那么就重新下载模板,否则直接使用缓存文件中的模板。

个性化模板

在创建项目时,根据不同的需求可能需要不同的模板,针对不同的需求去创建一套固定的模板是不现实的,例如对于一个vue项目来说,有的项目需要vuex,有的项目又不需要vuex,这个时候你去创建两套模板,那么后期就需要维护则这两套模板。这种重复的工作作为一名程序员就需要把它干掉。

友好的交互

交互的友好程度优先决定了一款脚手架在团队或组织中能否迅速地推广起来。合适的颜色搭配进度条反馈信息等。

搭建你的第一个脚手架项目

在了解完上述内容后,我们就开始搭建你的第一个脚手架

预备知识

由于正式搭建脚手架是是需要一部分额外的知识点,如果你对这部分知识十分熟悉,可以直接跳过本节,如果你不熟悉,我建议你先阅读本章内容了解下。这部分内容只会简单介绍下这些东西,至少让你在后面的章节中不会太过迷茫。

Javascript

Javascript 是基础,这个就不用多介绍了。

chalk

chalk 是一个给 log 文字设置颜色、粗细、背景色、下划线等样式的工具。更多 API 参考官网

常见用法:

const chalk = require('chalk');

// 给 Hello world! 设置蓝色
console.log(chalk.blue('Hello world!')); 

// Hello 设置为红色,world 设置为下划线且背景蓝色
console.log(chalk.red('Hello', chalk.underline.bgBlue('world') + '!'));

// Underlined reddish color 设置为 rgb(123, 45, 67) 颜色,且有下划线
console.log(chalk.rgb(123, 45, 67).underline('Underlined reddish color'));

// Bold gray! 设置为 #DEADED 颜色,并且加粗
console.log(chalk.hex('#DEADED').bold('Bold gray!'));

下面这张图就是上面这个例子在终端里的样子。

image.png

commander

commander 是一个完整的 node 命令行解决方案。使用它,可以利用命令行做一些交互操作,是node脚手架开发必备利器。更多API参考官网

download-git-repo

download-git-repo 是一款 git 仓库下载工具。参考官网

fs-extra

fs-extra 添加了未包含在fs模块中的文件系统方法,并为这些方法添加了对 promise 支持。提供了更多便利的文件操作方法,继承自fs模块。官网

inquirer

inquirer 是常见的交互式命令行用户接口的集合,能够使得命令行更加的美观。inquirer 能都提供以下内容:

  • 返回内容
  • 提问
  • 输入答案
  • 验证答案
  • 管理多级提示

例如vue-cli中的 vue create 命令,就是通过 inquirer 进行了大量的询问选择。下图就是执行完 vue create 出现的选择预设页面。

image.png

更多 API 参考官网

jscodeshift

jscodeshift 是一个基于 codemod 理念的 JavaScript/TypeScript 重构工具,其原理是将 JS/TS 代码解析为抽象语法树(Abstract Syntax TreeAST),并提供一系列用于访问和修改 ASTAPI 以实现自动化的代码重构。jscodeshiftbabel parserast-types(用于快速创建新的 AST 节点)和 recast(维护生成代码的代码风格信息)三大工具整合在一起,提供了简便快捷的操作接口;同时它还提供了多任务并行执行的功能,使其对于海量代码文件的重构操作可以并行运行,充分利用多核 CPU 算力,缩短重构任务执行时间。

Codemod 是一个诞生于 Facebook 内部的概念,可以理解为 code modification 的缩写。如官方介绍所述,codemod 针对的场景是规模较大的代码库中的重构工作。当某个在代码中被频繁使用的接口发生了无法向前兼容的重大变化,codemod 提供了快速且可靠的、半自动的工具来对代码库中所有相关代码进行重构,以帮助开发者对代码进行快速迭代。

这个东西我原来也不知道,我是看了 vue-cli的源码才知道的,而且网上对它的介绍也不是很多,官方的文档更是看的云里雾里的。不过好在最后我看懂了,具体我会在后面章节中具体介绍其用法。

简单来说就是使用 jscodeshift 可以快速的对 AST 进行增删改查。

lodash

Lodash 是一个一致性、模块化、高性能的 JavaScript 实用工具库。更多API参考官网

ora

ora 是一个终端 loading 状态。

image.png

image.png

常用方法:

// 设置默认文案并启动
const spinner = ora('Loading unicorns').start();
// 颜色
spinner.color = 'yellow';
// 文本
spinner.text = 'Loading rainbows';
// 成功状态
spinner.succeed('succeed')
// 失败状态
spinner.fail('fail')
// 警告状态
spinner.warn('warn')
// 普通状态
spinner.info('info')
// 停止
spinner.stop()

更多API参考官网

semver

semver 是 语义化版本(Semantic Versioning)规范 的一个实现,目前是由 npm 的团队维护,实现了版本和版本范围的解析、计算、比较。常用的方法有:

方法参数描述
sortlist: Array<string>排序
gta: string, b: string大于
lta: string, b: string小于
eqa: string, b: string等于
gtea: string, b: string大于等于
ltea: string, b: string小于等于

起步

在你喜欢的位置创建一个目录(cv-cli),用来存放脚手架的代码,当然你也可以使用自己喜欢的名字。然后用终端进入到这个目录,执行以下命令。

npm init -y

这样我们就初始化了我们的脚手架项目,本文使用到的依赖库并不多,都是一些常用的依赖库,实际上一个企业级的依赖可能比这个多,接下来我们使用 npm install 安装一下本文用到的所有依赖和版本。

{
  dependencies: {
    "chalk": "^4.1.2",
    "commander": "^8.3.0",
    "download-git-repo": "^3.0.2",
    "fs-extra": "^10.0.0",
    "inquirer": "^8.2.2",
    "jscodeshift": "^0.13.1",
    "lodash": "^4.17.21",
    "ora": "^6.1.0",
    "semver": "^7.3.5"
  }
}

我们在 cv-cli 根目录下,创建一个 src/index.js,在 index.js 中写入以下内容:

#!/usr/bin/env node
const { Command } = require('commander')
const package = require('../package.json')

const program = new Command()

// cli 版本怎么设置?
program.parse().version(package.version)

console.log(`当前版本为 ${package.version}。`)

我们注意到,在代码的第一行有个 #!/usr/bin/env node ,它的作用是用 node 来执行此文件,node从在哪儿来,就去用户(usr)的安装根目录(bin)下的env环境变量中去找node执行器,这样我们就可以用node来执行这个文件。

package.json 中的 script 新增

{
  "cv": "node src/index.js"
}

完成上述步骤后,我们执行 npm run cv,我们就会在控制台看到 package.jsonversion 的版本 当前版本为 1.0.0。。这样我们的脚手架基本结构就完成了。所以如果需要判断脚手架是否需要更新,就根据把这个 version 和最新的 version 来比较。

create 命令

create 命令用来创建我们的项目,其中会涉及到模板的选择,开发的规范等。

index.js 中新增

const create = async (cwd) => {
  let { name } = cwd
  
  if (isEmpty(name)) {
    logger.error('项目名称不能为空')
    process.exit(0)
  }

  console.log(`${name}创建成功`)
}

program
  .command('create')
  .description('初始化一个项目')
  .option('-n --name <name>', '项目名称')
  .action(create)

其中 program 实例

函数说明
command命令的名称
description对命令的描述,给 cli 帮助使用
option参数选项,可以有多个参数
action这个命令执行的函数,即执行这个命令要最哪些事儿

假设我们要创建 cv-demo 项目,则需要在 package.json 中的 script 新增:

{
    // ...
    "create": "node src/index.js create -n cv-demo"
}

运行后控制台就会输出 cv-demo创建成功

本地模板管理方式

本地模板

接下来我们就要继续完善这个命令,实现真正能创建项目的功能。

首先我们在 cv-cli/src/templates/pc 文件夹中新增一个pc项目模板,你可以使用任何你想使用的模板,在这里我放了 vite-vue 项目模板。

image.png

我们在 create 函数中继续添加以下内容:


const create = async (cwd) => {
  // ...
  const currentDir = resolve(process.cwd(), name)
  const templateDir = resolve(__dirname, 'templates/pc')

  let spinner = ora(`开始创建 ${name} 项目...`).start();
  try {
    setTimeout(async () => {
      await fs.mkdirs(name)
      await fs.copy(templateDir, currentDir)
      spinner.succeed(`${name} 项目创建成功!`)
    }, 1000)
  } catch(e) {
    console.log(e);
  }
}

process.cwd() 能够获取当前node命令执行时的工作目录是什么,例如我们在 demo 文件夹下使用终端运行node进程,那么 process.cwd()返回就是, demo文件夹的绝对路径。所以 currentDir 就是你要创建项目目录的路径。templateDir 是获取模板所在的目录。这里使用 setTimeout 是为了模拟从远程拉去模板,这条就会有个loading的状态显示,后面我们会讲如何从远程拉取模板。

然后我们在与cv-cli同级的目录下新建一个文件夹叫demo,然后使用终端进入到这个文件夹内,然后执行以下命令:

node ../cv-cli/src/index.js create -n cv-demo

开始执行时

image.png

执行成功

image.png

然后我们在打开对应的文件夹,可以看到这个项目创建成功了,然后就可以愉快的开发你的项目了。

image.png

多模板选择

到此,我们发现只有一个模板,但是我们的工作需要往往还有移动端的、微信、后台管理等平台或其他类型的模版,那么这就需要配置多模板。这里我们在创建 cv-cli/src/templates/admincv-cli/src/templates/mobile 文件夹,并往里面加入对应的模板

image.png

我们改造create代码

const create = async (cwd) => {
  let { name } = cwd;

  if (isEmpty(name)) {
    logger.error("项目名称不能为空");
    process.exit(0);
  }

   // 新增
  const TEMPLATE_MAP = Object.freeze({
    pc: 'pc',
    admin: 'admin',
    mobile: 'mobile',
  })

  // 新增
  const { templateName } = await inquirer.prompt([
    {
      name: "templateName",
      type: "list",
      message: "(必填)请选择一个模板:",
      choices: Object.keys(TEMPLATE_MAP),
      default: 0,
    },
  ]);

  const currentDir = resolve(process.cwd(), name);
  const templateDir = resolve(__dirname, "templates", templateName); // 修改

  let spinner = ora(`开始使用${templateName}模板创建 ${name} 项目...`).start();
  try {
    setTimeout(async () => {
      await fs.mkdirs(name)
      await fs.copy(templateDir, currentDir)
      spinner.succeed(`${name} 项目创建成功!`)
    }, 1000)
  } catch(e) {
    console.log(e);
  }
};

这里只要关注注释中修改和新增字样部分。要选择不同的模板就需要使用 inquirer.prompt来实现交互式选择, prompt是一个数组,数组的每一项是一个对象,这个对象有以下属性:

属性说明
templateName当前列表的key
typeprompt的类型,这里是list
message提示信息
choices选择项列表,是一个数组
default默认选择choices中第几项

之前模板文件夹是固定的,现在我们改成动态的

  const templateDir = resolve(__dirname, "templates", templateName);

这样我们运行

node ../cv-cli/src/index.js create -n cv-demo-pc

就会出现一个选择项目让我们选择模板,我们可以使用上下键选择不同的模板

image.png 然后回车,等待创建完成,我们就可以看到 cv-demo-pc 项目创建成功。然后我们再试试选择其他的模板在改个项目的名字,我们发现都可以创建成功。

image.png

项目配置项

对于一个项目我们可能会在针对不同业务需求使用不同的功能,比如一个vue项目来说,你可能需要vue-router、pinia、eslint,但他们的组合是不确定。

首先我们需要创建一个对象,来说明你需要哪些模块:

  const MODULE_MAP = Object.freeze({
    'vue-router': 'vue-router',
    'pinia': 'pinia',
    'scss': 'scss',
  })

这里使用了 Object.freeze 可以防止这些数据被修改,只对第一层有效,对嵌套对象无效。

再给 inquirer.prompt 新增一个模块对象

  const { templateName, module } = await inquirer.prompt([
    {
      name: "templateName",
      type: "list",
      message: "请选择一个模板:",
      choices: Object.keys(TEMPLATE_MAP),
      default: 0,
    },
    {
      name: "module",
      type: "checkbox",
      message: "请选择您需要的模块:",
      choices: Object.keys(MODULE_MAP),
      default: ['vuex'],
    },
  ]);

选择完模块还需要在 package.json 中新增依赖,由于 package.json 本质上是一个json对象,所有可以使用 fs.readJSON 读取这个文件,其返回值就是一个JSON对象。

const extendPkg = (source, target) => {
  source.dependencies = {
    ...source.dependencies,
    ...target.dependencies
  }
}

const createPackageTemplate = async (
  tempTemplateDir,
  currentDir,
  templateDir,
  module
) => {
  const package = await fs.readJSON(resolve(tempTemplateDir, "package.json"));

  if (module.includes("vue-router")) {
    extendPkg(package, {
      dependencies: {
        "vue-router": "3",
      },
    });
    injectRouter(tempTemplateDir);
  }
  if (module.includes("pinia")) {
    extendPkg(package, {
      dependencies: {
        pinia: "^2.0.11",
      },
    });
  }
  if (module.includes("scss")) {
    extendPkg(package, {
      dependencies: {
        sass: "^1.49.9",
      },
    });
  }

  return JSON.stringify(package, null, 2);
};

有了这个字符串,然后我们就要把这个字符串写入到 package.json 文件中,我们在原来拷贝模板的位置的下面新增写入文件的逻辑:

try {
    setTimeout(async () => {
      await fs.mkdirs(name)
      await fs.copy(templateDir, currentDir)
      const packageTemplate = await createPackageTemplate(tempTemplateDir, module)
      await fs.writeFile(resolve(currentDir, 'package.json'), packageTemplate),
      spinner.succeed(`${name} 项目创建成功!`)
    }, 1000)
  } catch(e) {
    console.log(e);
  }

这样我们就完成了。下面我们来试试吧

image.png 选择 admin模板,然后选择pinia和scss

image.png

等待创建完成,这样我们打开项目的 package.json,我们发现刚才选择依赖模块就添加进去了。

image.png

注入

我们除了要在 package.json 文件中新增依赖,有些模块还需要在main.js 中导入,那么如何导入呢?修改main.js 中的代码,我们需要使用 jscodeshift,例如我们选择了vue-router,我们需要在 main.js 中新增导入语句,以及使用导入的 router

// 导入注入
const injectImports = (root, imports) => {
  const toImportAST = (i) => j(`${i}\n`).nodes()[0].program.body[0];
  const toImportHash = (node) =>
    JSON.stringify({
      specifiers: node.specifiers.map((s) => s.local.name),
      source: node.source.raw,
    });

  const declarations = root.find(j.ImportDeclaration);
  const importSet = new Set(declarations.nodes().map(toImportHash));
  const nonDuplicates = (node) => !importSet.has(toImportHash(node));

  const importASTNodes = imports.map(toImportAST).filter(nonDuplicates);

  if (declarations.length) {
    declarations
      .at(-1)
      // a tricky way to avoid blank line after the previous import
      .forEach(({ node }) => delete node.loc)
      .insertAfter(importASTNodes);
  } else {
    // no pre-existing import declarations
    root.get().node.program.body.unshift(...importASTNodes);
  }

  return root.toSource();
};

// 路由注入
const injectRouter = async (tempTemplateDir) => {
  const mainFile = resolve(tempTemplateDir, "src/main.js");
  const content = fs.readFileSync(mainFile, "utf-8");
  const root = j(content);

  injectImports(root, [`import router from './router'`]);

  const appRoots = root.find(j.CallExpression, (node) => {
    if (j.Identifier.check(node.callee) && node.callee.name === "createApp") {
      return true;
    }

    if (
      j.MemberExpression.check(node.callee) &&
      j.Identifier.check(node.callee.object) &&
      node.callee.object.name === "Vue" &&
      j.Identifier.check(node.callee.property) &&
      node.callee.property.name === "createApp"
    ) {
      return true;
    }
  });

  appRoots.replaceWith(({ node: createAppCall }) => {
    return j.callExpression(
      j.memberExpression(createAppCall, j.identifier("use")),
      [j.identifier("router")]
    );
  });

  const final = root.toSource();
  try {
    await fs.writeFile(mainFile, final);
  } catch (e) {
    console.log('injectRouter', e);
  }
};

// 创建路由
const createRouteFile = async (templateDir, currentDir) => {
  const routeFile = resolve(templateDir, "router/index.js");
  await fs.copy(routeFile, resolve(currentDir, "src/router/index.js"));
};

我们在调用 createPackageTemplat 时,如果模块是 vue-router,我们新增了两行代码,创建路由文件 和 注入路由,创建路由文件这个很简单,就不多说了,主要是 注入路由 这个,原来我的想法是,把main.js 做成一个字符串,然后根据需要拼凑而成,后来想着这样十分不优雅,就去翻了下 vue-cli 的源码,找到其源码中,用利用了ast 的来修改 main.js,由此有了 injectRouter 和 injectImports 方法。

injectImports

这个注入导入,就是 import...from,

injectRouter

还需要修改下 createPackageTemplate 方法

const createPackageTemplate = async (
  tempTemplateDir,
  currentDir,
  templateDir,
  module
) => {
  // ...

  if (module.includes("vue-router")) {
    extendPkg(package, {
      dependencies: {
        "vue-router": "3",
      },
    });
    // 新增:创建路由文件
    await createRouteFile(templateDir, currentDir);
    // 新增: 注入路由
    injectRouter(tempTemplateDir);
  }
  // ...
};

好的,到现在就完成了一个项目的脚手架的创建。当然上面的例子都很简单,实际一个真正的企业级脚手架会比这个复杂很多。除了你可以重写package.json,你也可以动态创建vue-router,然后还会创建对应的router文件。还要检查项目文件夹是否存在。

远程模板管理方式

上面我们实现了本地的模板,那么现在我们就要实现以下远程模板。 既然要下载远程模板,我们就需要使用 download-git-repo,这里我把 downloadGitRepo 封装成了 promise

const downloadGitReposPromise = (api, projectName) => {
  return new Promise((resolve, reject) => {
    downloadGitRepo(api, projectName, (err) => {
      if (err) {
        reject(err);
        return;
      }
      resolve();
    });
  });
};

完整下载逻辑 判断有木有模板 没有模板

是否有文件夹

const hasDir = async (dir) => {
  try {
    await fs.access(dir);
    return true;
  } catch (e) {
    return false;
  }
};

下载

在下载的时候,我们需要在终端给出提示,告诉用户正在执行什么操作。

const download = async (templateName, projectName) => {
  if (!(await hasDir(projectName))) {
    await fs.mkdirs(projectName);
  }
  const isVersion = templateName === "version";
  const startMsg = isVersion ? "正在获取版本...\n" : "开始下载模板\n";
  const endMsg = isVersion ? "成功获取版本\n" : "成功下载模板\n";
  const registry = "github:GeorgeLeoo/cv-cli-templates";
  // const registry = "https://github.com/GeorgeLeoo/cv-cli-templates.git";
  let api = `${registry}#${templateName}`;
  logger.info(startMsg);
  logger.info(api + "\n");
  await downloadGitReposPromise(api, projectName);
  logger.success(endMsg);
};

获取本地模板的版本

获取本地模板的版本就很简单,直接获取对应模板的 package.json 的 version 即可。

  const getLocalVersion = async (templateName, tempTemplateDir) => {
  const package = await fs.readJSON(resolve(tempTemplateDir, "package.json"));
  return package.version;
};

获取远程模板的版本

我们在模板仓库创建一个version分支并创建一个版本文件 version.json,

{
  "pc": "1.0.0",
  "admin": "1.0.0",
  "mobile": "1.0.0"
}

这个文件就是用来维护所有的模板版本。我们获取远程模板的版本就是获取这个文件,然后根据这个文件就能取出对应的版本

const getRemoteVersion = async (templateName) => {
  const versionDir = resolve(__dirname, "../.tmp/version");
  const versionFile = resolve(__dirname, "../.tmp/version/version.json");

  await download("version", versionDir);
  const version = await fs.readJSON(versionFile);
  return version[templateName];
};

修改 create 方法这部分内容

try {
   if (!(await hasDir(tempTemplateDir))) {
      logger.info("还没有模板,即将下载模板");
      await download(templateName, tempTemplateDir);
    } else {
      const remoteVersion = await getRemoteVersion(templateName);
      const localVersion = await getLocalVersion(templateName, tempTemplateDir);
      console.log("localVersion: ", localVersion);
      if (semver.gt(remoteVersion, localVersion)) {
        logger.info(
          `发现有新版本${remoteVersion},当前版本${localVersion},即将下载新版本模板`
        );
        await download(templateName, tempTemplateDir);
      }
    }
    await fs.mkdirs(name);
    // ...
}

npm link

当我们做完以后,又一个问题可能一致困扰着你。我们使用create命令一直使用这种方式:

node ../cv-cli/src/index.js create

而我们实际应用中,使用的是 xxx create。 新建一个bin目录,创建index.js文件

image.png

index.js 内容就是

#!/usr/bin/env node
require('../src/index.js')

然后在package.json中新增:

{
  // ...
  "bin": {
    "cv": "bin/index.js"
  },
  // ...
}

我们只需进入到cv-cli根目录,然后执行 npm link 即可。可能由于权限问题,需要使用 sudo npm link 这样你就会发现,可以使用 cv create

npm 发布

使用 npm publish,然后输入账号密码就可以发布。

总结

脚手架看上去很难,但是花点时间,认认真真的学习下,就不会觉得很难了。其实任何一个脚手架都不是一蹴而就的,一开始就堆了一堆的功能,例如vue-cli的1.0和2.0版本,这两个版本的代码十分简单,后来随着使用vue的人数越来越多,业务需求更多,从3.0开始vue-cli就觉来越复杂。学完这些你再去看vue-cli的源码就很简单了,只是多了很多的东西。脚手架让我们化繁为简。二次封装其它脚手架。