diff算法

217 阅读8分钟

感受diff算法

现在有两组虚拟节点,我们使用diff算法进行patch他们,并来观察观察结果。这块我们就使用snabbdom这个库来进行演示了。

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

const path = init([
    // 通过传入模块初始化 path 函数
    classModule,  // 支持classes功能
    propsModule, // 支持传入 props 属性
    styleModule,  // 支持内联样式,同时支持动画
    eventListenersModule // 添加事件监听
])

使用

let vnode = h('ul', {}, [
    h('li', { key: 'A' }, 'A'),
    h('li', { key: 'B' }, 'B'),
    h('li', { key: 'C' }, 'C'),
    h('li', { key: 'D' }, 'D'),
    h('li', { key: 'E' }, 'E'),
])

let vnode1 = h('ul', {}, [
    h('li', { key: 'A' }, 'A'),
    h('li', { key: 'B' }, 'B'),
    h('li', { key: 'C' }, 'C')
])

let container = document.getElementById('container')
let btn = document.getElementById('btn')

patch(container, vnode)

btn.onclick = function () {
    patch(vnode, vnode1)
}

我们来看一下效果,最后发现只是删除了几个多余的DOM节点,原来的三个节点并没有发生任何改变。这就是diff算法要做的事情,用js对象模拟真实dom,对比找出差异,并实现最小化更新。

tyerwityweiurtweui.gif

整体流程

diff算法 (1).png

实现h.js

在实现h函数之前,先要弄清楚h函数的作用,h函数的作用就是,接受三个参数,生成虚拟节点。用来描述真实DOM

// 手写h 函数

import vnode from "./vnode"

/**
 * 
 * 根据传入的属性返回一个vnode
 * 
 * 只支持传入三个参数的调用形式
 * 
 * h('', {}, '文本')
 * 
 * h('', {}, [])
 * 
 * h('', {}, h())
 */

/**
 * 
 * @param {选择器} sel 
 * @param {属性等} data 
 * @param {孩子} c 
 */
export default function h(sel, data, c) {
    // 1. 先检测参数个数
    if (arguments.length !== 3) {
        throw new Error('h函数传入的参数格式只能是三个!!!')
    }

    // 2. 检测第三参数的类型   h('', {}, '文本')
    if (typeof c === 'string' || typeof c === 'number') {
        return vnode(sel, data, undefined, c, undefined)
    }
    // 3. 第三参数是数组的情况  h('', {}, [])
    else if (Array.isArray(c)) {
        // 需要循环判断数组里面的元素是不是一个vnode
        let children = []
        for (let item of c) {
            // 写好满足的条件,取反就是不满足的情况,需要抛异常
            if (!(typeof item === 'object' && item.hasOwnProperty('sel'))) {
                throw new Error('传入的数组中的项存在不是一个vnode的情况!!!')
            }
            children.push(item)
        }
        return vnode(sel, data, children, undefined, undefined)
    }
    // 4. 第三参数是对象,并且是一个vnode的情况  h('', {}, h())
    else if (typeof c === 'object' && c.hasOwnProperty('sel')) {
        // 说明是 ③ h函数 是一个对象(h函数返回值是一个对象)放到children数组中就行了
        let children = [c]
        return vnode(sel, data, children, undefined, undefined)
    } else {
        throw new Error('传入的参数类型不正确!!!')
    }
}

实现vnode.js

在这里,我们发现使用了一个vnode函数,它其实很简单,就是把参数包装成一个对象。 vnode.js

// 将传入的参数组合成对象返回
export default function (sel, data, children, text, elm) {
    const key = data.key
    return {
        sel,
        data,
        children,
        text,
        elm,
        key
    }
}

实现patch.js

patch函数的作用,接受一个老的虚拟节点和一个新的虚拟节点,判断是否需要精细化对比。

import vnode from "./vnode"
import createElement from "./createElement"
import patchVnode from "./patchVnode"

export default function patch(oldVnode, newVnode) {
    // 判断oldVnode 是不是一个虚拟节点
    if (oldVnode.sel === '' || oldVnode.sel === undefined) {
        let sel = oldVnode.tagName.toLowerCase()
        oldVnode = vnode(sel, {}, [], oldVnode.innerText, oldVnode)
    }
    // 判断 oldVnode和 newVnode 是不是同一个虚拟节点
    if (oldVnode.sel === newVnode.sel && oldVnode.key === newVnode.key) {
        // 如果是同一个虚拟节点,则需要进行精细化比较
        patchVnode(oldVnode, newVnode)
    } else {
        // 如果不是同一个虚拟节点,则需要进行替换

        // 需要操作DOM 需要先将 新的虚拟节点转换成 真实 DOM
        let newVnodeElm = createElement(newVnode)
        let oldVnodeElm = oldVnode.elm

        // 插入新节点到老的之前
        if (newVnodeElm) {
            oldVnodeElm.parentNode.insertBefore(newVnodeElm, oldVnodeElm)
        }
        // 删除老的节点
        oldVnodeElm.parentNode.removeChild(oldVnodeElm)
    }

}

实现patchVnode.js

如果是同一个节点的时候,调用patchVnode进行详细的比较。

import createElement from "./createElement"
import updateChildren from "./updateChildren"

export default function patchVnode(oldVnode, newVnode) {
    if (oldVnode === newVnode) return

    // 判断newVnode 有没有text属性
    if (newVnode.text !== '' && (newVnode.children === undefined || newVnode.children.length === 0)) {
        // 如果有text 属性 判断新老节点的text 属性是否相同?
        if (oldVnode.text === newVnode.text) {
            return
        } else {
            // 如果不相同,则需要替换文本
            oldVnode.elm.innerText = newVnode.text
        }
    } else {
        // 判断oldVnode 有没有children
        if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
            // 两者都有孩子,就进入更复杂的diff
            updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
        } else {
            // 老的是 text 节点, 新的有 children
            // 先清空老的节点
            oldVnode.elm.innerText = ''
            for (let ch of newVnode.children) {
                let chDom = createElement(ch)
                oldVnode.elm.appendChild(chDom)
            }
        }
    }
}

实现createElement.js

createElement函数的作用,接受一个虚拟节点,根据虚拟节点创建出真实节点,如果当前虚拟节点有孩子,就进行递归创建。


export default function createElement(vnode) {
    // 先用 vnode 最外层创建出一个节点

    let domNode = document.createElement(vnode.sel)

    // 判断 vnode 是有子节点还是有 文本?
    // 当文本属性不为空,并且 children 属性有值,且长度大于0
    if (vnode.text !== '' && (vnode.children === undefined || vnode.children.length === 0)) {
        domNode.innerText = vnode.text
    } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
        // 说明内部还有子节点,需要递归创建
        for (let ch of vnode.children) {
            let chDom = createElement(ch)
            domNode.appendChild(chDom)
        }
    }
    // 给虚拟节点挂载一个真实节点
    vnode.elm = domNode
    // 返回当前真实节点
    return domNode
}

实现updateChildren.js

当两个虚拟节点都有children的时候,因为有可能涉及到子节点还有子节点,所以可能有递归patch的情况,如果在调用updateChildren函数的时候发现两个节点是同一个节点的时候,就需要再次调用patchVnode函数。

import createElement from "./createElement";
import patch from "./patch";
import patchVnode from "./patchVnode";

export default function updateChildren(parentElm, oldCh, newCh) {
    console.log('updateChildren()');
    console.log(oldCh, newCh)


    // 先定义是个指针

    let newStartIdx = 0

    let newEndIdx = newCh.length - 1

    let oldStartIdx = 0

    let oldEndIdx = oldCh.length - 1

    // 指针指向的四个节点

    let newStartVnode = newCh[newStartIdx]

    let newEndVnode = newCh[newEndIdx]

    let oldStartVnode = oldCh[oldStartIdx]

    let oldEndVnode = oldCh[oldEndIdx]

    let keyMap = null

    // 开始循环 

    while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
        // 首先不是判断四种命中,而是略过 已经加了 undefined 的标记项
        if (oldStartVnode === null || oldCh[oldStartIdx] === undefined) {
            oldStartVnode = oldCh[++oldStartIdx]
        } else if (oldEndIdx === null || oldCh[oldEndIdx] === undefined) {
            oldEndVnode = oldCh[--oldEndIdx]
        } else if (newStartVnode === null || newCh[newStartIdx] === undefined) {
            newStartVnode = newCh[++newStartIdx]
        } else if (newEndVnode === null || newCh[newEndIdx] === undefined) {
            newEndVnode = newCh[--newEndIdx]
        } else if (checkSameVnode(oldStartVnode, newStartVnode)) {
            console.log('1.新前与旧前 命中')
            // 精细化比较两个虚拟节点
            patchVnode(oldStartVnode, newStartVnode)
            // 移动节点
            newStartVnode = newCh[++newStartIdx]
            oldStartVnode = oldCh[++oldStartIdx]
        } else if (checkSameVnode(oldEndVnode, newEndVnode)) {
            console.log('2.新后与旧后 命中')
            patchVnode(oldEndVnode, newEndVnode)
            newEndVnode = newCh[--newEndIdx]
            oldEndVnode = oldCh[--oldEndIdx]
        } else if (checkSameVnode(oldStartVnode, newEndVnode)) {
            console.log('3.新后与旧前 命中')
            patchVnode(oldStartVnode, newEndVnode)
            // 把旧前的节点放到新后面
            // 这块只能是往最后一个节点插入,因为新的节点是从后面开始插入的
            parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
            newEndVnode = newCh[--newEndIdx]
            oldStartVnode = oldCh[++oldStartIdx]
        } else if (checkSameVnode(oldEndVnode, newStartVnode)) {
            console.log('4.新前与旧后 命中')
            patch(oldEndVnode, newStartVnode)
            //
            parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
            newStartVnode = newCh[++newStartIdx]
            oldEndVnode = oldCh[--oldEndIdx]
        } else {
            // 四种都没找到
            console.log('5.四种都没找到')
            if (!keyMap) {
                keyMap = {}
                // 记录 oldVnode 中的节点出现的 key
                // 从 oldStartIdx 开始,到 oldEndIdx 结束 ,创建 keyMap
                for (let i = oldStartIdx; i <= oldEndIdx; i++) {
                    const key = oldCh[i].key
                    if (key !== undefined) {
                        keyMap[key] = i
                    }
                }
            }
            console.log(keyMap)
            // 寻找当前项 (newStartIdx) 在keyMap 中映射的序号
            const idxInOld = keyMap[newStartVnode.key]
            if (idxInOld === undefined) {
                // 如果 idxInOld 是 undefined 说明是全新的项,要插入
                // 被加入的项(就是 newStartVnode这项)  现在不是真正的 DOM 节点
                parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
            } else {
                // 如果找到了, 说明不是全新的项, 要移动
                const elmToMove = oldCh[idxInOld]
                patchVnode(elmToMove, newStartVnode)
                // 把这项设置为 undefined ,表示已经处理完这一项了
                parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
            }
            newStartVnode = newCh[++newStartIdx]
        }
    }

    // 循环结束
    if (newStartIdx <= newEndIdx) {
        // 说明 newNode 还有剩余节点没有处理,所以要添加这些节点
        // 找插入的标杆
        for (let i = newStartIdx; i <= newEndIdx; i++) {
            console.log(oldCh, oldStartIdx)
            // insertBefore 方法可以自动设别 null ,如果是 null 就会自动排到队尾,和appendChild一样
            let anchor = oldCh[oldStartIdx] ? oldCh[oldStartIdx].elm : null
            parentElm.insertBefore(createElement(newCh[i]), anchor)
        }
    } else if (oldStartIdx <= oldEndIdx) {
        // 说明oldVnode 还有剩余节点没有处理,所以要删除这些节点
        for (let i = oldStartIdx; i <= oldEndIdx; i++) {
            if (oldCh[i]) {
                parentElm.removeChild(oldCh[i].elm)
            }
        }
    }
}


function checkSameVnode(a, b) {
    return a.sel === b.sel && a.key === b.key
}

多画流程图,多些一边,过几天再复习一下。

总结

  • 在搞清楚 diff 算法之前,一定要知道,diff 算法 对比的是虚拟 DOM 修改的是 老虚拟 DOM 上面真实 DOM 的引用。

  • 明天看四种命中查找

  • 简单总结一下新老节点都有 children 的时候的 diff 策略

  1. 循环的条件是 newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx。

  2. 循环的时候如果发现四个节点指向的 vnode 等于 null 或者 undefined 就先跳过 (更新指针和虚拟节点的值)。

  3. 检测新老节点是否相同,进入第一种对比策略。

    1. 新前与旧前对比(如果相同要精心 patch,并更新指针位置和指针指向的节点)
    2. 新后与旧后对比(如果相同要精心 patch)
    3. 新后与旧前对比(如果相同要精心 patch)涉及节点移动 移动到旧后之后
    4. 新前与旧后对比(如果相同要精心 patch)涉及节点移动 移动到旧前之前
    5. 3.4中移动的节点都是老的DOM,参考的节点也都是老节点,位置的方向是跟着新节点走的。
  4. 假如四种策略都没有命中。

    1. 记录 oldVnode 中节点出现的 key, 从 oldStartIdx 开始到 oldEndIdx 结束,创建 keyMap

    2. 寻找当前项 (newStartIdx) 在 keyMap 中映射的序号 keyMap[newStartVnode.key],找到在老虚拟节点中的位置。(idxInOld)

      1. 如果 === undefined 说明是全新的项,要插入

      2. 如果存在,说明不是全新的项,则说明要移动,根据 idxInOld 在老节点中找到要移动的元素(elmToMove),插入到 oldStartVnode.elm 之前,把处理完成的虚拟节点设置成 undefined

  5. 循环结束。

    1. 如果 newStartIdx <= newEndIdx 则说明还有剩余节点没有处理,所以要添加这些节点
    2. 如果 oldStartIdx <= oldEndIdx 这说明还有剩余节点没有处理,所以要删除这些节点