Virtual DOM 开源库——Snabbdom

Snabbdom 特点

  • Vue 2.x 内部使用的 Virtual DOM 就是改造的 Snabbdom

  • 大约 200 行代码

  • 通过模块可扩展

  • 源码使用 TypeScript 开发

  • 最快的 Virtual DOM 之一

常用模块

class

  • 方便动态切换 class

  • 注意:给元素设置类是通过 sel 选择器

attributes

  • 设置 DOM 元素的属性,使用 setAttribute()

  • 能处理布尔类型的属性

props

  • 跟上面的 attributes 模块相似,不过设置 DOM 元素的属性是用 element[attr] = value 的形式

  • 不处理布尔类型的属性

dataset

  • 设置 data-* 的自定义属性

style

  • 设置行内样式,支持动画

  • 增加了三个属性:delayed/remove/destroy

eventlisteners

  • 注册和移除事件

Snabbdom 源码解析

先说一下如何学习源码:先宏观了解,然后带着目标看源码,看源码的过程中不要陷入细节,而是要不求甚解,可以配合调试理解源码的运行,并且可以参考必要的资料。

Snabbdom 核心

  • 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM

  • init() 设置模块,创建 patch()

  • patch() 比较新旧两个 VNode

  • 把变化的内容更新到真实 DOM 树上

h() 函数

h()函数最早见于 hyperscript,使用 JavaScript 创建超文本。Snabbdom 中的 h()函数不是用来创建超文本,而是创建 VNode。

h() 函数有函数重载的概念,函数重载是参数个数和类型不同的同一函数实现不同的逻辑,JavaScript 中没有重载,TypeScript 中有重载,不过重载的实现还是通过代码调整参数。

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 {
  let data: VNodeData = {}
  let children: any
  let text: any
  let i: number

  if (c !== undefined) {
    // 三个参数的情况
    if (b !== null) {
      data = b
    }
    if (is.array(c)) {
      children = c
    } else if (is.primitive(c)) {
      text = c
    } else if (c && c.sel) {
      children = [c]
    }
  } else if (b !== undefined && b !== null) {
    // 两个参数的情况
    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) {
    for (i = 0; i < children.length; ++i) {
      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] === '#')) {
    addNS(data, children, sel)
  }
  return vnode(sel, data, children, text, undefined)
}

VNode

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

VNode 渲染成真实 DOM

  • patch(oldVnode, newVnode)

  • 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点

具体对比过程如下:

  • 对比新旧 VNode 是否是相同节点(节点的 key 和 sel 相同)

  • 如果不是相同节点,删除之前的内容,重新渲染

  • 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新 文本内容

  • 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法

  • diff 过程只进行同层比较

init() 返回 patch()

init 是高阶函数,内部返回 patch 函数,把 vnode 渲染成真实 DOM,并返回 VNode。

export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  let i: number
  let j: number
  const cbs: ModuleHooks = {
    create: [],
    update: [],
    remove: [],
    destroy: [],
    pre: [],
    post: [],
  }
  // 初始化转换虚拟节点的 api
  const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi
  // 把传入的所有模块的钩子函数,统一存储到 cbs 对象中
  // 最终构建的 cbs 对象的形式 cbs = {create: [fn1, fn2], update: [], ... }
  for (i = 0; i < hooks.length; ++i) {
    // cbs.create = [], cbs.update = [] ...
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      // modules 传入的模块数组
      // 获取模块中的 hook 函数
      // hook = modules[0][create] ...
      const hook = modules[j][hooks[i]]
      if (hook !== undefined) {
        // 把获取到的 hook 函数放入到 cbs 对应的钩子函数数组中
        ;(cbs[hooks[i]] as any[]).push(hook)
      }
    }
  }

  function emptyNodeAt(elm: Element) {
    // ……
  }

  function createRmCb(childElm: Node, listeners: number) {
    // ……
  }

  function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    // ……
  }

  function addVnodes(parentElm: Node, before: Node | null, vnodes: VNode[], startIdx: number, endIdx: number, insertedVnodeQueue: VNodeQueue) {
    // ……
  }

  function invokeDestroyHook(vnode: VNode) {
    // ……
  }

  function removeVnodes(parentElm: Node, vnodes: VNode[], startIdx: number, endIdx: number): void {
    // ……
  }

  function updateChildren(parentElm: Node, oldCh: VNode[], newCh: VNode[], insertedVnodeQueue: VNodeQueue) {
    // ……
  }

  function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    // ……
  }

  return 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 并设置 elm
    if (!isVnode(oldVnode)) {
      // 把 DOM 元素转换成空的 VNode
      oldVnode = emptyNodeAt(oldVnode)
    }

    // 如果新旧节点是相同节点(key 和 sel 相同)
    if (sameVnode(oldVnode, vnode)) {
      // 找节点的差异并更新 DOM
      patchVnode(oldVnode, vnode, insertedVnodeQueue)
    } else {
      // 如果新旧节点不同,vnode 创建对应的 DOM
      // 获取当前的 DOM 元素
      elm = oldVnode.elm!
      parent = api.parentNode(elm) as Node
      // 创建 vnode 对应的 DOM 元素,并触发 init/create 钩子函数
      createElm(vnode, insertedVnodeQueue)

      if (parent !== null) {
        // 如果父节点不为空,把 vnode 对应的 DOM 插入到文档中
        api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
        // 移除老节点
        removeVnodes(parent, [oldVnode], 0, 0)
      }
    }
    // 执行用户设置的 insert 钩子函数
    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]()
    // 返回 vnode
    return vnode
  }
}

createElm()

创建 vnode 对应的 DOM 元素,即给 vnode.elm 赋值,并触发 init/create 钩子函数

function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
  let i: any
  let data = vnode.data
  if (data !== undefined) {
    // 执行用户设置的 init 钩子函数
    const init = data.hook?.init
    if (isDef(init)) {
      init(vnode)
      data = vnode.data
    }
  }
  // 把 vnode 转换成真实 DOM 对象(没有渲染到页面)
  const children = vnode.children
  const sel = vnode.sel
  if (sel === '!') {
    // 如果选择器是 ! ,创建注释节点
    if (isUndef(vnode.text)) {
      vnode.text = ''
    }
    vnode.elm = api.createComment(vnode.text!)
  } else if (sel !== undefined) {
    // 如果选择器不为空
    // 解析选择器
    // 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, data) : api.createElement(tag, data))
    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)
    // 如果 vnode 中有子节点,创建子 vnode 对应的 DOM 元素并追加到 DOM 树上
    if (is.array(children)) {
      for (i = 0; i < children.length; ++i) {
        const ch = children[i]
        if (ch != null) {
          api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))
        }
      }
    } else if (is.primitive(vnode.text)) {
      // 如果 vnode 的 text 值是 string/number,创建文本节点并追加到 DOM 树
      api.appendChild(elm, api.createTextNode(vnode.text))
    }
    const hook = vnode.data!.hook
    if (isDef(hook)) {
      // 执行用户传入的钩子 create
      hook.create?.(emptyNode, vnode)
      if (hook.insert) {
        // 把 vnode 添加到队列中,为后续执行 insert 钩子做准备
        insertedVnodeQueue.push(vnode)
      }
    }
  } else {
    // 如果选择器为空,创建文本节点
    vnode.elm = api.createTextNode(vnode.text!)
  }
  // 返回新创建的 DOM
  return vnode.elm
}

removeVnodes()

function createRmCb(childElm: Node, listeners: number) {
  // 返回删除元素的回调函数
  return function rmCb() {
    if (--listeners === 0) {
      const parent = api.parentNode(childElm) as Node
      api.removeChild(parent, childElm)
    }
  }
}

function invokeDestroyHook(vnode: VNode) {
  const data = vnode.data
  if (data !== undefined) {
    // 执行用户设置的 destroy 钩子函数
    data?.hook?.destroy?.(vnode)
    // 调用模块中的 destroy 钩子函数
    for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
    // 执行子节点的 destroy 钩子函数
    if (vnode.children !== undefined) {
      for (let j = 0; j < vnode.children.length; ++j) {
        const child = vnode.children[j]
        if (child != null && typeof child !== 'string') {
          invokeDestroyHook(child)
        }
      }
    }
  }
}

function removeVnodes(parentElm: Node, vnodes: VNode[], startIdx: number, endIdx: number): void {
  for (; startIdx <= endIdx; ++startIdx) {
    let listeners: number
    let rm: () => void
    const ch = vnodes[startIdx]
    if (ch != null) {
      // 如果 sel 有值
      if (isDef(ch.sel)) {
        // 执行 destroy 钩子函数(会执行所有子节点的 destroy 钩子函数)
        invokeDestroyHook(ch)
        // listeners 保证模块的 remove 钩子函数都调用完毕后,才删除节点,避免重复删除
        listeners = cbs.remove.length + 1
        // 创建删除的回调函数
        rm = createRmCb(ch.elm!, listeners)
        for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)
        // 执行用户设置的 remove 钩子函数
        const removeHook = ch?.data?.hook?.remove
        if (isDef(removeHook)) {
          removeHook(ch, rm)
        } else {
          // 如果没有用户钩子函数,直接调用删除元素的方法
          rm()
        }
      } else {
        // Text node
        // 如果是文本节点,直接调用删除元素的方法
        api.removeChild(parentElm, ch.elm!)
      }
    }
  }
}

addVnodes()

function addVnodes(parentElm: Node, before: Node | null, vnodes: VNode[], startIdx: number, endIdx: number, insertedVnodeQueue: VNodeQueue) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (ch != null) {
      api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before)
    }
  }
}

patchVnode()

function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
  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[]
  // 如果新旧 vnode 相同,直接返回
  if (oldVnode === vnode) return
  if (vnode.data !== undefined) {
    //  执行模块的 update 钩子函数
    for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    // 执行用户设置的 update 钩子函数
    vnode.data.hook?.update?.(oldVnode, vnode)
  }
  // 如果 vnode.text 未定义
  if (isUndef(vnode.text)) {
    if (isDef(oldCh) && isDef(ch)) {
      // 新旧节点都有 children,且不相等,调用 updateChildren(),使用 diff 算法,对比子节点,并且更新子节点的差异
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
    } else if (isDef(ch)) {
      // 只有新节点有 children 属性,如果旧节点有 text 属性,清空对应的 DOM 元素的 textContent,然后添加所有的子节点
      if (isDef(oldVnode.text)) api.setTextContent(elm, '')
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) {
      // 只有旧节点有 children 属性,就移除旧节点的所有子节点
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      // 只有旧节点有 text 属性, 清空对应 DOM 元素的 textContent
      api.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    // 新节点有 text 属性,且不等于旧节点的 text 属性
    // 如果旧节点有 children,移除旧节点 children 对应的 DOM 元素
    if (isDef(oldCh)) {
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    }
    // 设置新节点对应 DOM 元素的 textContent
    api.setTextContent(elm, vnode.text!)
  }
  // 执行用户设置的 postpatch 钩子函数
  hook?.postpatch?.(oldVnode, vnode)
}

Last updated

Was this helpful?