对 virtual-dom 的一些理解

1,834 阅读13分钟
原文链接: zhuanlan.zhihu.com

1. 前言

Vue 2.xReact 都引入了 Virtual Dom 的概念,来更加高效地更新 dom 节点,提升渲染性能。为了能更深入地了解 Virtual Dom 的相关细节,决定将 Matt-Esch/virtual-dom 作为学习对象。这个项目很纯粹,也很清晰地展示了如何利用虚拟节点来更新视图的整个过程。

总体来说,大致分为以下几个阶段。


在依次介绍几个阶段之前,先来明确几个概念,对后续的学习很有帮助。



2. 基本概念

VNode

VNode 虚拟节点,它可以代表一个真实的 dom 节点。通过 createElement 方法能将 VNode 渲染成 dom 节点。

VText

VText 虚拟文本节点,代表了一个真实的文本节点。内容中若有 HTML 会被转义。

Hooks

Hooks 钩子方法,如给节点注册 ev-click 等事件。

Thunk

Thunk 方法允许开发者参与 diff 过程。如对于某节点,能够预先判断状态不会发生改变,则可以通过此方法,在 diff 过程中直接返回旧 VNode 。

Widget

Widget 和 Thunk 的作用有点相似,但它参与的是 patch 的过程。它能定制如何渲染成最终的 dom 节点。如要求某个状态只为偶数时,重新渲染等。

VPatch

VPatch 权且称之为「补丁对象 」,它描述了对于某个节点的具体操作,比如删除,插入,排序等等。



3. render - 创建虚拟节点

从 render 出发,我们将一步步探索 virtual-dom 的整个过程。 先从 render 实现谈起。


// @virtual-hyperscript/index.js
tag = parseTag(tagName, props);

由于 tagName 支持 #ID , div , .class , div#ID 等形式,第一步需要对其进行解析:

// @virtual-hyperscript/parse-tag.js
var classIdSplit = /([\.#]?[a-zA-Z0-9\u007F-\uFFFF_:-]+)/;

var tagParts = split(tag, classIdSplit);
var tagName = null;

if (notClassId.test(tagParts[1])) {
    tagName = 'DIV';
}

这里比较巧妙,通过匹配 class 和 id 的组合正则对 tagName 进行切割,得到一个数组,一次性将标签,类名, id 抽离出来。之后分别对其进行预处理。最后一行:

// @virtual-hyperscript/parse-tag.js
return props.namespace ? tagName : tagName.toUpperCase();
这里牵扯出一个 namespace 的概念。可能平时主要接触 HTML ,对于这个概念较陌生,但如果了解 XML 的话,应该知道,namespace 常用来避免元素名冲突。有兴趣的可以自行了解,这里不展开介绍。继续往下:
// @virtual-hyperscript/index.js
transformProperties(props);

if (children !== undefined && children !== null) {
    addChild(children, childNodes, tag, props);
}

props 中并不全是像 id , class 等可以直接设置到 dom 元素上的属性值,还支持像 ev-click 等事件的注册。 所以需要一个方法对其进行预处理,transformProperties 干了这活。在标签名和属性处理完后,接着对不同类型的子节点进行处理生成相应的虚拟节点,最后组装成 childNodes 数组。

标签、属性、子节点都已就位,主角 VNode 出场:

// @virtual-hyperscript/index.js
return new VNode(tag, props, childNodes, key, namespace);

进入 VNode ,看看里面都发生了啥?

// @vnode/vnode.js
// 部分逻辑简化
for (var propName in properties) {
  if (properties.hasOwnProperty(propName)) {
    ...
    var property = properties[propName]
    if (isVHook(property) && property.unhook) {
      if (!hooks) {
          hooks = {}
      }

      hooks[propName] = property
    }
    ...
  }
}

for (var i = 0; i < count; i++) {
  var child = children[i]
  if (isVNode(child)) {
    descendants += child.count || 0

    if (...) {
      hasWidgets = true
    }

    if (...) {
      hasThunks = true
    }

    if (...) {
      descendantHooks = true
    }
  } else if (...) {
    hasThunks = true
  } else if (...) {
    hasThunks = true
  }
}

这里省略了部分代码,让逻辑看起来更加清晰。过程如下:

  1. 先将属性中所有 Hook 注册的事件缓存。
  2. 遍历所有子节点,统计子孙节点的数量,同时打上各种标记,比如子节点是否有 Widget , 是否有 Thunk 等等。

这些标记以及数量统计,现在还看不出什么价值,在后面的几个阶段中,会被用到。不过,按常规来猜测的话,无非是冗余数据,为了后续的一些查找遍历节省时间,提升性能。

4. createElement - 创建 dom 节点

// @vdom/create-element.js
vnode = handleThunk(vnode).a

if (isWidget(vnode)) {
    return vnode.init()
} else if (isVText(vnode)) {
    return doc.createTextNode(vnode.text)
}

VNode 在上个阶段已经生成,接下来进入 createElement ,负责将 VNode 渲染成 dom 节点。过程如下:


  1. createElement 由于支持传 Thunk ,所以为了能统一拿到 VNode ,handleThunk 需要在这进行转换。
  2. 处理掉两个和 dom 生成直接相关的类型 Widget 和 Text 。

特殊 case 处理完了,接着就是处理 VNode 了:

// @vdom/create-element.js
var node = (vnode.namespace === null) ?
    doc.createElement(vnode.tagName) :
    doc.createElementNS(vnode.namespace, vnode.tagName)

var props = vnode.properties
applyProperties(node, props)

var children = vnode.children

for (var i = 0; i < children.length; i++) {
    var childNode = createElement(children[i], opts)
    if (childNode) {
        node.appendChild(childNode)
    }
}

过程如下:

  1. 通过 createElement/createElementNS 创建 dom 元素。
  2. 用 applyProperties 方法将属性 props 通过 setAttribute / removeAttribute / dom[key] = value 的方式设置到 dom 上。
  3. 当前节点自身生成好后,遍历子元素,递归创建节点,将子节点 appendChild 到当前 node 中。

这样,一个真实完整的 dom 节点就创建好。



5. diff - 新旧 VNode 差异对比

// @vtree/diff.js
function diff(a, b) {
  var patch = { a: a }
  walk(a, b, patch, 0)
  return patch
}

当有状态更新后,会 render 出一个新的 VNode 。virtual-dom 则通过 diff 方法比较出其中的差异,生成一个 patch 对象。结构如下:

patch = {
  0: {VPatch},
  1: {VPatch},
  ...
  a: VNode
};

最后 a 的 value 为旧 VNode。数字 key 代表旧 VNode 子节点的索引,value 为相应的 VPatch 或者 Array<VPatch>。通过 walk 方法的递归将一个个 VPatch 打进 patch 中。

为了方便理解,可以这么描述,walk 是用来对比 a b 两个新旧 VNode,如检测到其中第 index 节点有状态更新,则将 VPatch 打到 patch[index] 上。

// @vtree/diff.js
function walk(a, b, patch, index) {
    // 全等则直接返回
    if (a === b) {
        return
    }

    var apply = patch[index]
    // 当新旧节点类型不同时,标记是否清除旧节点的所有属性以及状态
    var applyClear = false

    // case: 对 Thunk 的处理
    // thunks 方法内部将 a , b 转成 VNode ,再调用 diff(a, b) 进行递归
    if (isThunk(a) || isThunk(b)) {
        thunks(a, b, patch, index)
    }
    // case: 对新节点为 null 的处理
    else if (b == null) {
        // case: 旧元素不是 Widget
        // 调用 clearState 方法,该方法干了两件事
        //
        // unhook():
        // 通过将该元素以及其子孙元素的所有 hook 属性全都设置为 null 的方式,释放 hook 绑定的方法。
        //
        // destroyWidgets():
        // 递归删除该元素中所有 Widget。
        // 当然这里的删除,并不是硬删除,而是新建一个 Remove-VPatch。
        if (!isWidget(a)) {
            clearState(a, patch, index)
            apply = patch[index]
        }

        // 删除自身,旧元素,无论是它是 Widget 还是 VNode。
        // 所以,源码中有这样一段注释
        // "This prevents adding two remove patches for a widget."
        // 如果旧元素 a 本身是一个 Widget,而上面的条件不判断,会出现有什么情况呢?
        // 就会发现在 destroyWidgets 中递归时,就已经自身打了个 Remove-VPatch
        // 如果这里删除自身再次打一个,显然就重复了。
        apply = appendPatch(apply, new VPatch(VPatch.REMOVE, a, b))
    }
    // case: 对 VNode 的处理
    else if (isVNode(b)) {
        if (isVNode(a)) {
            // case: 新旧元素节点相同,只是更新了属性
            if (a.tagName === b.tagName &&
                a.namespace === b.namespace &&
                a.key === b.key) {
                // 对比出更新的属性
                var propsPatch = diffProps(a.properties, b.properties)
                if (propsPatch) {
                    // 打 Props-VPatch
                    apply = appendPatch(apply,
                        new VPatch(VPatch.PROPS, a, propsPatch))
                }
                // 对比子孙元素,递归
                apply = diffChildren(a, b, patch, apply, index)
            } else {
                // 新旧同为 VNode ,但标签不用,直接新节点更新旧节点
                apply = appendPatch(apply, new VPatch(VPatch.VNODE, a, b))
                applyClear = true
            }
        } else {
            // 新节点为 VNode ,旧节点不是,直接新节点更新旧节点
            apply = appendPatch(apply, new VPatch(VPatch.VNODE, a, b))
            applyClear = true
        }
    }
    // case: 对 VText 的处理
    else if (isVText(b)) {
        // 新节点为 VText ,旧节点不是,直接新节点更新旧节点
        if (!isVText(a)) {
            apply = appendPatch(apply, new VPatch(VPatch.VTEXT, a, b))
            applyClear = true
        }else if (a.text !== b.text) {
            // 同为 VText ,内容不同,直接新节点更新旧节点
            apply = appendPatch(apply, new VPatch(VPatch.VTEXT, a, b))
        }
    }
    // case: 对 Widget 的处理
    else if (isWidget(b)) {
        if (!isWidget(a)) {
            applyClear = true
        }

        // 新节点为 Widget,旧节点无论是否为 Widget,都直接更新
        apply = appendPatch(apply, new VPatch(VPatch.WIDGET, a, b))
    }

    if (apply) {
        patch[index] = apply
    }

    if (applyClear) {
        clearState(a, patch, index)
    }
}

walk 方法并没有返回值,由于 patch 是传引用,直接对它进行了修改。这里需要着重说明的是 diffChildren 方法,它主要用来遍历和递归子节点。另外,其中的 reorder 方法特别值得关注。

// @vtree/diff.js
function diffChildren(a, b, patch, apply, index) {
    var aChildren = a.children
    // 对新旧节点的子节点进行一个对比,重新排序,生成一个操作队列对象
    // 内容暂且略过,后面会着重来谈。
    // {
    //  children: [],
    //  moves: {
    //    removes: removes,
    //    inserts: inserts
    //  }
    // }
    var orderedSet = reorder(aChildren, b.children)
    var bChildren = orderedSet.children

    var aLen = aChildren.length
    var bLen = bChildren.length
    var len = aLen > bLen ? aLen : bLen

    for (var i = 0; i < len; i++) {
        var leftNode = aChildren[i]
        var rightNode = bChildren[i]
        index += 1

        // 旧节点子节点为 null ,新子节点直接插入
        if (!leftNode) {
            if (rightNode) {
                apply = appendPatch(apply,
                    new VPatch(VPatch.INSERT, null, rightNode))
            }
        } else {
            // 递归
            walk(leftNode, rightNode, patch, index)
        }

        // 跳过这个子节点
        if (isVNode(leftNode) && leftNode.count) {
            index += leftNode.count
        }
    }

    // 是否有需要移动的操作
    // 只有当有节点有 key 属性时,才会需要移动。
    if (orderedSet.moves) {
        apply = appendPatch(apply, new VPatch(
            VPatch.ORDER,
            a,
            orderedSet.moves
        ))
    }

    return apply
}

6. patch - 将 patch 更新 dom 节点

通过上一步差异对比,已经拿到 patch 对象,接下来,需要做的,就是依照这个 patch 对象,将 dom 进行更新。在 patchRecursive 中,通过遍历 patch 对象依次操作相应 dom 节点。

// @vdom/patch.js
function patchRecursive(rootNode, patches, renderOptions) {
    var indices = patchIndices(patches)

    if (indices.length === 0) {
        return rootNode
    }

    var index = domIndex(rootNode, patches.a, indices)
    var ownerDocument = rootNode.ownerDocument

    if (!renderOptions.document && ownerDocument !== document) {
        renderOptions.document = ownerDocument
    }

    for (var i = 0; i < indices.length; i++) {
        var nodeIndex = indices[i]
        rootNode = applyPatch(rootNode,
            index[nodeIndex],
            patches[nodeIndex],
            renderOptions)
    }

    return rootNode
}

前面有提过,patches 对象的数字 key 为子节点索引,value 为相应的 VPatch。那么看看,这里做了些什么,过程如下:

  1. 通过 patchIndices 方法将索引取出组成数组 indices。
  2. 通过 domIndex 方法将节点索引和对应的 dom 节点映射上,生成 index 对象。

在 domIndex 方法中有个细节需要提一下。代码如下:
// @vdom/dom-index.js
indices.sort(ascending)

可能大家会想了,patch 对象中不都已经是这样的吗?

{
  0: { ... },
  1: { ... },
  2: { ... },
  a: { ... }
}

那么通过 patchIndices 中 for in 遍历 patches 生成出来的数组不应该就是递增的吗?为何还要在 domIndex 显式进行一次升序排序呢?

由于对象是 key-value 结构,无序的,无法完全保证在不同浏览器下,通过 for in 遍历出的顺序一致。所以,这里为了确保表现一致,显式地进行了一次升序排序。

接着通过在 applyPatch 中,调用 patchOp 给节点打上相应的 VPatch,也就是对 dom 进行操作,实现视图更新。结构较简单,这里就不多说了。部分代码如下:


// @vdom/patch-op.js
function applyPatch(vpatch, domNode, renderOptions) {
    var type = vpatch.type
    var vNode = vpatch.vNode
    var patch = vpatch.patch

    switch (type) {
        case VPatch.REMOVE:
            return removeNode(domNode, vNode)
        case VPatch.INSERT:
            return insertNode(domNode, patch, renderOptions)
        ...
    }
}
到目前为止,已经完成了 virtual-dom 从创建、状态更新、重新渲染的整个过程。

7. reorder - 重新排序

不得不说,文章已经较长了,但抱歉还没结束。diff 阶段中有个当时被一笔带过的 reorder 方法。这里打算单独谈一谈。在 virtual-dom 的过程中,这个方法起到了很重要的作用。

reorder 会用在哪些场景呢?以 Vue 的语法,举几个栗子。

<!--
遍历对象
object 是个对象,结构如下
object = {
 key1: value1
};
-->
<!-- case 1  -->
<ul>
  <li v-for="(key, val) in object">
  </li>
</ul>

<!-- case 2  -->
<ul>
  <li v-for="value in object">
    {{ $key }} : {{ value }}
  </li>
</ul>

<!--
items 是个数组,结构如下
items = {
 {
   _uid: '...'
 }...
}
-->
<!-- case 3  -->
<ul>
  <li v-for="item in items"></li>
</ul>

<!-- case 4  -->
<ul>
  <li v-for="item in items" track-by="_uid"></li>
</ul>

case 1、case 2 为遍历对象:之前谈过,对象由于是 key-value 结构,遍历顺序会有表现不确定性。所以这里内部需要调用 reorder 强制 key 按照第一次渲染的顺序进行排序,这样每次状态的更新,都能按照相对固定的顺序进行差异对比,性能最佳.

case 3、case 4 为遍历数组:这里又分了两种,区别在于是否用 track-by。问题来了,两种使用有啥区别呢,哪种更好。带着这个问题,我们开始对 reorder 的探索。

方法的最开始,进行了两个特殊逻辑判断。

// @vtree/diff.js
...

if (bFree.length === bChildren.length) {
    return {
        children: bChildren,
        moves: null
    }
}

...

if (aFree.length === aChildren.length) {
    return {
        children: bChildren,
        moves: null
    }
}

我们大多数时候不加 track-by 列表循环,在这里就直接 return 了。这时,是否会有疑惑,这样不就足够了吗,直接返回新的子节点,然后按次序和旧子节点进行对比,对结果也不会有影响。我们来看看下面这个栗子。

var oldItems = [
  {
    uid: '1',
    value: 1
  },
  {
    uid: '2',
    value: 2
  },
  {
    uid: '3',
    value: 3
  }
];

var newItems = [
  {
    uid: '3',
    value: 3
  },
  {
    uid: '1',
    value: 1
  },
  {
    uid: '2',
    value: 2
  }
];

不用 track-by 的场景下,在做 diff 时,会直接按照数组的顺序进行比较,结果是,所有节点都有状态更新,然后执行 3 条 dom 更新操作。这显示是没必要的,因为列表数据并没有发生改变,只是位置改变。(当然前提是,业务只关心数据,不关心顺序)由于 Js 的执行速度是比 dom 操作快很多。所以为了尽量减少 dom 操作,预先以 uid 为 key 将 newItems 按照 oldItems 的顺序进行排序,再做 diff ,则无需更新 dom 了。

那么 reorder 是如何重新排序的呢?先上个图:


首先要明确一点,在 reorder 阶段的排序,并没有真正将 newChildren 进行重新排序,而只是生成一个 insert 和 remove 操作记录数组,将在 patch 阶段时对 dom 节点进行操作。

为了辅助说明,这里将 reorder 的过程粗略分为四个阶段,分别称之为 「 准备阶段 」,「 key 类型顺序还原阶段 」,「 新增 key 添加阶段 」,「 新旧顺序转换阶段 」。

同时还定义了 「 key 类型 」这个概念,分为 3 种类型。

  • 无 key 节点: 如 { VText('string') }
  • key 节点:如 { h('div', { key: 'key1' }) }, { h('div', { key: 'key2' }) }
  • null 节点:标识被删除的元素

1. 准备阶段

newChildren,oldChildren 为新旧子节点。这里设定的两个新旧子节点还是比较有典型性的。特点如下:

  • 有新增 key 的节点
  • 有删除 key 的节点
  • 有相同 key 的节点
  • 有无 key 节点

2. 相同 key 还原阶段

这个阶段的原则是,按照 oldChildren 子节点的 key 类型顺序,将 newChildren 还原回去。如旧子节点 key 顺序为

[无key, key1, 无key, key2]

还原步骤如下:

  1. oldChildren 第一个节点为无 key 节点,对应的 newChildren 中的第一个无 key 节点为 b2 。
  2. oldChildren 第二个节点为 key1 节点,newChildren 中的 key1 节点为 b1。

依次类推,得出 simulateChildren 数组为:
[b2, b1, b4, null]

为啥最后是个 null 呢,因为 oldChildren 中的 key2 在 newChildren 中并没有找到,表示被删除。

3. 新增 key 添加阶段

在上面阶段,只是完成了按照旧节点 key 类型顺序,将新节点进行了一个还原。但对于新节点中的新 key 类型节点并没有处理。这个阶段则是将新 key 类型的节点,插到 simulateChildren 结尾。

[b2, b1, b4, null, b3]

4. 新旧顺序转换阶段

这一步算法还是有点绕,建议直接看源码,一步步来观察转换的过程(其实就是我文字太弱,表达不清楚。 = =)。总结起来就是,如何将 simulateChildren的 key 类型顺序 转换成 newChildren 的 key 类型顺序的过程。

[无key, key1, 无key, null, key3] ==> [key1, 无key, key3, 无key]

最后会生成一个操作队列对象 moves。这里有个问题,为啥将 simulateChildren 变成 newChildren 的过程就是 oldChildren 变成 newChildren 的过程呢?

很显然了,因为通过 2,3 两步,已经将 simulateChildren 的 key 类型顺序和 oldChildren 做成一样了。

所以,是否适合用 track-by 要看具体的场景。如果子节点状态更新幅度很大,重复数据数据较少,如翻页,就不适合用 track-by ,省去 reorder 这一步直接做 diff。如果是较少数据会更新,如对于往固定列表中插入一行数据或者几行数据的变更等等,这时用 track-by 可以减少 dom 操作。



8. 结尾

好吧,差不多了。暂时能够聊的就这么多。(这次是真要结束了)

因理解深度,文字水平有限。有没说清楚的地方请各位看官多包涵,有误的地方,请留言指正。谢谢。

^_^