本周精读内容是 《插件化思惟》。没有参考文章,资料源自 webpack、fis、egg 以及笔者自身开发经验。html
用过构建工具的同窗都知道,grunt
, webpack
, gulp
都支持插件开发。后端框架好比 egg
koa
都支持插件机制拓展,前端页面也有许多可拓展性的要求。插件化无处不在,全部的框架都但愿自身拥有最强大的可拓展能力,可维护性,并且都选择了插件化的方式达到目标。前端
我认为插件化思惟是一种极客精神,并且大量可拓展、须要协同开发的程序都离不开插件机制支撑。node
没有插件化,核心库的代码会变得冗余,功能耦合愈来愈严重,最后致使维护困难。插件化就是将不断扩张的功能分散在插件中,内部集中维护逻辑,这就有点像数据库横向扩容,结构不变,拆分数据。react
理想状况下,咱们都但愿一个库,或者一个框架具备足够的可拓展性。这个可拓展性体如今这三个方面:webpack
咱们都清楚插件化应该能解决问题,但从哪下手呢?这就是笔者但愿分享给你们的经验。git
作技术设计时,最好先从使用者角度出发,当设计出舒服的调用方式时,再去考虑实现。因此咱们先从插件使用者角度出发,看看能够提供哪些插件使用方式给开发者。github
插件化许多都是从设计模式演化而来的,大概能够参考的有:命令模式,工厂模式,抽象工厂模式等等,笔者根据我的经验,总结出三种插件化形式:web
最后还有一个不算插件化实现方式,但效果比较优雅,姑且称为分形插件化吧。下面一一解释。typescript
按照某个约定来设计插件,这个约定通常是:入口文件/指定文件名做为插件入口,文件形式.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
。
顾名思义,经过事件的方式提供插件开发的能力。
这种方式的框架之间跨界更大,好比 dom 事件:
document.on("focus", callback);
复制代码
虽然只是普通的业务代码,但这本质上就是插件机制:
也能够解释为,事件机制就是在一些阶段放出钩子,容许用户代码拓展总体框架的生命周期。
service worker
就更明显,业务代码几乎彻底由一堆时间监听构成,好比 install
时机,随时能够新增一个监听,将 install
时机进行 delay,而不须要侵入其余代码。
在事件机制玩出花样的应该算 koa
了,它的中间件洋葱模型很是有名,换个角度理解,能够认为是能控制执行时机的事件插件化,也就是只要想把执行时机放在全部事件执行完毕时,把代码放在 next()
以后便可,若是想终止插件执行,能够不调用 next()
。
举例:
koa
、service worker
、dom events
。
这种插件化通常用在对 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
的实现就无需顾及啦,只须要默认子元素有哪些约定便可。
举例:
gaea-editor
。
表明 egg,特色是插件结构与项目结构分型,也就是组成大项目的小插件,自身结构与项目结构相同。
由于对于 node server 的插件来讲,要实现的功能应该是项目功能的子集,而自己 egg 对功能是按照目录结构划分的,因此插件的目录结构与项目一致,看起来也很美观。
举例:
egg
。
固然不是全部插件都能写成目录分形的,这也刚好解释了 egg
与 koa
之间的关系:koa
是 node 框架,与项目结构无关,egg
是基于 koa
上层的框架,将项目结构转化成 server 功能,而插件须要拓展的也是 server 功能,刚好能够用项目结构的方式写插件。
一个支持插件化的框架,核心功能是整合插件以及定义生命周期,与功能相关的代码反而能够经过插件实现,下一小节再展开说明。
根据 2.1 节的描述,咱们根据项目的功能,找到一个合适的插件使用方式,这会决定咱们如何执行插件。
插件注册方式很是多样,这里举几个例子:
经过 npm 注册:好比只要 npm 包符合某个前缀,就会自动注册为插件,这个很简单,不举例子了。
经过文件名注册:好比项目中存在 xx.plugin.ts
会自动作到插件引用,固然这通常做为辅助方案使用。
经过代码注册:这个很基础,就是经过代码 require
就行,好比 babel-polyfill
,不过这个要求插件执行逻辑正好要在浏览器运行,场景比较受限。
经过描述注册:好比在 package.json
描述一个属性,代表了要加载的插件,好比 .babelrc
:
{
"presets": ["es2015"]
}
复制代码
自动注册:比较暴力,经过遍历可能存在的位置,只要知足插件约定的,会自动注册为插件。这个行为比较像 require
行为,会自动递归寻找 node_modules
,固然别忘了像 require
同样提供 paths
让用户手动配置寻址起始路径。
肯定插件注册方式后,通常第一件事就是加载插件,后面就是根据框架业务逻辑不一样而不一样的生命周期了,插件在这些生命周期中扮演不一样的功能,咱们须要经过一些方式,让插件可以影响这些过程。
通常经过事件、回调函数的方式,支持插件对生命周期的拦截,最简单的例子好比:
document.on("click", callback);
复制代码
就是让插件拦截了 click
这个事件,固然这个事件与 dom 的生命周期相比微乎其微,但也算是一个微小的生命周期,咱们也能够 event.stopPropagation()
阻止冒泡,来影响这个生命周期的逻辑。
插件之间不免有依赖关系,目前有两种方式处理,分为:依赖关系定义在业务项目中,与依赖关系定义在插件中。
稍微解释下,依赖关系定义在业务项目中,好比 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,这里简单介绍一下。
利用 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 修改掉了。
2.2 开头说到,插件化框架的核心代码主要功能是对插件的加载、生命周期的梳理,以及实现 hook 让插件影响生命周期,最后补充上插件的加载顺序以及通讯,就比较完备了。
那么写到这里,衡量代码质量的点就在于,是否是全部核心业务逻辑均可以由插件完成?由于只有用插件实现核心业务逻辑,才能检验插件的能力,进而推导出第三方插件是否拥有足够的拓展能力。
若是核心逻辑中有一部分代码没有经过插件机制编写,不只让第三方插件也没法拓展此逻辑,并且还不利于框架的维护。
因此这主要是个思想,但愿开发者首先明确哪些功能应该作成插件,以及将哪些插件固化为内置插件。
笔者认为应该提早思考清楚三点:
这个是业务相关的问题,但整体来看,开源的,基础功能以及体现核心竞争力的能够内置,能够开源与核心竞争力都比较好理解,主要说下基础功能:
基础功能就是一个业务的架子。由于插件机制的代码并不解决任何业务问题,一个没有内置插件的框架确定什么都不是,因此选择基础功能就尤其重要。
举个例子,好比作构建工具,至少要有一个基本的配置做为模版,其余插件经过拓展这个配置来修改构建效果。那么这个基本配置就决定了其余插件能够如何修改它,也决定了这个框架的配置基调。
好比:create-react-app
对 dev 开发时的模版配置。若是没有这个模版,本地就没法开发,因此这个插件必须内置,并且须要考虑如何让其余插件对其拓展,这个在 2.3.2 节详细说明。
另外一种状况就是很是基本,而又不须要再拓展加工的能够作成内置插件,好比 babel
对 js 模块的 commonjs
分析逻辑就不须要暴露出来,由于这个标准已经肯定,既不须要拓展,又是 babel 运行的基础,因此确定要内置。
功能彻底正交的插件是最完美的,由于它既不会影响其余插件,也不须要依赖任何插件,自身也不须要被任何插件拓展。
在写非正交功能的插件时就要担忧了,咱们仍是分为三个点去看:
举个例子,好比插件 X 须要拓展命令行,在执行 npm start
时统计当前用户信息并打点。那么这个插件就要知道当前登录用户是谁。这个功能刚好是另外一个 “用户登录” 插件完成的,那么插件 X 就要依赖 “用户登录” 插件了。
这种状况,根据 2.2.5 插件依赖小节经验,须要明确这个插件是插件级别依赖,仍是项目级别依赖。
固然,这种状况是插件级别依赖,咱们把依赖关系定义在插件 X 中便可,好比 package.json
:
"plugin-dep": ["user-login"]
复制代码
另外一种状况,好比咱们写的是 babel-loader
插件,它在 ts 项目中依赖 ts-loader
,那只能在项目中定义依赖了,此时须要补充一些文档说明 ts 场景的使用顺序。
若是插件 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 文件处理,不要影响其余规则。
这点是最难的,难在如何设计拓展的粒度。
因为全部场景都相似,咱们拿对模版的拓展举例子,其余场景能够类比:插件 X 定义了入口文件的基础内容,但还要提供一些 hook 供其余插件修改入口文件。
假设入口文件通常是这样的:
import * as React from "react";
import * as ReactDOM from "react-dom";
import { App } from "./app";
ReactDOM.render(<App />, document.getELementById("root"));
复制代码
这种最简单的模版,其实内部要考虑如下几点潜在拓展需求:
hot(App)
替换 App
做为入口,怎么支持?root
,怎么支持?ReactDOM.hydrate
而不是 ReactDOM.render
,怎么支持?笔者此处给出一种解决方案,供你们参考。另外要注意,这个方案随着考虑到的使用场景增多,是要不断调整变化的。
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.2.3 节,为内置插件考虑最大化的拓展机制,才能确保内置插件的功能不会变成拓展性瓶颈。
每新增一个内置的插件,都在消灭一部分拓展能力,由于由插件拓展后的区块拥有的拓展能力,应该是逐渐减弱的。这里比较拗口,能够比喻为,一条小溪流,插件就是层层的水处理站,每新增一个处理站就会改变下游水势变化,甚至可能将水拦住,下游一滴水也拿不到。
2.3.1 节说了哪些插件须要内置,而这一节想说明的是,谨慎增长内置插件数量,由于内置的越多,框架拓展能力就越弱。
最后梳理下插件化适用场景,笔者根据有限的经验列出一下一些场景。
若是你要作一个前/后端开发框架,插件化是必然,好比 react
的生命周期,koa
的中间件,甚至业务代码用到的 request 处理,都是插件化的体现。
支持插件化的脚手架具备拓展性,社区方便提供插件,并且脚手架为了适配多种代码,功能可插拔是很是重要的。
一些小的工具库,好比管理数据流的 redux 提供的中间件机制,就是让社区贡献插件,完善自身的功能。
若是业务项目很复杂,同时又有多人协做完成,最好按照功能划分来分工。可是分工若是只是简单的文件目录分配方式,必然致使功能的不均匀,也就是每一个人开发的模块可能不能访问全部系统能力,或者涉及到与其余功能协同时,文件相互引用带来代码的耦合度提升,最终致使难以维护。
插件化给这种项目带来的最大优点就是,每个人开发的插件都是一个拥有完整功能的个体,这样只须要关心功能的分配,不用担忧局部代码功能不均衡;插件之间的调用框架层已经作掉了,因此协同不会发生耦合,只须要申明好依赖关系。
插件化机制良好的项目开发,和 git 功能分支开发的体验有类似之处,git 给每一个功能或需求开一个分支,而插件化可让每一个功能做为一个插件,而 git 功能分支之间是无关联的,因此只有功能之间正交的需求才能开多个分支,而插件机制能够考虑到依赖状况,进行更复杂的功能协同。
如今尚未找到对插件化系统化思考的文章,因此这一篇算是抛砖引玉,你们必定有更多的框架开发心得值得分享。
同时也想借这篇文章提升你们对插件化必要性的重视,许多状况插件化并非小题大作,由于它能带来更好的分工协做,而分工的重要性不言而喻。
若是你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。