简述虚拟DOM中的VNode种类

VNode 是真实 DOM 的描述,在vue中,使用tag来描述vnode的种类(相关源码),比如我们可以用如下对象描述一个 div 标签:

const elementVnode = {
  tag: 'div'
}

VNode 描述不同的 DOM 时,其属性的值也各不相同。比如一个 VNode 对象是 html 标签的描述,那么其 tag 属性值就是一个字符串,即标签的名字;如果是组件的描述,那么其 tag 属性值则引用组件类(或函数)本身;如果是文本节点的描述,那么其 tag 属性值为 null。最终我们发现,不同类型的 VNode 拥有不同的设计,这些差异积少成多,所以我们完全可以将它们分门别类。

VNode的种类

总的来说,在vue中把 VNode 分成五类,分别是:html/svg 元素组件纯文本Fragment 以及 Portal

组件

基于现有设计,我们可以把组件可以分为为 有状态组件函数式组件。同时在vue中,有状态组件还可以细分为三部分:普通的有状态组件需要被 keepAlive 的有状态组件 以及 已经被 keepAlive 的有状态组件 。但无论是普通的有状态组件还是 keepAlive 相关的有状态组件,它们都是有状态组件。所以我们在设计 VNode 时可以将它们作为一类看待。

Fragment

Fragments 允许你将子列表分组,而无需向 DOM 添加额外节点。一种常见模式是组件返回一个子元素列表,而我们不希望使用一个元素标签包裹占位,这个时候 Fragment 就比较适合。

Portal

Portal 提供了将子节点渲染到存在于父组件以外的 DOM 节点的方案。而且 Portal 可以不严谨地认为是可以被到处挂载的 Fragment,但是虽然 Portal 的内容可以被渲染到任意位置,但它的行为仍然像普通的DOM元素一样,如事件的捕获/冒泡机制仍然按照代码所编写的DOM结构实施。要实现这个功能就必须需要一个占位的DOM元素来承接事件。

如何标识 VNode

既然 VNode 有类别之分,我们就有必要使用一个唯一的标识,来标明某一个 VNode 属于哪一类。同时给 VNode 添加 flags 也是 Virtual DOM 算法的优化手段之一。

比如在 Vue2 中区分 VNodehtml 元素还是组件亦或是普通文本,是这样做的:

  • 1、拿到 VNode 后先尝试把它当作组件去处理,如果成功地创建了组件,那说明该 VNode 就是组件的 VNode
  • 2、如果没能成功地创建组件,则检查 vnode.tag 是否有定义,如果有定义则当作普通标签处理
  • 3、如果 vnode.tag 没有定义则检查是否是注释节点
  • 4、如果不是注释节点,则会把它当作文本节点对待

以上这些判断都是在挂载(或patch)阶段进行的,换句话说,一个 VNode 到底描述的是什么是在挂载或 patch 的时候才知道的。这就带来了两个难题:无法从 AOT 的层面优化开发者无法手动优化

if (flags & VNodeFlags.ELEMENT) {
  // VNode 是普通标签
  mountElement(/* ... */)
} else if (flags & VNodeFlags.COMPONENT) {
  // VNode 是组件
  mountComponent(/* ... */)
} else if (flags & VNodeFlags.TEXT) {
  // VNode 是纯文本
  mountText(/* ... */)
}

如上,采用了位运算,在一次挂载任务中如上判断很可能大量的进行,使用位运算在一定程度上再次拉升了运行时性能。

这就意味着我们在设计 VNode 对象时,应该包含 flags 字段:

// VNode 对象
{
  flags: ...
}

枚举值 VNodeFlags

那么一个 VNode 对象的 flags 可以是哪些值呢?那就看 VNode 有哪些种类就好了,每一个 VNode种类我们都为其分配一个 flags 值即可,我们把它设计成一个枚举值并取名为 VNodeFlags,在 javascript 里就用一个对象来表示即可:

const VNodeFlags = {
  // html 标签
  ELEMENT_HTML: 1,
  // SVG 标签
  ELEMENT_SVG: 1 << 1,

  // 普通有状态组件
  COMPONENT_STATEFUL_NORMAL: 1 << 2,
  // 需要被keepAlive的有状态组件
  COMPONENT_STATEFUL_SHOULD_KEEP_ALIVE: 1 << 3,
  // 已经被keepAlive的有状态组件
  COMPONENT_STATEFUL_KEPT_ALIVE: 1 << 4,
  // 函数式组件
  COMPONENT_FUNCTIONAL: 1 << 5,

  // 纯文本
  TEXT: 1 << 6,
  // Fragment
  FRAGMENT: 1 << 7,
  // Portal
  PORTAL: 1 << 8
}

我们注意到,这些枚举属性的值基本都是通过将十进制数字 1 左移不同的位数得来的。根据这些基本的枚举属性值,我们还可以派生出额外的三个标识:

// html 和 svg 都是标签元素,可以用 ELEMENT 表示
VNodeFlags.ELEMENT = VNodeFlags.ELEMENT_HTML | VNodeFlags.ELEMENT_SVG
// 普通有状态组件、需要被keepAlive的有状态组件、已经被keepAlice的有状态组件 都是“有状态组件”,统一用 COMPONENT_STATEFUL 表示
VNodeFlags.COMPONENT_STATEFUL =
  VNodeFlags.COMPONENT_STATEFUL_NORMAL |
  VNodeFlags.COMPONENT_STATEFUL_SHOULD_KEEP_ALIVE |
  VNodeFlags.COMPONENT_STATEFUL_KEPT_ALIVE
// 有状态组件 和  函数式组件都是“组件”,用 COMPONENT 表示
VNodeFlags.COMPONENT = VNodeFlags.COMPONENT_STATEFUL | VNodeFlags.COMPONENT_FUNCTIONAL

其中 VNodeFlags.ELEMENTVNodeFlags.COMPONENT_STATEFUL 以及 VNodeFlags.COMPONENT 是由基本标识通过按位或(|)运算得到的,这三个派生值将用于辅助判断。

有了这些 flags 之后,我们在创建 VNode 的时候就可以预先为其打上 flags,以标明该 VNode 的类型:

// html 元素节点
const htmlVnode = {
  flags: VNodeFlags.ELEMENT_HTML,
  tag: 'div',
  data: null
}

// svg 元素节点
const svgVnode = {
  flags: VNodeFlags.ELEMENT_SVG,
  tag: 'svg',
  data: null
}

// 函数式组件
const functionalComponentVnode = {
  flags: VNodeFlags.COMPONENT_FUNCTIONAL,
  tag: MyFunctionalComponent
}

// 普通的有状态组件
const normalComponentVnode = {
  flags: VNodeFlags.COMPONENT_STATEFUL_NORMAL,
  tag: MyStatefulComponent
}

// Fragment
const fragmentVnode = {
  flags: VNodeFlags.FRAGMENT
  // 注意,由于 flags 的存在,我们已经不需要使用 tag 属性来存储唯一标识
  tag: null
}

// Portal
const portalVnode = {
  flags: VNodeFlags.PORTAL
  // 注意,由于 flags 的存在,我们已经不需要使用 tag 属性来存储唯一标识,tag 属性用来存储 Portal 的 target
  tag: target
}

如下是利用 VNodeFlags 判断 VNode 类型的例子,比如判断一个 VNode 是否是组件:

// 使用按位与(&)运算
functionalComponentVnode.flags & VNodeFlags.COMPONENT // 真
normalComponentVnode.flags & VNodeFlags.COMPONENT // 真
htmlVnode.flags & VNodeFlags.COMPONENT // 假

熟悉位运算的话,理解起来很简单。这实际上是多种位运算技巧中的一个小技巧。我们可以列一个表格:

VNodeFlags 左移运算 32 位的 bit 序列(出于简略,只用 9 位表示)
ELEMENT_HTML 000000001
ELEMENT_SVG 1 << 1 000000010
COMPONENT_STATEFUL_NORMAL 1 << 2 000000100
COMPONENT_STATEFUL_SHOULD_KEEP_ALIVE 1 << 3 000001000
COMPONENT_STATEFUL_KEPT_ALIVE 1 << 4 000010000
COMPONENT_FUNCTIONAL 1 << 5 000100000
TEXT 1 << 6 001000000
FRAGMENT 1 << 7 010000000
PORTAL 1 << 8 100000000

根据上表展示的基本 flags 值可以很容易地得出下表:

VNodeFlags 32 位的比特序列(出于简略,只用 9 位表示)
ELEMENT 00000001 1
COMPONENT_STATEFUL 00001 1 100
COMPONENT 0001 1 1 100

所以只有 VNodeFlags.ELEMENT_HTMLVNodeFlags.ELEMENT_SVGVNodeFlags.ELEMENT 进行按位与(&amp;)运算才会得到非零值,即为真,这样我们就可以很方便的判断出一个元素到底是什么类型的VNode了。

前端模块化方案

为什么需要模块化概念?

在最初的web1.0时代,都是通过服务器解析好所有的页面,用户的每一次交互都会触发浏览器的刷新,而且js刚诞生,大家都是用来给页面加一点动效,代码量不多,所以也不会有统一的规范,也不会探讨什么依赖、组合,需要什么效果网上copy一段代码,能跑起来就可以了,不行就再换一段试试。

但是在一个页面的JavaScript代码一点一点躲起来的时候,大家就会发现各种变量污染,莫名其妙的bug,所以大家都开始思考如何取组织代码防止污染或被污染,

开荒时代

最开始大家都喜欢使用一个立即执行的匿名函数来包裹代码:

;function(){
        // todo ...
}();

这样实现了最初始的变量隔离,在jQuery大行其道的时代,我们可以经常看到这样的实现。

nodejs 引入了 CommonJS

但是在nodejs诞生之后,我们的JavaScript可以跑在服务器上了,而且也带来了一种模块化的实现方式:CommonJS,因为nodejs运行于服务器,直接读取硬盘要快很多,所以CommonJS以同步的方式加载模块,用module.exports定义当前模块对外输出的接口,用require加载模块,常见的代码文件类似于:

var http = require('http');
function add(a, b) {
  return a + b;
}
module.exports = {
  add: add,
}

AMD

nodejs的出现不仅带给前端很多的想象,也带来了模块化方案的启蒙,由于浏览器端和服务器端最大的区别就是前者的瓶颈是带宽,而后者的瓶颈时CPU及内存等资源,所以社区依据CommonJS推出了AMD规范,AMD的全称为 Asynchronous Module Definition,即是异步模块定义。由于AMD依据于CommonJS产生,所以,它的模块定义如下:

define(id?, dependencies?, factory);

它的id和dependencies都是可选的,与nodejs模块相似的地方在于factory的内容就是实际代码的内容,例如下面的代码定义了一个简单的模块:

define(["alpha"], function (alpha) {
  return {
    verb: function(){
      return alpha.verb() + 2;
    }
  };
});

在AMD模块中,通过define来明确定义一个模块,而在CommonJS中时隐式包装的,他们的目的都是进行作用域隔离,而且AMD提倡依赖前置、提前加载,也就是说,当你定义了依赖的时候,浏览器会提前加载相关模块。

CMD

在AMD诞生的同时,国内前端社区也悄悄流行起另外一种模块化方案:CMD,CMD是由玉波提出,并在当年淘宝中大量应用,CMD与AMD相同之处也是异步方式加载模块,但不同之处是提倡依赖就近、延迟加载,而且与AMD相比,CMD更接近于CommonJS规范的定义:

define(factory);

在依赖部分,CMD支持动态引入,例如:

define(function(require, exports, module){
    // code...
});

require,、exports和module都是通过形参传递给模块,在需要依赖依赖模块是,随时调用require()引入即可,例如:

define(function(require, exports, module) {
    var a = require('./a'); //在需要时申明
    a.doSomething();
    if (false) {
        var b = require('./b'); // 不会引入
        b.doSomething();
    }
});

ES Module

由于ES6的模块化规范制定的较晚,各个模块加载器都各持己见,互不兼容,导致生态环境独立。ES的模块化功能是在语言标准的层面上的实现,而且实现得相当简单,旨在成为浏览器和服务器通用的模块解决方案。而且它的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。其模块功能主要由两个命令构成:exportimportexport命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。例如:

// 引入模块
import { printLog } from 'my_module';

var basicNum = 0;
var add = function (a, b) {
  return a + b;
};
// 对外接口
export { basicNum, add };

由于ES6 Module是编译时加载,使得静态分析成为可能,而且默认采用JS的严格模式,所以也可以避免很多语法滥用的问题,鉴于是ES的亲儿子,所以浏览器也都在逐步的支持。我们只需要在script标签添加type="module"属性,既可以在新版本的chrome中都可以启用,多个“,会按照在页面出现的顺序依次执行。

如何制定有效的Lint规范

在我们的项目中,lint工具总是提示着各种各样的error、warning,但是我们共同维护的代码库里依然有着各种不同风格的实现,为此很多时候我们只能不断的修改我们的lint规则,防止魔法代码的提交,但为此我们提出了更多的lint规则,到最后我们lint规则配置的繁琐而又不起作用。

在社区中,大家也越来越推崇用严格的lint规则来避免不断的浪费时间去设定、争论lint规则是否合理,但是我认为我们确实不应该花费太多的精力去争论这些没有意义的事情,但是我也不赞同使用一些较流行的严格的lint规则,在我们选择这些严格的lint规范的时候,我觉得我们是为了避免一个问题而引入了另一个问题,使用团队成员都不熟悉的lint规范,会让效率大大降低不说,还可能导致很多时候为了避免报error,而书写一些魔法代码。比如,我们如果配置了禁止在 componentDidMount 中调用 setState,那么很多时候,我们的实现都会发生报错,或者为了避免报错,把setState放在一个定时器里,这样是避免了lint报错,但是这个实现是在让人捉急。

那么我们怎么去制订一个不仅可以提高项目质量又可以保持大家效率的lint规则呢?

我觉得我们可以从以下几个方面出发:

一、禁止让人理解困难的代码

这一条算是比较好理解的,在多人/长期维护的项目中不要写一些让人摸不到头脑的“炫技”代码,比如位运算,如果我在项目中写了如下代码let a = ~~num;,这可能在一些经验丰富或者计算机基础扎实的工程师眼里根本不算什么,但是还是让很多人费解他要表达什么,但是我如果使用let a = parseInt(num);这样的话是不是就很理解了,其实大多数位运算都有更易于理解的实现,而且并不见的性能有啥损耗或者文件会增大多少字节,所以我们在lint中需要禁止一些让人理解困难的代码风格。

二、团队内推广最佳实践

我们首先需要明确一件事,那就是:lint不是万能的。所以我们怎么样让我们的项目保持一个高可维护的状态呢?我觉得我们还需要不断推广各种最佳实践,在 wiki 或设计指南里分享有用的知识,把各种最佳实践梳理出文档或工具,通过一条命令或者一键就可以生成我们需要的目录/代码结构,我觉得比我们去设置按字母序排列之类的规则有用的多,这样我们在多个项目中切换,或者多个文件中查看代码,都可以预期知道哪里会有我们需要的代码,或者哪里会出现解决问题的地方,这回让我们的效率更高,让规范在项目的每个角落落地,

三、让可修复的问题自动修复

在开发过程中你完全不需要一个工具告诉你得在这加一个空格,而且很多提示都可以通过使用 Prettier 或 Exlint —fix来修复问题,我们可以在项目/编辑器中配置自动执行来修复一些规则提示,提供效率。

四、提高代码设计能力

不论多少缩进或按字母序排列,都不能修复糟糕的设计,所以我们需要提高代码设计能力,如何行之有效的设计代码?这是一个很大的话题,不便展开来讲,在《代码大全》还有《重构——改善既有代码的设计》中已经提到了很多好的代码设计原则,而且业界比较出名的设计原则,我们可以坚持,比如:

  • 不要重复自己原则,系统的每一个功能都应该有唯一的实现。也就是说,如果多次遇到同样的问题,就应该抽象出一个共同的解决方法,不要重复开发同样的功能。
  • 你不会需要它原则,指的是你自以为有用的功能,实际上都是用不到的。
  • 单一职责原则,大到一个系统、模块的职责边界划分,小到一个类、一个方法的职责。清晰的划分类、方法的职责,保持其单一、稳定的功能,能够有效的降低日后代码越来越臃肿时带来的维护成本增加。
  • 开放封闭原则,对扩展开放,对修改关闭。也就是在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。
  • 最少知道原则,就是说:一个实体应当尽量少的与其他实体之间发生相互作用,使得系统功能模块相对独立。
  • 合成复用原则,简言之:要尽量使用组合/聚合关系,少用继承。

还有很多好的设计规范我们可以在开发中不断实践,去追求实现的代码模块都能达到高内聚、低耦合、易扩展、易替换的优秀设计。

五、对一些规则追问:这条规则有帮我们找到过 bug 吗

我们不应该假设每一条规则都会提供给我们有效的建议,我们应该把团队内的成员组织到一起,逐条过一遍lint规则,把没有用的规则剔除出去,让我们的规则就是风格指南,一眼就可以明白哪些实现方式或者书写格式是合理的,是行之有效让编码风格一致的。

六、组内使用统一lint的规范

我觉得这最后一条也算是团队合作比较核心的一条——使用统一的lint规范,只有大家都是用统一的lint规范,在不同的项目间切换开发的时候才能保持一致的编程体验,降低“过敏反应”!

DOM事件流三连:捕获、目标、冒泡

DOM事件流三连:捕获、目标、冒泡

在浏览器前端的人机交互中,所有的操作都是通过浏览器实现的DOM事件来完成的,每当我们点击一个按钮的时候,会触发一系列绑定的事件,通常我们会去通过DOM Element的addEventListener来给元素绑定监听事件,但是有时候,多人协作开发的页面中,总会触发一些莫名其妙的连锁事件,这个时候我们怎么去避免或者处理这些问题,让他们各自在合适的时机完成各自的事件回调,这就需要我们了解浏览器的事件流过程。

事件流三阶段

在W3C的规定中完整的事件流包括三个阶段:事件捕获阶段、处于目标阶段和冒泡阶段。如下图所示:

W3C对事件流三个阶段的解释为:

捕获阶段:事件对象传播通过从目标的祖先Window到目标的父级节点,也就是上图的P1 –> P4–>m0。

目标阶段:事件对象到达事件对象的目标。这一阶段也称为在目标阶段。如果事件类型表明事件不用冒泡,那么事件对象将在完成这一阶段后停止,即上图的m0。

冒泡阶段:事件对象传播通过从目标开始,一直到祖先Window节点结束,冒泡阶段与捕获阶段是相反的顺序,也就是上图的m0 –> m1 –> m4。

事件对象在执行上面的流程时,并不是每次都完全执行,当遇到不支持某阶段的事件或者事件对象被停止了时,就会跳过部分阶段。

事件回调执行顺序

现在浏览器大多实现了DOM3级的事件流模型,而我们大多前端看的小红书还是DOM2级的事件模型,由于历史原因及其他原因,DOM2级的事件模型在不同的浏览器实现中都各有不同,而DOM3级的事件流模型也对此做了兼容和修正。在DOM3级的事件流模型中介绍了以下新概念:

  • 现在订阅事件监听器是有序的。在DOM Level 2中事件排序未指定。
  • 现在的事件流包括Window,包含浏览器已有的实现

为了清晰的展示事件是如何传播的我们看下面的示例,我们通过传递addEventListener的第三个参数区别事件是在捕获阶段还是冒泡阶段

<!-- dom结构 -->
<body class="elm" id="body">
    body
    <div class="box elm" id="box">
        box
        <div class="item1 elm" id="item1">
            item1
            <div class="sub elm" id="sub">sub</div>
        </div>
        <div class="item2 elm" id="item2">item2</div>
        <div class="item3 elm" id="item3">item3</div>
    </div>
</body>
// 下面方法仅适用于演示使用
window.id = 'window';
document.id = 'document';
var elms = [window, document].concat(
    Array.from(document.querySelectorAll('.elm'))
);
// 绑定事件监听
elms.forEach(function(el) {
    el.addEventListener(
        'click',
        function(e) {
            console.log('冒泡:', el.id);
            if (el.id === 'item1') {
                e.stopImmediatePropagation();
            }
        },
        false
    );
    el.addEventListener(
        'click',
        function() {
            console.log('捕获:', el.id);
        },
        true
    );
});

Edit static 点击按钮进入在线示例

在上例中,我们很明显的发现打印的结果并不是我们所设想的那样,而是如下图所示:

当点击sub元素的时候,捕获传递到sub的时候,先触发了他的冒泡事件,而后才是捕获事件,这是什么原因?

我们可以看到我们的事件绑定是这样的,示例1:

elms.forEach(function(el) {
    // 绑定冒泡
    el.addEventListener(
        'click',
        function() {
            console.log('冒泡:', el.id);
        },
        true
    );
    // 绑定捕获
    el.addEventListener(
        'click',
        function() {
            console.log('捕获:', el.id);
        },
        false
    );
});

那我们调整下代码绑定顺序,示例2:

elms.forEach(function(el) {
    // 绑定捕获
    el.addEventListener(
        'click',
        function() {
            console.log('捕获:', el.id);
        },
        false
    );
    // 绑定冒泡
    el.addEventListener(
        'click',
        function() {
            console.log('冒泡:', el.id);
        },
        true
    );
});

再次执行,得到结果:

这次符合我们的预期,由此可以看出,我们如果使用addEventListener来绑定事件,最终先执行目标的捕获阶段还是冒泡阶段是和顺序有关系的。所以如果我们没有注意绑定顺序。

中断事件冒泡

通常,我们停止一个事件的冒泡使用stopPropagation方法,但是在DOM3级事件模型中新增了一个stopImmediatePropagation,如果在sub绑定的冒泡事件中使用stopImmediatePropagation来阻止冒泡的话会发生什么?

elms.forEach(function(el) {
    // 冒泡
    el.addEventListener(
        'click',
        function(e) {
            console.log('冒泡:', el.id);
            if (el.id === 'sub') {
                // 停止冒泡
                e.stopImmediatePropagation();
            }
        },
        false
    );
    // 捕获
    el.addEventListener(
        'click',
        function() {
            console.log('捕获:', el.id);
        },
        true
    );
});

触发事件后,我们会发现,输出如下:

可以看到这样也会停止掉sub绑定的捕获事件,stopImmediatePropagation是DOM3级新增的事件方法,在W3C中对stopImmediatePropagation是如下定义的:

Invoking this method prevents event from reaching any registered event listeners after the current one finishes running and, when dispatched in a tree, also prevents event from reaching any other objects.

大致意思就是:如果有多个相同类型事件的监听函数绑定到同一个元素,当该类型的事件触发时,它们会按照被添加的顺序执行。如果其中某个监听函数执行了 event.stopImmediatePropagation() 方法,则当前元素剩下的监听函数将不会被执行。

有不同的事件

虽然大多数事件都有冒泡阶段,但是也有部分事件没有,具体为:

  • UI事件
    • load
    • unload
    • abort:
    • error:当一个资源加载失败时会触发error事件
    • select
    • scroll
    • resize
  • 焦点事件
    • blur
    • focus
  • 鼠标事件
    • mouseleave
    • mouseenter

上面我们说过事件流中包括Window,所以有时候我们做前端页面错误的收集是在捕获阶段通过Window来添加事件处理的。

简述前端系统的微内核架构

现今大型前端系统越来越多,功能也越来越复杂的环境中,前端也需要通过架构来降低开发成本,提高维护效率,所以社区也提出了相应的微前端架构概念,但是相对于较新的微前端概念,微内核架构已经在各种软件场景中应用了。

微内核架构主要包含两种架构组件: 核心系统和插件模块。核心系统一般情况下只包含一个能够使系统运作起来的最小化模块。而插件模块独立维护的的组件,通常我们需要设定一些规范或规则来连接核心模块。

整体而言微内核有以下优点:
– 整体灵活性高,通过插件模块的松耦合实现,可以将变化隔离起来,并且快速满足需求。通常,微内核架构的核心系统很快趋于稳定,这样系统就变得很健壮,随着时间的推移它也不会发生多大改变。
– 易于部署,插件模块能够在运行时被动态地添加到核心系统中
– 插件模块能够被独立的测试,够非常简单地被核心系统模拟出来进行演示,或者在对核心系统很小影响甚至没有影响的情况下对一个特定的特性进行原型展示。

核心系统

在前端中,核心系统应该是一些最基本的操作规则,比如路由的处理,页面的展示,全局信息的发布,以及模块化方案的选择,插件的加载调度,这些都需要系统服务来处理。也就是说核心模块需要了解插件模块的可用性以及如何获取到它们。

插件模块

插件模块是一个包含特定处理、额外特性的独立组件。自定义意味着增加或者扩展核心系统以达到产生附加的业务逻辑的能力。通常,插件模块之间应该是没有任何依赖性的,但是你也可以设计一个需要依赖另一个插件的插件。但无论如何,插件之间的通信要保持在最低限度,以避免因依赖导致的问题出现。所以,每个插件模块都需要提供一个包含本身信息的描述文件,如果我们依赖NPM服务来创建的话,我们可以利用NPM的package.json来加快整体系统的成型。

在我们内网环境中,我们建立NPM服务来维护管理我们的插件模块,NPM服务提供多维度插件查询接口,在核心系统中,我们可以通过该接口,获取到特定模式的模块。

插件模块的开发

在开发插件模块的时候,需要根据核心系统提供的规则,来设定插件模块的依赖和接口,通常我们需要暴露插件的名称、功能、使用模式等方便构建系统的调度,核心系统收集错误,重启、关闭插件模块等。

插件模块的安装调用机制

1、本地构建,项目源码中引用

在本地的构建系统中,我们通过NPM直接获取插件本身,安装在核心系统的插件目录中,当项目构建打包时,通过打包工具如webpack等生产相应的bundle文件。

  • 优点:
    • 可以使用编辑器的本地联想
    • 可以降低每次插件模块更新带来的影响,对于质量较低的插件模块发生更新后也不会影响到所有引用系统的报错甚至瘫痪
  • 缺点也很明显,就是当我们的比较通用的插件模块发生更新后,我们需要去对没有引用了该插件的模块进行新的构建及发布,这样对于维护通用性较强的插件是一件痛苦的事情。
2、异步加载

插件模块在编译时需要提供最终可使用的bundle文件,发布时直接发布到CDN之类的服务上,在核心系统中我们需要提供一个引用关系描述文件或者结构,每次项目中插件模块发生更新的时候,我们只需要更新这个描述体即可,甚至,我们可以点对点更新项目中的插件,比较登陆模块发生更新,我们需要做的是发布登陆相关的bundle,狠一点直接刷新CDN有良好的基建服务可以使用刷新描述体文件的服务即可。

  • 优点
    • 更新及时,维护大量项目时复杂度很低
    • 独立开发维护
  • 缺点
    • 不能在本地使用编辑器的本地联想
    • 需要大量的测试来保证插件的可用性和有效性,被使用的维度越大,发布新版时心跳就越快!

构建系统

待续。。。

最后

微内核架构需要详尽周全的设计和严格的规范结构,这使得它实现起来相当复杂。版本控制,内部插件注册,插件粒度,广泛使用的插件的质量维护,所有这些都是导致该架构的实现变得复杂的重要因素。所以,如何通过组织搭建一个构建系统来降低这些问题是一件值得探讨和努力的事情。