学习snabbdom笔记

226 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情 >>

序言

看完本篇文章你将学到:

  1. 什么是“虚拟 DOM”(VDOM) 以及他的组成?
  2. 基于“虚拟 DOM”框架的是如何完成首次渲染流程。
  3. 为什么说“虚拟 DOM”,可以跨平台?
  4. 模块化 + hook 机制的好处?

一、snabbdom 是什么?

说到 VDOM (virtual DOM) 这里就要说了一个著名的 VDOM 开源库 —— snabbdom 官方描述 :A virtual DOM library with focus on simplicity, modularity, powerful features and performance. 一个专注于简单性、模块化、强大特性和性能的虚拟DOM库。通过我们来分析他来找答案吧。

首先我们从一个最简单的例子看下:

import { init } from 'snabbdom/init'
import { h } from 'snabbdom/h' // helper function for creating vnodes

const patch = init([])

const container = document.getElementById('container')

const vnode = h('div', {}, ' 我是一个div 节点')
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode)

运行完后我们就看到页面上就渲染了

现在我们就来看下他是如何完成首次渲染的,在 snabbdom/src/package/init.ts 文件中我们找到了定义 init 的地方,通过观察该函数为一个高阶函数,返回了一个 patch 函数,然后继续调用 h 函数。

// snabbdom/src/package/init.ts
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  let i: number
  let j: number
  const cbs: ModuleHooks = {
    create: [],
    update: [],
    remove: [],
    destroy: [],
    pre: [],
    post: []
  }

  // 省略部分源码

  return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node
    const insertedVnodeQueue: VNodeQueue = []
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
	
    // 1、判断是否是 Vnode 不是就转成 Vnode
    if (!isVnode(oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode)
    }
		// 2、判断是否是相同的 Vnode
    if (sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue)
    } else {
      // 3、不是相同的 Vnode
      elm = oldVnode.elm!
      parent = api.parentNode(elm) as Node

      createElm(vnode, insertedVnodeQueue) // 4、生成真实DOM

      if (parent !== null) { 
        // 插入到真实 DOM 树上
        api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
        removeVnodes(parent, [oldVnode], 0, 0)
      }
    }

    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
    }
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
  
    return vnode
  }
}

二、h函数是什么?

1、看看 h 函数长什么样:

// snabbdom/src/package/h.ts
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
  
  // 省略部分代码

  return vnode(sel, data, children, text, undefined)
};

// snabbdom/src/package/vnode.ts
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/src/package/h.ts 中我们找到了定义 h函数的地方,通过函数的签名可知,他的返回值是VNode 对象,函数内部返回了一个 vnode() 函数, 可以看出是一个 VNode 的工厂方法,根据参数返回 VNode 对象。

三、什么是 Vnode ?

1、看看 Vnode 长什么样子

// snabbdom/src/package/vnode.ts
export interface VNode {
  sel: string | undefined
  data: VNodeData | undefined
  children: Array<VNode | string> | undefined
  elm: Node | undefined
  text: string | undefined
  key: Key | undefined
}

export interface VNodeData {
  props?: Props
  attrs?: Attrs
  class?: Classes
  style?: VNodeStyle
  on?: On

  //... 省略部分代码
  
  [key: string]: any // for any other 3rd party module
}

在 snabbdom/src/package/vnode.ts 文件中,我们找到了 VNode 的类型定义。通过 VNode 的类型定义可知,VNode 本质是一个对象,他的 children 属性包含了他的子节点信息,类型也是 VNode 类型,VNodeData 中保存着节点包含的各种属性如 classstyle 以及绑定的事件 on

四、如何完成首次渲染?

最后调用 patch 函数。那我们就来看看 patch 做了什么。

// snabbdom/src/package/init.ts
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {

  // 省略部分源码

  return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node
    const insertedVnodeQueue: VNodeQueue = []
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
	
    // 1、判断是否是 Vnode 不是就转成 Vnode
    if (!isVnode(oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode)
    }
		// 2、判断是否是相同的 Vnode
    if (sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue)
    } else {
      // 3、不是相同的 Vnode
      elm = oldVnode.elm!
      parent = api.parentNode(elm) as Node

      createElm(vnode, insertedVnodeQueue) // 4、生成真实DOM

      if (parent !== null) { 
        // 插入到真实 DOM 树上
        api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
        removeVnodes(parent, [oldVnode], 0, 0)
      }
    }

    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
    }
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
  
    return vnode
  }
}

通过分析 patch 函数接收2个参数,oldVnodevnode ,返回 Vnode ,在上面的例子中,我们第一个参数传入的是一个真实 DOM,那他是如何转成 Vnode ,然后再对比,最后在挂载的呢?

const container = document.getElementById('container')

const vnode = h('div', {}, ' 我是一个div 节点')
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode)

4.1 如何判断对象是 Vnode?

// snabbdom/src/package/init.ts
function isVnode (vnode: any): vnode is VNode {
  return vnode.sel !== undefined
}

通过 isVnode 方法可知 snabbdom 通过 sel 属性来判断当前传入的 Vnode 是否是一个 Vnode, 在上面一个例子中,我们传入的是一个真实 DOM ,所以返回 false

4.2 如何将真实 Node 变为为 Vnode?

// snabbdom/src/package/init.ts 
function emptyNodeAt (elm: Element) {
    const id = elm.id ? '#' + elm.id : ''
    const c = elm.className ? '.' + elm.className.split(' ').join('.') : ''
    return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm)
  }
// snabbdom/src/package/init.ts
function sameVnode (vnode1: VNode, vnode2: VNode): boolean {
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}

我们找到了 emptyNodeAt 的方法,可以看出内部调用了 vnode 工厂方法,返回了 Vnode 对象,并添加了 elm 属性指向真实 NODE 节点,这样就真实 DOM 就变成了 Vnode 。然后就继续执行 oldVnode ,与 Vnode 是否相同,很明显不相同,然后继续执行,createElm 方法。

// snabbdom/src/package/init.ts
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    let i: any
    let data = vnode.data
    if (data !== undefined) {
      const init = data.hook?.init
      if (isDef(init)) {
        init(vnode)
        data = vnode.data
      }
    }
    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)
        : 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, ' '))
      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) {
            api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))
          }
        }
      } else if (is.primitive(vnode.text)) {
        api.appendChild(elm, api.createTextNode(vnode.text))
      }
      const hook = vnode.data!.hook
      if (isDef(hook)) {
        hook.create?.(emptyNode, vnode)
        if (hook.insert) {
          insertedVnodeQueue.push(vnode)
        }
      }
    } else {
      vnode.elm = api.createTextNode(vnode.text!)
    }
    return vnode.elm
  }

通过观察 createElm 函数的入参与出参可知,该函数 接收一个 Vnode 参数,然后返回 Vnodeelm ,该属性在上面流程中讲到了,他是一个 Vnode 属性指向一个真实的 DOM 对象,因此调用 createElm 放回了一个真实 DOM , 然后继续执行 insertBefore 方法, 在 snabbdomsrc/package/htmldomapi.ts 中我们找到了定义该函数的地方。可以知道他在 oldVnode 的 父节点上插入新的 Vnode 然后就完成了首次渲染了。

// snabbdomsrc/package/htmldomapi.ts
function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node | null): void {
  parentNode.insertBefore(newNode, referenceNode)
}

总结如下:

五、Vnode的作用是什么?

细心的小伙伴可能发现了定义 init 函数的第二个可选参数,domApi 可以看出他是 DOMAPI 类型,

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  // 省略部分源码
  return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
   // 省略部分源码
    return vnode
  }
}

DOMAPI:

// snabbdom/src/package/htmldomapi.ts
export interface DOMAPI {
  createElement: (tagName: any) => HTMLElement
  createElementNS: (namespaceURI: string, qualifiedName: string) => Element
  createTextNode: (text: string) => Text
  createComment: (text: string) => Comment
  insertBefore: (parentNode: Node, newNode: Node, referenceNode: Node | null) => void
  removeChild: (node: Node, child: Node) => void
  appendChild: (node: Node, child: Node) => void
  parentNode: (node: Node) => Node | null
  nextSibling: (node: Node) => Node | null
  tagName: (elm: Element) => string
  setTextContent: (node: Node, text: string | null) => void
  getTextContent: (node: Node) => string | null
  isElement: (node: Node) => node is Element
  isText: (node: Node) => node is Text
  isComment: (node: Node) => node is Comment
}

snabbdom/src/package/htmldomapi.ts 文件中我们找到了定义该类型的地方,通过分析发现他内部定义了一些操作 DOM 节点的方法,但是并没有去具体实现。这是为什么呢?

因为这样做就实现 VNode 抽象层,实现跨平台渲染了,当在浏览器环境中,使用 Document 文档对象生成真实 DOM 绘制,而在其他环境中,使用其平台提供的绘制 "DOM" 方法,然后统一实现定义好的 Api ,这样就可以做到在核心代码不变的情况下, 我只要自定义实现绘制的 API ,因此可以渲染到 DOM 以外的平台,实现 SSR、同构渲染这些高级特性,各种跨端的框架就是应用的这一特性,比如 Tarouni-app 等跨平台框架。

六、模块化的应用

在上面的流程中,我们并没有给我们的元素添加事件监听,以及 class 样式绑定,那我们设想如果要自己做你会如何实现呢? 在创建 DOM 的地方直接绑定事件? 设置各种属性?

让我们看看再 snabbdom 中是如何实现的吧,我们先引入简单的 2 个模块试试。分析下他是如何做的。

import { h } from 'snabbdom/src/package/h'
import { init } from 'snabbdom/src/package/init'
import { propsModule } from 'snabbdom/src/package/modules/props'
import { eventListenersModule } from 'snabbdom/src/package/modules/eventlisteners'

const patch = init([propsModule, eventListenersModule])

通过查看 init 函数可知,模块初始化时其中定义了Vnode生命周期6种钩子,然后在Vnode生命周期流程中依次触发,实现了流程的解耦。

// snabbdom/src/package/init.ts
const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
    let i: number
    let j: number
    // 初始化 模块钩子
    const cbs: ModuleHooks = {
      create: [],
      update: [],
      remove: [],
      destroy: [],
      pre: [],
      post: []
    }

    const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi

    for (i = 0; i < hooks.length; ++i) {
      cbs[hooks[i]] = []
      for (j = 0; j < modules.length; ++j) {
        const hook = modules[j][hooks[i]]
        if (hook !== undefined) {
          (cbs[hooks[i]] as any[]).push(hook)
        }
      }
    }
  
  // 省略部分源码


}

然后看下模块与钩子是如何联系起来的,通过目录结构我们可以看出,模块放在 modules 目录下每个文件单独处理一种情况,这就体现了软件设计原则中的——单一原则,我们先打开一个我们传入 eventListenersModule 模块进行分析下。

snabbdom/src/package/modules/eventlisteners.ts 文件中,我们找到了绑定事件的模块,通过观察,我们发现只有特定的三个时机会调用 updateEventListeners 函数, 在 updateEventListeners 函数中,处理处理事件的逻辑。

// snabbdom/src/package/modules/eventlisteners.ts
function createListener () {
  return function handler (event: Event) {
    handleEvent(event, (handler as any).vnode)
  }
}

function updateEventListeners (oldVnode: VNode, vnode?: VNode): void {

  // 省略部分源码
  
  if (on) {
    // reuse existing listener or create new
    const listener = (vnode as any).listener = (oldVnode as any).listener || createListener()
    // update vnode for listener
    listener.vnode = vnode

    // if element changed or added we add all needed listeners unconditionally
    if (!oldOn) {
      for (name in on) {
        // add listener if element was changed or new listeners added
        elm.addEventListener(name, listener, false)
      }
    } else {
      for (name in on) {
        // add listener if new listener added
        if (!oldOn[name]) {
          elm.addEventListener(name, listener, false)
        }
      }
    }
  }
}

// 最后导出模块,最后根据特定时机调用模块方法
export const eventListenersModule: Module = {
  create: updateEventListeners, // create 时机
  update: updateEventListeners, // update 时机
  destroy: updateEventListeners // destroy 时机
}

通过断点调试可以看到初始化模块时会把我们的模块写的钩子合并起来,然后统一调用。这样就做到了模块的按需引入,同时只需要我们实现 ModuleHooks 中定义好的接口即可灵活替换模块。

// snabbdom/src/package/init.ts
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) { 
  
  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]()

      // ...省略部分源码

       createElm(vnode, insertedVnodeQueue) 

      // ...省略部分源码

       // 触发 post 钩子
      for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()

      return vnode
    }
}

最后当我们的钩子初始化完毕后就会在特定时机调用了。

一张图总结下流程:

七、总结

本文主要以 Snabbdom-demo 仓库为学习示例,知道了什么是“虚拟 DOM”(VDOM) 以及他的组成,基于“虚拟 DOM”框架的是如何完成首次渲染流程,以及为什么说“虚拟 DOM”,可以跨平台,模块化 + hook 机制的好处。