🔥 微内核架构在前端的实现及其应用

3,771 阅读13分钟

前言

写了很多代码之后,总感觉代码写的还是一团糟,也不想改。
我时常在想为什么这样写是好的,这么写是不好的,然后改成了这种好的写法。但是过一阵子又提笔写代码的时候还是用的不好的写法,大抵是好的写法并没有根深蒂固🤔。
不知道大家会不会有这样一种感觉,就是我们都看过一些框架和组件库的实现,当时大抵是懂了一点,过阵子照样全忘了,一问就卡壳🤔。
其实设计模式和一些架构思想确实是能帮我们快速理清逻辑的,但是这玩意又很虚,看不到摸不着,所以本篇文章举了个微内核架构的例子,希望能帮你拨开一层云雾🍺。

前置知识

微内核架构,大白话讲就是插件系统,就像下面这张图: image.png 从上图中可以看出,微内核架构主要由两部分组成:Core + plugin,也就是一个内核和多个插件。通常来说:

  • 内核主要负责一些基础功能、核心功能以及插件的管理;
  • 插件则是一个独立的功能模块,用来丰富和加强内核的能力。

内核和插件通常是解耦的,在不使用插件的情况下,内核也能够独立运行。看得出来这种架构拓展性极强,在前端主流框架中都能看到它的影子:

  • 你在用 vue 的时候,可以用 Vue.use()
  • webpack 用的一些 plugins
  • babel 用的一些 plugins
  • 浏览器插件
  • vscode 插件
  • koa 中间件(中间件也可以理解为插件)
  • 甚至是 jQuery 插件
  • ...

这里就简单罗列一下,不觉得也没关系,一会在我们实现完这个架构之后还会回过头来重温一次😄。

如何实现

话不多说,接下来就直接开撸,实现一个微内核系统。不过在做之前,我们要先明确要做什么东西,这里就以文档编辑器为例子吧,就是简版的所见即所得的文档(类似飞书文档、语雀、Notion 这种),每篇文档由多个 Block 区块组成,每个 Block 又可以展示不同的内容(列表、图片、图表等),这种情况下,我们要想扩展文档没有的功能(比如脑图),就很适合用插件系统做啦!当然了,这只是一个幌子,俺们并不会真正去实现👊🏻,这只是一个噱头(其实是打算在下一篇文章中用这个架构实现啦,哈哈🚩旗子立在这里了)。

如何调用

通常在开发之前,我们会先确定一下期望的调用方式,大抵应该是下面这个样子👇🏻:

// 内核初始化
const core = new Core();
// 使用插件
core.use(new Plugin1());
core.use(new Plugin2());
// 开始运行
core.run();

Core 和 Plugin 的实现

插件是围绕内核而生的,所以我们先来实现内核的部分吧,也就是 Core 类。通常内核有两个用途:一个是实现基础功能;一个是管理插件。基础功能要看具体使用场景,它是基于业务的,这里就略过了。我们的重点在插件,要想能够在内核中使用插件肯定是要在内核中开个口子的,最基本的问题就是如何注册、如何执行。一般来说插件的格式会长下面这个样子:

interface IPlugin {
    /** 插件名字 */
    name: string;
    /** 插件能力,通常是个函数,也可以叫 apply、exec、handle */
    fn: Function;
}

再看看前面定义的调用方式,core.use() 就是向内核中注册插件了,而 core.run() 则是执行,也就是说 Core 中需要有 use 和 run 这两个方法,于是我们就能简单写出如下代码👇🏻:

/** 内核 Core :基础功能 + 插件调度器 */
class Core {
    pluginMap: Map<string, IPlugin> = new Map();
    constructor() {
        console.log('实现内核基础功能:文档初始化');
    }
    /** 插件注册,也可以叫 register,通常以注册表的形式实现,其实就是个对象映射 */
    use(plugin: IPlugin): Core {
        this.pluginMap.set(plugin.name, plugin);
        return this; // 方便链式调用
    }
    /** 插件执行,也可以叫 start */
    run() {
        this.pluginMap.forEach(plugin => {
            plugin.fn();
        });
    }
}
class Plugin1 implements IPlugin {
    name = 'Block1';
    fn() {
        console.log('扩展文档功能:Block1');
    }
}
class Plugin2 implements IPlugin {
    name = 'Block2';
    fn() {
        console.log('扩展文档功能:Block2');
    }
}
// 运行结果如下:
// 实现内核基础功能:文档初始化
// 扩展文档功能:Block1
// 扩展文档功能:Block2

不管你相不相信😒,上面这点代码已经是一个极简的微内核架构雏形了,也能正确运行。现在要是想在 Core 中扩展一个文档功能,只要仿照上面的格式写一个 Plugin3 就行了,不用去修改内核代码,也不会影响到其他插件😎。

设计思想

一个好的架构肯定是能够体现一些设计模式思想的:

  • 单一职责:每个插件相互独立,只负责其对应的功能模块,也方便进行单独测试。如果把功能点都写在内核里,就容易造成高耦合。
  • 开放封闭:也就是我们扩展某个功能,不会去修改老代码,每个插件的接入成本是一样的,我们也不用关心内核是怎么实现的,只要知道它暴露了什么 api,会用就行。
  • 策略模式:比如我们在渲染文档的某个 Block 区块时,需要根据不同 Block 类型(插件名、策略名)去执行不同的渲染方法(插件方法、策略),当然了,当前的代码体现的可能不是很明显。
  • 还有一个就是控制反转:这个暂时还没体现,但是下文会提到,简单来说就是通过依赖注入实现控制权的反转(不懂没关系,先跳过)。

重点问题

要是一个架构有那么简单就好了,上面的那个雏形肯定还存在着一些问题,下面就让我们来深究几个比较重要的问题👇🏻:

通讯问题

前面我们说道,内核和插件是解耦的,那如果我需要在插件中用到一些内核的功能,该怎么和内核通信呢?插件与插件之间又该怎么通信呢?别急,先来解决第一个问题,这个很简单,既然插件要用内核的东西,那我把内核暴露给插件不就好了,就像下面这样👇🏻:

interface IPlugin {
    /** 插件名字 */
    name: string;
    /** 插件能力:这个 ctx 就是内核 */
    fn(ctx: Core): void;
}
class Core {
    run() {
        this.pluginMap.forEach(plugin => {
            plugin.fn(this); // 注意这里,我们把 this 传递了进去
        });
    }
}

这样一来我们就能在插件中获取 Core 实例了(也可以叫做上下文),相应的就能够用内核的一些东西了。比如内核中通常会有一些系统基础配置信息(isDev、platform、version 等),那我们在插件中就能根据不同环境、不同平台、不同版本进行进一步处理,甚至是更改内核的内容。
这个暴露内核的过程其实就是前文提到的控制反转,什么意思呢?比如通常情况下我们要在一个类中要使用另外一个类,你会怎么做呢?是不是会直接在这个类中直接实例化另外一个类,这个就是正常的控制权。现在呢,我们虽然需要在内核中使用插件,但是我们把内核实例暴露给了插件,变成了在插件中使用内核,注意,此时主动权已经在插件这里了,这就是通过依赖注入实现了控制反转,可以好仔体会一下🤯。
这种方式虽然我们能够引用和修改到内核的一些信息,但是好像不能干预内核初始化和执行的过程,要是想在内核初始化和执行过程中做一些处理该怎么办呢?我们可以引入事件机制,也就是发布订阅模式,在内核执行过程中抛出一些事件,然后由插件去监听相应的事件,我们可以叫 events,也可以叫 hooks(webpack里面就是 hooks),具体实现就像下面这样:

import { EventEmitter } from 'events'; // webpack 中使用 tapable,很强大的一个发布订阅库

class Core {
    events: EventEmitter = new EventEmitter(); // 也可以叫 hooks,就是发布订阅
    constructor() {
        this.events.emit('beforeInit');
        console.log('实现内核基础功能:文档初始化');
        this.events.emit('afterInit');
    }
    run() {
        this.events.emit('before all plugins');
        this.pluginMap.forEach(plugin => {
            plugin.fn(this);
        });
        this.events.emit('after all plugins');
    }
}
// 插件中可以这样调用:this.events.on('xxxx');

我们也可以改成生命周期的形式,比如:

class Core {
    constructor(opts?: IOption) {
        opts?.beforeCreate();
        console.log('实现内核基础功能:文档初始化');
        opts?.afterCreate();
    }
}

这就是生命周期钩子,是不是很眼熟,哈哈🧐。除了内核本身的生命周期之外,插件也可以有,一般分为加载、运行和卸载三部分,道理类似。当然这里要注意上面两种通信方式的区别,hooks 侧重的是时机,它会在特定阶段触发,而 ctx 则侧重于共享信息的传递。
至于插件和插件的通信,通常来说在这个模式中使用相互影响的插件是不友好的,也不建议这么做,但是如果一定需要的话可以通过内核和一些 hooks 来做桥接。

插件的调度

我们思考一个问题,插件的加载顺序对内核有影响吗?多个插件之间应该如何运行,它们是怎样的关系?看看我们的代码是直接 forEach 遍历执行的,这样的对吗?此处可以停下来思考几秒种🤔。
通常来说多个插件有以下几种运行方式:

  • 管道式:插件直接顺序执行 image.png
  • 洋葱式:插件也是顺序执行,但是会经过两次 image.png
  • 集散式:插件独立作用,和顺序无关,一般不修改内核,而是扩展各种功能 image.png

具体采用哪种方式就要看我们的具体业务场景了,比如我们的业务是文档编辑器,插件主要用于扩展文档的 Block 功能,所以顺序不是很重要,相当于上面的第三种模式,主要就是加强功能。那管道式呢,管道式的特点就是上一步的输入是下一步的输出,也就是管道流,这种就是对顺序有要求的,改变插件的顺序,结果也是不一样的,比如我们的业务核心是处理数据,input 就是原始数据,中间的 plugin 就是各种数据处理步骤(比如采样、均化、归一等),output 就是最终处理后的结果;又比如构建工具 gulp 也是如此,有兴趣的可以自行了解下。最后是洋葱式,这个最典型的应用场景就是 koa 中间件了,每个路由都会经过层层中间件,又层层返回;还有 babel 遍历过程中访问器 vistor 的进出过程也是如此。了解上图的三种模式你就大概能知道何时何地加载、运行和销毁插件了。
那我们除了用 use 来使用插件外,还可以用什么方式来注册插件呢?可以用声明式注入,也就是通过配置文件来告诉系统应该去哪里去取什么插件,系统运行时会按照约定的配置去加载对应的插件。比如 babel 就可以通过在配置文件中填写插件名称,运行时就会去自行查找对应的插件并加载(可以想想我们平时开发时的各种 config.js 配置文件)。而编程式的就是系统提供某种注册 api,开发者通过将插件传入该 api 中来完成注册,也就是本文使用的方式。
另外我们再来说个小问题,就是插件的管理,也就是插件池,有时我们会用 map,有时我们会用数组,就像这样:

map: {'pluginName1': plugin1, 'pluginName2': plugin2, ...}
数组:[new Plugin1(), new Plugin2(), ...]

这两种用法在日常开发中也很常见,那它们有什么区别呢?这就要看你要求了,用 map 一般都是要具名的,目的是为了存取方便,尤其是取,就像缓存的感觉;而如果我们只需要单纯的遍历执行插件用数组就可以了,当然如果复杂度较高的情况下,可以两者一起使用,就像下面这样👇🏻:

class Core {
    plugins: IPlugin[] = [];
    pluginMap: Map<string, IPlugin> = new Map();
    use(plugin: IPlugin): Core {
        this.plugins.push(plugin);
        this.pluginMap.set(plugin.name, plugin);
        return this;
    }
}

安全性和稳定性

因为这种架构的扩展性相当强,并且插件也有能力去修改内核,所以安全性和稳定性的问题也是必须要考虑的问题😬。
为了保障稳定性,在插件运行异常时,我们应当进行相应的容错处理,由内核来捕获并继续往下执行,不能让系统轻易崩掉,可以给个警告或错误的提示,或者通过系统级事件通知内核重启关闭该插件。
关于安全性,我们可以只暴露必要的信息给插件,而不是全部(整个内核),这是什么意思呢,其实就是要搞个简单的沙箱,在前端这边具体点就是用 with+proxy+白名单 来处理,node 用 vm 模块来处理,当然在浏览器中要想完全隔绝是很难的,不过已经有个提案的 api 在路上了,可以期待一下。

presets 预设

presets 这个字眼在前端中应该还是挺常见的,它其实是什么意思呢,中文我们叫预设,本质就是一些插件的集合,亦即 Core + presets(几个插件),内核可以有不同的预设,presets1、presets2 等等,每个 presets 就是几个不同插件的组合,主要用途就是方便,不用我们一个一个去引入去设置,比如 babel 的预设、vue 脚手架中的预设。当然除了预设之外,我们也可以将一些必备的插件固化为内置插件,这个度由开发者自己去把握。

应用场景

还记得我们开篇提到的几个应用场景吗,经过上面的一番讲解之后,我们再来重新审视一下它们,应该就豁然开朗了🤩 Here we go。

Webpack

image.png image.png 有兴趣可以点击官网链接看看。

babel

image.png image.png 有兴趣可以点击官网链接看看。

Taro

image.png image.png 有兴趣可以点击官网链接看看。

西瓜视频

image.png 有兴趣可以点击官网链接看看。

Vue

那当然还有我们熟悉的 vue 啦! image.png 类似 jQuery、window 从某种角度上来说也可以看做是内核,通过 $.fn() 或者在 prototype 中扩展方法也是一种插件的形式。

小结

最后我们再来巩固一下这篇文章的核心思想,微内核架构主要由两部分组成: Core + Plugin,其中:

Core 需要具备的能力有:

  • 基础功能,视业务情况而定
  • 一些配置信息(环境变量、全局变量)
  • 插件管理:通信、调度、稳定性
  • 生命周期钩子 hooks:约束一些特定阶段,用较少的钩子尽可能覆盖大部分场景

plugin 应该具备的能力有:

  • 能被内核调用
  • 可以更改内核的一些东西
  • 相互独立
  • 插件一般先注册后干预执行,有需要再提供卸载功能

其实微内核架构的核心思想就是在系统内部预留一些入口,系统本身功能不变,但是通过这个入口可以集百家之长以丰富系统自身🍺。 好啦,本次分享就到这里,有什么问题欢迎点赞评论留言,我们下期再见,拜拜👋🏻