Skip to content

Virtual DOM 就是用 JS 对象去 模拟 DOM 结构,它是真实 DOM 的抽象,只保留一些有用的信息,更轻量地描述 DOM 树的结构;新旧 vnode 对比,得出最小的更新范围,最后更新 DOM;数据驱动视图的模式下,有效控制 DOM 操作。

简单虚拟 DOM 结构

DOM 节点

html
<div id="app" class="container">
  <p>我是p标签</p>
  <ul style="background-color: red">
    <li>我是a标签</li>
  </ul>
</div>

js模拟 vnode 节点

json
{
	tag: "div",
	props: {
		className: "container",
		id: "app",
	},
	children: [
		{
			tag: "p",
			children: "我是p标签",
		},
		{
			tag: "ul",
			props: { style: "background-color: red" },
			children: [
				{
					tag: "li",
					children: "我是a标签",
				},
			],
		},
	],
};

snabbdom使用

案例代码:https://github.com/WuChenDi/Front-End/tree/master/12-snabbdom/virtual-dom

index.html

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</title>
  </head>
  <body>
    <div id="app">app</div>
    <div id="container">container</div>
    <button id="btn-change">change</button>
    <script src="./src/index.js"></script>
  </body>
</html>

index.js

javascript
// index.js
{
  import { init, h } from 'snabbdom'

  let patch = init([])

  let vnode = h('div', 'Hello World')
  let app = document.querySelector('#app')
  console.log(app)
  let oldVnode = patch(app, vnode)

  vnode = h('div#dd', 'Hello cdd')
  patch(oldVnode, vnode)
}

{
  import { init, h } from 'snabbdom'
  let patch = init([])

  let vnode = h('div#container', [
    h('h1', '我是h1标签'),
    h('ul', [h('li', '我是li标签')]),
  ])

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

  setTimeout(() => {
    var newVnode = h('div#container', [h('h2', '我是h2标签')])
    patch(vnode, newVnode)
  }, 2000)

  // setTimeout(() => {
  // 	var newVnode = h("div#container", [h("h2", "我是h2标签")]);
  // 	patch(vnode, h("!"));
  // }, 2000);
}

{
  import { init, h } from 'snabbdom'

  // 定义 patch
  const patch = init([
    snabbdom_class,
    snabbdom_props,
    snabbdom_style,
    snabbdom_eventlisteners,
  ])

  const container = document.getElementById('container')

  // 生成 vnode
  const vnode = h('ul#list', {}, [h('li.item', {}, 'Item 1'), h('li.item', {}, 'Item 2')])

  patch(container, vnode)

  document.getElementById('btn-change').addEventListener('click', () => {
    // 生成 newVnode
    const newVnode = h('ul#list', {}, [
      h('li.item', {}, 'Item 1'),
      h('li.item', {}, 'Item B'),
      h('li.item', {}, 'Item 3'),
    ])
    patch(vnode, newVnode)
  })
}

diff算法

步骤

用 js 对象来描述 dom 树结构,然后用这个 js 对象来创建一棵真正的 dom 树,插入到文档中;当状态更新时,将新的 js 对象和旧的 js 对象进行比较,得到两个对象之间的差异;最后将差异应用到真正的 dom 上。

复杂度

树 diff 的时间复杂度 O(n^3)

第一次需要对 oldVnode 遍历一次tree 然后第二次对 vnode 遍历tree 最后两者排序

优化时间复杂度到 O(n)

只比较同一层级,不跨级比较 tag 不相同,则直接删掉重建,不再深度比较 tag 与 key,两者都相同,则认为是相同节点,不再深度比较

diagram1.jpg

diagram1.jpg

snabbdom源码分析

https://github.com/snabbdom/snabbdom > https://github.com/coconilu/Blog/issues/152

案例分析

javascript
import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from 'snabbdom'

const patch = init([
  // Init patch function with chosen modules
  classModule, // makes it easy to toggle classes
  propsModule, // for setting properties on DOM elements
  styleModule, // handles styling on elements with support for animations
  eventListenersModule, // attaches event listeners
])

const container = document.getElementById('container')

const vnode = h('div#container.two.classes', { on: { click: someFn } }, [
  h('span', { style: { fontWeight: 'bold' } }, 'This is bold'),
  ' and this is just normal text',
  h('a', { props: { href: '/foo' } }, "I'll take you places!"),
])
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode)

const newVnode = h('div#container.two.classes', { on: { click: anotherEventHandler } }, [
  h(
    'span',
    { style: { fontWeight: 'normal', fontStyle: 'italic' } },
    'This is now italic type'
  ),
  ' and this is still just normal text',
  h('a', { props: { href: '/bar' } }, "I'll take you places!"),
])
// Second `patch` invocation
patch(vnode, newVnode) // Snabbdom efficiently updates the old view to the new state

大白话解释

首先 snabbdom 模块提供一个 init 方法,调用 init 方法会返回一个 patch 函数,第一个参数 oldVNode 或 DOM,第二个参数是新的 VNode 节点,调用 patch 函数会对 DOM 进行更新。通过调用 h 函数来创建 VNode。初始化 patch(container, vnode),container作为应用根节点,更新patch(vnode, newVnode)。

hook 钩子

NameTriggered when触发时机Arguments to callback
prethe patch process beginspatch 开始之前none
inita vnode has been added已经创建了一个 vnodevnode
createa DOM element has been created based on a vnode已经基于 vnode 创建了一个 DOM,但尚未挂载emptyVnode, vnode
insertan element has been inserted into the DOM创建的 DOM 被挂载了vnode
prepatchan element is about to be patched一个元素即将被 patcholdVnode, vnode
updatean element is being updated元素正在被更新oldVnode, vnode
postpatchan element has been patched元素已经 patch 完毕oldVnode, vnode
destroyan element is directly or indirectly being removed一个元素被直接或间接地移除了。间接移除的情况是指被移除元素的子元素vnode
removean element is directly being removed from the DOM一个元素被直接移除了(卸载)vnode, removeCallback
postthe patch process is donepatch 结束none

源码分析

https://github.com/WuChenDi/Front-End/blob/master/12-snabbdom/3.0.1

vnode

typescript
export interface VNode {
  sel: string | undefined // selector
  data: VNodeData | undefined
  children: Array<VNode | string> | undefined // 子节点
  elm: Node | undefined // element, 存储 HTMLELement
  text: string | undefined // 文本节点
  key: Key | undefined // 节点 key
}

export interface VNodeData {
  props?: Props // 节点属性
  attrs?: Attrs // 节点 attribute 属性
  class?: Classes // class 类名
  style?: VNodeStyle // style 样式
  dataset?: Dataset // html 自定义属性 data-
  on?: On // 事件
  attachData?: AttachData
  hook?: Hooks // 钩子
  key?: Key
  ns?: string // for SVGs
  fn?: () => VNode // for thunks
  args?: any[] // for thunks
  is?: string // for custom elements v1
  [key: string]: any // for any other 3rd party module
}

h 函数

h 渲染函数实际上只用于创建 vnode,并没有将 vnode 绘制到文档中。作为接口,h 渲染函数本身也只包含参数多态的处理,由于存在多种参数情况,所以首先会对参数进行格式化,在对 children 处理,如果是 svg 元素,调用 addNS 函数处理,最后返回 vnode 。

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
    }
  }
  // 如果存在 children,将不是 vnode 的项转成 vnode
  if (children !== undefined) {
    for (i = 0; i < children.length; ++i) {
      if (is.primitive(children[i]))
        children[i] = vnode(undefined, undefined, undefined, children[i], undefined)
    }
  }
  // svg 元素添加 namespace
  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)
}

sameVnode 函数

sameVnode 函数的主要逻辑是用来判断 vnode 节点是否为相同

typescript
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
  // key 和 sel 都相等
  // 如果 key 没有传入 => undefined === undefined // true
  // 不传 key 情况分析:不在循环体里面,像直接定义的情况,直接通过 tag/sel 来比较
  const isSameKey = vnode1.key === vnode2.key
  const isSameIs = vnode1.data?.is === vnode2.data?.is
  const isSameSel = vnode1.sel === vnode2.sel

  return isSameSel && isSameKey && isSameIs
}

createKeyToOldIdx 函数

createKeyToOldIdx 函数 返回 map 将所有定义了 key 的 oldVNode 在数组中的 index 作为键值,key作为键名存储起来,然后赋给 oldKeyToIdx(updateChildren 方法使用)

typescript
function createKeyToOldIdx(
  children: VNode[],
  beginIdx: number,
  endIdx: number
): KeyToIndexMap {
  const map: KeyToIndexMap = {}
  for (let i = beginIdx; i <= endIdx; ++i) {
    const key = children[i]?.key
    if (key !== undefined) {
      map[key as string] = i
    }
  }
  return map
}

createElm 函数

大白话

createElm 函数的主要逻辑在于创建真实的 DOM 节点 vnode.elm。首先调用 init hook ,然后创建 DOM 树分为如下三种情形:

  • vnode.sel === '!' 表示当前元素是注释节点,调用 createComment 创建注释节点,挂载到 vnode.elm;
  • vnode.sel !== 'undefined',对当前选择器进行解析(tag、id,class等),调用 createElementNS 或 createElement 生成 elm,接着调用 create hook,如果存在 children,遍历所有子节点并递归调用 createElm 创建 dom,通过 appendChild 挂载到当前的 elm 上,不存在 children 但存在 text,便使用 createTextNode 来创建文本,再次调用 create hook 与 insert hook 存储起来等 dom 插入后才会调用,这里用个数组来保存能避免调用时再次对 vnode 树做遍历;
  • 以上两种都不满足时,表示当前只是 text,调用 createTextNode 来创建文本,然后挂载到 vnode.elm;

流程图

diagram1.jpg

coding

typescript
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
  let i: any
  let data = vnode.data
  if (data !== undefined) {
    // 调用 init hook
    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, 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 hook
    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)) {
      // 调用 create hook
      hook.create?.(emptyNode, vnode)
      if (hook.insert) {
        // insert hook 存储起来 等 dom 插入后才会调用,这里用个数组来保存能避免调用时再次对 vnode 树做遍历
        insertedVnodeQueue.push(vnode)
      }
    }
  } else {
    // 文本节点
    vnode.elm = api.createTextNode(vnode.text!)
  }
  return vnode.elm
}

patchVnode 函数

大白话

patchVnode 函数的主要逻辑在于更新节点。首先调用 vnode 上的 prepatch hook,如果当前的两个 vnode 完全相同,直接返回。然后分如下两大类情形:

  • vnode.text === undefined
    • 新旧节点都存在 children,执行 updateChildren 更新
    • 存在新 children,不存在旧 children,如果旧 text 存在,则先清空在调用 addVnodes 添加children
    • 不存在新 children,存在旧 children,调用 removeVnodes 移除旧节点的 children
    • oldVnode 存在 text,调用 setTextContent 置空
  • vnode.text !== undefined,移除 oldVnode 对应的 dom 节点,并使用 setTextContent 更新 vnode.elm

流程图

diagram1.jpg

coding

typescript
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
  // 执行 prepatch hook
  const hook = vnode.data?.hook
  hook?.prepatch?.(oldVnode, vnode)
  // 设置 vnode.elm
  const elm = (vnode.elm = oldVnode.elm)!
  // 旧 children
  const oldCh = oldVnode.children as VNode[]
  // 新 children
  const ch = vnode.children as VNode[]
  // 如果 oldVnode 和 vnode 是完全相同,说明无需更新,直接返回。
  // 极少这种情况,除非人为测试
  if (oldVnode === vnode) return
  // hook 相关
  if (vnode.data !== undefined) {
    // 调用 update hook
    for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)

    // 调用 vnode update hook
    vnode.data.hook?.update?.(oldVnode, vnode)
  }
  // 新test(vnode.text) === undefined (vnode.children != undefined) / (vnode.children 一般有值)
  // text 与 children 不可能共存,但是都为 undefined 成立
  if (isUndef(vnode.text)) {
    // 新旧都有 children
    if (isDef(oldCh) && isDef(ch)) {
      // 新旧节点都存在 children 就执行 updateChildren
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
    }
    // 存在新 children,不存在旧 children(有可能存在旧 text)
    else if (isDef(ch)) {
      // 如果旧 text 存在值,先置空
      if (isDef(oldVnode.text)) api.setTextContent(elm, '')
      // 添加 children
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    }
    // 存在旧 children,不存在新 children(有可能存在旧 text)
    else if (isDef(oldCh)) {
      // 移除旧节点的 children
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      // 旧节点存在 text 置空
      api.setTextContent(elm, '')
    }
  }
  // else: vnode.text !== undefined (vnode.children 无值)
  else if (oldVnode.text !== vnode.text) {
    // 移除旧 children
    if (isDef(oldCh)) {
      // 新节点删除了 children ,删除老的 DOM 元素
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    }
    // 文本节点更新
    api.setTextContent(elm, vnode.text!)
  }
  // 调用 postpatch hook
  hook?.postpatch?.(oldVnode, vnode)
}

updateChildren 函数

大白话

流程图

coding

typescript
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

  // 遍历 oldCh newCh,对节点进行比较和更新
  // 每轮比较最多处理一个节点,算法复杂度 O(n)
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    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]
    }
    // 以旧 vnode 首节点和新 vnode 首节点对比
    else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    }
    // 以旧 vnode 尾节点和新 vnode 尾节点对比
    else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    }
    // 以旧 vnode 首节点和新 vnode 尾节点对比
    else if (sameVnode(oldStartVnode, newEndVnode)) {
      // Vnode moved right
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
      api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    }
    // 以旧 vnode 尾节点和新 vnode 新节点对比
    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]
    }
    // 以上4中都未命中
    else {
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      }
      // 拿新节点 key , 能否对应上 oldCh 中的某个节点的 key
      idxInOld = oldKeyToIdx[newStartVnode.key as string]

      // 对应不上
      if (isUndef(idxInOld)) {
        // New element
        api.insertBefore(
          parentElm,
          createElm(newStartVnode, insertedVnodeQueue),
          oldStartVnode.elm!
        )
      }
      // 对应上了
      else {
        // 拿到对应上 key 的节点
        elmToMove = oldCh[idxInOld]
        // sel 是否相等(sameVnode 条件)
        // sel 不相等,key相等
        if (elmToMove.sel !== newStartVnode.sel) {
          // New element
          api.insertBefore(
            parentElm,
            createElm(newStartVnode, insertedVnodeQueue),
            oldStartVnode.elm!
          )
        }
        // sel 相等,key 相等
        else {
          patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
          oldCh[idxInOld] = undefined as any
          api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
        }
      }
      // 更新之后,调整指针
      newStartVnode = newCh[++newStartIdx]
    }
  }
  // oldCh 已经全部处理完成,而 newCh 还有新的节点,需要对剩下的每个项都创建新的 dom
  if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
    if (oldStartIdx > oldEndIdx) {
      before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else {
      // newCh 已经全部处理完成,而 oldCh 还有旧的节点,需要将多余的节点移除
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
  }
}

infernojs源码分析

be continued

diff 过程

  • patch
  • patchVnode
  • addVnode
  • removeVnode
  • updateChildren
  • diff算法极致的性能优化做出的改变

vnode.key的作用是什么?

key 是用来判断两个 VNode 是否相同,参与到 diff 过程的一些判断中。key 情况分如下两种情况:

  • 当 key 相同时,调用 patchVnode 函数 更新节点,首先在旧列表中找到对应的 DOM 节点,然后执行移动操作;
  • 当 key 不相同时,会调用 createElm 函数 创建新节点,然后如果存在父节点,便将其插入到 dom 上,然后移除旧的 dom 节点来完成更新;

换句话说,key 用来在一个兄弟节点列表中进行唯一标记,这样在构建新节点的时候,会根据 key 去查找已经存在的节点,而不是就地复用,同时移除 key 不存在的节点。

Vue 与 React实现对比

Vue v2+ 中的 patch 机制就是基于 snabbdom 类库实现的(v3+ 切换到 infernojs)。 React v16+ 在推行 Fiber 之后,摒弃了递归 diff 的做法,但 diff 的核心思想是类似的。

Vue2和Vue3和React三者的diff算法有什么

Vue2:双端比较 Vue3:最长递增子序列 React:仅右移