感受diff算法
现在有两组虚拟节点,我们使用diff算法进行patch他们,并来观察观察结果。这块我们就使用snabbdom这个库来进行演示了。
import {
h,
init,
classModule,
propsModule,
styleModule,
eventListenersModule
} from 'snabbdom'
const path = init([
// 通过传入模块初始化 path 函数
classModule, // 支持classes功能
propsModule, // 支持传入 props 属性
styleModule, // 支持内联样式,同时支持动画
eventListenersModule // 添加事件监听
])
使用
let vnode = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D'),
h('li', { key: 'E' }, 'E'),
])
let vnode1 = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C')
])
let container = document.getElementById('container')
let btn = document.getElementById('btn')
patch(container, vnode)
btn.onclick = function () {
patch(vnode, vnode1)
}
我们来看一下效果,最后发现只是删除了几个多余的DOM节点,原来的三个节点并没有发生任何改变。这就是diff算法要做的事情,用js对象模拟真实dom,对比找出差异,并实现最小化更新。
整体流程
实现h.js
在实现h函数之前,先要弄清楚h函数的作用,h函数的作用就是,接受三个参数,生成虚拟节点。用来描述真实DOM。
// 手写h 函数
import vnode from "./vnode"
/**
*
* 根据传入的属性返回一个vnode
*
* 只支持传入三个参数的调用形式
*
* h('', {}, '文本')
*
* h('', {}, [])
*
* h('', {}, h())
*/
/**
*
* @param {选择器} sel
* @param {属性等} data
* @param {孩子} c
*/
export default function h(sel, data, c) {
// 1. 先检测参数个数
if (arguments.length !== 3) {
throw new Error('h函数传入的参数格式只能是三个!!!')
}
// 2. 检测第三参数的类型 h('', {}, '文本')
if (typeof c === 'string' || typeof c === 'number') {
return vnode(sel, data, undefined, c, undefined)
}
// 3. 第三参数是数组的情况 h('', {}, [])
else if (Array.isArray(c)) {
// 需要循环判断数组里面的元素是不是一个vnode
let children = []
for (let item of c) {
// 写好满足的条件,取反就是不满足的情况,需要抛异常
if (!(typeof item === 'object' && item.hasOwnProperty('sel'))) {
throw new Error('传入的数组中的项存在不是一个vnode的情况!!!')
}
children.push(item)
}
return vnode(sel, data, children, undefined, undefined)
}
// 4. 第三参数是对象,并且是一个vnode的情况 h('', {}, h())
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('传入的参数类型不正确!!!')
}
}
实现vnode.js
在这里,我们发现使用了一个vnode函数,它其实很简单,就是把参数包装成一个对象。
vnode.js
// 将传入的参数组合成对象返回
export default function (sel, data, children, text, elm) {
const key = data.key
return {
sel,
data,
children,
text,
elm,
key
}
}
实现patch.js
patch函数的作用,接受一个老的虚拟节点和一个新的虚拟节点,判断是否需要精细化对比。
import vnode from "./vnode"
import createElement from "./createElement"
import patchVnode from "./patchVnode"
export default function patch(oldVnode, newVnode) {
// 判断oldVnode 是不是一个虚拟节点
if (oldVnode.sel === '' || oldVnode.sel === undefined) {
let sel = oldVnode.tagName.toLowerCase()
oldVnode = vnode(sel, {}, [], oldVnode.innerText, oldVnode)
}
// 判断 oldVnode和 newVnode 是不是同一个虚拟节点
if (oldVnode.sel === newVnode.sel && oldVnode.key === newVnode.key) {
// 如果是同一个虚拟节点,则需要进行精细化比较
patchVnode(oldVnode, newVnode)
} else {
// 如果不是同一个虚拟节点,则需要进行替换
// 需要操作DOM 需要先将 新的虚拟节点转换成 真实 DOM
let newVnodeElm = createElement(newVnode)
let oldVnodeElm = oldVnode.elm
// 插入新节点到老的之前
if (newVnodeElm) {
oldVnodeElm.parentNode.insertBefore(newVnodeElm, oldVnodeElm)
}
// 删除老的节点
oldVnodeElm.parentNode.removeChild(oldVnodeElm)
}
}
实现patchVnode.js
如果是同一个节点的时候,调用patchVnode进行详细的比较。
import createElement from "./createElement"
import updateChildren from "./updateChildren"
export default function patchVnode(oldVnode, newVnode) {
if (oldVnode === newVnode) return
// 判断newVnode 有没有text属性
if (newVnode.text !== '' && (newVnode.children === undefined || newVnode.children.length === 0)) {
// 如果有text 属性 判断新老节点的text 属性是否相同?
if (oldVnode.text === newVnode.text) {
return
} else {
// 如果不相同,则需要替换文本
oldVnode.elm.innerText = newVnode.text
}
} else {
// 判断oldVnode 有没有children
if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
// 两者都有孩子,就进入更复杂的diff
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
} else {
// 老的是 text 节点, 新的有 children
// 先清空老的节点
oldVnode.elm.innerText = ''
for (let ch of newVnode.children) {
let chDom = createElement(ch)
oldVnode.elm.appendChild(chDom)
}
}
}
}
实现createElement.js
createElement函数的作用,接受一个虚拟节点,根据虚拟节点创建出真实节点,如果当前虚拟节点有孩子,就进行递归创建。
export default function createElement(vnode) {
// 先用 vnode 最外层创建出一个节点
let domNode = document.createElement(vnode.sel)
// 判断 vnode 是有子节点还是有 文本?
// 当文本属性不为空,并且 children 属性有值,且长度大于0
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 ch of vnode.children) {
let chDom = createElement(ch)
domNode.appendChild(chDom)
}
}
// 给虚拟节点挂载一个真实节点
vnode.elm = domNode
// 返回当前真实节点
return domNode
}
实现updateChildren.js
当两个虚拟节点都有children的时候,因为有可能涉及到子节点还有子节点,所以可能有递归patch的情况,如果在调用updateChildren函数的时候发现两个节点是同一个节点的时候,就需要再次调用patchVnode函数。
import createElement from "./createElement";
import patch from "./patch";
import patchVnode from "./patchVnode";
export default function updateChildren(parentElm, oldCh, newCh) {
console.log('updateChildren()');
console.log(oldCh, newCh)
// 先定义是个指针
let newStartIdx = 0
let newEndIdx = newCh.length - 1
let oldStartIdx = 0
let oldEndIdx = oldCh.length - 1
// 指针指向的四个节点
let newStartVnode = newCh[newStartIdx]
let newEndVnode = newCh[newEndIdx]
let oldStartVnode = oldCh[oldStartIdx]
let oldEndVnode = oldCh[oldEndIdx]
let keyMap = null
// 开始循环
while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
// 首先不是判断四种命中,而是略过 已经加了 undefined 的标记项
if (oldStartVnode === null || oldCh[oldStartIdx] === undefined) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (oldEndIdx === null || oldCh[oldEndIdx] === undefined) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (newStartVnode === null || newCh[newStartIdx] === undefined) {
newStartVnode = newCh[++newStartIdx]
} else if (newEndVnode === null || newCh[newEndIdx] === undefined) {
newEndVnode = newCh[--newEndIdx]
} else if (checkSameVnode(oldStartVnode, newStartVnode)) {
console.log('1.新前与旧前 命中')
// 精细化比较两个虚拟节点
patchVnode(oldStartVnode, newStartVnode)
// 移动节点
newStartVnode = newCh[++newStartIdx]
oldStartVnode = oldCh[++oldStartIdx]
} else if (checkSameVnode(oldEndVnode, newEndVnode)) {
console.log('2.新后与旧后 命中')
patchVnode(oldEndVnode, newEndVnode)
newEndVnode = newCh[--newEndIdx]
oldEndVnode = oldCh[--oldEndIdx]
} else if (checkSameVnode(oldStartVnode, newEndVnode)) {
console.log('3.新后与旧前 命中')
patchVnode(oldStartVnode, newEndVnode)
// 把旧前的节点放到新后面
// 这块只能是往最后一个节点插入,因为新的节点是从后面开始插入的
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
newEndVnode = newCh[--newEndIdx]
oldStartVnode = oldCh[++oldStartIdx]
} else if (checkSameVnode(oldEndVnode, newStartVnode)) {
console.log('4.新前与旧后 命中')
patch(oldEndVnode, newStartVnode)
//
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
newStartVnode = newCh[++newStartIdx]
oldEndVnode = oldCh[--oldEndIdx]
} else {
// 四种都没找到
console.log('5.四种都没找到')
if (!keyMap) {
keyMap = {}
// 记录 oldVnode 中的节点出现的 key
// 从 oldStartIdx 开始,到 oldEndIdx 结束 ,创建 keyMap
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key
if (key !== undefined) {
keyMap[key] = i
}
}
}
console.log(keyMap)
// 寻找当前项 (newStartIdx) 在keyMap 中映射的序号
const idxInOld = keyMap[newStartVnode.key]
if (idxInOld === undefined) {
// 如果 idxInOld 是 undefined 说明是全新的项,要插入
// 被加入的项(就是 newStartVnode这项) 现在不是真正的 DOM 节点
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
} else {
// 如果找到了, 说明不是全新的项, 要移动
const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove, newStartVnode)
// 把这项设置为 undefined ,表示已经处理完这一项了
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
}
newStartVnode = newCh[++newStartIdx]
}
}
// 循环结束
if (newStartIdx <= newEndIdx) {
// 说明 newNode 还有剩余节点没有处理,所以要添加这些节点
// 找插入的标杆
for (let i = newStartIdx; i <= newEndIdx; i++) {
console.log(oldCh, oldStartIdx)
// insertBefore 方法可以自动设别 null ,如果是 null 就会自动排到队尾,和appendChild一样
let anchor = oldCh[oldStartIdx] ? oldCh[oldStartIdx].elm : null
parentElm.insertBefore(createElement(newCh[i]), anchor)
}
} else if (oldStartIdx <= oldEndIdx) {
// 说明oldVnode 还有剩余节点没有处理,所以要删除这些节点
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm)
}
}
}
}
function checkSameVnode(a, b) {
return a.sel === b.sel && a.key === b.key
}
多画流程图,多些一边,过几天再复习一下。
总结
-
在搞清楚 diff 算法之前,一定要知道,diff 算法 对比的是虚拟 DOM 修改的是 老虚拟 DOM 上面真实 DOM 的引用。
-
明天看四种命中查找
-
简单总结一下新老节点都有 children 的时候的 diff 策略
-
循环的条件是 newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx。
-
循环的时候如果发现四个节点指向的 vnode 等于 null 或者 undefined 就先跳过 (更新指针和虚拟节点的值)。
-
检测新老节点是否相同,进入第一种对比策略。
- 新前与旧前对比(如果相同要精心 patch,并更新指针位置和指针指向的节点)
- 新后与旧后对比(如果相同要精心 patch)
- 新后与旧前对比(如果相同要精心 patch)涉及节点移动 移动到旧后之后
- 新前与旧后对比(如果相同要精心 patch)涉及节点移动 移动到旧前之前
- 3.4中移动的节点都是老的DOM,参考的节点也都是老节点,位置的方向是跟着新节点走的。
-
假如四种策略都没有命中。
-
记录 oldVnode 中节点出现的 key, 从 oldStartIdx 开始到 oldEndIdx 结束,创建 keyMap
-
寻找当前项 (newStartIdx) 在 keyMap 中映射的序号 keyMap[newStartVnode.key],找到在老虚拟节点中的位置。(idxInOld)
-
如果 === undefined 说明是全新的项,要插入
-
如果存在,说明不是全新的项,则说明要移动,根据 idxInOld 在老节点中找到要移动的元素(elmToMove),插入到 oldStartVnode.elm 之前,把处理完成的虚拟节点设置成 undefined
-
-
-
循环结束。
- 如果 newStartIdx <= newEndIdx 则说明还有剩余节点没有处理,所以要添加这些节点
- 如果 oldStartIdx <= oldEndIdx 这说明还有剩余节点没有处理,所以要删除这些节点