超硬核!教你手搓一套船新架构的前端脚手架~

769 阅读20分钟

开始前先打个广子~

面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:Tongxx_yj

20250310220634

背景

什么是脚手架?

尽管目前社区上已有很多优秀的脚手架工具,手搓脚手架的相关文章也层出不穷,但我今天想要带来的是一个全新架构设计的脚手架:Create-Neat(或称 cn)
Github:github.com/xun082/crea… (同学!点点 Star 呀!)

全新在哪里?我们通过了一套协议架构来保证了插件框架构建工具三个维度的自由组合,打破了指数型复杂度带来的研发壁垒

我将会以这套架构设计为核心,介绍 cn 脚手架的实现,通过这篇文章,你将会了解到:

  1. 基本脚手架的实现方式
  2. 脚手架的技术壁垒
  3. 新型脚手架设计介绍

基本脚手架的实现方式

这一模块我并不会花太多的笔墨,只是讲解一下核心的实现逻辑,在以往的文章中,我也有做过相关的介绍:juejin.cn/post/736578…

基本定义与流程

在介绍基本脚手架的实现方式之前,我们先对核心概念做一个基本定义:

  • 插件:项目生成中可扩展的相关能力

  • 构建工具:大家应该都知道,常见的有 webpack、vite……

  • 框架:也很简单,Vue、React……

  • 生成器:我们将脚手架的逻辑内核称为生成器,这一步骤涉及到核心文件、配置的生成,我们简称为 Generator

  • 预设:用户对预期项目的选项集合

在对齐基本定义之后,我们再来看一个流程图:

image.png

上图就是一个比较传统的脚手架的逻辑实现,核心分为四个阶段:

  1. 命令行交互:与用户“对话”,获取用户的选项
  2. 初始化处理:基于用户选项进行相关的初始化操作,如基本文件的创建选项相关依赖注入
  3. 生成器执行:基于用户选项进行核心文件的生成,比如用户选了 xx 插件,便会在生成器中调用对应的配置方法,将 xx 插件的相关配置、文件注入到目标文件夹
  4. 收尾处理:最后,项目会因为配置的更新注入了新的依赖,而需要再安装一次依赖,与此同时需要我们输出一些总结信息,告知用户相关的结果

生成器逻辑简述

先说核心逻辑:在生成器中,Generator 会基于预设去读取对应插件、构建工具、框架所在的文件路径,并:

  • 调用内部默认导出的一个方法
  • 读取内部的一个 template 文件内容,加工处理后转移至产物文件

再回答几个问题

  1. 对应的文件是什么:假设我们是一个 monorepo 的仓库结构,上述的逻辑都在一个 core 的子仓中,那像插件、构建工具、框架都会被放到一个个独立的子仓中,方便独立管理、发布
  2. 对应的文件有什么用:存放了一些核心配置,分为:
    • template 模版文件夹:存放了独立文件配置,如:.eslintrc、webpack.config.js
    • 默认导出方法:通过执行这个方法,可以执行一些特殊操作,比如给 package.json 做配置更改
  3. 插件、构建工具、框架之间会有影响吗:会!比如我们有一个 babel 插件,我们就需要在 webpack 的配置文件里面注入一些内容
  4. 影响是如何实现的:我们核心是通过一套模版渲染引擎(比如 EJS)实现,比如:
    // apps/webpack/template/webpack.config.js
    <% if (preset.babel === true) { %>
        {
            test: /.(js|jsx)$/,
            exclude: /node_modules/,
            use: {
                loader: 'babel-loader'
            }
        }
    <% } %>
    
    // apps/core/Generator.js
    const ejs = require('ejs');
    const fs = require('fs');
    
    // 读取模板文件
    const template = 
        fs.readFileSync('../webpack/template/webpack.config.js', 'utf8');
    
    // 假设用户选择了 babel
    const preset = {
        babel: true
    };
    
    // 渲染模板
    const webpackConfigJs = ejs.render(template, data); 
    
    

到这一步,全流程的基本实现都已经讲清楚了,,如果对更多的细节感兴趣,可以转战juejin.cn/post/736578… 进行深造~

脚手架的技术壁垒

壁垒在哪里

目前的脚手架存在什么问题?我们就聊一个最严重的:不够灵活

为什么说不够灵活?我们可以看看 vue-cli 都支持什么插件、构建工具以及框架
插件:11 个(pwa、babel、eslint、vuex……)
构建工具:1 个(webpack)
框架:2 个(vue2、vue3)【实际上就算一个框架】
在上一部分中,我们已经看到有些时候三者之间会有影响情况,如果用一个具体的图片来呈现,那将会是这样的一个效果:

image.png

实现效果也很简单,我们还是通过 EJS 一把唆:

  1. webpack 里面配置 EJS 语法实现11个插件、1个框架的内容注入
  2. vue2/3 里面配置 EJS 语法实现11个插件的内容注入

那如果面对这样的情况呢?

image.png

  1. webpack 里面配置 EJS 语法实现11个插件、2个框架的内容注入
  2. vite 里面配置 EJS 语法实现11个插件、2个框架的内容注入
  3. rollup 里面配置 EJS 语法实现11个插件、2个框架的内容注入
  4. vue 里面配置 EJS 语法实现11个插件的内容注入
  5. react 里面配置 EJS 语法实现11个插件的内容注入

我们看看复杂度的比对:

  • 前者:11 + 1 + 11 = 23
  • 后者:11 + 2 + 11 + 2 + 11 + 2 + 11 + 11 = 61

我们把这个指标定义为:
开发的复杂度 A = (插件 + 框架) * 构建工具 + 插件 * 框架
与此同时,我们再定义一个指标:项目的丰富度 B插件 + 框架 + 构建工具

我们比对两个场景的数据差异:

指标前者后者
A2361
B1216

可以看到 B 的差异很小,但是后者的 A 却大了很多很多
我们再来个夸张的,20 个插件 3 个框架 6 个构建工具

  • A = (20 + 3)* 6 + 20 * 3 = 198
  • B = 20 + 3 + 6 = 29

综合得出一个规律:项目丰富度的小幅提升会带来框架的较大幅度提升
结合这个规律,是否能解释目前脚手架的技术壁垒呢?
—— 支持的越多,复杂度就越高(超级难维护!)

事实上,这个规律还是不严谨的。在实际的开发过程中,插件与插件之间甚至也会产生影响,我们还需要给插件分成多类,来实现更好的维护,但这种情况下,整体的复杂度就越来越趋于指数性增长

额外的,这对开发者的需求迭代也是一个恐怖的挑战,比如:小明要让脚手架一个 vite 构建工具,他要面对的则是十几个插件、以及构建工具的影响……

壁垒导致的现状

为了避免这种问题,目前的脚手架生态大部分都局限于一种构建工具、一种框架
当然对于框架,我倒觉得没有什么毛病 —— 大部分提供脚手架的都是对应框架方,也没必要为其他框架服务~
但如果我们想要一个比较理想的脚手架,支持:

  1. 基本支持目前社区上绝大多数的内容
  2. 有多种构建工具选择

这是一个非常困难的事情,那目前社区上有相关实现吗?有的兄弟,有的
那就是 yeoman:相关学习参考 yowebapp.github.io/learning/

他是怎么解决这个问题的呢?

yeoman 提供了一套可以直接让我们定制化开发 Generator 的方法,通过发布模板至 npm 以调用
虽然完美解决了上述的问题,但最大的问题就是研发成本很高
那我们有没有办法解决这个研发成本问题呢?这就要谈及我们的新架构设计了!

新型脚手架设计介绍

核心目标

我们的核心目标:插件、框架、构建工具的影响,避免排列组合配置的情况出现

想法雏形

我们观察模版引擎渲染的实现,会发现一个规律:
xx 对 yy 的影响,会把影响内容放在 yy 的 template 文件里
举个例子:Babel 对 webpack 的影响,会在 webpack 的 template 文件里注入 EJS 语法

这种情况下,有什么问题?
—— 就算我们想通过一些抽象的方式来实现 webpack、vite 的 Babel 配置统一,我们还是得在两个不同的 template 文件里面注入 EJS 语法
那有什么解决方案呢?
—— 是不是可以把 xx 对 yy 的影响内容配置在 xx 里面,而不是通过模版引擎渲染来实现?

实现思路

基于上述的思路,我们看看怎么实现

目前最核心的,就是实现一套 “xx 影响 yy 内容” 的语法,在插件、框架中配置相关的语法,经调用处理后,形成直接的配置影响,比如:

/**
 * 框架受插件的影响处理
 * @param {object} pluginAffects 插件带来的影响集合
 * @param {string} template 当前框架
 */
const templateConfigGenerator = (pluginAffects, template) => {};

/**
 * 构建工具受框架的影响处理
 * @param {object} templateAffects 框架带来的影响集合
 * @param {string} buildTool 当前构建工具
 */
const buildToolConfigGenerator = (templateAffects, buildTool) => {};

基于这个目标,我们拆解成两个要解决的方向:

  1. xxxAffects 怎么写(或者说如何去描述这个结构体)
  2. 函数内部怎么处理 xxxAffects

抽象协议的概念

这两个问题如同我们规范了一套网络协议:

A 发送网络请求 ——> B 接收网络请求 ——> B 提取请求头 ——> B 基于请求头做 xx 处理

我们也可以把 xxxAffects 的描述和处理定义为一套抽象协议,通过相关的处理方式去解读协议、处理协议,最终对目标文件产生影响

开发这套协议,我们需要对协议的特性来规定统一的认知:

  • 特征:

    • 协议分为协议和协议处理器两部分,对应的 xxxAffectsxxxAffects 的处理函数
    • 协议只在插件、框架中定义
  • 性质:

    • 通用性: 每一个协议都是一种操作的描述,能够适用于不同的插件、框架
    • 抽象性: 我们应该将具体操作的实现细节抽象成配置项,便于扩展和维护
    • 自由性: 面对一些复杂特化的场景,支持灵活的定制行为

初步探索

我们先拿一个场景讲明白,比如插件对框架的影响,我们需要考虑到:

  1. 插件对框架影响什么内容,比如入口文件、 变量注入与消费

  2. 插件的影响应该是抽象的,不能具体基于某个框架做一个配置,对另一个也做一个配置,因为框架是不一样的,语法可能不同,但是影响的主题是一致的协议描述这个抽象的事,协议处理器来处理这个抽象的事

让我们针对插件对 Vue 和 React 的影响进行详细分析,并设计一个抽象协议,确保它能够适应不同框架的特性而不依赖于具体实现,下面是一个简单的示意:

image.png

场景分析

我们结合实际场景,探索一下插件对框架的影响内容:

  1. 入口文件 (import) 影响内容的入口文件配置,比如 less、sass 文件的引入
  2. 内容导出(export): 影响内容的导出,例如注入全局组件、导出高阶组件 HOC
  3. 环境变量注入: 插件可以影响框架的环境变量,特别是在开发和构建工具之间传递信息
  4. 业务代码更改: 插件可以改变框架的业务代码,比如给 react 返回的 jsx 内容包裹一层 <Router></Router>
  5. …… 可能还有更多的协议,但前四条基本能 cover 全部的场景了

抽象协议的结构体设计

我们将设计一个抽象协议,描述插件对框架、构建工具的影响,比如以插件为基本单元

const pluginImpactProtocol = {
    pluginName: "scss", // 插件名称
    version: "xxx", // 插件版本
    affects: {
        template: {
            description: "影响框架入口文件配置",
            changes: [
                entry_file: {
                    description: "入口文件引入全局 scss 文件",
                    content: "import './styles/main.scss'", // 全局样式文件路径
                }
            ],
            priority: 1, // 优先级
        },
        buildTool: {
            description: "影响构建工具配置",
            changes: [
                // ……
            ],
            priority: 2,
        },
    },
};

这个结构体中,描述了 scss 插件对 template(框架)、buildTool(构建工具) 的影响,其中 template 中,我们通过 changes 数组,描述了影响操作:如 entry_file,描述了往入口文件插入一个内容:import './styles/main.scss'

那么 entry_file 就是一个协议,他的结构体描述了影响内容

那我们为什么会以插件为基本单元,通过 changes 数组传入一个个协议呢?

这要回归到我们的最基础设计思路:降低业务复杂度

回归我们的目标:对比 EJS 模版渲染,我们把 A 对 B 的影响,从写在 B 中,转移至了 A 中,实现了谁做的谁负责到底。

这种行为也保证了我们避免了背景中描述的问题,十分合理~

不过协议只做到了描述,我们怎么实现对内容的影响呢?这就需要实现对协议的处理了

协议处理器设计

接下来,我们需要设计一个协议处理器,用于根据协议生成具体的配置,比如我们写一个 entry_file 协议的处理器

const srcDir = path.resolve(__dirname, 'src'); // src 目录路径
const templateChanges = pluginImpactProtocol.affects.template.changes;

// 处理入口文件
if (templateChanges.entry_file) {
  const entryFilePath = path.join(srcDir, "index.js"); // 假设入口文件为 index.js
  let entryContent = fs.readFileSync(entryFilePath, "utf-8");

  entryContent += templateChanges.entry_file.content;

  // 文件重写,实现插入
  // todo: 具体如何实现,其实很灵活,甚至可以借助 AST 进行
  fs.writeFileSync(entryFilePath, entryContent, "utf-8");
}

可以理解为,我们在插件中注册了一个“回调函数”,在脚手架运行内核逻辑时,会对一个个插件进行处理。此时我们会遍历脚手架中绑定的“回调函数”并执行

走到这一步,全流程就已经被打通了,那接下来需要面对的问题就是:

  1. 抽象所有场景可能存在的协议内容,制定协议
  2. 基于不同的协议,开发不同的协议处理器

插件的类型划分

在做协议内容的例举之前,我们先来仔细研究研究插件的类型

为什么需要明确插件的类型,而不去讨论框架和构建工具的划分?

  1. 框架、构建工具的扩展性是有限的,且相对同质化的
  2. 插件则是天马行空,存在很多方向,我们需要一个统一的划分原则,进行有效归类

大体上插件分为三类

  1. 第一类:基础设施类插件
    这类插件的作用通常是提供基础的工具支持,不会直接影响框架的功能或构建流程的结构。它们通常只会修改配置文件或提供构建/开发时的基本支持,更多是作为开发环境的支撑工具
  • 常见插件: Babel、ESLint、Prettier、Husky、SWC

  • 特点:

    • 无框架依赖性: 不直接与框架绑定
    • 配置驱动: 通过配置文件来管理
    • 功能局限性: 仅在构建流程或开发时发挥作用
  1. 第二类:通用框架影响类插件
    这类插件通常影响的是特定开发文件的导入方式或配置,且这种影响不依赖于特定框架,它可以适用于多种框架(例如 Vue、React 等)
  • 常见插件: SCSS、TypeScript

  • 特点:

    • 通过配置或代码注入的方式改变开发文件(如 JS、CSS)行为
    • 不依赖于特定框架
  1. 第三类:特化框架影响类插件
    这类插件需要在开发文件中直接引入并且影响框架本身的结构,例如,修改应用的路由结构、UI 组件等。
  • 常见插件: Pinia、Mobx、VueRouter

  • 特点:

    • 通过修改框架的结构、引入特定的组件或模块来增强框架功能
    • 需要在框架中明确指定或配置
  1. 第四类:构建工具扩展型插件
    这类插件主要影响构建工具(如 Webpack、Vite 等),用于扩展或定制构建流程,通常涉及文件处理、模块解析、构建优化
  • 常见插件: PostCSS、Babel

  • 特点:

    • 影响 构建过程,包括文件打包、代码拆分等
    • 通常在构建配置中定义和使用

当然,某个具体的插件可能会包含一类或几类特征,需要视具体情况而定

协议开发

明确好相关设计后,我们来大概设计一两个协议试试效果

【通用协议】ENTRY_FILE

协议描述

用于 xx 给 yy 添加入口文件,比如 scss 插件,需要在 vue 框架的 template/src/index.vue 中注入一个 import 语句

协议的注册

我们以 scss 为例,在 scss 的默认导出方法中注册协议:

const protocol = require("@/core/src/configs/protocol.ts");
const pluginToTemplateProtocol = protocol.pluginToTemplateProtocol;

/**
 * scss 的默认导出方法,会被生成器调用
 * @param generatorAPI 生成器暴露的一些 API,用于对产物内容进行操作
 */
module.exports = (generatorAPI) => {
  // extendPackage 用于在 package.json 中注入依赖
  generatorAPI.extendPackage({
    devDependencies: {
      scss: "^1.81.0", // todo: 暂时的版本
    },
  });

  // protocolGenerate 用于执行指定的协议处理器
  generatorAPI.protocolGenerate({
    // 注册 ENTRY_FILE 协议
    [pluginToTemplateProtocol.ENTRY_FILE]: {
      // 入口文件引入全局 scss 文件
      params: {
        content: "import './styles/main.scss'", // 全局样式文件路径
      },
      priority: 1, // 优先级
    },
  });
};

在上述的逻辑中,Generator 执行到 scss 的导出方法后,就会执行 protocolGenerate 对应的逻辑,大致实现如下:

protocolGenerate(protocols) {
  // 统一定义协议所需参数
  const props: ProtocolProps = {
    preset: this.generator.getPreset(), // 用户所选的预设
    buildToolConfigAst: this.generator.buildToolConfigAst, // 构建工具配置文件语法树
  };
  let api: ProtocolAPI = undefined;
  // 此处会遍历调用的各个协议,并将 Generator 的数据(例如用户preset)传入协议处理器中去
  // [xx]To[yy]API 内部注册了相关的协议处理函数
  for (const protocol in protocols) {
    if (protocol in pluginToTemplateProtocol) {
      api = new PluginToTemplateAPI(protocols, props, protocol);
    } else if (protocol in pluginToBuildToolProtocol) {
      api = new PluginToBuildToolAPI(protocols, props, protocol);
    } else if (protocol in templateToBuildToolProtocol) {
      api = new TemplateToBuildToolAPI(protocols, props, protocol);
    }
    // 调用 [xx]To[yy]API 的协议处理函数
    api.generator();
  }
}

在对应的实例中,调用的实际逻辑如下:

// [xx]To[yy]API 内部的 generator
generator() {
  const protocol = this.protocol;
  const protocols = this.protocols;
  // 执行目标协议处理器
  this[protocol](protocols[protocol]);  // 等价于 this.ENTRY_FILE(protocols['ENTRY_FILE'])
}

ENTRY_FILE() {
  // ……
}
协议处理器

这一步就需要我们来完善 ENTRY_FILE 的实现,大致如下:

ENTRY_FILE(params) {
  const srcDir = path.resolve(import.meta.dirname, "src"); // src 目录路径
  const content = params.content;

  // 处理入口文件
  if (content) {
    const entryFilePath = path.join(srcDir, "index.js"); // 假设入口文件为 index.js
    let entryContent = fs.readFileSync(entryFilePath, "utf-8");
    entryContent += content;

    // 文件重写,实现插入
    fs.writeFileSync(entryFilePath, entryContent, "utf-8");
  }
}

到这一步我们就顺利地实现了协议的注册和处理,最终实现了 scss 对 vue 的影响,整体流程如下:

image.png

【插件对框架】SLOT_INJECT

顾名思义:为插槽注入内容
当我们面对插件对框架的影响,如 mobx、react-router 等,会对框架的内容进行比较复杂的更改,这个时候用 EJS 或 AST 操作都有一定的缺陷:

  • EJS:EJS 的语法面对比较复杂的场景会显得非常复杂,导致可读性、可维护性非常差
    • 比如:
      <% if (preset.reactRouter === true) { %>
          <Router history={history}>
      <% } %>
              <div>……</div>
      <% if (preset.reactRouter === true) { %>
          </Router>
      <% } %> 
      
    • 更复杂的:
      <% if (preset.reactRouter === true) { %>
          <Router history={history}>
      <% } %>
          <% if (preset.mobx === true) { %> 
              <Provider store={store}> 
          <% } %>
                  <div>……</div>
          <% if (preset.mobx === true) { %> 
              </Provider> 
          <% } %>
      <% if (preset.reactRouter === true) { %>
          </Router>
      <% } %> 
      
  • AST:实现成本高,且不好抽象(比如实现 xx 对 yy 影响时,插入指定的位置需要结合上下文判断,实现逻辑比较特化)

基于这种背景,我们需要设计一种协议来实现更加灵活的插入,且保证“工作重心”仍然维持在我们的插件中!

我们可以结合 EJS 的优势来设计一个插槽的体系:在模版文件中注册一个个插槽,通过 id 等唯一标识区分位置,并在协议传参中带入 id、content实现内容注入,实现如下:

插槽注册

我们通过一套自定义语法来描述传参:/* slot: <slot-name> */,在命中相关的 slot-name 之后,便会注入指定的内容,比如:

/* slot: router-start-slot */
    <div>……</div>
/* slot: router-end-slot */
协议传参

我们在插件中定义相关的协议

module.exports = (generatorAPI) => {
  generatorAPI.protocolGenerate({
    [pluginToTemplateProtocol.SLOT_CONTENT_PROTOCOL]: {
      params: {
        slotConfig: [
          {
            url: "src/App",
            slotName: "router-start-slot",
            slotContent: "<Router>",
          },
          {
            url: "src/App",
            slotName: "router-end-slot",
            slotContent: "</Router>",
          },
        ],
      },
    },
  });
};

通过 slotName 锁定插槽后,插入对应的 slotContent,实现最终的效果:

<Router>
    <div>……</div>
</Router>

如果再复杂点,也不会影响可读性,相比之前的 EJS 描述要好了很多!

/* slot: router-start-slot */
    /* slot: mobx-start-slot */
    <div>……</div>
    /* slot: mobx-end-slot */
/* slot: router-end-slot */

当然由于不同插件有着不同的特点,会有越来越多的协议需要实现,但在实现一定程度后,所有的插件都能基本被已有的协议覆盖,最终实现了有限的协议满足了几乎无限的插件!至此,脚手架在灵活性上的壁垒也基本得到了解决

协议开发深入

当然,协议的开发不可能仅此而已,实际的复杂度与功能抽象的要求使得很多方案难以实现,这时我们就需要借助一些强力的方案,比如:回调函数、AST 等

回调函数

当我们很难通过协议传参来配合协议处理器时,我们可以将回调函数作为参数,在协议处理器中执行,比如:

module.exports = (generatorAPI) => {
  generatorAPI.protocolGenerate({
    [pluginToTemplateProtocol.XXX_PROTOCOL]: {
      params: {
        func: (xx) => {
            // 具体回调内容
        }
      },Ï
    },
  });
};

AST

AST 对于内容的修改是非常细粒度的,在我们的场景中,AST 可以实现在协议的处理器中,对于一些位置明确结构规范稳定的协议是非常适用的
但面对上述两种情况以外时,我们应该尽可能地避免使用 AST,原因是:

  1. 位置不明确会导致 AST 难以定位上下文,使得难度给到了协议的传参规范中,但这往往是不可靠的
  2. 结构不规范、稳定,会让 AST 的处理复杂度大大提升,可能需要非常多的判断语句

面对这种场景,我们应该更多地去考虑拆分协议~

开发规范

保证协议顺利实现的同时,我们还需要约束一定的规范,保证我们的协议质量,下面是一个合理的协议开发结构:

image.png

最后

目前协议的开发还在推进过程中,因此并没有合入主分支,感兴趣的同学可以在 feat/generator-upgrade 分支了解源码

对于后续的发展方向,基本会围绕在几个主题:

  1. 协议的扩展
  2. 插件、构建工具的持续扩展
  3. 可视化
  4. ……

非常欢迎大家参与贡献 🎉