Vue2 Diff算法原理解析

触发更新

diff 复制代码
-   响应式数据变更时,setter 触发 Dep.notify(),通知所有关联的 Watcher。
-   Watcher 执行组件的渲染函数(render),生成新虚拟 DOM 树(vnode)。

虚拟节点(vnode)的核心属性

diff 复制代码
-   sel:标签选择器(如 div#app,含标签名、id、class)。
-   data:包含节点属性(如 class、style,其中 key 是核心)。
-   children:子节点数组(与 text 互斥)。
-   text:文本内容(若存在,则无 children)。
-   elm:对应的真实 DOM 元素。

patch 方法:判断节点复用性

markdown 复制代码
-   是否为同一节点:通过 sel 和 key 判断(key 优先级更高)。

    -   不同:暴力更新,创建并插入新节点
    -   相同:进入 `patchVnode` 精细化对比。
   

patchVnode:精细化对比 (这里我们要知道我们要尽量复用节点,也就是说所有的更新操作在旧节点上)

markdown 复制代码
-   文本节点:

    -   新节点有 text → 直接更新 旧节点 oldvnode.elm.innerText = text。

-   子节点处理:

    -   旧无子,新有子:批量插入新子节点到父容器(oldVnode.elm)。
    -   旧有子,新无子:清空旧节点的所有子节点。
    -   新旧均有子 → 调用 updateChildren 进行双端对比(核心逻辑)。

updateChildren:双端对比核心

整个流程图

h函数 是用来创建一个虚拟节点 这里我们做一个简易的h函数,实际上h函数参数不止这么多 这里我们只显示以下几种方式的调用

h('div', { }, 'hello')

h('div', { }, [ ])

h('div', { }, h( ))

js 复制代码
h(sel,data,c){
   if(arguments.length != 3){   
    throw new Error('参数必须为3个')
   }
   if(typeof c == 'string' || typeof c == 'number'){
    return vnode(sel,data,undefined,c,undefined)
   }
   else if(Array.isArray(c)){
    let children = []
    for(let i = 0; i < c.length; i++){
        if(!(typeof c[i] == 'object' && c[i].hasOwnProperty('sel')))
            throw new Error('传入的数组参数中有项不是h函数')
        //这里不需要执行c[i],因为c[i]已经是一个虚拟节点 
        children.push(c[i])
    }
    return vnode(sel,data,children,undefined,undefined)
   }
   else if(typeof c == 'object' && c.hasOwnProperty('sel')){
    let children = [c]
    return vnode(sel,data,children,undefined,undefined)
   }
   else{
    throw new Error('传入的参数类型错误')
   }
}   

 //vnode函数很简单用来返回虚拟节点
export default function vnode(sel,data,children,text,elm){
    const key = data === undefined ? undefined : data.key
    return {sel,data,children,text,elm,key}
} 

创建一个 <div> 元素

js 复制代码
h('div', { class: 'container' }, 'Hello World')

我们需要几个工具函数

js 复制代码
import vnode from './vnode' 
//判断是否为虚拟节点
export function  isVnode(vnode){
    return vnode.sel !== undefined
//将dom节点转换为虚拟节点
export function emptyNodeAt (elm) {
// tag其实就是就是标签元素比如div , span
    return vnode(elm.tagName.toLowerCase() , {}, [], undefined, elm)
  }
//判断是否为相同节点
export function isSameVnode(vnode1,vnode2){
    return vnode1.sel == vnode2.sel && vnode1.key == vnode2.key
}

//根据vnode创建对应Dom元素
export default function createElement(vnode){
     let domNode = document.createElement(vnode.sel)
     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 i = 0; i < vnode.children.length; i++){
            let ch = vnode.children[i]
             let chDom = createElement(ch)
             domNode.appendChild(chDom)
        }
     }
     vnode.elm = domNode
     return vnode.elm
}

准备工作完成

js 复制代码
import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'
//创建虚拟节点
var myNode1 = h('h2',{},[
    h('p',{key:"a"},"a"),
    h('p',{key:"b"},"b"),
    h('p',{key:"c"},"c"),
])
var vnode2 = h('h2',{},[
    h('p',{key:"a"},"a"),
    h('p',{key:"b"},"b"),
    h('p',{key:"f"},"f"),
    h('p',{key:"c"},"c"),
    h('p',{key:"d"},"d"),
 
])
//patch函数,让虚拟节点上树
 const container = document.getElementById('container')
 patch(container,myNode1)
 const btn = document.getElementById('btn')
 btn.onclick = function(){
    patch(myNode1,vnode2)
 }
html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="container"></div>
  <button id="btn">点击更改Dom</button>
  <script src="/dist/bundle.js"></script>
</body>
</html>

patch函数

js 复制代码
export default function patch(oldVnode, newVnode){
    //判断oldVnode是否 为虚拟节点

     if(!isVnode(oldVnode) ){
         oldVnode = emptyNodeAt(oldVnode)
     }      
     if(isSameVnode(oldVnode,newVnode)){
        console.log('是同一个节点')
        patchVnode(oldVnode,newVnode)
    }else{
        console.log('不是同一个节点,暴力插入新的,删除旧的')
        let newVnodeElm = createElement(newVnode)
        if(oldVnode.elm.parentNode && newVnodeElm){
            oldVnode.elm.parentNode.insertBefore( newVnodeElm, oldVnode.elm)
        }

    }
    
}

patchVnode函数

按照我的理解这个函数其实就是给旧节点打补丁,保持与新节点一致

注意 :回想我开头的提到 childrentext 相斥

js 复制代码
export default function patchVnode(oldVnode,newVnode){
        //判断新旧节点是否为同一个对象
        if(oldVnode === newVnode){
            return
        }
        // 判断新节点是否有text属性
        if(newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)){
            console.log('新节点有text属性')
            if(newVnode.text !== oldVnode.text){
                oldVnode.elm.innerText = newVnode.text
            }
        }else {
            console.log('新节点没有text属性') // 没有text意味着有chidlren
            //判断旧节点是否有children属性
            if(oldVnode.children !== undefined && oldVnode.children.length > 0){
                console.log('旧节点有children属性')
                // 注意 这里是diff的核心。新旧节点都存在children
               updateChildren(oldVnode.elm,oldVnode.children,newVnode.children)
            }else{
                console.log('旧节点没有children属性')
                oldVnode.elm.innerHTML = ''
                 for(let i = 0; i < newVnode.children.length; i++){
                    let ch = newVnode.children[i]
                    let chDom = createElement(ch)
                    oldVnode.elm.appendChild(chDom)
                 }

            }
        }
        //patch方法就是让 newVNode 及其所有的子元素,都能够正确的挂载上DOM元素。 如果 oldVNode 的有现成的DOM,就扔给 newVNode
    newVnode.elm = oldVnode.elm
}   

updateChildren方法

diff算法的核心采用双端对比策略 因此我们需要 4个指针 分别指向旧节点头部,尾部 和新节点头部,尾部

新前 vs 旧前(头头相同):直接复用节点,指针后移。

新后 vs 旧后(尾尾相同):直接复用节点,指针前移。

新后 vs 旧前(尾头相同):将旧前节点移动到旧后之后,旧前指针后移,新后指针前移。

新前 vs 旧后(头尾相同):将旧后节点移动到旧前之前,旧后指针前移,新前指针后移。

按上述优先级依次判断,若均未命中,则通过key查找可复用节点。

如何移动节点?

DOM中,如果我们插入一个已经在DOM树中已经存在的元素,他就会被移动

js 复制代码
    let oldStartIdx = 0   //旧前指针
    let newStartIdx = 0  // 新前指针
    let oldEndIdx = oldCh.length - 1    //旧后指针
    let newEndIdx = newCh.length - 1    //新后指针
    let oldStartVnode = oldCh[oldStartIdx]  //旧前节点
    let newStartVnode = newCh[newStartIdx]  //新前节点
    let oldEndVnode = oldCh[oldEndIdx]    //旧后节点
    let newEndVnode = newCh[newEndIdx]    //新后节点

我们需要在一个循环里依次进行匹配

头节点超过尾节点就代表循环结束

js 复制代码
  while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
     if(oldStartVnode == undefined){
            oldStartVnode = oldCh[++oldStartIdx]
         } 
         else if(oldEndVnode == undefined){
            oldEndVnode = oldCh[--oldEndIdx]
         } 
         else if(newStartVnode == undefined){
            newStartVnode = newCh[++newStartIdx]
         } 
         else if(newEndVnode == undefined){
            newEndVnode = newCh[--newEndIdx]
         }
         else if(isSameVnode(oldStartVnode,newStartVnode)){
            console.log('第一种情况---新前后前对比');
            patchVnode(oldStartVnode,newStartVnode)
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
        }
        else if(isSameVnode(oldEndVnode,newEndVnode)) {
            console.log('第二种情况---新后旧后对比');
            patchVnode(oldEndVnode,newEndVnode)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
        }
        else if(isSameVnode(oldStartVnode,newEndVnode)) {
            console.log('第三种情况---新后旧前对比');
            patchVnode(oldStartVnode,newEndVnode)
            // 移动节点  ,把新前节点移动到旧后节点后面
            //移动节点 只要你插入一个已经在Dom树上的节点,他就会被移动
            parentElm.insertBefore(oldStartVnode.elm,oldEndVnode.elm.nextSibling)
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]

        }
        else if(isSameVnode(oldEndVnode,newStartVnode)) {
            console.log('第四种情况---新前旧后对比');
            patchVnode(oldEndVnode,newStartVnode)
            // 新前节点移动到旧前节点前面
            parentElm.insertBefore(oldEndVnode.elm,oldStartVnode.elm)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        }
        else {
            console.log('四种情况都不匹配');
            
-   建立旧子节点的 `key` 到索引的映射表(`keyMap`)。
-   用新子节点的 `key` 查找可复用节点:
    -   找到 → 移动节点到正确位置。
    -   未找到 → 创建新节点插入。
    
           if(!keyMap){
            keyMap = {}
              for(let i = oldStartIdx; i <= oldEndIdx; i++){
                let key = oldCh[i].key
                if(key){
                    keyMap[key] = i
                }
              }
           }
           console.log("🚀 ~ updateChildren ~ keyMap:", keyMap)
           const idxInOld = keyMap[newStartVnode.key]
       
           if(idxInOld == undefined){    
            // 如果idxInOld不存在,则说明新节点在旧节点中不存在
            parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm)
           } else {
                /// 如果idxInOld存在,则说明新节点在旧节点中存在,则需要移动节点
            const elmToMove = oldCh[idxInOld]
            patchVnode(elmToMove,newStartVnode)
            oldCh[idxInOld] = undefined
            parentElm.insertBefore(elmToMove.elm,oldStartVnode.elm)
           }
            newStartVnode = newCh[++newStartIdx]
        }
    }   

循环结束如果

  • 旧子节点剩余 → 批量删除。 新节点有删除的情况,旧节点没有处理完,说明旧前跟旧后指针之间的节点都是多余的删除

  • 新子节点剩余 → 批量插入。 就是新节点有新增的情况,新节点没有处理完,说明新前很新后之间的节点都是新增的需要插入

完整代码

js 复制代码
export default function updateChildren(parentElm, oldCh, newCh) {
    let oldStartIdx = 0
    let newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let newEndIdx = newCh.length - 1
    let oldStartVnode = oldCh[oldStartIdx]
    let newStartVnode = newCh[newStartIdx]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndVnode = newCh[newEndIdx]

    let keyMap = null
    // 开始遍历 
    while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
     if(oldStartVnode == undefined){
            oldStartVnode = oldCh[++oldStartIdx]
         } 
         else if(oldEndVnode == undefined){
            oldEndVnode = oldCh[--oldEndIdx]
         } 
         else if(newStartVnode == undefined){
            newStartVnode = newCh[++newStartIdx]
         } 
         else if(newEndVnode == undefined){
            newEndVnode = newCh[--newEndIdx]
         }
         else if(isSameVnode(oldStartVnode,newStartVnode)){
            console.log('第一种情况---新前后前对比');
            patchVnode(oldStartVnode,newStartVnode)
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
        }
        else if(isSameVnode(oldEndVnode,newEndVnode)) {
            console.log('第二种情况---新后旧后对比');
            patchVnode(oldEndVnode,newEndVnode)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
        }
        else if(isSameVnode(oldStartVnode,newEndVnode)) {
            console.log('第三种情况---新后旧前对比');
            patchVnode(oldStartVnode,newEndVnode)
            // 移动节点  ,把新前节点移动到旧后节点后面
            //移动节点 只要你插入一个已经在Dom树上的节点,他就会被移动
            parentElm.insertBefore(oldStartVnode.elm,oldEndVnode.elm.nextSibling)
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]

        }
        else if(isSameVnode(oldEndVnode,newStartVnode)) {
            console.log('第四种情况---新前旧后对比');
            patchVnode(oldEndVnode,newStartVnode)
            // 新前节点移动到旧前节点前面
            parentElm.insertBefore(oldEndVnode.elm,oldStartVnode.elm)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        }
        else {
            console.log('四种情况都不匹配');
           if(!keyMap){
            keyMap = {}
              for(let i = oldStartIdx; i <= oldEndIdx; i++){
                let key = oldCh[i].key
                if(key){
                    keyMap[key] = i
                }
              }
           }
           console.log("🚀 ~ updateChildren ~ keyMap:", keyMap)
           const idxInOld = keyMap[newStartVnode.key]
       
           if(idxInOld == undefined){    
            // 如果idxInOld不存在,则说明新节点在旧节点中不存在
            parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm)
           } else {
                /// 如果idxInOld存在,则说明新节点在旧节点中存在,则需要移动节点
            const elmToMove = oldCh[idxInOld]
            patchVnode(elmToMove,newStartVnode)
            oldCh[idxInOld] = undefined
            parentElm.insertBefore(elmToMove.elm,oldStartVnode.elm)
           }
            newStartVnode = newCh[++newStartIdx]
        }
    }   
    
    if(newStartIdx <= newEndIdx){
                console.log('就是新节点有新增的情况,新节点没有处理完');
                 const before = newCh[ newEndIdx +1] == null? null : newCh[newEndIdx+1].elm
                 console.log("🚀 ~ updateChildren ~ before:", before)
                 for(let i = newStartIdx; i <= newEndIdx; i++){
                    //insertBefore 如果是null 则插入到父元素的最后一个子元素后面 与appendChild一样
                    parentElm.insertBefore( createElement(newCh[i]), before)
                 }
            }
     else if(oldStartIdx <= oldEndIdx){
                console.log('新节点有删除的情况,旧节点没有处理完');
                for(let i = oldStartIdx; i <= oldEndIdx; i++){
                    parentElm.removeChild(oldCh[i].elm)
                }
            }
}
相关推荐
百万蹄蹄向前冲21 分钟前
组建百万前端梦之队-计算机大学生竞赛发展蓝图
前端·vue.js·掘金社区
云隙阳光i34 分钟前
实现手机手势签字功能
前端·javascript·vue.js
imkaifan1 小时前
vue2升级Vue3--native、对inheritAttrs作用做以解释、声明的prop属性和未声明prop的属性
前端·vue.js·native修饰符·inheritattrs作用·声明的prop属性·未声明prop的属性
小程序设计1 小时前
【2025】基于springboot+vue的宠物领养管理系统(源码、万字文档、图文修改、调试答疑)
vue.js·spring boot·宠物
小程序设计1 小时前
【2025】基于springboot+vue的体育场馆预约管理系统(源码、万字文档、图文修改、调试答疑)
vue.js·spring boot·后端
柠檬树^-^2 小时前
app.config.globalProperties
前端·javascript·vue.js
1024小神2 小时前
vue/react/vite前端项目打包的时候加上时间最简单版本,防止后端扯皮
前端·vue.js·react.js
轻口味3 小时前
Vue.js 与 RESTful API 集成之使用 Axios 请求数据
前端·vue.js·restful
程序员SKY4 小时前
Vue 系列之:ref、reactive、toRef、toRefs
vue.js
奶糖 肥晨4 小时前
基于 Vue 和 Element Plus 的时间范围控制与数据展示
前端·vue.js·elementui