关于 Virtual Dom 的简单了解(snabbdom,Vue, React)

  1. Virtual Dom 即根据最终状态在内存中绘制出一棵 Virtual Dom Tree,使用 Diff 算法与现存的 Dom Tree 对比并更新。
  2. Virtual Dom 并不能提升性能, 直接操作 Dom 理论上是最快的。

1. Snabbdom Example(官方实例)


2. 深入浅出


1.) Virtual Node

/**
sel [string]: 选择器, 比如 'div#id.class1.class2'
data [any]: 该节点属性(包括style、class等)
children (Array[]Vnode): 子节点(也由此函数创建)
text [string]: 节点内部的 text
ele [HTMLElement]: Dom 元素
**/
function vnode(sel, data, children, text, elm) (
  // 是否包含key,在list中元素变动时有些许性能影响
  let key = data === undefined ? undefined : data.key;
  return {sel: sel, data: data, children: children,
          text: text, elm: elm, key: key};
}

并不是每次创建虚拟节点都需要那么多参数,下面会根据传入的参数来调整 vnode 的函数的产出:

/**
sel: 元素选择器
b: 如果是数组或包含sel属性的object, 则为子节点; 如果是string, 则是文本节点; 否则就是data
c: 如果存在, 就肯定是子节点,同时b是data(类型判断早于b)
**/
function h(sel, b, c) {
  var data = {}, children, text, i;
  if (c !== undefined) {
    // ...
  } else if (b !== undefined) {
    // ...
  }

  if (is.array(children)) {
    for (i = 0; i < children.length; ++i) {
        // 如果该元素是字符串或数字, 那么就是个纯文本节点
        if (is.primitive(children[i]))
            children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
    }
  }

  return vnode(sel, data, children, text, undefined);
}

通过以上方法,就可以创建出一整棵 Dom Tree 了, 如下:

var data = [
  {id: 65, content: 'A'},
  {id: 66, content: 'B'},
  {id: 67, content: 'C'},
];

var tree = h('ul', data.map(node => {
  return h('li', {key: node.id}, node.content)
}))

// 得到的虚拟dom结构如下:
// 
    //
  • A
  • //
  • B
  • //
  • C
  • //

2.) 从Virtual Dom 到 Real Dom

这是从 Virtual Dom 映射到 Real Dom 的 main 函数:

// oldVNode: 可以是 HTMLElement(第一次调用) 也可以是上一次生成的虚拟Dom tree
// Vnode: 根据最新状态形成的虚拟Dom Tree
function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node;

    // 用于生命周期 inserted 阶段,记录下所有新插入的节点以备调用
    const insertedVnodeQueue: VNodeQueue = [];

    // 整个 Diff 过程模块可注册的钩子(跳过) 
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();

    if (!isVnode(oldVnode)) {
      // 将 HTMLElement 转换成 VNode
      oldVnode = emptyNodeAt(oldVnode);
    }

    // 如果两个节点相似(节点的 sel 与 key 完全相等)则更新
    if (sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    } else { // 否则直接替换
      elm = oldVnode.elm as Node;
      parent = api.parentNode(elm);

      createElm(vnode, insertedVnodeQueue);

      if (parent !== null) {
        // 取代 oldNode 的位置
        api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
        removeVnodes(parent, [oldVnode], 0, 0);
      }
    }

    // 整个 Diff 过程所有节点可注册的节点插入后调用的钩子(跳过)
    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      (((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
    }
    // 整个 Diff 过程模块可注册的钩子(跳过)
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
    return vnode;
  };

可见,整个流程比较清晰,替换节点或者在节点相似时更新节点(注释中提到,代码通过比较key以及选择器来判断是否相似,当没有指定key时,那么元素只能通过选择器来判断)。下面处理节点更新细节的 patchVnode 函数:

function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    let i: any, hook: any;
    // 更新前调用的节点生命周期钩子
    if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
      i(oldVnode, vnode);
    }
    const elm = vnode.elm = (oldVnode.elm as Node);
    let oldCh = oldVnode.children;
    let ch = vnode.children;

    // 完全是同一个对象,不作处理
    if (oldVnode === vnode) return;

    // 更新时调用的生命周期钩子,包括模块以及节点自身的
    if (vnode.data !== undefined) {
      // 这里如果引入了class模块,那么就会更新class属性;引入style模块,则会更新元素的样式
      // 此节点的更新都再这个循环里处理了
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
      i = vnode.data.hook;
      if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
    }

    // 开始处理子节点
    // 如果不是纯文本
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh as Array, ch as Array, insertedVnodeQueue);
      } else if (isDef(ch)) {
        // 如果旧元素没有子节点,而新的有,那么简单的添加节点在此节点上 
        if (isDef(oldVnode.text)) api.setTextContent(elm, '');
        addVnodes(elm, null, ch as Array, 0, (ch as Array).length - 1, insertedVnodeQueue);
      } else if (isDef(oldCh)) {
        // 否则就是移除不该存在的子节点
        removeVnodes(elm, oldCh as Array, 0, (oldCh as Array).length - 1);
      } else if (isDef(oldVnode.text)) {
        api.setTextContent(elm, '');
      }
    } else if (oldVnode.text !== vnode.text) {
      api.setTextContent(elm, vnode.text as string);
    }

    // 生命周期钩子,更新完成后调用
    if (isDef(hook) && isDef(i = hook.postpatch)) {
      i(oldVnode, vnode);
    }
  }

整个框架的模块也是重要的组成部分。使用者可以加载不同的模块让框架选择性的更新 Dom 中元素的属性。
下面是某节点的所有子节点更新函数:

function updateChildren(parentElm: Node,
                          oldCh: Array,
                          newCh: Array,
                          insertedVnodeQueue: VNodeQueue) {

    // 几个变量:
    // 1. 旧子节点数组的 startIndex, endIndex, startNode, endNode
    // 2. 新子节点数组的 startIndex, endIndex, startNode, endNode
    let oldStartIdx = 0, newStartIdx = 0;
    let oldEndIdx = oldCh.length - 1;
    let oldStartVnode = oldCh[0];
    let oldEndVnode = oldCh[oldEndIdx];
    let newEndIdx = newCh.length - 1;
    let newStartVnode = newCh[0];
    let newEndVnode = newCh[newEndIdx];

    let oldKeyToIdx: any;
    let idxInOld: number;
    let elmToMove: VNode;
    let before: any;

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (oldStartVnode == null) {
        oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
      } else if (oldEndVnode == null) {
        oldEndVnode = oldCh[--oldEndIdx];
      } else if (newStartVnode == null) {
        newStartVnode = newCh[++newStartIdx];
      } else if (newEndVnode == null) {
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
        oldStartVnode = oldCh[++oldStartIdx];
        newStartVnode = newCh[++newStartIdx];
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
        oldEndVnode = oldCh[--oldEndIdx];
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        // 1,2,3,4,5 --> 2,3,4,5,1  (处理一些特殊情况?reverse也可以用到)
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldStartVnode.elm as Node, api.nextSibling(oldEndVnode.elm as Node));
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        // 1,2,3,4,5  --> 5,1,2,3,4 (处理一些特殊情况?reverse也可以用到)
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      } else {
        // 如果每个子元素都有 key(可识别),那麽乱序后大概率会进这里(list)
        if (oldKeyToIdx === undefined) {
          // 创建一个关于旧子元素数组的, key --> index 的映射(map)
          // 没有key则不存在于map中
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        }

        // 新子元素,获取该新元素在旧数组中的位置(如果有key)
        idxInOld = oldKeyToIdx[newStartVnode.key as string];
        if (isUndef(idxInOld)) { // New element
          // 不存在在旧子元素数组中(如果没有指定key也会进入这里)
          // 根据这个新元素创建dom元素插入
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
          newStartVnode = newCh[++newStartIdx];
        } else {
          // 说明不是新元素,只是位置变了
          elmToMove = oldCh[idxInOld];   // 与新元素对应的旧元素
          if (elmToMove.sel !== newStartVnode.sel) {
            // 虽然key相同,但是元素tag已经不同了
            api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
          } else {
            // 没大变,可能只是需要更新一下元素
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
            // 这个节点处理过了,以后再循环到这里也不需要在处理了,置空
            oldCh[idxInOld] = undefined as any;
            // 更新完后简单的移动元素
            api.insertBefore(parentElm, (elmToMove.elm as Node), oldStartVnode.elm as Node);
          }
          newStartVnode = newCh[++newStartIdx];
        }
      }
    }

    // 多余的删掉,缺的补上。。
    if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
      if (oldStartIdx > oldEndIdx) {
        before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm;
        addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
      } else {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
      }
    }
  }

整个子节点 Diff 的过程也比较简单。这样也可以解答使用过程中的一个疑惑了,“为什么给list中的元素添加唯一的key可以提升性能”?
因为在比对列表元素的过程中,一旦有了key值就可以复用之前的元素本身,而免去更新多数元素的过程了。比如原本是 [A, B, C, D, E], 此时往B与C之间插入元素F。
如果没有携带 key 值,整个过程就是将C更新为F,D更新为C,E更新为D,最后添加元素E;
如果携带了key值,则会进入最后一个 ifelse,直接在B之后插入元素F,也不用更新其他任何元素。

你可能感兴趣的:(前端,Javascript)