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/