Vue 【进阶】- diff 算法(2), 【包含完整 patchNode】
1. 前言
上一讲https://www.cnblogs.com/caijinghong/p/16879388.htmldiff 算法讲了:
- 虚拟dom
- 文件位置
- seter 触发后的过程
- 实现 render createElment 生成虚拟dom, 和转换成真实 dom
- 实现了简单的 diff ,实现了 文本、标签、属性的更换。
- 节点的比较还未实现, 也就是源码中的 patchNode 的方法,本次将复习 diff ,并实现该方法
2. 本次学习流程
知识储备前提
element.appendChild() 为元素添加一个新的子元素
element.parentNode 返回元素的父节点
element.childNodes 返回元素的一个子节点的数组
element.nextSibling 返回该元素紧跟的一个节点
element.removeChild() 删除一个子元素
element.insertBefore() 现有的子元素之前插入一个新的子元素
element.textContent 设置或返回一个节点和它的文本内容
element.innerText 会覆盖之前的所有元素,如果只想改文本,可以使用 textContent
3. diff 简介
- diff 算法可以将 两个虚拟dom 进行比较,他是一个精细化对比,实现dom的最小量更新
- 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')])
-
只有是同一个虚拟节点(选择器相同且key相同),才会进行精细化比较,否则就进行暴力删除旧的、插入新的。
-
只进行通层级比较深度优先,不会进行跨层级比较。即使同一片虚拟节点,但是跨层级了,仍是暴力删除旧的、然后插入新的。
-
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)
手写之前了解几个方法
- h、vnode 函数可以生成虚拟dom
- patch 函数用来比较同层级虚拟dom不相等相等的情况(层级比较)
- pathVnode 比较同层级虚拟dom相等的情况 (层级比较)
- 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算法的规则
- 首尾指针法 新旧虚拟(子)节点前后两两比较,匹配成功(相同)则指针往中间靠
- newVnode为渲染标准,对比新的虚拟dom然后操作旧的
- 如果首尾指针法,匹配不出则循环比较虚拟DOM key(源码优化为映射缓存),如果成功匹配,则移动该虚拟DOM到旧的前面(新的指针往中间靠)
- 循环不出结果则当前newVnode转换成真实dom,追加到旧的oldstartindex指针前面
- 当首尾指针法循环完后,剩下判断新旧虚拟节点指针位置,以确定有无剩余节点未处理,newVnode剩-添加,oldVnode剩-删除
7.2对比流程图
第一轮首尾指针法
命中成功,以newVnode为标准移动位置
两两指针中间靠拢
第二轮首尾指针
两两指针中间靠拢
第三轮首尾指针法
两两指针中间靠拢(头加一,尾减一),如果newstart>newend或者oldstart>oldend,则首尾指针法结束
首尾指针法结束结束后的两种情况,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
- 首尾指针法 新旧虚拟(子)节点前后两两比较,匹配成功(相同)则指针往中间靠
- newVnode为渲染标准,对比新的虚拟dom然后操作旧的
- 如果首尾指针法,匹配不出则循环比较虚拟DOM key(源码优化为映射缓存),如果成功匹配,则移动该虚拟DOM到旧的前面(新的指针往中间靠)
- 循环不出结果则当前newVnode转换成真实dom,追加到旧的oldstartindex指针前面
- 当首尾指针法循环完后,剩下判断新旧虚拟节点指针位置,以确定有无剩余节点未处理,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. 总结
- 高效性:有虚拟dom,必然需要diff算法。通过对比新旧虚拟dom,将有变化的地方更新在真实dom上,另外,通过diff高效的执行比对过程,把dom的操作最小化(只操作有变化的dom)。
- 必要性:vue2中为了降低watcher粒度,每个组件只有一个watcher。通过diff精确找到发生变化的节点,并复用相同的节点。
到此 diff 算法算是大功告成啦!!!完美谢幕!!!致敬努力的自己!!!
如有问题请大家留言讨论。
仓库地址(注意是sty_snabbdom分支):https://gitee.com/CjingHong/mini-ui/tree/sty_snabbdom/
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
2020-11-16 网页性能优化(渲染原理)