插件化思维

Posted by Qz on October 8, 2019

“Yeah It’s on. ”

正文

所谓插件是一种能允许非核心代码在运行时修改应用程序的处理方式。

插件系数是一个由实现了插件化的核心模块,和其配套的插件模块组成的一种应用组织形式, 其中核心模块能独立运行并实现某种特定的功能,插件模块需要在核心模块上运行,并能在应用程序运行时修改程序的处理方式,从而增强或改变程序的处理结果。

精读《插件化思维》

BetterScroll 插件化实现

前端领域的插件式设计

当我们说插件系统的时候,我们在说什么

最近刚完成了一个基于原生小程序的框架,在这里就简单聊聊插件化机制对框架的重要性。


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

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

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

插件化分类

三种插件化形式:

  • 定/注入插件化。
  • 事件插件化。
  • 插槽插件化。

约定/注入插件化

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

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

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

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

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

事件插件化

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

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

document.on("focus", callback);

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

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

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

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

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


插槽插件化

这种插件化一般用在对 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>

分型插件化

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

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

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

确定生命周期

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

插件之间的依赖与通信

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

稍微解释下,依赖关系定义在业务项目中,比如 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。

核心功能的插件化

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

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

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

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

哪些插件需要内置

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

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

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

比如:create-react-app 对 dev 开发时的模版配置。如果没有这个模版,本地就无法开发,所以这个插件必须内置,而且需要考虑如何让其他插件对其拓展

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

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

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

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

依赖其他插件的插件

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

这种情况,需要明确这个插件是插件级别依赖,还是项目级别依赖。

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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