Virtual DOM的实现原理
@[TOC]
一、简介 |
虚拟DOM
就是使用普通的js
对象模拟的DOM
元素,只保留DOM
元素中比较关键的几个属性。在数据驱动视图的应用场景下,数据变化时,使用虚拟DOM
不会立即更新视图,而是会对比旧的虚拟DOM
和新生成的虚拟DOM
之间有没有差异,只更新有修改的部分。Vue.js
中虚拟DOM
使用的是改造后的Snabbdom
库,Snabbdom
库代码量不大,但是功能很强大,可以通过模块扩展的方式扩展功能。所以本篇文章主要对Snabbdom
库的使用和源码进行探究。
二、Snabbdom库的基本使用 |
使用的Snabbdom库的版本:3.5.1
(一)项目初始化
1、初始化项目安装打包工具parcel
2、更改package.json的scripts配置
"scripts": {
"dev": "parcel index.html --open",
"build": "parcel build index.html"
},
3、初始化文件目录
并在
index.html
中引入js
文件
(二)Snabbdom基本用法
1、下载Snabbdom
npm install snabbdom
2、Hello World案例
案例目标:
创建虚拟DOM
,进行差异对比并替换旧的DOM
API简介:
① init()
:接受一个模块组成的数组作为参数,返回一个patch()
函数
② patch()
:对比差异,并且更新DOM
。接收两个参数,
参数一:虚拟DOM
或者真实的元素,如果传递真实的元素,内部会转化出一个虚拟DOM
参数二:虚拟DOM
返回值:新的虚拟DOM
,即参数二,用来作为下一次差异对比的旧值
③ h()
:创建虚拟DOM
。h()函数接收参数的方式有很多种,这里使用:
参数一:标签和选择器
参数二:一段字符串,适用于虚拟DOM
里面只有纯文本的情况 案例代码:
import {
init,
h,
} from "snabbdom";
// 使用init函数创建patch函数用来对比差异
const patch = init([])
// 使用h函数创建虚拟节点:
// tagName=`div`, id=`container`, class=`cls`, innerHTML=`Hello World`
const vnode = h('div#container.cls', 'Hello World')
// 使用patch函数对比差异,更新到真实DOM
// 使用oldVnode保存上一次的虚拟节点,用于下一次的对比
const oldValue = patch(document.getElementById('app'), vnode)
案例效果:
第二次差异对比:
创建变量接收patch()
函数的返回值,和新的虚拟DOM
进行二次对比,并将新的虚拟DOM
展示到视图上。 代码:
const oldValue = patch(document.getElementById('app'), vnode)
const newValue = h('div#container.xxx', 'Hello Snabbdom')
patch(oldValue, newValue)
效果:
3、创建一个包含子元素的DOM
案例实现的功能:
① 创建一个包含子元素的虚拟DOM
并替换原始html
中的div#app
② 2s之后修改子节点中的内容
③ 再过2s之后清除虚拟DOM 知识点:
① h()
函数创建有子节点的虚拟DOM
时,参数列表:
参数一:选择器
参数二:使用h()
创建的虚拟DOM
数组
② h()
函数创建空的注释节点:h('!')
代码:
import {
init,
h,
} from "snabbdom";
// 使用init函数创建patch函数用来对比差异
const patch = init([])
// 使用h函数创建一个有子节点的虚拟DOM
const vnode = h('div#container.cls', [
h('h1', 'Hello Snabbdom'),
h('p', '这是一个p标签')
])
// 使用patch函数对比差异
const app = document.querySelector('#app')
const oldVnode = patch(app, vnode)
// 2s后再次对比差异
setTimeout(() => {
const newVnode = h('div#container.cls', [
h('h1', 'Hello World'),
h('p', 'Hello P')
])
patch(oldVnode, newVnode)
}, 2000)
// 4s之后清空页面
setTimeout(() => {
patch(oldVnode, h('!'))
}, 4000)
(三)模块基本使用
Snabbdom
库中提供的模块都只能操作虚拟DOM
,不能操作真实的DOM
。
Snabbdom
中的模块可以用来扩展 Snabbdom
的功能。 引入:
import {
init,
h,
styleModule,
eventListenersModule
} from "snabbdom";
在init()
中注册:
const patch = init([styleModule,eventListenersModule])
在h()
中使用:
h()
函数的第二个参数传递模块所需要的配置参数
const vnode = h("div",[
h("h1",{
style:{color:'pink'},// styleModeule需要配置style属性
on:{
click:()=>{alert('Hello Snabbdom')},
} // eventListenersModule需要配置on属性
}, "Hello Snabbdom")
])
三、Snabbdom源码解析 |
看源码tips: 先大致了解库的核心实现过程,然后围绕一个核心的目标,顺着一条功能线去逐步看源码是怎么实现的,可以灵活使用调试工具查看函数执行的流程。 Snabbdom库核心执行过程:
(一)运行官方示例
github地址
将源码下载下来之后,运行npm i
安装依赖;npm run build
构建项目;npm run examples
运行示例代码就可以在页面中看到官方提供的示例效果:
(二)h()函数
h()
函数是用来创建虚拟DOM
的,通过阅读h()
函数的源码,我们需要回答一个问题: h()函数是如何创建虚拟DOM的?
h()
函数的定义在src/h.ts
中
h()
函数在执行的时候有多种接收参数的方式,其中关键部分在于h()
函数的重载,定义了四种接收不同种类和数量的参数的h()
函数,真正的代码执行在第五个h()
函数的定义中执行
主要是判断传进来的第三个参数和第二个参数有没有值,以及值的类型,对不同类型的值进行处理,将子元素处理成数组等等处理,最终返回
vnode()
函数的调用结果。
// 返回vnode函数处理后的结果
// sel: string, 选择器
// data: VNodeData | null, 模块配置数据
// children: VNodeChildren, 子元素组成的数组
// text: string | undefined, 文本
// elm: Element | Text | undefined
return vnode(sel, data, children, text, undefined);
接下来看vnode()
函数的内部实现
其内部定义了两个接口,规定两个对象的数据类型,
vnode()
方法把参数组装成一个js
对象返回。
所以,
h()
函数是如何创建虚拟DOM
的?
h()
函数存在重载机制,它的参数有四种组合方式,h()
函数会对传进来的参数进行判断和处理,最终组装成一个vNode
类型的js对象。
(三)patch()函数
patch()
函数的作用是对比两个vNode的差异,并且更新视图。patch()
函数由一个高阶函数init()
返回。回顾一下init()
函数的使用:
const patch = init([styleModule,eventListenersModule])
我们在使用的时候传入了一个模块组成的数组。
init()
函数的定义:
① 参数一是用来接收模块数组的。
init()
中初始化了一个钩子函数对象,然后会遍历模块,将模块中的钩子函数添加到钩子函数对象中,在将来对应的时机执行。
// 钩子函数,会在对应的时机执行。初始化为数组,因为未来可能会有多个钩子函数
const cbs: ModuleHooks = {create: [], update: [], remove: [], destroy: [], pre: [], post: [],};
for (const hook of hooks) {
// 遍历模块,把模块中的钩子函数添加到cbs中
for (const module of modules) {
const currentHook = module[hook];
if (currentHook !== undefined) {
// 遍历结果:cbs = {create: [fn1,fn2],...}
(cbs[hook] as any[]).push(currentHook);
}
}
}
② 参数二domApi
如果用户不指定,就会使用snabbdom
自己定义的htmlDOMApi
,内部就是对原生的document
方法的封装
// 初始化DOMApi。如果用户没有指定,就使用默认的htmlDomApi
// htmlDomApi就是一个对象,里面有一些方法,比如createElement、appendChild等,
// 这些方法都是对原生DOM的封装
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
下面是htmlDomApi
的接口定义:
createElement()
方法就是直接返回了原生的createElement()
的执行结果
function createElement(
tagName: any,
options?: ElementCreationOptions
): HTMLElement {
return document.createElement(tagName, options);
}
③ 参数三options
的接口定义如下:
// TODO Should `domApi` be put into this in the next major version bump?
export type Options = {
experimental?: {
fragments?: boolean;
};
};
是一个实验性的功能,支持使用DocumentFragment
对象替换真实的DOM节点。如果patch()
函数的第二个参数(新的vNode
),没有sel
选择器属性(没有指定真实DOM
),但是有children
属性(有子节点),并且将参数三设置为
{
fragments:true
}
就会创建一个DocumentFragment
对象,并且增加指定的子节点,挂载到新的vNode.elm
属性上,后续会被渲染到DOM
中。
init()
函数最终返回patch()
函数。patch()
函数内部的执行步骤如下:
patch()
// 两个参数:新旧vNode
// 首次渲染时,oldVnode是一个真实的dom节点,相当于占位的作用
// 新创建的虚拟DOM vNode,会替换掉oldVnode
return function patch(oldVnode: VNode | Element | DocumentFragment, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
// 存储新插入节点的队列
// 为了触发节点上insert钩子
const insertedVnodeQueue: VNodeQueue = [];
// 遍历执行钩子函数中的pre()
// pre:处理vnode之前的预处理钩子函数
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
if (isElement(api, oldVnode)) {
// 如果oldVnode是一个真实的dom节点,将其转换为vNode
oldVnode = emptyNodeAt(oldVnode);
} else if (isDocumentFragment(api, oldVnode)) {
oldVnode = emptyDocumentFragmentAt(oldVnode);
}
// 判断是否是相同节点
if (sameVnode(oldVnode, vnode)) {
// 如果是相同节点,执行patchVnode,对比两个节点的差异
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
// 如果不是相同节点,直接替换
// !表示这个属性一定有值
elm = oldVnode.elm!;
// 获取父节点
parent = api.parentNode(elm) as Node;
// 创建vNode对应的真实dom节点
// 将新插入的节点的队列传入进去,会触发相应的钩子函数
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
// 将创建出来的DOM插入到父节点中
// elm:老节点对应的dom
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
// 把老节点对应的DOM元素移除
removeVnodes(parent, [oldVnode], 0, 0);
}
}
for (i = 0; i < insertedVnodeQueue.length; ++i) {
// 触发insert钩子函数。从data中获取,由用户自定义
// !如果没有值就不往下执行
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]);
}
// 遍历执行钩子函数中的post()
// post是模块中定义的钩子函数,用于处理vnode之后的钩子函数
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
return vnode;
};
其中有三个比较复杂的函数,patchVnode()
、createElm()
、removeVnodes()
。接下来依次看这三个函数的执行:
1、createElm()函数
createElm()
函数功能:
① 执行用户传入的init()
钩子函数
② 判断vnode.sel
属性,创建对应的DOM
元素,并且添加到vnode.elm
属性中。这一步比较复杂,sel
分为四种情况:
🦋 !
:注释节点
🦋 有值:创建真实DOM
。如果有子节点,递归调用createElm()
🦋 undefined,需要创建文档片段:创建DocumentFragment
,递归处理children
🦋 undefined,不需要创建文档片段:根据text
属性创建文本节点
③ 返回vnode.elm
属性
createElm()
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any;
let data = vnode.data;
if (data !== undefined) {
// init是用户传入的钩子函数
// init使用户可以在创建元素之前执行一些操作
// 如果hook没有值,直接返回undefined
const init = data.hook?.init;
// init是否有定义
if (isDef(init)) {
init(vnode);
data = vnode.data;
}
}
// 获取子节点,后面会递归创建vnode的子节点
const children = vnode.children;
// 获取vnode选择器
const sel = vnode.sel;
if (sel === "!") {
// 创建注释节点
if (isUndef(vnode.text)) {
// 如果vnode.text没有值,就把vnode.text设置为空字符串
vnode.text = "";
}
// 创建注释节点,并把注释节点赋值给vnode.elm,
// 内部调用的是document.createComment
vnode.elm = api.createComment(vnode.text!);
} else if (sel !== undefined) {
// 转换selector,解析出标签名、id、class
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;
// 创建元素节点
// ns:命名空间,一般用于svg元素
const elm = (vnode.elm =
isDef(data) && isDef((i = data.ns))
? api.createElementNS(i, tag, data)
: api.createElement(tag, data));
// 处理id和class
if (hash < dot) elm.setAttribute("id", sel.slice(hash + 1, dot));
if (dotIdx > 0)
elm.setAttribute("class", sel.slice(dot + 1).replace(/\./g, " "));
// dom元素创建完毕,执行create钩子函数
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
// 如果children是数组,就递归创建子节点
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));
}
}
} else if (is.primitive(vnode.text)) {
// 判断vnode.text是否是字符串或者数字
// 如果是字符串或者数字,就创建文本节点,并把文本节点添加到elm中
api.appendChild(elm, api.createTextNode(vnode.text));
}
// 获取用户传入的钩子函数
const hook = vnode.data!.hook;
if (isDef(hook)) {
// 如果用户传入的钩子函数中有create钩子函数,就执行create钩子函数
hook.create?.(emptyNode, vnode);
if (hook.insert) {
// 如果用户传入的钩子函数中有insert钩子函数,就把vnode添加到insertedVnodeQueue中
// DOM元素插入到DOM树中后,执行insert钩子函数
insertedVnodeQueue.push(vnode);
}
}
} else if (options?.experimental?.fragments && vnode.children) {
// sel == undefined并且vnode.children有值
// 创建DocumentFragment
vnode.elm = (
api.createDocumentFragment ?? documentFragmentIsNotSupported
)();
// 执行create钩子函数
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
for (i = 0; i < vnode.children.length; ++i) {
const ch = vnode.children[i];
if (ch != null) {
api.appendChild(
vnode.elm,
createElm(ch as VNode, insertedVnodeQueue)
);
}
}
} else {
// sel为空,创建文本节点
vnode.elm = api.createTextNode(vnode.text!);
}
return vnode.elm;
}
2、removeVnodes()函数
removeVnodes()
函数用户删除老节点对应的DOM
元素。
// 获取父节点
parent = api.parentNode(elm) as Node;
removeVnodes(parent, [oldVnode], 0, 0);
参数列表:
🦋 parent
:要删除的DOM
元素的父节点
🦋 [oldVnode]
:要删除的DOM
元素对应的vnode
数组
🦋 startIdx
:要删除的DOM
元素在[oldVnode]
中的起始索引
🦋 endIdx
:要删除的DOM
元素在[oldVnode]
中的结束索引
由于要删除的节点只有一个,所以要删除的节点起始索引和结束索引都是0,startIdx
和endIdx
都是0。removeVnodes()
函数的执行流程如下:
removeVnodes()
function removeVnodes(
parentElm: Node,
vnodes: VNode[],
startIdx: number,
endIdx: number
): void {
// 循环删除vnodes中的节点
for (; startIdx <= endIdx; ++startIdx) {
let listeners: number;
let rm: () => void;
const ch = vnodes[startIdx];
if (ch != null) {
if (isDef(ch.sel)) {
// 元素节点
// 执行destroy钩子函数
invokeDestroyHook(ch);
// remove钩子函数个数
listeners = cbs.remove.length + 1;
// 删除DOM元素的方法
rm = createRmCb(ch.elm!, listeners);
// 执行模块中的remove钩子函数
// remove钩子函数参数一:vnode
// remove钩子函数参数二:钩子函数内部会执行的函数
// 每执行一次remove钩子函数,listeners减1
for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
// 执行用户传入的钩子函数
const removeHook = ch?.data?.hook?.remove;
if (isDef(removeHook)) {
// 如果用户传入了remove钩子函数,就执行remove钩子函数
// 需要用户手动调用rm函数
removeHook(ch, rm);
} else {
// 如果用户没有传入remove钩子函数,就直接调用rm函数
// 所有remove钩子函数执行完毕后,才会调用rm函数
rm();
}
} else if (ch.children) {
// DocumentFragment节点
invokeDestroyHook(ch);
removeVnodes(
parentElm,
ch.children as VNode[],
0,
ch.children.length - 1
);
} else {
// 文本节点
api.removeChild(parentElm, ch.elm!);
}
}
}
}
删除元素的操作需要在所有的模块的remove
钩子函数执行完毕之后进行。这里使用到了高阶函数来返回真正删除DOM
元素的函数。
// remove钩子函数个数
listeners = cbs.remove.length + 1;
// 删除DOM元素的方法
rm = createRmCb(ch.elm!, listeners);
// 执行模块中的remove钩子函数
// remove钩子函数参数一:vnode
// remove钩子函数参数二:钩子函数内部会执行的函数
// 每执行一次remove钩子函数,listeners减1
for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
// 执行用户传入的钩子函数
const removeHook = ch?.data?.hook?.remove;
if (isDef(removeHook)) {
// 如果用户传入了remove钩子函数,就执行remove钩子函数
// 需要用户手动调用rm函数
removeHook(ch, rm);
} else {
// 如果用户没有传入remove钩子函数,就直接调用rm函数
// 所有remove钩子函数执行完毕后,才会调用rm函数
rm();
}
createRmCb()
方法
function createRmCb(childElm: Node, listeners: number) {
// 高阶函数
// 使用高阶函数的目的是对传进来的两个参数进行缓存
return function rmCb() {
// 先让listeners减1
// 当所有的remove钩子函数都执行完毕才会执行删除节点的操作
if (--listeners === 0) {
const parent = api.parentNode(childElm) as Node;
api.removeChild(parent, childElm);
}
};
}
这个高阶函数的定义和用法比较难理解,通过查看styleModule
模块源码,发现这个模块支持用户给元素设定一个删除时的过渡效果,等所有元素的过渡效果结束之后再调用rm()
方法,相当于是一个异步调用。
(elm as Element).addEventListener(
"transitionend",
function (ev: TransitionEvent) {
if (ev.target === elm) --amount;
if (amount === 0) rm();
}
);
并且只有styleModule
模块是支持用户自己定义remove
属性的:
export const styleModule: Module = {
pre: forceReflow,
create: updateStyle,
update: updateStyle,
destroy: applyDestroyStyle,
remove: applyRemoveStyle,
};
applyRemoveStyle()
方法内部,用户如果没有配置remove
属性,就会直接执行rm()
,如果配置了remove
属性,就会给元素添加transitionend
事件监听,异步调用rm()
。
也就是说cbs.remove
里面最多只有一个函数,就是applyRemoveStyle
,执行模块的remove
钩子函数就是执行applyRemoveStyle()
方法。
回到rm()
,假设给元素设置了删除时的过渡效果需要耗时3s,执行过程如下:
3、patchVnode(oldVnode,vnode,insertedVnodeQueue)函数
当oldVnode
和vnode
是同一节点的时候,需要调用patchVnode()
函数,对比两个节点的差异,进行节点替换。patchVnode()
函数内部大致分为三个阶段:
🦋 阶段一:执行prepatch
钩子函数和update
钩子函数
🦋 阶段二:对比差异,更新DOM
🦋 阶段三:执行postpatch
钩子函数。此时DOM
已经更新完毕,postpatch
钩子函数类似于vue
中的updated
钩子函数。
其中阶段二是核心的对比差异、更新DOM
的操作。我们先看一下vnode
的接口定义:
export interface VNode {
sel: string | undefined; // 选择器
data: VNodeData | undefined; // 配置对象,定义钩子函数等
children: Array<VNode | string> | undefined; // 子元素
elm: Node | undefined; // 对应的真实DOM
text: string | undefined; // 文本节点
key: Key | undefined; // 唯一标识
}
其中children
属性和text
属性是互斥的,在对比差异时,首先要知道新旧vnode
中存的是文本节点还是子节点,其内部存在很多的if
条件判断,大致执行流程如下:
其中,比较复杂的操作是当
newVnode
和oldVnode
都存在子节点时,执行的updateChildren()
函数。还用到了一个addVnodes()
函数,给一个DOM
元素添加子节点。 在上面的思维导图中,当oldVnode
和newVnode
都没有文本并且都没有子节点的时候就不处理了,那思考一下,如果新旧节点都是img
标签,修改的仅仅是src
属性,会如何进行处理? 答案是:给img
设置标签需要用到attributesModule
,经过断点调试发现,attributesModule
这个模块的update
钩子函数中会更新元素的属性,所以只更新src
属性,在update
钩子函数中就完成了,并且不会创建新元素。
const vnode = h("img", {
attrs: {
src: "https://is1-ssl.mzstatic.com/image/thumb/Purple126/v4/62/59/89/62598941-0360-2d04-7c79-327e3f611cfd/icon.png/460x0w.webp"
}
})
// 使用patch函数对比差异
const img = document.querySelector('img')
patch(img, vnode)
patchVnode()
function patchVnode(
oldVnode: VNode,
vnode: VNode,
insertedVnodeQueue: VNodeQueue
) {
// 第一个过程:触发prepacth钩子函数、update钩子函数
const hook = vnode.data?.hook;
// 执行用户传入的钩子函数,不管有没有差异都会执行
hook?.prepatch?.(oldVnode, vnode);
const elm = (vnode.elm = oldVnode.elm)!;
// 如果两个节点相同直接返回
if (oldVnode === vnode) return;
if (
vnode.data !== undefined ||
(isDef(vnode.text) && vnode.text !== oldVnode.text)
) {
// 如果vnode.data属性不存在,则初始化一个空对象
vnode.data ??= {};
// 如果oldVnode.data属性不存在,则初始化一个空对象
oldVnode.data ??= {};
// 先执行模块的update钩子函数,再执行用户传入的update钩子函数
// 用户的修改会覆盖模块的修改
for (let i = 0; i < cbs.update.length; ++i)
cbs.update[i](oldVnode, vnode);
vnode.data?.hook?.update?.(oldVnode, vnode);
}
// 第二个过程:真正对比新旧vnode差异的地方
const oldCh = oldVnode.children as VNode[];
const ch = vnode.children as VNode[];
if (isUndef(vnode.text)) {
// 如果新节点的text属性不存在,就说明新节点是一个标签节点
if (isDef(oldCh) && isDef(ch)) {
// 如果老节点的children属性和新节点的children属性都存在
// 并且不相同,则调用updateChildren函数对比子节点的差异
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
} else if (isDef(ch)) {
// 如果新节点的children属性存在,老节点的text属性存在
// 则清空老节点的text属性,将新节点的children添加到DOM中
if (isDef(oldVnode.text)) api.setTextContent(elm, "");
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 如果新节点的children属性不存在,老节点的children属性存在
// 则移除DOM中的老节点的children
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
// 如果老节点有text属性,清空老节点的text属性
api.setTextContent(elm, "");
}
} else if (oldVnode.text !== vnode.text) {
// 如果新节点的text属性存在,并且和旧节点的text属性不同
if (isDef(oldCh)) {
// 如果老节点有子节点,先删除子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
// 设置vnode对应的DOM元素的文本内容
// DOM元素没有重新创建,仅仅更新了DOM元素的文本内容
api.setTextContent(elm, vnode.text!);
}
// 第三个过程:触发postpatch钩子函数
// DOM已经更新完毕,类似于vue中的updated钩子函数
hook?.postpatch?.(oldVnode, vnode);
}
① updateChildren()函数
在新旧vnode
的子节点都存在并且新旧vnode
的子节点不相同的时候会调用updateChildren()
方法对比所有子节点的差异并更新DOM
。在页面中如果存在大量的DOM
操作会耗费很多的性能,diff
算法就是不会在数据变化的同时立马更新DOM
,而是创建虚拟DOM
进行差异对比,对比出新旧节点中的不同之处,只修改发生了改变的地方,从而减少反复销毁和创建DOM
元素。
🐳 diff算法
diff
算法是一种用于比较两个文本或数据集的差异的算法。它可以找出在两个文本中哪些部分发生了变化,类似于排序。
SnabbDOM
中的diff
算法,是对两个新旧DOM
树的每一层进行对比,由于DOM
操作很少会跨级别操作,比如把子节点移动为兄弟节点,所以SnabbDOM
中的diff
算法只进行同一层级的DOM
节点的对比,在现存的DOM
列表中查找有没有和新节点sel
和key
属性都相同的节点,如果能找到就复用已经存在的DOM
节点,改变该节点的内容、属性、位置等,不需要重新销毁或创建DOM
。
diff
算法负责对两个节点数组进行差异对比,其中应用到了“首尾指针法”的思想。 扩展
首尾指针法是一种常用的算法技巧,通常用于在数组或链表中查找满足某种条件的连续子序列。
该方法使用两个指针,一个指向序列的起始位置,另一个指向序列的结束位置。通过不断调整这两个指针的位置,可以实现对序列的遍历和搜索。
diff
算法一开始会将指针分别指向新旧节点数组的首尾节点上,不断移动指针位置,依次进行节点之间的对比。对比的情况有以下四种:
🌼 oldStartVnode
/ newStartVnode
(旧开始节点 / 新开始节点)
🌼 oldEndVnode
/ newEndVnode
(旧结束节点 /新结束节点)
🌼 oldStartVnode
/ newEndVnode
(旧开始节点/新结束节点)
🌼 oldEndVnode
/ newStartVnode
(日结束节点 /新开始节点)
对比的过程如下:
① 如下图所示,刚开始进行比较的时候,会进行旧开始节点和新开始节点之间的比较,如果新旧开始节点是 sameVnode
(key
和 sel
相同)
🎤 调用patchVnode()
对比和更新节点
🎤 把旧开始和新开始索引往后移动 oldstartldxt+
/ newstartldx++
,以2号节点为新旧起始节点进行对比
② 如果起始新旧节点不是同一节点,则会进行旧结束节点 /新结束节点的对比,类似于第一种情况。当旧结束节点 /新结束节点也不是同一个节点的时候,会进行第三个阶段的对比
③ 对比旧开始节点/新结束节点
在这个阶段,会首先进行
old1
号和new6
号的对比,如果这两个节点是同一节点:
🎤 会调用patchVnode()
更新old1
号节点,并且将old1
号节点对应的DOM
元素移动到oldVnode
列表的最后,相当于把old1
号元素移动位置并且内容可能更新,此时不需要重新创建DOM
。
🎤 将oldStartIdx++
,newEndIdx--
,下一轮对比会首先进行old2
号和new1
号的对比
如果旧开始节点/新结束节点不是sameVnode
,会进行第四阶段的比较
④ 旧结束节点 /新开始节点
进行
old6
号和new1
号的比较,如果这两个节点是sameVnode
:
🎤 调用 patchVnode()
对比和更新节点
🎤 把oldEndVnode
对应的 DOM
元素,移动到oldStartIdx
对应的DOM
元素的前边,更新索引,oldEndIdx--
,newStartIdx++
;下一轮对比会首先进行old1
号和new2
号的对比
⑤ 如果上述四种情况都不满足,会在
oldVnode
数组中找和new1
号key
值相同的旧节点:
🎤 假设找到old3
号和new1
号的key
值相同,并且经过判断它们的sel
属性也相同,说明old3
号和new1
号是相同的节点,此时会将old3
号赋值给emlToMove
这个变量,并且将old3
号节点对应的DOM
元素移动到oldStartIdx
对应的DOM
元素的前边。
🎤 如果没有找到和new1
号key
值和sel
值都相同的就节点,那么要根据new1
号虚拟节点调用createElm()
方法创建一个新的DOM
元素,并插入到DOM
树中oldStartVnode
对应的DOM
元素的前边。
🎤 newStartIdx++
⑥ 循环结束的情况
判断循环执行的条件是
oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx
。首先解释一下这个循环结束的条件。假设新旧节点列表数量和元素都相同,对比差异的循环会依次向后进行,当进行到最末尾元素的对比时,oldStartIdx == oldEndIdx && newStartIdx == newEndIdx
,下一次循环就需要跳出来。
如果新旧节点列表数量不同,结束循环后就会出现以下两种情况:
🎤
newVnode
有剩余节点,即newStartIdx<=newEndIdx
。此时就需要对于剩下的new4
号和new5
号创建新DOM
节点,并且插入到真实DOM
元素列表的末尾。
如果剩余的不是结尾的节点,会找到索引为
newEndIdx+1
的新节点对应的旧节点(即old5
号),把new3
号和new4
号插入到目标节点的前边。代码中最后会调用原生的insertBefore(parentElm,child)
方法,只不过如果剩下的是new4
号和new5
号,目标节点会是null
,查看mdn
发现insertBefore
这个方法的第二个参数为null
时,会把要插入的元素插入到parentElm
的结尾,正好符合需求。
🎤
oldVnode
有剩余节点,即oldStartIdx <= oldEndIdx
,需要从父节点中删除
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
updateChildren()
// parentElm: 父节点
// oldCh: 旧的子节点
// newCh: 新的子节点
// insertedVnodeQueue: 存储插入的具有insert钩子函数的vnode
function updateChildren(
parentElm: Node,
oldCh: VNode[],
newCh: VNode[],
insertedVnodeQueue: VNodeQueue
) {
// 定义所有变量
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
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) {
// 如果旧的开始节点是null,oldStartVnode就指向下一个节点
oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
} else if (oldEndVnode == null) {
// 如果旧的结束节点是null,oldEndVnode就指向上一个节点
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null) {
// 如果新的开始节点是null,newStartVnode就指向下一个节点
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null) {
// 如果新的结束节点是null,newEndVnode就指向上一个节点
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 如果旧的开始节点和新的开始节点是同一个节点
// 就对旧的开始节点和新的开始节点进行patchVnode,不会重新创建DOM元素,会重用旧的开始节点dom元素
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
// 旧的开始节点和新的开始节点都指向下一个节点
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 如果旧的结束节点和新的结束节点是同一个节点
// 就对旧的结束节点和新的结束节点进行patchVnode,不会重新创建DOM元素
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
// 旧的结束节点和新的结束节点都指向上一个节点
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 如果旧的开始节点和新的结束节点是同一个节点
// Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
// 将oldStartVnode对应的dom元素向后移动,移动到oldEndVnode对应的dom元素的后边,位置和newVnode数组保持一致
api.insertBefore(
parentElm,
oldStartVnode.elm!,
api.nextSibling(oldEndVnode.elm!)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 如果旧的结束节点和新的开始节点是同一个节点
// Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
// 将oldEndVnode对应的dom元素向前移动到oldEndVnode对应的dom元素前面,位置与newVnode数组保持一致
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// oldKeyToIdx:存储旧的子节点的key和索引的映射
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
// 使用新节点的key属性,找到旧节点中与之相同的节点的索引
// 如果是新插入的节点,可能找不到对应的旧节点,此时idxInOld为undefined
idxInOld = oldKeyToIdx[newStartVnode.key as string];
if (isUndef(idxInOld)) {
// New element
// 如果idxInOld为undefined,说明是新插入的节点
// 就创建新的节点,并插入到旧的开始节点之前
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm!
);
} else {
// 找到了对应的旧节点
elmToMove = oldCh[idxInOld];
if (elmToMove.sel !== newStartVnode.sel) {
// 如果找到的旧节点的选择器和新的开始节点的选择器不同
// 说明是不同类型的节点,就创建新的节点,并插入到旧的开始节点之前
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm!
);
} else {
// 如果找到的旧节点的选择器和新的开始节点的选择器相同
// 就对旧的开始节点和新的开始节点进行patchVnode,不会重新创建DOM元素
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
// 把旧的开始节点设置为undefined,表示已经处理过了
oldCh[idxInOld] = undefined as any;
// 把找到的旧节点插入到旧的开始节点之前
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
}
}
newStartVnode = newCh[++newStartIdx];
}
}
// 循环结束后,有可能是oldCh或者newCh还有剩余的节点没有处理
if (newStartIdx <= newEndIdx) {
// 如果是newCh还有剩余的节点没有处理
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
before,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
}
if (oldStartIdx <= oldEndIdx) {
// 如果是oldCh还有剩余的节点没有处理
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
② addVnodes()函数
addVnodes()
函数用于将节点放到旧节点的后面。
// 参数:
// parentElm: 父节点
// before: 插入到哪个节点之前
// vnodes: 要插入的节点
// startIdx: 开始索引
// endIdx: 结束索引 startIdx和endIdx之间的节点都会被插入到DOM树中
// insertedVnodeQueue: 存储插入的具有insert钩子函数的vnode
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);
}
}
}
(四)key值的意义
1、设置key值的效果
首先我们先复习一下sameVnode()
函数的代码:
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
// key:节点的唯一标识
const isSameKey = vnode1.key === vnode2.key;
const isSameIs = vnode1.data?.is === vnode2.data?.is;
const isSameSel = vnode1.sel === vnode2.sel;
const isSameTextOrFragment =
!vnode1.sel && vnode1.sel === vnode2.sel
? typeof vnode1.text === typeof vnode2.text
: true;
return isSameSel && isSameKey && isSameIs && isSameTextOrFragment;
}
这里不考虑太过复杂的情况,只考虑sel
和key
属性。如果不给虚拟DOM
设置key
属性,那么只要是选择器相同就会被认为是同一个DOM
元素,设置了key
属性之后只有sel
和key
都相同才会被认为是同一个DOM
元素。下面我们通过调试,观察一下虚拟DOM
不设置key
和设置key
在进行差异对比的时候会有什么区别。 第一段代码不设置key
:
const vnode1 = h('ul', [
h('li','苹果'),
h('li', '香蕉'),
])
const vnode2 = h('ul', [
h('li', '香蕉'),
h('li','苹果'),
])
patch(document.querySelector('#app'), vnode1)
setTimeout(() => {
patch(vnode1, vnode2)
}, 1000)
vnode1
和vnode2
中的第一个子节点由于sel
属性相同,并且key
属性都是undefined
,也是相同的,所以会被认为是同一个节点,此时是旧开始节点/新开始节点,如下图所示,oldStartVnode
是vnode1
中的h('li','苹果')
;newStartVnode
是vnode2
中的h('li','香蕉')
:
并且这两个节点都是文本节点,所以会更改该节点里面的文本:
第二段代码设置
key
:
const vnode1 = h('ul', [
h('li',{key:'apple'},'苹果'),
h('li',{key:'banana'}, '香蕉'),
])
const vnode2 = h('ul', [
h('li',{key:'banana'}, '香蕉'),
h('li',{key:'apple'},'苹果'),
])
patch(document.querySelector('#app'), vnode1)
setTimeout(() => {
patch(vnode1, vnode2)
}, 1000)
vnode1
中 h('li',{key:'apple'},'苹果')
和vnode2
中h('li',{key:'apple'},'苹果')
是同一节点,此时是旧开始节点/新结束节点的情形,此时会将旧开始节点对应的DOM
元素移动到旧结束节点对应的DOM
元素的后面,也就是会把h('li',{key:'apple'},'苹果')
移动到h('li',{key:'banana'}, '香蕉')
的后面
2、key值的必要性
不使用key
值的话,可以最大程度的复用已经存在的元素,但是会有一些隐患,看下面一段代码,
const vnode1 = h('ul', [
h('li',[
h('input',{attrs:{type:'checkbox',value:'香蕉'}}),
'香蕉'
])
,
h('li',[
h('input',{attrs:{type:'checkbox',value:'苹果'}}),
'苹果'
])
])
const vnode2 = h('ul', [
h('li',[
h('input',{attrs:{type:'checkbox',value:'苹果'}}),
'苹果'
])
,
h('li',[
h('input',{attrs:{type:'checkbox',value:'香蕉'}}),
'香蕉'
])
])
patch(document.querySelector('#app'), vnode1)
setTimeout(() => {
patch(vnode1, vnode2)
}, 3000)
子元素li
里面还有两个子节点,一个是checkbox
,一个是文本节点。由于没有给li
设置key,old1
号li
和new1
号li
就会被认为是同一个节点,然后会修改li1>input
中的value
属性和文本节点中的文本,但是如果当前checkbox
被选中,就会导致选中状态出现混乱,例如我们在patch(vnode1,vnode2)
之前选中香蕉:
等待
patch()
函数进行完毕就会变成
但是如果我们给
li1
设置key
:
const vnode1 = h('ul', [
h('li',{key:'banana'},[
h('input',{attrs:{type:'checkbox',value:'香蕉'}}),
'香蕉'
])
,
h('li',[
h('input',{attrs:{type:'checkbox',value:'苹果'}}),
'苹果'
])
])
const vnode2 = h('ul', [
h('li',[
h('input',{attrs:{type:'checkbox',value:'苹果'}}),
'苹果'
])
,
h('li',{key:'banana'},[
h('input',{attrs:{type:'checkbox',value:'香蕉'}}),
'香蕉'
])
])
就会将香蕉所在的li
移动位置,而不是只改变文本内容,选中状态就会保持正常。
四、总结 |
Snabbdom
是一个实现虚拟DOM
的库,阅读Snabbdom
的源码是为了为后续学习vue
的源码做铺垫。Snabbdom
中主要的函数有下面几个:
🌸 ① h()
函数:用于创建虚拟DOM
,其内部实现了重载,拥有四种传递参数的方法,h()
函数内部会进行参数判断,对参数进行处理,然后把参数列表传递给vnode()
函数,并且返回vnode()
函数的处理结果;
🌸 ② vnode()
函数:用于返回一个虚拟DOM
对象,虚拟DOM
对象的数据类型参考VNode
接口定义;
🌸 ③ init()
函数:参数列表有三个,一般只传递第一个参数,即模块列表,init()
函数会将模块中的钩子函数整合成一个对象数组,在合适的时机调用,最终返回一个patch()
函数,init()
函数是一个高阶函数,其内部定义的变量都可以在patch()
函数中使用,并且可以得到缓存,可以减少很多变量的重新定义;
🌸 ④ patch()
函数:用于差异对比以及更新DOM
,接收两个虚拟DOM
节点作为参数,如果参数一是真实DOM
也可以进行转换为虚拟DOM
。然后会判断两个节点是不是同一节点,如果不是同一节点直接删除重新添加,如果是同一节点就需要调用patchVnode()
函数进行节点的差异对比;
🌸 ⑤ patchVnode()
函数:用于同一节点差异对比以及更新DOM
,会判断新旧节点的text
属性和children
属性,根据这两个属性有没有值以及值是否相同进行不同的操作。其中最复杂的情况是新旧节点都有children
属性,并且children
属性不相同,此时就会调用updateChildren()
函数
🌸 ⑥ updateChildren()
函数:这个函数是Snabbdom
库中最复杂的函数,用于对新旧节点序列进行差异对比,查找相同的节点,其中使用到diff
算法,进行两个序列的差异对比。此处的diff
算法只进行同一层级节点的对比,找到新旧相同的节点时,调用patchVnode()
函数进行差异对比以及更新DOM
,如果是新增的节点还需要创建新的DOM
元素插入到DOM
树上,如果旧节点还有剩余节点也需要从DOM
树上删除。
大致的流程就是这个样子,其中还需要注意生命周期钩子函数的调用时机。还运用到了很多高阶函数的思想,在实际的编程中也很值得借鉴。以及patchVnode()
函数和updateChildren()
函数的相互调用来实现对于深层DOM
树的遍历思想也很interesting。另外还有使用ts
实现重载,支持多种参数类型,对参数进行处理再组装成一个标准形式进行后续的使用,以及diff
算法等等,在工作中都可以借鉴这些变成思想。
转载自:https://juejin.cn/post/7256346521644777527