记一波简单的 diff 算法学习,我是基于 https://github.com/snabbdom/snabbdom学习的,各个框架的比较算法不太一样,但是总体流程类似。
本篇参考了 https://segmentfault.com/a/1190000017494569 这个文章,我的代码是最新拉取的,和作者展示的略有不同。
(注:旧的入口 snabbdom.js 名字换成了init.ts)
问题引导: 为什么v-for
的时候要传key
?
- 大部分的答案是直接告诉你可以增加效率,我稍微看了如下的两个核心函数才有点感觉。
其实主要是 vnode 对比,然后移动旧节点比创建一个新的节点效率高。然后就是 key
不能为每次都生成的 js 随机数和 index
,随机数的话和没传一样,起不到对比作用,index
会动态改变会产生一些不可预料的结果(比如我们在 data
中删除节点3,界面上却把节点2删除了)。
比较绝的是 vue3 的 render 还给每个模板的数据绑定的位置加了 key,拥有了针对性之后跳过了很多 vnode 对比,所以性能提升很多。再加上 vite 的按需引入,开发效率 upup。
流程引导:官网给了一个示例,我们可以跟着示例的顺序去查看对应的内容
提示: 把下面的代码复制到 js 中,最好安装一个叫
Better Comments
的vscode插件,我的注释就可以高亮显示了
代码片段来源:src/h.ts
import { vnode, VNode, VNodeData } from './vnode'
import * as is from './is'
export type VNodes = VNode[]
export type VNodeChildElement = VNode | string | number | undefined | null
export type ArrayOrElement<T> = T | T[]
export type VNodeChildren = ArrayOrElement<VNodeChildElement>
function addNS (data: any, children: VNodes | undefined, sel: string | undefined): void {
data.ns = 'http://www.w3.org/2000/svg'
if (sel !== 'foreignObject' && children !== undefined) {
for (let i = 0; i < children.length; ++i) {
const childData = children[i].data
if (childData !== undefined) {
addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel)
}
}
}
}
/**
*
* @param sel 选择器,类似jquery
* @param b 数据
* @param c 子节点
* @returns {{sel, data, children, text, elm, key}}
*/
// todo h函数的主要工1作就是把传入的参数封装为vnode
// todo 在调用之前会对数据进行一个处理:是否含有数据,是否含有子节点,子节点类型的判断等
export function h (sel: string): VNode
export function h (sel: string, data: VNodeData | null): VNode
export function h (sel: string, children: VNodeChildren): VNode
export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}
var children: any
var text: any
var i: number
// todo 如果childNode存在,则其为子节点
if (c !== undefined) {
if (b !== null) {
// todo 则h的第二项b就是data
data = b
}
// todo 如果子节点是数组,则存在子element节点
if (is.array(c)) {
children = c
// todo 否则子节点为text节点
} else if (is.primitive(c)) {
text = c
} else if (c && c.sel) {
children = [c]
}
// todo 如果只有 b数据 存在,childNode不存在,则b有可能是子节点也有可能是数据
} else if (b !== undefined && b !== null) {
// todo 数组代表子element节点
if (is.array(b)) {
children = b
// todo 代表子文本节点
} else if (is.primitive(b)) {
text = b
} else if (b && b.sel) {
children = [b]
// todo 剩下的就是数据了
} else { data = b }
}
if (children !== undefined) {
for (i = 0; i < children.length; ++i) {
// todo 如果子节点数组中,存在节点是原始类型,说明该节点是text节点,因此我们将它渲染为一个只包含text的VNode
if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined)
}
}
if (
sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
(sel.length === 3 || sel[3] === '.' || sel[3] === '#')
) {
addNS(data, children, sel)
}
// todo 返回 Vnode
return vnode(sel, data, children, text, undefined)
};
代码片段来源:src/init.ts
核心顺序是:从最后面的 patch()
函数入手 -> patchVnode()
-> updateChildren()
,中间会夹杂各个小函数,当然直接去跟着我参考的那个文章比较容易理解,我只是把部分注释搬移了过来。
查看核心的update
算法函数之前,最好看一下这个算法的解析:http://qiutianaimeili.com/html/page/2018/05/4si69yn4stl.html
// todo 对比函数 如果不传 key 结果就是true,就会去对比 sel
import { Module } from './modules/module'
import { vnode, VNode } from './vnode'
import * as is from './is'
import { htmlDomApi, DOMAPI } from './htmldomapi'
type NonUndefined<T> = T extends undefined ? never : T
function isUndef (s: any): boolean {
return s === undefined
}
function isDef<A> (s: A): s is NonUndefined<A> {
return s !== undefined
}
type VNodeQueue = VNode[]
const emptyNode = vnode('', {}, [], undefined, undefined)
// todo 如果不传 key 结果就是true,就会去对比 sel
/*
* 如果新旧vnode的key和sel都相同,说明两个vnode相似,我们就可以保留旧的vnode节点,
* 再具体去比较其差异性,在旧的vnode上进行'打补丁',否则直接替换节点
*
* 这里需要说的是如果不定义key值,则这个值就为undefined,undefined===undefined //true
* 所以平时在用vue的时候,在没有用v-for渲染的组件的条件下,是不需要定义key值的,不会影响其比较
*/
function sameVnode (vnode1: VNode, vnode2: VNode): boolean {
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}
function isVnode (vnode: any): vnode is VNode {
return vnode.sel !== undefined
}
type KeyToIndexMap = {[key: string]: number}
type ArraysOf<T> = {
[K in keyof T]: Array<T[K]>;
}
type ModuleHooks = ArraysOf<Required<Module>>
function createKeyToOldIdx (children: VNode[], beginIdx: number, endIdx: number): KeyToIndexMap {
const map: KeyToIndexMap = {}
for (let i = beginIdx; i <= endIdx; ++i) {
const key = children[i]?.key
if (key !== undefined) {
map[key] = i
}
}
return map
}
const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
let i: number
let j: number
const cbs: ModuleHooks = {
create: [],
update: [],
remove: [],
destroy: [],
pre: [],
post: []
}
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi
for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = []
for (j = 0; j < modules.length; ++j) {
const hook = modules[j][hooks[i]]
if (hook !== undefined) {
(cbs[hooks[i]] as any[]).push(hook)
}
}
}
/*
todo 用来将一个真实的无子节点的DOM节点转化成vnode形式,
* 如:<div id='a' class='b c'></div>
* 将转换为{sel:'div#a.b.c',data:{},children:[],text:undefined,elm:<div id='a' class='b c'>}
*/
function emptyNodeAt (elm: Element) {
const id = elm.id ? '#' + elm.id : ''
const c = elm.className ? '.' + elm.className.split(' ').join('.') : ''
return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm)
}
/*
* remove一个vnode时,会触发remove钩子作拦截器,只有在所有remove钩子
* 回调函数都触发完才会将节点从父节点删除,而这个函数提供的就是对remove钩子回调操作的计数功能
*/
function createRmCb (childElm: Node, listeners: number) {
return function rmCb () {
if (--listeners === 0) {
const parent = api.parentNode(childElm) as Node
api.removeChild(parent, childElm)
}
}
}
// todo //将vnode创建为真实dom
// * 创建vnode对应的真实dom,并将其赋值给vnode.elm,后续对于dom的修改都是在这个值上进行
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any
let data = vnode.data
if (data !== undefined) {
// todo hook 钩子
const init = data.hook?.init
if (isDef(init)) {
init(vnode)
data = vnode.data
}
}
const children = vnode.children
const sel = vnode.sel
if (sel === '!') {
if (isUndef(vnode.text)) {
vnode.text = ''
}
vnode.elm = api.createComment(vnode.text!)
} else if (sel !== undefined) {
// todo 解析sel参数
// * 例如div#divId.divClass ==> id="divId" class="divClass"
const hashIdx = sel.indexOf('#')
const dotIdx = sel.indexOf('.', hashIdx)
const hash = hashIdx > 0 ? hashIdx : sel.length
const dot = dotIdx > 0 ? dotIdx : sel.length
const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel
// todo 创建一个DOM节点引用,并对其属性实例化
const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
? api.createElementNS(i, tag)
: api.createElement(tag)
// todo 获取id名 #a --> a
if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
// todo 获取类名,并格式化 .a.b --> a b
if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '))
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)
// *如果存在子元素Vnode节点,则递归将子元素节点插入到当前Vnode节点中,
// *并将已插入的子元素节点在insertedVnodeQueue中作记录
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
const ch = children[i]
if (ch != null) {
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))
}
}
// todo 如果存在子文本节点,则直接将其插入到当前Vnode节点
} else if (is.primitive(vnode.text)) {
api.appendChild(elm, api.createTextNode(vnode.text))
}
// todo create钩子回调
const hook = vnode.data!.hook
if (isDef(hook)) {
hook.create?.(emptyNode, vnode)
if (hook.insert) {
insertedVnodeQueue.push(vnode)
}
}
// todo 如果没声明选择器,则说明这个是一个text节点
} else {
vnode.elm = api.createTextNode(vnode.text!)
}
return vnode.elm
}
// todo 将vnode转换后的dom节点插入到dom树的指定位置中去
function addVnodes (
parentElm: Node,
before: Node | null,
vnodes: VNode[],
startIdx: number,
endIdx: number,
insertedVnodeQueue: VNodeQueue
) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (ch != null) {
api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before)
}
}
}
// todo 手动触发destory钩子回调
/*
* 先调用vnode上的destory
* 再调用全局下的destory
* 递归调用子vnode的destory
*/
function invokeDestroyHook (vnode: VNode) {
const data = vnode.data
if (data !== undefined) {
// todo 调用自身的destroy钩子
// * 原来的写法 if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode);
data?.hook?.destroy?.(vnode)
// todo 调用全局destroy钩子
for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
if (vnode.children !== undefined) {
for (let j = 0; j < vnode.children.length; ++j) {
const child = vnode.children[j]
if (child != null && typeof child !== 'string') {
invokeDestroyHook(child)
}
}
}
}
}
// todo 批量删除DOM节点
/*
* @param parentElm 父节点
* @param vnodes 删除节点数组
* @param startIdx 删除起始坐标
* @param endIdx 删除结束坐标
*/
function removeVnodes (parentElm: Node,
vnodes: VNode[],
startIdx: number,
endIdx: number): void {
for (; startIdx <= endIdx; ++startIdx) {
let listeners: number
let rm: () => void
// todo ch代表子节点
const ch = vnodes[startIdx]
if (ch != null) {
if (isDef(ch.sel)) {
// todo 调用 destroy 钩子
invokeDestroyHook(ch)
// todo 对全局remove钩子进行计数
listeners = cbs.remove.length + 1
rm = createRmCb(ch.elm!, listeners)
// todo 调用全局remove回调函数,并每次减少一个remove钩子计数
for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)
const removeHook = ch?.data?.hook?.remove
// todo 调用内部vnode.data.hook中的remove钩子(只有一个)
if (isDef(removeHook)) {
removeHook(ch, rm)
} else {
// todo 如果没有内部remove钩子,需要调用rm,确保能够remove节点
rm()
}
} else { // Text node
api.removeChild(parentElm, ch.elm!)
}
}
}
}
// todo 更新函数 diff渲染 http://qiutianaimeili.com/html/page/2018/05/4si69yn4stl.html
// * snabbdom的对比策略是针对同层级的节点进行对比 广度优先遍历
function updateChildren (parentElm: Node,
oldCh: VNode[],
newCh: VNode[],
insertedVnodeQueue: VNodeQueue) {
// todo 定义长度,两边逐渐往中间移动,两个都重合时候就结束
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let newEndIdx = newCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx: KeyToIndexMap | undefined
let idxInOld: number
let elmToMove: VNode
let before: any
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode might have been moved left
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
// todo 旧开始和新开始对比
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
// todo 旧结束和新结束对比
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
// todo 旧开始和新结束对比
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
// todo 旧结束和新开始对比
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
// todo 都未命中
} else {
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
// todo 当前的key,能否对应上某个节点的key
idxInOld = oldKeyToIdx[newStartVnode.key as string]
// todo 没对上可能是新的,直接插入就好了
if (isUndef(idxInOld)) { // New element
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
// todo 对应上
} else {
// todo 拿到 key 的节点
elmToMove = oldCh[idxInOld]
// todo sel 是否相等 (参考 sameVnode 的条件)
if (elmToMove.sel !== newStartVnode.sel) {
// todo 白搭和新的一样处理
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
// todo 开始递归, 如果还存在其他子节点则调用补丁patchVnode, 然后继续 updateChildren
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined as any
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
}
// todo 如果两个vnode相似,则会对具体的vnode进行‘打补丁’的操作
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
// todo 执行生命周期钩子
const hook = vnode.data?.hook
hook?.prepatch?.(oldVnode, vnode)
// todo 设置 vnode.elm 直接复制旧的
const elm = vnode.elm = oldVnode.elm!
// todo 旧的 children
const oldCh = oldVnode.children as VNode[]
// todo 新的 children
const ch = vnode.children as VNode[]
// todo 如果oldnode和vnode的引用相同,说明没发生任何变化直接返回,避免性能浪费
if (oldVnode === vnode) return
// todo hook 相关
if (vnode.data !== undefined) {
for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
vnode.data.hook?.update?.(oldVnode, vnode)
}
// todo 新的 vnode.text === undefined 一般意味着 vnode.children != undefined
// !ps:如果自身存在文本节点,则不存在子节点 即:有text则不会存在ch,反之亦然
/*
分情况讨论节点的更新: new代表新Vnode old代表旧Vnode
ps:如果自身存在文本节点,则不存在子节点 即:有text则不会存在ch,反之亦然
1 new不为文本节点
1.1 new不为文本节点,new还存在子节点
1.1.1 new不为文本节点,new还存在子节点,old有子节点
1.1.2 new不为文本节点,new还存在子节点,old没有子节点
1.1.2.1 new不为文本节点,new还存在子节点,old没有子节点,old为文本节点
1.2 new不为文本节点,new不存在子节点
1.2.1 new不为文本节点,new不存在子节点,old存在子节点
1.2.2 new不为文本节点,new不存在子节点,old为文本节点
2.new为文本节点
2.1 new为文本节点,并且old与new的文本节点不相等
ps:这里只需要讨论这一种情况,因为如果old存在子节点,那么文本节点text为undefined,则与new的text不相等
直接node.textContent即可清楚old存在的子节点。若old存在子节点,且相等则无需修改
*/
// 1
if (isUndef(vnode.text)) {
// todo 新旧都有 children
// 1.1.1
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
// todo 新 children 有值,旧 children 无 (旧 text 有)
// 1.1.2
} else if (isDef(ch)) {
// todo 先清空旧的 text
if (isDef(oldVnode.text)) api.setTextContent(elm, '')
// todo 并添加vnode的children
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
// 1.2.1
// todo 如果oldvnode有children,而vnode没children,则移除elm的children
} else if (isDef(oldCh)) {
// todo 移除旧的
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
//1.2.2
// todo 如果vnode和oldvnode都没chidlren,且vnode没text,则删除oldvnode的text
} else if (isDef(oldVnode.text)) {
api.setTextContent(elm, '')
}
// * else 直接意味着:vnode.text !== undefined 一般意味着 vnode.children 无值
// todo ↓ 新的 text 有值,旧的 text 和我还不一样,直接干掉
// 2.1
} else if (oldVnode.text !== vnode.text) {
// todo 移除旧的
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
// todo 设置新的 text
api.setTextContent(elm, vnode.text!)
}
// todo 触发postpatch钩子
hook?.postpatch?.(oldVnode, vnode)
}
// todo 入口函数
return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node
const insertedVnodeQueue: VNodeQueue = []
// todo 执行 pre hooks
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
// todo 第一个参数不是 vnode,就创建一个空的 vnode,关联到这个 DOM 元素
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode)
}
// todo 判断相同的 vnode
if (sameVnode(oldVnode, vnode)) {
// todo vnode 对比
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
// todo 不同的 vnode 直接销毁重建
elm = oldVnode.elm!
parent = api.parentNode(elm) as Node
// todo 重建
createElm(vnode, insertedVnodeQueue)
if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
removeVnodes(parent, [oldVnode], 0, 0)
}
}
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
return vnode
}
}