开发一个高性能可扩展的脚手架

Posted 在厕所喝茶

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了开发一个高性能可扩展的脚手架相关的知识,希望对你有一定的参考价值。

开发一个高性能可扩展的脚手架

背景

之前在公司使用脚手架生成一个项目模板的时候,发现项目的模板竟然是直接内置在脚手架中的,导致如果项目模板有任何的更新,都需要升级脚手架。而且脚手架只能生成一种项目的模板,pc 端有一个脚手架,移动端也有一个脚手架,脚手架太多不好管理。所以我就想着开发一个统一的脚手架,可以快速生成各种项目的模板,并且可以支持自定义生成模板。

高性能和可扩展

首先说一下脚手架的高性能和可扩展体现在哪里的:

高性能

主要体现在使用了缓存。脚手架会对用户使用过的模板进行缓存(缓存在本地),当使用的模板不存在缓存时,就是去下载模板,然后再使用模板。如果模板存在缓存,首先检查是否需要进行更新,如果不需要更新,就直接使用缓存的模板,如果需要更新,就去更新模板,然后再使用更新后的模板。

这样子既实现了模板的自动更新,又实现了模板的缓存功能。

用户每次使用同一模板的时候就不用频繁的从网络上面下载下来,提高项目模板的生成速度。

可扩展

可扩展主要是指可以生成不同的项目模板,并且可以自定义生成模板的行为。

不同的项目需要使用到不同的模板,比如pc移动端小程序等不同端的项目肯定是需要不同的模板的。

脚手架内部默认实现了一套生成项目模板的逻辑,如果不想使用默认生成项目模板的逻辑,可以自己去定义生成项目模板的逻辑,脚手架会自动执行你的自定义行为。

脚手架命令集合

主要命令集合如下:

快速生成项目模板:

cli init [npm包名]

发布项目:

cli publish

清除缓存:

cli clear

本文重点讲解怎么实现cli init [npm包名]cli publish跟实际业务需求有关不做讲解。cli clear就是删除缓存目录,也不做讲解。

脚手架目录结构

我们采用monorepo(单仓库,多项目)的形式,使用lerna工具去管理,每个命令都作为一个子项目去维护,方便管理,项目目录结构如下:

  • core:核心包,脚手架的初始化,以及一些前期准备工作

  • clear:cli clear 命令处理,清除缓存

  • init:cli init [npm包名] 命令处理,生成项目模板

  • public:cli publish 项目、组件库等自动化发布

  • utils:公共方法

模板处理

关于模板,所有模板都需要发布到npm上面,cli init [npm包名]命令就是根据npm包名npm上下载对应的包。

关于模板的处理,你需要注意一下几点:

  • package.json文件中的main字段,该字段决定了脚手架生成项目模板的时候,是使用脚手架内置的生成逻辑,还是使用自定义生成逻辑

    • main字段不存在,就使用脚手架内置的模板生成逻辑。并且你的所有模板文件都需要放置在template目录下,因为脚手架默认的就是读取这个目录下的模板文件

    • main字段存在,就是使用自定义生成逻辑。main字段所指向的文件必须默认导出一个函数,脚手架会引入这个文件,并执行导出来的函数,函数会接收到一系列的参数信息,你可以根据这些参数信息来自定义生成逻辑

  • 在使用脚手架内置的模板生成逻辑的时候,会使用ejs模板引擎对所有模板文件进行遍历,并且会传入一系列的参数,比如项目名称name,项目版本号version。你可以在package.json文件中编写ejs模板的语法,并将这些nameversion等数据填充到package.json文件的指定位置

脚手架执行主流程

整体的流程图如下:

获取脚手架版本号

可以通过直接读取package.json中的version字段得到,然后打印出来,告诉用户当前脚手架的版本信息

检查脚手架是否有更新

检查脚手架是否有更新主要将线上发布的最新版本号和本地的脚手架版本号进行对比,如果不一致说明就有更新。然后提示用户有更新,这样子可以提高用户的体验度。

获取线上的最新版本号有 2 种方式:

第一种:

通过请求https://registry.npmjs.org或者https://registry.npm.taobao.org获取。假设我们想要获取element-ui的最新版本号,可以通过拼接如下url地址https://registry.npm.taobao.org/element-ui,发送get请求。请求返回的是一个json对象,json对象中包含了一个versions字段,你可以通过这个字段获取线上的最新版本号

这种获取的方法前提是你要发布到npm上面才行,如果是发布到私服,这种方法可能会获取不了

第二种:

通过执行命令npm view [npm包名] version获取

我这里是通过第二种方式获取的,代码如下:

const cp = require("child_process");

function getNpmVersion(npmName) 
  return new Promise((resolve, reject) => 
    cp.exec(`npm view $npmName version`, , (error, stdout, stderr) => 
      if (error) 
        reject(error);
        return;
      
      stdout = stdout ? stdout.replace("\\n", "") : "";
      resolve(stdout);
    );
  );

获取到线上的最新版本号和脚手架本地的版本号之后,需要将 2 个版本号进行对比:

  • 线上最新版本号大于脚手架最新版本号,提示用户可以进行更新,让用户自行决定是否更新

  • 线上最新版本号小于或者等于脚手架最新版本号,进入到下一个步骤

root 账号启动检查和自动降级

这一步主要是为了检查 root 账号,如果用户是通过 root 去创建的文件,其他用户去修改会导致报错。可以通过第三方库root-check去检查

检查 node 版本号是否符合要求

如果脚手架中使用到了一些高版本的新特性,就需要检查node的版本号是否符合要求。node的版本号可以通过process.version获取,然后跟我们所需要的最低node版本号进行对比,如果不符合要求,就需要退出程序了

检查用户主目录是否存在

因为有些命令需要使用本地存储的(如 init 命令需要存储模板文件),所以需要检查一下是否存在主目录,不存在就需要退出程序了。

可以通过第三方库userhome获取用户的主目录路径,然后使用fs模块判断路径是否存在

加载环境变量

这一步主要是加载一些全局的变量,方便在其他地方使用

比如,初始化本地存储目录路径:

process.env.CLI_HOME_PATH = path.join(userHomePath, ".xxx");

注册命令

通过第三方库commander注册命令,代码如下:

const  Command  = require("commander");
const pck = require("../../package.json");
const init = require("@xxx/init");

function registerCommander() 
  const program = new Command();
  program
    .name(Object.keys(pck.bin)[0])
    // 用法
    .usage("<command> [options]")
    // 版本号
    .version(pck.version)
    // 参数
    .option("-d,--debug", "是否开启调试模式", false);

  // 注册init命令
  program
    .command("init <packageName>")
    .description("初始化模板")
    .option("-f,--force", "是否强制初始化项目", false)
    .action(init);

  program.parse(process.argv);

  if (program.args && program.args.length < 1) 
    // program.args是上面已经声明的参数
    // 没有参数的情况,也就是直接输入 imc-cli
    program.outputHelp();
  

initpublishclear等命令就是在这里进行注册的

其他

1、在检查脚手架是否有更新检查 node 版本号是否符合要求步骤中,需要对比 2 个版本号之间的大小关系,可以通过semver这个第三方库去进行对比

2、日志打印建议不要使用console.log,可以使用第三方库npmlog打印日志。并且在用户开启debug模式的情况下设置日志等级,代码如下:

cli init temp --debug
const log = require("npmlog");

// 监听参数
program.on("option:debug", () => 
  const options = program.opts();
  // 改变日志模式
  if (options.debug) 
    process.env.LOG_LEVEL = "verbose";
   else 
    process.env.LOG_LEVEL = "info";
  
  //   设置日志等级
  log.level = process.env.LOG_LEVEL;
);

一个好的脚手架必须要有一个好的日志输出,这样子用户才知道脚手架在干什么

init 命令执行流程

init 命令的执行流程可以分为两步,第一步是模板的下载和更新,第二步是生成项目模板

模板的下载和更新

整体流程图如下:

检查模板缓存目录是否存在

这一步主要是确保模板缓存目录是存在的,不存在就需要进行创建,防止后面因为缓存目录不存在而报错

// 缓存目录
this.cacheDir = path.resolve(process.env.CLI_HOME_PATH, "template-cache");
if (this.cacheDir && !pathExists(this.cacheDir)) 
  fse.mkdirpSync(this.cacheDir);

更新或者安装模板

  • 首先判断是否有缓存,判断是否有缓存是根据缓存目录和npm包名拼接出一个路径,然后检查这个目录中是否存在package.json,不存在说明没缓存,存在说明有缓存

    • 不存在缓存的情况下,直接使用npm i [包名]进行安装下载

    • 存在缓存的情况下,获取模板线上的最新版本(检查脚手架是否有更新章节有说明如何获取),和缓存在本地的版本(package.json文件中的version字段),对比版本号,判断是否需要更新

      • 线上版本号大于本地缓存版本号,表示需要更新,也是通过npm i [包名]@[latestVersion]进行更新

      • 线上版本号等于本地缓存版本号,不需要更新,可以直接使用

      • 线上版本号小于本地缓存版本号,不存在的情况,发布不可能是向下发布的

经过上述的步骤,模板已经可以进行使用了

生成项目模板

整体流程图如下:

检查模板是否存在入口文件

通过检查package.json文件中的main字段判断是否存在入口文件,

  • 存在入口文件,就加载入口文件,并且执行入口文件导出的函数

  • 不存在入口文件,就执行下一个步骤,初始化脚手架默认的模板生成逻辑

初始化脚手架默认的模板生成逻辑

  • 检查当前所在的目录是否为空,这一步的目的是为了防止误删除目录下的文件

    • 非空的情况下

      • 判断命令行中是否存在--force

        • 存在--force,说明是强制生成项目,直接来到下一个步骤

        • 不存在--force,询问用户是否继续创建项目

          • 继续创建,来到下一个步骤

          • 取消创建,退出程序

      • 二次询问是否要清空当前目录下的文件,这一步是为了防止用户进行了误操作,因为删除是比较危险的行为,然后来到下一个步骤

    • 空目录的情况下,来到下一个步骤

  • 通过命令行交互的方式获取一些项目的信息,比如项目名称,版本号等等。可以通过inquirer第三方库实现命令行交互。

  • 将模板文件拷贝到当前目录下

  • 使用ejs遍历渲染所有模板文件

  • 执行npm i 命令安装依赖

到此,项目的模板已经生成完毕了,这一步主要做的是通过命令行交互的方式跟用户进行互动,获取所需要的信息,然后通过ejs填充到模板文件中

总结

其实实现一个脚手架并不是很难,难点就在于怎么设计一个好用的,可以满足大部分人需要的脚手架。比方说缓存模板,可以减少项目模板的生成时间,如果每次从网络上面去下载,或者网络不好的情况下,会消耗大量的等待时间。

以上是关于开发一个高性能可扩展的脚手架的主要内容,如果未能解决你的问题,请参考以下文章

express 脚手架安装及使用

前端核心工具:yarnnpmcnpm三者如何优雅的在一起使用 ?

Laravel框架搭建的管理系统脚手架

ThinkPhp6框架搭建的后台管理系统脚手架

Laravel9框架搭建的后台管理系统脚手架

干货 | 如何扩展 Create React App 的 Webpack 配置