Snabbdom

1,747 阅读5分钟

准备:

  1. 什么是虚拟DOM?

    Virtual DOM 其实就是一棵以 JavaScript 对象( VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。 www.cnblogs.com/fundebug/p/…

  2. 虚拟DOM的作用

    • 维护视图和状态的关系
    • 在复杂视图的情况下提高渲染性能
    • 跨平台
      • 浏览器平台渲染DOM
      • 服务端渲染SSR(nuxt.js、next.js)
      • 原生应用(weex、react native)
      • 小程序(mpvue、uni-app)
  3. 相关的虚拟DOM库

    • Snabbdom(以下都是使用的snabbdom)
    • virtual-dom

创建项目:

md snabbdom-demo
cd snabbdom-demo
npm init -y
npm i parcel-bundler -D

后面使用parcel来进行项目的打包

配置scripts:

"scripts": {
    "dev": "parcel index.html --open",
    "build": "parcel build index.html"
}

项目目录:

1625058322370.png

index.html中需要引入src下的js文件

引入snabbdom

安装snabbdom:

npm i snabbdom@2.1.0

引入

import { init } from 'snabbdom/src/package/init'
import { h } from 'snabbdom/src/package/h'

const patch = init([])

init函数和h函数是snabbdom中的核心函数

init函数执行时传入一个数组,返回一个patch函数,它会将虚拟dom转换成真实的dom并挂载到dom树上

官方示例中的引入是直接引入:github.com/snabbdom/sn…

import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";

这是因为在webpack5中已经支持在package.json中使用exports字段来定义路径以便外部的引用:

1625059388128.png

但是目前parcel中并不支持这种方式引入,所以还是需要按照路径导入

基本使用

import { init } from 'snabbdom/src/package/init'
import { h } from 'snabbdom/src/package/h'

const patch = init([])

// 第一个参数:标签+选择器
// 第二个参数:如果是字符串那么第二个参数就会被作为标签中的文本内容
let vnode = h('div#container.demo', 'hello')
let app = document.querySelector('#app')

// 第一个参数:旧的vnode,可以为dom元素
// 第二个参数:新的vnode
// 返回新的vnode,一般返回的vnode会作为下次patch时的“旧的vnode”
let oldVnode = patch(app, vnode)

index.html中需要有一个div#app占位:

1625060383703.png

完成之后npm run dev启动项目,可以看到视图和结构中都已经变成了相应的节点:

1625060664144.png

h函数中第二个参数还可以传入数组:

let vnode = h('div#container', [
  h('h1', 'title'),
  h('p', 'pppppppp')
])

如果想要渲染空的节点,可以往h函数中传入!

let vode = h('!')

snabbdom中的模块

作用:

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

官方提供了这些模块:

  • attributes:设置vnode内部对应的属性,使用setAttribute实现的
  • props:设置vnode内部对应的属性,使用“对象.属性”这种形式实现的,内部不会处理布尔型的属性
  • dataset:处理data-这样的属性
  • class:切换类样式
  • style:设置行内样式
  • eventListeners:注册、移除事件

使用步骤:

  1. 导入所需的模块
  2. init方法中注册模块
  3. h函数中第二个参数位置使用模块
import { init } from "snabbdom/src/package/init";
import { h } from "snabbdom/src/package/h";


// 导入模块
import { styleModule } from "snabbdom/src/package/modules/style";
import { eventListenersModule } from "snabbdom/src/package/modules/eventlisteners";

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

// 使用模块
let vnode = h('div#container', [
  h('h1', { style: { backgroundColor: 'cyan' } }, 'hello'),
  h('p', { on: { click: eventHandler } }, 'ppp')
])

function eventHandler() {
  console.log(111111111111)
}

let app = document.getElementById('app')
patch(app, vnode)

浏览器中结果也能正常显示,事件也有正常注册:

1625062868409.png

Snabbdom

h函数

作用:创建vnode对象

分析:

import { vnode, VNode, VNodeData } from './vnode'
import * as is from './is'

export type VNodes = VNode[]
export type VNodeChildElement = VNode | string | number | undefined | null
export type ArrayOrElement<T> = T | T[]
export type VNodeChildren = ArrayOrElement<VNodeChildElement>

function addNS (data: any, children: VNodes | undefined, sel: string | undefined): void {
  data.ns = 'http://www.w3.org/2000/svg'
  if (sel !== 'foreignObject' && children !== undefined) {
    for (let i = 0; i < children.length; ++i) {
      const childData = children[i].data
      if (childData !== undefined) {
        addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel)
      }
    }
  }
}

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) {
    // 处理三个参数时的情况--self、data、children/text
    if (b !== null) {
      data = b
    }
    if (is.array(c)) {
      // 判断c是否是数组,是的话将其保存在children中
      children = c
    } else if (is.primitive(c)) {
      // 判断c是否是字符串或者数字,是则将其保存在text中
      text = c
    } else if (c && c.sel) {
      // 判断c是否是vnode对象,是则将其转换成数组并保存在children中
      children = [c]
    }
  } else if (b !== undefined && b !== null) {
    // 处理二个参数时的情况--self、data/children
    if (is.array(b)) {
      children = b
    } else if (is.primitive(b)) {
      text = b
    } else if (b && b.sel) {
      children = [b]
    } else { data = b }
  }
  if (children !== undefined) {
    // 判断children中是否有值
    for (i = 0; i < children.length; ++i) {
      // 处理children中元素的值为number或者string时的情况--当元素为原始值类型时,用vnode将其创建为文本节点
      if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined)
    }
  }
  // 判断当前节点是否是svg
  if (
    sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
    (sel.length === 3 || sel[3] === '.' || sel[3] === '#')
  ) {
    // 添加命名空间
    addNS(data, children, sel)
  }
  // 创建vnode并返回
  return vnode(sel, data, children, text, undefined)
};

vnode函数

作用:返回vnode对象

源码:

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 }
}

patch函数

作用:比较两个vnode的差异,并将差异渲染到页面上,再将新的vnode返回作为下次patch时的oldvnode

源码:

  function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
    // 定义一些内部变量
    let i: number, elm: Node, parent: Node
    // 存储新插入节点的队列,这些是为了触发新插入节点插入时的钩子函数
    const insertedVnodeQueue: VNodeQueue = []
    // 触发模块中pre钩子函数
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()

    // 判断oldVNode是否为VNode对象,不是则将其转换成VNode
    // isVnode通过判断oldVnode是否有sel这个属性来判断oldVnode是否为vnode对象
    if (!isVnode(oldVnode)) {
      // emptyNodeAt方法是将元素标签名+id选择器+类选择器拼接起来再将其传入vnode方法将其转换成vnode
      // -> vnode(元素标签名+id选择器+类选择器, {}, [], undefined, elm)
      oldVnode = emptyNodeAt(oldVnode)
    }

    // 判断新旧vnode是否为相同节点
    // sameVnode通过判断新旧vnode的sel和key属性是否相等来判断vnode是否相同
    if (sameVnode(oldVnode, vnode)) {
      // 判断vnode中的内容是否有变化
      patchVnode(oldVnode, vnode, insertedVnodeQueue)
    } else {
      // 将旧vnode的dom元素保存在elm上
      elm = oldVnode.elm!
      // 将旧vnode的dom元素的父元素保存在parent上
      // 获取父元素是为了在创建新的节点之后,将其挂载到父元素上
      parent = api.parentNode(elm) as Node

      // 将新的vnode转换成dom元素,并将dom保存在vnode.elm上
      createElm(vnode, insertedVnodeQueue)

      if (parent !== null) {
        // 如果parent不为null,则将其挂载到dom树上
        api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
        // 将oldVnode从dom上移除
        removeVnodes(parent, [oldVnode], 0, 0)
      }
    }

    // 触发inserted钩子函数
    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
    }
    // 触发post钩子函数
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
    return vnode
  }

createElm函数

作用:创建dom节点

源码:

  function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    // 定义内部变量
    let i: any
    let data = vnode.data
    // 用户定义的init钩子
    if (data !== undefined) {
      const init = data.hook?.init
      // 判断init是否是undefined
      if (isDef(init)) {
        // init函数处理vnode
        init(vnode)
        data = vnode.data
      }
    }
    // 将子节点和选择器保存
    const children = vnode.children
    const sel = vnode.sel
    if (sel === '!') {
      // sel === '!',创建注释节点
      if (isUndef(vnode.text)) {
        vnode.text = ''
      }
      vnode.elm = api.createComment(vnode.text!)
    } else if (sel !== undefined) {
      // 创建dom
      // Parse selector
      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
      const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
        ? api.createElementNS(i, tag)
        : api.createElement(tag)
      if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
      if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '))
      // 触发create钩子
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)
      // 判断是否有子元素
      if (is.array(children)) {
        // 子元素为数组时,遍历数组,
        for (i = 0; i < children.length; ++i) {
          const ch = children[i]
          if (ch != null) {
            // 递归调用createElm函数创建dom并挂载到当前元素的elm属性上
            api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))
          }
        }
      } else if (is.primitive(vnode.text)) {
        // 当元素为文本时,直接创建文本节点挂载到elm上
        api.appendChild(elm, api.createTextNode(vnode.text))
      }
    // 触发用户定义的create钩子
      const hook = vnode.data!.hook
      if (isDef(hook)) {
        hook.create?.(emptyNode, vnode)
        if (hook.insert) {
          // 将vnode的insert钩子函数存入insert钩子队列,会在插入dom树后执行
          insertedVnodeQueue.push(vnode)
        }
      }
    } else {
      // 创建文本节点
      vnode.elm = api.createTextNode(vnode.text!)
    }
    // 返回当前vnode的elm
    return vnode.elm
  }

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) {
        // 当前vnode不为空
        // ch有sel属性时,会被当作一个元素节点,否则为文本节点
        if (isDef(ch.sel)) {
          // 触发destroy钩子函数
          invokeDestroyHook(ch)
          // 获取cbs中remove钩子函数的个数 + 1
          // listeners是为了防止重发删除dom元素
          listeners = cbs.remove.length + 1
          // createRmCb会返回一个函数,执行该函数就能删除元素
          rm = createRmCb(ch.elm!, listeners)
          // 遍历remove钩子函数,依次触发remove函数
          for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)
          const removeHook = ch?.data?.hook?.remove
          if (isDef(removeHook)) {
            removeHook(ch, rm)
          } else {
            rm()
          }
        } else { // Text node
          // 当前vnode为文本节点时直接调用removeChildren删除这个节点
          api.removeChild(parentElm, ch.elm!)
        }
      }
    }
  }

其中的createRmCb函数里面返回的rm函数执行时会将listenters先自减1再判断是否为0,若为零,则执行删除操作:

  function createRmCb (childElm: Node, listeners: number) {
    return function rmCb () {
      if (--listeners === 0) {
        const parent = api.parentNode(childElm) as Node
        api.removeChild(parent, childElm)
      }
    }
  }

patchNode函数

patchNode函数执行的大致过程:

1625580644913.png

源码:

  function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    // 触发prePatch和update钩子
    const hook = vnode.data?.hook
    // 如果有用户传入的prepatch钩子,立即执行
    hook?.prepatch?.(oldVnode, vnode)
    const elm = vnode.elm = oldVnode.elm!
    const oldCh = oldVnode.children as VNode[]
    const ch = vnode.children as VNode[]
    // 如果是相同节点,结束patch
    if (oldVnode === vnode) return
    // 执行cbs的update钩子和用户传入的update钩子
    if (vnode.data !== undefined) {
      for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      vnode.data.hook?.update?.(oldVnode, vnode)
    }
    // diff新旧vnode
    // 判断vnode的text是否为空:为空则说明vnode里面只包含元素节点,否则只包含文本节点
    if (isUndef(vnode.text)) {
      // 判断新旧节点是否有子节点
      if (isDef(oldCh) && isDef(ch)) {
        // 都有子节点且子节点不相同时调用updateChildren函数
        // updateChildren函数会对比所有子节点,并更新dom
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
      } else if (isDef(ch)) {
        // 只有新节点有子节点
        // 如果oldVnode有文本节点,清空
        if (isDef(oldVnode.text)) api.setTextContent(elm, '')
        // 调用addVnodes将新节点插入老节点
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        // 只有老节点有子节点
        // 移除老节点里面的子节点
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        // 如果老节点里面有text属性,清空
        api.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      // 新旧节点的text不相等
      // 判断老节点是否有子节点,若有则将子节点删除
      if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      }
      // 替换文本
      api.setTextContent(elm, vnode.text!)
    }
    // 触发postPatch钩子
    hook?.postpatch?.(oldVnode, vnode)
  }

updateChildren函数

当新旧节点都有子节点时会触发updateChildren函数,这个函数中使用了diff算法

diff算法:

1625665501924.png

snabbdom中的diff算法只对同级node进行dif

diff的过程中,会对比4种情况:

  • 旧开始节点与新开始节点的比较
  • 旧结束节点与旧结束节点的比较
  • 旧开始节点与新结束节点的比较
  • 旧结束节点与新开始节点的比较

1625666364868.png

  1. 新旧开始节点的之间的比较

    从新旧节点的开始位置开始比较,如果新旧开始节点是sameVnode,调用patchNode对比节点更新差异

    然后把oldStartIndex/newStartIndex都自加1

  2. 新旧结束节点之间的比较

    与新旧开始节点相反,它是从结束位置开始比较

  3. 旧开始节点与新结束节点

    将旧的开始节点与新的结束节点比较,如果是相同节点,则调用patchVnode函数对比节点更新差异

    然后将oldStartIndex对应的节点移动到右边,更新索引

  4. 旧结束节点与新开始节点

    与3相反

  5. 如果都不满足以上四种情况

    遍历新的开始节点,看其中有无与旧节点里有相同key值的节点:

    1. 如果没有找到那么以这个vnode创建一个新的节点,并将其插入旧节点最前面的位置
    2. 如果找到了,那么对比key值相同的两个节点的sel属性,如果sel属性不相同,那么将创建新的dom节点将其插入最前面的位置;如果sel属性相同,将该节点保存到elmToMove这个变量上,然后调用patchVnode对比更新这两个节点的差异,然后将elmToMove移动到最前面

这样遍历结束后,如果:

  1. 老节点先遍历完:

    说明新节点有剩余,这时候会调用addVnodes把剩余的新节点批量插到右边

  2. 新节点先遍历完

    说明老节点有剩余,调用removeVnodes把多的给删除

源码:

  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) {
      // 下面是处理节点为null的情况
      if (oldStartVnode == null) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode might have been moved left
      } else if (oldEndVnode == null) {
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (newStartVnode == null) {
        newStartVnode = newCh[++newStartIdx]
      } else if (newEndVnode == null) {
        newEndVnode = newCh[--newEndIdx]
        // 处理结束
        // 如果旧起始节点===新起始节点
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 调用patchVnode函数比较更新节点
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
        // 新旧节点索引都自加1,同时保存新的新旧起始节点
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
        // 如果旧结束节点===新结束节点
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // 调用patchVnode函数比较更新节点
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
        // 新旧节点索引都自减1,同时保存新的新旧结束节点
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
        // 如果旧的起始节点===新结束节点
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        // 调用patchVnode函数比较更新节点
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
        // 将旧起始节点移动到旧结束节点后面
        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)
        // 将旧结束节点插入旧起始节点前面
        api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
        // 旧结束节点--,新开始节点++
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        // oldKeyToIdx是一个对象,对象中的键对应的是老节点的key,值是老节点的索引
        // 作用是方便根据新节点的key,找到对应在老节点中的索引
        if (oldKeyToIdx === undefined) {
          // 利用createKeyToOldIdx初始化这个对象,传入旧节点、旧节点开始索引、旧节点结束索引
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        }
        // oldKeyToIdx[以新起始节点的key]
        idxInOld = oldKeyToIdx[newStartVnode.key as string]
        if (isUndef(idxInOld)) { // New element
          // 如果新节点在老节点中不存在,则新创建元素
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
        } else {
          // 将新节点的key在旧节点中对应的元素保存到elmToMove上
          elmToMove = oldCh[idxInOld]
          if (elmToMove.sel !== newStartVnode.sel) {
            // 若sel属性不相同,则将新节点创建出来并插入旧起始节点之前
            api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
          } else {
            // 相同的话调用patchVnode方法比较更新节点
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
            // 将旧节点数组中的该节点变成undefined
            oldCh[idxInOld] = undefined as any
            // 将elmToMove更新到旧开始节点之前
            api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
          }
        }
        // 新开始节点++
        newStartVnode = newCh[++newStartIdx]
      }
    }
    // 收尾工作
    if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
      if (oldStartIdx > oldEndIdx) {
        // 老节点遍历完,新节点有剩余
        // before是参考元素,插入的元素插在before之前
        before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
        addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
      } else {
        // 新节点遍历完,老节点有剩余
        // 直接删除
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
      }
    }
  }