乐趣区

关于前端:一文带你掌握虚拟-DOM-的灵魂

概述

React 和 Vue 中都有虚构 DOM,咱们应该如何了解和把握虚构 DOM 的精华呢?我举荐大家学习 Snabbdom 这个我的项目。Snabbdom 是一个虚构 DOM 实现库,举荐的起因一是代码比拟少,外围代码只有几百行;二是 Vue 就是借鉴此我的项目的思路来实现虚构 DOM 的;三是这个我的项目的设计 / 实现和扩大思路值得参考。

snabb /snab/,瑞典语,意思是疾速的。

调整好难受的坐姿,打起精神咱们要开始啦~ 要学习虚构 DOM,咱们得先晓得 DOM 的基础知识和用 JS 间接操作 DOM 的痛点在哪里。

DOM 的作用和类型构造

DOM(Document Object Model)是一种文档对象模型,用一个对象树的构造来示意一个 HTML/XML 文档,树的每个分支的起点都是一个节点(node),每个节点都蕴含着对象。DOM API 的办法让你能够用特定形式操作这个树,用这些办法你能够扭转文档的构造、款式或者内容。

DOM 树中的所有节点首先都是一个 NodeNode 是一个基类。ElementTextComment 都继承于它。
换句话说,ElementTextComment 是三种非凡的 Node,它们别离叫做 ELEMENT_NODE,
TEXT_NODECOMMENT_NODE,代表的是元素节点(HTML 标签)、文本节点和正文节点。其中 Element 还有一个子类是 HTMLElement,那 HTMLElementElement 有什么区别呢?HTMLElement 代表 HTML 中的元素,如:<span><img> 等,而有些元素并不是 HTML 规范的,比方 <svg>。能够用上面的办法来判断这个元素是不是 HTMLElement

document.getElementById('myIMG') instanceof HTMLElement;

为什么须要虚构 DOM?

浏览器创立 DOM 是很“低廉”的。来一个经典示例,咱们能够通过 document.createElement('div') 创立一个简略的 div 元素,将属性都打印进去康康:

能够看到打印进去的属性十分多,当频繁地去更新简单的 DOM 树时,会产生性能问题。虚构 DOM 就是用一个原生的 JS 对象去形容一个 DOM 节点,所以创立一个 JS 对象比创立一个 DOM 对象的代价要小很多。

VNode

Vnode 就是 Snabbdom 中形容虚构 DOM 的一个对象构造,内容如下:

type Key = string | number | symbol;

interface VNode {
  // CSS 选择器,比方:'div#container'。sel: string | undefined;
  
  // 通过 modules 操作 CSS classes、attributes 等。data: VNodeData | undefined; 
  
   // 虚构子节点数组,数组元素也能够是 string。children: Array<VNode | string> | undefined;
  
  // 指向创立的实在 DOM 对象。elm: Node | undefined;
  
  /**
   * text 属性有两种状况:* 1. 没有设置 sel 选择器,阐明这个节点自身是一个文本节点。* 2. 设置了 sel,阐明这个节点的内容是一个文本节点。*/
  text: string | undefined;
  
  // 用于给已存在的 DOM 提供标识,在同级元素之间必须惟一,无效防止不必要地重建操作。key: Key | undefined;
}

// vnode.data 上的一些设置,class 或者生命周期函数钩子等等。interface VNodeData {
  props?: Props;
  attrs?: Attrs;
  class?: Classes;
  style?: VNodeStyle;
  dataset?: Dataset;
  on?: On;
  attachData?: AttachData;
  hook?: Hooks;
  key?: Key;
  ns?: string; // for SVGs
  fn?: () => VNode; // for thunks
  args?: any[]; // for thunks
  is?: string; // for custom elements v1
  [key: string]: any; // for any other 3rd party module
}

例如这样定义一个 vnode 的对象:

const vnode = h(
  'div#container',
  {class: { active: true} },
  [h('span', { style: { fontWeight: 'bold'} }, 'This is bold'),
    'and this is just normal text'
]);

咱们通过 h(sel, b, c) 函数来创立 vnode 对象。h() 代码实现中次要是判断了 b 和 c 参数是否存在,并解决成 data 和 children,children 最终会是数组的模式。最初通过 vnode() 函数返回下面定义的 VNode 类型格局。

Snabbdom 的运行流程

先来一张运行流程的简略示例图,先有个大略的流程概念:

diff 解决是用来计算新老节点之间差别的处理过程。

再来看一段 Snabbdom 运行的示例代码:

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from 'snabbdom';

const patch = init([
  // 通过传入模块初始化 patch 函数
  classModule, // 开启 classes 性能
  propsModule, // 反对传入 props
  styleModule, // 反对内联款式同时反对动画
  eventListenersModule, // 增加事件监听
]);

// <div id="container"></div>
const container = document.getElementById('container');

const vnode = h(
  'div#container.two.classes',
  {on: { click: someFn} },
  [h('span', { style: { fontWeight: 'bold'} }, 'This is bold'),
    'and this is just normal text',
    h('a', { props: { href: '/foo'} }, "I'll take you places!"),
  ]
);

// 传入一个空的元素节点。patch(container, vnode);

const newVnode = h(
  'div#container.two.classes',
  {on: { click: anotherEventHandler} },
  [
    h(
      'span',
      {style: { fontWeight: 'normal', fontStyle: 'italic'} },
      'This is now italic type'
    ),
    'and this is still just normal text',
    h('a', { props: { href: ''/bar'} }, "I'll take you places!"),
  ]
);

// 再次调用 patch(),将旧节点更新为新节点。patch(vnode, newVnode);

从流程示意图和示例代码能够看出,Snabbdom 的运行流程形容如下:

  1. 首先调用 init() 进行初始化,初始化时须要配置须要应用的模块。比方 classModule 模块用来应用对象的模式来配置元素的 class 属性;eventListenersModule 模块用来配置事件监听器等等。init() 调用后会返回 patch() 函数。
  2. 通过 h() 函数创立初始化 vnode 对象,调用 patch() 函数去更新,最初通过 createElm() 创立真正的 DOM 对象。
  3. 当须要更新时,创立一个新的 vnode 对象,调用 patch() 函数去更新,通过 patchVnode()updateChildren() 实现本节点和子节点的差别更新。

    Snabbdom 是通过模块这种设计来扩大相干属性的更新而不是全副写到外围代码中。那这是如何设计与实现的?接下来就先来康康这个设计的核心内容,Hooks——生命周期函数。

Hooks

Snabbdom 提供了一系列丰盛的生命周期函数也就是钩子函数,这些生命周期函数实用在模块中或者能够间接定义在 vnode 上。比方咱们能够在 vnode 上这样定义钩子的执行:

h('div.row', {
  key: 'myRow',
  hook: {insert: (vnode) => {console.log(vnode.elm.offsetHeight);
    },
  },
});

全副的生命周期函数申明如下:

名称 触发节点 回调参数
pre patch 开始执行 none
init vnode 被增加 vnode
create 一个基于 vnode 的 DOM 元素被创立 emptyVnode, vnode
insert 元素被插入到 DOM vnode
prepatch 元素行将 patch oldVnode, vnode
update 元素已更新 oldVnode, vnode
postpatch 元素已被 patch oldVnode, vnode
destroy 元素被间接或间接得移除 vnode
remove 元素已从 DOM 中移除 vnode, removeCallback
post 已实现 patch 过程 none

其中实用于模块的是:pre, create,update, destroy, remove, post。实用于 vnode 申明的是:init, create, insert, prepatch, update,postpatch, destroy, remove

咱们来康康是如何实现的,比方咱们以 classModule 模块为例,康康它的申明:

import {VNode, VNodeData} from "../vnode";
import {Module} from "./module";

export type Classes = Record<string, boolean>;

function updateClass(oldVnode: VNode, vnode: VNode): void {
  // 这里是更新 class 属性的细节,先不论。// ...
}

export const classModule: Module = {create: updateClass, update: updateClass};

能够看到最初导出的模块定义是一个对象,对象的 key 就是钩子函数的名称,模块对象 Module 的定义如下:

import {
  PreHook,
  CreateHook,
  UpdateHook,
  DestroyHook,
  RemoveHook,
  PostHook,
} from "../hooks";

export type Module = Partial<{
  pre: PreHook;
  create: CreateHook;
  update: UpdateHook;
  destroy: DestroyHook;
  remove: RemoveHook;
  post: PostHook;
}>;

TS 中 Partial 示意对象中每个 key 的属性都是能够为空的,也就是说模块定义中你关怀哪个钩子,就定义哪个钩子就好了。钩子的定义有了,在流程中是怎么执行的呢?接着咱们来看 init() 函数:

// 模块中可能定义的钩子有哪些。const hooks: Array<keyof Module> = [
  "create",
  "update",
  "remove",
  "destroy",
  "pre",
  "post",
];

export function init(
  modules: Array<Partial<Module>>,
  domApi?: DOMAPI,
  options?: Options
) {
  // 模块中定义的钩子函数最初会存在这里。const cbs: ModuleHooks = {create: [],
    update: [],
    remove: [],
    destroy: [],
    pre: [],
    post: [],};

  // ...

  // 遍历模块中定义的钩子,并存起来。for (const hook of hooks) {for (const module of modules) {const currentHook = module[hook];
      if (currentHook !== undefined) {(cbs[hook] as any[]).push(currentHook);
      }
    }
  }
  
  // ...
}

能够看到 init() 在执行时先遍历各个模块,而后把钩子函数存到了 cbs 这个对象中。执行的时候能够康康 patch() 函数外面:

export function init(
  modules: Array<Partial<Module>>,
  domApi?: DOMAPI,
  options?: Options
) {
  // ...
  
  return function patch(
  oldVnode: VNode | Element | DocumentFragment,
   vnode: VNode
  ): VNode {
    // ...
    
    // patch 开始了,执行 pre 钩子。for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
    
    // ...
  }
}

这里以 pre 这个钩子举例,pre 钩子的执行机会是在 patch 开始执行时。能够看到 patch() 函数在执行的开始处去循环调用了 cbs 中存储的 pre 相干钩子。其余生命周期函数的调用也跟这个相似,大家能够在源码中其余中央看到对应生命周期函数调用的中央。

这里的设计思路是 观察者模式。Snabbdom 把非核心性能散布在模块中来实现,联合生命周期的定义,模块能够定义它本人感兴趣的钩子,而后 init() 执行时解决成 cbs 对象就是注册这些钩子;当执行工夫到来时,调用这些钩子来告诉模块解决。这样就把外围代码和模块代码拆散了进去,从这里咱们能够看出观察者模式是一种代码解耦的罕用模式。

patch()

接下来咱们来康康外围函数 patch(),这个函数是在 init() 调用后返回的,作用是执行 VNode 的挂载和更新,签名如下:

function patch(oldVnode: VNode | Element | DocumentFragment, vnode: VNode): VNode {
    // 为简略起见先不关注 DocumentFragment。// ...
}

oldVnode 参数是旧的 VNode 或 DOM 元素或文档片段,vnode 参数是更新后的对象。这里我间接贴出整顿的流程形容:

  1. 调用模块上注册的 pre 钩子。
  2. 如果 oldVnodeElement,则将其转换为空的 vnode 对象,属性外面记录了 elm

    这里判断是不是 Element 是判断 (oldVnode as any).nodeType === 1 是实现的,nodeType === 1 表明是一个 ELEMENT_NODE,定义在 这里。

  3. 而后判断 oldVnodevnode 是不是雷同的,这里会调用 sameVnode() 来判断:

    function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
      // 同样的 key。const isSameKey = vnode1.key === vnode2.key;
      
      // Web component,自定义元素标签名,看这里:// https://developer.mozilla.org/zh-CN/docs/Web/API/Document/createElement
      const isSameIs = vnode1.data?.is === vnode2.data?.is;
      
      // 同样的选择器。const isSameSel = vnode1.sel === vnode2.sel;
    
      // 三者都雷同即是雷同的。return isSameSel && isSameKey && isSameIs;
    }
    • 如果雷同,则调用 patchVnode() 做 diff 更新。
    • 如果不同,则调用 createElm() 创立新的 DOM 节点;创立结束后插入 DOM 节点并删除旧的 DOM 节点。
  4. 调用上述操作中波及的 vnode 对象中注册的 insert 钩子队列,patchVnode() createElm() 都可能会有新节点插入。至于为什么这样做,在 createElm() 中会说到。
  5. 最初调用模块上注册的 post 钩子。

流程根本就是雷同的 vnode 就做 diff,不同的就创立新的删除旧的。接下来先看下 createElm() 是如何创立 DOM 节点的。

createElm()

createElm() 是依据 vnode 的配置来创立 DOM 节点。流程如下:

  1. 调用 vnode 对象上可能存在的 init 钩子。
  2. 而后分一下几种状况来解决:

    1. 如果 vnode.sel === '!',这是 Snabbdom 用来删除原节点的办法,这样会新插入一个正文节点。因为在 createElm() 后会删除老节点,所以这样设置就能够达到卸载的目标。
    2. 如果 vnode.sel 选择器定义是存在的:

      1. 解析选择器,失去 idtagclass
      2. 调用 document.createElement()document.createElementNS 创立 DOM 节点,并记录到 vnode.elm 中,并依据上一步的后果来设置 idtagclass
      3. 调用模块上的 create 钩子。
      4. 解决 children 子节点数组:

        1. 如果 children 是数组,则递归调用 createElm() 创立子节点后,调用 appendChild 挂载到 vnode.elm 下。
        2. 如果 children 不是数组但 vnode.text 存在,阐明这个元素的内容是个文本,这个时候调用 createTextNode 创立文本节点并挂载到 vnode.elm 下。
      5. 调用 vnode 上的 create 钩子。并将 vnode 上的 insert 钩子退出到 insert 钩子队列。
    3. 剩下的状况就是 vnode.sel 不存在,阐明节点自身是文本,那就调用 createTextNode 创立文本节点并记录到 vnode.elm
  3. 最初返回 vnode.elm

整个过程能够看出 createElm() 是依据 sel 选择器的不同设置来抉择如何创立 DOM 节点。这里有个细节是补一下:patch() 中提到的 insert 钩子队列。须要这个 insert 钩子队列的起因是须要等到 DOM 真正被插入后才执行,而且也要等到所有子孙节点都插入实现,这样咱们能够在 insert 中去计算元素的大小地位信息才是精确的。联合下面创立子节点的过程,createElm() 创立子节点是递归调用,所以队列会先记录子节点,再记录本身。这样在 patch() 的结尾执行这个队列时就能够保障这个程序。

patchVnode()

接下来咱们来看 Snabbdom 如何用 patchVnode() 来做 diff 的,这是虚构 DOM 的外围。patchVnode() 的解决流程如下:

  1. 首先执行 vnode 上 prepatch 钩子。
  2. 如果 oldVnode 和 vnode 是同一个对象援用,则不解决间接返回。
  3. 调用模块和 vnode 上的 update 钩子。
  4. 如果没有定义 vnode.text,则解决 children 的几种状况:

    1. 如果 oldVnode.childrenvnode.children 均存在并且不雷同。则调用 updateChildren 去更新。
    2. vnode.children 存在而 oldVnode.children 不存在。如果 oldVnode.text 存在则先清空,而后调用 addVnodes 去增加新的 vnode.children
    3. vnode.children 不存在而 oldVnode.children 存在。调用 removeVnodes 移除 oldVnode.children
    4. 如果 oldVnode.childrenvnode.children 均不存在。如果 oldVnode.text 存在则清空。
  5. 如果有定义 vnode.text并且与 oldVnode.text 不同。如果 oldVnode.children 存在则调用 removeVnodes 革除。而后通过 textContent 来设置文本内容。
  6. 最初执行 vnode 上的 postpatch 钩子。

从过程能够看出,diff 中对于本身节点的相干属性的扭转比方 classstyle 之类的是依附模块去更新的,这里不过多开展了大家有须要能够去看下模块相干的代码。diff 的次要外围解决是集中在 children 上,接下来康康 diff 解决 children 的几个相干函数。

addVnodes()

这个很简略,先调用 createElm() 创立,而后插入到对应的 parent 中。

removeVnodes()

移除的时候会先调用 destoryremove 钩子,这里重点讲讲这两个钩子的调用逻辑和区别。

  • destory,首先调用这个钩子。逻辑是先调用 vnode 对象上的这个钩子,再调用模块上的。而后对 vnode.children 也依照这个程序递归调用这个钩子。
  • remove,这个 hook 只有在以后元素从它的父级中删除才会触发,被移除的元素中的子元素则不会触发,并且模块和 vnode 对象上的这个钩子都会调用,程序是先调用模块上的再调用 vnode 上的。而且比拟非凡的是期待所有的 remove 都会调用后,元素才会真正被移除,这样做能够实现一些提早删除的需要。

以上能够看出这两个钩子调用逻辑不同的中央,特地是 remove 只在间接脱离父级的元素上才会被调用。

updateChildren()

updateChildren() 是用来解决子节点 diff 的,也是 Snabbdom 中比较复杂的一个函数。总的思维是对 oldChnewCh 各设置头、尾一共四个指针,这四个指针别离是 oldStartIdxoldEndIdxnewStartIdxnewEndIdx。而后在 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) 循环中对两个数组进行比照,找到雷同的局部进行复用更新,并且每次比拟解决最多挪动一对指针。具体的遍历过程按以下程序解决:

  1. 如果这四个指针有任何一个指向的 vnode == null,则这个指针往两头挪动,比方:start++ 或 end–,null 的产生在前面状况有阐明。
  2. 如果新旧开始节点雷同,也就是 sameVnode(oldStartVnode, newStartVnode) 返回 true,则用 patchVnode() 执行 diff,并且两个开始节点都向两头前进一步。
  3. 如果新旧完结节点雷同,也采纳 patchVnode() 解决,两个完结节点向两头后退一步。
  4. 如果旧开始节点与新完结节点雷同,先用 patchVnode() 解决更新。而后须要挪动 oldStart 对应的 DOM 节点,挪动的策略是挪动到 oldEndVnode 对应 DOM 节点的下一个兄弟节点之前。为什么是这样挪动呢?首先,oldStart 与 newEnd 雷同,阐明在以后循环解决中,老数组的开始节点是往右挪动了;因为每次的解决都是首尾指针往两头挪动,咱们是把老数组更新成新的,这个时候 oldEnd 可能还没解决,但这个时候 oldStart 已确定在新数组的以后解决中是最初一个了,所以挪动到 oldEnd 的下一个兄弟节点之前是正当的。挪动结束后,oldStart++,newEnd–,别离向各自的数组两头挪动一步。
  5. 如果旧完结节点与新开始节点雷同,也是先用 patchVnode() 解决更新,而后把 oldEnd 对应的 DOM 节点挪动 oldStartVnode 对应的 DOM 节点之前,挪动理由同上一步一样。挪动结束后,oldEnd–,newStart++。
  6. 如果以上状况都不是,则通过 newStartVnode 的 key 去找在 oldChildren 的下标 idx,依据下标是否存在有两种不同的解决逻辑:

    1. 如果下标不存在,阐明 newStartVnode 是新创建的。通过 createElm() 创立新的 DOM,并插入到 oldStartVnode 对应的 DOM 之前。
    2. 如果下标存在,也要分两种状况解决:

      1. 如果两个 vnode 的 sel 不同,也还是当做新创建的,通过 createElm() 创立新的 DOM,并插入到 oldStartVnode 对应的 DOM 之前。
      2. 如果 sel 是雷同的,则通过 patchVnode() 解决更新,并把 oldChildren 对应下标的 vnode 设置为 undefined,这也是后面双指针遍历中为什么会呈现 == null 的起因。而后把更新结束后的节点插入到 oldStartVnode 对应的 DOM 之前。
    3. 以上操作完后,newStart++。

遍历完结后,还有两种状况要解决。一种是 oldCh 曾经全副解决实现,而 newCh 中还有新的节点,须要对 newCh 剩下的每个都创立新的 DOM;另一种是 newCh 全副解决实现,而 oldCh 中还有旧的节点,须要将多余的节点移除。这两种状况的解决在 如下:

  function updateChildren(
    parentElm: Node,
    oldCh: VNode[],
    newCh: VNode[],
    insertedVnodeQueue: VNodeQueue
  ) { 
    // 双指针遍历过程。// ...
      
    // newCh 中还有新的节点须要创立。if (newStartIdx <= newEndIdx) {
      // 须要插入到最初一个解决好的 newEndIdx 之前。before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
      addVnodes(
        parentElm,
        before,
        newCh,
        newStartIdx,
        newEndIdx,
        insertedVnodeQueue
      );
    }
      
    // oldCh 中还有旧的节点要移除。if (oldStartIdx <= oldEndIdx) {removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }

咱们用一个理论例子来看一下 updateChildren() 的处理过程:

  1. 初始状态如下,旧子节点数组为 [A, B, C],新节点数组为 [B, A, C, D]:
  1. 第一轮比拟,开始和完结节点都不一样,于是看 newStartVnode 在旧节点中是否存在,找到了在 oldCh[1] 这个地位,那么先执行 patchVnode() 进行更新,而后把 oldCh[1] = undefined,并把 DOM 插入到 oldStartVnode 之前,newStartIdx 向后挪动一步,解决完后状态如下:
  1. 第二轮比拟,oldStartVnodenewStartVnode 雷同,执行 patchVnode() 更新,oldStartIdxnewStartIdx 向两头挪动,解决完后状态如下:
  1. 第三轮比拟,oldStartVnode == nulloldStartIdx 向两头挪动,状态更新如下:
  1. 第四轮比拟,oldStartVnodenewStartVnode 雷同,执行 patchVnode() 更新,oldStartIdxnewStartIdx 向两头挪动,解决完后状态如下:
  1. 此时 oldStartIdx 大于 oldEndIdx,循环完结。此时 newCh 中还有没解决完的新节点,须要调用 addVnodes() 插入,最终状态如下:

总结

到这里虚构 DOM 的核心内容曾经梳理结束,Snabbdom 的设计和实现原理我感觉挺好的,大家有空能够去康康源码的细节再细品下,其中的思维很值得学习。

欢送关注我的 JS 博客:小声比比 JavaScript

退出移动版