混子前端React diff探究

3,085 阅读8分钟

最近混子前端想探究一波 React diff,发现网上博客能讲通俗易懂的很少(也可能本人水平有限),所以打算四处借鉴,更新一篇 React 老版本的 diff 探究,虽然大家现在可能都在使用 React Hooks,但也许这和我本人一样总是后知后觉,好了,废话不多说,进入正文:

前言

React render 函数每次执行都会生成一个新的 Virtual DOM Tree,diff 算法就是比较前后两次 render 函数生成的 Virtual DOM Tree 的差异性,相比通过传统 diff 方法比较随机两棵树差异的O(n^3)时间复杂度,React diff 算法可以将时间复杂度降至O(n)(n为树中的节点树)

通过 diff 算法,React 可以找出新老 Virtual DOM Tree 的最小差异集,下一步就是要把这些变化通过最少步骤更新到真是DOM节点上,React 把构造新Virtual DOM -> 执行diff算法 -> 更新老Virtual DOM -> 更新真实DOM 这一系列称为 reconciliation 流程。

以下是 render 函数执行后的组件生命周期图 (帮助大家更好理解):

React Element

react Element分为两种:DOM Element 和 Component Element。

DOM Element:浏览器原生支持的DOM元素(div、p等);

Component Element:通过 React Component 构建出来的元素;

Virtual DOM

以 React15 为例:

// virtual dom
const vDom = <div>virtual dom</div>

// babel 转义后生成的js对象
const vDomObj = {
    ?typeof: Symbol(react.element),
    key: null,
    ref: null,
    type: div,
    props: {
        children: 'virtual dom'
    },
    _owner: {...},
    _store: {validated: false}
}

vDomObj 各属性含义:

diff算法

React 官网中描述的 diff 算法基于以下两个假设:

1、不同类型的元素产生会产生不同的节点数。

2、不同子元素可以通过 key 属性来保持稳定。

除此之外,还有一个隐藏的假设:

1、Web UI 中的 DOM 节点的跨层级操作特别少,可以忽略不计。

分层比较

React 对树的分层比较策略,即diff算法在比较新老 Virtual DOM Tree 的时候,只会比较同层级的 Virtual DOM:

diff 算法只会比较相同颜色框中的节点,这种分层比较策略会让 diff 广度遍历一次 Virtual DOM Tree 后就完成整棵树的比较。

在实际使用 React 过程中,无法避免跨层级操作,如下图:

react-cross-level.png

场景:想将 A节点 从 B节点 下方跨层级移动到 R节点 下方,这种情况,diff 算法整个过程:

find new R.A -> create R.A -> B.A not exists -> delete B.A

算法不会把 B.A 完整的移动到 R.A 上,而是通过上述先增加后删除的操作来实现这种移动操作

减少比较次数

对比两颗 Virtual DOM Tree 时,diff 算法优先比较两棵树的根节点,如果它们的类型(type)不同就认为这两个根节点及其下属的子树完全不同,diff 算法不会在继续递归比较这两颗子树,如图:react-root-diff.png

当diff算法检测到 R1.type !== R2.type,则会把 R1 跟 R2 视为两个不同节点,因此会删除 R1 节点及 R1.A 和 R1.B,然后在创建 R2 节点和 R2.A 和 R2.B,diff 算法通过这种策略减少遍历比较次数,从而提升算法性能。

稳定的DOM

Virtual DOM 的 key 属性用来标示节点是否唯一,diff 算法可以根据 key 值判断新老两个 Virtual DOM 在相同类型情况下是否属于同一实例,如果新老 Virtual DOM 的 key 值不同,diff算法会销毁老的实例,然后在创建一个新的实例,下面代码在切换 Child 的 key 值之后,会把原来的 a 实例摧毁掉,再创建一个 b 实例子

import React from 'react';

class Child extends React.Component {
    constructor(props){
        super(props);
        console.log(`创建实例${props.name}`);
    }
    
    conponentWillUnmount(){
        console.log(`销毁实例${this.props.name}`);
    }

    render() {
        return <div>子组件</div>
    }
}

class Parent extends React.Component {
    state = {
        key: 1,
        name: 'a'
    }
    
    render(){
        return (
            <div>
                <button onClick={()=>this.setState({key: 2, name: 'b'})}>
                    切换key
                </button>
                <Child key={this.state.key} name={this.state.name} />
            </div>
        )
    }
}

/**
* 初始化:打印出'创建实例a'
* 点击切换按钮后:打印出'销毁实例a' -> 打印出'创建实例b'
**/

key 的作用不仅体现再保证元素的稳定上,它还可以帮助diff算法快速找到 Virtual DOM 列表中的某个具体节点。

实际应用中,经常出现某个节点含有多个子节点的场景,对于 diff 算法来说,有效快速地比较出改变前后子节点列表的差异和更新步骤可以提升React应用的性能

把问题抽象简化:给定改变钱的子节点列表prevList和改变后子节点列表 nextList,将 prevList变换成 nextList 所需的最少操作步骤,操作步骤可以为

  • INSERT_MARKUP: nextList 某节点不存在 prevList,需要对该新节点执行插入操作
  • MOVE_EXISTING:nextList 与 prevList存在相同 type 和 key 的节点,直接复用 prevList 节点,并进行移动操作
  • REMOVE_NODE:nextList 中不存在 prevList 中某节点相同 type 和 key 的节点,从prevList 删除该节点

我们需要队列 updateQueue 来按照优先顺序存放计算过程中得到的操作,updateQueue 是一个数组,每个元素是描述操作的js对象,属性定义如下:

  • type:操作类型,包括 INSERT_MARKUP、MOVE_EXISTING、REMOVE_NODE
  • fromIndex:节点在prevList的初始位置信息
  • toIndex:经过移动或插入后,节点在nextList的位置信息
  • markupNode:要插入的子节点,仅对INSERT_MARKUP有效

接下来,我们为每种操作分别定义一个函数:

// 操作的队列
const updateQueue = [];

// 插入操作
function enqueueInsert(markupNode, toIndex) {
    updateQueue.push({
        type: INSERT_MARKUP,
        markupNode,
        fromIndex: null,
        toIndex
    })
};

// 移动操作
function enqueueMove(fromIndex, toIndex) {
    updateQueue.push({
        type: MOVE_EXISTING,
        fromIndex,
        toIndex
    })
};

// 删除操作
function enqueueRemove(fromIndex) {
    updateQueue.push({
        type: REMOVE_NODE,
        fromIndex,
        toIndex: null
    })
};

为了从 prevList 和 nextList 中快速查找某个节点,得把节点列表转换成一个存放 key - vnode 映射关系得对象

// 节点列表转换为对象
function nodeListToMap(list=[]) {
    const nodeMap = {};
    list.forEach(
        (vnode, index) => {
            nodeMap[vnode.key || index] = vnode
        }
    )
    return nodeMap;
}

通过 nodeMap 分别获取到改变前后两个列表中key或index相同的两个节点,然后再根据这两个节点是否相等来判断下一步更新操作(插入、删除、移动),为了不遗漏两个列表中节点,需要分别对两个列表中节点进行遍历。

1、首先遍历 nextList 中的节点并将其与 prevList 中的节点进行对比。

search-next-list.png

2、再遍历prevList中的节点并将其与nextList中的节点进行对比。

search-prev-list.png

通过以上两步操作,就可以将 prevList 变换到 nextList,还剩三个关键问题

  • 计算新增节点在 nextList 中的插入位置
  • 如何删除不存在的老节点
  • 如何移动可复用的老节点

解决这三个问题之前,先了解几个概念:

  • lastIndex:pervList 中被遍历过最右节点的位置,初始值为0
  • nextIndex:nextList 中当前被遍历到节点的下标,初始值为0
  • mountIndex:某个节点在 nextList 中的最终位置,初始值为该节点在 prevList 中的位置

对于插入操作:把新增节点 nextNode 的 mountIndex 设置为 nextIndex,再根据 nextNode.mountIndex 把 nextNode 插入到 nextList 的对应位置:

// 插入节点
function insertNode(nextNode, nextIndex) {
    nextNode.mountIndex = nextIndex;

    enqueueInsert(nextNode, nextIndex);
}

删除操作:根据老节点在pervList中的原始位置删除:

// 删除节点
function removeNode(prevNode) {
    enqueueRemove(prevNode.mountIndex);

    prevNode.mountIndex = null;
}

移动操作:如果lastIndex > prevNode.mountIndex,说明当前遍历到的pervNode在prevList中就逼上一个prevNode节点靠前,因此需要将当前遍历到的pervNode移动到nextIndex位置:

// 移动节点

function moveNode(prevNode, nextIndex, lastIndex) {
    if (prevNode.mountIndex < lastIndex) {
        enqueueMove(prevNode.mountIndex, nextIndex)
    }
}

完整的计算更新步骤代码:

// 计算更新步骤
function updateList(prevList, nextList) {
    // 列表转对象
    const pervMap = nodeListToMap(prevList);
    const nextMap = nodeListToMap(nextList);
    
    let name;
    // 初始化 lastIndex 和 nextIndex
    let lastIndex = 0;
    let nextIndex = 0;

    // 首先遍历nextMap
    for (name in nextMap) {
        const nextNode = nextMap[name];
        // 根据name获取到prevNode
        const prevNode = prevMap[name];

        if (prevNode) {
            if (prevNode.type === nextNode.type) {
                // 相同节点执行移动操作
                moveNode(prevNode, nextIndex, lastIndex);
                // 更新lastIndex, 确定它是最右侧位置
                lastIndex = Math.max(prevNode.mountIndex, lastIndex);
                // 更新prevNode的最终位置
                prevNode.mountIndex = nextIndex
            } else {
                // 不同节点,先删除 prevNode,再插入 nextNode
                // 更新lastIndex,确定它是最右侧位置
                lastIndex = Math.max(prevNode.mountIndex, lastIndex)
                // 删除 prevNode
                removeNode(prevNode)
                // 插入 nextNode
                insertNode(nextNode, nextIndex)
            }
        } else {
            // 若 prevNode 不存在,则直接插入 nextNode
            insertNode(nextNode, nextIndex)
        }
        // 访问下一个 nextNode, 更新 nextIndex
        nextIndex ++
    }    
    // 再遍历 prevMap
    for (name in prevMap) {
        if(!nextMap[name]){
            // 如果老节点在 nextMap 中不存在,则直接删除
            removeNode(prevMap[name])
        }
    } 
}

以上代码体现了 React diff 在处理 list diff 时候的基本逻辑,在 React 源码中其实现远比这个复杂。

为什么不将变化直接更新到真实 DOM 节点上?

通过 reconciliation 将节点变化部分更新到真实节点的意义在于:

  • 通过 diff 算法对比 render 前后的 Virtual DOM 可以获取需要改变的 Virtual DOM 最小集合以及将这些变化部分更新到真实节点的最少步骤。
  • 在更新真实节点的过程中,React 会根据 diff 获取到的更新步骤,在一次事件循环中执行完所有步骤并保证在此次事件循环中只执行一次真实节点的重绘和重排操作。

以上两点可以确保 React 在更新真实 DOM 过程中,避免无用重复的重排重绘操作,从而降低更新操作带来的性能影响。