• 8.4 diff算法实现
    • 8.4.1 diffVnode
    • 8.4.2 _sameVnode
    • 8.4.3 generateElm
    • 8.4.4 patchVnode
    • 8.4.5 updateChildren

    8.4 diff算法实现

    更新组件的过程首先是响应式数据发生了变化,数据频繁的修改如果直接渲染到真实DOM上会引起整个DOM树的重绘和重排,频繁的重绘和重排是极其消耗性能的。如何优化这一渲染过程,Vue源码中给出了两个具体的思路,其中一个是在介绍响应式系统时提到的将多次修改推到一个队列中,在下一个tick去执行视图更新,另一个就是接下来要着重介绍的diff算法,将需要修改的数据进行比较,并只渲染必要的DOM

    数据的改变最终会导致节点的改变,所以diff算法的核心在于在尽可能小变动的前提下找到需要更新的节点,直接调用原生相关DOM方法修改视图。不管是真实DOM还是前面创建的Virtual DOM,都可以理解为一颗DOM树,算法比较节点不同时,只会进行同层节点的比较,不会跨层进行比较,这也大大减少了算法复杂度。

    8.4.1 diffVnode

    在之前的基础上,我们实现一个思路,1秒之后数据发生改变。

    1. // index.html
    2. setTimeout(function() {
    3. arr = [{
    4. tag: 'span',
    5. text: 1
    6. },{
    7. tag: 'strong',
    8. text: 2
    9. },{
    10. tag: 'i',
    11. text: 3
    12. },{
    13. tag: 'i',
    14. text: 4
    15. }]
    16. // newVnode 表示改变后新的Vnode树
    17. const newVnode = createVnode();
    18. // diffVnode会比较新旧Vnode树,并完成视图更新
    19. vn.diffVnode(newVnode, preVnode);
    20. })

    diffVnode的逻辑,会对比新旧节点的不同,并完成视图渲染更新

    1. class Vn {
    2. ···
    3. diffVnode(nVnode, oVnode) {
    4. if (!this._sameVnode(nVnode, oVnode)) {
    5. // 直接更新根节点及所有子节点
    6. return ***
    7. }
    8. this.generateElm(vonde);
    9. this.patchVnode(nVnode, oVnode);
    10. }
    11. }

    8.4.2 _sameVnode

    新旧节点的对比是算法的第一步,如果新旧节点的根节点不是同一个节点,则直接替换节点。这遵从上面提到的原则,只进行同层节点的比较,节点不一致,直接用新节点及其子节点替换旧节点。为了理解方便,我们假定节点相同的判断是tag标签是否一致(实际源码要复杂)。

    1. class Vn {
    2. _sameVnode(n, o) {
    3. return n.tag === o.tag;
    4. }
    5. }

    8.4.3 generateElm

    generateElm的作用是跟踪每个节点实际的真实节点,方便在对比虚拟节点后实时更新真实DOM节点。虽然Vue源码中做法不同,但是这不是分析diff的重点。

    1. class Vn {
    2. generateElm(vnode) {
    3. const traverseTree = (v, parentEl) => {
    4. let children = v.children;
    5. if(Array.isArray(children)) {
    6. children.forEach((c, i) => {
    7. c.elm = parentEl.childNodes[i];
    8. traverseTree(c, c.elm)
    9. })
    10. }
    11. }
    12. traverseTree(vnode, this.el);
    13. }
    14. }

    执行generateElm方法后,我们可以在旧节点的Vnode中跟踪到每个Virtual DOM的真实节点信息。

    8.4.4 patchVnode

    patchVnode是新旧Vnode对比的核心方法,对比的逻辑如下。

    1. 节点相同,且节点除了拥有文本节点外没有其他子节点。这种情况下直接替换文本内容。
    2. 新节点没有子节点,旧节点有子节点,则删除旧节点所有子节点。
    3. 旧节点没有子节点,新节点有子节点,则用新的所有子节点去更新旧节点。
    4. 新旧都存在子节点。则对比子节点内容做操作。

    代码逻辑如下:

    1. class Vn {
    2. patchVnode(nVnode, oVnode) {
    3. if(nVnode.text && nVnode.text !== oVnode) {
    4. // 当前真实dom元素
    5. let ele = oVnode.elm
    6. // 子节点为文本节点
    7. ele.textContent = nVnode.text;
    8. } else {
    9. const oldCh = oVnode.children;
    10. const newCh = nVnode.children;
    11. // 新旧节点都存在。对比子节点
    12. if (util._isDef(oldCh) && util._isDef(newCh)) {
    13. this.updateChildren(ele, newCh, oldCh)
    14. } else if (util._isDef(oldCh)) {
    15. // 新节点没有子节点
    16. } else {
    17. // 老节点没有子节点
    18. }
    19. }
    20. }
    21. }

    上述例子在patchVnode过程中,新旧子节点都存在,所以会走updateChildren分支。

    8.4.5 updateChildren

    子节点的对比,我们通过文字和画图的形式分析,通过图解的形式可以很清晰看到diff算法的巧妙之处。

    大致逻辑是:

    1. 旧节点的起始位置为oldStartIndex,截至位置为oldEndIndex,新节点的起始位置为newStartIndex,截至位置为newEndIndex
    2. 新旧children的起始位置的元素两两对比,顺序是newStartVnode, oldStartVnode; newEndVnode, oldEndVnode;newEndVnode, oldStartVnode;newStartIndex, oldEndIndex
    3. newStartVnode, oldStartVnode节点相同,执行一次patchVnode过程,也就是递归对比相应子节点,并替换节点的过程。oldStartIndex,newStartIndex都像右移动一位。
    4. newEndVnode, oldEndVnode节点相同,执行一次patchVnode过程,递归对比相应子节点,并替换节点。oldEndIndex, newEndIndex都像左移动一位。
    5. newEndVnode, oldStartVnode节点相同,执行一次patchVnode过程,并将旧的oldStartVnode移动到尾部,oldStartIndex右移一味,newEndIndex左移一位。
    6. newStartIndex, oldEndIndex节点相同,执行一次patchVnode过程,并将旧的oldEndVnode移动到头部,oldEndIndex左移一味,newStartIndex右移一位。
    7. 四种组合都不相同,则会搜索旧节点所有子节点,找到将这个旧节点和newStartVnode执行patchVnode过程。
    8. 不断对比的过程使得oldStartIndex不断逼近oldEndIndexnewStartIndex不断逼近newEndIndex。当oldEndIndex <= oldStartIndex说明旧节点已经遍历完了,此时只要批量增加新节点即可。当newEndIndex <= newStartIndex说明旧节点还有剩下,此时只要批量删除旧节点即可。

    结合前面的例子:

    第一步:

    8.4 diff算法实现 - 图1

    第二步:

    8.4 diff算法实现 - 图2

    第三步:

    8.4 diff算法实现 - 图3

    第三步:

    8.4 diff算法实现 - 图4

    第四步:

    8.4 diff算法实现 - 图5

    根据这些步骤,代码实现如下:

    1. class Vn {
    2. updateChildren(el, newCh, oldCh) {
    3. // 新children开始标志
    4. let newStartIndex = 0;
    5. // 旧children开始标志
    6. let oldStartIndex = 0;
    7. // 新children结束标志
    8. let newEndIndex = newCh.length - 1;
    9. // 旧children结束标志
    10. let oldEndIndex = oldCh.length - 1;
    11. let oldKeyToId;
    12. let idxInOld;
    13. let newStartVnode = newCh[newStartIndex];
    14. let oldStartVnode = oldCh[oldStartIndex];
    15. let newEndVnode = newCh[newEndIndex];
    16. let oldEndVnode = oldCh[oldEndIndex];
    17. // 遍历结束条件
    18. while (newStartIndex <= newEndIndex && oldStartIndex <= oldEndIndex) {
    19. // 新children开始节点和旧开始节点相同
    20. if (this._sameVnode(newStartVnode, oldStartVnode)) {
    21. this.patchVnode(newCh[newStartIndex], oldCh[oldStartIndex]);
    22. newStartVnode = newCh[++newStartIndex];
    23. oldStartVnode = oldCh[++oldStartIndex]
    24. } else if (this._sameVnode(newEndVnode, oldEndVnode)) {
    25. // 新childre结束节点和旧结束节点相同
    26. this.patchVnode(newCh[newEndIndex], oldCh[oldEndIndex])
    27. oldEndVnode = oldCh[--oldEndIndex];
    28. newEndVnode = newCh[--newEndIndex]
    29. } else if (this._sameVnode(newEndVnode, oldStartVnode)) {
    30. // 新childre结束节点和旧开始节点相同
    31. this.patchVnode(newCh[newEndIndex], oldCh[oldStartIndex])
    32. // 旧的oldStartVnode移动到尾部
    33. el.insertBefore(oldCh[oldStartIndex].elm, null);
    34. oldStartVnode = oldCh[++oldStartIndex];
    35. newEndVnode = newCh[--newEndIndex];
    36. } else if (this._sameVnode(newStartVnode, oldEndVnode)) {
    37. // 新children开始节点和旧结束节点相同
    38. this.patchVnode(newCh[newStartIndex], oldCh[oldEndIndex]);
    39. el.insertBefore(oldCh[oldEndIndex].elm, oldCh[oldStartIndex].elm);
    40. oldEndVnode = oldCh[--oldEndIndex];
    41. newStartVnode = newCh[++newStartIndex];
    42. } else {
    43. // 都不符合的处理,查找新节点中与对比旧节点相同的vnode
    44. this.findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
    45. }
    46. }
    47. // 新节点比旧节点多,批量增加节点
    48. if(oldEndIndex <= oldStartIndex) {
    49. for (let i = newStartIndex; i <= newEndIndex; i++) {
    50. // 批量增加节点
    51. this.createElm(oldCh[oldEndIndex].elm, newCh[i])
    52. }
    53. }
    54. }
    55. createElm(el, vnode) {
    56. let tag = vnode.tag;
    57. const ele = document.createElement(tag);
    58. this._setAttrs(ele, vnode.data);
    59. const testEle = document.createTextNode(vnode.children);
    60. ele.appendChild(testEle)
    61. el.parentNode.insertBefore(ele, el.nextSibling)
    62. }
    63. // 查找匹配值
    64. findIdxInOld(newStartVnode, oldCh, start, end) {
    65. for (var i = start; i < end; i++) {
    66. var c = oldCh[i];
    67. if (util.isDef(c) && this.sameVnode(newStartVnode, c)) { return i }
    68. }
    69. }
    70. }