插件化思维实战指南:从 webpack、egg、koa 到自研框架的插件机制设计(前端精读周刊)

原创2026-09-30 20:17:391,345 阅读
文章标签:文档技术博客教程

插件化思维实战指南:从 webpack、egg、koa 到自研框架的插件机制设计(前端精读周刊)

导读

插件化是所有成熟框架的必答题:构建工具(webpack、gulp、fis)、后端框架(egg、koa)、前端页面组件体系都在用插件机制换取可拓展性与可维护性。本文以《插件化思维》精读为基础,系统拆解四种插件化形态(约定/注入、事件、插槽、分形)、核心框架加载与调度插件的完整链路(注册、生命周期、拦截、依赖通信),并结合本仓库中关于 gaea-editor 插槽机制、webpack 插件、可视化搭建组件注册等内容的精读,帮助你在自研框架、脚手架或复杂协同项目中落地插件化设计。

1 插件化为何无处不在

用过构建工具的同学都知道,grunt、webpack、gulp 都支持插件开发;后端框架比如 egg、koa 都支持插件机制拓展;前端页面也有许多可拓展性的要求。插件化无处不在,几乎所有框架都希望自身拥有最强大的可拓展能力与可维护性,而它们不约而同选择了插件化这条路径。

插件化思维本质上是一种工程上的"分而治之":没有插件化,核心库的代码会变得冗余,功能耦合越来越严重,最后导致维护困难。插件化就是把不断扩张的功能分散在插件中,核心库内部集中维护逻辑,这与数据库横向扩容的思路类似——结构不变,拆分数据。

一个理想的库或框架,其可拓展性应体现在三个方面:

  • 让社区可以贡献代码,且即使代码存在问题,也不会影响核心代码的稳定性;
  • 支持二次开发,满足不同业务场景的特定需求;
  • 让代码以功能为纬度聚合起来,而不是某个片面的逻辑结构,在代码数量庞大的场景尤为重要。

做技术设计时,最好先从使用者角度出发:先设计出舒服的调用方式,再去考虑实现。因此下文先从插件使用者视角,梳理可供开发者使用的插件形式。

2 插件化分类:四种形态

插件化许多都是从设计模式演化而来的,可以参考的有命令模式、工厂模式、抽象工厂模式等。根据实践经验,可以总结出三种插件化形式,外加一种效果优雅但严格来说不算插件化实现方式的"分形插件化":

  • 约定/注入插件化;
  • 事件插件化;
  • 插槽插件化;
  • 分形插件化。

2.1 约定/注入插件化

按照某个约定来设计插件,这个约定一般是:入口文件/指定文件名作为插件入口,文件形式可以是 .json、.ts 等,只要返回的对象按照约定名称书写,就会被加载,并可以拿到一些上下文。

举例来说,只要项目的 package.json 的 apollo 字段存在 commands 属性,就会自动注册新的命令行:

{
  "apollo": {
    "commands": [{ "name": "publish", "action": "doPublish" }]
  }
}

当然 json 能力很弱,定义函数部分需要单独在 ts 文件中完成。更广泛的方式是直接写 ts 文件,但按照文件路径决定作用,比如项目的 ./controllers 目录下存在 ts 文件,会自动作为控制器响应前端的请求。

这种情况根据功能类型决定对 ts 文件代码结构的要求。比如 node 控制器这一层,一个文件要响应多个请求,且逻辑单一,就很适合用 class 作为约定:

export default class User {
  async login(ctx: Context) {
    ctx.json({ ok: true });
  }
}

如果功能相对杂乱,没有清晰的功能入口规划(比如 gulp 这类插件),用对象会更简洁,而且更倾向于用一个入口,因为主要操作的是上下文,内部逻辑种类无法控制。所以可能会这样写:

export default (context: Context) => {
  // context.sourceFiles.xx
};

典型案例:fis、gulp、webpack、egg。

本仓库对这类"按约定注册"的机制也有呼应,可以参考 可视化搭建/269.组件注册与画布渲染.md:可视化搭建器用 componentName 作为组件类型约定、用 element 作为渲染函数注册组件元信息,只要注册了组件元信息与组件树,画布就能渲染出来——这正是"约定即注册"思维在组件体系中的体现。

2.2 事件插件化

顾名思义,通过事件的方式提供插件开发的能力。这种方式在不同框架之间的跨界更大,比如 dom 事件:

document.on("focus", callback);

虽然只是普通的业务代码,但这本质上就是插件机制:

  • 可拓展:可以重复定义 N 个 focus 事件,相互独立;
  • 事件相互独立:每个 callback 之间互不影响。

也可以理解为:事件机制就是在一些阶段放出钩子,允许用户代码拓展整体框架的生命周期。

service worker 就更明显,业务代码几乎完全由一堆事件监听构成。比如 install 时机,随时可以新增一个监听把 install 时机进行 delay,而不需要侵入其他代码。

在事件机制上玩出花样的应该算 koa:它的中间件洋葱模型非常有名。换个角度理解,可以认为是能控制执行时机的事件插件化——想把执行时机放在所有事件执行完毕时,就把代码放在 next() 之后;想终止插件执行,可以不调用 next()。

本仓库 前沿技术/213.精读《Prisma 的使用》.md 中对数据库中间件的描述印证了同一模型:"中间件执行顺序是洋葱模型,并且每个操作都会触发,我们可以利用中间件拓展业务逻辑或者进行操作时间的打点记录。"

典型案例:koa、service worker、dom events。

2.3 插槽插件化

这种插件化一般用于对 UI 元素的拓展。react 的内置数据流是符合组件物理结构的,而 redux 数据流是符合用户定义的逻辑结构;对于 html 布局来说也是一样:html 默认布局是物理结构,那么插槽布局方式就是 html 中的 redux。

正常 UI 组织逻辑是这样的:

<div>
  <Layout>
    <Header>
      <Logo />
    </Header>
    <Footer>
      <Help />
    </Footer>
  </Layout>
</div>

插槽的组织方式是这样的:

{
  position: "root",
  View: <Layout>{insertPosition("layout")}</Layout>
}
{
  position: "layout",
  View: [
    <Header>{insertPosition("header")}</Header>,
    <Footer>{insertPosition("footer")}</Footer>
  ]
}
{
  position: "header",
  View: <Logo />
}
{
  position: "footer",
  View: <Help />
}

这样插件中的代码可以不受物理结构的约束,直接插入到任何插入点。

更重要的是,插槽实现了 UI 解耦:父元素不需要知道子元素的具体实例。一般来说,决定一个组件状态的都是其父元素而不是子元素,比如一个按钮可能在 <ButtonGroup/> 中表现为组合态样式,但不可能说 <ButtonGroup/> 因为有了 <Select/> 作为子元素,自身逻辑就发生变化。这就意味着,父元素只需要声明"这里可以放什么",而不必关心放进来的是什么,例如 Tabs:

<Tabs>{insertPosition(`tabs-${this.state.selectedTab}`)}</Tabs>

当然有些情况看似是例外,比如 Tree 的查询功能就依赖子元素 TreeNode 的配合。但它依赖的是基于某个约定的子元素,而不是具体子元素的实例——父级只需要与子元素约定接口即可。真正需要关心物理结构的恰恰是子元素:比如插入到 Tree 下的 TreeNode 必须实现某些方法,如果不满足这个功能,就不要把组件放在 Tree 下面;而 Tree 的实现无需顾及,只需要默认子元素有哪些约定即可。

本仓库 前沿技术/42.精读《前端数据流哲学》.md 中介绍了 gaea-editor(一个 web designer)的插件机制,正是插槽插件化的工程实践:任何插件只要提供一个名字,就能嵌入到申明了对应名字的 UI 插槽中,插件自己也可以申明任意数量的插槽,内核中也有几个内置的插槽。这样插件的 UI 能力极强,任何 UI 都可以被新插件以同名申明的方式替代。

典型案例:gaea-editor。

2.4 分形插件化

代表是 egg,特点是插件结构与项目结构分形:组成大项目的小插件,自身结构与项目结构相同。因为对于 node server 的插件来说,要实现的功能应该是项目功能的子集,而 egg 对功能是按照目录结构划分的,所以插件的目录结构与项目一致,看起来也很美观。

典型案例:egg。

当然不是所有插件都能写成目录分形的,这也恰好解释了 egg 与 koa 之间的关系:koa 是 node 框架,与项目结构无关;egg 是基于 koa 上层的框架,将项目结构转化成 server 功能,而插件需要拓展的也正是 server 功能,恰好可以用项目结构的方式写插件。

3 核心代码如何加载插件

一个支持插件化的框架,核心功能是整合插件以及定义生命周期,与功能相关的代码反而可以通过插件实现。核心代码的工作链路分为五步。

3.1 确定插件加载形式

根据上一节的分类,我们根据项目的功能找到一个合适的插件使用方式,这会决定我们如何执行插件:

  • 功能按约定文件加载 → 约定/注入插件化;
  • 需要挂载到生命周期节点 → 事件插件化;
  • 需要拓展 UI → 插槽插件化;
  • 插件是项目功能的子集 → 分形插件化。

3.2 确定插件注册方式

插件注册方式非常多样,这里举几个例子:

通过 npm 注册:只要 npm 包符合某个前缀,就会自动注册为插件,实现最简单。

通过文件名注册:项目中存在 xx.plugin.ts 会自动做到插件引用,一般作为辅助方案使用。

通过代码注册:通过代码 require 即可,比如 babel-polyfill。不过这种方案要求插件执行逻辑正好要在浏览器运行,场景比较受限。

通过描述注册:在 package.json 描述一个属性,表明要加载的插件,比如 .babelrc:

{
  "presets": ["es2015"]
}

自动注册:比较暴力,通过遍历可能存在的位置,只要满足插件约定的,会自动注册为插件。这个行为类似 require——会自动递归寻找 node_modules,当然别忘了像 require 一样提供 paths 让用户手动配置寻址起始路径。

3.3 确定生命周期

确定插件注册方式后,一般第一件事就是加载插件,后面就是根据框架业务逻辑不同而不同的生命周期了。插件在这些生命周期中扮演不同功能,我们需要通过一些方式让插件能够影响这些过程。

3.4 插件对生命周期的拦截

一般通过事件、回调函数的方式,支持插件对生命周期的拦截,最简单的例子比如:

document.on("click", callback);

就是让插件拦截了 click 这个事件。当然这个事件与 dom 生命周期相比微乎其微,但也算是一个微小的生命周期,我们也可以 event.stopPropagation() 阻止冒泡,来影响这个生命周期的逻辑。

3.5 插件之间的依赖与通信

插件之间难免有依赖关系,目前有两种方式处理:依赖关系定义在业务项目中,或依赖关系定义在插件中。

依赖关系定义在业务项目中,比如 webpack 的配置,业务项目里是这么配的:

{
  "use": ["babel-loader", "ts-loader"]
}

在 webpack 中,执行逻辑是 ts-loader -> babel-loader,规则由框架说了算,但插件加载执行肯定有个顺序,而且与配置写法有关,且配置需要写在项目中(至少不在插件中)。另一种做法,把插件依赖写在插件中,比如 webpack-preload-plugin 就依赖 html-webpack-plugin。

这两种场景各不同:一种是业务有关的顺序——插件无法做主的业务逻辑问题,需要把顺序交给业务项目配置;一种是插件内部顺序——业务无需关心的顺序问题,由插件自己定义。注意框架核心一般可能需要同时支持这两种配置方式,最终决定插件的加载顺序。

插件之间通信可以通过 hook 或者 context 方式支持:

  • hook 主要传递的是时机信息;
  • context 主要传递的是数据信息;
  • 两者最终能否生效,取决于上面说到的插件加载顺序。

context 可以拿 react 做类比:一般都有作用域,且与执行顺序严格相关。hook 等于插件内部的一个事件机制,由一个插件注册,业界有个较好的实现叫 tapable(webpack 的底层插件系统),这里简单介绍其用法。

利用 tapable 在 A 插件注册新 hook:

const SyncWaterfallHook = require("tapable").SyncWaterfallHook;
compilation.hooks.htmlWebpackPluginAlterChunks = new SyncWaterfallHook([
  "chunks",
  "objectWithPluginRef"
]);

在 A 插件某个地方使用此 hook,实现某个特定业务逻辑:

const chunks = compilation.hooks.htmlWebpackPluginAlterChunks.call(chunks, {
  plugin: self
});

B 插件可以拓展此 hook,来改变 A 的行为:

compilation.hooks.htmlWebpackPluginAlterChunks.tap(
  "HtmlWebpackIncludeSiblingChunksPlugin",
  chunks => {
    const ids = []
      .concat(...chunks.map(chunk => [...chunk.siblings, chunk.id]))
      .filter(onlyUnique);
    return ids.map(id => allChunks[id]);
  }
);

这样,A 拿到的 chunks 就被 B 修改掉了。

4 核心功能的插件化:衡量框架质量的标尺

插件化框架的核心代码主要功能是对插件的加载、生命周期的梳理,以及实现 hook 让插件影响生命周期,最后补充上插件的加载顺序以及通信,就比较完备了。

那么写到这里,衡量代码质量的点就在于:是不是所有核心业务逻辑都可以由插件完成? 因为只有用插件实现核心业务逻辑,才能检验插件的能力,进而推导出第三方插件是否拥有足够的拓展能力。

如果核心逻辑中有一部分代码没有通过插件机制编写,不仅让第三方插件无法拓展此逻辑,而且还不利于框架的维护。所以这主要是个思想:开发者需要明确哪些功能应该做成插件,以及将哪些插件固化为内置插件。本仓库 前沿技术/42.精读《前端数据流哲学》.md 中 gaea-editor 的设计正是这种思路的极致化实践:为了让插件拥有最大能力,这个 web designer 所有内核代码都是用插件写的,除了调用插件的部分,插件可以随意访问和修改内核中任何数据,包括 UI。

需要提前思考清楚三点。

4.1 哪些插件需要内置

这个是业务相关的问题,但总体来看,开源的、基础功能以及体现核心竞争力的可以内置。开源与核心竞争力比较好理解,主要说下基础功能。

基础功能就是一个业务的架子。因为插件机制的代码并不解决任何业务问题,一个没有内置插件的框架肯定什么都不是,所以选择基础功能尤为重要。

举个例子,做构建工具至少要有一个基本的配置作为模版,其他插件通过拓展这个配置来修改构建效果。那么这个基本配置就决定了其他插件可以如何修改它,也决定了这个框架的配置基调。比如 create-react-app 对 dev 开发时的模版配置,如果没有这个模版,本地就无法开发,所以这个插件必须内置,而且需要考虑如何让其他插件对其拓展。

另一种情况就是非常基本、又不需要再拓展加工的可以做成内置插件,比如 babel 对 js 模块的 commonjs 分析逻辑就不需要暴露出来——因为这个标准已经确定,既不需要拓展,又是 babel 运行的基础,所以肯定要内置。

4.2 插件是依赖型还是完全正交的

功能完全正交的插件是最完美的:它既不会影响其他插件,也不需要依赖任何插件,自身也不需要被任何插件拓展。在写非正交功能的插件时就要担心了,可以分为三个点去看。

4.2.1 依赖其他插件的插件

举个例子,插件 X 需要拓展命令行,在执行 npm start 时统计当前用户信息并打点。那么这个插件就要知道当前登录用户是谁,这个功能恰好由另一个"用户登录"插件完成,那么插件 X 就要依赖"用户登录"插件。

这种情况,根据依赖小节的经验,需要明确这个依赖是插件级别依赖还是项目级别依赖。插件级别依赖把依赖关系定义在插件 X 中即可,比如 package.json:

"plugin-dep": ["user-login"]

另一种情况,比如写的是 babel-loader 插件,它在 ts 项目中依赖 ts-loader,那只能在项目中定义依赖了,此时需要补充一些文档说明 ts 场景的使用顺序。

4.2.2 依赖并拓展其他插件的插件

如果插件 X 在依赖"用户登录"插件的基础上,还要拓展登录时获取的用户信息(比如同时获取手机号),而"用户登录"插件默认没有获取此信息、但可以通过扩展方式实现,插件 X 需要注意什么呢?

首先,插件 X 最好不要减少另一个插件的功能(具体拓展方式参考 tapable 小节,这里假设插件都比较具有可拓展性),否则插件 X 可能破坏"用户登录"插件与其他插件之间的协作。

减少功能的情况非常普遍,这里举一个例子:某个插件通过 pipeTemplate 拓展模版内容,但插件 X 直接返回了新内容而没有 concat 原有内容,就是减少了功能。

但也不是所有情况都要保证不减少功能,比如当缺少必要的配置项时,可以直接抛出异常,提前终止程序。

其次,要确保增加的功能尽可能少地与其他插件产生冲突。拿拓展 webpack 配置举例,现在要拓展对 node_modules 中 js 文件的处理,让这些文件过一遍 babel:

  • 不好的做法:直接修改原有对 js 的 rules,增加一项对 node_modules 的 include 以及 babel-loader。因为这会破坏原先插件对项目内 js 文件的处理——可能项目的 js 文件根本不需要 babel 处理;
  • 比较好的做法:新增一个 rules,单独对 node_modules 的 js 文件处理,不要影响其他规则。

本仓库 前沿技术/47.精读《webpack4.0 升级指南》.md 从使用侧佐证了这一结论:webpack 4.0 开启 --mode production 会自动开启代码压缩、scope hoist 等插件,开启 --mode development 会自动开启 sourceMap 等开发插件——框架把"基础功能内置 + 插件自动装配"做透后,使用者只需要关心更简单的配置。而 前沿技术/144.精读《Webpack5 新特性 - 模块联邦》.md 展示了插件体系的下一个演化方向:通过 ModuleFederationPlugin 这类内置插件,让模块在运行时跨应用共享。

4.2.3 可能被其他插件拓展的插件

这点是最难的,难在如何设计拓展的粒度。由于所有场景都类似,拿对模版的拓展举例子,其他场景可以类比:插件 X 定义了入口文件的基础内容,但还要提供一些 hook 供其他插件修改入口文件。

假设入口文件一般是这样的:

import * as React from "react";
import * as ReactDOM from "react-dom";
import { App } from "./app";

ReactDOM.render(<App />, document.getELementById("root"));

这种最简单的模版,内部其实要考虑以下几点潜在拓展需求:

  1. 在某处需要插入其他代码,怎么支持?
  2. 如何保证插入代码的顺序?
  3. 用 react-lite 替换 react,怎么支持?
  4. dev 模式需要用 hot(App) 替换 App 作为入口,怎么支持?
  5. 模版入口 div 的 id 可能不是 root,怎么支持?
  6. 模版入口 div 是自动生成的,怎么支持?
  7. 用在 reactNative,没有 document,怎么支持?
  8. 后端渲染时,需要用 ReactDOM.hydrate 而不是 ReactDOM.render,怎么支持?
  9. 以上 8 种场景可能会不同组合,需要保证任意组合都能正确运行,所以无法全量模版替换,那怎么办?

一种可行的解决方案是"分段可插拔"的模版拼接,把模版拆成多个可被 hook 覆盖的片段:

get(
  "entry",
  `
  ${get("importBefore", "")}
  ${get("importReact", `import * as React from "react"`)}
  ${get("importReactDOM", `import * as ReactDOM from "react-dom"`)}
  import { App } from "./app"
  ${get("importAfter", "")}

  ${get("renderMethod", `ReactDOM.render`)}(${get(
    "renderApp",
    "<App/>"
  )}, ${get("rootElement", `document.getELementById("root")`)})
  ${get("renderAfter", "")}
`
);

以上八种情况读者可以自行脑补对应到各个 get(...) 片段:替换 react 只需覆盖 importReact;dev 模式只需覆盖 renderApp 为 hot(App);改入口 id 只需覆盖 rootElement;后端渲染只需覆盖 renderMethod 为 ReactDOM.hydrate……每个片段都有默认值,任意组合都能通过覆盖不同片段拼出正确结果。要注意的是,这个方案随着考虑到的使用场景增多,是要不断调整变化的。

4.3 内置插件如何与第三方插件相处

内置插件与第三方插件的冲突点在于:内置插件如果拓展性很差,那还不如不要内置,内置了反而阻碍第三方插件的拓展。所以参考上一节,为内置插件考虑最大化的拓展机制,才能确保内置插件的功能不会变成拓展性瓶颈。

每新增一个内置插件,都在消灭一部分拓展能力:由插件拓展后的区块,其拓展能力应该逐渐减弱。这里比较拗口,可以比喻为一条小溪流,插件就是层层的水处理站——每新增一个处理站就会改变下游水势变化,甚至可能将水拦住,下游一滴水也拿不到。

结论是:要谨慎增加内置插件数量,因为内置的越多,框架拓展能力就越弱。

5 哪些场景可以插件化

最后梳理下插件化的适用场景。

5.1 前后端框架

如果你要做一个前/后端开发框架,插件化是必然:比如 react 的生命周期、koa 的中间件,甚至业务代码用到的 request 处理,都是插件化的体现。

5.2 脚手架

支持插件化的脚手架具有拓展性,社区方便提供插件,而且脚手架为了适配多种代码,功能可插拔非常重要。

5.3 工具库

一些小的工具库,比如管理数据流的 redux 提供的中间件机制,就是让社区贡献插件完善自身功能。本仓库 前沿技术/42.精读《前端数据流哲学》.md 提到:redux middleware 将 action 对接到 reducer 的黑盒控制权暴露给了开发者,几乎是参考 koa 中间件设计思路的产物。

5.4 需要多人协同的复杂业务项目

如果业务项目很复杂,同时又有多人协作完成,最好按照功能划分来分工。但如果分工只是简单的文件目录分配方式,必然导致功能的不均匀:每个人开发的模块可能不能访问所有系统能力,或者涉及与其他功能协同时,文件相互引用带来代码耦合度提高,最终导致难以维护。

插件化给这种项目带来的最大优势就是:每一个人开发的插件都是一个拥有完整功能的个体,这样只需要关心功能的分配,不用担心局部代码功能不均衡;插件之间的调用框架层已经做掉了,所以协同不会发生耦合,只需要申明好依赖关系。

插件化机制良好的项目开发,和 git 功能分支开发的体验有相似之处:git 给每个功能或需求开一个分支,插件化可以让每个功能作为一个插件;而 git 功能分支之间是无关联的,所以只有功能之间正交的需求才能开多个分支,而插件机制可以考虑到依赖情况,进行更复杂的功能协同。

6 总结

目前还缺少对插件化系统化思考的文章,本篇算是抛砖引玉。插件化并不是小题大做,因为它能带来更好的分工协作,而分工的重要性不言而喻。

回顾全文,插件化设计的核心决策可以归纳为一条可执行清单:

  1. 选形态:按功能特性在约定/注入、事件、插槽、分形四种形态中确定插件使用方式;
  2. 定注册:从 npm 前缀、文件名、代码 require、描述字段、自动遍历五种注册方式中选择(可组合);
  3. 理生命周期:明确加载时机,并通过事件、回调、hook 提供对生命周期的拦截能力;
  4. 管依赖:区分业务项目级依赖与插件内部级依赖,用 hook(时机)与 context(数据)支撑插件间通信;
  5. 写核心:让核心业务逻辑也由插件实现,以此检验插件能力;只内置"基础功能 + 核心竞争力"插件,且为每个内置插件设计最大化的拓展粒度;
  6. 控数量:每新增一个内置插件都会削弱整体拓展能力,谨慎为之。

本文属于"前端精读周刊"系列(仓库目录见 readme.md),该系列覆盖前沿技术、源码解读、设计模式、编译原理等模块,其中与插件化主题相关的延伸阅读包括:gaea-editor 插件机制的完整剖析见 前沿技术/42.精读《前端数据流哲学》.md,组件注册与插槽式画布渲染见 可视化搭建/269.组件注册与画布渲染.md,webpack 插件体系的演进见 前沿技术/47.精读《webpack4.0 升级指南》.md 与 前沿技术/144.精读《Webpack5 新特性 - 模块联邦》.md,中间件洋葱模型见 前沿技术/213.精读《Prisma 的使用》.md。

登录后查看全文
weekly