理解虚拟 DOM
DOM 和虚拟 DOM
定义
从最原始定义来讲,DOM 是用于访问和处理 HTML 和 XML 文档的 API。通过这套 API 我们可以将一段合规的 HTML 代码转换成一堆由 Node 对象描述的结点组成的结点树, 在 JavaScript 里,这个结点树就呈现为一个可以表示这个结点树的对象。显然这个结点树很复杂,因为组成树 的 Node 就是复杂的,它里面包含了很多的属性。可实际使用上,那些 Node 对象里真正被用到的属性很有限,比如 nodeType、attributes、tagName、绑定的事件等核心属性,通过维护一份与这些属性相关的信息,我们用一个 JavaScript 对象来描述,这个对象就称为虚拟 Node(后简称为 vNode),vNode 的概念明晰后,对应地,虚拟 DOM 的定义也就不难给出了。
node 和 vNode 是一一对应的,这样我们便可以通过这些 vNode 来还原一个结点树,以及通过对比两个不同的 vNode tree 找出它们之间的最小差异,进而在旧结点树中只需对对应最小差异的部分做修改便能以最小成本的实现一个旧结点树向新结点树的转换。以上便是通过虚拟 DOM 实现以最小的成本更新 DOM 的原理。
小结
JavaScript 里,node tree 的 node 是一个复杂的对象,vNode tree 的 vNode 是一个只保存了 node 里必要信息的简单对象,可以通过某种算法 D 找出两个 vNode tree 的最小差异部分,然后通过某种算法 P 来对 node tree 上对应的差异部分作出修改,使之变成与新 vNode tree 对应的 node tree。
具体实现
节点类型
需要区分的节点类型包括 element 节点、文本节点、注释节点。
vNode 的定义
interface VNode {
sel: string | undefined;
data: VNodeData | undefined; // 与 attributes 和 时间绑定有关,简便起见,暂忽略之
children: Array<VNode | string> | undefined;
text: string | undefined;
elm: Node | undefined;
key: string | number | undefined;
}
其中 sel
的可能取值为:
- 只包含 HTML 标签名、id 和 class 的 selector 字符串,此时表示一个 element 节点
-
undefined
此时表示一个文本节点 -
!
此时表示一个注释节点
children
表示子节点,或为空或为一个由 VNode
组成的数据;text
当表示一个 element
为 textContent
;elm
为 vNode 所对应的 node;key
为优化效率用的。
先看看对比两个 vNode 的函数 patch
:
function isVoid (v: any): boolean {
return v === void 0 || v === null
}
function isUndef (v: any): boolean {
return v === void 0
}
function patch (oldVNode: VNode | Element, vNode: VNode) : VNode {
// 初始化时,在一个 Node 上挂载
if (oldVNode.sel === undefined) {
oldVNode = emptyNodeAt(oldVNode)
}
if (sameVNode(oldVNode, vNode)) {
patchVNode(oldVNode, vNode)
} else {
elm = oldVNode.elm
parent = api.parent(elm)
createElm(vNode)
api.insertBefore(parent, vNode.elm, api.nextSibling(elm))
removeVNodes(parent, [oldVNode], 0, 0)
}
return vNode
}
function emptyNodeAt (elm: Element): VNode {
const id = elm.id ? `#${elm.id}` : ''
const c = elm.class ? `.${elm.class.split(' ').join('.')}`
return {
sel: `${elm.tagName.toLowerCase()}${id}${c}`,
data: {},
children: [],
text: undefined,
elm,
key: undefined
}
}
function sameVNode (oldVNode: VNode, vNode: VNode): boolean {
return oldVNode.sel === vNode.sel && oldVNode.key === vNode.key
}
patch
函数的逻辑为:
- 第一个参数是否为 Element,是的话将其初始化为一个空的 vNode(这主要发生在首次从一个 node 下挂载新的 node 时,后续的 node tree 的变化都是基于 vNode 来操作)
- 对比两个 vNode 是否是相同的 vNode
- 是,则调用
patchVNode
函数 - 否,则根据 vNode 创建一个新的 node 并将这个新 node 插入到原来的 node 之后,然后删除
oldVNode
(删除对应 elm 的操作也在其中)
- 是,则调用
- return
vNode
其中 api
是浏览器提供的一系列 DOM 操作的封装,真正找出两个 vNode 最小差异的函数是 patchVNode
函数,createElm
函数的作用是根据 vNode 创建一个与之对应的 node,removeNodes
函数的作用是根据提供的 vNodes 删除对应的 node。它们的定义如下:
api
function createElement(tagName: any): HTMLElement {
return document.createElement(tagName)
}
function createElementNS(namespaceURI: string, qualifiedName: string): Element {
return document.createElementNS(namespaceURI, qualifiedName)
}
function createTextNode(text: string): Text {
return document.createTextNode(text)
}
function createComment(text: string): Comment {
return document.createComment(text)
}
function insertBefore(parentNode: Node, newNode: Node, referenceNode: Node | null): void {
parentNode.insertBefore(newNode, referenceNode)
}
function removeChild(node: Node, child: Node): void {
node.removeChild(child)
}
function appendChild(node: Node, child: Node): void {
node.appendChild(child)
}
function parentNode(node: Node): Node | null {
return node.parentNode
}
function nextSibling(node: Node): Node | null {
return node.nextSibling
}
function tagName(elm: Element): string {
return elm.tagName
}
function setTextContent(node: Node, text: string | null): void {
node.textContent = text
}
function getTextContent(node: Node): string | null {
return node.textContent
}
function isElement(node: Node): node is Element {
return node.nodeType === 1
}
function isText(node: Node): node is Text {
return node.nodeType === 3
}
function isComment(node: Node): node is Comment {
return node.nodeType === 8
}
export const htmlDomApi = {
createElement,
createElementNS,
createTextNode,
createComment,
insertBefore,
removeChild,
appendChild,
parentNode,
nextSibling,
tagName,
setTextContent,
getTextContent,
isElement,
isText,
isComment,
} as DOMAPI
createElm
function createElm (vNode: VNode): Node {
const { sel, children } = vNode
if (sel === '!') {
if (isUndef(vNode.text)) {
vNode.text = ''
}
vNode.elm = api.createComment(vNode.text)
} else if (isDef(sel) {
const { id, c, tag } = getIdCTagFromSel(sel) // 通过 sel 解析出 tagName、className、id
const elm = api.createElement(tag)
if (isDef(id)) {
elm.setAttribute('id', id)
}
if (isDef(c)) {
elm.setAttribute('class', c)
}
if (Array.isArray(children)) {
for (let i = 0; i < children.length; ++i) {
const ch = children[i]
if (!isVoid(ch)) {
api.appendChild(elm, createElm(ch))
}
}
} else if (typeof vNode.text === 'number' || typeof vNode.text === 'string') {
api.appendChild(elm, api.createTextNode(vNode.text))
}
} else {
vNode.elm = api.createTextNode(vNode.text)
}
return vNode.elm
}
removeVNodes
和 addVNodes
function removeVNodes (parentElm: Element, vNodes: Array<VNode>, startIdx: number, endIndex: number): void {
for(; startIdx <= endIdx; ++startIdx) {
const ch = vNodes[startIdx]
if (!isVoid(ch)) {
api.removeChild(parentElm, ch.elm)
}
}
}
function addVNodes (parentElm: Element, before: Node | null, vNodes: Array<VNode>, startIdx: number, endIdx: number): void {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vNodes[startIdx]
if (!isVoid(ch)) {
api.insertBefore(parentElement, createElm(ch), before)
}
}
}
patchNode
function patchVNode(oldVNode: VNode, vNode: VNode): void {
const elm = vNode.elm = oldVNode.elm
let oldCh = oldVNode.children
let ch = vNode.children
if (isUndef(vNode.text)) {
if (Array.isArray(oldCh) && Array.isArray(ch)) {
updateChildren(elm, oldCh, ch)
} else if (Array.isArray(oldCh)) {
removeVNodes(elm, oldCh, 0, oldCh.length - 1)
if (isDef(oldVNode.text)) {
api.setTextContent(elm, '')
}
} else if (Array.isArray(ch)){
if (isDef(oldVNode.text)) {
api.setTextContent(elm, '')
}
addVNodes(elm, null, ch, 0, ch.length - 1)
} else if (isDef(oldVNode.text)) {
api.setTextContent(elm, '')
}
} else if (oldVNode.text !== vNode.text){
if (Array.isArray(oldCh)) {
removeVNodes(elm, oldCh, 0, oldCh.length - 1)
}
api.setTextContent(elm, vNode.text)
}
}
patchVNode
按 oldVNode.text
到 vNode.text
变化(从无、有到有和从有到有)的 2 种情况以及 oldVNode.children
到 vNode.children
变化(从有到有,从无到有,从有到无)的 3 种情况,一共分为 6 种情况,而在 vNode.text
为「有」时,vNode.children
应该为「无」的,所以最终剩 4 种情况。
最复杂的是 children
从有到有的情况,其处理逻辑在 updateChildren
函数中,实现如下:
function updateChildren(
elm: Element,
oldCh: Array<VNode>,
newCh: Array<VNode>
) {
let oldStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVNode = oldCh[oldStartIdx]
let oldEndVNode = oldCh[oldEndIdx]
let newStartIdx = 0
let newEndIdx = newCh.length - 1
let newStartVNode = newCh[newStartIdx]
let newEndVNode = newCh[newEndIdx]
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// filter the void vNode
if (isVoid(oldStartVNode)) {
oldStartVNode = oldCh[++oldStartIdx]
}
if (isVoid(oldEndVNode)) {
oldEndVNode = oldCh[--oldEndIdx]
}
if (isVoid(newStartVNode)) {
newStartVNode = newCh[++newStartIdx]
}
if (isVoid(newEndVNode)) {
newEndVNode = newCh[--newEndIdx]
}
if (sameVNode(oldStartVNode, newStartVNode)) {
patchVNode(oldStartVNode, newStartVNode)
oldStartVNode = oldCh[++oldStartIdx]
newStartVNode = newCh[++newStartIdx]
} else if (sameVNode(oldEndVNode, newEndVNode)) {
patchVNode(oldEndVNode, newEndVNode)
oldEndVNode = oldCh[--oldEndIdx]
newEndVNode = newCh[--newEndIdx]
} else if (sameVNode(oldStartVNode, newEndVNode)) {
// vNode move to right
patchVNode(oldStartVNode, newEndVNode)
api.insertBefore(elm, oldStartVNode.elm, api.nextSibling(oldEndVNode.elm))
oldStartVNode = oldCh[++oldStartIdx]
newEndVNode = newCh[--newEndIdx]
} else if (sameVNode(oldEndVNode, newStartVNode)) {
// vNode move to left
patchVNode(oldEndVNode, newStartVNode)
api.insertBefore(elm, oldEndVNode.elm, oldStartVNode.elm)
oldEndVNode = oldCh[--oldEndIdx]
newStartVNode = newCh[++newStartIdx]
} else {
// a new element
api.insertBefore(elm, createElm(newStartVNode), oldStartVNode.elm)
newStartVNode = newCh[++newStartIdx]
}
}
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
// the count of vNodes increased
const before = isVoid(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVNodes(elm, before, newCh, newStartIdx, newEndIdx)
} else {
// the count of vNodes decreased
removeVNodes(elm, oldCh, oldStartIdx, oldEndIdx)
}
}
}
总结
以上便是一个简洁(因为 VNode 里的 data 属性和 key 属性都还没有用到)的通过 vNode 最小成本改动结点树的方法,基本逻辑:
- 判断两个 vNode(oldVNode 和 newVNode) 是否有相同的 sel 和 key,若否,则直接移除旧 vNode 所对应的整个 elm,然后根据新 vNode 创建一个新的 node,替代之
- 否则,则视为同一类型 vNode,然后进一步比较他们的 text 和 children:
i.if(!isVoid(newVNode.text) && isArray(oldVNode.children))
,则移除 oldVNode.elm 下面的所有子节点,并将其 textContent 赋值为 newVNode.text
ii. 否则if(isVoid(newVNode.text) && isArray(oldVNode.children)) && !isArray(newVNode.children))
,则移除 oldVNode.elm 下面的所有子节点,并将其 textContent 赋值为''
iii. 否则if(isVoid(newVNode.text) && !isArray(oldVNode.children)) && isArray(newVNode.children))
则将 oldVNode.elm 的 textContent 赋值为''
,并根据 newVNode.children 生成对应 nodes 数组,然后挂在到 oldVNode 上
iv. 否则if(isVoid(newVNode.text) && isArray(oldVNode.children)) && isArray(newVNode.children))
,则依次遍历比较两者的 children,当发现两者的 children 中有可以视为同一类 vNode 的两个 vNode 时,则将这两个 vNode 代入步骤 2,同时将对应的 node 移到指定的位置;如果没有,则根据新的子 vNode 创建一个新 node,同时插入到 oldVNode.elm 对应位置,最后移除多余的 node 或补齐新增的 node
参考:
https://github.com/snabbdom/snabbdom#the-class-module
事件绑定
事件绑定的功能是通过 new VNode
的第二个参数 data
里的 on
属性来完成的,data.on
的值是一个对象,对象里的键可以是传入以 document.addEventListener(name, handler)
里的 name
,对应的值为 handler
。可以通过一个 updateEventListeners(oldVNode, vNode)
的函数来实现,函数的作用就是对比 oldVNode 和 vNode 的 data.on
的属性,将 oldVNode.data.on
有而 vNode.data.on
没有的那些 name
调用 oldVNode.elm.removeEventLisetner(name, oldListener)
,对 vNode.data.on
有而 oldVNode.data.on
没有的调用 oldVNode.elm.addEventListener(name, listener)
。其中 listener
和 oldListener
是为 vNode 处理 vNode.on
时在 vNode 上新增的一个属性 listener
。vNode.listener
是一个函数,同时 vNode.listener.vNode === vNode
为 true
。elm 上所有事件的回调函数都是这个 vNode.listener
,由它的内部统一处理对应的事件,它由下面函数生成:
function createLisenter (): Function {
return function handler (): void {
handleEvent(event, handler.vNode)
}
}
function handleEvent(event: Event, vNode: VNode): void {
const name = event.type
const on = vNode.on
if (on && on[name]) {
// 这里只是一个简单的实现,不能往回调函数传参,可以专门抽出一个方法处理传参的情形
on[name].call(vNode, event, vNode)
}
}
更新事件的的内部逻辑就是这些,还有个问题是我们应该何时执行 updateEventListeners
这个方法?初步看来下面三个时机:
- 一个 vNode 刚被新建
- newVNode 预备替换 oldVNode
- 一个 vNode 要被移除
而且应该在 vnode 被真正渲染到 node tree 之前。于是我们可以为一个 vNode 定义一系列的 hooks,比如就这上面三个:create、update、destroy,然后在对应的时机调用 updateEventListeners
就可以了。snabbdom.js 将处理事件绑定的机制定义为一个 module,类似地专门处理 style 属性的又是一个 module,所有 module 有个公共的 hooks,每个 module 所要用到 hooks 并不一定相同,而且一个 module 里每个 hook 所调用的方法也不一定相同,这些 modules 用来传入 require('snabbdom').init
:
const hooks = ['hook1', 'hook2', /*...*/'hookN']
function init (modules, domApi) {
let i, j, cbs = {}
const api = domApi !== undefined ? domApi : htmlDomApi
for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = []
for (j = 0; j < modules.length; ++j) {
var hook = modules[j][hooks[i]]
if (hook !== undefined) {
cbs[hooks[i]].push(hook)
}
}
}
// ...
return patch(oldVNode, vNode)
}
最终返回一个具备处理与那些 module 对应的属性的 patch 函数。