vue系列 -- 虚拟 DOM

604 阅读7分钟

前言

JS 操作真实 DOM 的代价?

用我们传统的开发模式,原生 JS 或 JQ 操作 DOM 时,浏览器会从构建 DOM 树开始从头到尾执行一遍流程。

如果在一次操作中,我需要更新 10 个 DOM 节点,浏览器收到第一个 DOM 请求后并不知道还有 9 次更新操作,因此会马上执行流程,最终执行了 10 次

例如,第一次计算完,紧接着下一个 DOM 更新请求,这个节点的坐标值就变了前一次计算为无用功。计算 DOM 节点坐标值等都是白白浪费的性能。即使计算机硬件一直在迭代更新,操作 DOM 的代价仍旧是昂贵的,频繁操作还是会出现页面卡顿,影响用户体验

虚拟 DOM

虚拟 DOM 的好处

虚拟 DOM 就是为了解决浏览器性能问题而被设计出来的。

如前,若一次操作中有 10 次更新 DOM 的动作,虚拟 DOM 不会立即操作 DOM,而是将这 10 次更新的 diff 内容保存到本地一个 JS 对象中,最终将这个 JS 对象一次性 attch 到 DOM 树上,再进行后续操作,避免大量无谓的计算量

所以,用 JS 对象模拟 DOM 节点的好处是,页面的更新可以先提前全部反映在 JS 对象 (即虚拟 DOM )上,操作 内存中的 JS 对象的速度显然要更快,等更新完成后,再将最终的 JS 对象映射成真实的 DOM,然后再交由浏览器去绘制

算法实现

1. 用 JS 对象模拟 DOM 树

例如一个真实的 DOM 节点如下:

<div id="virtual-dom">
<p>Virtual DOM</p>
<ul id="list">
  <li class="item">Item 1</li>
  <li class="item">Item 2</li>
  <li class="item">Item 3</li>
</ul>
<div>Hello World</div>
</div> 

我们用 JavaScript 对象来表示 DOM 节点,使用对象的属性记录节点的类型属性子节点等。

结构设定为:Element ( 'tagName' , { class / id : 'className / idName' } , ['内容' / 子标签] )

根据 Element 对象的设定,则上面的 DOM 结构就可以简单表示为:

var el = createElement // 调用 createElement 函数
var tree = el('div',{id:'virtual-dom'},[
  el('p',{},['Virtual DOM']),
  el('ul', { id: 'list' }, [
	el('li', { class: 'item' }, ['Item 1']),
	el('li', { class: 'item' }, ['Item 2']),
	el('li', { class: 'item' }, ['Item 3'])
  ]),
  el('div',{},['Hello World'])
]) 

Element 构造函数、及调用它的 createElement 函数代码如下:

/**
 * Element virdual-dom 对象定义
 * @param {String} tagName - dom 元素名称
 * @param {Object} props - dom 属性
 * @param {Array<Element|String>} - 子节点
 */
function Element(tagName, props, children) {
    this.tagName = tagName
    this.props = props
    this.children = children
    // dom 元素的 key 值,用作唯一标识符
    if(props.key){
       this.key = props.key
    }
    var count = 0
    children.forEach(function (child, i) {
        if (child instanceof Element) {
            count += child.count
        } else {
            children[i] = '' + child
        }
        count++
    })
    // 子元素个数
    this.count = count
}

function createElement(tagName, props, children){
    return new Element(tagName, props, children);
}

创建的 tree 就是我们用 JavaScript 对象表示的 虚拟 DOM,我们输出查看 虚拟 DOM 如下:

image.png

2. 将用 JS 表示的 DOM 对象转化为真实 DOM

但是页面上并没有这个结构,下一步我们介绍如何将 tree 渲染成页面上真实的 DOM 结构,渲染函数 render 代码如下:

/**
 * render 将virdual-dom 对象渲染为实际 DOM 元素
 */
Element.prototype.render = function () {
    var el = document.createElement(this.tagName)
    var props = this.props
    // 设置节点的DOM属性
    for (var propName in props) {
        var propValue = props[propName]
        el.setAttribute(propName, propValue)
    }

    var children = this.children || []
    children.forEach(function (child) {
        var childEl = (child instanceof Element)
            ? child.render() // 如果子节点也是虚拟DOM,递归构建DOM节点
            : document.createTextNode(child) // 如果字符串,只构建文本节点
        el.appendChild(childEl)
    })
    return el
} 

我们通过查看以上 render 方法,会根据 tagName 构建一个真正的 DOM 节点,然后设置这个节点的属性,最后递归地把自己的子节点也构建起来。

我们将构建好的 虚拟 DOM 使用 render 函数转化为 真实 DOM ,并添加到页面 body 上面,代码如下:

var root = ul.render();
console.log(root)

这样,页面 body 里面就有真正的 DOM 结构:

image.png

document.body.appendChild(root); 

我们已经完成了创建虚拟 DOM 并将其映射成真实 DOM,这样所有的更新都可以先反应到虚拟 DOM 上,如何反应?需要用到 Diff 算法

diff 算法

Virtual DOM 的 diff 的目的就是比较新旧 Virtual DOM Tree 找出差异并更新。diff 算法是直接影响 Virtual DOM 性能的关键部分

两棵树如果完全比较时间复杂度是 O(n^3),但参照《深入浅出React和Redux》一书中的介绍,React的Diff算法的时间复杂度是 O(n)。要实现这么低的时间复杂度,意味着只能平层的比较两棵树的节点,放弃了深度遍历。这样做,似乎牺牲掉了一定的精确性来换取速度,但考虑到现实中前端页面通常也不会跨层移动DOM元素,这样做是最优的。

在实际代码中,会对新旧两棵树进行一个深度的遍历,每个节点都会有一个标记。每遍历到一个节点就把该节点和新的树进行对比,如果有差异就 记录到一个对象中

var el = createElement
// 老树
var oldTree = el('div',{id:'virtual-dom'},[
  el('p',{},['Virtual DOM']),
  el('div',{},['before update']),
  el('ul' , [
	el('li', { class: 'item' }, ['Item 1']),
	el('li', { class: 'item' }, ['Item 2']),
	el('li', { class: 'item' }, ['Item 3'])
  ]),
  el('div',{},['Hello World'])
]) 
// 新树
var newTree = el('div',{id:'virtual-dom'},[
  el('h3',{},['Virtual DOM']), // REPLACE
  el('div',{},['before update']), // TEXT
  el('ul', { class: 'marginLeft10' }, [ // PROPS
	el('li', { class: 'item' }, ['Item 1']),
	// el('li', { class: 'item' }, ['Item 2']), // REORDER
	el('li', { class: 'item' }, ['Item 3'])
  ]),
  el('div',{},['Hello World'])
]) 

平层 Diff,只有以下 4 种情况

  1. 节点类型变了,例如下图中的 p 变成了 h3。我们将这个过程称之为 REPLACE。直接将旧节点卸载并装载新节点。旧节点包括下面的子节点都将被卸载,如果新节点和旧节点仅仅是类型不同,但下面的所有子节点都一样时,这样做效率不高。但为了避免 O(n^3) 的时间复杂度,这样是值得的。这也提醒了开发者,应该避免无谓的节点类型的变化,例如运行时将 div 变成 p 没有意义。

  2. 节点类型一样,仅仅属性或属性值变了。我们将这个过程称之为 PROPS。此时不会触发节点卸载和装载,而是节点更新。

  3. 文本变了,文本对也是一个 Text Node,也比较简单,直接修改文字内容就行了,我们将这个过程称之为 TEXT

  4. 移动/增加/删除 子节点,我们将这个过程称之为 REORDER。看一个例子,在A、B、C、D、E五个节点的 B 和 C 中的 BC 两个节点中间加入一个 F 节点

我们简单粗暴的做法是遍历每一个新虚拟DOM的节点,与旧虚拟DOM对比相应节点对比,在旧DOM中是否存在,不同就卸载原来的按上新的。这样会对F后边每一个节点进行操作。卸载C,装载F,卸载D,装载C,卸载E,装载D,装载E。效率太低

如果我们在 JSX 里为数组或枚举型元素增加上 key 后,它能够根据 key,直接找到具体位置进行操作,效率比较高。常见的最小编辑距离问题,可以用 Levenshtein Distance 算法来实现,时间复杂度是 O(M * N),但通常我们只要一些简单的移动就能满足需要,降低精确性,将时间复杂度降低到 O(max(M,N)) 即可

最终 diff 出来的结果:

根据 diff 结果更新真实 DOM

虚拟 DOM 有了,Diff 也有了,现在就可以将 Diff 应用到真实 DOM 上了。深度遍历 DOM 将 Diff 的内容更新进去

我的理解是:

遍历 diff 的结果的每一项,我们对应操作 dom 节点,因此能够实现把多次的 dom 操作集中为一次

image.png

如若有错误或大家有更好的理解,欢迎评论区指出~

参考文章