前言
软件开发中,随着系统功能变多,复杂度成指数级上升,而复杂度的增高多来源于模块间的耦合过于严重,插件化的设计模式能一定程度解决模块耦合的问题。抽象出系统的核心流程节点,基于这些节点与多个插件进行交互,最终实现整个系统。当然,前端领域的一些场景也有插件化应用的案例,本篇文章我们基于这些案例,一览其中的设计原理与插件核心执行流程。
案例
Babel
Babel 是 JavaScript 编译器,更确切地说是源码到源码的编译器,通常也叫做“转换编译器(transpiler)”。 意思是说你为 Babel 提供一些 JavaScript 代码,Babel 更改这些代码,然后返回给你新生成的代码。
babel 不可能把所有 js 新特性都囊括进去,例如一些未进入标准的,或还在草案中的。所以使用插件化架构,用户需要哪些特性,自行增加 babel plugin 来使用,甚至自定义特定场景插件。
Babel 的三个主要处理步骤分别是: 解析(parse),转换(transform),生成(generate)。.这些步骤具体处理细节本篇文章不会扩展讲述,有兴趣可以查看 babel plugin handbook。
这里最复杂的步骤是 转换,同时也是 插件 的工作阶段。babel 插件通过访问者模式定位具体 AST 节点,并进行节点路径的各种操作。节点的操作类似 DOM ,同样有节点树,与基于整个节点树的增删改查。
babel 插件设计本身并不复杂,插件间完全互相隔离,且无互相拦截阻断通信、异步等互相依赖性、执行时序等问题,是比较纯粹的 AST 转换。假设我们有这么一段代码:
function square(n) {
return n * n;
}
它的树结构如下:
- FunctionDeclaration
- Identifier (id)
- Identifier (params[0])
- BlockStatement (body)
- ReturnStatement (body)
- BinaryExpression (argument)
- Identifier (left)
- Identifier (right)
当我们向下遍历这颗树的每一个分支时我们最终会走到尽头,于是我们需要往上遍历回去从而获取到下一个节点。 向下遍历这棵树我们进入每个节点,向上遍历回去时我们退出每个节点。
配置的插件数组依次执行,类似这样:
babel 在遍历前后会对应执行 pre hook & post hook 函数,针对所有插件配置的 hook 执行一遍,传递当前文件信息(BabelFile),BabelFile 实例会包含 ast、code、path 等内部信息。遍历 AST 的过程中,遍历到某个节点都会依次执行所有插件对应配置的 visitor type callback,并且提供 enter、exit 更加细节的调用行为,给予插件定义更多的执行时机。
示例代码:
const babel = require('@babel/core');
/**
* @returns {import('@babel/core').PluginItem}
*/
const createPlugin = (name) => {
const log = (...args) => console.log(`[${name}]:`, ...args);
return {
name,
pre(file) {
log('pre');
},
visitor: {
FunctionDeclaration(path, state) {
log('visit FunctionDeclaration');
},
ReturnStatement(path, state) {
log('visit ReturnStatement');
},
},
post(file) {
log('post');
},
};
};
babel.transform(
`
function a(m) {
return m*m;
}
`,
{
plugins: [createPlugin('A'), createPlugin('B')],
},
);
输出:
[A]: pre
[B]: pre
[A]: visit FunctionDeclaration
[B]: visit FunctionDeclaration
[A]: visit ReturnStatement
[B]: visit ReturnStatement
[A]: post
[B]: post
Koa
Koa 应用程序是一个包含一组中间件函数的对象,它是按照类似堆栈的方式组织和执行的。
koa-compose 是经典的洋葱模型实现,图中的每一层洋葱圈在 koa 中叫 中间件(middleware)。中间件即函数,其核心包含 context、next 概念。
- context: 即函数共享上下文对象,所有中间件都有完全控制权限;
- next 即调用内层函数(图中的被包裹洋葱圈),类似调用堆栈, next 即当前栈的上一层栈函数,由当前中间件决定何时调用。
洋葱模型扩展了一次性行为的拦截,预设数据等场景。很方便的对主链路进行数据更新,流程管控,流程校验等操作。对于 http request ,page bootstrap 这种一次执行场景非常适用。
中间件可将独立的逻辑做单独封装,解耦,降低系统复杂度,常用的中间件如:
- 错误拦截,将友好错误信息返回给前台;
- 缓存,可做到接口级别的缓存控制;
- Session 数据预置,用户信息之类的基础数据。
Axios
Axios 是跨平台(node browser)的 http request 库。Axios 也有插件的概念,Axios 中叫 interceptor 拦截器,针对请求相应进行拦截、操作、更新等逻辑。reques interceptor 可拿到 request config,并进行修改;response interceptor 可拿到 reqeust config 与 http response 等信息。
在 Axios 中,拦截器就是普通 js 函数,请求、相应拦截独立。
// Add a request interceptor
axios.interceptors.request.use(function (config) {
// Do something before request is sent
return config;
}, function (error) {
// Do something with request error
return Promise.reject(error);
});
执行流程为(需要特别注意 request interceptor 的执行顺序),rejected 为 fullfilled 执行失败 or 上一个 interceptor 抛异常时执行,核心就是 Promise.then 的链式执行逻辑。如下图:
Tapable(Webpack)
The tapable package expose many Hook classes, which can be used to create hooks for plugins.
Tapable 可以为插件提供钩子,webpack 的插件化架构就是基于此实现的,不同的执行流程产出不同的 hook 类型,webpack 打包流程中 hooks 点位非常多,并且根据需要,每个 hook 的类型会不同。相比以上案例,Tapable 实现相对会复杂很多,它包含了同步、异步并行/串行、可阻断、瀑布流等执行流程相关的概念,是集大成者。
Tapable 的四种同步流程:
除了标准的流程外,其他流程都是基于 插件返回值 做文章:
- Bail 可阻断流程,返回非 undefined 是执行阻断逻辑;
- Waterfall 瀑布流,插件输入为前一插件的输出;
Loop 循环执行,所有插件返回值都会 undefined 时,才结束,否则继续从头开始执行。(目前 webpack 暂时没用到)
Tapable 异步流程类型共有 5 种:
- AsyncParallelHook
- AsyncParallelBailHook
- AsyncSeriesHook
- AsyncSeriesBailHook
- AsyncSeriesWaterfallHook
增加了异步特有的并行、串行等逻辑,去除了 Loop。流程图与同步流程大致类似,就不重复绘制了。
实战
登录注册
我司登录注册是一个相当复杂的功能,其本身核心功能:
- 手机号验证码登录
- 账密登录
- 手机号验证码注册
- 三方注册
所有登录中涉及到:多账号绑定、登录风控、图片验证码、滑动验证码、协议弹窗;
所有注册流程涉及:手机绑定、身份选择、行业选择、协议弹窗;
基于此模块衍生出的扩展功能有:
- 多账号切换;
- 登录弹窗、登录页面;
- 多个站点公用统一套核心登录注册逻辑;
- 某站点可能会限制一些账号类型,权限等(例如仅商家账号可登陆);
- 某注册来源送 xxx 奖励,注册来源传递问题;
- 单点登录;
- 手动埋点;
- 某站点自定义可跳过某些注册流程,直接注册(拦截);
- ... 等
基于此,我们只保留核心流程,将其他流程作为插件形式,作为登录注册模块的插件,去修改、增加一些逻辑,去影响核心流程的走向 或单纯 获取事件点等。基于此衍生模块都成为了插件,整个模块复杂度被平摊到了不同插件内部中。
应用启动
我司主站,收敛启动逻辑,主流程仅提供一些启动所需的 hook,例如:应用初始化、挂载、更新、卸载等钩子。基于此我们封装出了:
- 用户信息插件
- 导航信息注入插件;
- 样式隔离插件;
- 组件库主题包预置插件;
- 权限判断插件;
- ... 等
以上实战,可以给予大家一些参考价值,具体实现细节包含敏感信息,暂不细说。
总结
插件式的设计模式在软件开发当中应用相当广泛,插件大多数由不同开发人员、甚至不同团队去开发。它不仅能降低系统模块间的耦合度,更能给予软件全局的约束和规范,这对于大型软件开发相当重要。我们纵览了前端开发领域的一些实战,对插件设计思路有了大致的了解,并且深入了下这些插件的执行流程。最后对我司的一些复杂场景的实战经验做了分享。
希望大家看完有所收获,完。