Vue进阶之Virtual DOM(虚拟DOM) 实现原理

11 篇文章 0 订阅

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 元素的内容
    • 如果设置了 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 元素的内容
  • 如果设置了 vnode.text 新节点是文本节点并且和 oldVnode.text 不等和旧节点文本不一样
    • 如果老节点有子节点,全部移除
    • 直接设置 DOM 元素的 textContent 为 vnode.text,更新为新节点的文本内容

三、处理子节点都有的情况updateChildren() diff 算法的核心

  • 进行同级比较,首先从新老节点数组的开始和结尾设置索引标记,遍历过程移动索引

  • 对开始和结束节点比较的时候,总共有四种情况:

    1. oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
    2. oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)

    分别从开始为位置和结束位置,一一对比,有相同的就patchVnode()进行深层子节点对比,走相同流程,否则就是继续向下

    1. oldStartVnode / oldEndVnode (旧开始节点 / 新结束节点)
    2. oldEndVnode / newStartVnode (旧结束节点 / 新开始节点)

    分别将旧开始节点与新结束节点交叉对比,有相同的就patchVnode()进行深层子节点对比,并且移动旧节点位置,对应新节点位置,省去创建节点的消耗

  • 如果不是以上四种情况:

    • 则将旧节点数组的key生成key => i(索引)的map地图,
    • 然后从新节点开始节点,在map中查找是否有相同的节点
    • 如果没有找到,则是新节点,插入当前旧开始节点之前
    • 如果有相同的,判断新节点和找到的老节点的 sel 选择器是否相同;
    • 如果不相同,还是新节点,插入当前旧开始节点之前
    • 如果相同,调用patchVnode()进行深层子节点对比,并将这个旧节点,移动到前旧开始节点之前
  • 如此知道循环结束,循环结束有两种情况

    • 当老节点的所有子节点先遍历完 (oldStartIdx > oldEndIdx),循环结束
    • 新节点的所有子节点先遍历完 (newStartIdx > newEndIdx),循环结束
  • 循环结束后处理两种情况

    • 如果旧节点的数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把剩余节点批量插入到右边最后
    • 如果新节点的数组先遍历完(newStartIdx > newEndIdx),说明老节点有剩余,把剩余节点批量删除
  • 完成最后

snabbdom对比过程中就会同时渲染DOM元素,这样可以让界面更快的展示,优化体验。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值