Vue2、Vue3中diff算法剖析

虚拟DOM和key

Vue为什么需要虚拟DOM?

虚拟 DOM 在 Vue 中起到了优化性能提供跨平台兼容性 以及简化开发流程的作⽤。

  • 虚拟 DOM 可以减少直接操作实际 DOM 的次数。
  • 虚拟 DOM 是⼀个抽象层,将实际 DOM 抽象为⼀个跨平台 的表示形式。使得vue 可以在不同的平台上运⾏。 Vue 会通过⽐较新旧虚拟
  • DOM 树的差异(Diff算法),找出需要更新的部分进⾏更新。

Vue中key的作⽤?

对节点进⾏标识(相同),⽤于优化节点更新。key在同⼀层级的兄弟节点中必须是唯⼀的

  • 在节点复⽤时,判断是否是相同节点。主要看标签和key是否相同,如果相同则可以进⾏复⽤

Vue2中Diff算法解析

Vue 2 的 diff 算法通过递归、双指针和优化策略来实现的,是同层级比较,不会涉及到跨级比对

  • 同层级节点的⽐较 (⽐较节点的标签、Key 和属性)
  • ⽐较⼦节点 (采⽤双指针⽅式进⾏⽐较),递归⽐较⼦节点

实现Vue2Diff算法

创建虚拟节点

虚拟节点就是一个对象来描述真实节点

h.js

// h.js

// 创建元素节点
export function createElement(tag, data = {},...children) {
    let key = data.key; // key属性
    if (key) {
        delete data.key
    }
    return vnode(tag,data,key,children)
}
// 创建文本节点
export function createTextNode(text) {
    return vnode(undefined,undefined,undefined,undefined,text)
}

function vnode(tag,data,key,children,text) {
    return { // -> vnode.key  // vnode.data.key 不存在
        tag,data,key,children,text
    }
}

生成真实节点

完整patch.js

export function patch(oldVnode, vnode) {
    // 判断oldVnode是一个元素节点?
    if (oldVnode.nodeType) { // 元素
        const el = createElm(vnode);
        oldVnode.appendChild(el);
    } else {
        patchVnode(oldVnode, vnode); // 从根开始比较的
    }
}
function isSameVnode(oldVnode, vnode) { // 必须标签一样key 一样才是同一个元素
    return (oldVnode.tag === vnode.tag) && (oldVnode.key === vnode.key)
}
function patchVnode(oldVnode, vnode) {
    // 比较两个节点 (节点需要能复用)
    if (!isSameVnode(oldVnode, vnode)) {
        // 如果不是相同节点,将老dom元素直接替换成新元素即可
        return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el)
    }
    // 走到这里说明之前和现在的节点是同一个节点, 要复用节点
    const el = vnode.el = oldVnode.el
    if (!oldVnode.tag) { // 文本比较文本内容,有变化复用文本节点更新内容
        if (oldVnode.text !== vnode.text) {
            el.textContent = vnode.text
        }
    }
    // 除了文本那就是元素了, 元素的话需要比较自己的属性和儿子
    updateProperties(vnode, oldVnode.data); // 更新属性,需要和老的比对
    // 比较双方儿子
    let oldChildren = oldVnode.children || [];
    let newChildren = vnode.children || [];
    // 双方都有儿子
    if (oldChildren.length > 0 && newChildren.length > 0) {
        // 比较双方的儿子
        updateChildren(el, oldChildren, newChildren); // 交给此方法来更新
    } else if (oldChildren.length > 0) {
        el.innerHTML = '';
    } else if (newChildren.length > 0) {
        for (let i = 0; i < newChildren.length; i++) {
            el.appendChild(createElm(newChildren[i]))
        }
    }
    // 之前有儿子 现在没儿子 把以前的儿子删除掉
    // 之前的没儿子 现在有儿子 直接将现在的儿子插入即可
}
// 给dom元素添加样式
function updateProperties(vnode, oldProps = {}) {
    const newProps = vnode.data || {}
    const el = vnode.el;
    // 对于属性来说新的要直接生效 但是老的里面有的新的没有还要移除
    let newStyle = newProps.style || {}
    let oldStyle = oldProps.style || {};
    for (let key in oldStyle) { // 老的样式有,新的没有要删除dom元素的样式
        if (!newStyle[key]) {
            el.style[key] = ''
        }
    }
    for (let key in oldProps) { // 老的属性有新的没有 移除这个属性
        if (!newProps[key]) {
            el.removeAttribute(key)
        }
    }
    for (let key in newProps) {
        if (key === 'style') {
            for (let styleName in newProps.style) {
                el.style[styleName] = newProps.style[styleName]
            }
        } else {
            el.setAttribute(key, newProps[key])
        }
    }
}
// 递归创建节点
function createElm(vnode) {
    let { tag, children, text } = vnode
    // 如果标签名是字符串说明是一个元素节点
    if (typeof tag === 'string') {
        // createElement DOMapi
        vnode.el = document.createElement(tag);
        updateProperties(vnode)
        children.forEach(child => vnode.el.appendChild(createElm(child)))
    } else {
        vnode.el = document.createTextNode(text)
    }
    return vnode.el
}
function updateChildren(el, oldChildren, newChildren) {
    // 对dom操作的常见优化 
    // 给你一个列表  增加一个 删除一个 倒序 反序
    // 双端比对
    let oldStartIndex = 0;
    let oldStartVnode = oldChildren[0];
    let oldEndIndex = oldChildren.length - 1;
    let oldEndVnode = oldChildren[oldEndIndex];

    let newStartIndex = 0;
    let newStartVnode = newChildren[0];
    let newEndIndex = newChildren.length - 1;
    let newEndVnode = newChildren[newEndIndex]


    function makeIndexByKey(children) {
        let map = {};

        children.forEach((child, index) => {
            map[child.key] = index; // 老的key 和索引的映射表
        })
        return map;
    }
    const map = makeIndexByKey(oldChildren)



    // 一直比较直到一方指针重合就停止
    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
        // 如果头指针指向的结点是同一个节点,要复用这个节点
        if (!oldStartVnode) { // 比对的时候跳过空节点
            oldStartVnode = oldChildren[++oldStartIndex];
        } else if (!oldEndVnode) {
            oldEndVnode = oldChildren[--oldEndIndex];
        } else if (isSameVnode(oldStartVnode, newStartVnode)) { // 从头往后比
            patchVnode(oldStartVnode, newStartVnode)
            oldStartVnode = oldChildren[++oldStartIndex];
            newStartVnode = newChildren[++newStartIndex]
        } 
        // 老的尾结点和新的尾节点进行比较
        else if (isSameVnode(oldEndVnode, newEndVnode)) { // 从尾往前比
            patchVnode(oldEndVnode, newEndVnode)
            oldEndVnode = oldChildren[--oldEndIndex];
            newEndVnode = newChildren[--newEndIndex]
        } else if (isSameVnode(oldEndVnode, newStartVnode)) {
            // 尾部和头部比较
            patchVnode(oldEndVnode, newStartVnode); // 递归比较
            el.insertBefore(oldEndVnode.el, oldStartVnode.el); // 把尾部移动到头部
            oldEndVnode = oldChildren[--oldEndIndex]; // 老的往前移动
            newStartVnode = newChildren[++newStartIndex]; // 新的往后移动
        } else if (isSameVnode(oldStartVnode, newEndVnode)) {
            patchVnode(oldStartVnode, newEndVnode);
            el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling); // 把尾部移动到头部
            oldStartVnode = oldChildren[++oldStartIndex];
            newEndVnode = newChildren[--newEndIndex]
        }
        // 优化diff算法, 通过dom常见操作优化出来的 
        else {
            // 用新的节点去老的里面找,如果找的到则移动复用
            // 如果找不到则创建插入,
            // 如果新的都判断完了,老的中多的就删除即可
            let moveIndex = map[newStartVnode.key]; //  用新的节点去老的里面找索引
            if (moveIndex == undefined) { // null == undefiend
                el.insertBefore(createElm(newStartVnode), oldStartVnode.el); // 老的中没有
            } else {
                let moveVnode = oldChildren[moveIndex]; // 找到要移动的节点
                el.insertBefore(moveVnode.el, oldStartVnode.el); // 将节点移动到头指针的前面
                oldChildren[moveIndex] = null;
                patchVnode(moveVnode, newStartVnode); // 比对属性和子节点
            }
            newStartVnode = newChildren[++newStartIndex]
        }
    }
    console.log(oldStartIndex,oldEndIndex)
    if (oldStartIndex <= oldEndIndex) { // 老的对于的要删除掉
        for (let i = oldStartIndex; i <= oldEndIndex; i++) {
            let child = oldChildren[i]
            if (child) {
                el.removeChild(child.el)
            }
        }
    }

    if (newStartIndex <= newEndIndex) { // 新的比老的多
        for (let i = newStartIndex; i <= newEndIndex; i++) {
            let ele = newChildren[i]
            let anchor = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].el
            el.insertBefore(createElm(ele), anchor);
            //  el.insertBefore(createElm(ele),null)  === el.appendChild(createElm(ele))
        }
    }

    // newStartIndex >= newEndIndex

}
// 初次渲染
// 比对的核心是从patch开始的  patch(真实的容器,虚拟节点)
//  - 根据虚拟节点创建成真实节点插入到容器中 (创建真实节点采用的是createElm) 
//  - 根据虚拟节点属性创建真实的属性updateProperties
// diff算法 
// 从patch开始的  patch(老的虚拟节点,新的虚拟节点) 
// patchVnode 比较两个节点的差异做更新的 文本、孩子、属性。。。
//  - isSameVnode 看两个节点是不是同一个节点,如果不相同删除替换即可 
//  - 复用之前的dom元素
//  - 如果是文本看文本内容是否有差异
//  - 如果是元素更新属性
//  - 如果是元素在更新儿子
//  - 更新儿子的三种情况 (updateChildren 两方都有儿子如何更新)

patch.js

// patch.js 把虚拟节点变成真实节点

export function patch(oldVnode, vnode) {
    // 判断oldVnode是一个元素节点?
    if (oldVnode.nodeType) { // 有nodeType属性,表示是一个元素
        const el = createElm(vnode);
        oldVnode.appendChild(el);
    } else {
        patchVnode(oldVnode, vnode); // 从根开始比较的
    }
}

// 递归创建节点
function createElm(vnode) {
    let { tag, children, text } = vnode
    // 如果标签名是字符串说明是一个元素节点
    if (typeof tag === 'string') {
        // createElement DOMapi
        // 虚拟节点映射真实dom
        vnode.el = document.createElement(tag);
        // 更新属性
        updateProperties(vnode)
        // 儿子也创造为真实节点
        children.forEach(child => vnode.el.appendChild(createElm(child)))
    } else {
        vnode.el = document.createTextNode(text)
    }
    return vnode.el
}

// 更新属性,给dom元素添加样式
function updateProperties(vnode, oldProps = {}) {
    const newProps = vnode.data || {}
    const el = vnode.el;
    // 对于属性来说新的要直接生效 但是老的里面有的新的没有还要移除
    let newStyle = newProps.style || {}
    let oldStyle = oldProps.style || {};
    for (let key in oldStyle) { // 老的样式有,新的没有要删除dom元素的样式
        if (!newStyle[key]) {
            el.style[key] = ''
        }
    }
    for (let key in oldProps) { // 老的属性有新的没有 移除这个属性
        if (!newProps[key]) {
            el.removeAttribute(key)
        }
    }
    for (let key in newProps) {
        if (key === 'style') {
            for (let styleName in newProps.style) {
                el.style[styleName] = newProps.style[styleName]
            }
        } else {
            el.setAttribute(key, newProps[key])
        }
    }
}

Diff根节点、比对子节点

function isSameVnode(oldVnode, vnode) { // 必须标签一样key 一样才是同一个元素
    return (oldVnode.tag === vnode.tag) && (oldVnode.key === vnode.key)
}
function patchVnode(oldVnode, vnode) {
    // 比较两个节点 (节点需要能复用)
    if (!isSameVnode(oldVnode, vnode)) {
        // 如果不是相同节点,将老dom元素直接替换成新元素即可
        return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el)
    }
    // 走到这里说明之前和现在的节点是同一个节点, 要复用节点
    const el = vnode.el = oldVnode.el
    if (!oldVnode.tag) { // 文本比较文本内容,有变化复用文本节点更新内容
        if (oldVnode.text !== vnode.text) {
            el.textContent = vnode.text
        }
    }
    // 除了文本那就是元素了, 元素的话需要比较自己的属性和儿子
    updateProperties(vnode, oldVnode.data); // 更新属性,需要和老的比对
    // 比较双方儿子
    let oldChildren = oldVnode.children || [];
    let newChildren = vnode.children || [];
    // 双方都有儿子
    if (oldChildren.length > 0 && newChildren.length > 0) {
        // 比较双方的儿子
        updateChildren(el, oldChildren, newChildren); // 交给此方法来更新
    } 
    // 老儿子有值,新儿子没有
    else if (oldChildren.length > 0) {
        el.innerHTML = '';
    } 
    // 新的儿子有值,老的儿子没值
    else if (newChildren.length > 0) {
        for (let i = 0; i < newChildren.length; i++) {
            // 丢到容器
            el.appendChild(createElm(newChildren[i]))
        }
    }
    // 之前有儿子 现在没儿子 把以前的儿子删除掉
    // 之前的没儿子 现在有儿子 直接将现在的儿子插入即可
}

Diff元素属性

function updateProperties(vnode, oldProps = {}) {
    const newProps = vnode.data || {}
    const el = vnode.el;
    // 对于属性来说新的要直接生效 但是老的里面有的新的没有还要移除
    let newStyle = newProps.style || {}
    let oldStyle = oldProps.style || {};
    for (let key in oldStyle) { // 老的样式有,新的没有要删除dom元素的样式
        if (!newStyle[key]) {
            el.style[key] = ''
        }
    }
    for (let key in oldProps) { // 老的属性有新的没有 移除这个属性
        if (!newProps[key]) {
            el.removeAttribute(key)
        }
    }
    for (let key in newProps) {
        if (key === 'style') {
            for (let styleName in newProps.style) {
                el.style[styleName] = newProps.style[styleName]
            }
        } else {
            el.setAttribute(key, newProps[key])
        }
    }
}

Vue2中Diff中的优化策略

在开头和结果新增元素

头移尾、尾移头

暴力比对

Vue3中Diff算法解析

Vue2 中的 diff 算法如何可以复用节点,可能会产生移动节点的操作(最长递增子序列,尽可能少移动节点)

Vue2中的diff算法,递归比对(浪费性能),能否只比较动态的节点,非动态的就不比较了

Vue3在模板编译的时候会标记哪些是动态节点,只比较动态节点

Vue3给动态属性添加了标识

Vue3优化了追加和删除的情况

sync from start

同步从头开始,找可以复用的

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

sync from end

同步从尾部开始

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

common sequence + mount

同序列挂载,找参照物,就是看下一个节点是否有值,有值则向前插入,没有值则向后插入

image-20240802210403539

common sequence + unmount

同序列卸载

image-20240802210934517

unknown sequence

未知序列,去除相同部分,比对不同部分(最长递归子序列)

build key:index map for newChildren

用新节点创建映射表

image-20240802211158729

loop through old children left to be patched and try to patch

循环,通过老节点循环,尝试去做补丁

如果老的节点和新的节点能复用,则比对属性和儿子,如果匹配到了则复用,如果匹配不到,老得多,则把老的干掉

move and mount

根据新的节点做了一个映射表,并且给节点做了一个新数组[有多少个节点需要比对,这个数组就有多少项]

用老的和新的去做patch,patch过的节点,会被标识成老的索引

循环的时候,如果值为0,则说明是新增节点,直接插入即可

根据列表倒序插入,将新的节点进行倒叙插入,在插入的过程中遇到和序列一样的索引则跳过,其他的需要移动

最长递归子序列的目的就是标识哪些索引不用移动

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Vue3中编译优化

前提写的是模板,模板通过 vue 编译后,会给动态节点添加标识 PatchFlags

Block节点

block节点主要⽤于收集动态⼦节点。可以帮助Vue仅跟踪和更新模板中必要的部分。在数据变化时,Vue 3可以准确地知道哪些部分

的模板需要进⾏更新,从⽽避免不必要的diff操作。(基于dynamicChildren实现靶向更新)

后续更新的时候,只比较动态节点,不用递归比较了

但是会忽略层级,更新的时候会有问题

动态标识
export const enum PatchFlags {
  TEXT = 1, // 动态⽂本节点
  CLASS = 1 << 1, // 动态class
  STYLE = 1 << 2, // 动态style
  PROPS = 1 << 3, // 除了class\style动态属性
  FULL_PROPS = 1 << 4, // 有key,需要完整diff
  HYDRATE_EVENTS = 1 << 5, // 挂载过事件的
  STABLE_FRAGMENT = 1 << 6, // 稳定序列,⼦节点顺序
  不会发⽣变化
  KEYED_FRAGMENT = 1 << 7, // ⼦节点有key的
  fragment
  UNKEYED_FRAGMENT = 1 << 8, // ⼦节点没有key的
  fragment
  NEED_PATCH = 1 << 9, // 进⾏⾮props⽐较, ref⽐较
  DYNAMIC_SLOTS = 1 << 10, // 动态插槽
  DEV_ROOT_FRAGMENT = 1 << 11,
  HOISTED = -1, // 表示静态节点,内容变化,不⽐较⼉⼦
  BAIL = -2 // 表示diff算法应该结束
}

BlockTree

为什么我们还要提出 blockTree 的概念? 只有 block 不就挺好的么? 问题出在 block 在收集动态节点时是忽略虚拟 DOM 树层级的。

靶向更新,

<div>
  <p v-if="flag">
    <span>{{a}}</span>
  </p>
  <div v-else>
    <span>{{a}}</span>
  </div>
</div>

这里我们知道默认根节点是一个 block 节点,如果要是按照之前的套路来搞,这时候切换 flag 的状态将无法从 p 标签切换到 div 标签。 解决方案:就是将不稳定的结构也作为 block 来进行处理

不稳定结构

所谓的不稳结构就是 DOM 树的结构可能会发生变化。不稳定结构有哪些呢? (v-if/v-for/Fragment)

v-if
<div>
  <div v-if="flag">
    <span>{{a}}</span>
  </div>
  <div v-else>
    <p><span>{{a}}</span></p>
  </div>
</div>

编译后的结果:

return function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(),
    _createElementBlock("div", null, [
      _ctx.flag
        ? (_openBlock(),
          _createElementBlock("div", { key: 0 }, [
            _createElementVNode(
              "span",
              null,
              _toDisplayString(_ctx.a),
              1 /* TEXT */
            ),
          ]))
        : (_openBlock(),
           // 给节点增加key
          _createElementBlock("div", { key: 1 }, [
            _createElementVNode("p", null, [
              _createElementVNode(
                "span",
                null,
                _toDisplayString(_ctx.a),
                1 /* TEXT */
              ),
            ]),
          ])),
    ])
  );
};
Block(div)
	Blcok(div,{key:0})
	Block(div,{key:1})

父节点除了会收集动态节点之外,也会收集子 block。 更新时因 key 值不同会进行删除重新创建

v-for

随着v-for变量的变化也会导致虚拟 DOM 树变得不稳定

<div>
  <div v-for="item in fruits">{{item}}</div>
</div>
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(true),
    _createElementBlock(
      _Fragment,
      null,
      _renderList(_ctx.fruits, (item) => {
        return (
          _openBlock(),
          _createElementBlock("div", null, _toDisplayString(item), 1 /* TEXT */)
        );
      }),
      256 /* UNKEYED_FRAGMENT */
    )
  );
}

可以试想一下,如果不增加这个 block,前后元素不一致是无法做到靶向更新的。因为 dynamicChildren 中还有可能有其他层级的元素。同时这里还生成了一个 Fragment,因为前后元素个数不一致,所以称之为不稳定序列

稳定 Fragment

这里是可以靶向更新的, 因为稳定则有参照物,循环次数是固定的,所以是稳定的

<div>
  <div v-for="item in 3">{{item}}</div>
</div>
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(),
    _createElementBlock("div", null, [
      (_openBlock(),
      _createElementBlock(
        _Fragment,
        null,
        _renderList(3, (item) => {
          return _createElementVNode(
            "div",
            null,
            _toDisplayString(item),
            1 /* TEXT */
          );
        }),
        64 /* STABLE_FRAGMENT */
      )),
    ])
  );
}

静态提升

<div>
  <span>hello</span>
  <span a="1" b="2">{{name}}</span>
  <a><span>{{age}}</span></a>
</div>

我们把模板直接转化成 render 函数是这个酱紫的,那么问题就是每次调用render函数都要重新创建虚拟节点。

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(),
    _createElementBlock("div", null, [
      _createElementVNode("span", null, "hello"),
      _createElementVNode(
        "span",
        {
          a: "1",
          b: "2",
        },
        _toDisplayString(_ctx.name),
        1 /* TEXT */
      ),
      _createElementVNode("a", null, [
        _createElementVNode(
          "span",
          null,
          _toDisplayString(_ctx.age),
          1 /* TEXT */
        ),
      ]),
    ])
  );
}
const _hoisted_1 = /*#__PURE__*/ _createElementVNode(
  "span",
  null,
  "hello",
  -1 /* HOISTED */
);
const _hoisted_2 = {
  a: "1",
  b: "2",
};

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(),
    _createElementBlock("div", null, [
      _hoisted_1,
      _createElementVNode(
        "span",
        _hoisted_2,
        _toDisplayString(_ctx.name),
        1 /* TEXT */
      ),
      _createElementVNode("a", null, [
        _createElementVNode(
          "span",
          null,
          _toDisplayString(_ctx.age),
          1 /* TEXT */
        ),
      ]),
    ])
  );
}

静态提升则是将静态的节点或者属性提升出去,标记成跳过diff算法。静态提升是以树为单位。也就是说树中节点有动态的不会进行提升。

预字符串化

静态提升的节点都是静态的,我们可以将提升出来的节点字符串化。 当连续静态节点超过 20 个时,会将静态节点序列化为字符串。

<div>
  <span></span>
  ... ...
  <span></span>
</div>
const _hoisted_1 = /*#__PURE__*/ _createStaticVNode("<span>....</span>", 20);

缓存函数

<div @click="e=>v=e.target.value"></div>

每次调用 render 的时都要创建新函数

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(),
    _createElementBlock(
      "div",
      {
        onClick: (e) => (_ctx.v = e.target.value),
      },
      null,
      8 /* PROPS */,
      ["onClick"]
    )
  );
}

开启函数缓存后,函数会被缓存起来,后续可以直接使用

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(),
    _createElementBlock("div", {
      onClick: _cache[0] || (_cache[0] = (e) => (_ctx.v = e.target.value)),
    })
  );
}
  • 10
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值