从React源码学习React的工作原理之Diff算法(三)

393 阅读9分钟

一、diff算法

1、定义

        React diff算法,也称为Reconciliation协调算法,是React用来比较新旧虚拟DOM树,并找出最小差异以更新真实DOM的核心机制。

        React维护了两棵虚拟DOM树:一棵表示当前显示在页面中的DOM树——current树,另一个表示内存中正在构建的树——workInProgress树;当组件的state或props发生变化时,React会生成新的虚拟DOM树,并通过diff算法比较新旧两棵树,找出需要变更的虚拟节点,最后将需要更新的节点应用到真实DOM上。

2、React diff算法的升级

        传统diff算法通过循环递归的方式对节点进行依次对比,效率低下,算法复杂度达到O(n^3);React提出了只比较同层节点的思想,将算法的时间复杂度从O(n^3)变成了O(n);

        diff算法采用的是深度优先遍历的方式;【React的核心就是虚拟DOM + diff算法】

3、React diff算法的对比策略

        React提供了三种对比策略,分别是:Tree diff、Component diff、Element diff;

(1)Tree diff —— 树层级的对比
        它只会比较同一层级的节点,不会进行跨层级的比较;React会对两棵树的每一层进行遍历,比较相同父节点下的所有子节点;

  • 新旧两棵树逐层对比,找出需要更新的节点;
  • 如果节点是组件,就使用Component diff对比策略;
  • 如果节点是元素,就使用Element diff对比策略;

(2)Component diff —— 组件层级的对比
        如果是同类型组件,即拥有相同的类或构造函数,会进行更细致的比较;
        如果是不同类型组件,React会删除旧组件及其所有子组件,并创建新的组件及其子组件;

  • 如果节点是组件,就查看组件类型;
  • 类型不同,直接替换;类型相同,则只更新属性;
  • 进入组件内部做Tree diff;

(3)Element diff —— 元素层级的对比
        如果元素类型相同,React会比较它们的属性,并只更新发生变化的属性;
        如果元素类型不同,React会删除旧元素并创建新元素;

        对于同一层级的子节点,React会进行精细的对比,包括节点的增加、删除、移动和更新;

  • 如果节点是原生标签,则查看标签名;
    • 标签名相同,则只更新属性;
    • 标签名不同,则直接替换;
  • 进入标签后代做Tree diff;

二、diff算法原理

1、原理

        React的diff算法采用仅右移策略,即对元素发生的位置变化,只会将其移动到右边,右边移完了,元素就有序了;

2、对比过程

        diff操作包含节点新增、节点移动、节点删除;

        在React中,一个fiber对象的子节点可能是单个子节点,也可能是多个子节点,diff算法会调用不同的函数去处理这些节点。

(1)单节点操作

单节点操作会通过调用 reconcileSingleElement 函数处理,单节点是指新的节点为单节点,但是oldFiber节点可能会有多个;

【可能的单节点操作场景如下】

  • 节点属性props变化 —— 节点更新操作
    • 旧节点:<div className='a' key='a'>hello</div>
    • 新节点:<div className='b' key='a'>hello</div>
    • 对比过程:key值相同,节点属性发生变化,则只需更新节点属性即可;
  • 多个旧节点,一个新节点 —— 根据key或tag匹配到节点后,删除多余节点;
    • 旧节点
      • <div className='a' key='a'>hello</div>
      • <div className='b' key='b'>hello</div>
      • <div className='c' key='c'>hello</div>
    • 新节点
      • <div className='a' key='a'>hello</div>
  • 无旧节点,一个新节点
    • 新节点
      • <div className='a' key='a'>hello</div>
【单个节点更新的源码解析】
// 单节点更新 
function reconcileSingleElement( 
    returnFiber: Fiber, // 父节点 
    currentFirstChild: Fiber | null, // 父节点的第一个子节点 
    element: ReactElement, // 新创建的元素节点 
    lanes: Lanes, // 优先级 
): Fiber { 
    const key = element.key; // 获取元素的key属性 
    let child = currentFirstChild; 
    while (child !== null) { // 存在旧节点,与新节点进行比较 
        if (child.key === key) { // 如果旧节点的key属性与新的节点的key属性相同,表示当前属于节点更新操作 
            const elementType = element.type; // fiber对应的DOM元素的节点类型:div span 
            if (elementType === REACT_FRAGMENT_TYPE) { 
                // 若为fragment 
                if (child.tag === Fragment) { 
                    // 如果标签类型属于Fragment 
                    deleteRemainingChildren(returnFiber, child.sibling); // 删除剩余的子节点 
                    // 基于旧的fiber节点与新的fiber节点创建一个新的fiber节点 
                    const existing = useFiber(child, element.props.children); 
                    existing.return = returnFiber; 
                    return existing; 
                } 
            } else { 
                if ( child.elementType === elementType || 
                    (typeof elementType === 'object' && 
                    elementType !== null && 
                    elementType.$$typeof === REACT_LAZY_TYPE && 
                    resolveLazy(elementType) === child.type) 
                ) { 
                    deleteRemainingChildren(returnFiber, child.sibling); 
                    const existing = useFiber(child, element.props); 
                    existing.ref = coerceRef(returnFiber, child, element); 
                    existing.return = returnFiber; 
                    return existing; 
                } 
            } 
            // Didn't match. 
            // 没有找到匹配的节点,则直接删除该节点 
            deleteRemainingChildren(returnFiber, child); break; 
        } else { 
            deleteChild(returnFiber, child); 
        } 
        child = child.sibling; 
    } 
    // 不存在旧节点时,直接创建 
    if (element.type === REACT_FRAGMENT_TYPE) { 
        const created = createFiberFromFragment( 
            element.props.children, 
            returnFiber.mode, 
            lanes, 
            element.key, 
        ); 
        created.return = returnFiber; 
        return created; 
    } else { 
        const created = createFiberFromElement(element, returnFiber.mode, lanes); 
        created.ref = coerceRef(returnFiber, currentFirstChild, element); 
        created.return = returnFiber; return created; 
    } 
}

(2)多节点操作
        多节点的操作包括:节点新增、节点删除、节点移动、节点更新;多节点操作通过 reconcileChildrenArray 函数处理,它接受的参数 newChildren 是一个数组类型;
        节点的更新、节点的新增、节点的删除都比较好理解,重点需要理解的是节点的移动;

【示例】

  • 旧Fiber链表:A - B - C - D - E - F
  • 新Fiber链表:A - B - D - C - E

从上述示例可以看出:节点C与节点D交换位置,节点F被删除;

【如何判断节点是否是固定节点?】
  • 旧节点的获取:newFiber.alternate;
    • 如果旧节点的 index 大于 lastPlacedIndex,说明该节点在固定节点的右边,即:该节点位置不变;
      • lastPlacedIndex的值为oldFiber.index;
    • 如果旧节点的 index 小于 lastPlacedIndex,说明该节点在lastPlacedIndex的左边,即:该节点需要移动;
      • lastPlacedIndex的值不变;
【节点移动过程详解】
  • 开始遍历,A、B节点为固定节点,则lastPlacedIndex的值为1;
    • lastPlacedIndex的值通过调用placeChild函数得出;
  • 遍历D节点时,
    • 调用updateSlot函数创建的newFiber节点为null,因为新Fiber节点的key与旧Fiber不同,所以返回null;
    • newFiber的值为null,退出本次循环;
    • 最右侧的固定节点仍然为B,lastPlacedIndex的值不变,仍为1;
    • 此时,新旧节点都还未结束遍历,执行移动操作;
      • 先将剩余的旧的未遍历的节点存储到一个map对象中,用Fiber节点的key或index属性作为键名,方便遍历时查找;
  • 继续遍历D节点
    • lastPlacedIndex的值为1,D在oldFiber中的index值为3,3 > 1,则说明D节点为固定节点,不移动;
    • 更新lastPlaceIndx的值为3;
    • newIndex的值为3;
  • 遍历节点C
    • lastPlacedIndex的值为3,C在oldFiber中的index值为2,2 < 3,则说明C节点需要右移;
    • lastPlaceIndx的值不变;
    • newIndex的值更新为4;
  • 遍历节点E
    • lastPlacedIndex的值为3,E在oldFiber中的index值为4,4 > 3,则说明E节点为固定节点,不移动;
    • 更新lastPlaceIndx的值为4;
    • newIndex的值更新为5;
  • 到此,newChildren遍历结束;每次遍历完一个节点,都需要从existingChildren删除已经遍历过的节点;
  • 继续查看existingChildren中是否还有未遍历完的节点,有,则遍历删除未遍历的节点,
  • 至此,diff操作结束;beginWork阶段的工作也就此结束;
【节点移动的函数】
function placeChild( 
    newFiber: Fiber, 
    lastPlacedIndex: number, 
    newIndex: number, 
): number { 
    newFiber.index = newIndex; 
    if (!shouldTrackSideEffects) { 
        newFiber.flags |= Forked; 
        return lastPlacedIndex; 
    } 
    // 获取旧的fiber节点 
    const current = newFiber.alternate; 
    // 判断节点是否固定 
    if (current !== null) { 
        const oldIndex = current.index; 
        if (oldIndex < lastPlacedIndex) { 
            // 这是移动操作:旧的改变位置的节点 
            newFiber.flags |= Placement | PlacementDEV; 
            return lastPlacedIndex; 
        } else { 
            // 该项可以留在原地 
            return oldIndex; 
        } 
    } else { 
        // 这是插入操作:新创建的节点 
        newFiber.flags |= Placement | PlacementDEV; 
        return lastPlacedIndex; 
    } 
}
【节点移动的函数】
function reconcileChildrenArray( 
    returnFiber: Fiber, // currentFirstChild的父级节点 
    currentFirstChild: Fiber | null, // 当前执行更新任务的fiber节点 
    newChildren: Array<any>, // 新的元素节点 
    lanes: Lanes, 
): Fiber | null { 
    let resultingFirstChild: Fiber | null = null; // diff后新生成的fiber链表的第一个fiber对象 
    let previousNewFiber: Fiber | null = null; 
    let oldFiber = currentFirstChild; // 当前要比对的oldFiber节点 
    let lastPlacedIndex = 0; // 固定节点的索引值 
    let newIdx = 0; // 新节点的索引值 
    let nextOldFiber = null; // 用于记录下一个要遍历的oldFiber节点 
    // 处理节点更新:依据节点是否可以复用来决定是否中断遍历 
    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) { 
        // 新节点遍历结束,旧节点还未结束,则需要中断遍历 
        if (oldFiber.index > newIdx) { 
            nextOldFiber = oldFiber; 
            oldFiber = null; 
        } else { 
            // 获取下一个需要遍历的oldFiber节点 
            nextOldFiber = oldFiber.sibling; 
        } 
        // 创建一个新的fiber节点:对于DOM元素来说,key与tag都相同才会复用oldFiber,否则返回null 
        const newFiber = updateSlot( 
            returnFiber, 
            oldFiber, 
            newChildren[newIdx], 
            lanes, 
        ); 
        // newFiber为null,说明元素的key或tag不同,节点不可复用,中断遍历 
        if (newFiber === null) { 
            if (oldFiber === null) { 
                // oldFiber 为null说明oldFiber此时也遍历完了 
                oldFiber = nextOldFiber; 
            } 
            break; 
        } 
        if (shouldTrackSideEffects) { 
            if (oldFiber && newFiber.alternate === null) { 
                // We matched the slot, but we didn't reuse the existing fiber, so we // need to delete the existing child. 
                deleteChild(returnFiber, oldFiber); 
            } 
        } 
        // 记录固定节点的索引值:placeChild是移动节点的方法,节点只更新,则只停留在原来的位置即可 
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); 
        if (previousNewFiber === null) { 
            // 最终要返回的fiber链表:只有首次渲染时,previousNewFiber才会为null 
            resultingFirstChild = newFiber; 
        } else { 
            // 通过sibling连接成一个单向链表 
            previousNewFiber.sibling = newFiber; 
        } 
        previousNewFiber = newFiber; 
        // 遍历下一个oldFiber节点 
        oldFiber = nextOldFiber; 
    } 
    // 省略节点的删除与新增代码 
    // ... 
    // 移动操作:将剩余还未遍历到的子节点存储到一个map对象中,方便快速查找,key是fiber.key 
    // 设置:existingChildren.set(existingChild.key | index, existingChild); 
    const existingChildren = mapRemainingChildren(returnFiber, oldFiber); 
    // 节点移动 
    for (; newIdx < newChildren.length; newIdx++) { 
        // 基于map对象中的oldFiber创建新的fiber对象 
        const newFiber = updateFromMap( 
            existingChildren, 
            returnFiber, 
            newIdx, 
            newChildren[newIdx], 
            lanes, 
        ); 
        if (newFiber !== null) { 
            if (shouldTrackSideEffects) { 
                if (newFiber.alternate !== null) { 
                    // newFiber是当前正在构建的fiber,但是它的current属性不为空,也就是说newFiber不是新增的节点 
                    // 我们需要从子列表中删除它,并且无需将其标记为要删除的fiber 
                    existingChildren.delete( 
                        newFiber.key === null ? newIdx : newFiber.key, 
                    ); 
                } 
            } 
            // 实现真正的节点移动:这是多节点diff的核心 
            lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); 
            if (previousNewFiber === null) { 
                resultingFirstChild = newFiber; 
            } else { 
                previousNewFiber.sibling = newFiber; 
            } 
            previousNewFiber = newFiber; 
        } 
    } 
    if (shouldTrackSideEffects) { 
        // 将没有遍历到的oldFiber节点标记为要删除的节点 
        existingChildren.forEach(child => deleteChild(returnFiber, child)); 
    } 
    return resultingFirstChild; 
}

3、diff中的key

        diff中的key属性主要用于在子节点对比中,帮助React快速识别哪些节点是稳定的,从而可以高效地复用和移动DOM节点;

        key的值应该是稳定的、唯一的,并且最好能够反映节点在列表中的位置或身份,避免使用不稳定的值作为key;

总结

        React的diff算法又叫协调,通过比较同级节点,找出最小差异,并应用到真实DOM上。Diff算法采用深度优先遍历的方式,采用仅右移策略,最小范围的移动元素。