React 算法应用之深度优先遍历

1,108 阅读6分钟

「这是我参与2022首次更文挑战的第6天,活动详情查看:2022首次更文挑战」。

react 版本:v17.0.3

深度优先遍历

深度优先搜索算法(英语:Depth-First-Search,DFS)是一种用于遍历或搜索树或图的算法。沿着树的深度遍历树的节点,尽可能深的搜索树的分支。当节点 v 的所在边都己被探寻过,搜索将回溯到发现节点 v 的那条边的起始节点。这一过程一直进行到已发现从源节点可达的所有节点为止。如果还存在未被发现的节点,则选择其中一个作为源节点并重复以上过程,整个进程反复进行直到所有节点都被访问为止。

深度优先搜索是图论中的经典算法,利用深度优先搜索算法可以产生目标图的相应拓扑排序表,利用拓扑排序表可以方便的解决很多相关的图论问题,如最大路径问题等等。

算法实现方式

深度优先遍历通常有递归和非递归两种实现方式。

递归实现

递归实现比较简单,表达性很好,也很容易理解,不过如果层级过深,很容易导致栈溢出。

function deepFirstSearch(node,nodeList) {  
    if (node) {    
        nodeList.push(node);    
        var children = node.children;    
        for (var i = 0; i < children.length; i++) 
        //每次递归的时候将 需要遍历的节点 和 节点所存储的数组传下去
        deepFirstSearch(children[i],nodeList);    
    }    
    return nodeList;  
} 

非递归实现

非递归方式借助栈来模拟递归,可以不用担心递归那样层级过深导致的栈溢出问题。

function deepFirstSearch(node) {
    var nodes = [];
    if (node != null) {
        var stack = [];
        stack.push(node);
        while (stack.length != 0) {
        var item = stack.pop();
        nodes.push(item);
        var children = item.children;
        for (var i = children.length - 1; i >= 0; i--)
            stack.push(children[i]);
        }
    }
    return nodes;
}

React中的应用场景

深度优先遍历在react中的使用有三个地方,分别是 fiber树的构造,查找 context 的消费节点,commit阶段DOM节点的删除。

fiber树的构造

fiber树的构造过程发生在 reconciler 协调过程中,源码位于 react-reconciler 包的 ReactFiberWorkLoop.new.js 文件中。我们先来回顾一下 reconciler 的过程 (具体详情可阅读《React源码解读之任务调度流程》一文):

reconciler 的过程可以分为四个阶段:

  1. 任务输入:scheduleUpdateOnFiber 是处理更新任务开始的地方

  2. 调度任务注册:与调度中心(Scheduler)交互,注册调度任务(scheduler task),等待回调

  3. 执行任务回调:执行渲染任务,在内存中构造出fiber树,同时与渲染器(react-dom)交互,在内存中创建出与fiber对应的DOM节点

  4. 输出DOM节点:与渲染器(react-dom)交互,渲染DOM节点

fiber树构造过程中深度优先遍历的使用就在执行任务回调阶段。入口函数是 ReactFiberWorkLoop.new.js 文件中的 workLoopSync()。

下面的代码做了精简处理,将于dfs无关的逻辑代码去掉了

function workLoopSync() {
    // 1、最外层循环,保证每一个节点都能遍历到,不会遗漏
    while (workInProgress !== null) {
        performUnitOfWork(workInProgress);
    }
}


function performUnitOfWork(unitOfWork: Fiber): void {
   
    const current = unitOfWork.alternate;

    let next;
   
    // 2、beginWork是向下探寻阶段
    next = beginWork(current, unitOfWork, subtreeRenderLanes);
    

    if (next === null) {
        // 3、completeUnitOfWork 是回溯节点
        completeUnitOfWork(unitOfWork);
    } else {
        // workInProgress 是一个全局变量,如果 workInProgress 不为 null,
        // 在 workLoopSync 和 workLoopConcurrent 中循环执行任务,直到所有的任务执行完
        workInProgress = next;
    }

}


function completeUnitOfWork(unitOfWork: Fiber): void {
    
    // 尝试完成当前的工作单元,然后移动到下一个兄弟。 如果没有更多的兄弟姐妹,则返回到父节点。
    let completedWork = unitOfWork;
    do {
        
        const current = completedWork.alternate;
        const returnFiber = completedWork.return;

        let next;

        // 3.1 回溯并处理节点
        next = completeWork(current, completedWork, subtreeRenderLanes);
           
        } else {
     	    // 3.2 判断是否有旁支
            if (next !== null) {
               // 判断在处理节点的过程中, 是否派生出新的节点
                workInProgress = next;
                return;
            }

        }

        // 当前的fiber已完成渲染任务,移动到它的兄弟节点,继续执行兄弟节点上的任务
        const siblingFiber = completedWork.sibling;
        if (siblingFiber !== null) {
            // If there is more work to do in this returnFiber, do that next.
            workInProgress = siblingFiber;
            return;
        }
        // 3.3 没有旁支,继续回溯
        completedWork = returnFiber;
        // Update the next thing we're working on in case something throws.
        workInProgress = completedWork;
    } while (completedWork !== null);

}

深度优先遍历的主要思路是从一个未访问的顶点开始,沿着一条路一直走到底,然后从这条路尽头回退到上一个节点,再从另一条路开始走到底...,不断重复此过程,直到所有的顶点都遍历完成。

在fiber树的构造过程中,从当前工作中的fiber节点 workInProgress 开始,这一步体现在workLoopSync() 函数中。在该函数中调用 performUnitOfWork函数,传入 workInProgress,开始进行深度优先遍历。

沿着一条路一直走到底,体现在performUnitOfWork函数中。在performUnitOfWork函数中调用了beginWork函数向下探寻下一个节点。在beginWork函数中,会根据不同的组件类型,执行不同组件的更新函数。以 updateFunctionComponent 为例,它对外返回的是workInProgress的子节点。

// packages/react-reconciler/src/ReactFiberBeginWork.new.js

function updateFunctionComponent(
  current,
  workInProgress,
  Component,
  nextProps: any,
  renderLanes,
) {

  // ...
  return workInProgress.child;
}

由于子节点不为空,因此在workLoopSync函数中继续执行performUnitOfWork函数,向下遍历下一个节点。直到子节点为空后,在performUnitOfWork 函数中执行completeUnitOfWork回溯节点,再从另一条路开始向下遍历,不断重复此过程,直到遍历完所有的节点。

查找context的消费节点

function propagateContextChanges<T>(
    workInProgress: Fiber,
    contexts: Array<any>,
    renderLanes: Lanes,
    forcePropagateEntireTree: boolean,
  ): void {
   
    let fiber = workInProgress.child;
    if (fiber !== null) {
      // Set the return pointer of the child to the work-in-progress fiber.
      fiber.return = workInProgress;
    }
    while (fiber !== null) {
      let nextFiber;
  
      // Visit this fiber.
      const list = fiber.dependencies;
      if (list !== null) {
        nextFiber = fiber.child;
  
      } else {
        // Traverse down.
        nextFiber = fiber.child;
      }
  
      
      fiber = nextFiber;
    }
  }

当context发生改变之后,也是通过深度优先遍历的方式找出依赖该context的所有子节点。如上述源码,通过while循环向下遍历子节点。

commit阶段DOM节点的删除

在commit阶段,对fiber树进行遍历时,如果fiber上有Deletion副作用标记,则会执行DOM节点的删除操作。在遍历fiber树的过程中,也使用到深度优先遍历。源码如下:

// react-reconciler/src/ReactFiberCommitWork.new.js

function commitNestedUnmounts(
    finishedRoot: FiberRoot,
    root: Fiber,
    nearestMountedAncestor: Fiber,
): void {
    // While we're inside a removed host node we don't want to call
    // removeChild on the inner nodes because they're removed by the top
    // call anyway. We also want to call componentWillUnmount on all
    // composites before this host node is removed from the tree. Therefore
    // we do an inner loop while we're still inside the host node.
    let node: Fiber = root;
    while (true) {
        // 调用 commitUnmount 卸载 ref ,执行生命周期函数
        commitUnmount(finishedRoot, node, nearestMountedAncestor);
        // Visit children because they may contain more composite or host nodes.
        // Skip portals because commitUnmount() currently visits them recursively.
        if (
            node.child !== null &&
            // If we use mutation we drill down into portals using commitUnmount above.
            // If we don't use mutation we drill down into portals here instead.
            (!supportsMutation || node.tag !== HostPortal)
        ) {
            // 深度优先遍历向下遍历子树
            node.child.return = node;
            node = node.child;
            continue;
        }

        // node 与 root 相等时说明整棵树的深度优先遍历已完成
        if (node === root) {
            return;
        }

        // 当前子节点没有兄弟节点,说明当前子树已经遍历完成,返回父节点继续深度遍历
        while (node.sibling === null) {
            if (node.return === null || node.return === root) {
                return;
            }
            node = node.return;
        }

        // 遍历兄弟节点
        node.sibling.return = node.return;
        node = node.sibling;
    }
}

总结

虚拟DOM是一棵树形结构,即fiber树,对于树的遍历,通常有两种方法:深度优先遍历和广度优先遍历。如果react使用广度优先遍历来遍历fiber树,则可能会导致组件的生命周期时序错乱,而深度优先遍历就可以解决这个问题,因此react diff 算法采用了深度优先遍历。