Vue源码—Virtual DOM和Diffing算法

Virtual DOM 是一种编程概念,在更新state或props时,render 返回一颗虚拟dom树,而非直接修改真实dom

在这里插入图片描述

Diffing算法 用与对比两棵虚拟dom树,将旧dom更新为新dom,如果用传统的dfs算法转换需要O(n3),而diff只需要O(n)

目前 Virtual DOM 的主流 diff算法 基本一致,Vue整合了snabbdom库,React使用了reconcilation

在这里插入图片描述

snabbdom中是如何实现的?
1. h函数 => 创建 VNode

snabbdom中使用 h() 函数创建虚拟节点称为VNode,因此Vue中使用 h 作为 createElement 的别名

h函数接口如下

export declare function h(sel: string): VNode;
export declare function h(sel: string, data: VNodeData | null): VNode;
export declare function h(sel: string, children: VNodeChildren): VNode;
export declare function h(sel: string, data: VNodeData | null, children: VNodeChildren): VNode;

其中,VNode和VNodeData接口如下,VNodeChildren表示一个子VNode或子VNode数组

export interface VNode {
    sel: string | undefined; // 选择器,即html标签
    data: VNodeData | undefined; 
    children: Array<VNode | string> | undefined; // 当真实dom节点包含子节点时,存为children
    elm: Node | undefined; // 对真实dom的封装,能反应出真实dom的信息
    text: string | undefined; // 当真实dom节点是文本时,存为text
    key: Key | undefined;
}
export interface VNodeData {
    props?: Props;
    attrs?: Attrs;
    class?: Classes;
    style?: VNodeStyle;
    dataset?: Dataset;
    on?: On;
    attachData?: AttachData;
    hook?: Hooks;
    key?: Key;
    ns?: string;
    fn?: () => VNode;
    args?: any[];
    is?: string;
    [key: string]: any;
}

测试

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

// 此函数用于将vnode挂载到真实node
const patch = init([classModule, propsModule, styleModule, eventListenersModule]);

const vnode1 = h('a', {
  props: {
    href: 'http://www.bilibili.com',
    target: '_blank'
  }
}, '哔哩哔哩');

const vnode2 = h('ul', h('li', '科技区'));

const vnode3 = h('ul', [h('li', '知识区'), h('li', '番剧区')])

console.log('vnode1', vnode1);
console.log('vnode2', vnode2);
console.log('vnode3', vnode3);

const node1 = document.getElementById('node1');
const node2 = document.getElementById('node2');
const node3 = document.getElementById('node3');

patch(node1, vnode1);
patch(node2, vnode2);
patch(node3, vnode3);

image-20210715171150868

h函数的实现

export function h(sel: any, b?: any, c?: any): VNode {  // 注意:用来实现重载的函数,参数都用any

  let data: VNodeData = {};
  let children: any;
  let text: any;
  let i: number; 

  if (c !== undefined) { // 如果有三个参数
    if (b !== null) { // 如果第二个参数不为空
      data = b; // 把第二个参数作为data
    }
    if (Array.isArray(c)) { // 如果第三个参数是数组
      children = c; // 把第三个参数作为children
    } else if (typeof c === "string" || typeof c === "number") { // 如果第三个参数是string或number
      text = c; // 把第三个参数作为text
    } else if (c && c.sel) { // 如果第三个参数是单个的vnode对象
      children = [c]; // 把第三个参数作为children
    }
  } else if (b !== undefined && b !== null) { // 如果只有两个参数
    if (Array.isArray(b)) {
      children = b;
    } else if (typeof b === "string" || typeof c === "number") {
      text = b;
    } else if (b && b.sel) {
      children = [b];
    } else {
      data = b;
    }
  }
  if (children !== undefined) {
    for (i = 0; i < children.length; i++) {
      // 如果children数组中的当前元素为string或number类型,则转为vnode,且作为vnode中的text
      if (typeof children[i] === "string" || typeof children[i] === "number") {
        children[i] = vnode (undefined, undefined, undefined, children[i], undefined);
      }
    }
  }

  return vnode(sel, data, children, text, undefined);
}
2. 旧VNode vs 新VNode

init.ts中的 sameVnode() 方法明确了如何判断新旧节点是同一节点

function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
  const isSameKey = vnode1.key === vnode2.key;	// 1.两个节点要有相同的key
  const isSameIs = vnode1.data?.is === vnode2.data?.is; // 2.两个节点如果都存在data,则is属性要相同
  const isSameSel = vnode1.sel === vnode2.sel; // 3.两个节点要有相同的选择器
  return isSameSel && isSameKey && isSameIs;
}

init.ts中主函数返回了一个 patch() 方法,利用这个方法对比新旧vnode,从而在diff过程中将真实dom进行差异化更新,同时也说明了 ** 虚拟dom => 真实dom 的过程包含在diff算法中**,patch方法如下:

  return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node;
    const insertedVnodeQueue: VNodeQueue = [];

    if (!isVnode(oldVnode)) { // 如果老节点不是vnode类型
      oldVnode = emptyNodeAt(oldVnode); // 将老节点转成vnode类型
    }

    if (sameVnode(oldVnode, vnode)) { // 如果两个节点是同一节点
      patchVnode(oldVnode, vnode, insertedVnodeQueue); // 执行对比操作
    } else { // 如果两个节点不是同一节点
      elm = oldVnode.elm!; // 拿到旧节点的dom信息
      parent = api.parentNode(elm) as Node; // 拿到旧节点的父节点dom

      createElm(vnode, insertedVnodeQueue); // 创建新节点dom

      if (parent !== null) { // 如果父节点dom不为空
        api.insertBefore(parent, vnode.elm!, api.nextSibling(elm)); // 将新节点dom插入到旧节点dom的下一兄弟节点dom前
        removeVnodes(parent, [oldVnode], 0, 0); // 移除旧节点dom
      }
    }
    return vnode;
  };

由patch的源码可知,patch主要进行了两件事情:

  1. 新旧节点是同一节点,则执行 patchVnode() 方法
  2. 新旧节点不是同一节点,则 创建新节点dom -> 插入新节点dom -> 删除旧节点dom

其中创建新节点dom的方法 createElm() ,使用递归遍历了所有节点中的text和children,从而创建出真实dom并返回,源码如下

  function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    let i: any;
    let data = vnode.data;
    if (data !== undefined) {
      const init = data.hook?.init;
      if (isDef(init)) {
        init(vnode);
        data = vnode.data;
      }
    }
    const children = vnode.children;
    const sel = vnode.sel;
    if (sel === "!") {
      if (isUndef(vnode.text)) {
        vnode.text = "";
      }
      vnode.elm = api.createComment(vnode.text!);
    } else if (sel !== undefined) {
      // Parse selector
      const hashIdx = sel.indexOf("#");
      const dotIdx = sel.indexOf(".", hashIdx);
      const hash = hashIdx > 0 ? hashIdx : sel.length;
      const dot = dotIdx > 0 ? dotIdx : sel.length;
      const tag =
        hashIdx !== -1 || dotIdx !== -1
          ? sel.slice(0, Math.min(hash, dot))
          : sel;
      const elm = (vnode.elm =
        isDef(data) && isDef((i = data.ns))
          ? api.createElementNS(i, tag, data)
          : api.createElement(tag, data));
      if (hash < dot) elm.setAttribute("id", sel.slice(hash + 1, dot));
      if (dotIdx > 0)
        elm.setAttribute("class", sel.slice(dot + 1).replace(/\./g, " "));
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
      if (is.array(children)) {
        for (i = 0; i < children.length; ++i) {
          const ch = children[i];
          if (ch != null) {
            api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue)); // 递归追加节点dom
          }
        }
      } else if (is.primitive(vnode.text)) {
        api.appendChild(elm, api.createTextNode(vnode.text)); 
      }
      const hook = vnode.data!.hook;
      if (isDef(hook)) {
        hook.create?.(emptyNode, vnode);
        if (hook.insert) {
          insertedVnodeQueue.push(vnode);
        }
      }
    } else {
      vnode.elm = api.createTextNode(vnode.text!);
    }
    return vnode.elm;
  }

同一节点时的 patchVnode() 方法如下

  function patchVnode(
    oldVnode: VNode,
    vnode: VNode,
    insertedVnodeQueue: VNodeQueue
  ) {
    const hook = vnode.data?.hook;
    hook?.prepatch?.(oldVnode, vnode);
    const elm = (vnode.elm = oldVnode.elm)!;
    const oldCh = oldVnode.children as VNode[];
    const ch = vnode.children as VNode[];
    if (oldVnode === vnode) return; // 如果新旧节点完全是同一个对象,则直接return
    if (vnode.data !== undefined) {
      for (let i = 0; i < cbs.update.length; ++i)
        cbs.update[i](oldVnode, vnode);
      vnode.data.hook?.update?.(oldVnode, vnode);
    }
    if (isUndef(vnode.text)) { // 如果新节点不是text类型的
      if (isDef(oldCh) && isDef(ch)) { // 如果新老节点都有children
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue); // 如果新老节点children不同,则更新children
      } else if (isDef(ch)) { // 如果新节点是children,老节点是text
        if (isDef(oldVnode.text)) api.setTextContent(elm, ""); // 清除老节点文本内容
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue); // 插入children到老节点dom下
      } else if (isDef(oldCh)) { // 如果只有老节点,且只是children
        removeVnodes(elm, oldCh, 0, oldCh.length - 1); // 删除老节点dom 
      } else if (isDef(oldVnode.text)) { // 如果只有老节点,且还是text
        api.setTextContent(elm, ""); //清除老节点文本内容
      }
    } else if (oldVnode.text !== vnode.text) { // 如果新节点是text类型的
      if (isDef(oldCh)) { // 如果老节点是children
        removeVnodes(elm, oldCh, 0, oldCh.length - 1); //删除老节点dom
      }
      api.setTextContent(elm, vnode.text!); // 更新文本dom
    }
    hook?.postpatch?.(oldVnode, vnode);
  }

最后,patchVnode中的 updateChildren() 方法,源码如下:

  function updateChildren(
    parentElm: Node,
    oldCh: VNode[],
    newCh: VNode[],
    insertedVnodeQueue: VNodeQueue
  ) {
    let oldStartIdx = 0;
    let 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: KeyToIndexMap | undefined;
    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
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        api.insertBefore(
          parentElm,
          oldStartVnode.elm!,
          api.nextSibling(oldEndVnode.elm!)
        );
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // 新节点头和旧节点尾相同
        // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      } else {
        if (oldKeyToIdx === undefined) {
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        }
        idxInOld = oldKeyToIdx[newStartVnode.key as string];
        if (isUndef(idxInOld)) {
          // New element
          api.insertBefore(
            parentElm,
            createElm(newStartVnode, insertedVnodeQueue),
            oldStartVnode.elm!
          );
        } else {
          elmToMove = oldCh[idxInOld];
          if (elmToMove.sel !== newStartVnode.sel) {
            api.insertBefore(
              parentElm,
              createElm(newStartVnode, insertedVnodeQueue),
              oldStartVnode.elm!
            );
          } else {
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
            oldCh[idxInOld] = undefined as any;
            api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
          }
        }
        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);
      }
    }
  }

可以看到,源码中首先为新旧节点定义了4个索引,并在循环中根据情况使start和end索引向中间移动

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EGc4akto-1626772840720)(https://i.loli.net/2021/07/20/xThPgXnNBcOwWzI.png)]

4个索引对应的节点,两两对比,共存在5种情况:

  1. sameVnode(oldStartVnode, newStartVnode)
  2. sameVnode(oldEndVnode, newEndVnode)
  3. sameVnode(oldStartVnode, newEndVnode)
  4. sameVnode(oldEndVnode, newStartVnode)
  5. 都不相同
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值