likes
comments
collection
share

3. 「snabbdom@3.5.1 源码分析」patch(如何打补丁?)

作者站长头像
站长
· 阅读数 16

init:创建patch函数

看到会返回一个patch函数。看到init内部有很多函数,这些函数大都都是用到api进行DOM操作,而api依赖入参domApi(如果放在外侧,domApi需要作为参数传递)。 这里实际上通过闭包私有化这些函数作为方法存在。

看到在init方法入口处从入参modules中收集了指定的钩子回调。这样清楚了module的构造和用意。modules是一个对象,键就是init中cbs的key有:create\update\remove\destroy\pre\post,而值是函数。这样就可以参与从虚拟DOM到真实DOM的过程。

export function init(modules, domApi, options) {
    const cbs = {
        create: [],
        update: [],
        remove: [],
        destroy: [],
        pre: [],
        post: [],
    };
    
    const api = domApi !== undefined ? domApi : htmlDomApi;
    for (const hook of hooks) {
        for (const module of modules) {
            const currentHook = module[hook];
            if (currentHook !== undefined) {
                cbs[hook].push(currentHook);
            }
        }
    }
    
    function emptyNodeAt(elm) {
        //...
    }
    
    function emptyDocumentFragmentAt(frag) {
        //...
    }
    
    function createRmCb(childElm, listeners) {
        return function rmCb() {
            //...
        };
    }
    
    function createElm(vnode, insertedVnodeQueue) {
        //...
    }
    
    function addVnodes(parentElm, before, vnodes, startIdx, endIdx, insertedVnodeQueue) {
        //...
    }
    
    function invokeDestroyHook(vnode) {
        //...
    }
    
    function removeVnodes(parentElm, vnodes, startIdx, endIdx) {
        //...
    }
    
    function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) {
        //...
    }
    
    function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
        //...
    }
    
    return function patch(oldVnode, vnode) {
        //...
        return vnode;
    };
}

下面看下patch函数的实现

patch(打补丁):对比虚拟DOM树(根节点)

  1. 触发pre钩子,相当于会执行module提供的pre指向的函数
  2. 如果是真实的DOM节点,并根据该DOM创建对应的虚拟节点,【注意】此时会忽略其所有的孩子节点。
     function emptyNodeAt(elm: Element) {
        const id = elm.id ? "#" + elm.id : "";
        const classes = elm.getAttribute("class");
        const c = classes ? "." + classes.split(" ").join(".") : "";
        return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm );
      }
    
  3. 如果判断为相同节点(sameVnode)则调用patchVnode进行对比 sameVnode:注意如果没有定义属性即为undefinedundefined === undefined 是真值
    function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
      const isSameKey = vnode1.key === vnode2.key;
      const isSameIs = vnode1.data?.is === vnode2.data?.is;
      const isSameSel = vnode1.sel === vnode2.sel;
      const isSameTextOrFragment =
        !vnode1.sel && vnode1.sel === vnode2.sel
          ? typeof vnode1.text === typeof vnode2.text
          : true;
    
      return isSameSel && isSameKey && isSameIs && isSameTextOrFragment;
    }
    
    patchVnode比较重要,后面单独说下。
  4. 如果判断不是相同的vnode,则根据新的vnode直接创建真实DOM取代老的vnode。
    1. createElm:根据新vnode创建新DOM,创建vnode时如果传递了data.hook.insert,会将该vnode保存到insertedVnodeQueue。另外:这个过程是递归的(如果有children)
    2. 插入新DOM 并且 删除旧DOM
  5. 钩子,有两类
    1. init 初始时收集到cbs的钩子(收集自init入参传递的modules),执行post
    2. 遍历insertedVnodeQueue,并执行vnode.data.hook.insert,显然这个钩子是当前vnode关联的。
export function init(modules, domApi, options) {
    // ...
    return function patch(oldVnode, vnode) {
        let i, elm, parent;
        const insertedVnodeQueue = [];
        for (i = 0; i < cbs.pre.length; ++i)
            cbs.pre[i]();
        if (isElement(api, oldVnode)) {
            oldVnode = emptyNodeAt(oldVnode);
        }
        else if (isDocumentFragment(api, oldVnode)) {
           //... 实验特性,暂时不看
        }
        if (sameVnode(oldVnode, vnode)) {
            patchVnode(oldVnode, vnode, insertedVnodeQueue);
        }
        else {
            elm = oldVnode.elm;
            parent = api.parentNode(elm);
            createElm(vnode, insertedVnodeQueue);
            if (parent !== null) {
                api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
                removeVnodes(parent, [oldVnode], 0, 0);
            }
        }
        for (i = 0; i < insertedVnodeQueue.length; ++i) {
            insertedVnodeQueue[i].data.hook.insert(insertedVnodeQueue[i]);
        }
        for (i = 0; i < cbs.post.length; ++i)
            cbs.post[i]();
        return vnode;
    };
}

上面说过到如果新旧虚拟DOM被判断为相同节点,则会对这两个vnode进行对比,找出差异,并同步到界面上。 见patchVnode函数

patchVnode:对比单个虚拟DOM

  1. vnode.data.hook.prepatch
  2. 因为被判断是相同的vnode,会复用oldVnode关联的DOM,因此(vnode.elm = oldVnode.elm)
  3. 如果vnode和oldVnode是相同的对象,则返回
  4. 校正vnode/oldVnode.data,并执行 [cbs].updatevnode.data.hook.update
    // 怎么感觉应该是不需要第一个判断呢???
    if (vnode.data !== undefined || (isDef(vnode.text) && vnode.text !== oldVnode.text)) {
      vnode.data ??= {};
      oldVnode.data ??= {};
      for (let i = 0; i < cbs.update.length; ++i)
        cbs.update[i](oldVnode, vnode);
      vnode.data?.hook?.update?.(oldVnode, vnode);
    }
    
  5. 根据vnode.text是否存在(这一步是孩子的处理)
    • 不存在,又细分多种情况(else-if,下面case是进一步):
      • 新老vnode都存在孩子,调用updateChildren对孩子进行对比;
      • 只有新vnode有孩子:如果老vnode.text存在,则设置老vnode.text=''(实际上这里看出text和children的区别,虽然text也是孩子,但是拎出来单独处理了,是因为创建文本节点是特殊的api);调用addVnodes创建新元素并挂载到界面上
      • 如果只有老vnode有孩子,则调用removeVnodes删除孩子
      • vnode.text存在,设置为空串
    • 存在:调用removeVnodes删除孩子,调用setTextContent设置文本(新的孩子)
  6. 调用vnode.data.hook.postpatch
  function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    const hook = vnode.data?.hook;
    hook?.prepatch?.(oldVnode, vnode);
    const elm = (vnode.elm = oldVnode.elm)!;
    if (oldVnode === vnode) return;
    if (vnode.data !== undefined || (isDef(vnode.text) && vnode.text !== oldVnode.text)) {
      vnode.data ??= {};
      oldVnode.data ??= {};
      for (let i = 0; i < cbs.update.length; ++i)
        cbs.update[i](oldVnode, vnode);
      vnode.data?.hook?.update?.(oldVnode, vnode);
    }
    const oldCh = oldVnode.children as VNode[];
    const ch = vnode.children as VNode[];
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
      } else if (isDef(ch)) {
        if (isDef(oldVnode.text)) api.setTextContent(elm, "");
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
      } else if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      } else if (isDef(oldVnode.text)) {
        api.setTextContent(elm, "");
      }
    } else if (oldVnode.text !== vnode.text) {
      if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      }
      api.setTextContent(elm, vnode.text!);
    }
    hook?.postpatch?.(oldVnode, vnode);
  }

上面过程用到了removeVnodesaddVnodes

addVnodes

创建DOM,通过insertBefore衔接父子节点(如updateChildren过程中)或挂载到界面。

function addVnodes(parentElm: Node, before: Node | null, vnodes: VNode[], startIdx: number, endIdx: number, insertedVnodeQueue: VNodeQueue) {
    for (; startIdx <= endIdx; ++startIdx) {
      const ch = vnodes[startIdx];
      if (ch != null) {
        api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before);
      }
    }
  }

removeVnodes

涉及destroy,remove钩子(cbs即模块上的、vnode.data.hook)

function removeVnodes(parentElm: Node, vnodes: VNode[], startIdx: number, endIdx: number): void {
    for (; startIdx <= endIdx; ++startIdx) {
      let listeners: number;
      let rm: () => void;
      const ch = vnodes[startIdx];
      if (ch != null) {
        if (isDef(ch.sel)) {
          invokeDestroyHook(ch);
          listeners = cbs.remove.length + 1;
          rm = createRmCb(ch.elm!, listeners);
          for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
          const removeHook = ch?.data?.hook?.remove;
          if (isDef(removeHook)) {
            removeHook(ch, rm);
          } else {
            rm();
          }
        } else if (ch.children) {
          // Fragment node
          invokeDestroyHook(ch);
          removeVnodes(parentElm, ch.children as VNode[], 0, ch.children.length - 1);
        } else {
          // Text node
          api.removeChild(parentElm, ch.elm!);
        }
      }
    }
  }
  • invokeDestroyHook
function invokeDestroyHook(vnode: VNode) {
    const data = vnode.data;
    if (data !== undefined) {
      data?.hook?.destroy?.(vnode);
      for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
      if (vnode.children !== undefined) {
        for (let j = 0; j < vnode.children.length; ++j) {
          const child = vnode.children[j];
          if (child != null && typeof child !== "string") {
            invokeDestroyHook(child);
          }
        }
      }
    }
  }

updateChildren: 对比子虚拟节点(diff的本质)

diff的本质是什么

patch函数的目的是打补丁,找出差异,然后将差异同步到界面上。另外就是应该尽可能的复用已有的DOM。updateChildren的核心目的就是为了做这件事情:在考虑时间复杂度情况下去复用已有的DOM。

不考虑时间复杂度的情况下,给你新老两个vnode数组(oldCh: m 个元素,newCh: n 个元素)和samveVnode函数,你会如何实现呢?

我能想到的实现是两层遍历,外层是遍历 newCh,内层遍历oldCh,从oldCh中查找可以服用的节点。大致实现如下:

function patchEssential(oldCh,newCh) {
    let oldStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]

    // 外层遍历 newCh
    while (newStartVnode <= newEndIdx && oldStartIdx <= oldEndIdx) {
      const newVnode = newCh[newStartVnode]
      
      // 内层遍历 oldCh
      let canReused = false; // 用来标识当前newVnode是否找打了可以被复用的oldVnode
      for (; oldStartIdx <= oldEndIdx; oldStartIdx++) {
        const oldVnode = oldCh[oldStartIdx]
        // 如果被复用过
        if (!oldVnode) {
          continue
        }

        // 找到了可以被复用的oldVnode
        if (sameVnode(newVnode, oldVnode)) {
          canReused = true; 
          patchVnode(newVnode, oldVnode) // 递归对比
          // 直接移动位置(避免了创建DOM的过程)
          nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) // 调整元素位置
          // 设置为undefined,表示被复用过
          oldCh[oldStartIdx] = undefined; 
          break;
        }
      }
      // 如果没有找到可以复用的节点,则创建新的并挂载 
      !canReused && createElm(newVnode, ...)
    }

   //... 删除oldVnode未被复用的,添加newVnode尚未遍历到的
  }

这里的流程不细说了,都在注释里了。另外这里的时间复杂度是O(m * n),在页面渲染这种高优的事情中,这个复杂度不能被接受。

所以snabbdom的实现为了在时间复杂度和复用率上取了平衡。在没有提供key的情况下,snabbdom的双端对比做不到完全复用,key场景下当然是可以的。实际上这种取舍是合理的,在少量DOM场景下可能不会涉及到性能问题,如果有性能问题,添加key就可以解决。也就是说给你偷懒的机会,但是偷懒造成了严重后果,也提供解决方案让你弥补。

snabbdom: updateChildren的实现

  1. 变量声明,没什么好说的
  2. while循环,显然只有满足oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx才可能查找复用的节点
    1. 如果遍历的节点是空的,跳过看下一个节点(前面四个if);被复用的节点(oldVnode)可能会被置为undefinednull == undefined 为true(vue-2.6.11 中只考虑了该场景)
      // vue-2.6.11 updateChildren 片段
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx];
      }
      
    2. 双端对比,首尾相互比对
      • 降级的关键:本来应该是要从所有的oldVnode中查找可以复用的节点,但是现在只考虑首尾两处,时间复杂度从O(n)降至o(1)。
      • 实际上你可以随机挑常数个位置的节点进行对比,我理解首尾对比只是一种简单的随机策略,除非框架作者调研的结论是首尾对比能够较大概率的满足节点复用的场景。
      sameVnode(oldEndVnode, newEndVnode)
      sameVnode(oldEndVnode, newStartVnode)
      sameVnode(oldStartVnode, newEndVnode)
      sameVnode(oldStartVnode, newStartVnode)
      
    3. 最后一个else,是处理key场景的,用来提高性能的关键之处,逻辑显然,不赘述。
  3. while循环退出后的两种情况
    1. 老节点都已经遍历完了,那未遍历到的新节点,当然要创建并挂载(addVnodes
    2. 新节点都已经遍历完了,那剩下的未被遍历的节点,当然要删除(removeVnodes注意: (index < oldStartIdx || index > oldEndIdx)的节点一定是被复用了的,只有[oldStartIdx, oldEndIdx]会存在未被复用的节点。因为只有被复用oldStartIdx和oldEndIdx才会向中间收缩,[oldStartIdx, oldEndIdx]中间的被复用的节点由于被重置为undefined,因此遍历该区间进行删除时原有的老节点不会被删除(因为没拿到)
  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 (newStartIdx <= newEndIdx) {
      before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
      addVnodes(
        parentElm,
        before,
        newCh,
        newStartIdx,
        newEndIdx,
        insertedVnodeQueue
      );
    }
    if (oldStartIdx <= oldEndIdx) {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }

老节点的宿命要么复用(原地保持,移动(左移、右移))要么删除。新节点都会被考虑到,要么复用老节点,要么新创建。

另外的细节之处是要注意:移动和插入的位置需要兄弟节点和父节点辅助

createElm:根据虚拟DOM创建真实DOM

  1. 执行钩子:vnode.data.hook.init (可能会修改data,因此调用完后重新赋值了)

  2. 判断sel==='!'时,则会创建一个注释节点,保存到vnode.elm中。注意: 卸载oldVnode,框架本身没有提供专门的api来删除,依然是基于patch能力来模拟(unmounting)

    // patch -> createElm,在createElm中 ,而后新增该注释节点删除老的节点,实现老节点的删除。
    patch(oldVnode, h("!", {}));
    
  3. sel不为undefined

    • 从sel中解析出tag如div
    • createElement创建DOM
    • 设置idclass。注意: createElement的可以传递一个对象选项
    • 执行钩子:cbs.create(创建完DOM)
    • 如果有孩子,则递归创建孩子DOM,并append当前DOM下(父子关系嘛):
      api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
      
    • vnode.text也算是孩子,如果有,createTextNode + appendChild
    • 调用 vnode.data.hook.create
    • 如果vnode.data.hook.insert存在则保存新创建的vnode到insertedVnodeQueue。后面挂载到界面上会触发。(createElm只是创建,并未挂载到界面
  4. 返回新创建的 vnode.elm

  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));
          }
        }
      } 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 if (options?.experimental?.fragments && vnode.children) {
      //... 实验特性 暂不考虑
    } else {
      vnode.elm = api.createTextNode(vnode.text!);
    }
    return vnode.elm;
  }

总结

patch:对比整棵树(从根节点开始,也说明根节点只能有一颗) -> patchVnode:对比单个vnode -> updateChildren:对比孩子

打补丁的前提是:不考虑节点的跨层移动,只对比同层节点。

hook有哪些

Snabbdom 提供了一系列丰富的生命周期函数,这些生命周期函数适用于拓展 Snabbdom 模块或者在虚拟节点生命周期中执行任意代码。

名称触发节点回调参数
prepatch 开始执行none
initvnode 被添加vnode
create一个基于 vnode 的 DOM 元素被创建emptyVnode, vnode
insert元素 被插入到 DOMvnode
prepatch元素 即将 patcholdVnode, vnode
update元素 已更新oldVnode, vnode
postpatch元素 已被 patcholdVnode, vnode
destroy元素 被直接或间接得移除vnode
remove元素 已从 DOM 中移除vnode, removeCallback
post已完成 patch 过程none

适用于模块(module.xxx):pre, create,update, destroy, remove, post

适用于单个元素(vnode.data.hook.xxx):init, create, insert, prepatch, update,postpatch, destroy, remove

虽然很多钩子的触发时机是一致,但是为什么还要区分这两类钩子呢?因为有些逻辑是共同的,这些逻辑收敛到模块中,而有些逻辑对于不同的vnode有差异,因此交个具体的vnode自己处理。

可以根据钩子的执行位置,回忆从data -> vnode -> dom -> 界面的过程。

转载自:https://juejin.cn/post/7194014863469838393
评论
请登录