Vue进阶之Virtual DOM(虚拟DOM) 实现原理
Virtual DOM(虚拟 DOM),是由普通的 JS 对象来描述 DOM 对象,因为不是真实的 DOM 对象,所以叫 Virtual DOM。
背景
- 手动操作 DOM 比较麻烦,还需要考虑浏览器兼容性问题,虽然有jQuery 等库简化 DOM 操作,但是随着项目的复杂 DOM 操作复杂提升;
- 为了简化 DOM 的复杂操作于是出现了各种 MVVM 框架,MVVM 框架解决了视图和状态的同步问题 ;
- 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是Virtual DOM 出现了;
- Virtual DOM 的好处是当状态改变时不需要立即更新 DOM,只需要创建一个虚拟树来描述DOM, Virtual DOM 内部将弄清楚如何有效(diff)的更新;
- DOM 参考 virtual-dom 的描述:
- 虚拟 DOM 可以维护程序的状态,跟踪上一次的状态
- 通过比较前后两次状态的差异更新真实 DOM
作用好处
- 维护视图和状态的关系
- 复杂视图情况下提升渲染性能
- 除了渲染 DOM 以外,还可以实现 SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序 (mpvue/uni-app)等
Snabbdom
Vue 2.x 内部使用的 Virtual DOM 就是改造的 Snabbdom的,大约200行代码,最快的 Virtual DOM 之一
基本使用
Hello world案例
主要方法:
- init构建patch对比虚拟DOM并更新真是DOM
- h函数,创建虚拟DOM
import { init } from 'snabbdom/build/package/init.js'
import { h } from 'snabbdom/build/package/h.js'
// 引入这里新版snabbdom@1.0.1导出到了package.json的exports中,parcel与webpack5以下均不支持,可以先将引用变为全路径
// 参数:数组,模块
// 返回值:patch函数,作用对比两个vnode的差异更新到真实DOM
let patch = init([])
// h函数
// 第一个参数:标签+选择器
// 第二个参数:如果是字符串的话就是标签中的内容
let vnode = h('dev#container', 'Hello World')
let app = document.querySelector('#app')
let oldVnode = patch(app, vnode)
let newvnode = h('dev#ids.cla', [
h('h1.classh1', '我是标题'),
h('p.list', '我是文案')
])
patch(oldVnode, newvnode)
setTimeout(() => {
// 清空元素用一个注释标签代替, h('!')
patch(newvnode, h('!'))
},2000)
模块类似插件
Snabbdom 的核心库并不能处理元素的属性/样式/事件等,如果需要处理的话,可以使用模块
常用模块,官方提供了 6 个模块
- attributes
- 设置 DOM 元素的属性,使用 setAttribute ()
- 处理布尔类型的属性
- props
- 和 attributes 模块相似,设置 DOM 元素的属性 element[attr] = value
- 不处理布尔类型的属性
- class
- 切换类样式
- 注意:给元素设置类样式是通过 sel 选择器
- dataset
- 设置 data-* 的自定义属性 eventlisteners
- 注册和移除事件
- style
- 设置行内样式,支持动画
- delayed/remove/destroy
import { init } from 'snabbdom/build/package/init.js'
import { h } from 'snabbdom/build/package/h.js'
// 模块导入
import { styleModule } from 'snabbdom/build/package/modules/style'
import { eventListenersModule } from 'snabbdom/build/package/modules/eventlisteners'
// 模块注册
let patch = init([
styleModule,
eventListenersModule
])
阅读snabbdom源码的大致流程
- 先宏观了解,看大致目录和结构;
- 带着目标看源码,明确核心功能;
- 看源码的过程要不求甚解,跳过分支功能;
- 调试
- 参考资料
Snabbdom 的核心 - 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM init()
- 设置模块,创建 patch()
- patch() 比较新旧两个 VNode
- 把变化的内容更新到真实 DOM 树上
分割
小技巧:vscode
1. F12 跳转到定义处
2. Ctrl+鼠标左键 跳转到定义处
3. Alt+← 跳回之前地方
4. command+k+0 折叠全部代码
5. command+k+j 展开全部代码
H函数
- Vue中的h函数就是Snabbdom 中的 h函数的增强版
- h() 函数最早见于 hyperscript,使用 JavaScript 创建超文本
- Snabbdom 中的 h() 函数不是用来创建超文本,而是创建 VNode
函数重载
- 参数个数和类型不同的函数
- JavaScript 中没有重载的概念
- TypeScript 中有重载,不过重载的实现还是通过代码调整参数
源码h.ts
// h 函数的重载
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
// 处理参数,实现重载的机制
if (c !== undefined) {
// 处理三个参数的情况
// sel、data、children/text
if (b !== null) {
data = b
}
if (is.array(c)) {
children = c
} else if (is.primitive(c)) {
// 如果 c 是字符串或者数字
text = c
} else if (c && c.sel) {
// 如果 c 是 VNode
children = [c]
}
} else if (b !== undefined && b !== null) {
// 处理两个参数的情况
// 如果 b 是数组
if (is.array(b)) {
children = b
} else if (is.primitive(b)) {
// 如果 b 是字符串或者数字
text = b
} else if (b && b.sel) {
// 如果 b 是 VNode
children = [b]
} else { data = b }
}
if (children !== undefined) {
// 处理 children 中的原始值(string/number)
for (i = 0; i < children.length; ++i) {
// 如果 child 是 string/number,创建文本节点
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] === '#')
) {
// 如果是 svg,添加命名空间
addNS(data, children, sel)
}
// 返回 VNode
return vnode(sel, data, children, text, undefined)
};
源码vnode.ts
// 接口
export interface VNode {
// 选择器
sel: string | undefined
// 节点数据:属性/样式/事件等
data: VNodeData | undefined
// 子节点,和 text 只能互斥
children: Array<VNode | string> | undefined
// 记录 vnode 对应的真实 DOM
elm: Node | undefined
// 节点中的内容,和 children 只能互斥
text: string | undefined
// 优化用
key: Key | undefined
}
export function vnode (sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined): VNode {
const key = data === undefined ? undefined : data.key
return { sel, data, children, text, elm, key }
}
snabbdom的核心(init返回的patch函数)
- patch(oldVnode, newVnode)
- 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
- 对比新旧 VNode 是否相同节点(节点的 key 和 sel 相同)
- 如果不是相同节点,删除之前的内容,重新渲染
- 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新文本内容
- 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法
- diff 过程只进行同层级比较(因为DOM大多为同级操作,很少有将子节点变为父节点或者父节点变为子节点的)
init
- 功能:init(modules, domApi),返回 patch() 函数(高阶函数)
- 为什么要使用高阶函数?
- 因为 patch() 函数再外部会调用多次,每次调用依赖一些参数,比如: modules/domApi/cbs
- 通过高阶函数让 init() 内部形成闭包,返回的 patch() 可以访问到modules/domApi/cbs,而不需要重新创建
- init() 在返回 patch() 之前,首先收集了所有模块中的钩子函数存储到 cbs 对象中
- 源码位置:src/snabbdom.ts
patch(oldVnode, vnode)对比新旧节点
- 功能:
- 传入新旧 VNode,对比差异,把差异渲染到 DOM
- 返回新的 VNode,作为下一次 patch() 的 oldVnode
- 执行过程:
- 首先执行模块中的钩子函数 pre
- 如果 oldVnode 和 vnode 相同(key 和 sel 相同)
- 调用 patchVnode(),找节点的差异并更新 DOM
- 不同的vnode直接创建新节点删除旧节点
- 如果 oldVnode 是 DOM 元素
- 把 DOM 元素转换成 oldVnode
- 调用 createElm() 把 vnode 转换为真实 DOM,记录到 vnode.elm
- 把刚创建的 DOM 元素插入到 parent 中
- 移除老节点
- 触发用户设置的 create 钩子函数
- 源码位置:src/snabbdom.ts
createElm() 创建真实DOM
- 功能:
- createElm(vnode, insertedVnodeQueue),返回创建的 DOM 元素
- 创建 vnode 对应的 DOM 元素
- 执行过程:
- 首先触发用户设置的 init 钩子函数
- 如果选择器是“!”,创建注释节点
- 如果选择器为空,创建文本节点
- 如果选择器不为空
- 解析选择器,设置标签的 id 和 class 属性
- 执行模块的 create 钩子函数
- 如果 vnode 有 children,创建子 vnode 对应的 DOM,追加到 DOM 树
- 如果 vnode 的 text 值是 string/number,创建文本节点并追击到 DOM 树
- 执行用户设置的 create 钩子函数
- 如果有用户设置的 insert 钩子函数,把 vnode 添加到队列中
- 源码位置:src/snabbdom.ts
insertedVnodeQueue,插入虚拟节点队列,储蓄虚拟节点中的insert钩子函数,在虚拟节点创建为真实DOM后添加到队列,在真实DOM插入文档后循环调用队列中节点的insert钩子函数。
patchVnode()对比相同节点差异
- 功能:
- patchVnode(oldVnode, vnode, insertedVnodeQueue)
- 对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM
- 执行过程:
- 首先执行用户设置的 prepatch 钩子函数
- 执行 create 钩子函数
- 首先执行模块的 create 钩子函数
- 然后执行用户设置的 create 钩子函数
- 开始对比如果 vnode.text 未定义
- 如果 oldVnode.children 和 vnode.children 都有值
- 调用 updateChildren() diff核心
- 使用 diff 算法对比子节点,更新子节点
- 如果 vnode.children 有值, oldVnode.children 无值
- 清空 DOM 元素
- 调用 addVnodes() ,批量添加子节点
- 如果 oldVnode.children 有值, vnode.children 无值
- 调用 removeVnodes() ,批量移除子节点
- 如果 oldVnode.text 有值
- 清空 DOM 元素的内容
- 如果 oldVnode.children 和 vnode.children 都有值
- 如果设置了 vnode.text 并且和和 oldVnode.text 不等
- 如果老节点有子节点,全部移除
- 设置 DOM 元素的 textContent 为 vnode.text
- 最后执行用户设置的 postpatch 钩子函数
- 源码位置:src/snabbdom.ts
updateChildren() 对比子节点
- 功能:
- diff 算法的核心,对比新旧节点的 children,更新 DOM
- 执行过程:
- 要对比两棵树的差异,我们可以取第一棵树的每一个节点依次和第二课树的每一个节点比 较,但是这样的时间复杂度为 O(n^3),复杂度太高
- 在DOM 操作的时候我们很少很少会把一个父节点移动/更新到某一个子节点
- 因此只需要找同级别的子节点依次比较,然后再找下一级别的节点比较,这样算法的时间复 杂度为 O(n)
- 在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置标记索引,遍 历的过程中移动索引
- 在对开始和结束节点比较的时候,总共有四种情况:
- oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
- oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
- oldStartVnode / oldEndVnode (旧开始节点 / 新结束节点)
- oldEndVnode / newStartVnode (旧结束节点 / 新开始节点)
- 开始节点和结束节点比较,这两种情况类似
- oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
- oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
- 如果 oldStartVnode 和 newStartVnode 是 sameVnode (key 和 sel 相同)
- 调用 patchVnode() 对比和更新节点
- 把旧开始和新开始索引往后移动 oldStartIdx++ / oldEndIdx++
- oldStartVnode / newEndVnode (旧开始节点 / 新结束节点) 相同
- 调用 patchVnode() 对比和更新节点
- 把 oldStartVnode 对应的 DOM 元素,移动到右边
- 更新索引
- oldEndVnode / newStartVnode (旧结束节点 / 新开始节点) 相同
- 调用 patchVnode() 对比和更新节点
- 把 oldEndVnode 对应的 DOM 元素,移动到左边
- 更新索引
- 如果不是以上四种情况
- 遍历新节点,使用 newStartNode 的 key 在老节点数组中找相同节点
- 如果没有找到,说明 newStartNode 是新节点
- 创建新节点对应的 DOM 元素,插入到 DOM 树中
- 如果找到了
- 判断新节点和找到的老节点的 sel 选择器是否相同
- 如果不相同,说明节点被修改了,重新创建对应的 DOM 元素,插入到 DOM 树中
- 如果相同,把 elmToMove 对应的 DOM 元素,移动到左边
- 循环结束
- 当老节点的所有子节点先遍历完 (oldStartIdx > oldEndIdx),循环结束
- 新节点的所有子节点先遍历完 (newStartIdx > newEndIdx),循环结束
- 如果老节点的数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把剩余节点批量 插入到右边
- 如果新节点的数组先遍历完(newStartIdx > newEndIdx),说明老节点有剩余,把剩余节点批 量删除
- 源码位置:src/snabbdom.ts
带 key 优势
通过以上调试 updateChildren,我们发现不带 key 的情况需要进行两次 DOM 操作,带 key 的情况只 需要更新一次 DOM 操作(移动 DOM 项),所以带 key 的情况可以减少 DOM 的操作,如果 li 中的子项 比较多,更能体现出带 key 的优势。
总结diff算法逻辑
一、 使用patch函数对象新旧节点patch(oldVnode, vnode);
- 如果 oldVnode 和 vnode 相同(有相同的key 和 sel 相同(标签名.id.class名))开始对比使用patchVnode()寻找节点差异;
- 不同的话,直接创建新vnode节点删除旧节点;
二、处理相同的情况patchVnode(patchVnode(oldVnode, vnode, insertedVnodeQueue));
- 首先如果 vnode.text 未定义新节点不是文本节点
- 内部判断如果 oldVnode.children 和 vnode.children 都有值,都有子节点
- 调用 updateChildren() diff核心,使用 diff 算法对比子节点,更新子节点
- 如果 vnode.children 有值新节点有值, oldVnode.children 无值旧节点无值
- 清空 DOM 元素,调用 addVnodes() ,批量添加新节点的子节点
- 如果 oldVnode.children 有值旧节点有值, vnode.children 无值新节点无值
- 调用 removeVnodes() ,批量移除旧节点的子节点
- 如果 oldVnode.text 有值旧节点有文本,这时新节点无文本
- 直接清空旧 DOM 元素的内容
- 内部判断如果 oldVnode.children 和 vnode.children 都有值,都有子节点
- 如果设置了 vnode.text 新节点是文本节点并且和 oldVnode.text 不等和旧节点文本不一样
- 如果老节点有子节点,全部移除
- 直接设置 DOM 元素的 textContent 为 vnode.text,更新为新节点的文本内容
三、处理子节点都有的情况updateChildren() diff 算法的核心
-
进行同级比较,首先从新老节点数组的开始和结尾设置索引标记,遍历过程移动索引
-
对开始和结束节点比较的时候,总共有四种情况:
- oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
- oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
分别从开始为位置和结束位置,一一对比,有相同的就patchVnode()进行深层子节点对比,走相同流程,否则就是继续向下
- oldStartVnode / oldEndVnode (旧开始节点 / 新结束节点)
- oldEndVnode / newStartVnode (旧结束节点 / 新开始节点)
分别将旧开始节点与新结束节点交叉对比,有相同的就patchVnode()进行深层子节点对比,并且移动旧节点位置,对应新节点位置,省去创建节点的消耗
-
如果不是以上四种情况:
- 则将旧节点数组的key生成key => i(索引)的map地图,
- 然后从新节点开始节点,在map中查找是否有相同的节点
- 如果没有找到,则是新节点,插入当前旧开始节点之前
- 如果有相同的,判断新节点和找到的老节点的 sel 选择器是否相同;
- 如果不相同,还是新节点,插入当前旧开始节点之前
- 如果相同,调用patchVnode()进行深层子节点对比,并将这个旧节点,移动到前旧开始节点之前
-
如此知道循环结束,循环结束有两种情况
- 当老节点的所有子节点先遍历完 (oldStartIdx > oldEndIdx),循环结束
- 新节点的所有子节点先遍历完 (newStartIdx > newEndIdx),循环结束
-
循环结束后处理两种情况
- 如果旧节点的数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把剩余节点批量插入到右边最后
- 如果新节点的数组先遍历完(newStartIdx > newEndIdx),说明老节点有剩余,把剩余节点批量删除
-
完成最后
snabbdom对比过程中就会同时渲染DOM元素,这样可以让界面更快的展示,优化体验。