本周精读内容是 《插件化思维》。没有参考文章，资料源自 webpack、fis、egg 以及笔者自身开发经验。

## 1 引言

用过构建工具的同学都知道，`grunt`, `webpack`, `gulp` 都支持插件开发。后端框架比如 `egg` `koa` 都支持插件机制拓展，前端页面也有许多可拓展性的要求。插件化无处不在，所有的框架都希望自身拥有最强大的可拓展能力，可维护性，而且都选择了插件化的方式达到目标。

我认为插件化思维是一种极客精神，而且大量可拓展、需要协同开发的程序都离不开插件机制支撑。

没有插件化，核心库的代码会变得冗余，功能耦合越来越严重，最后导致维护困难。插件化就是将不断扩张的功能分散在插件中，内部集中维护逻辑，这就有点像数据库横向扩容，结构不变，拆分数据。

## 2 精读

理想情况下，我们都希望一个库，或者一个框架具有足够的可拓展性。这个可拓展性体现在这三个方面：

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

我们都清楚插件化应该能解决问题，但从哪下手呢？这就是笔者希望分享给大家的经验。

做技术设计时，最好先从使用者角度出发，当设计出舒服的调用方式时，再去考虑实现。所以我们先从插件使用者角度出发，看看可以提供哪些插件使用方式给开发者。

### 2.1 插件化分类

插件化许多都是从设计模式演化而来的，大概可以参考的有：命令模式，工厂模式，抽象工厂模式等等，笔者根据个人经验，总结出三种插件化形式：

* 约定/注入插件化。
* 事件插件化。
* 插槽插件化。

最后还有一个不算插件化实现方式，但效果比较优雅，姑且称为分形插件化吧。下面一一解释。

#### 2.1.1 约定/注入插件化

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

举例来说，比如只要项目的 `package.json` 的 `apollo` 存在 `commands` 属性，会自动注册新的命令行：

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

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

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

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

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

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

> 举例：`fis`、`gulp`、`webpack`、`egg`。

#### 2.1.2 事件插件化

顾名思义，通过事件的方式提供插件开发的能力。

这种方式的框架之间跨界更大，比如 dom 事件：

```typescript
document.on("focus", callback);
```

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

* 可拓展：可以重复定义 N 个 focus 事件相互独立。
* 事件相互独立：每个 callback 之间互相不受影响。

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

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

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

> 举例：`koa`、`service worker`、`dom events`。

#### 2.1.3 插槽插件化

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

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

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

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

```tsx
{
  position: "root",
  View: <Layout>{insertPosition("layout")}</Layout>
}
```

```tsx
{
  position: "layout",
  View: [
    <Header>{insertPosition("header")}</Header>,
    <Footer>{insertPosition("footer")}</Footer>
  ]
}
```

```tsx
{
  position: "header",
  View: <Logo />
}
```

```tsx
{
  position: "footer",
  View: <Help />
}
```

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

更重要的是，实现了 UI 解耦，父元素就不需要知道子元素的具体实例。一般来说，决定一个组件状态的都是其父元素而不是子元素，比如一个按钮可能在 `<ButtonGroup/>` 中表现为一种组合态的样式。但不可能说 `<ButtonGroup/>` 因为有了 `<Select/>` 作为子元素，自身的逻辑而发生变化的。

这就意味着，父元素不需要知道子元素的实例，比如 `Tabs`:

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

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

> 举例：`gaea-editor`。

#### 2.1.4 分型插件化

代表 `egg`，特点是插件结构与项目结构分型，也就是组成大项目的小插件，自身结构与项目结构相同。

因为对于 `node server` 的插件来说，要实现的功能应该是项目功能的子集，而本身 `egg` 对功能是按照目录结构划分的，所以插件的目录结构与项目一致，看起来也很美观。

> 举例：`egg`。

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

### 2.2 核心代码如何加载插件

一个支持插件化的框架，核心功能是整合插件以及定义生命周期，与功能相关的代码反而可以通过插件实现，下一小节再展开说明。

### 2.2.1 确定插件加载形式

根据 2.1 节的描述，我们根据项目的功能，找到一个合适的插件使用方式，这会决定我们如何执行插件。

### 2.2.2 确定插件注册方式

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

**通过 npm 注册**：比如只要 `npm` 包符合某个前缀，就会自动注册为插件，这个很简单，不举例子了。

**通过文件名注册**：比如项目中存在 `xx.plugin.ts` 会自动做到插件引用，当然这一般作为辅助方案使用。

**通过代码注册**：这个很基础，就是通过代码 `require` 就行，比如 `babel-polyfill`，不过这个要求插件执行逻辑正好要在浏览器运行，场景比较受限。

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

```json
{
  "presets": ["es2015"]
}
```

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

### 2.2.3 确定生命周期

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

### 2.2.4 插件对生命周期的拦截

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

```typescript
document.on("click", callback);
```

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

### 2.2.5 插件之间的依赖与通信

插件之间难免有依赖关系，目前有两种方式处理，分为：**依赖关系定义在业务项目中，与依赖关系定义在插件中**。

稍微解释下，依赖关系定义在业务项目中，比如 webpack 的配置，我们在业务项目里是这么配的：

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

在 webpack 中，执行逻辑是 `ts-loader -> babel-loader`，当然这个规则由框架说了算，但总之插件加载执行肯定有个顺序，而且与配置写法有关，而且配置需要写在项目中（至少不在插件中）。

另一种行为，将插件依赖写在插件中，比如 `webpack-preload-plugin` 就是依赖 `html-webpack-plugin`。

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

插件之间通信也可以通过 `hook` 或者 `context` 方式支持，`hook` 主要传递的是时机信息，而 `context` 主要传递的是数据信息，但最终是否能生效，取决于上面说到的插件加载顺序。

`context` 可以拿 react 做个类比，一般都有作用域的，而且与执行顺序严格相关。

`hook` 等于插件内部的一个事件机制，由一个插件注册。业界有个比较好的实现，叫 [tapable](https://github.com/webpack/tapable)，这里简单介绍一下。

利用 `tapable` 在 A 插件注册新 `hook`：

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

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

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

B 插件可以拓展此 `hook`，来改变 A 的行为：

```typescript
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 修改掉了。

### 2.3 核心功能的插件化

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

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

如果核心逻辑中有一部分代码没有通过插件机制编写，不仅让第三方插件也无法拓展此逻辑，而且还不利于框架的维护。

所以这主要是个思想，希望开发者首先明确哪些功能应该做成插件，以及将哪些插件固化为内置插件。

笔者认为应该提前思考清楚三点：

#### 2.3.1 哪些插件需要内置

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

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

举个例子，比如做构建工具，至少要有一个基本的配置作为模版，其他插件通过拓展这个配置来修改构建效果。那么这个基本配置就决定了其他插件可以如何修改它，也决定了这个框架的配置基调。

比如：`create-react-app` 对 dev 开发时的模版配置。如果没有这个模版，本地就无法开发，所以这个插件必须内置，而且需要考虑如何让其他插件对其拓展，这个在 2.3.2 节详细说明。

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

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

功能完全正交的插件是最完美的，因为它既不会影响其他插件，也不需要依赖任何插件，自身也不需要被任何插件拓展。

在写非正交功能的插件时就要担心了，我们还是分为三个点去看：

##### 2.3.2.1 依赖其他插件的插件

举个例子，比如插件 X 需要拓展命令行，在执行 `npm start` 时统计当前用户信息并打点。那么这个插件就要知道当前登陆用户是谁。这个功能恰好是另一个 “用户登陆” 插件完成的，那么插件 X 就要依赖 “用户登陆” 插件了。

这种情况，根据 2.2.5 插件依赖小节经验，需要明确这个插件是插件级别依赖，还是项目级别依赖。

当然，这种情况是插件级别依赖，我们把依赖关系定义在插件 X 中即可，比如 `package.json`:

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

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

##### 2.3.2.2 依赖并拓展其他插件的插件

如果插件 X 在以来 “用户登陆” 插件的基础上，还要拓展登陆时获取的用户信息，比如要同时获取用户的手机号，而 “用户登陆” 插件默认并没有获取此信息，但可以通过扩展方式实现，插件 X 需要注意什么呢？

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

> 减少功能的情况非常普遍，为了加深理解，这里举一个例子：某个插件直接 pipeTemplate 拓展模版内容，但插件 X 直接返回了新内容，而没有 concat 原有内容，就是减少了功能。

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

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

不好的做法是直接修改原有对 js 的 rules，增加一项对 `node_modules` 的 include，以及 `babel-loader`。因为这样会破坏原先插件对项目内 js 文件的处理，可能项目的 js 文件不需要 `babel` 处理呢？

比较好的做法是，新增一个 rules，单独对 `node_modules` 的 js 文件处理，不要影响其他规则。

##### 2.3.2.3 可能被其他插件拓展的插件

这点是最难的，难在如何设计拓展的粒度。

由于所有场景都类似，我们拿对模版的拓展举例子，其他场景可以类比：插件 X 定义了入口文件的基础内容，但还要提供一些 `hook` 供其他插件修改入口文件。

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

```typescript
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 种场景可能会不同组合，需要保证任意组合都能正确运行，所以无法全量模版替换，那怎么办？

笔者此处给出一种解决方案，供大家参考。另外要注意，这个方案随着考虑到的使用场景增多，是要不断调整变化的。

```typescript
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", "")}
`
);
```

以上八种情况读者脑补一下，不详细说明了。

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

内置的插件与第三方插件的冲突点在于，内置插件如果拓展性很差，那还不如不要内置，内置了反而阻碍第三方插件的拓展。

所以参考 2.3.2.3 节，为内置插件考虑最大化的拓展机制，才能确保内置插件的功能不会变成拓展性瓶颈。

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

2.3.1 节说了哪些插件需要内置，而这一节想说明的是，谨慎增加内置插件数量，因为内置的越多，框架拓展能力就越弱。

### 2.4 哪些场景可以插件化

最后梳理下插件化适用场景，笔者根据有限的经验列出一下一些场景。

#### 2.4.1 前后端框架

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

#### 2.4.2 脚手架

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

#### 2.4.3 工具库

一些小的工具库，比如管理数据流的 `redux` 提供的中间件机制，就是让社区贡献插件，完善自身的功能。

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

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

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

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

## 3 总结

现在还没有找到对插件化系统化思考的文章，所以这一篇算是抛砖引玉，大家一定有更多的框架开发心得值得分享。

同时也想借这篇文章提高大家对插件化必要性的重视，许多情况插件化并不是小题大做，因为它能带来更好的分工协作，而分工的重要性不言而喻。

## 4 更多讨论

> 讨论地址是：[精读《插件化思维》 · Issue #75 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/75)

**如果你想参与讨论，请[点击这里](https://github.com/dt-fe/weekly)，每周都有新的主题，周末或周一发布。**
