Vue3渲染器之简单Diff算法

diff 算法的目的是根据 key 复用 dom 节点,通过移动节点而不是创建新节点来减少 dom 操作。

由上一节可以看到我们只做同层的对比,type 变了就不再对比子节点

key 属性就像虚拟节点的"身份证"号,只要两个虚拟节点的 type 属性值和 key 属性值都相同,那么我们就认为它们是相同的,即可以进行 DOM 的复用

js 复制代码
     let vnode = reactive({
            type: 'div',
            children: [
                { type: 'p', children: '1', key: 1 },
                { type: 'p', children: '2', key: 2 },
                { type: 'p', children: '3', key: 3 }
            ]
        })
        
      vnode.children =
            [
                { type: 'p', children: '10', key: 1 },
                { type: 'p', children: '20', key: 2 },
                { type: 'p', children: '30', key: 3 }
            ]

先从最简单的情况开始(只修改内容),只需要使用双层for循环找出key相同的替换既可

js 复制代码
    if (typeof n2.children === 'string') {
            // 省略部分代码
        } else if (Array.isArray(n2.children)) {
            const oldChildren = n1.children
            const newChildren = n2.children

            // 遍历新的 children
            for (let i = 0; i < newChildren.length; i++) {
                const newVNode = newChildren[i]
                // 遍历旧的 children
                for (let j = 0; j < oldChildren.length; j++) {
                    const oldVNode = oldChildren[j]
                    // 如果找到了具有相同 key 值的两个节点,说明可以复用,但仍然需要调patch函数更新
                    if (newVNode.key === oldVNode.key) {
                        patch(oldVNode, newVNode, container)
                        break // 这里需要 break
                    }
                }
            }
        }

如果位置调换应该怎么做呢

js 复制代码
  vnode.children =
            [
                { type: 'p', children: '1', key: 1 },
                { type: 'p', children: '3', key: 3 }
                { type: 'p', children: '2', key: 2 },
            ]

第一步先确定需要移动的元素

  1. 先遍历新数组,根据新数组元素的key在旧数组查找对应key的元素,记录它在旧数组的索引值,这些索引值应该是递增的,如果不是递增代表他要被移动
  2. 需要一个变量记录索引值列表的最大值 let lastIndex = 0
  3. 第一步:在旧数组的索引值0,不小于于lastIndex,不需要移动
  4. 第二步:在旧数组的索引值2,不小于lastIndex,不需要移动,lastIndex更新为2
  5. 第三步:在旧数组的索引值1,小于lastIndex,需要移动,lastIndex不变

总之不是递增的序列就需要移动

js 复制代码
let lastIndex = 0
for (let i = 0; i < newLen; i++) {
    const newVnode = newChildren[i];
    for (let j = 0; j < oldLen; j++) {
        const oldVnode = oldChildren[i];
        if (newVnode.key === oldVnode.key) {
            patch(oldVnode, newVnode, container) 
            if (j < lastIndex) {
                //进入代表需要移动元素
            } else {
                lastIndex = j
            }
            break
        }
    }
}

第二步如何移动

先换一个复杂一点的例子

js 复制代码
  vnode.children =
            [
                { type: 'p', children: '3', key: 3 },
                { type: 'p', children: '1', key: 1 },
                { type: 'p', children: '2', key: 2 },
            ]

不难看出需要移动的是第二,三个元素.

  1. 如何移动第二个key为1的元素,可以看出这个元素的位置在新数组是排在key为3之后的,所以只需要把旧数组里的这个元素插入到key为3的元素之后.也就是
js 复制代码
  children: [
                { type: 'p', children: '2', key: 2 },
                { type: 'p', children: '3', key: 3 },
                { type: 'p', children: '1', key: 1 },
            ]

2.同理,如何移动第三个key为2的元素,这个元素的位置在新数组是排在key为1之后的,即:

js 复制代码
children: [
                { type: 'p', children: '3', key: 3 },
                { type: 'p', children: '1', key: 1 },
                { type: 'p', children: '2', key: 2 }
            ]

使用insertBefore在指定节点之前插入元素,parent是父节点,在这里也就是container容器,el是添加的元素,anchor即指定节点,如果不传anchor插入在最后.

另外这个insertBefore是在DOM下使用的,需要把它当成生成渲染器函数的选项传递,因为不同平台实现api不一样.

parent.insertBefore(el, anchor)

js 复制代码
let lastIndex = 0
for (let i = 0; i < newLen; i++) {
    const newVnode = newChildren[i];
    for (let j = 0; j < oldLen; j++) {
        const oldVnode = oldChildren[i];
        if (newVnode.key === oldVnode.key) {
            patch(oldVnode, newVnode, container) 
            if (j < lastIndex) {
                // 代码运行到这里,说明 newVNode 对应的真实 DOM 需要移动
                // 先获取 newVNode 的前一个 vnode,即 prevVNode
                const prevVNode = newChildren[i - 1]
                // 如果 prevVNode 不存在,则说明当前 newVNode 是第一个节点,它不需要移动
                if (prevVNode) {
                    // 由于我们要将 newVNode 对应的真实 DOM 移动到prevVNode 所对应真实 DOM 后面,
                    // 所以我们需要获取 prevVNode 所对应真实 DOM 的下一个兄弟节点,并将其作为锚点
                    const anchor = prevVNode.el.nextSibling
                    // 调用 insert 方法将 newVNode 对应的真实 DOM 插入到锚点元素前面,
                    // 也就是 prevVNode 对应真实 DOM 的后面
                    insert(newVNode.el, container, anchor)
                }
            } else {
                lastIndex = j
            }
            break

        }
    }
}

注意在mountElement patchElement方法里都处理保存了真实DOM,所以上面才能直接拿到对应的真实DOM.

js 复制代码
  //mountElement
  const el = vnode.el = createElement(vnode.type)
  //patchElement
   const el = n2.el = n1.el

接下来考虑一下有新增元素的情况

  1. 想办法找到新增节点
  2. 将新增节点挂载到正确位置

所以整体的代码是

js 复制代码
const oldChildren = n1.children const newChildren = n2.children const oldLen = oldChildren.length const newLen = newChildren.length const commonLength = Math.min(oldLen, newLen) let lastIndex = 0
for (let i = 0; i < newLen; i++) {
    const newVNode = newChildren[i];
    // 在第一层循环中定义变量 find,代表是否在旧的一组子节点中找到可复用的节点,
    // 初始值为 false,代表没找到
    let find = false
    for (j = 0; j < oldChildren.length; j++) {
        const oldVNode = oldChildren[j]
        if (newVNode.key === oldVNode.key) {
            // 一旦找到可复用的节点,则将变量 find 的值设为 true
            find = true patch(oldVNode, newVNode, container) if (j < lastIndex) {
                const prevVNode = newChildren[i - 1]
                if (prevVNode) {
                    const anchor = prevVNode.el.nextSibling insert(newVNode.el, container, anchor)
                }
            } else {
                lastIndex = j
            }
            break
        }
    }
    // 如果代码运行到这里,find 仍然为 false,
    // 说明当前 newVNode 没有在旧的一组子节点中找到可复用的节点
    // 也就是说,当前 newVNode 是新增节点,需要挂载
    if (!find) {
        // 为了将节点挂载到正确位置,我们需要先获取锚点元素
        // 首先获取当前 newVNode 的前一个 vnode 节点
        const prevVNode = newChildren[i - 1] let anchor = null
        if (prevVNode) {
            anchor = prevVNode.el.nextSibling
        } else {
            // 如果没有前一个 vnode 节点,说明即将挂载的新节点是第一个子节
            // 这时我们使用容器元素的 firstChild 作为锚点
            anchor = container.firstChild
        }
        // 挂载 newVNode
        patch(null, newVNode, container, anchor)
    }

}

注意最后并不是直接insert插入,而是重新调用patch方法,因为新增节点的type不同处理流程也不同,需要重新调用patch.

所以patch,patch中的mountElement,mountElement中的insert函数需要添加一个锚点参数用来在指定位置添加.

之后当然是考虑元素需要卸载的情况,这种情况其实处理起来很简单.

只需要在双层for循环之后,即更新操作完成.遍历旧数组,查找key在新数组没有对应的元素,将其卸载.

js 复制代码
// 上一步的更新操作完成后
// 遍历旧的一组子节点
for (let i = 0; i < oldChildren.length; i++) {
    const oldVNode = oldChildren[i]
    // 拿旧子节点 oldVNode 去新的一组子节点中寻找具有相同 key 值的节点
    const has = newChildren.find(vnode = >vnode.key === oldVNode.key) if (!has) {
        // 如果没有找到具有相同 key 值的节点,则说明需要删除该节点
        // 调用 unmount 函数将其卸载
        unmount(oldVNode)
    }
}

unmount函数在上节渲染器有提到

js 复制代码
     function unmount(vnode) {
            if (vnode.type === Fragment) {
                vnode.children.forEach(c => unmount(c))
                return
            }
            const parent = vnode.el.parentNode
            if (parent) {
                parent.removeChild(vnode.el)
            }
        }

下一节讲双端Diff

相关推荐
蜗牛快跑21312 分钟前
面向对象编程 vs 函数式编程
前端·函数式编程·面向对象编程
Dread_lxy13 分钟前
vue 依赖注入(Provide、Inject )和混入(mixins)
前端·javascript·vue.js
涔溪1 小时前
Ecmascript(ES)标准
前端·elasticsearch·ecmascript
榴莲千丞1 小时前
第8章利用CSS制作导航菜单
前端·css
奔跑草-1 小时前
【前端】深入浅出 - TypeScript 的详细讲解
前端·javascript·react.js·typescript
羡与1 小时前
echarts-gl 3D柱状图配置
前端·javascript·echarts
guokanglun1 小时前
CSS样式实现3D效果
前端·css·3d
咔咔库奇2 小时前
ES6进阶知识一
前端·ecmascript·es6
渗透测试老鸟-九青2 小时前
通过投毒Bingbot索引挖掘必应中的存储型XSS
服务器·前端·javascript·安全·web安全·缓存·xss
龙猫蓝图2 小时前
vue el-date-picker 日期选择器禁用失效问题
前端·javascript·vue.js