专栏名称: 寒东设计师
前端工程师
51好读  ›  专栏  ›  寒东设计师

浅入浅出图解domDIff

寒东设计师  · 掘金  ·  · 2018-04-18 02:02

正文

浅入浅出图解domDIff

虚拟DOM/domDiff

我们常说的虚拟DOM是通过JS对象模拟出来的 DOM 节点,domDiff是通过特定算法计算出来一次操作所带来的 DOM 变化。
react和vue中都使用了虚拟DOM,vue我只停留在使用层面就不多说了,react了解多一些,就借着react聊聊虚拟DOM。
react中涉及到虚拟DOM的代码主要分为以下三部分,其中核心是第二步的domDiff算法:

  • 把render中的JSX(或者createElement这个API)转化成虚拟DOM
  • 状态或属性改变后重新计算虚拟DOM并生成一个补丁对象(domDiff)
  • 通过这个补丁对象更新视图中的DOM节点

虚拟DOM不一定更快

干前端的都知道 DOM 操作是性能杀手,因为操作 DOM 会引起页面的回流或者重绘。相比起来,通过多一些预先计算来减少 DOM 的操作要划算的多。
但是,“使用虚拟DOM会更快”这句话并不一定适用于所有场景。例如:一个页面就有一个按钮,点击一下,数字加一,那肯定是直接操作 DOM 更快。使用虚拟DOM无非白白增加了计算量和代码量。即使是复杂情况,浏览器也会对我们的 DOM 操作进行优化,大部分浏览器会根据我们操作的时间和次数进行批量处理,所以直接操作 DOM 也未必很慢。
那么为什么现在的框架都使用虚拟DOM呢?因为使用虚拟DOM可以提高代码的性能下限,并极大的优化大量操作DOM时产生的性能损耗。同时这些框架也保证了,即使在少数虚拟DOM不太给力的场景下,性能也在我们接受的范围内。
而且,我们之所以喜欢react、vue等使用了虚拟DOM框架,不光是因为他们快,还有很多其他更重要的原因。例如react对函数式编程的友好,vue优秀的开发体验等,目前社区也有好多比较这两个框架并打口水战的,我觉着还是在两个都懂的情况下多探究一下原理更有意义一些。

实现domDiff的思路

实现domDiff分为以下四步:

  1. 用JS模拟真实DOM节点
  2. 把虚拟DOM转换成真实DOM插入页面中
  3. 发生变化时,比较两棵树的差异,生成差异对象
  4. 根据差异对象更新真实DOM

设计师的老本行不能忘,看我画张图:

解释一下这张图:
首先看第一个红色色块,这里说的是把真实 DOM 映射为虚拟 DOM ,其实在react中没有这个过程,我们直接写的就是虚拟DOM(JSX),只是这个虚拟 DOM 代表着真实 DOM
当虚拟DOM变化时,例如上图,它的第三个 p 和第二个 p 中的 son2 被删除了。这个时候我们会根据前后的变化计算出一个差异对象 patches
这个差异对象的key值就是老的 DOM 节点遍历时的索引,用这个索引我们可以找到那个节点。属性值是记录的变化,这里是 remove ,代表删除。
最后,根据 patches 中每一项的索引去对应的位置修改老的 DOM 节点。

代码如何实现呢?

通过虚拟DOM创建真实DOM

下面这段代码是入口文件,我们模拟了一个虚拟DOM叫 oldEle ,我们这里是写死的。而在react中,是通过babel解析JSX语法得到一个抽象语法树(AST),进而生成虚拟DOM。如果对babel转换感兴趣,可以看看另一篇文章 入门babel--实现一个es6的class转换器

import { createElement } from './createElement'

let oldEle = createElement('div', { class: 'father' }, [
    createElement('h1', { style:'color:red' }, ['son1']),
    createElement('h2', { style:'color:blue' }, ['son2']),
    createElement('h3', { style:'color:red' }, ['son3'])
])
document.body.appendChild(oldEle.render())

下面这个文件导出了 createElement 方法。它其实就是 new 了一个 Element 类,调用这个类的 render 方法可以把虚拟 DOM 转换为真实 DOM

class Element {
    constructor(tagName, attrs, childs) {
        this.tagName = tagName
        this.attrs = attrs
        this.childs = childs
    }
    render() {
        let element = document.createElement(this.tagName)
        let attrs = this.attrs
        let childs = this.childs
        //设置属性
        for (let attr in attrs) {
            setAttr(element, attr, attrs[attr])
        }
        //先序深度优先遍历子创建并插入子节点
        for (let i = 0; i < childs.length; i++) {
            let child = childs[i]
            console.log(111, child instanceof Element)
            let childElement = child instanceof Element ? child.render() : document.createTextNode(child)
            element.appendChild(childElement)
        }
        return element
    }
}
function setAttr(ele, attr, value) {
    switch (attr) {
        case 'style':
            ele.style.cssText = value
            break;
        case 'value':
            let tageName = ele.tagName.toLowerCase()
            if (tagName == 'input' || tagName == 'textarea') {
                ele.value = value
            } else {
                ele.setAttribute(attr, value)
            }
            break;
        default:
            ele.setAttribute(attr, value)
            break;
    }
}
function createElement(tagName, props, child) {
    return new Element(tagName, props, child)
}
module.exports = { createElement }

现在这段代码已经可以跑起来了,执行以后的结果如下图:

DIff算法

//keyIndex记录遍历顺序
let keyIndex = 0
function diff(oldEle, newEle) {
    let patches = {}
    keyIndex = 0
    walk(patches, 0, oldEle, newEle)
    return patches
}
//分析变化
function walk(patches, index, oldEle, newEle) {
    let currentPatches = []
    //这里应该有很多的判断类型,这里只处理了删除的情况...
    if (!newEle) {
        currentPatches.push({ type: 'remove' })
    }
    else if (oldEle.tagName == newEle.tagName) {
        //比较儿子们
        walkChild(patches, currentPatches, oldEle.childs, newEle.childs)
    }
    //判断当前节点是否有改变,有的话把补丁放入补丁集合中
    if (currentPatches.length) {
        patches[index] = currentPatches
    }
}
function walkChild(patches, currentPatches, oldChilds, newChilds) {
    if (oldChilds) {
        for (let i = 0; i < oldChilds.length; i++) {
            let oldChild = oldChilds[i]
            let newChild = newChilds[i]
            walk(patches, ++keyIndex, oldChild, newChild)
        }
    }
}
module.exports = { diff }

这段代码就是domDiff算法的超级简化版本:

  • 首先声明一个变量记录遍历的顺序
  • 执行walk方法分析变化,如果两个元素tagName相同,递归遍历子节点

其实walk中应该有大量的逻辑,我只处理了一种情况,就是元素被删除。其实还应该有添加、替换等各种情况,同时涉及到大量的边界检查。真正的domDiff算法很复杂,它的复杂度应该是O(n3),react为了把复杂度降低到线性而做了一系列的妥协。
我这里只是选取一种情况做了演示,有兴趣的可以看看源码或者搜索一些相关的文章。这篇文章毕竟叫“浅入浅出”,非常浅……

好,那我们执行这个算法看看效果:

import { createElement } from './createElement'
import { diff } from './diff'

let oldEle = createElement('div', { class: 'father' }, [
    createElement('h1', { style: 'color:red' }, ['son1']),
    createElement('h2', { style: 'color:blue' }, ['son2']),
    createElement('h3', { style: 'color:red' }, ['son3'])
])
let newEle = createElement('div', { class: 'father' }, [
    createElement('h1', { style: 'color:red' }, ['son1']),
    createElement('h2', { style: 'color:blue' }, [])
])
console.log(diff(oldEle, newEle))

我在入口文件中新创建了一个元素,用来代表被更改之后的虚拟DOM,它有两个元素被删除了,一个 h3 、一个文本节点 son2 ,理论上应该有两条记录,执行代码我们看下:







请到「今天看啥」查看全文