插件化设计模式在前端领域的应用

前言

软件开发中,随着系统功能变多,复杂度成指数级上升,而复杂度的增高多来源于模块间的耦合过于严重,插件化的设计模式能一定程度解决模块耦合的问题。抽象出系统的核心流程节点,基于这些节点与多个插件进行交互,最终实现整个系统。当然,前端领域的一些场景也有插件化应用的案例,本篇文章我们基于这些案例,一览其中的设计原理与插件核心执行流程。

案例

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,例如:应用初始化、挂载、更新、卸载等钩子。基于此我们封装出了:

  • 用户信息插件
  • 导航信息注入插件;
  • 样式隔离插件;
  • 组件库主题包预置插件;
  • 权限判断插件;
  • ... 等

以上实战,可以给予大家一些参考价值,具体实现细节包含敏感信息,暂不细说。

总结

插件式的设计模式在软件开发当中应用相当广泛,插件大多数由不同开发人员、甚至不同团队去开发。它不仅能降低系统模块间的耦合度,更能给予软件全局的约束和规范,这对于大型软件开发相当重要。我们纵览了前端开发领域的一些实战,对插件设计思路有了大致的了解,并且深入了下这些插件的执行流程。最后对我司的一些复杂场景的实战经验做了分享。

希望大家看完有所收获,完。

参考文档