探究Diff 算法的执行过程

298 阅读7分钟

渲染真实DOM的开销很大,dom操作会引起浏览器的重排和重绘,也就是浏览器重新渲染。浏览器重新渲染是非常耗费性能的,因为要重新绘制整个页面。当数据变化后,尤其是大量的数据变化后,例如列表中的数据,如果直接操作dom的话,会让浏览器重新渲染整个列表。虚拟DOM中diff的核心是当数据变化后不直接操作dom,而是用javascript对象来描述真实DOM。当数据变化后,会先比较javascript对象是否发生变化,找到所有变化后的位置,最后只是最小化的更新变化的位置,从而提升性能。

Virtual DOM

Virtual DOM(虚拟 DOM),是由普通的 JS 对象来描述 DOM 对象。

  • 虚拟 DOM 可以维护程序的状态,跟踪上一次的状态
  • 通过比较前后两次状态差异更新真实 DOM

虚拟 DOM 的作用

  • 维护视图和状态的关系,可以保存视图的状态
  • 复杂视图情况下提升渲染性能
  • 跨平台
    • 浏览器平台渲染DOM
    • 服务端渲染 SSR(Nuxt.js/Next.js)
    • 原生应用(Weex/React Native)
    • 小程序(mpvue/uni-app)等

虚拟 DOM 库

Snabbdom

  • Vue.js 2.x 内部使用的虚拟 DOM 就是改造的 Snabbdom
  • 大约 200 SLOC (single line of code)
  • 通过模块可扩展
  • 源码使用 TypeScript 开发
  • 最快的 Virtual DOM 之一

Snabbdom

Snabbdom基本使用

  • 创建项目
  1. 安装 parcel

image.png

  1. 配置 scripts

image.png

  • 创建index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>snabbdom-demo</title>
</head>
<body>
  <div id='app'></div>
  <script src="./src/basicusage.js"></script>
</body>
</html>
  • 导入 Snabbdom
  1. 安装 Snabbdom

    npm intall snabbdom

  2. 导入 Snabbdom

    Snabbdom 的两个核心函数

    • init 和 h()
      • init() 是一个高阶函数,返回 patch()
      • h() 返回虚拟节点 VNode 案例1:
// basicusage.js

// parcel/webpack 4 不支持 package.json 中的 exports 字段
import { init } from 'snabbdom/build/init'
import { h } from 'snabbdom/build/h'

const patch = init([])

// 第一个参数:标签+选择器
// 第二个参数:如果是字符串就是标签中的文本内容
let vnode = h('div#container.cls', 'Hello World')
let app = document.querySelector('#app')
// 第一个参数:旧的 VNode,可以是 DOM 元素
// 第二个参数:新的 VNode
// 返回新的 VNode
let oldVnode = patch(app, vnode)
patch(oldVnode, vnode)

案例2:

// basicusage.js

import { init } from 'snabbdom/build/init'
import { h } from 'snabbdom/build/h'

const patch = init([])
// 使用h()去创建一个div,可以创建div里面的子元素
let vnode = h('div#container', [
  h('h1', 'Hello Snabbdom'),
  h('p', '这是一个p')
])

let app = document.querySelector('#app')
let oldVnode = patch(app, vnode)

setTimeout(() => {
  // vnode = h('div#container', [
  //   h('h1', 'Hello World'),
  //   h('p', 'Hello P')
  // ])
  // patch(oldVnode, vnode)

  // 清除div中的内容
  patch(oldVnode, h('!'))
}, 2000);
  • Snabbdom 中的模块 模块的作用
  1. Snabbdom 的核心库并不能处理 DOM 元素的属性/样式/事件等,可以通过注册 Snabbdom 默认提供的模块来实现
  2. Snabbdom 中的模块可以用来扩展 Snabbdom的功能
  3. Snabbdom 中的模块的实现是通过注册全局的钩子函数来实现的 官方提供的模块
  4. attributes
  5. props
  6. dataset
  7. class
  8. style
  9. eventlisteners 模块使用步骤
  10. 导入需要的模块
  11. init() 中注册模块
  12. h() 函数的第二个参数处使用模块
// modules.js

import { init } from 'snabbdom/build/init'
import { h } from 'snabbdom/build/h'

// 1. 导入模块
import { styleModule } from 'snabbdom/build/modules/style'
import { eventListenersModule } from 'snabbdom/build/modules/eventlisteners'

// 2. 注册模块
const patch = init([
  styleModule,
  eventListenersModule
])

// 3. 使用h() 函数的第二个参数传入模块中使用的数据(对象)
let vnode = h('div', [
  h('h1', { style: { backgroundColor: 'red' } }, 'Hello World'),
  h('p', { on: { click: eventHandler } }, 'Hello P')
])

function eventHandler () {
  console.log('别点我,疼')
}

let app = document.querySelector('#app')
patch(app, vnode)

Snabbdom 源码解析

Snabbdom 的核心

  • init() 设置模块,创建 patch() 函数
  • 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM
  • patch() 比较新旧两个 Vnode
  • 把变化的内容更新到真实 DOM 树

Snabbdom源码地址 github.com/snabbdom/sn…

diff算法

diff算法的本质就是查找出两个对象之间的差异,目的是尽可能多的复用节点。这个对象对应vue中的虚拟DOM,它是使用javascript对象来表示页面上的dom结构。虚拟DOM是将真实DOM数据抽离出来,用对象的形式模拟树形结构,diff算法比较的就是虚拟DOM。

diff算法是对操作前后的dom树的同一节点进行比较,一层一层的对比,然后插入真实的dom中进行渲染。它会给循环的列表中添加唯一标识,因为vue组件是高度复用的,增加了key可以识别组件的唯一性,这样diff算法就可以正确的识别此节点,并找到正确的位置插入新的节点。

Diff 算法的执行过程

diff 是找同级别的子节点依次比较,然后再找下一级别的节点比较。

  • 在进行同级别节点比较的时候,首先会对新旧节点数组的 开始 和 结尾 节点设置标记索引,遍历的过程中移动索引;

索引标记为:

oldStartIdx/ newStartIdx (旧开始节点索引 / 新开始节点索引)
oldEndIdx/ newEndIdx (旧结束节点索引 / 新结束节点索引)

对应的节点为:

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

  • 开始 和 结尾 点的比较依次按下面步骤进行

    • 如果 oldStartVnode 和 newStartVnode 是 sameVnode (key 和 sel 相同),调用 patchVnode() 对比和更新节点,把旧开始和新开始索引往后移动, oldStartIdx++ / newStartIdx++,进入下一个循环。若不同,则进入下一个判断。
    • 如果 oldEndVnode 和 newEndVnode 是 sameVnode (key 和 sel 相同),调用 patchVnode() 对比和更新节点,把旧结尾和新结尾索引往前移动 oldEndIdx-- / newEndIdx--,进入下一个循环;若不同,则进入下一个判断。
    • 如果 oldStartVnode 和 newEndVnode 是 sameVnode (key 和 sel 相同),即 旧开始节点 / 新结尾节点 相同,调用 patchVnode() 对比和更新节点,把 oldStartVnode 对应的 DOM 元素移动到当前标记的 oldEndVnode 对应的 DOM 元素的后面,然后更新索引 oldStartIdx++ / newEndIdx--,进入下一个循环;不同,则进入下一个判断。
    • 如果 oldEndVnode 和 newStartVnode 是 sameVnode (key 和 sel 相同),即 旧结束节点 / 新开始节点 相同,调用 patchVnode() 对比和更新节点,把 oldEndVnode 对应的 DOM 元素移动到当前标记的 oldStartVnode 对应的 DOM 元素的前面,然后更新索引 oldEndIdx-- / newStartIdx++,进入下一个循环;不同,则进入下一步。
  • 如果首尾标记节点对比都不通过,则进入如下步骤:

    • 使用当前标记的 newStartVnode 的 key 在 旧节点 数组中找相同节点。
    • 如果没有找到,说明 newStartVnode 是新增节点,则用 newStartVnode 创建新的 DOM 元素,插入到当前标记的 oldStartVnode 对应的 DOM 元素之前,newStartIdx++ ,进入下一个循环。
    • 如果找到了,则判断 新节点 和找到的 旧节点 的 sel 选择器是否相同。
      • 如果相同,调用 patchVnode() 对比和更新节点,把找到的 旧节点 对应的 DOM 元素,移动到当前标记的 oldStartVnode 对应的 DOM 元素的前面, newStartIdx++ ,进入下一个循环。
      • 如果不相同,说明节点被修改了,则用 newStartVnode 创建新的 DOM 元素,插入到当前标记的 oldStartVnode 对应的 DOM 元素之前,newStartIdx++ ,进入下一个循环。
  • 同级对比循环结束时会有两种情况:旧节点的所有子节点先遍历完(oldStartIdx > oldEndIdx)、新节点的所有子节点先遍历完 (newStartIdx > newEndIdx),此时需要对新旧节点数组进行后续处理:

    • 如果旧节点的数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余且是新创建的 Vnode,则用这些剩余节点创建新的 DOM元素,并批量插入到当前所标记的 newEndVnode 之后的 Vnode(即标识索引为 newEndIdx+1)所对应的 DOM 元素之前,若不存在该 Vnode,则相当于插入到末尾。
    • 如果新节点的数组先遍历完(newStartIdx > newEndIdx),说明旧节点中有多余,这直接把多余节点批量删除。