Vue 【进阶】- diff 算法(2), 【包含完整 patchNode】

1. 前言

上一讲https://www.cnblogs.com/caijinghong/p/16879388.htmldiff 算法讲了:

  1. 虚拟dom
  2. 文件位置
  3. seter 触发后的过程
  4. 实现 render createElment 生成虚拟dom, 和转换成真实 dom
  5. 实现了简单的 diff ,实现了 文本、标签、属性的更换。
  6. 节点的比较还未实现, 也就是源码中的 patchNode 的方法,本次将复习 diff ,并实现该方法

2. 本次学习流程

知识储备前提
element.appendChild() 为元素添加一个新的子元素
element.parentNode 返回元素的父节点
element.childNodes 返回元素的一个子节点的数组
element.nextSibling 返回该元素紧跟的一个节点
element.removeChild() 删除一个子元素
element.insertBefore() 现有的子元素之前插入一个新的子元素
element.textContent 设置或返回一个节点和它的文本内容
element.innerText 会覆盖之前的所有元素,如果只想改文本,可以使用 textContent

3. diff 简介

  1. diff 算法可以将 两个虚拟dom 进行比较,他是一个精细化对比,实现dom的最小量更新
  2. key是节点的唯一标识符,告诉diff算法,在更改前后他们是同一个DOM节点
// 下面代码如果没有key则会一一比对替换
const vnode1 = h('ul',{}, [h('li',{},'A'), h('li',{},'A')])
const vnode2 = h('ul',{}, [h('li',{},'C'),h('li',{},'A'), h('li',{},'A')])
// 如果有key不会全部替换,只会追加
const vnode1 = h('ul',{}, [h('li',{key:'A'},'A'), h('li',{key:'B'},'B')])
const vnode2 = h('ul',{}, [h('li',{key:'C'},'C'),h('li',{key:'A'},'A'), h('li',{key:'B'},'B')])
  1. 只有是同一个虚拟节点(选择器相同且key相同),才会进行精细化比较,否则就进行暴力删除旧的、插入新的。

  2. 只进行通层级比较深度优先,不会进行跨层级比较。即使同一片虚拟节点,但是跨层级了,仍是暴力删除旧的、然后插入新的。

  3. Key的重要性:如果没有key 则是新建节点,旧的节点删除, key还可以生成一个映射对象,起到缓存作用,无需多次循环

4. 虚拟 dom

虚拟 dom 的 js 形式

const vDom = createElement(
  'ul', 
  {class:'list',style:'color:#efef;width:500px;height:300px;background-color:brown;'}, 
  [
    createElement('li', { class:'item', 'data-index': 0 }, [
      createElement('p', { class: 'text' }, ['第一个列表项'])
    ]),
    createElement('li', { class:'item', 'data-index': 1 }, [
      createElement('p', { class: 'text' }, [
        createElement('span', { class: 'title' }, ['第2个列表项'])
      ])
    ]),
    createElement('li', { class:'item', 'data-index': 2 }, [
      createElement('p', { class: 'text' }, ['第3个列表项'])
    ])
  ]
)

虚拟dom

真实dom

5. 手写h(createElement)函数

vnode.js

/**
 * 产生虚拟节点
 * 将传入的参数组合成对象返回
 * @param {string} sel 选择器
 * @param {object} data 属性、样式
 * @param {Array} children 子元素
 * @param {string|number} text 文本内容
 * @param {object} elm 对应的真正的dom节点(对象),undefined表示节点还没有上dom树
 * @returns 
 */
export default function(sel, data, children, text, elm) {
  const key = data.key;
  return { sel, data, children, text, elm, key };
}

h.js

import vnode from "./vnode";
/**
 * 产生虚拟DOM树,返回的一个对象
 * 低配版本的h函数,这个函数必须接受三个参数,缺一不可
 * @param {*} sel
 * @param {*} data
 * @param {*} c
 * 调用只有三种形态 文字、数组、h函数
 * ① h('div', {}, '文字')
 * ② h('div', {}, [])
 * ③ h('div', {}, h())
 */
export default function (sel, data, c) {
  // 检查参数个数
  if (arguments.length !== 3) {
    throw new Error("请传且只传入三个参数!");
  }
  // 检查第三个参数 c 的类型
  if (typeof c === "string" || typeof c === "number") {
    // 说明现在是 ① 文字
    return vnode(sel, data, undefined, c, undefined);
  } else if (Array.isArray(c)) {
    // 说明是 ② 数组
    let children = [];
    // 遍历 c 数组
    for (let item of c) {
      if (!(typeof item === "object" && item.hasOwnProperty("sel"))) {
        throw new Error("传入的数组有不是h函数的项");
      }
      // 不用执行item, 只要收集数组中的每一个对象
      children.push(item);
    }
    return vnode(sel, data, children, undefined, undefined);
  } 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("传入的参数类型不对!");
  }
}

效果图

6. 手写 patch 函数(里面包含diff)

手写之前了解几个方法

  1. h、vnode 函数可以生成虚拟dom
  2. patch 函数用来比较同层级虚拟dom不相等相等的情况(层级比较)
  3. pathVnode 比较同层级虚拟dom相等的情况 (层级比较)
  4. updateChildren 方法是当新旧虚拟dom都有子节点时,对比子节点的,子节点中调用path递归调用对比子节点层级,一直到底(深度优先)

patch流程

6.1处理本层级节点(key,sel)不同,进行暴力添加删除操作

// 这里说的节点可以理解为标签
import vnode from "./vnode";

export default function(oleVnode, newVnode) {

  if(!isVnode(oleVnode)) { // 旧节点是否是虚拟节点(初始化的时候是真实节点)
    oleVnode = vnode(oleVnode.tagName.toLowerCase(), {}, [], undefined, oleVnode)
  }

  if(isSame(oleVnode, newVnode)) { // 相同节点
    console.log('相同节点')
  } else { // 不是相同节点,暴力拆解
    const newEle = toRealyDom(newVnode),
          oleEle = oleVnode.elm
    
    oleEle.parentNode.insertBefore(newEle,oleEle)
    oleEle.parentNode.removeChild(oleEle)
  }
  
}

function isVnode(el){
  return el.sel && el.sel != ''
}

function isSame(oleVnode, newVnode) {
  return oleVnode.sel === newVnode.sel && oleVnode.key === newVnode.key
}

function toRealyDom(vnode) {
  const dom = document.createElement(vnode.sel)
  // 还有一种文本子节点共存的这里不写
  if(vnode.children && vnode.children.length > 0) { // 有子节点
    vnode.children.forEach(child => {
      const rVnode = toRealyDom(child) // 递归获取到子真实节点
      dom.appendChild(rVnode)
    })
  } else { // 无子节点
    vnode.text && (dom.textContent = vnode.text)
  }

  vnode.elm = dom

  return dom
}

6.2 处理本层级相同情况和不同情况新旧虚拟节点(后面相同情况patchVnode分出去) ---- key,sel相等,并且没有子节点childrens


index.js

const myVnode1 = h("section", {}, 'hello word!');

const myVnode2 = h("section", {}, [
  h("li", {}, 'xxx'),
  h("li", {}, 2),
  h("li", {}, 3),
]);

patch.js

import vnode from "./vnode";

export default function(oleVnode, newVnode) {

  if(!isVnode(oleVnode)) { // 旧节点是否是虚拟节点(初始化的时候是真实节点)
    oleVnode = vnode(oleVnode.tagName.toLowerCase(), {}, [], undefined, oleVnode)
  }

  if(isSame(oleVnode, newVnode)) { // 两虚拟节点相同
    // 如果两个新旧虚拟节点完全相同(说明不是新new的,指针也都相同)
    if(oleVnode === newVnode) return 
    // **本次源码只写text和child只会出现能出现一种的情况,没有考虑同时出现的情况**
    // 新Vnode有文本
    if(newVnode.text != '' && newVnode.text && (newVnode.children == undefined || newVnode.children == [])) { 
      // 新旧的文本节点,不做处理
      if(newVnode.text === oleVnode.text) return
      // innerText 除了改变文本,也会把所有的子节点清空
      oleVnode.elm.innerText = newVnode.text
    } else { // 没有文本则是有子节点 (本次只考虑着两种情况)
      // 判断旧虚拟dom是否有子节点
      if(Array.isArray(oleVnode.children) && oleVnode.children.length > 0) {
        // **这里进行diff,除了这里递归 patch(这里使得每次都比较同层级),因为其他条件都是同层级的**
        console.log('相同节点,并且都有子节点,则要进一步进行对比')
      } else {
        // 旧virtual dom 没有子节点,则清空文本和追加元素,因为追加元素不能覆盖文本所以要清除文本
        oleVnode.elm.innerText = ''
        newVnode.children.forEach(item => {
          oleVnode.elm.appendChild(toRealyDom(item))
        })
      }
    }

  } else { // 不是相同节点,暴力拆解
    const newEle = toRealyDom(newVnode),
          oleEle = oleVnode.elm
    
    oleEle.parentNode.insertBefore(newEle,oleEle)
    oleEle.parentNode.removeChild(oleEle)
  }
}

function isVnode(el){
  return el.sel && el.sel != ''
}

function isSame(oleVnode, newVnode) {
  return oleVnode.sel === newVnode.sel && oleVnode.key === newVnode.key
}

function toRealyDom(vnode) {
  const dom = document.createElement(vnode.sel)
  // 还有一种文本子节点共存的这里不写
  if(vnode.children && vnode.children.length > 0) { // 有子节点
    vnode.children.forEach(child => {
      const rVnode = toRealyDom(child) // 递归获取到子真实节点
      dom.appendChild(rVnode)
    })
  } else { // 无子节点
    vnode.text && (dom.textContent = vnode.text)
  }

  vnode.elm = dom

  return dom
}

7. 处理相同节点下面都有子节点的情况(真正的diff地方,patchVnode关键方法)

建议回去看第三点diff算法的简介

7.1diff算法的规则

  1. 首尾指针法 新旧虚拟(子)节点前后两两比较,匹配成功(相同)则指针往中间靠
  2. newVnode为渲染标准,对比新的虚拟dom然后操作旧的
  3. 如果首尾指针法,匹配不出则循环比较虚拟DOM key(源码优化为映射缓存),如果成功匹配,则移动该虚拟DOM到旧的前面(新的指针往中间靠)
  4. 循环不出结果则当前newVnode转换成真实dom,追加到旧的oldstartindex指针前面
  5. 当首尾指针法循环完后,剩下判断新旧虚拟节点指针位置,以确定有无剩余节点未处理,newVnode剩-添加,oldVnode剩-删除

image

7.2对比流程图

第一轮首尾指针法
image

命中成功,以newVnode为标准移动位置
image

两两指针中间靠拢
image

第二轮首尾指针
image

两两指针中间靠拢
image

第三轮首尾指针法
image

两两指针中间靠拢(头加一,尾减一),如果newstart>newend或者oldstart>oldend,则首尾指针法结束
image

首尾指针法结束结束后的两种情况,newVnode多余则添加到旧的oldstart指针前面,oldVnode多余则删除

7.3 上代码

下面开始上代码 vnode.js,patch.js,patchVnode.js,updateChildren.js 均有改变,请注意观察
vnode.js 为所有虚拟节点加入 ele 项

import {toRealyDom} from './patch'

/**
 * 产生虚拟节点
 * 将传入的参数组合成对象返回
 * @param {string} sel 选择器
 * @param {object} data 属性、样式
 * @param {Array} children 子元素
 * @param {string|number} text 文本内容
 * @param {object} elm 对应的真正的dom节点(对象),undefined表示节点还没有上dom树
 * @returns 
 */
export default function(sel, data, children, text, elm) {
  const key = data.key

  if(!elm) elm = toRealyDom({ sel, data, children, text, elm: undefined, key })

  return { sel, data, children, text, elm, key };
}

patch.js 分出了 patchVnode 方法

import vnode from "./vnode";
import patchVnode from "./patchVnode";

export default function(oleVnode, newVnode) {
  if(!isVnode(oleVnode)) { // 旧节点是否是虚拟节点(初始化的时候是真实节点)
    oleVnode = vnode(oleVnode.tagName.toLowerCase(), {}, [], undefined, oleVnode)
  }

  if(isSame(oleVnode, newVnode)) { // 两虚拟节点相同
    patchVnode(oleVnode, newVnode)
  } else { // 不是相同节点,暴力拆解
    const newEle = toRealyDom(newVnode),
    oleEle = oleVnode.elm
    
    oleEle.parentNode.insertBefore(newEle,oleEle)
    oleEle.parentNode.removeChild(oleEle)
  }
}

function isVnode(el){
  return el.sel && el.sel != ''
}

export function isSame(oleVnode, newVnode) {
  return oleVnode.sel === newVnode.sel && oleVnode.key === newVnode.key
}

export function toRealyDom(vnode) {
  const dom = document.createElement(vnode.sel)
  // 还有一种文本子节点共存的这里不写
  if(vnode.children && vnode.children.length > 0) { // 有子节点
    vnode.children.forEach(child => {
      const rVnode = toRealyDom(child) // 递归获取到子真实节点
      dom.appendChild(rVnode)
    })
  } else { // 无子节点
    vnode.text && (dom.textContent = vnode.text)
  }

  vnode.elm = dom

  return dom
}

patchVnode.js

import updateChildren from './updateChildren'

export default function patchVnode(oleVnode, newVnode) {
  // 如果两个新旧虚拟节点完全相同(说明不是新new的,指针也都相同)
  if (oleVnode === newVnode) return
  // **本次源码只写text和child只会出现能出现一种的情况,没有考虑同时出现的情况**
  // 新Vnode有文本
  if (newVnode.text != '' && newVnode.text && (newVnode.children == undefined || newVnode.children == [])) {
    // 新旧的文本节点,不做处理
    if (newVnode.text === oleVnode.text) return
    // innerText 除了改变文本,也会把所有的子节点清空
    oleVnode.elm.innerText = newVnode.text
  } else { // 没有文本则是有子节点 (本次只考虑着两种情况)
    // 判断旧虚拟dom是否有子节点
    if (Array.isArray(oleVnode.children) && oleVnode.children.length > 0) {
      // **这里进行diff,除了这里递归 patch(这里使得每次都比较同层级),因为其他条件都是同层级的**
      console.log('相同节点,并且都有子节点,则要进一步进行对比')
      updateChildren(oleVnode.elm, oleVnode.children, newVnode.children)
    } else {
      // 旧virtual dom 没有子节点,则清空文本和追加元素,因为追加元素不能覆盖文本所以要清除文本
      oleVnode.elm.innerText = ''
      newVnode.children.forEach(item => {
        oleVnode.elm.appendChild(toRealyDom(item))
      })
    }
  }
}

7.4 updateChildren.js

  1. 首尾指针法 新旧虚拟(子)节点前后两两比较,匹配成功(相同)则指针往中间靠
  2. newVnode为渲染标准,对比新的虚拟dom然后操作旧的
  3. 如果首尾指针法,匹配不出则循环比较虚拟DOM key(源码优化为映射缓存),如果成功匹配,则移动该虚拟DOM到旧的前面(新的指针往中间靠)
  4. 循环不出结果则当前newVnode转换成真实dom,追加到旧的oldstartindex指针前面
  5. 当首尾指针法循环完后,剩下判断新旧虚拟节点指针位置,以确定有无剩余节点未处理,newVnode剩-添加,oldVnode剩-删除
import { isSame } from './patch'
import patch from './patch'

/**
 * @param {object} parentElm Dom节点(父节点)
 * @param {Array} oldCh oldVnode的子节点数组
 * @param {Array} newCh newVnode的子节点数组
 */
export default function(parentElm, oldCh, newCh) {
  // 暴力双循环太复杂了, 所以有了 diff 算法

  let oldStartIdx = 0,
      newStartIdx = 0,
      oldEndIdx = oldCh.length - 1,
      newEndIdx = newCh.length - 1,
      oldStartVnode = oldCh[oldStartIdx],
      newStartVnode = newCh[newStartIdx],
      oldEndVnode = oldCh[oldEndIdx],
      newEndVnode = newCh[newEndIdx],
      oldKeyMap = {}
      
  while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 首索引要小于尾索引
    // 如果指针法没有命中,则需去缓存地图匹配,如果匹配成功真实节点则会添加到oldStartIdx之前,虚拟节点变为null(占位,为了不影响虚拟Dom的顺序发生位移)
    // 所以有了下面四个判断
    if(!oldStartVnode) {oldStartVnode = oldCh[++oldStartIdx];continue}
    if(!newStartVnode) {newStartVnode = newCh[++newStartIdx];continue}
    if(!oldEndVnode) {oldEndVnode = oldCh[--oldEndIdx];continue}
    if(!newEndVnode) {newEndVnode = newCh[--newEndIdx];continue}

    // oldStart endStart
    if(isSame(oldStartVnode, newStartVnode)) {
      // 递归,对比,如果没有子节点就上树,如果新旧都有子节点则进入 patchVnode
      console.log('首首相同')
      patch(oldStartVnode, newStartVnode) 

      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if(isSame(oldEndVnode, newEndVnode)) {
      console.log('尾尾相同')
      // oldEnd, newEnd
      patch(oldEndVnode, newEndVnode) 

      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if(isSame(oldStartVnode, newEndVnode)) {
      console.log('首尾相同')
      // newEndIdx, oldStartIdx
      patch(oldStartVnode, newEndVnode)
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)

      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if(isSame(oldEndVnode, newStartVnode)) {
      console.log('尾首相同')
      // oldEndVnode, newStartVnode
      patch(oldEndVnode, newStartVnode)
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)

      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else { 
      // 都没有命中的情况
      if(Object.keys(oldKeyMap).length < 1) {
        // 处理得一个oldVnode的以key为索引的对象地图(也就是做了一个缓存)
        for(var i=oldStartIdx;i<=oldEndIdx;i++) {// 剩余未命中的旧虚拟dom
          let key = null
          // ************** 这里体现出key的关键性 ****************
          oldCh[i] && oldCh[i].data && oldCh[i].data.key && (key = oldCh[i].data.key)
          oldKeyMap[key] = i 
        }
      }

      // 根据新 virtual dom 去旧 virtual dom 找
      // 去缓存地图匹配,这样不用不停的循环查找
      let oldIdx = oldKeyMap[newStartVnode.data.key]

      if(oldIdx) { // 缓存地图匹配成功
        patch(oldCh[oldIdx], newStartVnode)
        parentElm.insertBefore(oldCh[oldIdx].elm, oldStartVnode.elm)
        oldCh[oldIdx] = null
      } else { // 匹配失败
        // ************** 这里体现出key的关键性 ****************
        //如果没有key 则是新建节点,旧的节点删除
        parentElm.insertBefore(newStartVnode.elm, oldStartVnode.elm)
      }

      newStartVnode = newCh[++newStartIdx]
    }
  }

  if(oldStartIdx <= oldEndIdx) { // 旧的有剩余 - 移除
    let lessCh = oldCh.slice(oldStartIdx, oldEndIdx + 1)

    lessCh.map(child => {
      parentElm.removeChild(child.elm)
    })
  }

  if(newStartIdx <= newEndIdx) { // 新的有剩余 - 添加
    let lessCh = newCh.slice(newStartIdx, newEndIdx + 1)

    lessCh.map(child => {
      parentElm.appendChild(child.elm)
    })
  }
}

8. 总结

  1. 高效性:有虚拟dom,必然需要diff算法。通过对比新旧虚拟dom,将有变化的地方更新在真实dom上,另外,通过diff高效的执行比对过程,把dom的操作最小化(只操作有变化的dom)。
  2. 必要性:vue2中为了降低watcher粒度,每个组件只有一个watcher。通过diff精确找到发生变化的节点,并复用相同的节点。

到此 diff 算法算是大功告成啦!!!完美谢幕!!!致敬努力的自己!!!
如有问题请大家留言讨论。
仓库地址(注意是sty_snabbdom分支):https://gitee.com/CjingHong/mini-ui/tree/sty_snabbdom/

posted on 2022-11-16 19:52  京鸿一瞥  阅读(95)  评论(0编辑  收藏  举报