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

本文解析了Snabbdom框架中的虚拟DOM实现原理,包括虚拟节点的创建与更新过程、真实DOM的生成以及子节点的更新算法。展示了如何通过唯一key提升列表渲染性能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

  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结构如下:
// <ul>
//   <li>A</li>
//   <li>B</li>
//   <li>C</li>
// </ul>

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<VNode>, ch as Array<VNode>, insertedVnodeQueue);
      } else if (isDef(ch)) {
        // 如果旧元素没有子节点,而新的有,那么简单的添加节点在此节点上 
        if (isDef(oldVnode.text)) api.setTextContent(elm, '');
        addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);
      } else if (isDef(oldCh)) {
        // 否则就是移除不该存在的子节点
        removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).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<VNode>,
                          newCh: Array<VNode>,
                          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,也不用更新其他任何元素。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值