探究 React Work Loop 原理

探究 React Work Loop 原理

背景 - 前端体验优化

为了让 React 在检测到数据变化需要重新渲染界面的时候不会阻塞浏览器主线程,从而让用户交互体验更流畅,React 团队提出了 Work Loop 的概念,将任务拆分成小的模块,在每一个任务完成后都检测是否当前有优先级更高的任务(例如:用户与浏览器的交互),有则优先执行优先级更高的任务,没有则继续执行下一个任务。

(声明:本文所解析的所有逻辑都是为了大家能更好的通过阅读源码来学习框架的设计思路,所以其中变量的命名,以及补充说明中的概念都尽可取用源码中的描述。文中会给出整个源码中函数调用顺序图供大家参考。)

参见 Lin Clark 在 ReactConf 2017 上发表的演讲:youtube.com/watch? (本文所用图片大部分源于此视频)

数据结构 - 链表

对于界面渲染任务,React 团队用一个 Fiber Node 来表示一个 React 组件节点。组件渲染的顺序是从父节点开始,向下依次遍历子节点,深度优先的渲染完子节点后,再回到其父节点去检查是否有兄弟节点,如果有兄弟节点,则从该兄弟节点开始继续深度优先的渲染,直到回退到根节点结束,所以需要链表来存储组件关系,形成一棵组件树,此时每一个节点可以看作一个渲染任务, 从而将整个界面渲染任务拆分成更小的模块。如下图:

其中 child 指向本节点的第一个子节点,return 指向本节点的父节点,sibling 指向本节点的第一个兄弟节点。所以上图节点的遍历顺序为 HostRoot -(1 child)- List -(2 child)- Button -(3 sibling)- Item -(4 return)- List -(5 return)-HostRoot,到达根节点渲染结束。下面是源码中的 Fiber Node 定义。

// 源码:react/packages/react-reconciler/src/ReactFiber.js

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance
  // ...
  this.tag = tag;                       // React 内部组件类型
                                        // FunctionComponent/ClassComponent/...
                                        // 针对不同的组件类型,会执行不同的操作
  
  // Fiber
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;

  this.ref = null;

  this.pendingProps = pendingProps;      // 存储新的渲染需求
  this.memoizedProps = null;             // 存储过去渲染结果
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;

  // Effects
  // ...
  this.alternate = null;                 // 存储过去渲染结果中对应的 FiberNode
}

重复遍历的节点并不会重复渲染,而是为了取到下一个可能需要渲染的节点,下面用一段伪代码来说明这个问题。

// 伪代码

function renderNode(node) {
   // 通过过去渲染结果,和将要渲染需求的对比来判断是否需要渲染该节点
   if (node.memoizedProps !== node.pendingProps) {
      render(node)
   }

   // 是否有子节点,优先渲染子节点
   if (node.child !== null) {
      return node.child

   // 是否有兄弟节点,渲染兄弟节点
   } else if (node.sibling !== null){
      return node.sibling

   // 没有子节点和兄弟节点,则返回父节点
   } else if (node.return !== null){
      return node.return

   // 返回到了 root 根节点其上无父节点,则返回空,
   // 在 workloop 里检测到下一个节点为空,则整棵节点树的渲染完成
   } else {
      return null
   }
}

function workloop(root) {
   nextNode = root
   // 每完成一个节点的渲染任务,就判断一次是否有更高优先级的任务需要优先执行,
   // 以免渲染阻塞浏览器主线程,影响用户体验
   while (nextNode !== null && (no other high priority task)) {
      nextNode = renderNode(nextNode)
   }
}

实际上渲染过程是需要对比过去已经渲染好的节点树(current)和即将要渲染的新节点树(workInProgress)的差异,利用类似 diff 的算法来最小化界面需要重新渲染的部分。但是 React 并没有实现两棵节点树,而是利用 FiberNode.alternate 属性来存储上一次渲染过的结果,具体逻辑没有深究,这里就先不提了。

所以上面的伪代码应该做如下修改,更贴近源码中的实现。

// 伪代码

function renderNode(current, workInProgress) {
   // 通过过去渲染结果,和将要渲染需求的对比来判断是否需要渲染该节点
   if (current.memoizedProps !== workInProgress.pendingProps) {
      render(workInProgress)
   }

   if (workInProgress.child !== null) {
      return workInProgress.child
   } else if (workInProgress.sibling !== null){
      return workInProgress.sibling
   } else if (workInProgress.return !== null){
      return workInProgress.return
   } else {
      return null
   }
}

然后我们完成了链表结构及其遍历规则的说明。

任务循环

任务循环的源码比较简单,是一个 while 循环,其中 workInProgress 对象就是一个 Fiber Node,循环每渲染完成一个 Fiber Node 就利用 shouldYield 来判断是否有优先级更高的任务存在,是则跳出循环先执行优先级更高的任务,否则继续渲染下一个 Fiber Node。(由于 shouldYield 的实现涉及到 React Scheduler 的设计,这里不展开说明。)

// 源码:react/packages/react-reconciler/src/ReactFiberWorkLoop.js

function renderRoot(
  root: FiberRoot,                  // 渲染树的根节点
  expirationTime: ExpirationTime,  
  isSync: boolean,
) {
  // ...
  do {
      try {
        if (isSync) {
          workLoopSync();
        } else {
          workLoop();      // 源码阅读的核心部分
        }
        break;
      }
  // ...
}

function workLoop() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

renderRoot 算是任务循环的起始函数,里面的代码内容较多,不需要全部弄懂。主要和任务循环相关的部分就是判断了是否需要使用异步的任务循环,判断的逻辑与循环内部实现无关,所以本文暂不关心,只梳理异步任务循环部分逻辑的实现。于是得到了下图的源码函数调用顺序图。

细节的实现大家可以跟着这个图详细了解,图中红色的部分是任务循环的核心实现,蓝色部分是一些条件判断。

生命周期

源码函数调用顺序图中 performUnitWork 的前半部分将下一个返回的节点(next)赋值为 beginWork 的返回值,若 beginWork 的返回值为 null,才将返回值赋值为 completeUnitOfWork的返回值。

// 源码:react/packages/react-reconciler/src/ReactFiberWorkLoop.js

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

  startWorkTimer(unitOfWork);
  setCurrentDebugFiberInDEV(unitOfWork);

  let next;
  if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
    startProfilerTimer(unitOfWork);
    next = beginWork(current, unitOfWork, renderExpirationTime);  // 阶段1 Phase 1
    stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
  } else {
    next = beginWork(current, unitOfWork, renderExpirationTime);
  }

  resetCurrentDebugFiberInDEV();
  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    // If this doesn't spawn new work, complete the current work.
    next = completeUnitOfWork(unitOfWork);     // 阶段1 Phase 2
  }

  ReactCurrentOwner.current = null;
  return next;
}

这让我想到了视频中关于生命周期的解释如下图:

我们随着源码来理解一下其含义,也就是探究 beginWork 和 completeUnitOfWork 这两个函数的作用。从源码中找到其对应的部分,beginWork 和 completeUnitOfWork 的实现逻辑都涉及到了通过判断 FiberNode 的 tag 来对不同的 React 组件进行不同的处理。为了方便理解,我们通过对比他们对于 Class Component 的处理区别来理解一下他们的不同用途。

// 源码:react/packages/react-reconciler/src/ReactFiberBeginWork.js

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderExpirationTime: ExpirationTime,
): Fiber | null {
  const updateExpirationTime = workInProgress.expirationTime;
  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;

    if (
      oldProps !== newProps ||
      hasLegacyContextChanged() ||
      // Force a re-render if the implementation changed due to hot reload:
      (__DEV__ ? workInProgress.type !== current.type : false)
    ) {
      // If props or context changed, mark the fiber as having performed work.
      // This may be unset if the props are determined to be equal later (memo).
      didReceiveUpdate = true;
    } else if (updateExpirationTime < renderExpirationTime) {
      didReceiveUpdate = false;
      // This fiber does not have any pending work. Bailout without entering
      // the begin phase. There's still some bookkeeping we that needs to be done
      // in this optimized path, mostly pushing stuff onto the stack.
      switch (workInProgress.tag) {
          // ...
          case ClassComponent: {
            const Component = workInProgress.type;
            if (isLegacyContextProvider(Component)) {
              pushLegacyContextProvider(workInProgress);   // 压栈
            }
            break;
          }
          // ...
      }
    }
    
    // Before entering the begin phase, clear the expiration time.
    workInProgress.expirationTime = NoWork;
    switch (workInProgress.tag) {
       // ...
       case ClassComponent: {
          const Component = workInProgress.type;
          const unresolvedProps = workInProgress.pendingProps;
          const resolvedProps =
          workInProgress.elementType === Component
            ? unresolvedProps
            : resolveDefaultProps(Component, unresolvedProps);
          return updateClassComponent(    // 进入阶段1 render / reconciliation
            current,
            workInProgress,
            Component,
            resolvedProps,
            renderExpirationTime,
          );
       }
       // ...
    }
}

switch 之上的注释告诉了我们 beginWork 的作用是 “pushing stuff onto the stack”,也就是将 FiberNode 上下文压入栈,然后更新了节点。符合上图视频中对于 Phase 1 的解释。

// 源码:react/packages/react-reconciler/src/ReactFiberCompleteWork.js

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderExpirationTime: ExpirationTime,
): Fiber | null {
  const newProps = workInProgress.pendingProps;

  switch (workInProgress.tag) {
    // ...
    case ClassComponent: {
      const Component = workInProgress.type;
      if (isLegacyContextProvider(Component)) {
        popLegacyContext(workInProgress);    // 出栈 进入阶段2 commit
      }
      break;
    }
    // ...
  }
}

再看 completeWork 的源码就发现它的作用是将 FiberNode 上下文出栈。符合上图视频中对于 Phase 2 的解释。

浏览器时钟

一般显示器的刷新频率默认为60Hz,也就是每秒刷新60次,每刷新一次就会重绘一次界面,让我们可以看到动画的进行。而浏览器也会维护这样一个刷新的时钟周期来重新绘制页面,浏览器为我们提供了两个函数来感知这样的时钟周期 requestAnimationFramerequestIdleCallback。两个函数都要传入一个 callback 函数,对于 requestAnimationFrame(callback),浏览器会在刷新周期末尾调用其注册的callback;而对于requestIdleCallback(callback),浏览器会在刷新周期的空闲时段调用其callback。

requestAnimationFrame(callback)
requestIdleCallback(callback)

如上图,requestAnimationFrame 注册的 callback 在每次刷新周期(Frame)的末尾都会执行,需要注意的是 requestAnimationFrame 和 requestIdleCallback 的 callback 都是注册次数等于执行次数的,也就是调用了两次 requestAnimationFrame,才会出现上图中 rAF callback 被执行两次的情况。这也是他俩和 setTimeout、setInterval 不同的地方,setTimeout(callback, time) 和 setInterval(callback, time),只要未被注销都能够按规定的时间间隔(time)重复执行。但是 setTimeout 和 setInterval 的时间间隔设置不当会导致丢帧,造成动画卡顿。例如设置 setTimeout(render, time = 10ms)来显示一段动画,那么动画每一次改变的动态将如下图:

浏览器每 16.66ms 刷新一次界面,那么第一次刷新之后界面显示的是 render 1 的结果,而第二次刷新之后就是 render 3的结果了,render 2的结果丢失显示,对于用户来说就是类似丢帧的卡顿。这也就是为什么 Web 提出并实现 requestAnimationFrame 的原因,通过 requestAnimationFrame 来顺应浏览器的刷新周期,使得动画的显示可以更流畅。

而 requestIdleCallback 的存在使得前端开发人员可以将优先级较低耗时又很多的任务注册为 requestIdleCallback 的 callback,等待浏览器空闲的时候再执行,类似一个后台程序,以免阻塞浏览器主线程造成卡顿。但是如果浏览器长期被其他高优先级的任务占据主线程,会导致 requestIdleCallback 注册的 callback 无法执行,所以 requestIdleCallback 还提供了一个 timeout 参数。

requestIdleCallback(callback, timeout)

从 requestIdleCallback 注册了其 callback 之后开始计时,如果时长超过了timeout,即使浏览器没有到达空闲时段也会优先执行其 callback。例如 requestIdleCallback(rIC callback, timeout = 20ms)

结合 requestIdleCallback 和 requestAnimationFrame 还有 workloop 我们就可以实现一个根据优先级来执行任务和渲染界面的程序,将优先级低的任务注册到 requestIdleCallback 执行,使得用户体验更优化了。

参考资料

[1] The how and why on React’s usage of linked list in Fiber to walk the component’s tree:medium.com/react-in-dep

[2] Lin Clark - A Cartoon Intro to Fiber - React Conf 2017: youtube.com/watch?

[3] requestIdleCallback-后台任务调度: zhangyunling.com/702.ht

发布于 2019-08-07

文章被以下专栏收录

    只看代码的话,上 https://github.com/ElemeFe 。这一群人,关心的不是「如何写前端」而是「如何很好地运行一个 ( web ) APP」;这一群人,会在监控屏上加上弹幕,会让实习生自主招聘,会设计、编写、监控整个 APP 的生命周期;这一群人,玩的时候... 更卖力,就像从来没来过那般卖力,卖力地热爱生活。所以这些创作大多基于 ❤️