读Snabbdom源码理解

450 阅读9分钟

什么是虚拟dom

虚拟dom就是用普通的js对象来描述dom对象,因为不是真实的dom对象,所以叫virtual dom

为什么要使用虚拟dom

  • 手动操作虚拟dom比较麻烦,项目复杂程度越高,dom操作复杂度越高
  • 大多数时候,操作虚拟dom的开销比真实dom的开销小很多
  • 传统的视图操作我们使用的是模板引擎,但是当数据状态发生变化的时候,我们是不能解决跟踪视图操作的问题的,我们只能删除后,重新渲染
  • virtual dom是当状态改变的时候,不需要立即更新dom,只需要创建一个虚拟dom树来描述dom,然后内部通过diff 算法计算如何有效的更新dom

虚拟dom的作用

  • 维护视图和状态的关系
  • 复杂视图情况下,提升渲染性能
  • 除了渲染dom外 ssr weex rn mpvue uni-app

virtual dom库

在研究vueVirtual dom之前,我们先研究一下Snabbdomvue2.x中使用的Virtual dom就是改造自Snabbdom,由于Snabbdom只有大概200行左右的代码,所以学习起来比较容易,模块可扩展,是最快的Virtual dom之一

Snabbdom创建项目

打包工具为了方便使用,选择parcel

  • 创建项目目录mkdir snabbdom-demo
  • 进入项目目录cd snabbdom-demo
  • 创建package.jsonnpm init -y
  • 本地安装parcel npm i parcel-bundler
  • 配置package.json的scripts
      "scripts" : {
          "dev":"parcel index.html --open",
          "build":"parcel build index.html"
      }
    
  • 创建目录结构

模块导入的问题

commonjs语法中,所有的模块都会导出一个对象,所以我们可以始终使用一个变量来接收
es6中模块没有使用export default的方式的话,必须使用大括号的形式,类似于结构,事实上不是解构,就是一种固定语法

// import snabbdom from 'snabbdom' 错误
import { h, init } from 'snabbdom'	//版本是0.7.x的版本

// 1. hello world
//  参数: 数组、模块
//  返回patch函数,作用对比两个vnode的差异,更新到真实dom中
let patch = init([])
//  第一个参数 标签+ 选择器
//  第二个参数 如果是字符串的话就是标签中的内容 
let vnode = h('div#container.cls', 'hello world')
let app = document.querySelector('#app')
// 第一个参数: 可以是dom元素,内部会吧dom元素转换成vnode
// 第二个参数: VNode
// 返回VNode
let oldVnode =  patch(app, vnode)

// 假设的时刻
vnode = h('div', 'hello snabbdom')
patch(oldVnode, vnode)
patch函数

是通过导出来的init函数返回的,然后patch接受两个参数

关于init函数

参数: 数组、模块
返回patch函数,作用对比两个vnode的差异,更新到真实dom中

关于patch函数

第一个参数:可以是dom元素,内部会吧dom元素转换成vnode,也可以是VNode
第二个参数:VNode

h函数--处理参数,并且调用vnode函数创建一个vnode对象返回

snabbdom中会导出h函数

第一个参数 标签+ 选择器
第二个参数 如果是字符串的话就是标签中的内容,如果有子元素,第二个参数可以使用数组

函数的重载

  • 函数的重载指的是两个同名函数中参数个数或者参数类型不同的函数,和参数相关和返回值无关
  • js中是没有重载的概念的,ts中有重载,不过还是通过代码调整参数来实现的

上面代码在js中运行是后面的函数会覆盖前面的函数,然而在支持重载的ts中可以通过参数个数的不同,来区分这两个同名函数。参数类型不同的区分同理。

Snabbdom模块

Snabbdom的核心库并不能处理元素的属性/样式/事件,如果需要处理的话,可以使用模块

模块的作用
  • Snabbdom的核心库并不能处理dom元素的属性/样式/事件等,可以通过注册Snabbdom默认提供的模块来实现
  • Snabbdom中的模块可以用来Snabbdom的功能
  • Snabbdom中的模块的实现是通过注册全局的钩子函数来实现的

官方提供了6个常用模块

  • attributes
    • 设置DOM元素的属性,使用setAttribute()
    • 处理布尔类型的属性
  • props
    • 和attributes模块类似,设置DOM元素的属性 element[attr]= value
    • 不处理布尔类型的属性
  • class
    • 切换类样式
    • 注意给元素设置类样式是通过sel选择器
  • dataset
    • 设置data-*的自定义属性
  • eventlisteners
    • 注册和移除事件
  • style
    • 设置行内样式,支持动画
    • delayed/remove/destory
模块的使用
  1. 导入模块
  2. init()中注册模块,参数中有一个数组,数组就是用来注册模块的
  3. 使用h()函数创建VNode的时候,可以吧第二个参数设置为对象(通过这个对象设置行内样式事件等等),其他参数后移
import { init } from 'snabbdom/build/package/init' 
// snabbdom版本@2.1.0
import { h } from 'snabbdom/build/package/h'
// 导入模块
import { styleModule } from 'snabbdom/build/package/modules/style'
import { eventListenersModule} from 'snabbdom/build/package/modules/eventlisteners'
// 注册模块
const patch = init([
    styleModule,
    eventListenersModule
])
// 使用h() 函数的第二个参数传入模块需要的数据(对象的形式)
let vnode = h('div', [
    h('h1',{ style: { backgroundColor: 'red' } }, 'hello snabbdom'),
    h('p', { on: { click: eventHandler } },'这是P标签')
])

function eventHandler() {
    console.log('点击我了');
}

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

patch的整体执行过程

  • patch(oldVnode, newVnode)
  • 由于Vnode也是树形结构的,所以patch函数内部就是要找这两棵树的差异,过程就是我们常说的diff算法,diff算法就是用来实现查找两棵树的差异
  • 核心就是把新节点中变化的内容渲染到真实dom,最后返回新节点作为下一次处理的旧节点
  • patch内部,首先会判断对比新旧VNode是否是相同节点(节点的key和节点的选择器sel相同),如果不是相同节点,就不用去对比子节点了,直接删除旧节点,重新渲染新节点
  • 如果是相同节点,再判断新的VNode是否有text,如果有并且跟旧的不同,直接更新文本
  • 如果新的VNode的children是否有值,判断有没有子节点,依次对比新旧节点的子节点,判断子节点是否有变化

init函数

返回patch函数,使用高阶函数把本该是四个参数patch函数变成了只需要传两个参数的函数,同时创建了多个钩子函数,不同时间做不同的事

patch函数的工作过程

调用了patchVnode、createElm函数进行新旧节点的对比

  • 先对比新旧两个节点是不是相同节点sameVnode(),是的话调用patchVnode
    • sameVnode的原理就是对比keysel是否相同
  • 不相同的话调用createElm创建新节点,删除旧节点
  • 执行插入队列中的insert钩子函数,遍历触发post钩子函数
  • 返回 新的 vnode 节点
patchVnode函数的工作过程
  • 对比新旧两个节点之前会触发两个钩子函数
    • prepatch
    • update
  • 对比新旧两个节点差异,更新真实dom
    • 新节点有text属性,并且不等于老节点
      • 如果老节点有children,则移除老节点children对应的dom元素
      • 并且设置新节点对应dom元素textContent
    • 新老节点都有children并且不相等
      • 调用updateChildren钩子函数,对比子节点,并且更新子节点的差异
    • 只有新节点有children属性
      • 如果老节点有text属性,清空对应dom元素的textContent
      • 添加新节点的所有的子节点
    • 只有老节点有children属性,移除所有的老节点
    • 只有老节点有text属性,清空对应dom元素的textContent
  • 触发postpatch钩子函数
updateChildren函数的整体执行过程

同级别节点比较,比较开始和结束的四种情况

  1. 使用sameVnode()比较oldStartVnodenewStartVnode是否是相同节点,比较key和sel
    • 是相同节点,则调用patchVnode(),比较新旧节点的差异,更新到真实dom上
    • 新旧开始节点的索引++ ,让oldStartVnode/newStartVnode指向各自数组的第二个节点
  2. 使用sameVnode()比较oldEndVnodenewEndVnode是否是相同节点,比较key和sel
    • 是相同节点,则调用patchVnode(),比较新旧节点的差异,更新到真实dom上
    • 新旧开始节点的索引-- ,让oldEndVnode/newEndVnode指向各自数组的倒数第二个节点
  3. 使用sameVnode()比较oldStartVnodenewEndVnode是否是相同节点,比较key和sel
    • 是相同节点,则调用patchVnode(),比较新旧节点的差异,更新到真实dom上
    • 旧开始节点的索引++ ,新的结束节点--,让oldStartVnode指向第二个节点,newEndVnode指向倒数第二个
    • 把旧的开始节点对应的dom元素oldStartVnode.elm移动到旧的结束节点之后oldEndVnode.elm之后
  4. 使用sameVnode()比较oldEndVnodenewStartVnode是否是相同节点,比较key和sel
    • 是相同节点,则调用patchVnode(),比较新旧节点的差异,更新到真实dom上
    • oldEndVnode.elm对应的dom元素oldStartVnode.elm对应的dom元素之前
    • 移动相对应的索引

开始和结束节点的比较结束之后,要移动对应的dom元素,可能是进行倒序排序的操作
以上四种情况都不满足的时候,走到下面的第五种情况

  1. 遍历所有新节点使用新节点的key到老节点数组中找相同key的节点
    • 找不到的话就使用createElm()在旧的数组队列的oldStartVnode之前插入一个newStartVnode
    • 找到的话,先比较两个节点是不是相同节点,如果不是相同节点,继续走上一步,新生成一个几点,插入oldStartVnode之前
    • 如果是相同节点,使用patchVnode()比较两个节点,然后清空老节点对应索引的值,然后把找到的老节点对的dom元素移动到对应的oldStartVnode.elm开始节点对应的元素之前
    • 索引++ 重新给newStartVnode赋值

最后收尾工作当老节点数组或者新节点数组被遍历完的时候,我们需要在老节点数组中移除剩余旧节点或者再新节点数组中新增剩余几点

key的意义

给所有具有相同父元素的子元素设置具有唯一值的key,否则可能造成渲染错误

给Vnode设置key之后,当在对元素列表排序,或者给列表出入项的时候会重用上一次对应的dom对象,减少渲染次数,因此会提高性能

当不设置key的时候key都是undefined,相当于设置了相同的key,diff算法依然会最大程度的重用界面上的元素