diff算法初识

123 阅读7分钟

一、初始化项目

安装webpack webpack-cli webpack-dev

配置webpack.config.js

const path = require('path');

module.exports = {
    // 入口
    entry: './src/index.js',
    // 出口
    output: {
        // 虚拟打包路径,就是说文件夹不会真正生成,而是在8080端口虚拟生成
        publicPath: 'xuni',
        // 打包出来的文件名,不会真正的物理生成
        filename: 'bundle.js'
    },
    devServer: {
        // 端口号
        port: 8080,
        // 静态资源文件夹
        contentBase: 'public'
    }
}

二、创建h函数和Vnode函数

h函数的目的是创建虚拟节点vnode

这里简化h函数的形式,h函数接收三个参数,第一个参数是标签,第二个参数是标签的属性,第三个参数是文本内容或者子节点(字符串或者数组)

//h.js
import vnode from './vnode'
export function h(sel,data,params){

    //h函数的第三个是字符串类型,意味没有子元素
    if(typeof params === 'string'){
        return vnode(sel,data,undefined,params,undefined)
    }else if(Array.isArray(params)){ //h函数的第三个参数是数组,意味有子元素
        let children = []
        for(let item of params){
            children.push(item)
        }
        return vnode(sel,data,children,undefined,undefined)
    }
}

h函数传递参数到vnode函数,vnode函数目的是创建虚拟DOM对应的对象结构

//vnode.js
//接受参数,sel对应元素标签,data对应元素标签的属性,text对应元素的文本,elm对应真实DOM节点,children对应的是子元素
export default function vnode(sel,data,children,text,elm){
    return {
        sel,
        data,
        children,
        text,
        elm
    }
}

三、patch函数/createElement函数/patchVnode函数

patch函数的目的是对比新旧的虚拟节点,从而把虚拟节点生成为DOM节点放到页面中

接受两个参数,第一个参数是旧的虚拟节点,第二个参数是新的虚拟节点

新老节点的替换规则:

  • 规则1:只能同层比较,不能跨层比较

  • 规则2:如果新老节点不是同一个节点,则暴力删除旧节点,插入创建新的节点

//patch.js
import vnode from "./vnode";
import createElement from "./createElement";

export default function (oldVnode, newVnode) {

    //如果oldVnode没有sel,证明不是虚拟节点(就让他变成虚拟节点)
    if (oldVnode.sel === undefined) {
        oldVnode = vnode(
            oldVnode.tagName.toLowerCase(),//sel
            {},//data
            [],//children
            undefined,//text
            oldVnode //elm
        )
    }
    //判断 旧的虚拟节点和 新的虚拟节点 是否是同一个节点
    if (oldVnode.sel === newVnode.sel) {
        //如果是同一个节点,判断条件会复杂,用patchVnode函数来处理,后面有提及
        patchVnode(oldVnode,newVnode)
    } else {
        //如果不是同一个节点就暴力删除旧节点,创建插入新的节点
        //把新的虚拟节点创建为真实DOM节点
        let newVnodeElemnt = createElement(newVnode)
        //获取旧的虚拟节点所对应的真实DOM
        let oldVnodeElement = oldVnode.elm
        //删除并替换旧的DOM
        if(newVnodeElemnt){
            oldVnodeElement.parentNode.insertBefore(newVnodeElemnt,oldVnodeElement)
        }
        oldVnodeElement.parentNode.removeChild(oldVnodeElement)
    }
}

这时候就要创建createElement函数,这个函数是根据虚拟节点创造真实DOM

//createElement.js
export default function createElement(vnode){

    //根据虚拟节点的标签sel创建DOM节点
    let domNode = document.createElement(vnode.sel)

    //判断有没有子节点 children是不是undefined
    if(vnode.children === undefined){
        domNode.innerText = vnode.text
    }else if(Array.isArray(vnode.children)){
        //说明内部有子节点,需要递归创建子节点
        for(let child of vnode.children){
            let childDom = createElement(child)
            domNode.appendChild(childDom)
        }
    }
    //补充elm属性
    vnode.elm = domNode
    return domNode
}
  • 规则3:如果是相同节点,又分为很多情况

    通过patchVnode函数来处理,第一个参数是旧的虚拟节点,第二个参数是新的虚拟节点

    • 情况1:新节点没有children,说明新节点是文本,不管旧的节点有没有children,直接替换真实DOM的文本内容

    • 情况2:新节点有children,旧的节点也有children,最复杂的情况,匹配策略有六种,在第四节详细讲

      • 旧前和新前
      • 旧后和新后
      • 旧前和新后
      • 旧后和新前
      • 以上都不满足,查找
      • 创建或者删除
    • 情况3:新节点有children,旧的节点没有children,把旧的内容删除清空,添加新的

//patchVnode.js
import createElement from "./createElement"

export default function patchVnode(oldVnode,newVnode){
    
    //判断新的虚拟节点有没有children
    if(newVnode.children === undefined){ //新的虚拟节点没有子节点
 
        //新的虚拟节点的文本和旧的虚拟节点的文本是否一样
        if(newVnode.text !== oldVnode.text ){//如果不一样,替换真实DOM的文本内容,对应情况1
            oldVnode.elm.innerText =  newVnode.text 
        }
    }else{ //新的虚拟节点有子节点

        //对应情况2:新的虚拟节点有子节点,旧的虚拟节点有子节点
        if(oldVnode.children !== undefined && oldVnode.children.length > 0){
            //比较复杂待会解释

        }else{ //对应情况3:新的虚拟节点有子节点,旧的虚拟节点没有子节点
            
            //清空旧节点的HTML内容
            oldVnode.elm.innerHTML = ''
            //遍历子节点创造DOM元素,添加到页面之中
            for(let child of newVnode.children){
                let childDom = createElement(child)
                oldVnode.elm.appendChild(childDom)
            }
        }
    }
}

四、新旧节点是同一个虚拟节点,且都有子节点(最复杂的情况,DIFF算法核心)

如果要提升性能,一定要添加key,key是虚拟DOM的唯一表示表示,在更改前后确定是否是同一个节点

6种策略顺序:

匹配策略:key相同,选择器相同说明匹配上了

  • 旧前和新前

匹配:说明是同一个节点,同时旧前的指针++,新前的指针++

  • 旧后和新后

匹配:旧后的指针--,新后的指针--

  • 旧前和新后

匹配:旧前的指针++,新后的指针--

  • 旧后和新前

匹配:旧后的指针--,新前的指针++

  • 以上都不满足,通过循环来查找

循环旧节点,查找到有相同节点的话,标记为undefined

  • 创建或者删除

首先先添加key,返回的vnode中要添加key

//vnode.js
export default function vnode(sel,data,children,text,elm){
    let key = data.key
    return {
        sel,
        data,
        children,
        text,
        elm,
        key
    }
}

然后创建updateChildren函数,接受三个参数,第一个参数是旧节点对应的真实DOM,第二个参数是旧节点的子节点,第三个参数是新节点的子节点

1.前五种匹配策略

//updateChildren.js
import patchVnode from './patchVnode'
import createElement from './createElement'

//判断是否是同一个节点
function isSameNode(vnode1, vnode2) {
    return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}

export default function updateChildren(parentElm, oldChildren, newChildren) {
    //声明旧前旧后、新前新后四种指针
    let oldStartIndex = 0,
        oldEndIndex = oldChildren.length - 1,
        newStartIndex = 0,
        newEndIndex = newChildren.length - 1
    //声明指针对应的节点
    let oldStartVnode = oldChildren[0],
        oldEndVnode = oldChildren[oldEndIndex],
        newStartVnode = newChildren[0],
        newEndVnode = newChildren[newEndIndex]

    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
        // 首先应该不是判断四种命中,而是略过已经加了undefined标记的项
        if (oldStartVnode === null || oldChildren[oldStartIndex] === undefined) {
            oldStartVnode = oldChildren[++oldStartIndex];
        } else if (oldEndVnode === null || oldChildren[oldEndIndex] === undefined) {
            oldEndVnode = oldChildren[--oldEndIndex];
        } else if (isSameNode(oldStartVnode, newStartVnode)) {
            //第一种情况:旧前和新前
            patchVnode(oldStartVnode, newStartVnode)
            //配置elm
            if (newStartVnode) { newStartVnode.elm = oldStartVnode?.elm }
            //指针++
            oldStartVnode = oldChildren[++oldStartIndex]
            newStartVnode = newChildren[++newStartIndex]

        } else if (isSameNode(oldEndVnode, newEndVnode)) {
            //第二种情况:旧后和新后
            patchVnode(oldEndVnode, newEndVnode)
            //配置elm
            if (newEndVnode) { newEndVnode.elm = oldEndVnode?.elm }
            //指针--
            oldEndVnode = oldChildren[--oldEndIndex]
            newEndVnode = newChildren[--newEndIndex]

        } else if (isSameNode(oldStartVnode, newEndVnode)) {
            //第三种情况:旧前和新后,需要移动位置
            patchVnode(oldStartVnode, newEndVnode)
            //配置elm
            if (newEndVnode) { newEndVnode.elm = oldStartVnode?.elm }
            //把旧前指向的节点移动到旧后指向的节点的后面
            parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
            //指针
            oldStartVnode = oldChildren[++oldStartIndex]
            newEndVnode = newChildren[--newEndIndex]

        } else if (isSameNode(oldEndVnode, newStartVnode)) {
            //第四种情况:旧后和新前,需要移动位置
            patchVnode(oldEndVnode, newStartVnode)
            //配置elm
            if (newStartVnode) { newStartVnode.elm = oldEndVnode?.elm }
            //把旧后指向的节点移动到旧前指向的节点的前面
            parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
            //指针
            oldEndVnode = oldChildren[--oldEndIndex]
            newStartVnode = newChildren[++newStartIndex]

        } else {
            //以上都不满足,查找
            //创建一个对象keymap,存放旧的虚拟节点,判断新旧有没有相同的节点
            //遍历旧节点,将key和index形成一一映射关系存放到对象keymap中
            const keyMap = {}
            for (let i = oldStartIndex; i <= oldEndIndex; i++) {
                const key = oldChildren[i]?.key
                if (key) keyMap[key] = i
            }
            //在旧节点中查找新前指针指向的节点,根据key来找
            let indexInOld = keyMap[newStartVnode.key]
            if (indexInOld) {
                //如果有,说明该数据在新旧虚拟节点都存在
                const elmMove = oldChildren[indexInOld]
                patchVnode(elmMove, newStartVnode)
                //处理过的节点,在旧的虚拟节点中设置为undefined,并且移动位置到旧前指针指向的节点的前面
                oldChildren[indexInOld] = undefined
                parentElm.insertBefore(elmMove.elm, oldStartVnode.elm)
            } else {
                //如果没有找到,说明是一个新节点,需要创建DOM元素,放在旧节点的最前面
                parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
            }
            //让新数据(指针)++
            newStartVnode = newChildren[++newStartIndex]

        }

    }
}

2.添加与删除

这里的逻辑还需要完善下,但是总体已经把DIFF算法核心总结了一波

//update 
//结束循环只有两种情况,新增和删除
 if (oldStartIndex > oldEndIndex) {
    // 说明newVndoe还有剩余节点没有处理,所以要添加这些节点
    / 插入的标杆
    const before = newChildren[newEndIndex + 1] ? newChildren[newEndIndex + 1].elm : null
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      // insertBefore方法可以自动识别null,如果是null就会自动排到队尾,和appendChild一致
      parentElm.insertBefore(createElement(newChildren[i]), before);
    }
  } else if (oldStartIndex <= oldEndIndex) {
    // 说明oldVnode还有剩余节点没有处理,所以要删除这些节点
    for (let i = oldStartIndex; i <= oldEndIndex; i++) {
      if (oldChildren[i]) {
        parentElm.removeChild(oldChildren[i].elm);
      }
    }
  }

五、简单测试一波

//index.js
import h from './dom/h'
import patch from './dom/patch'


let vnode1 = h('ul',{},[
    h('li',{key:'a'},'a'),
    h('li',{key:'b'},'b'),
    h('li',{key:'c'},'c')
])
let vnode2 = h('ul',{},[
    h('li',{key:'cd'},'cd'),
    h('li',{key:'a'},'a'),
])

//在页面中创建一个空的div,id为container,以及button按钮
let container = document.getElementById('container')
let button = document.querySelector('button')
patch(container,vnode1)


button.addEventListener('click',()=>{
    patch(vnode1,vnode2)
})

运行yarn dev

image-20211109031943572.png

点击Button

image-20211109032012099.png