Virtual DOM

Virtual DOM 本质上就是在 JS 和 DOM 之间做了一个缓存 (JS 只操作 Virtual DOM, 最后再把变更写入 DOM), 减少了 JS 操作真实 DOM 带来的性能消耗, 其最大的优势就是抽象了原本的渲染过程, 实现了跨平台的能力, 而不是局限于浏览器的 DOM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Virtual DOM 的结构
// 包括标签名、拥有的属性、子节点、render 函数
function Element(tagName, props, children) {
this.tagName = tagName;
this.props = props;
this.children = children;
}
// 这个函数是用来生成真实DOM的,最后会把 return 的结果添加到页面中去
Element.prototype.render = function () {
// 根据 tagName 创建
var el = document.createElement(this.tagName);
var props = this.props || {};
var children = this.children || [];
// 设置节点的 DOM 属性
for(var propName in props) {
var propValue = props[propName];
el.setAttribute(propName, propValue);
}
children.forEach(function (child) {
var childEl = (child instanceof Element)
? child.render() // 如果子节点也是虚拟 DOM, 递归构建 DOM 节点
: document.createTextNode(child) // 如果字符串,只构建文本节点
el.appendChild(childEl);
})
return el;
}

主流的虚拟 DOM 库中, 都会有一个 h 函数, 这也就是 React 中的 React.createElement 以及 Vue 中 render 方法的 createElement, React 是通过 babel 将 jsx 转换成 h 函数渲染的形式(babel 会找到所有的 jsx 节点并把它们转成 h 函数调用), 而 Vue 是使用 vue-loader 将模版转为 h 函数渲染的形式

h 代表 hyperscript, 最先开始在 JS 中编写 HTML 的框架之一;

h 函数使用 JSX 的返回值作为参数, 创建了一个 VNode 的东西, 一个 VNode 是一个 JS 对象, 代表一个 DOM 节点, 其中包含了它的属性和子节点;

1
2
3
4
5
{
nodeName: '',
attributes: {},
children: []
}

h 函数不会创建整棵树, 只会为指定的节点创建一个 JS 对象, 但由于 render 方法已经得到了树结构的 DOM JSX, 最终产出的结果就会是一个带有子节点、孙节点的 VNode, 看上去就像一棵树;

1
2
3
4
5
6
7
8
9
10
11
12
// 1. JSX
<div id="app">
<p class="text">hello world</p>
</div>
// 2. Pure JS / Babel JSX
h(
'div',
{ id: 'app' },
h('p', { class: 'text' }, 'hello world')
)
// 3. 挂载到真实 DOM 的主入口
render(h(FilteredList), document.getElementById('app'))

workflow

思考: Virtual DOM 的优势在哪里?

旨在对其解决的问题及为什么频繁的 DOM 操作性能差进行思考, 首先需要知道的:

  • DOM 引擎、JS 引擎相互独立, 却有工作在同一个线程(主线程)
  • JS 代码调用 DOM API 必须挂起 JS 引擎、转换传入参数数据、激活 DOM 引擎, DOM 重绘后再转换可能有的返回值, 最后激活 JS 引擎并继续执行
  • 如有频繁的 DOM API 调用, 且浏览器厂商不做批量处理优化, 那么引擎间切换的单位代价将迅速累积, 若其中有强制重绘的 DOM API 调用, 重新计算布局、重新绘制图像会引起更大的性能消耗

其次是 Virtual DOM 和真实 DOM 的区别和优化

  1. Virtual DOM 不会立马进行排版与重绘操作
  2. Virtual DOM 进行频繁修改, 然后一次性比较并修改真实 DOM 中需要改的部分, 最后在真实 DOM 中进行排版和重绘, 减少过多 DOM 节点排版和重绘的损耗
  3. Virtual DOM 有效降低大面积真实 DOM 的重绘与排版, 因为最终与真实 DOM 比较差异, 可以只渲染局部

DOM-DIFF

传统的 Virtual DOM 的复杂度为 O(n^3), 即两棵树完全相比计算; 而 DOM-DIFF 算法中, 通过一些策略, 如计算出 Virtual DOM 中真正变化的部分, 并只针对该部分进行实际 DOM 操作, 而非重新渲染整个页面, 从而保证每次操作更新后页面的高效渲染, 使得这个问题变成一个复杂度为 O(n) 的问题

dom-diff

DOM-DIFF 策略

  1. UI 中 DOM 节点跨层级的移动操作特别少, 可以忽略不计
  2. 拥有相同类的两个组件将会生成相似的属性结构, 拥有不同类的两个组件会生成不同的树形结构
  3. 对于同一层级的一组子节点, 可以通过唯一 id 来进行区分

基于以上三个策略, React 对 tree diff、component diff 以及 element diff 进行算法优化

tree diff - 虚拟 DOM 树

分层求异

基于策略一, React 对树的算法进行了优化, 即对树进行分层比较, 两棵树只会对同一层级的节点进行比较

React 通过 updateDepth 对 Virtual DOM 树进行层级控制, 即同一个父节点下的所有子节点; 当发现节点已经不存在, 则该节点及其子节点会被完全删除, 不会进一步比较, 这样只要对树进行一次遍历, 就可以完成整个 DOM 树的比较

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function updateChildren(nextNestedChildrenElements, transaction, context) {
updateDepth++;
var errorThrown = true;
try {
this._updateChildren(nextNestedChildrenElements, transaction, context);
errorThrown = false;
} finally {
updateDepth--;
if (!updateDepth) {
if (errorThrown) {
clearQueue();
} else {
processQueue();
}
}
}
}

注意: 在开发过程中, 尽量不要进行 DOM 节点跨层级的操作; 保持稳定的 DOM 结构有助于性能的提升; 例如: 可以使用 CSS 隐藏/显示节点, 而不是真的去删除/添加

component diff - 组件

相同类生成类似树形结构, 不同类生成不同树形结构

React 是组件化开发, 对于组件间的比较所采取的策略也是简洁、高效

  • 如果是同一类型的组件, 按照原策略继续比较 Virtual DOM Tree
  • 如果不是同一类型的组件, 将该组件判断为 dirty component, 从而替换整个组件下的所有子节点
  • 对于同一类型的组件, React 允许用户使用 shouldComponentUpdate 来判断 Virtual DOM 是否变化、组件是否需要进行 diff 运算, 从而节省 diff 运算时间

element diff - 元素

设置唯一 key

当节点处于同一层级时, diff 提供了三种节点操作, 分别是:

  • INSERT_MARKUP (插入): 新的 component 类型不再原有集合中, 即为新的节点, 需要对新节点执行插入操作

  • MOVE_EXISTING (移动): 来原有集合中有新的 component 类型, 且 element 时可更新的类型, generateComponentChildren 已调用 receiveComponent, 这种情况下 prevChild = nextChild, 只需要做移动操作, 可以复用以前的 DOM 节点

  • REMOVE_NODE (删除): 老 component 类型在新的集合中也存在, 但对应的 element 不同则不能直接复用和更新, 需要执行删除操作, 或者老 component 不再新的集合中, 也需要执行删除操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    function enqueueInsertMarkup(parentInst, markup, toIndex) {
    updateQueue.push({
    type: ReactMultiChildUpdateTypes.INSERT_MARKUP
    parentInst: parentInst,
    parentNode: null,
    markupIndex: markupQueue.push(markup) - 1,
    content: null,
    fromIndex: null,
    toIndex: toIndex
    });
    }

    function enqueueMove(parentInst, fromIndex, toIndex) {
    updateQueue.push({
    type: ReactMultiChildUpdateTypes.MOVE_EXISTING
    parentInst: parentInst,
    parentNode: null,
    markupIndex: null,
    content: null,
    fromIndex: fromIndex,
    toIndex: toIndex
    });
    }

    function enqueueRemove(parentInst, fromIndex) {
    updateQueue.push({
    type: ReactMultiChildUpdateTypes.REMOVE_NODE
    parentInst: parentInst,
    parentNode: null,
    markupIndex: null,
    content: null,
    fromIndex: fromIndex,
    toIndex: null
    });
    }

    但是, 有很多相同的节点只是因为位置发生变化, 导致进行很多无用的增删操作, 而需要做的只是将其位置移动即可, 针对这个问题, React 允许开发者对同一层级的同组子节点添加一个唯一 key 来进行区分

    原理: 首先对新集合的节点进行遍历, for (name in nextChildren) 通过唯一 key 可以判断新老集合中是否存在相同节点, if (prevChild === nextChild) 如果存在相同节点, 则进行移动操作, 但在移动前需要将当前节点在老集合中的位置与 lastIndex 进行比较, if (child._mountIndex < lastIndex) 则进行节点移动操作, 否则不执行该操作; 这是一种优化手段, lastIndex 一直在更新, 表示访问过的节点在老集合中最右的位置(即最大的位置), 如果新集合中当前访问的节点 比 lastIndex 大, 说明当前访问节点在老集合中就比上一个节点位置靠后, 则该节点不会影响其他节点的位置, 因此不用添加到差异队列中(即不执行移动操作), 只有当前访问的节点比 lastIndex 小时, 才需要进行移动操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    function _updateChildren(nextNestedChildrenElements, transaction, context) {
    var prevChildren = this._renderedChildren;
    var nextChildren = this._reconcilerUpdateChildren(prevChildren, nextNestedChildrenElements, transaction, context);
    if (!nextChildren && !prevChildren) {
    return
    }
    var name;
    var lastIndex = 0;
    var nextIndex = 0;
    for (name in nextChildren) {
    if (!nextChildren.hasOwnProperty(name)) {
    continue;
    }
    var prevChild = prevChildren && prevChildren[name];
    var nextChild = nextChildren[name];
    if (prevChild === nextChild) {
    // Move Node
    this.moveChild(prevChild, nextChild, lastIndex);
    lastIndex = Math.max(prevChild._mountIndex, lastIndex);
    prevChild._mountIndex = nextIndex;
    } else {
    if (prevChild) {
    lastIndex = Math.max(prevChild._mountIndex, lastIndex);
    // Remove Node
    this._unmountChild(prevChild);
    }
    // Initialize & Create Node
    this._mountChild(nextChild, nextIndex, transaction, context);
    }
    nextIndex++;
    }
    for (name in prevChildren) {
    if (prevChildren.hasOwnProperty(name) &&
    !(nextChildren && nextChildren.hasOwnProperty(name))) {
    this._unmountChild(prevChildren[name]);
    }
    }
    this._renderedChildren = nextChildren;
    }
    // Move Node
    function moveChild(child, toIndex, lastIndex) {
    if (child._mountIndex < lastIndex) {
    this.prepareToManageChildren();
    enqueueMove(this, child._mountIndex, toIndex);
    }
    }
    // Create Node
    function createChild(child, mountImage) {
    this.prepareToManageChildren();
    enqueueInsertMarkup(this, mountImage, child._mountIndex);
    }
    // Remove Node
    function removeChild(child) {
    this.prepareToManageChildren();
    enqueueRemove(this, child._mountIndex);
    }

    function _unmountChild(child) {
    this.removeChild(child);
    child._mountIndex = null;
    }
    function _mountChildAtIndex(child, index, transaction, context) {
    var mountImage = ReactReconciler.mountComponent(child, transaction, this, this._nativeContainerInfo, context);
    child._mountIndex = index;
    this.createChild(child, mountImage);
    }
  • 如何理解 Virtual DOM - via 知乎

  • Virtual DOM 的简单实现 - 戴嘉华 Gitee

  • React Diff - via 知乎

  • 虚拟 DOM 渲染原理