React Diff算法源码实现分析

173 阅读5分钟

在React开发中,Diff算法是一个非常重要的概念,它负责高效地对比新旧虚拟DOM树,找出最小变化集,并据此对真实DOM进行最小化的更新操作。本文将从源码层面深入分析React(以18.2.0版本为例)中的Diff算法实现,揭示其高效运作的秘密。

Diff算法概述

React的Diff算法主要解决的是如何高效更新UI的问题。由于每次状态更新都会重新渲染整个组件树,但并非所有子组件都需要重新渲染。Diff算法通过对比新旧虚拟DOM树,找出差异,并仅对变化的部分进行DOM操作,从而提高性能。

Diff算法的策略

React的Diff算法基于以下三个策略进行优化,将时间复杂度降低至O(n):

  1. Tree Diff:只对同级元素进行比较,不同层级的元素不进行比较和复用。
  2. Component Diff:如果组件类型不同,则视为不同的树形结构,直接替换整个组件。
  3. Element Diff:对于同一层级的子节点,开发者可以通过key属性来标识哪些子元素在不同渲染中可以保持稳定。

Diff算法的源码实现

入口函数:reconcileChildren

React的Diff过程从reconcileChildren函数开始。这个函数根据当前fiber节点是否存在,决定是直接渲染新的ReactElement内容还是与当前fiber进行Diff。

	function reconcileChildren(current, workInProgress, nextChildren, renderLanes) {  
	  if (current === null) {  
	    // 当前fiber节点为空,则直接将新的ReactElement内容生成新的fiber  
	    workInProgress.child = mountChildFibers(  
	      workInProgress,  
	      null,  
	      nextChildren,  
	      renderLanes  
	    );  
	  } else {  
	    // 当前fiber节点不为空,则与新生成的ReactElement内容进行Diff  
	    workInProgress.child = reconcileChildFibers(  
	      workInProgress,  
	      current.child, 
	      nextChildren,  
	      renderLanes  
	    ); 
	  }  
	}

核心函数:reconcileChildFibers

reconcileChildFibers是Diff算法的核心函数,它根据新节点的类型(如对象、字符串/数字、数组等)调用不同的处理函数。

	function reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes) {  
	  const isObject = typeof newChild === 'object' && newChild !== null;  
     // 单节点
	  if (isObject) {  
	    // 处理对象类型,可能是React元素或Portal等  
	    switch (newChild.$$typeof) {  
	      case REACT_ELEMENT_TYPE:  
	        return placeSingleChild(  
	          reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes), 
	          lanes  
	        );  
	      // 其他类型处理... 
	    }  
	  }  
    // 多节点
	  if (isArray(newChild)) { 
	    // 处理数组类型,即多节点  
	    return reconcileChildrenArray(  
	      returnFiber,  
	      currentFirstChild, 
	      newChild,  
	      lanes  
	    );  
	  }  
	  // 其他情况处理... 
	}

单节点对比:reconcileSingleElement

对于单个节点的对比,React通过reconcileSingleElement函数实现。该函数遍历当前fiber节点下的所有子节点,寻找与新节点key和type都相同的子节点进行复用。

	function reconcileSingleElement(returnFiber, currentFirstChild, element) {  
	  const key = element.key;  
	  let child = currentFirstChild; 
	  while (child !== null) { 
	    if (child.key === key) {  
	      if (child.elementType === element.type) {  
	        // 节点可复用,更新props和ref  
	        const existing = useFiber(child, element.props); 
	        existing.ref = coerceRef(returnFiber, child, element);
	        existing.return = returnFiber;  
	        return existing;  
	      }  
	      // key相同但type不同,删除当前节点及其兄弟节点  
	      break;  
	    }  
	    // key不同,继续遍历兄弟节点  
	    child = child.sibling;  
	  }  
	  // 创建新节点  
	  // ...省略  
	}

多节点对比:reconcileChildrenArray

对于多节点的对比,React通过reconcileChildrenArray函数实现。该函数会进行两次遍历,首先将旧节点列表转换为Map结构,然后遍历新节点列表,根据key值从旧节点Map中查找可复用的节点。

	function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {  
	  let resultingFirstChild: Fiber | null = null;  
	  let previousNewFiber: Fiber | null = null; 
          
	  let oldFiber = currentFirstChild;  
	  let lastPlacedIndex = 0;  
	  let newIdx = 0;  
	  let nextOldFiber = null;    
    // 第一轮顺序比对,如'a', 'b', 'c' => 'a', 'c', 'b' 
    // 比对到'c'时,newFiber.key !== oldFiber.key,说明节点不能复用
    // 经过updateSlot处理回来的newFiber为null,则结束第一轮循环  
    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
        if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
        nextOldFiber = oldFiber.sibling;
      }
      const newFiber = updateSlot(
        returnFiber,
        oldFiber,
        newChildren[newIdx],
        lanes,
      );
      if (newFiber === null) {
        if (oldFiber === null) {
          oldFiber = nextOldFiber;
        }
        break;
      }

      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
      oldFiber = nextOldFiber;
    }
    
    if (newIdx === newChildren.length) {
      // 说明新节点已经遍历完了,收集剩余旧的fiber节点,标记为ChildDeletion
      deleteRemainingChildren(returnFiber, oldFiber);
      return resultingFirstChild;
    }
    
    if (oldFiber === null) {
    // 说明旧节点已经遍历完了,则依次创建剩余的新的fiber节点
      for (; newIdx < newChildren.length; newIdx++) {
        const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
        if (newFiber === null) {
          continue;
        }
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
      return resultingFirstChild;
    }
    // 到这一步说明旧节点和新节点都不为空
    // 构建一个以key为键,fiber为值的Map  
    const existingChildren = mapRemainingChildren(oldFiber);
    
    for (; newIdx < newChildren.length; newIdx++) {
      // 在existingChildren中找能复用的fiber
      // 如果找到了,则调用useFiber方法复用节点
      // 如果没有可复用的节点,则调用createFiberFromElement创建新的fiber节点
      const newFiber = updateFromMap(
        existingChildren,
        returnFiber,
        newIdx,
        newChildren[newIdx],
        lanes,
      );
      if (newFiber !== null) {
        if (newFiber.alternate !== null) {
          // 说明是复用节点,则将已复用的节点从map中移除
          existingChildren.delete(
            newFiber.key === null ? newIdx : newFiber.key,
          );
        }
        // 更新下一个插入节点的位置
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
    }
     // 将剩余未复用的旧节点标记为删除
     existingChildren.forEach(child => deleteChild(returnFiber, child));

    return resultingFirstChild;

重要的辅助函数-placeChild:更新待插入节点的位置

// 例如'a', 'b', 'c' => 'a', 'c', 'b'
// 遍历到'c'时,oldIndex = 2, lastPlacedIndex = 1, oldIndex > lastPlacedIndex 说明不需要移动位置,更新lastPlacedIndex为oldIndex = 2
// 遍历到'b'时,oldIndex = 1, lastPlacedIndex = 2, oldIndex < lastPlacedIndex 说明需要移动位置,flags标记为Placement
// flags标记为Placement, 会在commitRoot的commitMutationEffects阶段,在commitReconciliationEffects-commitPlacement方法中依次插入
function placeChild(newFiber, lastPlacedIndex, newIndex) {
    newFiber.index = newIndex;
    const current = newFiber.alternate;
    if (current !== null) {
      const oldIndex = current.index;
      if (oldIndex < lastPlacedIndex) {
        newFiber.flags |= Placement;
        return lastPlacedIndex;
      } else {
        return oldIndex;
      }
    } else {
      newFiber.flags |= Placement;
      return lastPlacedIndex;
    }
  }

总结

React的diff算法主要分为单节点和多节点的比对

  • 单节点
    • 如果key和type相同,则复用,否则删除
  • 多节点:主要有两轮循环
    • 第一轮顺序对比,遇到不可复用的节点时,跳出循环
    • 如果新节点遍历完了,则删除剩余的老节点
    • 如果旧节点遍历完了,则依次创建剩余的新节点
    • 第二轮遍历,先构建一个以key为键,fiber为值的Map
      • 先去map找能复用的节点
      • 如果能复用则复用,并删除map中已复用的旧节点
      • 如果复用的旧节点的位置index小于待插入节点的位置,说明需要移动位置,flags标记为Placement,在commitRoot操作DOM时会移动节点
      • 如果不能复用,则重新创建fiber节点
    • 最后将剩余的旧节点删除

以上就是React diff算法的全部解析,整体分析下来,由于受限于fiber单链表的数据结构,没法使用更高效的算法,例如双端比较、最长递增子序列等,但使用两轮遍历的方式,将整体时间复杂度降为O(n),提高了遍历的效率。