前端底层构建工具重构之路

程序员成长指北

共 22568字,需浏览 46分钟

 ·

2021-12-09 17:25

大厂技术  高级前端  Node进阶

点击上方 程序员成长指北,关注公众号

回复1,加入高级Node交流群


 享一篇构建工具相关文章,本文的重构思考构建流程值得思考和学习,流程中的每一个步骤自己深挖会对自己的前端工程化知识有较好的成长,希望由所帮助。新。


导语 | IMFLOW 是 IMWeb 团队的一款集成了 Webpack 最佳实践的工程化工具,涵盖了初始化、创建模版、开发规范、编译和构建整个工作流程:从个体角度屏蔽了繁琐的构建配置,帮助新人快速上手;从团队角度讲收拢了整个研发团队的开发规范,化零为整,为团队工作流保驾护航。IMFLOW 在经历一次次演进后背负着沉重的历史包袱,本文重点讲述了在这个关头我们是如何设计和重构这个关键工具的。无论你是想学习如何重构一个大型轮子,如何设计一个前端构建工具亦或如何权衡规范限制和灵活配置,本文都或多或少能为你提供一点灵感。

1. 升级背景

IMFLOW 这一名词在 IMWEB 团队内有两个概念:一个是工具向的,指集成了 Webpack 最佳实践的工程化工具,功能上讲涵盖了一个 Web 前端项目的全流程:初始化、创建模版、开发规范、编译和构建;另一个是生态向的,指整个团队各方向业务(Web、SCF、LIB 等)的底层工程化生态,为各种各样的使用场景提供统一的工作流。

以任何一个身份而言,IMFLOW 从功能和体验上已经实现了它原本的使命,且接入并持续支持了团队很多的项目,然而从长远角度看,IMFLOW 想要成为一个家喻户晓的轮子仍有很多欠缺:

  • 从工具的角度出发,IMFLOW 作为一个工程化工具承担了太多,它是脚手架又不仅仅是脚手架,支持插件系统但是又和业务强绑定,整体需要加载的内容较多,单单从键入一个 imf create 指令到真正执行到 create 逻辑就需要 3s 左右,最近加入了对 Webpack 5 的支持,各个项目的升级情况不一,目前是 alpha 版本支持而正式版本不支持,这种版本管理模式也是不科学的。

  • 从生态的角度出发,目前已经成熟的 IMF、IMFS、IMFL 各自服务的业务类型不同,但实际上的 CORE 部分是重合的(如下图),核心维护者的一些改动往往需要同步到多个工具的同一模块中,不耦合的部分是重复工作,如果遇到部分和业务强耦合的模块还要做对应修改,后续将进入排期开发的 Mini Program、IMServer 等工程化工具需要编写大量重复内容;用户的使用逻辑和体验往往是相近的,同样是通过 create 创建模版,同样是使用 dev 进行本地编译,用户需要安装、学习和使用多套指令,书写多个配置文件,接入成本和学习成本都是不言而喻的。

2. 升级目标

  1. 解耦解耦再解耦,将流程代码(上图的 CORE 部分)和业务代码分离,IMFLOW 重构为 IMFLOW-CORE。

  2. 极致插件化,各构建工具重构为插件式的构建套件,通过 CORE 安装和加载,同时支持其他插件生态。

  3. 轻量化,压缩各构建套件的体积,提升启动时间到 1s 以内。

  4. 收归配置与依赖,各构建套件使用统一的配置规范、配置文件、依赖管理和环境判断逻辑。

  5. 极致封装,规范化暴露给插件(构建套件)的能力,所有可用 API 统一由 CORE 管理,封装数据上报、配置访问和指令注册等流程。

  6. 拥抱社区,兼容 FEFlow 生态中的插件,通过 IMFLOW 加载和管理,拥有相同的使用体验。

  7. 向下兼容,通过尽可能小的配置改动即可完成版本更新,降低用户的更新成本;尽可能小的修改既有插件,完成对新架构的支持。

3. 重构分析

3.1. 重构模式

业界常用的复杂系统重构方法有三种模式:拆迁者模式、绞杀者模式和修缮者模式三种,首先结合业务分析一下各个模式的利弊。

3.1.1. 拆迁者模式

拆迁者模式字如其名,指完全实现一个新系统,直接替代旧系统,在新系统开发和替换周期内,两套系统并行,直到新系统完全替代旧系统之后再下线旧系统。

特点:完全重构一个新系统,用于替代旧系统。

优点:

  1. 新系统与旧系统完全分离,彻底解决历史包袱。

缺点:

  1. 系统替换系统存在风险,容易遗漏或不兼容。

  2. 替换期间需要维护两套系统,人力成本较大。

3.1.2. 绞杀者模式

绞杀者模式指新需求出现的时候,新建一个模块替代旧模块,依此反复直到重构完成所有的模块。

特点:在遗留系统之外构建新服务,并使用新服务,保持原有的遗留系统不变

优点:

  1. 能完整还原新需求。

  2. 系统可稳定提供价值。

缺点:

  1. 重构由需求推动,风险不可控。

  2. 迭代成本依耦合程度和需求量决定,容易退化成拆迁者模式。

3.1.3. 修缮者模式

修缮者模式是将系统内部隔离出部分,通常是需要修缮的部分,对这部分进行抽象、重构和替换

特点:修缮者模式改造只会在系统的内部,不改变系统的外在表现

优点:

  1. 能完整还原原油需求。

  2. 系统外部无感知。

  3. 重构与业务需求随时可切换支持

缺点:

  1. 重构时间跨度大

  2. 技术上迭代成本高

3.1.4. 重构模式选型

在了解三种重构模式之后,来看看 IMFLOW 的业务特点:

  • 重构后外部体验必然发生变化,原本的 IMFLOW-LIB 和 IMFLOW-SCF 都将收拢进 IMFLOW。

  • 各构建工具和业务强绑定,很难在服务内部进行重构。

  • 一些旧业务比起新架构更需要稳定维护,没有升级必要。

  • 重构后架构和旧架构不是一一映射关系,很难抽离模块进行单独重构。

  • 很多业务的 CI/CD 流程已经稳定,全部重构需要大量测试。

综合三种重构模式和 IMFLOW 本身业务特点,比较合适的是拆迁者模式,我们可以完全抛弃历史包袱专注于新架构的设计上,且一些既有项目本身并不需要版本迭代,旧架构已经可以满足这类项目的需求。但上文也有提到一些项目的 CI/CD 产物是直接影响现网的,对于这一部分我们希望他保持稳定,且从功能本身也没有重构的必要,他们只是新架构中的一个功能模块,只涉及很少的核心逻辑,因而对于这一部分我们采用修缮者模式的思想,总结而言我采取一种底层拆迁上层修缮的方式完成这次架构升级。

3.2. 重构内容

如升级目标所述,我们需要将既有的几个构建工具 IMFLOW、IMFLOW-SCF 和 IMFLOW-LIB 整合为一套系统,将各个构建工具重构为构建套件,每个构建套件有业务强相关的构建相关指令如 createdev 和 build 等等,各个构建套件以插件的逻辑加载到新架构 IMFLOW-CORE 中,如下图:

这部分内容比较好理解,下文我想用逼人空想出的哪吒模型通俗的讲解新架构重构:整个重构可以想象成把三个泥人捏成一个哪吒,共用一套脏器,其余的部分都是可插拔的,那么这又引出了一个问题,头部(构建套件)和脏器(CORE 中的各个模块)都有了,胳膊腿是什么呢?这里抛出我们的插件系统,在各个构建工具设计之初就是支持插件的,一般分为两种插件类型:

  • template 模版类,如 react- template,可以理解为脚手架,主要调用了 yeoman-generator 和 inquirer,根据用户个性化需求创建模版项目。

  • 编译加持类,如 postcss,这种插件往往和业务(项目)强绑定,不是普适的,可能某个 Web 业务 A 安装了,但 Web 业务 B 没有。

在新架构中,插件类型显然没有这么简单,但我们也不想把他们搞得过为复杂,希望在规范和使用体验之间权衡,保证插件的开发者具备足够的操作空间又能尽可能压低用户的学习成本,总结出新构架中有如下两类插件

  • 业务强绑定插件,某个插件的功能固然可以和业务强绑定,他们可以根据自己的功能特点设置一个白名单以罗列它支持的所有构建套件。

  • 业务无关插件(通用插件),该类插件往往是支持整个工作流的,如 feflow-codecc 插件,这种插件对任何一种构建套件都适用。

稍微想一想我们会发现,对于以上两类插件,他们加载时所使用的上下文是不同的,前者需要使用对应构建套件的方法,而后者只需要使用一个相对 “松” 的上下文。从另一个角度理解,同样是胳膊腿,有的是靠某一颗或几颗脑袋(构建套件)控制的,而有的是靠心脏(或者说是心脏创造的一颗假想头)控制的。

截止到这我们其实已经明确了将几个小泥人重构成一个哪吒所需要的工作(对应重构目标):

  1. 脏器:一套,新架构核心 CORE,提炼公共核心模块,封装对外暴露的各种方法,收拢配置和依赖。

  2. 脑袋:多个,构建套件 BuildKit,专注解决自己所要解决的业务方向,实现 create、dev、build 等关键指令,并暴露一个上下文供支持他的胳膊腿消费。

  3. 胳膊腿:多个,插件 Plugin,专注实现自己需要拓展的能力,若为某些个特定的脑袋服务,注明后即可按照对应上下分编写逻辑,否则即为全体脑袋服务。

4. 名词解释

在正式开启正文之前,我们有必要对一些名词做必要的解释,防止歧义和迷惑:

名词解释
CORE新架构的核心模块集群,包含了新架构的全部核心功能
BuildKit构建套件,既有的构建工具(IMFLOW、IMFLOW-LIB、IMFLOW-SCF)重构产物,被 CORE 管理和加载,功能和重构前几乎一致
Plugin插件,包含通用插件和业务绑定插件两种类型,前者被 CORE 消费,用于拓展通用能力;后者被 BuildKit 消费,用于拓展构建能力
IMFLOW结合上下文理解,一般旧架构中的 Web 构建最佳实践工具,简称 IMF,重构为 BuildKit 后更名为 BuildKit-Web
IMFLOW-SCF旧架构中的云函数构建最佳实践工具,简称 IMFS,重构为 BuildKit 后更名为 BuildKit-SCF
IMFLOW-LIB旧架构中的基础公用库构建工具,简称 IMFL,重构为 BuildKit 后更名为 BuildKit-LIB
FEFLOW腾讯 OTeam 创建的致力于提升研发效率和规范的工程化解决方案,具备一定的社区规模
Commander.jsNode.js 中优秀的命令行管理工具,具体的使用方法可以参考往期文章玩转 Commander.js— 人人都是命令行工具大师

如果读者有发现我没阐述清楚的概念可以评论区写一下我增加到上述表格。

5. 旧架构分析(IMF & IMFS & IMFL)

5.1. 架构图

IMF、IMFS 和 IMFL 的整体构架均如下图所示,其中 IMFS 和 IMFL 由于业务生态体积较小暂没有插件生态,但是从代码架构上是存在插件加载的逻辑的,只不过是以本地方式加载的,没有提供安装远程插件的方法;另外在核心指令的部分也存在部分差异,比如 IMFL 存在 publish 指令,IMFS 存在 deploy 指令,但是不影响核心模块的公共特性。总体来看,架构以 CORE 部分为主要支持,同时支持脚手架和插件系统,可以消费 CORE 的上下文来拓展功能。

5.2. 架构异同对比分析

通过源码阅读和比对,总结 CORE 部分有以下几个需要特别关注的点:

  • 三者的配置模块各不相同,IMFLOW 读取的是 imflow.config.js 而 IMFS 读取的是 imflow-scf.config.js,且配置的书写方式不相同;

    // IMFS
    fs.existsSync(
    path.join(this.options.baseDir, "../..", "imflow-scf.config.js")
    )
    // IMFL
    const res = loadConfig.loadSync({
    files: ["imflow-lib.config.js", "package.json"],
    cwd: baseDir,
    packageKey: "imflow-lib"
    });
    // IMF
    const res = loadConfig.loadSync({
    files:
    typeof configFile === "string"
    ? [configFile]
    : ["imflow.config.js", "imtrc.js", "package.json"],
    cwd: baseDir,
    packageKey: "imflow"
    });
  • 三者的指令注册方法在底层是相同的,但是只是使用 Commander.js 做了一层最基本的封装,由各自根据内嵌指令类型封装了 RegisterXXXCommandType 方法,供插件和内嵌指令消费;

    // 底层注册方法,基于 Commander.js
    public registerCommand(command: string): Command {
    returnthis.cli.command(command);
    }
    // 顶层封装的内嵌指令类型数组
    public registerCreateType(
    type: string,
    description: string,
    action: (options: any) =>void
    ): ImflowSCF {
    this.createCommandTypes.push({ type, description, action });
    returnthis;
    }
  • 三者的上报策略不尽相同,三者对于报错上报的逻辑基本一致,但 IMFS 直接对几个内嵌指令做了执行时上报,而 IMF 没有这部分上报逻辑;

    // IMFS 对内嵌指令执行时上报的逻辑
    program.on("command:create", () => {
    reportCommand("create");
    });
    program.on("command:build", () => {
    reportCommand("build");
    });
    program.on("command:deploy", () => {
    reportCommand("deploy");
    });
  • 三者的插件加载逻辑基本一致,合并内嵌指令和配置文件中声明的插件为统一列表,统一规范化和加载;

    private applyPlugins() {
    const buildInPlugins = [
    ...
    ];
    const { baseDir } = this.options;
    const globalPlugins = getConfig("plugins") || [];
    try {
    this.initPlugins(
    loadPlugins(globalPlugins, IMFLOW_ROOT_MODULES_PATH),
    "global"
    );
    this.initPlugins(loadPlugins(buildInPlugins, baseDir), "build-in");
    this.initPlugins(loadPlugins(this.config.plugins || [], baseDir), "custom");
    } catch (e) {
    ...
    }
    }
  • 三者的上下文内容相对统一,初始化逻辑也相近,方便后续做上下文收归

    export default class ImflowSCF {
    public logger: any = logger;
    public options: ImflowLibOptions;
    public cli = program;
    public funcsPath: string;
    public projectConfigPath: string;
    public plugins: any[] = [];
    public func?: string = undefined;
    private pluginsSet = newSet<string>();
    private createCommandTypes: Array = [];
    private scfCommandTypes: Array = [];
    private apiCommandTypes: Array = [];
    ...
    }

5.3. 启动时间分析

5.3.1. 整体模块加载

一次性在文件头部使用 import 将所有模块进行引入,nodejs 的单线程在 require 时会被阻塞,造成不必要的时间消耗

// IMF /src/index.ts 头部
import loadConfig from"@tencent/imflow-cli-utils/load-config";
import { logger, spinner } from"@tencent/imflow-common";
import program, { Command } from"commander";
import fs from"fs-extra";
import inquirer from"inquirer";
import lodash from"lodash";
import path from"path";
import resolveFrom from"resolve-from";
import webpack from"webpack";
import WebpackChain from"webpack-chain";

5.3.2. 更新检查

更新检查主要涵盖三个步骤:

  1. 获取 npm /tnpm:使用脚本(command -V npm info)判断使用 npm /tnpm

  2. 获取版本信息:使用 npm info @tencent/imflow 得到最新的 IMFLOW 版本号,对比检查

  3. 执行更新:npm / tnpm i @tencent/imflow

存在一个问题是更新检查目前在每一次输入命令都会执行,整个过程持续 1.5 ~ 2.5s 极大影响了命令执行的速度。同时更新检查并不是实时性要求非常高的需求,imflow 更新也并不频繁。

5.3.3. 业务模块 / 插件没有全量加载

旧架构中的插件是一次性初始化加载的,没有按需加载,实际执行某一个特定指令的时候往往只需要加载部分插件

private applyPlugins() {
const buildInPlugins = [
[require("./plugins/command-create"), []],
require("./plugins/config-base"),
require("./plugins/config-dev"),
require("./plugins/config-html"),
require("./plugins/config-ssr"),
require("./plugins/command-build"),
require("./plugins/command-dev"),
require("./plugins/command-test"),
require("./plugins/command-utils"),
require("./plugins/command-add"),
require("./plugins/command-upgrade"),
require("./plugins/command-plugin"),
require("./plugins/command-config")
];
const { baseDir } = this.options;
this.initPlugins(loadPlugins(buildInPlugins, baseDir), "build-in");
...
}

6. 架构设计

6.1. 架构图

整体的构架图如下图所示,首先将各个模块设置为黑盒,关注模块划分和整体设计,总体分为四个部分:

  • CLI 交互层:对用户的命令行输入进行处理,作为 CORE 初始化的入参。

  • FILE 物理文件层:分为 Global 和 Local 两个存储维度,前者管理用户的全局配置和依赖,后者管理工程内配置和依赖。

  • CORE 核心逻辑层:流程模块集群,包含 PkgManager 包管理模块、Commander 指令管理模块、ConfigSys 配置管理模块和 PluginSys 插件管理模块。

  • Market 插件生态:包含业务绑定插件、通用插件和构建套件三种类型,三者均按照 IMFLOW 插件开发规范编写,由 PluginSys 统一加载和管理。

再打开黑盒看一下细致的架构拆分,每个模块内罗列了对应模块的核心功能 / 子模块。对比旧架构可以看到我们收拢了 CORE 的能力,泛化了一些具有普适性的能力,并极大丰富了插件 / 套件生态的内容,拓展了多种的插件类型,从整体结构上比较类似 FEFLOW 的架构,但是整个底层的实现逻辑和顶层的插件协议还是有很大差别的,后面会专门出一篇文章对比 IMFLOW,FEFLOW 和 VUE-CLI 架构。

6.2. 目录结构设计

.
├── bin
│ └── cli.ts ------------------- // 入口文件,实例化 imflowConfig 和 imflow
├── command ----------------------- // 命令行模块,注册 imflow 内嵌指令
│ ├── buildKit.ts -------------- // 注册构建套件的CRUD指令
│ ├── index.ts
│ └── plugin.ts ---------------- // 注册插件的CRUD指令
├── declarations.d.ts ------------- // declare 模块和命名空间
├── index.ts ---------------------- // IMFLOW 类
├── interface.d.ts ---------------- // 类型声明文件
├── modules ----------------------- // 核心模块文件夹
│ ├── configModule ------------- // 配置加载模块
│ │ └── index.ts
│ ├── packageManager ----------- // 包管理模块
│ │ ├── index.ts
│ │ └── load-pkg.ts
│ └── pluginModule ------------- // 插件加载器模块
│ ├── buildKitInterface.ts - // buildKit 接口
│ ├── feflowInterface.ts --- // feflow 插件接口
│ ├── imflowInterface.ts --- // imflow 插件接口
│ └── index.ts ------------- // 插件实例化与加载模块
└── utils
├── check-update.ts ----------- // 更新检查模块
├── cli-utils.ts -------------- // 环境工具检查
├── common.ts ----------------- // 通用工具函数
├── executeCommand.ts --------- // 指令执行工具
└── imlog.ts ------------------ // 日志模块

7. 核心模块

7.1. CLI 命令行模块

基本的 Option (--global, --debug, etc.) 注册,然后初始化一个 IMFLOW-CORE 实例,执行 Run,然后自定义帮助信息并执行 Parser。IMFLOW-CORE 目前支持主命令四种 Option:

program.option("--no-check-latest", "不检测最新版本", false);
program.option("--debug", "输出调试信息", false);
program.option("-k,--build-kit ", "指定BuildKit");
program.option("-g,--global", "全局使用", false);
  • noCheckLatest 会跳过 CORE 的版本更新检查。

  • debug 用于输出调试信息,在出现 Bug 时可以通过这一选项给开发者提供定位信息。

  • buildKit 用于指定要加载的构建套件,CORE 内置了交互友好的自动加载逻辑,这一选项的使用场景一般在工程内有多个 buildKit 时才需要指定按照特定的构建套件执行命令,比如某个同时存在 Web 和 SCF 的仓库。

  • global 强制按照按照全局环境执行命令,这也是 CORE 唯一一种可以打破环境隔离的操作,一般用于在工程内安装全局插件 / 套件,插件开发者书写使用文档时推荐用这种方式安装全局插件,防止使用者误将。

在以上四个以外还有 Commander.js 内置的 -V-h 两个选项用于输出版本号和帮助信息。

7.2. PkgManager 包管理模块

全局和工程内依赖的管理者,对外暴露一些简单易用的 API 对依赖进行 CRUD,还包含一个 Version Check 的版本更新模块,用于定时异步检查 IMFLOW-CORE 的版本更新。

外部在调用包管理模块的方法时,所有的逻辑都在模块中封装好了,只需要传包名,不需要关注用户使用了哪种包管理工具和执行环境:

// 安装
await context.pkgManager.add(plugin);
// 删除
await context.pkgManager.remove(plugin);

7.3. ConfigSystem 配置模块

该模块是 IMFLOW-CORE 实例构建器的入参之一。实现了以下几个核心功能:

  • 实例化前配置侧的错误拦截,提早暴露风险,确保实例化的 CORE 是充分可运行的。

  • 对外暴露配置读写 API 供其他模块消费,重写类 getter 缓存化读取。

  • 环境判断与隔离,确保根据 CLI 模块传达的命令行参数可以返回正确的配置项。

const imflowConfig = await Config.build(cookedOptions);
// 如果有配置异常在IMFLOW-CORE实例化之前就会抛出错误并结束进程
const app = new IMFlowX(cookedOptions, imflowConfig, program);

全局配置文件示例如下:

{
"buildKits": [ // 数组,每个元素是一个buildkit对象
{
"name": "@tencent/imflow-buildkit-web", // buildkit名称
"installed": true, // 是否已经安装
"plugins": [ // 该buildkit安装的业务绑定插件
"@tencent/imflow-plugin-react-template"
]
},
{
"name": "@tencent/imflow-buildkit-scf",
"plugins": [],
"installed": false
}
],
"lastUpdateDate": 1632455485712, // 上次更新时间
"plugins": [ // 通用插件列表
"@tencent/feflow-plugin-codecc"
],
"activeBuildKit": "@tencent/imflow-buildkit-web"// 当前激活的buildkit
}

工程内配置文件示例如下:

{
plugins:['@tencent/imflow-plugin-postcss', '@tencent/feflow-plugin-codecc'],
web:{ // 这里key的名字和buildkit的后缀名保持一致,在启动该buildkit时会对应加载配置
...
}
}

配置模块会将上面的两份配置文件和对应环境的 packag.json 按如下方式在 Config 的构建器中读取,并通过各种 API 暴露读写能力:

class Config {
...
constructor(options: IMFlowXOptions) {
// 入参透传
this.options = options;
// 初始化相关路径
this.localConfigPath = path.resolve(options.baseDir, "imflow.config.js");
this.localPkgPath = path.resolve(options.baseDir, "package.json");
this.globalPkgPath = path.resolve(IMFLOW_CONFIG_ROOT_PATH, "package.json");
this.globalConfigPath = configPath;
// 读取配置
const { resolveConfig } = Config;
this.localConfig = resolveConfig(this.localConfigPath);
this.localPkg = resolveConfig(this.localPkgPath);
this.globalPkg = resolveConfig(this.globalPkgPath);
this.globalConfig = resolveConfig(this.globalConfigPath);
// 初始化相关数据
this.getterCache = newMap(); // getter的缓存数据结构
this.hasInstalledGlobalBuildKit = this.hasBuildKits(); // 判断全局是否安装了任何一个buildkit
this.buildKit = this.initBuildKit(options); // 初始化buildkit
}
...
}

配置模块对于一些用户或其他模块可能访问的计算属性实现了类中 getter 的计算缓存策略,这样设计的目的是为了压缩类的体积,减少重复计算:

privategetFromCache(key: string, init: Function, listener?: Array<any>) {
// TODO listener实现变量监听
if (!this.getterCache.has(key)) {
const value = init();
this.getterCache.set(key, value);
}
returnthis.getterCache.get(key);
}

如在插件加载模块需要获取到当前有哪些插件是需要加载的,则可以通过 context.config.globalPlugins(假设全局)获取:

getglobalPlugins() {
returnthis.getFromCache("globalPlugins", () => {
const { activeBuildKit } = this.globalConfig;
let plugins: Array<string> = [];
if (this.hasInstalledGlobalBuildKit) {
this.globalConfig.buildKits.forEach(item => {
if (item.name === activeBuildKit) plugins.push(...item.plugins);
});
}
plugins.push(...(this.globalConfig.plugins || []));
return plugins;
});
}

考虑到当前的各种使用场景下,不存在写入配之后再读取的情况(一般比如安装插件,写入配置文件之后进程也就结束了),还没有实现监听变量变更的功能,后续如果出现相关需要可以补全这部分功能,参照 VUE 中的计算属性。具体的配置模块初始化和错误拦截机制详见第 8 节。

7.4. Commander 指令模块

包含了全部的 CORE 内嵌指令 plugin 和 buildkit,实现了相关内嵌指令的注册和监控。

以下两个指令均可以加入 -g 选项强制在全局环境执行

7.3.1. plugin 指令

提供了 list 、add 和 delete 三个子命令,分别用于罗列当前安装了的插件、增加插件和删除插件。其中 add 指令要求用户必须键入安装插件的包名,而 delete 指令不强制,如果没有输入会进入命令行界面让用户选择要删除的插件。关于插件的加载机制和管理逻辑详见第 8 节。

7.3.2. buildkit 指令

提供了 list 、add 、delete 和 switch 四个子命令,分别用于罗列当前安装了的构建套件、安装套件、删除套件和切换当前激活的全局套件。其中 list 支持加入 -a 选项同时输出安装在各 buildKit 下的插件。switch 支持在全局切换激活的 buildKit。delete 和 add 的使用体验和 plugin 的子命令一致。关于构建套件的加载机制和管理逻辑详见第 8 节。

7.5. PluginSystem 插件模块

插件模块是链接 CORE 和插件 / 套件生态的关键铰链,它提供了以下几个关键功能:

7.5.1. 上下文

为各类插件 / 套件封装了加载时上下文,提供插件 / 套件开发规范。

7.5.2. 加载器

插件 / 套件的加载器,个性化加载构建套件、通用 IMFLOW 插件、通用 FEFLOW 插件和业务绑定插件,供 CORE 的上下文消费。插件模块会依次加载当前激活的 BuildKit 以及对应的插件和全局通用插件。

如果在全局,则加载的是激活的 BuildKit + 该 BuildKit 在全局安装的插件 + 全局通用插件

如果在工程内,则加载的是工程内安装的 BuildKit(一般唯一)+ 工程内配置文件声明了的插件 + 全局通用插件

以上的逻辑都都是封装到加载器内部的,对于使用者而言,闭眼睛加载就完事了。

// imflow/index.ts
...
privateloadAll() {
this.loadBuildInCommands();
if (!this.config.buildKit) return;
this.loadBuildKit();
this.loadPlugins();
}
...

7.5.3. 指令注册封装

封装指令注册方法,包括重复指令管理,指令注册监控上报等功能。

// 封装指令注册
public registerCommand(command: string): Commander {
// command是一个如 create
[type] 的字符串,从中获取到命令 create 赋值给subCommand
const subCommand = command.split(" ")[0];
this.cli.on(`command:${subCommand}`, msg => {
logger.debug(`执行指令${subCommand}`, msg);
// TODO 上报
});
logger.debug(`正在注册指令${command}`);
if (this.commandMap.has(subCommand)) {
// 重复指令因为插件中的 commander 链式调用,直接返回其它类型会报错,所以再实例化一个 commander 作为子程序分离出去
logger.warn(
`${
this.activePlugin
}
正在注册的指令 ${subCommand} 已经被 ${this.commandMap.get(
subCommand
)}
注册过,跳过`

);
const tempProgram = new Commander();
return tempProgram.command(command);
}
this.commandMap.set(subCommand, this.activePlugin);
returnthis.cli.command(command); // 通过 Commander.js 注册指令
}

7.6. Market 插件 / 套件生态

包含业务绑定插件(下图左上角两个 plugins)、通用插件(下图右侧的 General Plugins)和构建套件(下图下方的 BuildKits)三种类型,三者均按照 IMFLOW 插件开发规范编写,由 PluginSys 统一加载和管理。

他们的逻辑关系和配置关系是完全对应的,以上图为例,@tencent/imflow-buildkit-web 包含了四个子插件 react-templatehulk-templatepostcss 和 oci-generator,而 BuildKit @tencent/imflow-buildkit-scf 则只包含了两个,全局通用插件则安装了两个 @tencent/imflow-plugin-cache-cors 和 @tencent/feflow-plugin-codecc。加载时以激活的是 @tencent/imflow-buildkit-web 为例,则加载的是这个 BuildKit + 4 个子插件 + 2 个全局通用插件。这些套件和插件的逻辑是完全可以从配置文件上找到踪迹的,如下是以上逻辑关系在配置中的体现。

{
"buildKits": [ // 数组,每个元素是一个buildkit对象
{
"name": "@tencent/imflow-buildkit-web",
"installed": true,
"plugins": [ // 四个子插件
"@tencent/imflow-plugin-react-template",
"@tencent/imflow-plugin-hulk-template",
"@tencent/imflow-plugin-postcss",
"@tencent/imflow-plugin-oci-generator"
]
},
{
"name": "@tencent/imflow-buildkit-scf",
"plugins": [ // 两个子插件
"@tencent/imflow-plugin-oci-generator",
"@tencent/imflow-plugin-standard-template"
],
"installed": true
}
],
"lastUpdateDate": 1632455485712, // 上次更新时间
"plugins": [ // 两个通用插件
"@tencent/feflow-plugin-codecc",
"@tencent/imflow-plugin-cache-cors"
],
"activeBuildKit": "@tencent/imflow-buildkit-web"// 当前激活的buildkit
}

7.7. IMFLOW-CORE 上下文

有了以上几个子模块的支持,IMFLOW-CORE 的上下文现在非常轻量:

class IMFlowX {
public logger: any = logger;
public options: IMFlowXOptions;
public configSys: ConfigSys;
public pluginSys: PluginSys;
public pkgManager: PackageManager;
...
}

8. 关键机制 & 逻辑

8.1. 配置模块初始化机制

配置模块是整个流程中最优先初始化的模块,也是 IMFLOW-CORE 实例化的入参之一,在这一模块我设计了高优先级的异常拦截,来确保 IMFLOW-CORE 实例是配置上绝对可执行的。

配置上绝对可运行是指工程 / 全局的配置符合新构架的协议规范。从代码上来讲,即加载器模块的出参作为后续模块的入参是充分可访问的,不会存在意外的 undefined 或者数据类型不对齐的情况。比如构架升级之后,工程内有配置文件 imflow.config.js,但是其中没有 buildKit 字段声明要启动的构建套件,且在 package.json 中也没有声明任何一个构建套件依赖,这种情况从配置上来讲项目不可能正常启动,因为包管理模块的入参会收到一个指向不明的构建套件名,不可能正常加载。

这一段逻辑可能相对晦涩,但从结果上讲,经过这一套流程后,所有配置上的边缘逻辑已被报错拦截,逻辑流程将返回一个正常可加载的 BuildKit。这里还要特殊的考虑一下 create 指令,create 指令并不是 IMFLOW-CORE 的内嵌指令,而是各个 BuildKit 都会注册的一条初始化模版指令,用于创建一个新项目。从用户体验上讲,正常来说是先有 BuildKit 再在该 BuildKit 环境下执行相应指令,而 create 指令不同,用户在创建时往往不会固定一个业务类型,他可能创建一个 Web 项目,也可能创建一个 SCF 项目,我们希望给用户逻辑和使用对应的体验,因此需要用户先选择 BuildKit 再初始化配置系统。可正如上文所述,在配置系统实例化之前,我们是完全无法访问到 IMFLOW 配置的,我们希望将这个异步的选择流程加入到配置模块初始化的方法中,因为类的初始化是不可以有异步方法的,这里我使用了静态方法用函数式的方法异步创建一个配置模块示例:

// 通过静态方法实例化
staticasyncbuild(options: IMFlowXOptions) {
const cookedOptions = Object.assign({}, options);
if (isCreating(cookedOptions.cliArgs)) {
const inquirer = require("inquirer");
const buildKitsPrompt = {
type: "list",
name: "activeBuildKit",
message: "选择要创建的项目类型",
choices: this.staticGetBuildKits()
};
const res = await inquirer.prompt(buildKitsPrompt);
// 选择全局buildKit并按照全局执行
cookedOptions.buildKit = res.activeBuildKit;
cookedOptions.global = true;
}
returnnew Config(cookedOptions);
}

虽然有点复杂,但实现了外部使用的统一,符合我们解耦和封装的目标:

const imflowConfig = await Config.build(cookedOptions);

这样我们就实现了当用户执行 create 指令时,先选择 BuildKit,再实例化配置模块。

8.2. 插件加载机制

上文已经说过我们有两种插件类型:通用插件和业务绑定插件。他们有一个更细化的划分如下:

  • 业务绑定的插件

  • 通用插件

    • IMFLOW 生态插件

    • FEFLOW 生态插件

在全局启动 IMFLOW-CORE 的时候,我们会先加载当前全局激活的 BuildKit,再加载该 BuildKit 下安装的插件,即上文的业务绑定插件,最后加载通用插件。

在工程内启动 IMFLOW-CORE 的时候,我们会先加载工程内声明的 BuildKit,再加载工程配置文件声明的插件,最后加载全局通用插件。

可以看出,全局插件是在任何一种环境(全局 / 工程内)下都会加载的插件,这里对于 IMFLOW 和 FEFLOW 的通用插件还有不同的插件加载方法,因为 IMFLOW 生态的插件规范是基于 Commander.js 实现的,使用方法是链式调用,而 FEFLOW 生态的插件使用的是声明式调用,IMFLOW-CORE 提供了供 FEFLOW 插件消费的上下文和指令注册的转换封装:

privateapplyFEFlowPlugin(feflowPluginConstructor: any) {
const feflowInterface = new FEFlowInterface(this.context.config); // 实例化feflowInterface
// 执行插件导出的函数,入参为feflow实例
const commandLineUsage = require("command-line-usage");
feflowPluginConstructor(feflowInterface);
// 获取 feflow 插件的注册信息
const commands = feflowInterface.commander.list();
commands.map(command => {
const usage = commandLineUsage(command.options); // 自定义 help 输出
// 将对象属性嵌入 commander 链式调用
this.registerCommand(command.name)
.description(command.desc asstring)
.allowUnknownOption()
.addHelpText("before", usage)
.action(<any>command.runFn);
});
}

8.3. 插件安装 / 卸载逻辑

上文我们已经知道了插件的类型,想必你会出现一个疑问:我们是先加载构建套件再去获取他需要加载的插件的,而业务绑定的类型又是可以支持多个构建套件的,那么如果插件 plugin-A 支持 buildkit-A 和 buildkit-B,而用户的环境中只安装了 buildkit-A,在安装 plugin-A 的时候或许不会出现什么错误,可是当用户后面再安装 buildkit-B 的时候,plugin-A 是怎么给 buildkit-B 提供支持的呢?

这里我们灵活使用了配置文件,在安装插件的时候,会去扫描插件上的一个静态属性(想想为什么用静态属性?)buildKitWhiteList,这是一个字符串数组,每个元素表示该插件支持的构建套件,我会构建这个白名单和配置文件中已经声明的构建套件之间的映射关系,将插件支持却没有在配置文件中写入的构建套件写入配置文件中:

if (buildKitWhiteList) {
try {
buildKitWhiteList.map((whiteBuildKit: string) => {
let declared = false;
buildKits.map(kit => {
if (kit.name === whiteBuildKit) {
// 待安装组件声明的支持的构建套件已在全局配置中声明
declared = true;
kit.plugins.push(plugin);
}
});
if (!declared) {
// 待安装组件声明的支持的构建套件没有在全局配置中声明过,防止未来安装了该套件不支持组建,安装前预声明
const buildKit2declare: BuildKit = {
name: whiteBuildKit,
plugins: [plugin],
installed: false
};
buildKits.push(buildKit2declare);
}
});
config.setGlobalConfig("buildKits", buildKits);
}

这也是为什么我们的全局配置文件 ~/.imflow/.imflow.config.json 中会出现如下的配置:

{
"buildKits": [
{
"name": "@tencent/imflow-buildkit-web",
"installed": true,
"plugins": [
"@tencent/imflow-plugin-react-template"
]
},
{
"name": "@tencent/imflow-buildkit-scf",
"plugins": [],
"installed": false// 看这里看这里看这里看这里看这里
}
],
"lastUpdateDate": 1632455485712,
"plugins": [
"@tencent/feflow-plugin-codecc"
],
"activeBuildKit": "@tencent/imflow-buildkit-web"
}

对于卸载我们目前的处理方法是删除所有构建套件中对于该插件的声明,后续会通过上报和主动沟通持续收集用户的使用体验看是否需要单独卸载。

8.4. 构建套件安装 / 卸载逻辑

那么有了上文插件的安装 / 卸载逻辑之后,构建套件这里就相对朴素了,在安装的时候会去判断配置文件中是否有声明,有则把 installed 字段置为 true,否则则写入一个新对象。

9. 成果验收

9.1. 启动时间

优化启动时间不是构架升级 1 期的核心目标,但是仅仅优化架构后启动时间就已经得到了质的飞跃,2 期中将针对启动时间做专项优化:

构建工具之前(十次均值)升级后(十次均值)
IMF4.9s0.8s
IMFS6s3.2s

9.2. 目标完成度

  • 解耦解耦再解耦,将流程代码(上图的 CORE 部分)和业务代码分离,IMFLOW 重构为 IMFLOW-CORE。

    ✅,流程代码和业务代码已经完全分离,用当前的 CORE 去接 PYTHON 项目也可以。

  • 极致插件化,各构建工具重构为插件式的构建套件,通过 CORE 安装和加载,同时支持其他插件生态。

    ✅,实现了多套件,多种多类型插件的插拔结构。

  • 轻量化,压缩各构建套件的体积,提升启动时间到 1s 以内。

    ⌛️,SCF 构建套件的配置收拢和依赖加载相对繁杂,2 期中将专项优化。

  • 收归配置与依赖,各构建套件使用统一的配置规范、配置文件、依赖管理和环境判断逻辑。

    ✅,配置和依赖管理模块对各套件、插件完成收拢。

  • 极致封装,规范化暴露给插件(构建套件)的能力,所有可用 API 统一由 CORE 管理,封装数据上报、配置访问和指令注册等流程。

    ✅,所有模块都尽可能时间最高程度的封装,每个模块专注于自己的工作,暴露易用 API 供其他模块消费。

  • 拥抱社区,兼容 FEFlow 生态中的插件,通过 IMFLOW 加载和管理,拥有相同的使用体验。

    ✅,IMFLOW-CORE 结合自己的架构模拟出 FEFLOW 的上下文,完全兼容 FEFLOW 插件生态。

  • 向下兼容,通过尽可能小的配置改动即可完成版本更新,降低用户的更新成本;尽可能小的修改既有插件,完成对新架构的支持。

    ✅,PC 项目已无痛接入并通过验证。

9.3. BETA 版本效果

其他 IMFLOW 生态的构建套件和插件正在逐步迁移。

9.3.1. 帮助信息

下图可见 IMFLOW 对指令和选项都按照首字母顺序排序,且对于所有指令都标注了注册该指令的来源(套件 / 插件)

9.3.2. buildkit 指令

  • list 与 add

  • delete

    在没有指定要删除的 buildkit 时会给出选择

  • switch

9.3.3. plugin 指令

  • list

    • 全局

    • 工程内


  • delete

9.3.4. create 指令

在执行 create 指令的时候会拦截 ConfigSys 的初始化,优先让用户选择启用的 BuildKit 以确保加载到用户想要创建的业务类型,如下图我们可以选择创建 Web 项目或者 SCF 项目。

9.3.5. 插件兼容

下图可见在安装了 @tencent/feflow-plugin-codecc 之后,IMFLOW 可以正常加载并使用该插件:



Node 社群


我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。


   “分享、点赞在看” 支持一波👍

浏览 26
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报