Git Product home page Git Product logo

mini-react's Introduction

Issues Forks Stars

如果对 react 源码感兴趣的朋友,可以从下面的 TODO 待办项中找一项,以此为发力点解析 React 源码。如果有什么好的外文需要翻译,也可以加到 TODO 中。或者觉得什么文章好,也欢迎提 PR 收录进来。希望能一起对文章质量把关,一起共建社区最好的 react 源码生态环境。

目录划分

  • docs。react 相关知识文档&源码剖析目录
  • react。手写 react 源码目录,对应的官方 react 版本为 17.0.1
  • react-dom。手写 react-dom 源码目录,对应的官方 react-dom 版本为 17.0.1
  • react-reconciler。手写 react-reconciler 源码目录,对应的官方 react-reconciler 版本为 17.0.1

React 源码系列文档(基于 React17.0.1 版本)

参考链接

关于作者

实干家,不贩卖焦虑,不写水文不吹水。业余时间会根据兴趣看些框架源码,有时间就写写文章。有兴趣的网友可以扫码加个好友一起聊聊人生(备注 react 源码)

如果觉得写得好,点个 star 或者 follow 满足一下男人的虚荣心。心情好的话同时有点小钱,也可以请我喝个小茶开心一下。写得差的话就轻点喷,我会连夜改,真的

mini-react's People

Contributors

lizuncong avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

mini-react's Issues

react hydrate原理及源码剖析

深入概述 React 初次渲染及状态更新主流程一文中介绍过 React 渲染过程,即ReactDOM.render执行过程分为两个大的阶段:render 阶段以及 commit 阶段。React.hydrate渲染过程和ReactDOM.render差不多,两者之间最大的区别就是,ReactDOM.hydraterender 阶段,会尝试复用(hydrate)浏览器现有的 dom 节点,并相互关联 dom 实例和 fiber,以及找出 dom 属性和 fiber 属性之间的差异。

Demo

这里,我们在 index.html 中直接返回一段 html,以模拟服务端渲染生成的 html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Mini React</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <div id="root"><div id="root"><div id="container"><h1 id="A">1<div id="A2">A2</div></h1><p id="B"><span id="B1">B1</span></p><span id="C">C</span></div></div></div>
  </body>
</html>

注意,root 里面的内容不能换行,不然客户端hydrate的时候会提示服务端和客户端的模版不一致。

新建 index.jsx:

import React from "react";
import ReactDOM from "react-dom";
class Home extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 1,
    };
  }

  render() {
    const { count } = this.state;
    return (
      <div id="container">
        <div id="A">
          {count}
          <div id="A2">A2</div>
        </div>
        <p id="B">
          <span id="B1">B1</span>
        </p>
      </div>
    );
  }
}

ReactDOM.hydrate(<Home />, document.getElementById("root"));

对比服务端和客户端的内容可知,服务端h1#A和客户端的div#A不同,同时服务端比客户端多了一个span#C

在客户端开始执行之前,即 ReactDOM.hydrate 开始执行前,由于服务端已经返回了 html 内容,浏览器会立马显示内容。对应的真实 DOM 树如下:

image

注意,这不是 fiber 树!!

ReactDOM.render

先来回顾一下 React 渲染更新过程,分为两大阶段,五小阶段:

  • render 阶段
    • beginWork
    • completeUnitOfWork
  • commit 阶段。
    • commitBeforeMutationEffects
    • commitMutationEffects
    • commitLayoutEffects

React 在 render 阶段会根据新的 element tree 构建 workInProgress 树,收集具有副作用的 fiber 节点,构建副作用链表。

特别是,当我们调用ReactDOM.render函数在客户端进行第一次渲染时,render阶段的completeUnitOfWork函数针对HostComponent以及HostText类型的 fiber 执行以下 dom 相关的操作:

    1. 调用document.createElementHostComponent类型的 fiber 节点创建真实的 DOM 实例。或者调用document.createTextNodeHostText类型的 fiber 节点创建真实的 DOM 实例
    1. 将 fiber 节点关联到真实 dom 的__reactFiber$rsdw3t27flk(后面是随机数)属性上。
    1. 将 fiber 节点的pendingProps 属性关联到真实 dom 的__reactProps$rsdw3t27flk(后面是随机数)属性上
    1. 将真实的 dom 实例关联到fiber.stateNode属性上:fiber.stateNode = dom
    1. 遍历 pendingProps,给真实的dom设置属性,比如设置 id、textContent 等

React 渲染更新完成后,React 会为每个真实的 dom 实例挂载两个私有的属性:__reactFiber$__reactProps$,以div#container为例:

image

ReactDOM.hydrate

hydrate中文意思是水合物,这样理解有点抽象。根据源码,我更乐意将hydrate的过程描述为:React 在 render 阶段,构造 workInProgress 树时,同时按相同的顺序遍历真实的 DOM 树,判断当前的 workInProgress fiber 节点和同一位置的 dom 实例是否满足hydrate的条件,如果满足,则直接复用当前位置的 DOM 实例,并相互关联 workInProgress fiber 节点和真实的 dom 实例,比如:

fiber.stateNode = dom;
dom.__reactProps$ = fiber.pendingProps;
dom.__reactFiber$ = fiber;

如果 fiber 和 dom 满足hydrate的条件,则还需要找出dom.attributesfiber.pendingProps之间的属性差异。

遍历真实 DOM 树的顺序和构建 workInProgress 树的顺序是一致的。都是深度优先遍历,先遍历当前节点的子节点,子节点都遍历完了以后,再遍历当前节点的兄弟节点。因为只有按相同的顺序,fiber 树同一位置的 fiber 节点和 dom 树同一位置的 dom 节点才能保持一致

只有类型为HostComponent或者HostText类型的 fiber 节点才能hydrate。这一点也很好理解,React 在 commit 阶段,也就只有这两个类型的 fiber 节点才需要执行 dom 操作。

fiber 节点和 dom 实例是否满足hydrate的条件:

  • 对于类型为HostComponent的 fiber 节点,如果当前位置对应的 DOM 实例nodeTypeELEMENT_NODE,并且fiber.type === dom.nodeName,那么当前的 fiber 可以混合(hydrate)

  • 对于类型为HostText的 fiber 节点,如果当前位置对应的 DOM 实例nodeTypeTEXT_NODE,同时fiber.pendingProps不为空,那么当前的 fiber 可以混合(hydrate)

hydrate的终极目标就是,在构造 workInProgress 树的过程中,尽可能的复用当前浏览器已经存在的 DOM 实例以及 DOM 上的属性,这样就无需再为 fiber 节点创建 DOM 实例,同时对比现有的 DOM 的attribute以及 fiber 的pendingProps,找出差异的属性。然后将 dom 实例和 fiber 节点相互关联(通过 dom 实例的__reactFiber$以及__reactProps$,fiber 的 stateNode 相互关联)

hydrate 过程

React 在 render 阶段构造HostComponent或者HostText类型的 fiber 节点时,会首先调用 tryToClaimNextHydratableInstance(workInProgress) 方法尝试给当前 fiber 混合(hydrate)DOM 实例。如果当前 fiber 不能被混合,那当前节点的所有子节点在后续的 render 过程中都不再进行hydrate,而是直接创建 dom 实例。等到当前节点所有子节点都调用completeUnitOfWork完成工作后,又会从当前节点的兄弟节点开始尝试混合。

以下面的 demo 为例

// 服务端返回的DOM结构,这里为了直观,我格式化了一下,按理服务端返回的内容,是不允许换行或者有空字符串的
<body>
  <div id="root">
    <div id="container">
      <h1 id="A">
        1
        <div id="A2">A2</div>
      </h1>
      <p id="B">
        <span id="B1">B1</span>
      </p>
      <span id="C">C</span>
    </div>
  </div>
</body>
// 客户端生成的内容
<div id="container">
  <div id="A">
    1
    <div id="A2">A2</div>
  </div>
  <p id="B">
    <span id="B1">B1</span>
  </p>
</div>

render 阶段,按以下顺序:

    1. div#container 满足hydrate的条件,因此关联 dom,fiber.stateNode = div#container。然后使用hydrationParentFiber记录当前混合的 fiber 节点:hydrationParentFiber = fiber。获取下一个 DOM 实例,这里是h1#A,保存在变量nextHydratableInstance中,nextHydratableInstance = h1#A

这里,hydrationParentFibernextHydratableInstance 都是全局变量。

    1. div#Ah1#A 不能混合,这时并不会立即结束混合的过程,React 继续对比h1#A的兄弟节点,即p#B,发现div#A还是不能和p#B混合,经过最多两次对比,React 认为 dom 树中已经没有 dom 实例满足和div#A这个 fiber 混合的条件,于是div#A节点及其所有子孙节点都不再进行混合的过程,此时将isHydrating设置为 false 表明div#A这棵子树都不再走混合的过程,直接走创建 dom 实例。同时控制台提示:Expected server HTML to contain a matching.. 之类的错误。
    1. beginWork 执行到文本节点 1 时,发现 isHydrating = false,因此直接跳过混合的过程,在completeUnitOfWork阶段直接调用document.createTextNode直接为其创建文本节点
    1. 同样的,beginWork 执行到节点div#A2时,发现isHydrating = false,因此直接跳过混合的过程,在completeUnitOfWork阶段直接调用document.createElement直接为其创建真实 dom 实例,并设置属性
    1. 由于div#A的子节点都已经completeUnitWork了,轮到div#A调用completeUnitWork完成工作,将hydrationParentFiber指向其父节点,即div#container这个 dom 实例。设置isHydrating = true表明可以为当前节点的兄弟节点继续混合的过程了。div#A没有混合的 dom 实例,因此调用document.createElement为其创建真实的 dom 实例。
    1. p#B执行 beginWork。由于nextHydratableInstance保存的还是h1#Adom 实例,因此p#Bh1#A对比发现不能复用,React 尝试和h1#A的兄弟节点p#B对比,发现 fiberp#B和 domp#B能混,因此将h1#A标记为删除,同时关联 dom 实例:fiber.stateNode = p#B,保存hydrationParentFiber = fibernextHydratableInstance指向p#B的第一个子节点,即span#B1

...省略了后续的过程。

从上面的执行过程可以看出,hydrate 的过程如下:

  • 调用 tryToClaimNextHydratableInstance 开始混合
  • 判断当前 fiber 节点和同一位置的 dom 实例是否满足混合的条件。
  • 如果当前位置的 dom 实例不满足混合条件,则继续比较当前 dom 的兄弟元素,如果兄弟元素和当前的 fiber 也不能混合,则当前 fiber 及其所有子孙节点都不能混合,后续 render 过程将会跳过混合。直到当前 fiber 节点的兄弟节点 render,才会继续混合的过程。

事件绑定

React在初次渲染时,不论是ReactDOM.render还是ReactDOM.hydrate,会调用createRootImpl函数创建fiber的容器,在这个函数中调用listenToAllSupportedEvents注册所有原生的事件。

function createRootImpl(container, tag, options) {
  // ...
  var root = createContainer(container, tag, hydrate);
  // ...
  listenToAllSupportedEvents(container);
  // ...
  return root;
}

这里container就是div#root节点。listenToAllSupportedEvents会给div#root节点注册浏览器支持的所有原生事件,比如onclick等。React合成事件一文介绍过,React采用的是事件委托的机制,将所有事件代理到div#root节点上。以下面的为例:

<div id="A" onClick={this.handleClick}>
button
<div>

我们知道React在渲染时,会将fiber的props关联到真实的dom的__reactProps$属性上,此时

div#A.__reactProps$ = {
  onClick: this.handleClick
}

当我们点击按钮时,会触发div#root上的事件监听器:

function onclick(e){
  const target = e.target
  const fiberProps = target.__reactProps$
  const clickhandle = fiberProps.onClick
  if(clickhandle){
    clickhandle(e)
  }
}

这样我们就可以实现事件的委托。这其中最重要的就是将fiber的props挂载到真实的dom实例的__reactProps$属性上。因此,只要我们在hydrate阶段能够成功关联dom和fiber,就自然也实现了事件的“绑定”

hydrate 源码剖析

hydrate 的过程发生在 render 阶段,commit 阶段几乎没有和 hydrate 相关的逻辑。render 阶段又分为两个小阶段:beginWorkcompleteUnitOfWork。只有HostRootHostComponentHostText三种类型的 fiber 节点才需要 hydrate,因此源码只针对这三种类型的 fiber 节点剖析

beginWork

beginWork 阶段判断 fiber 和 dom 实例是否满足混合的条件,如果满足,则为 fiber 关联 dom 实例:fiber.stateNode = dom

function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case HostRoot:
      return updateHostRoot(current, workInProgress, renderLanes);
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);
    case HostText:
      return updateHostText(current, workInProgress);
  }
}

HostRoot Fiber

HostRoot fiber 是容器root的 fiber 节点。

这里主要是判断当前 render 是ReactDOM.render还是ReactDOM.hydrate,我们调用ReactDOM.hydrate渲染时,root.hydrate为 true。

如果是调用的ReactDOM.hydrate,则调用enterHydrationState函数进入hydrate的过程。这个函数主要是初始化几个全局变量:

  • isHydrating。表示当前正处于 hydrate 的过程。如果当前节点及其所有子孙节点都不满足 hydrate 的条件时,这个变量为 false
  • hydrationParentFiber。当前混合的 fiber。正常情况下,该变量和HostComponent或者HostText类型的 workInProgress 一致。
  • nextHydratableInstance。下一个可以混合的 dom 实例。当前 dom 实例的第一个子元素或者兄弟元素。

注意getNextHydratable会判断 dom 实例是否是ELEMENT_NODE类型(对应的 fiber 类型是HostComponent)或者TEXT_NODE类型(对应的 fiber 类型是HostText)。只有ELEMENT_NODE或者HostText类型的 dom 实例才是可以 hydrate 的

function updateHostRoot(current, workInProgress, renderLanes) {
  if (root.hydrate && enterHydrationState(workInProgress)) {
    var child = mountChildFibers(workInProgress, null, nextChildren);
  }
  return workInProgress.child;
}
function getNextHydratable(node) {
  // 跳过 non-hydratable 节点.
  for (; node != null; node = node.nextSibling) {
    var nodeType = node.nodeType;
    if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) {
      break;
    }
  }
  return node;
}

function enterHydrationState() {
  var parentInstance = fiber.stateNode.containerInfo;
  nextHydratableInstance = getNextHydratable(parentInstance.firstChild);
  hydrationParentFiber = fiber;
  isHydrating = true;
}

HostComponent

function updateHostComponent(current, workInProgress, renderLanes) {
  if (current === null) {
    tryToClaimNextHydratableInstance(workInProgress);
  }
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

HostText Fiber

function updateHostText(current, workInProgress) {
  if (current === null) {
    tryToClaimNextHydratableInstance(workInProgress);
  }
  return null;
}

tryToClaimNextHydratableInstance

假设当前 fiberA 对应位置的 dom 为 domA,tryToClaimNextHydratableInstance 会首先调用tryHydrate判断 fiberA 和 domA 是否满足混合的条件:

  • 如果 fiberA 和 domA 满足混合的条件,则将hydrationParentFiber = fiberA;。并且获取 domA 的第一个子元素赋值给nextHydratableInstance
  • 如果 fiberA 和 domA 不满足混合的条件,则获取 domA 的兄弟节点,即 domB,调用tryHydrate判断 fiberA 和 domB 是否满足混合条件:
    • 如果 domB 满足和 fiberA 混合的条件,则将 domA 标记为删除,并获取 domB 的第一个子元素赋值给nextHydratableInstance
    • 如果 domB 不满足和 fiberA 混合的条件,则调用insertNonHydratedInstance提示错误:"Warning: Expected server HTML to contain a matching",同时将isHydrating标记为 false 退出。

这里可以看出,tryToClaimNextHydratableInstance最多比较两个 dom 节点,如果两个 dom 节点都无法满足和 fiberA 混合的条件,则说明当前 fiberA 及其所有的子孙节点都无需再进行混合的过程,因此将isHydrating标记为 false。等到当前 fiberA 节点及其子节点都完成了工作,即都执行了completeWorkisHydrating才会被设置为 true,以便继续比较 fiberA 的兄弟节点

这里还需要注意一点,如果两个 dom 都无法满足和 fiberA 混合,那么nextHydratableInstance依然保存的是 domA,domA 会继续和 fiberA 的兄弟节点比对。

function tryToClaimNextHydratableInstance(fiber) {
  if (!isHydrating) {
    return;
  }
  var nextInstance = nextHydratableInstance;
  var firstAttemptedInstance = nextInstance;

  if (!tryHydrate(fiber, nextInstance)) {
    // 如果第一次调用tryHydrate发现当前fiber和dom不满足hydrate的条件,则获取dom的兄弟节点
    // 然后调用 tryHydrate 继续对比fiber和兄弟节点是否满足混合
    nextInstance = getNextHydratableSibling(firstAttemptedInstance);

    if (!nextInstance || !tryHydrate(fiber, nextInstance)) {
      // 对比了两个dom发现都无法和fiber混合,因此调用insertNonHydratedInstance控制台提示错误
      insertNonHydratedInstance(hydrationParentFiber, fiber);
      isHydrating = false;
      hydrationParentFiber = fiber;
      return;
    }
    // 如果第一次tryHydrate不满足,第二次tryHydrate满足,则说明兄弟节点和当前fiber是可以混合的,此时需要删除当前位置的dom
    deleteHydratableInstance(hydrationParentFiber, firstAttemptedInstance);
  }

  hydrationParentFiber = fiber;
  nextHydratableInstance = getFirstHydratableChild(nextInstance);
}

// 将dom实例保存在 fiber.stateNode上
function tryHydrate(fiber, nextInstance) {
  switch (fiber.tag) {
    case HostComponent: {
      if (
        nextInstance.nodeType === ELEMENT_NODE &&
        fiber.type.toLowerCase() === nextInstance.nodeName.toLowerCase()
      ) {
        fiber.stateNode = nextInstance;
        return true;
      }
      return false;
    }
    case HostText: {
      var text = fiber.pendingProps;
      if (text !== "" && nextInstance.nodeType === TEXT_NODE) {
        fiber.stateNode = nextInstance;
        return true;
      }
      return false;
    }
    default:
      return false;
  }
}

completeUnitOfWork

completeUnitOfWork 阶段主要是给 dom 关联 fiber 以及 props:dom.__reactProps$ = fiber.pendingProps;dom.__reactFiber$ = fiber;同时对比fiber.pendingPropsdom.attributes的差异

function completeUnitOfWork(unitOfWork) {
  var completedWork = unitOfWork;
  do {
    var current = completedWork.alternate;
    var returnFiber = completedWork.return;
    next = completeWork(current, completedWork, subtreeRenderLanes);
    var siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      workInProgress = siblingFiber;
      return;
    }
    completedWork = returnFiber;
    workInProgress = completedWork;
  } while (completedWork !== null);
}
function completeWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case HostRoot: {
      if (current === null) {
        var wasHydrated = popHydrationState(workInProgress);
        if (wasHydrated) {
          markUpdate(workInProgress);
        }
      }
      return null;
    }
    case HostComponent:
      // 第一次渲染
      if (current === null) {
        var _wasHydrated = popHydrationState(workInProgress);
        if (_wasHydrated) {
          // 如果存在差异的属性,则将fiber副作用标记为更新
          if (prepareToHydrateHostInstance(workInProgress)) {
            markUpdate(workInProgress);
          }
        } else {
        }
      }
    case HostText: {
      var newText = newProps;
      if (current === null) {
        var _wasHydrated2 = popHydrationState(workInProgress);
        if (_wasHydrated2) {
          if (prepareToHydrateHostTextInstance(workInProgress)) {
            markUpdate(workInProgress);
          }
        }
      }
      return null;
    }
  }
}

popHydrationState

function popHydrationState(fiber) {
  if (fiber !== hydrationParentFiber) {
    return false;
  }

  if (!isHydrating) {
    popToNextHostParent(fiber);
    isHydrating = true;
    return false;
  }

  var type = fiber.type;

  if (
    fiber.tag !== HostComponent ||
    !shouldSetTextContent(type, fiber.memoizedProps)
  ) {
    var nextInstance = nextHydratableInstance;

    while (nextInstance) {
      deleteHydratableInstance(fiber, nextInstance);
      nextInstance = getNextHydratableSibling(nextInstance);
    }
  }

  popToNextHostParent(fiber);

  nextHydratableInstance = hydrationParentFiber
    ? getNextHydratableSibling(fiber.stateNode)
    : null;

  return true;
}

以下图为例:
image

在 beginWork 阶段对 p#B fiber 工作时,发现 dom 树中同一位置的h1#B不满足混合的条件,于是继续对比h1#B的兄弟节点,即div#C,仍然无法混合,经过最多两轮对比后发现p#B这个 fiber 没有可以混合的 dom 节点,于是将 isHydrating 标记为 false,hydrationParentFiber = fiberP#Bp#B的子孙节点都不再进行混合的过程。

div#B1fiber 没有子节点,因此它可以调用completeUnitOfWork完成工作,completeUnitOfWork 阶段调用 popHydrationState 方法,在popHydrationState方法内部,首先判断 fiber !== hydrationParentFiber,由于此时的hydrationParentFiber等于p#B,因此条件成立,不用往下执行。

由于p#B fiber 的子节点都已经完成了工作,因此它也可以调用completeUnitOfWork完成工作。同样的,在popHydrationState函数内部,第一个判断fiber !== hydrationParentFiber不成立,两者是相等的。第二个条件!isHydrating成立,进入条件语句,首先调用popToNextHostParenthydrationParentFiber设置为p#B的第一个类型为HostComponent的祖先元素,这里是div#A fiber,然后将isHydrating设置为 true,指示可以为p#B的兄弟节点进行混合。

如果服务端返回的 DOM 有多余的情况,则调用deleteHydratableInstance将其删除,比如下图中div#D节点将会在div#Afiber 的completeUnitOfWork阶段删除
image

prepareToHydrateHostInstance

对于HostComponent类型的fiber会调用这个方法,这里只要是关联 dom 和 fiber:

  • 设置domInstance.__reactFiber$w63z5ormsqk = fiber
  • 设置domInstance.__reactProps$w63z5ormsqk = props
  • 对比服务端和客户端的属性
function prepareToHydrateHostInstance(fiber) {
  var domInstance = fiber.stateNode;
  var updatePayload = hydrateInstance(
    domInstance,
    fiber.type,
    fiber.memoizedProps,
    fiber
  );

  fiber.updateQueue = updatePayload;
  if (updatePayload !== null) {
    return true;
  }

  return false;
}
function hydrateInstance(domInstance, type, props, fiber) {
  precacheFiberNode(fiber, domInstance); // domInstance.__reactFiber$w63z5ormsqk = fiber
  updateFiberProps(domInstance, props); // domInstance.__reactProps$w63z5ormsqk = props

  // 比较dom.attributes和props的差异,如果dom.attributes的属性比props多,说明服务端添加了额外的属性,此时控制台提示。
  // 注意,在对比过程中,只有服务端和客户端的children属性(即文本内容)不同时,控制台才会提示错误,同时在commit阶段,客户端会纠正这个错误,以客户端的文本为主。
  // 但是,如果是id不同,则客户端并不会纠正。
  return diffHydratedProperties(domInstance, type, props);
}

这里重点讲下diffHydratedProperties,以下面的demo为例:

// 服务端对应的dom
<div id="root"><div extra="server attr" id="server">客户端的文本</div></div>
// 客户端
render() {
  const { count } = this.state;
  return <div id="client">客户端的文本</div>;
}

diffHydratedProperties的过程中发现,服务端返回的id和客户端的id不同,控制台提示id不匹配,但是客户端并不会纠正这个,可以看到浏览器的id依然是server

同时,服务端多返回了一个extra属性,因此需要控制台提示,但由于已经提示了id不同的错误,这个错误就不会提示。

最后,客户端的文本和服务端的children不同,即文本内容不同,也需要提示错误,同时,客户端会纠正这个文本,以客户端的为主。

image

prepareToHydrateHostTextInstance

对于HostText类型的fiber会调用这个方法,这个方法逻辑比较简单,就不详细介绍了

盘点fiber副作用含义及常见的fiber flags使用场景

在 React 的渲染流程中,render 阶段从根节点开始处理所有的 fiber 节点,收集有副作用的 fiber 节点(即 fiber.flags 大于 1 的节点),并构建副作用链表。commit 阶段并不会处理所有的 fiber 节点,而是遍历副作用链表,根据 fiber.flags 的标志进行对应的处理。

位操作

在开始介绍 fiber flags 前,先来看下位操作

按位非(~)

按位非运算符(~),反转操作数的位。

const a = 5; // 00000000000000000000000000000101
const b = -3; // 11111111111111111111111111111101

console.log(~a); // 11111111111111111111111111111010,即-6

console.log(~b); // 00000000000000000000000000000010, 即2

按位非运算时,任何数字 x 的运算结果都是 -(x + 1)。例如,〜-5 运算结果为 4。

按位与(&)

按位与运算符 (&) 在两个操作数对应的二进位都为 1 时,该位的结果值才为 1,否则为 0。

按位或(|)

按位或运算符 (|) 在两个操作数对应的二进位只要有一个为 1 时,该位的结果值为 1,否则为 0。

按位异或(^)

有且仅有一个为 1 时,结果才为 1,否则为 0:

const a = 5; // 00000000000000000000000000000101
const b = 3; // 00000000000000000000000000000011

console.log(a ^ b); // 00000000000000000000000000000110,即6

React 为什么采用二进制表示副作用

原因可以归类为以下两点:

  • 位运算快速
  • 可以方便的给一个 fiber 节点添加多个副作用,同时内存开销小。

我们先来看下使用其他方式表示副作用会有什么问题。假设我们使用 2 表示插入,在 render 阶段,如果这个 fiber 节点是新的,我们就给这个 fiber 节点添加一个副作用:fiber.flags = 2。然后在 commit 阶段使用 fiber.flags === 2 判断节点是否需要插入。

这会带来一个问题,React 中一个 fiber 节点会有多个副作用,比如,既可以是插入,又可以是更新(类组件实现了 componentDidMount 方法,就是更新的副作用),如果使用十进制,我们可以很容易想到这样实现:

fiber.flags = [];
fiber.flags.push(2); // 插入
fiber.flags.push(4); // 更新,此时 fiber.flags有两个副作用:[2, 4]

在 commit 阶段就可以这样判断:

if (fiber.flags.includes(2)) {
  // 执行插入的逻辑
}
if (fiber.flags.includes(4)) {
  // 执行更新的逻辑
}

这样做理论上是可以的,但是数组操作比较麻烦,还会冗余,比如,如果多次 fiber.flags.push(2) 就会有多个重复的 2。同时如果需要先删除插入的副作用,并添加一个更新的副作用,操作起来较繁琐

因此 React 采用了二进制标记这些副作用。不仅占用内存小,运算迅速,同时还能表示多个副作用

如果一个 fiber 节点,既要插入又要更新,可以这样标记:

fiber.flags |= Placement | Update; // Placement 0b000000000000000010  Update  0b000000000000000100

如果需要删除一个插入的副作用,并且添加一个更新的副作用,那么可以这样标记:

fiber.flags = (fiber.flags & ~Placement) | Update;

可以说是相当的方便了

Fiber flags

PerformedWork 是专门提供给 React Dev Tools 读取的。fiber 节点的副作用从 2 开始。0 表示没有副作用。

对于原生的 HTML 标签,如果需要修改属性,文本等,就视为有副作用。对于类组件,如果类实例实现了 componentDidMountcomponentDidUpdate 等生命周期方法,则视为有副作用。对于函数组件,如果实现了 useEffectuseLayoutEffect 等 hook,则视为有副作用。以上这些都是副作用的例子。

React 在 render 阶段给有副作用的节点添加标志,并在 commit 阶段根据 fiber flags 执行对应的副作用操作,比如调用生命周期方法,或者操作真实的 DOM 节点。

React 支持的所有 flags

// 下面两个运用于 React Dev Tools,不能更改他们的值
const NoFlags = 0b000000000000000000;
const PerformedWork = 0b000000000000000001;

// 下面的 flags 用于标记副作用
const Placement = 0b000000000000000010; // 2 移动,插入
const Update = 0b000000000000000100; // 4
const PlacementAndUpdate = 0b000000000000000110; // 6
const Deletion = 0b000000000000001000; // 8
const ContentReset = 0b000000000000010000; // 16
const Callback = 0b000000000000100000; // 32 类组件的 update.callback
const DidCapture = 0b000000000001000000; // 64
const Ref = 0b000000000010000000; // 128
const Snapshot = 0b000000000100000000; // 256
const Passive = 0b000000001000000000; // 512
const Hydrating = 0b000000010000000000; // 1024

const HydratingAndUpdate = 0b000000010000000100; // 1028 Hydrating | Update

// 这是所有的生命周期方法(lifecycle methods)以及回调(callbacks)相关的副作用标志,其中 callbacks 指的是 update 的回调,比如调用this.setState(arg, callback)的第二个参数
const LifecycleEffectMask = 0b000000001110100100; // 932 Passive | Update | Callback | Ref | Snapshot

// 所有 host effects 的集合
const HostEffectMask = 0b000000011111111111; // 2047

// 下面这些并不是真正的副作用标志
const Incomplete = 0b000000100000000000; // 2048
const ShouldCapture = 0b000001000000000000; // 4096
const ForceUpdateForLegacySuspense = 0b000100000000000000; // 16384

flags 位操作

这里简单列举一下 fiber flags 中一些位操作的含义。

// 1.移除所有的生命周期相关的 flags
fiber.flags &= ~LifecycleEffectMask;

// 2.只保留 host effect 相关的副作用,移除其他的副作用位
fiber.flags &= HostEffectMask;

// 3.只保留 "插入" 副作用
fiber.flags &= Placement;

// 4.移除 "插入" 副作用,添加 "更新" 副作用
fiber.flags = (fiber.flags & ~Placement) | Update;

Placement

render 阶段

reconcile children 过程中,如果节点需要移动,插入,则在 placeChild 以及 placeSingleChild 方法中将 fiber 标记为 Placement

newFiber.flags = Placement;

本质上,创建新的 fiber 节点,也是一种 Placement 的副作用,即在 commit 阶段需要插入。因此,在类组件的 updateClassComponent 方法中判断 fiber 节点如果是新创建的,则标记为 Placement

if (instance === null) {
  if (current !== null) {
    // Since this is conceptually a new fiber, schedule a Placement effect
    workInProgress.flags |= Placement;
  }
}

在懒加载的 mountLazyComponent 方法中,以及在函数组件第一次执行的 mountIndeterminateComponent 方法中,判断 fiber 节点如果是新创建的,则标记为 Placement

if (_current !== null) {
  // Since this is conceptually a new fiber, schedule a Placement effect
  workInProgress.flags |= Placement;
}

commit 阶段

commit 阶段执行 Placement 副作用操作。Placement 对应的副作用操作是插入新的 DOM 节点。插入节点的逻辑都在 commitMutationEffects 方法以及 commitPlacement 方法中

function commitPlacement(finishedWork) {
  // 执行节点的插入逻辑
  if (isContainer) {
    insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
  } else {
    insertOrAppendPlacementNode(finishedWork, before, parent);
  }
}
function commitMutationEffects(root, renderPriorityLevel) {
  while (nextEffect !== null) {
    var primaryFlags = flags & (Placement | Update | Deletion | Hydrating);
    switch (primaryFlags) {
      case Placement: {
        commitPlacement(nextEffect);
        // 插入逻辑执行完成后,移除 Placement 副作用标记
        nextEffect.flags &= ~Placement;
        break;
      }

      case PlacementAndUpdate: {
        // Placement
        commitPlacement(nextEffect);
        nextEffect.flags &= ~Placement;
        // Update
        commitWork(_current, nextEffect);
        break;
      }
    }
    nextEffect = nextEffect.nextEffect;
  }
}

Update

render 阶段

  • mountClassInstance 方法中判断类组件如果实现了 componentDidMount 方法
  • updateClassInstance 方法中判断如果类组件实现了 componentDidUpdate方法
  • updateHostComponent 方法中调用 prepareUpdate 方法判断 HostComponent 的属性如果发生了变更
  • updateHostText 方法中判断如果新旧文本不同
  • completeWork 方法中,判断如果 HostComponent 需要聚焦
  • 函数组件如果调用了 useEffectuseLayoutEffect 这两个 hook
workInProgress.flags |= Update;

commit 阶段

Update 副作用执行的逻辑在 commitMutationEffects 以及 commitLayoutEffects 两个方法中:

commitMutationEffects 方法,用于执行 commitWork:

function commitMutationEffects(root, renderPriorityLevel) {
  while (nextEffect !== null) {
    var primaryFlags = flags & (Placement | Update | Deletion | Hydrating);
    switch (primaryFlags) {
      case PlacementAndUpdate: {
        // Placement
        commitPlacement(nextEffect);
        // Clear the "placement" from effect tag so that we know that this is
        // inserted, before any life-cycles like componentDidMount gets called.
        nextEffect.flags &= ~Placement; // Update
        var _current = nextEffect.alternate;
        commitWork(_current, nextEffect);
        break;
      }
      case HydratingAndUpdate: {
        nextEffect.flags &= ~Hydrating; // Update
        var _current2 = nextEffect.alternate;
        commitWork(_current2, nextEffect);
        break;
      }
      case Update: {
        var _current3 = nextEffect.alternate;
        commitWork(_current3, nextEffect);
        break;
      }
    }
    nextEffect = nextEffect.nextEffect;
  }
}
function commitWork(current, finishedWork) {
  switch (finishedWork.tag) {
    case FunctionComponent: {
      // 调用函数组件 useLayoutEffect 的清除函数
      commitHookEffectListUnmount(Layout | HasEffect, finishedWork);
      return;
    }
    case HostComponent: {
      if (instance != null) {
        var updatePayload = finishedWork.updateQueue;
        if (updatePayload !== null) {
          // 更新真实的DOM节点的属性
          commitUpdate(instance, updatePayload, type, oldProps, newProps);
        }
      }
      return;
    }
    case HostText: {
      var oldText = current !== null ? current.memoizedProps : newText;
      commitTextUpdate(textInstance, oldText, newText); // 更新 textInstance.nodeValue = newText
      return;
    }
  }
}
function commitLayoutEffects(root, committedLanes) {
  while (nextEffect !== null) {
    if (flags & (Update | Callback)) {
      var current = nextEffect.alternate;
      commitLifeCycles(root, current, nextEffect);
    }
    nextEffect = nextEffect.nextEffect;
  }
}
function commitLifeCycles(finishedRoot, current, finishedWork, committedLanes) {
  switch (finishedWork.tag) {
    case ClassComponent: {
      if (finishedWork.flags & Update) {
        if (current === null) {
          instance.componentDidMount();
        } else {
          instance.componentDidUpdate(
            prevProps,
            prevState,
            instance.__reactInternalSnapshotBeforeUpdate
          );
        }
      }
      return;
    }
    case HostComponent: {
      if (current === null && finishedWork.flags & Update) {
        commitMount(_instance2, type, props); // commitMount用于判断元素是否需要自动聚焦
      }
      return;
    }
  }
}

Deletion

render 阶段

  • reconcile children 过程中, deleteChild 判断节点如果需要被删除
childToDelete.flags = Deletion;

commit 阶段

function commitMutationEffects(root, renderPriorityLevel) {
  while (nextEffect !== null) {
    var primaryFlags = flags & (Placement | Update | Deletion | Hydrating);

    switch (primaryFlags) {
      case Deletion: {
        commitDeletion(root, nextEffect); // 删除节点
        break;
      }
    }
    nextEffect = nextEffect.nextEffect;
  }
}

ContentReset

render 阶段

  • updateHostComponent 方法判断是否需要重置文本
workInProgress.flags |= ContentReset;

commit 阶段

function commitMutationEffects(root, renderPriorityLevel) {
  while (nextEffect !== null) {
    if (flags & ContentReset) {
      commitResetTextContent(nextEffect);
    }
    nextEffect = nextEffect.nextEffect;
  }
}

Callback

render 阶段

  • processUpdateQueue 判断如果 update.callback 不为 null
if (callback !== null) {
  workInProgress.flags |= Callback;
}

commit 阶段

function commitLayoutEffects(root, committedLanes) {
  while (nextEffect !== null) {
    if (flags & (Update | Callback)) {
      commitLifeCycles(root, current, nextEffect);
    }
    nextEffect = nextEffect.nextEffect;
  }
}
function commitLifeCycles(finishedRoot, current, finishedWork, committedLanes) {
  switch (finishedWork.tag) {
    case ClassComponent: {
      var updateQueue = finishedWork.updateQueue;
      if (updateQueue !== null) {
        commitUpdateQueue(finishedWork, updateQueue, instance); // 执行update.callback
      }
      return;
    }
    case HostRoot: {
      if (_updateQueue !== null) {
        commitUpdateQueue(finishedWork, _updateQueue, _instance); // 执行update.callback
      }
      return;
    }
  }
}

Snapshot

render 阶段

  • updateClassInstance 方法判断类组件实例如果实现了 getSnapshotBeforeUpdate 方法
workInProgress.flags |= Snapshot;

commit 阶段

function commitBeforeMutationEffects() {
  while (nextEffect !== null) {
    if ((flags & Snapshot) !== NoFlags) {
      commitBeforeMutationLifeCycles(current, nextEffect);
    }
    nextEffect = nextEffect.nextEffect;
  }
}
function commitBeforeMutationLifeCycles(current, finishedWork) {
  switch (finishedWork.tag) {
    case FunctionComponent: {
      return;
    }
    case ClassComponent: {
      if (finishedWork.flags & Snapshot) {
        if (current !== null) {
          var snapshot = instance.getSnapshotBeforeUpdate(prevProps, prevState);
        }
      }
      return;
    }
    case HostRoot: {
      if (finishedWork.flags & Snapshot) {
        var root = finishedWork.stateNode;
        clearContainer(root.containerInfo);
      }
      return;
    }
  }
}

Passive

render 阶段

  • 函数组件如果实现了 useEffect(注意,useLayoutEffect 并不属于 Passive 的副作用)
fiber.flags |= Passive;

commit 阶段

function commitBeforeMutationEffects() {
  while (nextEffect !== null) {
    var flags = nextEffect.flags;
    if ((flags & Passive) !== NoFlags) {
      // 启动一个微任务刷新 useEffect 的监听函数以及清除函数
      if (!rootDoesHavePassiveEffects) {
        rootDoesHavePassiveEffects = true;
        scheduleCallback(NormalPriority$1, function () {
          flushPassiveEffects();
          return null;
        });
      }
    }
    nextEffect = nextEffect.nextEffect;
  }
}

【React Scheduler源码第四篇】React Scheduler任务优先级调度及高优先级任务插队原理及源码手写

本章是手写 React Scheduler 异步任务调度源码系列的第四篇文章,上一篇文章可以查看【React Scheduler 源码第三篇】React Scheduler 原理及手写源码
。本章实现 scheduler 中任务优先级、高优先级任务插队相关的源码

优先级

以我们平时需求排期为例,优先级高的需求优先开始,在开发的过程中,总是会有更高优先级的需求插队。那怎么衡量需求的优先级呢?一般来说,优先级高的需求都是需要尽快完成尽早上线。因此,高优先级的需求总是比低优先级的需求早点提测,即高优先级的提测日期(deadline)会更早一些。比如,今天(9 月 8 日)在给需求 A、B、C、D 排期时:

  • D 的优先级比较高,2 天后提测,提测日期为 9 月 10 日
  • 其次是 B,5 天后提测,提测日期为 9 月 13 日
  • 然后是 C,10 天后提测,提测日期为 9 月 18 日
  • 最后是 A,20 天后提测,提测日期为 9 月 28 日

这些需求在甘特图中,就会标明每个需求的开始日期,截止日期等信息,然后项目管理人员会按照需求优先级(提测的日期)排序,优先级高的先开始执行。在这个过程中,如果有新的优先级高的需求,比如 E 需要 9 月 15 日提测,那么项目管理人员需要重新排序,然后发现需求 E 需要在 C 之前,B 之后执行。

同理,在 React 调度中,当我们通过 scheduleCallback 添加一个任务时,我们需要记录这个任务的开始时间,截止时间等信息,然后按照任务的截止时间排序,截止时间越小的,优先级越高,需要尽快执行。

那截止时间该怎么算呢?我们是不是可以调度的时候传入这个任务的截止时间,比如

scheduleCallback(new Date("2022-09-08 18:45: 34"), task);
scheduleCallback(new Date("2022-09-08 19:20: 00"), task);

不会真有人这样设计 API 吧?

实际上,类比于需求排期,我们只需要将需求的过期时间标明,比如 2 天后提测,那截止日期不就是当前时间 + 2 天吗?同理,我们在调度任务时,只需要告诉 scheduler 这个任务多久过期,比如 200 毫秒,1000 毫秒,还是 50000 毫秒,就不需要开发者手动计算截止时间:

scheduleCallback(1000ms, task);
scheduleCallback(200ms, task);
scheduleCallback(500ms, task);
scheduleCallback(600ms, task);
scheduleCallback(500ms, task);

由于传具体的值不够语义化,因此我们可以定义几个优先级的枚举,这些枚举值代表不同的过期时间,比如:

// 以下过期时间单位都是毫秒
const maxSigned31BitInt = 1073741823; // 最大整数
const IMMEDIATE_PRIORITY_TIMEOUT = -1; // 过期时间-1毫秒,超高优先级,需要立即执行
const USER_BLOCKING_PRIORITY_TIMEOUT = 250;
const NORMAL_PRIORITY_TIMEOUT = 5000;
const LOW_PRIORITY_TIMEOUT = 10000;
const IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt; // 永不过期,最低优先级
// 优先级
const ImmediatePriority = 1;
const UserBlockingPriority = 2;
const NormalPriority = 3;
const LowPriority = 4;
const IdlePriority = 5;

然后我们就可以调用 scheduleCallback 时传入对应的优先级即可

scheduleCallback(NormalPriority, task);

现在,让我们开始修改上一节的代码以支持优先级调度

scheduleCallback 优化

根据我们前面提到的,当调用 scheduleCallback 调度任务 task 时,scheduleCallback 必须进行以下几步操作

  • 获取当前 task 调度的时间 startTime
  • 根据优先级转换成 timeout
  • 根据 startTime 和 timeout 计算 task 的过期时间 expirationTime
  • 将 task 添加到队列 taskQueue 中,同时还需要根据任务的优先级排序,即根据 expirationTime 排序
  • 触发一个 Message Channel 事件,在异步事件中处理 task

这些步骤中,第四步需要根据 expirationTime 排序,这就要求我们每次通过 scheduleCallback 添加任务时,都需要重新排序。因此我们还需要一个排序算法。这里我简单实现如下:

// 每次插入一个任务,都需要重新排序以确定新的优先级。就像没插入一个需求,都需要重新按照截止日期排期以确定新的优先级
// 高优先级的任务在前面
// 在react scheduler源码中,采用的是最小堆排序算法。这里为了简化,咱就不那么卷了
function push(queue, task) {
  queue.push(task);
  queue.sort((a, b) => {
    return a.sortIndex - b.sortIndex;
  });
}

在 scheduler 源码中,采用的是最小堆排序算法。这里我就简单通过数组的 sort 方法简单实现了下排序算法

function scheduleCallback(priorityLevel, callback) {
  // 1.获取任务开始调度的时间startTime
  const startTime = new Date().getTime();
  let timeout;
  // 2.根据优先级转换成对应的timeout
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = IMMEDIATE_PRIORITY_TIMEOUT;
      break;

    case UserBlockingPriority:
      timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
      break;

    case IdlePriority:
      timeout = IDLE_PRIORITY_TIMEOUT;
      break;

    case LowPriority:
      timeout = LOW_PRIORITY_TIMEOUT;
      break;

    case NormalPriority:
    default:
      timeout = NORMAL_PRIORITY_TIMEOUT;
      break;
  }
  // 3.根据startTime和timeout计算任务的截止时间
  const expirationTime = startTime + timeout;

  let newTask = {
    callback: callback,
    priorityLevel,
    startTime,
    expirationTime: expirationTime,
    sortIndex: expirationTime,
  };
  // 4.通过push方法往任务队列中添加任务,同时根据expirationTime重新排序
  push(taskQueue, newTask);
  // 5.触发一个Messsage Channel 事件
  if (!isHostCallbackScheduled) {
    isHostCallbackScheduled = true;
    requestHostCallback(flushWork);
  }
  return newTask;
}

为什么需要 push 方法?push 方法每次添加一个任务都会进行重新排序,这同时解决了我们高优先级任务插队的问题,比如下面的 demo,一开始我们通过 scheduleCallback 添加了两个相同优先级的任务,当在异步的宏任务事件中执行 printA 时,又添加了一个高优先级的 printE。此时 printE 在 printB 前面执行

function printA() {
  scheduleCallback(ImmediatePriority, printE);
}
scheduleCallback(NormalPriority, printA);
scheduleCallback(NormalPriority, printB);

workLoop

由于我们引入了任务过期时间、优先级相关的东西,那我们在执行每个任务时,需要告诉用户这个任务是否已经过期。如果开始执行这个任务的时间大于任务的过期时间,那说明这个任务已经过期了。
如果任务已经过期,即使当前的宏任务事件执行时间已经超过 5 毫秒,也要在当前事件中执行完这个任务,而不是在下一次事件循环中处理。因此在 workLoop 中需要执行以下操作:

  • 判断当前任务是否过期
    • 如果过期了,则一定要在当前宏任务事件中执行完成
    • 如果还没过期,则需要判断当前宏任务事件执行时间是否超过 5 毫秒,如果超过,则退出循环,剩余任务在下一个宏任务事件中处理
  • 计算当前任务是否过期
function workLoop(initialTime) {
  let currentTime = initialTime;

  currentTask = taskQueue[0];

  while (currentTask) {
    if (currentTask.expirationTime > currentTime && shouldYield()) {
      // 当前的currentTask还没过期,但是当前宏任务事件已经到达执行的最后期限,即我们需要
      // 将控制权交还给浏览器,剩下的任务在下一个事件循环中再继续执行
      // console.log("yield");
      break;
    }
    const callback = currentTask.callback;
    // 问题1 为什么需要判断callback
    if (typeof callback === "function") {
      // 问题2 为什么需要重置callback为null
      currentTask.callback = null;
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      callback(didUserCallbackTimeout);
      currentTime = new Date().getTime();
      // 问题3 为什么需要判断currentTask是否等于taskQueue[0]
      if (currentTask === taskQueue[0]) {
        taskQueue.shift();
      }
    } else {
      taskQueue.shift();
    }
    currentTask = taskQueue[0];
  }
  if (currentTask) {
    // 如果taskQueue中还有剩余工作,则返回true
    return true;
  } else {
    isHostCallbackScheduled = false;
    return false;
  }
}

注意上面的问题 1 到 3,实际上这都是为了解决高优先级任务插队的问题。比如下面的测试用例 2 中,如果我们嵌套调用 scheduleCallback 插入更高优先级的任务:

function printA(didTimeout) {
  scheduleCallback(UserBlockingPriority, printC);
  console.log("A didTimeout:", didTimeout);
}
function printB(didTimeout) {
  console.log("B didTimeout:", didTimeout);
}
function printC(didTimeout) {
  console.log("C didTimeout:", didTimeout);
}
scheduleCallback(NormalPriority, printA);
scheduleCallback(NormalPriority, printB);

一开始通过 scheduleCallback 添加了两个相同优先级的任务,此时 taskQueue = [taskA, taskB],然后在宏任务事件中开始调用 workLoop 执行任务。首先执行的是 taskA,执行 taskA 时又通过scheduleCallback(UserBlockingPriority, printC);插入了一个更高优先级的任务 taskC,此时 taskQueue=[taskC, taskA, taskB],因此我们不能简单的通过taskQueue.shift()将第一项删除,所以才有下面的判断:

// 问题3 为什么需要判断currentTask是否等于taskQueue[0]
if (currentTask === taskQueue[0]) {
  taskQueue.shift();
}

那我们应该要怎么删除已经执行完成的 taskA?这就是问题 2,我们通过在一开始执行 callback 时,就重置 callback 为 null:currentTask.callback = null。等到 while 循环又遍历到 taskA 时,由于 taskA.callback 为 null,因此直接调用 taskQueue.shift()将其删除即可。因为问题 1-3 都是为了解决高优先级任务插队的问题

测试用例

用例 1:不同优先级的任务

通过 scheduleCallback 调度不同的优先级任务,优先级高的先执行

function printA(didTimeout) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 7) {}
  console.log("A didTimeout:", didTimeout);
}
function printB(didTimeout) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 3) {}
  console.log("B didTimeout:", didTimeout);
}
function printC(didTimeout) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 4) {}
  console.log("C didTimeout:", didTimeout);
}
function printD(didTimeout) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 7) {}
  console.log("D didTimeout:", didTimeout);
}
function printE(didTimeout) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 10) {}
  console.log("E didTimeout:", didTimeout);
}
scheduleCallback(IdlePriority, printA);
scheduleCallback(LowPriority, printB);
scheduleCallback(NormalPriority, printC);
scheduleCallback(UserBlockingPriority, printD);
scheduleCallback(ImmediatePriority, printE);

打印:

E didTimeout: true
D didTimeout: false
C didTimeout: false
B didTimeout: false
A didTimeout: false

用例 2:高优先级任务插队问题

先通过 scheduleCallback 添加两个普通优先级的任务,此时 taskQueue = [taskA,taskB],然后在执行 printA 时,又嵌套调用了
scheduleCallback 插入一个更高优先级的任务 taskC,此时 taskQueue=[taskC, taskA, taskB]

function printA(didTimeout) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 7) {}
  scheduleCallback(UserBlockingPriority, printC);
  console.log("A didTimeout:", didTimeout);
}
function printB(didTimeout) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 3) {}
  console.log("B didTimeout:", didTimeout);
}
function printC(didTimeout) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 4) {}
  console.log("C didTimeout:", didTimeout);
}
scheduleCallback(NormalPriority, printA);
scheduleCallback(NormalPriority, printB);

控制台输出:

A didTimeout: false
C didTimeout: false
B didTimeout: false

用例 3:任务过期则强制执行

这次我们添加三个执行耗时 1000 毫秒的任务,优先级都是 UserBlockingPriority,因此他们的过期时间 timeout 都是 250 毫秒。同时为了方便我们查看触发了几次宏任务事件,我们在 performWorkUntilDeadline 添加一个 log

function performWorkUntilDeadline() {
  console.log("触发了performWorkUntilDeadline执行");
  // ...
}
function printA(didTimeout) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 1000) {}
  console.log("A didTimeout:", didTimeout);
}
function printB(didTimeout) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 1000) {}
  console.log("B didTimeout:", didTimeout);
}
function printC(didTimeout) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 1000) {}
  console.log("C didTimeout:", didTimeout);
}
scheduleCallback(UserBlockingPriority, printA);
scheduleCallback(UserBlockingPriority, printB);
scheduleCallback(UserBlockingPriority, printC);

控制台输出:

触发了performWorkUntilDeadline执行
A didTimeout: false
B didTimeout: true
C didTimeout: true

因此可以看到,即使三个任务的执行耗时都是 1 秒,远超过 5 毫秒,但由于他们都超时了,因此都在当前的宏任务事件中执行完成

小结

至此,我们已经实现了按优先级调度任务以及高优先级任务插队的问题,完整源码可以看这里。下一篇继续介绍实现延迟任务的问题

从源码的角度理解useEffect以及useLayoutEffect的区别

本章从源码层面介绍 useLayoutEffect 以及 useEffect 的区别以及执行时机,类组件常见生命周期的执行时机,类组件 this.setState(arg, callback)callback 的执行时机。建议在阅读本章时,在各个函数的入口处打个断点调试,找找感觉。

前置知识

  • 监听函数和 clear清除函数 的约定。我们将传递给 useEffect 或者 useLayoutEffect 的函数叫做监听函数。监听函数的返回值叫 clear清除函数
  • React 渲染主要分为两个阶段:render 阶段 以及 commit 阶段。render 阶段是可以并发的,可以中断的。render 阶段主要是协调子节点,找出有副作用的节点,构造副作用链表以及 fiber 树。commit 阶段是同步的,一旦开始就不能够中断。commit 阶段对真实的 DOM 进行增删改查,执行对应的生命周期方法。
  • 在 react-dom.development.js 中找到 commitRootImpl 函数并在入口处设置断点,然后在 commitRootImpl 中找到调用 commitBeforeMutationEffectscommitMutationEffectscommitLayoutEffects 这三个函数的地方并设置断点。后面会具体解释这些函数的作用。

image

image

useLayoutEffect 和 useEffect 的区别

  • useLayoutEffect监听函数 以及 clear 清除函数 都是同步执行的,是在真实的 DOM 发生了改变之后,浏览器绘制之前执行的。

  • useEffect监听函数 以及 clear清除函数 是异步执行的,是在真实的 DOM 发生了改变并且浏览器绘制之后(此时 JS 主线程已经执行完毕)异步执行的

  • useLayoutEffect 和 useEffect 的使用场景

    • useLayoutEffect 的 监听函数 以及 clear 清除函数 的执行都会阻塞浏览器渲染。当需要操作真实的 DOM 时,需要放在 useLayoutEffect 的监听函数中执行,同时 useLayoutEffect 的监听函数尽量避免耗时长的任务
    • useEffect 的 监听函数 以及 clear清除函数 的执行都不会阻塞浏览器渲染。useEffect 尽量避免操作真实的 DOM,因为 useEffect 的监听函数的执行时机是在浏览器绘制之后执行。如果此时在 useEffect 的监听函数里又操作真实的 DOM,会导致浏览器回流重绘。同时可以将耗时长的任务放在 useEffect 的 监听函数 中执行。

场景复现

修改 index.html 文件,添加两个额外的 dom

<body>
  <div id="root"></div>
  <div style="margin-top: 100px" id="useEffect"></div>
  <div id="useLayoutEffect"></div>
</body>

演示的 demo 组件:

import React, { useEffect, useState, useLayoutEffect } from "react";
import ReactDOM from "react-dom";

const sleep = () => {
  const start = Date.now();
  while (Date.now() - start < 5000) {}
};
const Counter = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.getElementById("useEffect").innerText = "useEffect:" + count;
    return () => {
      console.log("use effect 清除 =============");
    };
  });
  useLayoutEffect(() => {
    document.getElementById("useLayoutEffect").innerText =
      "useLayoutEffect:" + count;
    return () => {
      console.log("use layout effect 清除 ===========");
    };
  });
  const onBtnClick = () => {
    setCount(count + 1);
  };
  return <button onClick={onBtnClick}>Counter:{count}</button>;
};

class Index extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      showCounter: true,
    };
  }
  render() {
    return [
      <div
        style={{ marginTop: "100px" }}
        onClick={() =>
          this.setState({ showCounter: !this.state.showCounter }, () =>
            console.log("this.setState回调函数执行======")
          )
        }
      >
        切换显示计数器
      </div>,
      this.state.showCounter && <Counter />,
    ];
  }
}

ReactDOM.render(<Index />, document.getElementById("root"));

useLayoutEffect 监听函数

useLayoutEffect 的监听函数中调用 sleep 函数

useLayoutEffect(() => {
  document.getElementById("useLayoutEffect").innerText =
    "useLayoutEffect:" + count;
  sleep(); // 死循环5秒
  return () => {
    console.log("use layout effect 清除 ===========");
  };
});

点击 Counter 按钮,过了大概 5 秒页面才刷新。可以看出 useLayoutEffect 的监听函数是同步执行的,会阻塞页面渲染

useLayoutEffect 清除函数

useLayoutEffectclear 清除函数中调用 sleep 函数

useLayoutEffect(() => {
  document.getElementById("useLayoutEffect").innerText =
    "useLayoutEffect:" + count;
  return () => {
    console.log("use layout effect 清除 ===========");
    sleep(); // 死循环5秒
  };
});

点击 Counter 按钮,过了大概 5 秒页面才刷新。可以看出 useLayoutEffect 的清除函数是同步执行的,会阻塞页面渲染

useEffect 监听函数

useEffect 的监听函数中调用 sleep 函数

useEffect(() => {
  document.getElementById("useEffect").innerText = "useEffect:" + count;
  sleep(); // 死循环5秒
  return () => {
    console.log("use effect 清除 =============");
  };
});

点击 Counter 按钮,页面立即刷新,过了大概 5 秒,useEffect:后面的数字才更新。因此 useEffect 的监听函数是异步执行的,不会阻塞页面更新。但是如果监听函数里面有 DOM 操作,会导致页面回流重绘

useEffect 清除函数

useEffect 的监听函数中调用 sleep 函数

useEffect(() => {
  document.getElementById("useEffect").innerText = "useEffect:" + count;
  return () => {
    console.log("use effect 清除 =============");
    sleep(); // 死循环5秒
  };
});

点击 Counter 按钮,页面立即刷新,过了大概 5 秒,useEffect:后面的数字才更新。因此 useEffect 的清除函数是异步执行的,不会阻塞页面更新。

清除函数有个细微差别,我们在 useEffect 的监听函数里面改变 useEffect 的 innerText,为什么 清除函数睡眠了 5 秒后,这个 DOM 才更新??答案就是,清除函数和监听函数是一起执行的,先执行清除函数,紧接着执行监听函数

下面让我们从源码层面来解析这个过程,可以在下面函数的地方设置断点并且 debug

commitRootImpl

commit 阶段分成三个子阶段:

  • 第一阶段:commitBeforeMutationEffects。DOM 变更前

    • 调用 类组件的 getSnapshotBeforeUpdate 生命周期方法
    • 启动一个微任务以刷新 passive effects 异步队列。passive effects 异步队列存的是 useEffect 的清除函数以及监听函数
  • 第二阶段:commitMutationEffects。DOM 变更,操作真实的 DOM 节点。注意这个阶段是 卸载 相关的生命周期方法执行时机

    • 操作真实的 DOM 节点:增删改查
    • 同步调用函数组件 useLayoutEffect清除函数
    • 同步调用类组件的 componentWillUnmount 生命周期方法
    • 将函数组件的 useEffect清除函数 添加进异步队列,异步执行。
    • 所有的函数组件的 useLayoutEffect 的清除函数都在这个阶段执行完成
  • 第三阶段:commitLayoutEffects。DOM 变更后

    • 调用函数组件的 useLayoutEffect 监听函数,同步执行
    • 将函数组件的 useEffect 监听函数放入异步队列,异步执行
    • 执行类组件的 componentDidMount 生命周期方法,同步执行
    • 执行类组件的 componentDidUpdate 生命周期方法,同步执行
    • 执行类组件 this.setState(arg, callback) 中的 callback 回调,同步执行

每一个子阶段都是一个 while 循环,从头开始遍历副作用链表。

let nextEffect;
function commitRootImpl(root, renderPriorityLevel) {
  const finishedWork = root.finishedWork;
  root.finishedWork = null;
  let firstEffect;
  if (firstEffect !== null) {
    // commie阶段被划分成多个小阶段。每个阶段都从头开始遍历整个副作用链表
    nextEffect = firstEffect;
    // 第一阶段,DOM变更前,调用getSnapshotBeforeUpdate等生命周期方法。
    commitBeforeMutationEffects();
    // 重置 nextEffect,从头开始
    nextEffect = firstEffect;
    // 第二阶段,操作真实的DOM
    commitMutationEffects(root, renderPriorityLevel);
    // 注意:由于此时真实的DOM已经操作完成,因此将 finishedWork 设置成当前的 fiber tree。
    root.current = finishedWork;
    // 重置 nextEffect,从头开始
    nextEffect = firstEffect;
    // 第三阶段:DOM变更后
    commitLayoutEffects(root, lanes);
  }
}

commitBeforeMutationEffects

这个函数主要是在 DOM 变更前执行,主要逻辑如下:

  • 调用 类组件的 getSnapshotBeforeUpdate 生命周期方法
  • 启动一个微任务以刷新 passive effects。passive effects 指的是 useEffect 的清除函数以及监听函数
function commitBeforeMutationEffects() {
  while (nextEffect !== null) {
    // 调用类组件的 getSnapshotBeforeUpdate 生命周期方法
    commitBeforeMutationLifeCycles(current, nextEffect);
    // 提前启动一个异步任务以便JS主线程执行完成后刷新异步队列
    scheduleCallback(NormalPriority$1, function () {
      flushPassiveEffects();
      return null;
    });
    nextEffect = nextEffect.nextEffect;
  }
}
function commitBeforeMutationLifeCycles(current, finishedWork) {
  switch (finishedWork.tag) {
    case FunctionComponent:
      // 函数组件没有操作
      return;
    case ClassComponent:
      instance.getSnapshotBeforeUpdate(prevProps, prevState);
      return;
  }
}

commitMutationEffects

这个函数操作 DOM,主要有三个方法:

  • commitPlacement。调用 parentNode.appendChild(child); 或者 container.insertBefore(child, beforeChild) 插入 DOM 节点
  • commitWork。同步调用函数组件 useLayoutEffect清除函数,这个函数对于类组件没有任何操作
  • commitDeletion。主要是删除 DOM 节点,以及调用当前节点以及子节点所有的 卸载 相关的生命周期方法
    • 同步调用函数组件的 useLayoutEffect清除函数,这是同步执行的
    • 将函数组件的 useEffect清除函数 添加进异步刷新队列,这是异步执行的
    • 同步调用类组件的 componentWillUnmount 生命周期方法
function commitMutationEffects(root, renderPriorityLevel) {
  while (nextEffect !== null) {
    // 插入,更新,删除 DOM 节点
    switch (primaryFlags) {
      case PlacementAndUpdate: {
        // 插入
        commitPlacement(nextEffect);
        commitWork(_current, nextEffect);
        break;
      }
      case Deletion: {
        // 删除
        commitDeletion(root, nextEffect);
        break;
      }
    }
    nextEffect = nextEffect.nextEffect;
  }
}
function commitPlacement(finishedWork) {
  if (isContainer) {
    insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
  } else {
    insertOrAppendPlacementNode(finishedWork, before, parent);
  }
}

function insertOrAppendPlacementNodeIntoContainer(node, before, parent) {
  if (before) {
    insertInContainerBefore(parent, stateNode, before);
  } else {
    appendChildToContainer(parent, stateNode);
  }
}
function commitWork(current, finishedWork) {
  switch (finishedWork.tag) {
    case FunctionComponent: {
      // 调用函数组件的清除函数
      commitHookEffectListUnmount(Layout | HasEffect, finishedWork);
      return;
    }
    case ClassComponent:
      // 可以看到 类组件 在这里是不执行任何操作的
      return;
  }
}
// 执行函数组件的 useLayoutEffect 监听函数的回调,即清除函数
function commitHookEffectListUnmount(tag, finishedWork) {
  do {
    // Unmount
    var destroy = effect.destroy;
    effect.destroy = undefined;
    destroy(); // 执行 useLayoutEffect 的清除函数
    effect = effect.next;
  } while (effect !== firstEffect);
}
function commitDeletion(finishedRoot, current, renderPriorityLevel) {
  // 调用所有子节点的 componentWillUnmount() 方法
  unmountHostComponents(finishedRoot, current);
}
function unmountHostComponents(finishedRoot, current, renderPriorityLevel) {
  while (true) {
    commitUnmount(finishedRoot, node);
  }
}
function commitUnmount(finishedRoot, current, renderPriorityLevel) {
  switch (current.tag) {
    case FunctionComponent: {
      do {
        if (effect  useEffect) {
          // 将 useEffect 的清除函数添加进异步刷新队列,useEffect 的清除函数是异步执行的
          enqueuePendingPassiveHookEffectUnmount(current, effect);
        } else {
          // 调用 useLayoutEffect 的清除函数,同步执行的
          // 其实就是直接调用destroy();
          safelyCallDestroy(current, destroy);
        }
        effect = effect.next;
      } while (effect !== firstEffect);
      return;
    }
    case ClassComponent: {
      // 直接调用类组件的 componentWillUnmount() 生命周期方法,同步执行
      safelyCallComponentWillUnmount(current, instance);
      return;
    }
  }
}

commitLayoutEffects

当执行到这个函数,此时 useLayoutEffect 的清除函数已经全部执行完成。

  • 调用函数组件的 useLayoutEffect 监听函数,同步执行
  • 将函数组件的 useEffect 监听函数放入异步队列,异步执行
  • 执行类组件的 componentDidMount 生命周期方法,同步执行
  • 执行类组件的 componentDidUpdate 生命周期方法,同步执行
  • 执行类组件 this.setState(arg, callback) 中的 callback 回调,同步执行
function commitLayoutEffects(root, committedLanes) {
  // 此时所有的 `useLayoutEffect` 的清除函数已经执行完成,在commitMutationEffects阶段执行的
  while (nextEffect !== null) {
    commitLifeCycles(root, current, nextEffect);
    nextEffect = nextEffect.nextEffect;
  }
}
function commitLifeCycles(finishedRoot, current, finishedWork, committedLanes) {
  switch (finishedWork.tag) {
    case FunctionComponent: {
      // 同步执行 useLayoutEffect 的监听函数
      commitHookEffectListMount(Layout | HasEffect, finishedWork);
      // 将 useEffect 的监听函数放入异步队列等待执行
      schedulePassiveEffects(finishedWork);
      return;
    }
    case ClassComponent: {
      // 第一次挂载的时候执行类组件的componentDidMount生命周期方法
      instance.componentDidMount();
      // 组件更新的时候执行类组件的 componentDidUpdate 生命周期方法
      instance.componentDidUpdate(prevProps, prevState, snapshotBeforeUpdate);
      // 调用类组件 this.setState(arg, callback) 的callback回调
      commitUpdateQueue(finishedWork, updateQueue, instance);
      return;
    }
  }
}

// 执行useLayoutEffect监听函数
function commitHookEffectListMount(tag, finishedWork) {
  do {
    if ((effect.tag & tag) === tag) {
      // Mount
      var create = effect.create;
      effect.destroy = create();
    }
    effect = effect.next;
  } while (effect !== firstEffect);
}

flushPassiveEffectsImpl

useEffect 的清除函数和监听函数执行的地方。在这个函数的入口处打个断点,观察清除函数和监听函数的执行时机。当 JS 主线程执行完毕,浏览器绘制页面完成后,这个函数才会异步执行

function flushPassiveEffectsImpl() {
  var unmountEffects = pendingPassiveHookEffectsUnmount;
  pendingPassiveHookEffectsUnmount = [];

  // 首先要一次性执行完所有的清除函数
  for (var i = 0; i < unmountEffects.length; i += 2) {
    var _effect = unmountEffects[i];
    var fiber = unmountEffects[i + 1];
    var destroy = _effect.destroy;
    _effect.destroy = undefined;

    if (typeof destroy === "function") {
      destroy();
    }
  }
  // 其次,一次性执行完所有的监听函数
  var mountEffects = pendingPassiveHookEffectsMount;
  pendingPassiveHookEffectsMount = [];

  for (var _i = 0; _i < mountEffects.length; _i += 2) {
    var _effect2 = mountEffects[_i];
    var _fiber = mountEffects[_i + 1];
    var create = _effect2.create;
    _effect2.destroy = create();
  }

  return true;
}

从这个函数的执行中也可以看出,useEffect 的 监听函数清除函数 在同一个调用栈中是同步执行的。

React合成事件原理及源码主流程

React合成事件分三个阶段

  • 事件注册
  • 事件绑定
  • 事件触发

流程阶段概览

  • 事件名称注册阶段。这个阶段主要涉及两个方法:registerSimpleEvents以及registerTwoPhaseEvent。这个阶段主要是收集所有的原生事件allNativeEvents

    • registerSimpleEvents方法。在DOMEventProperties.js文件中。

      registerSimpleEvents方法根据原生事件集合discreteEventPairsForSimpleEventPlugin = ['click', 'click', 'dragend', 'dragEnd'](集合中第一个是原生事件名称,第二个是react事件名称)创建topLevelEventsToReactNames={ click: 'onClick', dragend: 'onDragEnd' }对象。然后调用registerTwoPhaseEvent(reactName, [topEvent])(reactName是合成事件名称如onClicktopEvent是原生事件名称如click)注册合成事件名称

    • registerTwoPhaseEvent方法。在EventRegistry.js文件中。

      registerTwoPhaseEvent方法创建合成事件名称和原生事件名称依赖集合const registrationNameDependencies = { onClick: ['click'], onClickCapture: ['click'] }。同时收集所有的原生事件集合const allNativeEvents = ['click'],这是最终会在容器上绑定的事件

    • 目的。这一阶段最主要的目的就是收集allNativeEvents以及topLevelEventsToReactNames。为事件绑定阶段做准备

  • 事件绑定阶段。这个阶段的入口从listenToAllSupportedEvents方法开始

    • listenToAllSupportedEvents方法。在DOMPluginEventSystem.js文件中
      • 遍历allNativeEvents给容器root注册原生的捕获冒泡事件。
      • 注册过的事件会存储在容器root的__reactEvents$ggbtgfosccg属性中($后面是随机生成的字符串),root.__reactEvents$ggbtgfosccg=['click__bubble', 'click__capture']
      • 通过let listener = dispatchEvent.bind(null, domEventName, eventSystemFlags, rootContainerElement)创建事件监听器dispatchEvent
      • 通过target.addEventListener(eventType, dispatchEvent, capture)绑定事件
      • 目的。这个阶段主要两个目的:一是绑定事件,通过dispatchEvent注册原生事件处理函数,这个函数会在事件触发时执行,比如点击按钮。二是在容器上创建一个root.__reactEvents$ggbtgfosccg收集容器已经绑定过的事件,避免重复绑定
  • 事件触发阶段。这里需要了解一下真实dom和fiber的引用关系。react在运行时,创建真实dom节点时,会给真实的dom节点挂载一个dom.__reactFiber$ggbtgfosccg = fiber属性以保存dom和fiber的引用关系,同时挂载一个dom.__reactProps$ggbtgfosccg = props属性,保持fiber的props值。

    • dispatchEvent方法。在ReactDOMEventListener.js文件中。根据nativeEvent.target.__reactFiber$ggbtgfosccg获取fiber节点,并调用dispatchEventForPluginEventSystem(domEventName, eventSystemFlags, nativeEvent, targetInst, targetContainer)

    • dispatchEventForPluginEventSystem方法。在DOMPluginEventSystem.js文件中。

      • 调用SimpleEventPlugin.extractEvents方法提取事件处理函数,并存在dispatchQueue数组中
        • 根据原生事件名称获取对应的合成事件构造函数
        • 找出父节点上所有的事件监听函数,dom.__reactProps$ggbtgfosccg.onClick或者dom.__reactProps$ggbtgfosccg.onClickCapture,存在listeners数组中
        • 通过合成事件构造函数生成合成事件对象event,将{event, listeners}存入dispatchQueue数组中
      • 调用processDispatchQueue方法遍历执行事件处理函数

感受一下事件阶段相关属性

通过几张截图来感受一下真实DOM和Fiber之间的关系,以下面的demo为例

const element = (
    <div id="parent" onClick={handleDivClick} onClickCapture={handleDivClickCapture}>
        <button 
            id="child" 
            onClick={handleButtonClick} 
            onClickCapture={handleButtonClickCapture}
        >
            点击
        </button>
    </div>
)
ReactDOM.render(element, root)

事件绑定阶段React往root注册的所有原生事件集合,这个集合用于防止重复绑定事件
image

每个真实的DOM节点都会挂载两个属性:

  • __reactProps$xxx:关联节点的props,通过这个属性可以获取到对应的事件监听处理函数
  • __reactFiber$xxx:关联fiber节点,点击按钮的时候,可以通过fiber节点向上遍历找到父节点、祖父节点上的事件监听处理函数

问题:既然每个真实的DOM节点都挂载了一个__reactProps属性,那通过原生的dom节点的parent属性一样可以找到父级节点,为啥还要挂载一个__reactFiber属性,这个属性是否还有其他用途???
image

React会给每个注册了onClick 事件的DOM节点绑定一个noop空函数,这仅仅只是为了兼容safari浏览器
image

image

源码

合成事件源码在react-dom/events目录下,直接运行react-dom/events/index.jsx下面的代码即可测试

简单聊聊React中不同类型的fiber的updateQueue属性的作用

fiber 的 updateQueue 属性在不同类型的 fiber 节点中含义不同,本节主要介绍HostRootHostComponentClassComponentFunctionComponent这几种类型的 fiber updateQueue 的作用。

概述

  • 在 HostRoot Fiber 中,updateQueue存的是ReactDOM.render/ReactDOM.hydrate的第三个回调参数,是个环状链表
  • 在 ClassComponent Fiber 中,updateQueue存的是this.setState的更新队列,是个环状链表
  • 在 FunctionComponent Fiber 中,updateQueue存的是useEffect或者useLayoutEffect的监听函数,是个环状链表
  • 在 HostComponent Fiber 中,updateQueue存的是在更新期间有变更的属性的键值对,是个数组

下面我会从render阶段和commit 阶段介绍对updateQueue的处理。

render 阶段主要是为 updateQueue 赋值,并计算 updateQueue。commit 阶段遍历 updateQueue 执行相应的操作

HostRootFiber 容器节点

HostRootFiber就是root容器对应的 fiber 节点。

对于HostRootFiberupdateQueue用于存储ReactDOM.render或者ReactDOM.hydrate的第三个参数(回调函数)。

ReactDOM.render(<Home />, document.getElementById("root"), () => {
  console.log("render 回调....");
});
// 或者
ReactDOM.hydrate(<Home />, document.getElementById("root"), () => {
  console.log("hydrate 回调....");
});

HostRootFiberupdateQueue是一个环状链表。

初次渲染时,updateContainer方法会为HostRootFiber添加一个update对象,如下:

var update = {
  eventTime: eventTime,
  lane: lane,
  tag: UpdateState,
  payload: null,
  callback: null, // ReactDOM.render或者ReactDOM.hydrate方法的第三个参数
  next: null,
};
update.next = update; // 环状链表 shared.pending指向最后一个更新的对象
HostRootFiber.updateQueue = {
  baseState: null,
  effects: null,
  firstBaseUpdate: null,
  lastBaseUpdate: null,
  shared: {
    pending: update,
  },
};

render 阶段

beginWork 阶段调用processUpdateQueue方法遍历updateQueue.shared.pending中的更新队列,计算 state。如果更新的对象updatecallback有值,则将update存入updateQueue.effects数组中。同时将当前 fiber 标记为具有Callback的副作用

HostRootFiber.updateQueue = {
  baseState: { element },
  effects: [
    {
      callback: ƒ(),
      payload: { element },
      next: null,
    },
  ],
  shared: {
    pending: null,
  },
};

commit 阶段

commitLayoutEffects阶段调用commitLifeCycles方法,commitLifeCycles方法调用commitUpdateQueue执行回调方法。

function commitUpdateQueue(finishedWork, finishedQueue, instance) {
  // 遍历effects,执行callback回调
  var effects = finishedQueue.effects;
  finishedQueue.effects = null; // effects重置为null

  if (effects !== null) {
    for (var i = 0; i < effects.length; i++) {
      var effect = effects[i];
      var callback = effect.callback;

      if (callback !== null) {
        effect.callback = null;
        callback(instance);
      }
    }
  }
}

类组件

对于类组件,updateQueue 存的是更新队列,即 this.setState 的更新对象链表,是一个环状链表。

this.setState实际上会调用this.enqueueSetState方法构造一个更新对象,并添加到队列中。shared.pending指向最后一个更新。

// 更新对象
var update = {
  callback: null, // this.setState的第二个参数,即回调函数
  eventTime,
  lane: 1,
  next: null,
  payload: { count: 4 }, // this.setState的第一个参数
  tag: 0,
};
update.next = update; // 环状链表
fiber.updateQueue = {
  baseState: { count: 1 },
  effects: null,
  shared: {
    pending: update,
  },
};

render 阶段

beginWork 阶段,updateClassInstance调用processUpdateQueue遍历更新的队列,计算 state,最终处理后的updateQueue如下:

fiber.updateQueue = {
  baseState: { count: 2 },
  effects: [
    {
      callback, // callback存的是this.setState的第二个参数,即回调函数
      next: null,
      payload: { count: 2 },
      tag: 0,
    },
  ],
  firstBaseUpdate: null,
  lastBaseUpdate: null,
  shared: { pending: null },
};

commit 阶段

commitLayoutEffects阶段调用commitUpdateQueue判断如果updateQueue不为 null,则调用commitUpdateQueue遍历updateQueue.effects,执行setState的回调

HostComponent

HostComponent fiber,就是原生的 div、span 等 HTML 标签

HostComponent fiber 的updateQueue在初次渲染时为 null。只有在更新阶段,dom 的属性发生了变更,才不为 null。

HostComponent 的 updateQueue 存的是需要更新的属性键值对,此时 updateQueue 就是一个数组。如果 dom 上的属性没有发生变化,但是事件监听函数引用发生了变化,则 updateQueue 为空数组

beginWork

completeUnitOfWork阶段调用 updateHostComponent对比新旧 props 的变化。

function updateHostComponent() {
  var updatePayload = prepareUpdate(instance, type, oldProps, newProps);

  workInProgress.updateQueue = updatePayload;

  if (updatePayload) {
    markUpdate(workInProgress);
  }
}
function prepareUpdate() {
  return diffProperties(domElement, type, oldProps, newProps);
}

updateHostComponent主要逻辑如下:

  • 调用prepareUpdate比较属性,找出有差异的属性键值对存储在 updatePayload
  • 如果 updatePayload 不为 null,则将当前 fiber 标记为具有更新的副作用

diffProperties会比较 oldPropsnewProps,找出有差异的属性,比如:

// 更新前
<div id="1" test1="2">1</div>
// 更新后
<div id="2" test1="3" test2="4">2</div>

旧的 id = 1,而新的id = 2,则id发生了变更,因此需要添加到updatePayload中,此时updatePayload = ['id', 2]

这里需要注意,如果我们的属性没有变更,但是 onClick 等监听函数的引用发生了变更,则diffProperties会返回一个空数组以标记该节点需要更新

// 更新前
<div onClick={() => { console.log('onclick')}}>1</div>
// 更新后
<div onClick={() => { console.log('onclick')}}>1</div>

虽然 div 节点更新前后属性没有发生变化,但是 onClick 的引用发生了变化,则 updatePayload = []

commit 阶段

commitMutationEffects 阶段,如果updateQueue不为 null,则调用commitUpdate更新 dom 属性

FunctionComponent 函数组件

函数组件的updateQueue 存的是 useEffect 以及 useLayoutEffect 的监听函数,并且是一个环状链表。lastEffect指向最后一个effect。函数组件每次执行时,都会重新初始化 updateQueue

  • tag = 3。对应的是 useLayoutEffect
  • tag = 5。对应的是 useEffect

beginWork 阶段

renderWithHooks函数在执行函数组件时,构造 effect 对象,并添加到updateQueue队列中

// effect对象
var effect = {
  tag: tag, // useLayoutEffect的tag等于3。useEffect的tag等于5
  create: create, // useEffect或者useLayoutEffect的第一个参数,即监听函数,
  destroy: destroy, // useEffect或者useLayoutEffect的清除函数
  deps: deps, // 依赖。即useEffect或者useLayoutEffect的第二个参数,即依赖
  // Circular
  next: null,
};
effect.next = effect;
fiber.updateQueue = {
  lastEffect: effect,
};

commit 阶段

  • commitMutationEffects阶段调用commitHookEffectListUnmount 执行 useLayoutEffect的清除函数
  • commitLayoutEffects 阶段调用 commitHookEffectListMount 执行 useLayoutEffect的监听函数。然后调用schedulePassiveEffectsuseEffect的监听函数和清除函数放入微任务队列执行,useEffect是异步执行的
// 执行useLayoutEffect的清除函数
function commitHookEffectListUnmount(tag, finishedWork) {
  var updateQueue = finishedWork.updateQueue;
  var lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;

  if (lastEffect !== null) {
    var firstEffect = lastEffect.next;
    var effect = firstEffect;

    do {
      // useLayoutEffect
      if ((effect.tag & 3) === 3) {
        var destroy = effect.destroy; // 清除函数
        effect.destroy = undefined;

        if (destroy !== undefined) {
          destroy();
        }
      }

      effect = effect.next;
    } while (effect !== firstEffect);
  }
}
// 执行useLayoutEffect的监听函数
function commitHookEffectListMount(tag, finishedWork) {
  var updateQueue = finishedWork.updateQueue;
  var lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;

  if (lastEffect !== null) {
    var firstEffect = lastEffect.next;
    var effect = firstEffect;

    do {
      if ((effect.tag & 3) === 3) {
        // useLayoutEffect的监听函数
        var create = effect.create;
        effect.destroy = create();
      }

      effect = effect.next;
    } while (effect !== firstEffect);
  }
}
// 调度useEffect。将useEffect的监听函数以及清除函数放入微任务队列等待异步执行
function schedulePassiveEffects(finishedWork) {
  var updateQueue = finishedWork.updateQueue;
  var lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;

  if (lastEffect !== null) {
    var firstEffect = lastEffect.next;
    var effect = firstEffect;

    do {
      const { next, tag } = effect;
      if ((tag & 5) === 5) {
        enqueuePendingPassiveHookEffectUnmount(finishedWork, effect); // 将useEffect的清除函数放入微任务队列
        enqueuePendingPassiveHookEffectMount(finishedWork, effect); // 将useEffect的监听函数放入微任务队列
      }

      effect = next;
    } while (effect !== firstEffect);
  }
}

深入理解React17构建fiber副作用链表算法源码

本章介绍构建副作用链表的算法。

知识点

  • 了解什么是 fiber 副作用,以及 fiber 中与副作用相关的属性
  • 了解如何构建副作用链表

深入理解 React Fiber 副作用链表的构建算法

React 在 render 阶段构建副作用链表。其中,在 reconcile children(协调子节点) 时,如果旧的子节点需要删除,则标记为 Deletion 副作用,并添加到父节点的副作用链表中,这个操作在 beginWork 阶段完成。其余类型的副作用节点都在 completeUnitOfWork 阶段添加到父节点的副作用链表中。

假设我们在更新时需要渲染以下新的节点,A、B、D 都是需要更新的,而 C 是需要删除的

// 旧的节点
<div id="A">
  <div id="B"></div>
  <div id="C"></div>
  <div id="D"></div>
</div>
// 更新后新的节点
<div id="A-1">
  <div id="B-1"></div>
  <div id="D-1"></div>
</div>

我们按照 React 渲染流程(如果对渲染流程不熟悉,可以查看这篇文章)来拆解这个过程

// React渲染流程主要源码
function performUnitOfWork(unitOfWork) {
  // beginWork主要逻辑就是协调子节点,即根据最新的react element元素和旧的fiber节点进行对比
  const next = beginWork(current, unitOfWork, subtreeRenderLanes); // next 就是当前节点unitOfWork的子节点
  if (next === null) {
    // 如果没有子节点,说明当前节点可以完成了
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }
}

对于新的节点"A-1",我们调用 performUnitOfWork(div#A-1) 开始工作:

  • 执行 beginWork,比较新的子节点("B-1","D-1") 以及 旧的 fiber 节点("B","C","D"),发现 "C" 需要被删除,因此将 "C" 添加到父节点,即"A-1"的副作用链表中。"B" 以及 "D" 需要更新。beginWork 执行完成,将新的子节点 "B-1" 返回
  • "A-1" 还不可以完成,因为有子节点 "B-1" 返回:
    • 对于节点 "B-1",执行 beginWork,由于"B-1"没有子节点,因此 next 为 null,调用 completeUnitOfWork 完成 "B-1" 节点。在 completeUnitOfWork 中判断"B-1"有副作用,需要更新,因此将其添加到父节点"A-1"的副作用链表中。同时返回兄弟节点"D-1"继续工作
    • 对于节点 "D-1",执行 beginWork,由于"D-1"没有子节点,因此 next 为 null,调用 completeUnitOfWork 完成 "D-1" 节点。在 completeUnitOfWork 中判断"D-1"有副作用,需要更新,因此将其添加到父节点"A-1"的副作用链表中。由于"D-1"没有子节点,因此父节点"A-1"可以完成了
  • 调用 completeUnitOfWork 完成节点"A-1"

因此,对于一个节点来说,它的副作用链表,被删除的子节点都在链表前面(至少在 React18 以前是这样)。删除的副作用是最先加到父节点的副作用链表中的,其次才是其他类型的副作用节点。 因为在 render 阶段,React 首先调用 beginWork 协调当前节点(比如 A)的子节点

从以上过程也可以看出,React 是自底向上构建副作用链表的

在开始下面的内容之前,可以在 react-dom.development.js 中找到 performSyncWorkOnRoot 方法,在调用 commitRoot(root) 方法前添加一行代码 printEffectList(finishedWork)。用于在将副作用链表打印出来,方便我们直观感受副作用链表的遍历顺序。

image

function printEffectList(finishedWork) {
  let nextEffect = finishedWork.firstEffect;
  while (nextEffect) {
    const id = nextEffect.memoizedProps.id;
    const label = nextEffect.type + "#" + id;
    let flagOperate = "";
    if ((nextEffect.flags & Placement) !== NoFlags) {
      flagOperate += "插入";
    }
    if ((nextEffect.flags & Update) !== NoFlags) {
      flagOperate += "更新";
    }
    if ((nextEffect.flags & Deletion) !== NoFlags) {
      flagOperate += "删除";
    }
    if ((nextEffect.flags & ContentReset) !== NoFlags) {
      flagOperate += "重置文本内容";
    }
    if ((nextEffect.flags & Callback) !== NoFlags) {
      flagOperate += "回调";
    }
    console.log(flagOperate + label);
    nextEffect = nextEffect.nextEffect;
  }
}

image

fiber 副作用

React 通过 fiber flag 属性标记副作用。如果不明白副作用是啥,可以看这篇文章深入理解 React Fiber 副作用

fiber 中与副作用相关的属性如下:

function FiberNode() {
  // ...
  // Effects
  this.flags = NoFlags;
  this.nextEffect = null;
  this.firstEffect = null;
  this.lastEffect = null;
  // ...
}

同时,建议使用以下 demo 测试:

import React from "react";
import ReactDOM from "react-dom";
class Home extends React.Component {
  constructor(props) {
    super(props);
    this.state = { step: 0 };
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    this.setState({
      step: this.state.step + 1,
    });
  }

  render() {
    const { step } = this.state;
    return (
      <div
        style={{ height: "100px" }}
        id={"A-" + step}
        onClick={this.handleClick}
      >
        <div id={"B" + step}></div>
        <div id={"C" + step}></div>
      </div>
    );
  }
}

ReactDOM.render(<Home />, document.getElementById("root"));

fiber 节点副作用链表

每个 fiber 节点都各自维护一个单向的具有副作用的子节点链表,其中 firstEffect 指向表头。lastEffect 指向表尾。子节点之间通过 nextEffect 连接。在 completeUnitOfWork 阶段,fiber 节点向父节点上交自己的副作用链表,这么说有点抽象,下面我们通过几个例子实践一下

React 在页面第一次渲染时,不会追踪副作用,因此 React 在第一次页面渲染时,是不会构建副作用链表的。所以在我们的例子中,不考虑页面第一次渲染的情况,我们只关注点击按钮触发页面更新的阶段

只有父子节点更新的情况

render() {
  const { step } = this.state;
  return (
    <div
      style={{ height: "100px" }}
      id={"A" + step}
      onClick={this.handleClick}
    >
      <div id={"B" + step}></div>
      <div id={"C" + step}></div>
    </div>
  );
}

当我们点击页面时,控制台依次打印:

更新div#B1
更新div#C1
更新div#A1

可以看出,div#B1 节点先完成,其次是 div#C1,最后才是父节点 div#A1,这三个节点都是具有更新的副作用,对应的副作用链表如下:

image

我们可以简单的实现下这个算法:

const Update = 4;
const Placement = 2;
const Deletion = 8;
const NoFlags = 0;
const HostRootFiber = { id: "root", flags: 0 };
function printEffectList(finishedWork) {
  let nextEffect = finishedWork.firstEffect;
  while (nextEffect) {
    const id = nextEffect.id;
    const label = "div#" + id;
    let flagOperate = "";
    if ((nextEffect.flags & Placement) !== NoFlags) {
      flagOperate += "插入";
    }
    if ((nextEffect.flags & Update) !== NoFlags) {
      flagOperate += "更新";
    }
    if ((nextEffect.flags & Deletion) !== NoFlags) {
      flagOperate += "删除";
    }
    console.log(flagOperate + label);
    nextEffect = nextEffect.nextEffect;
  }
}
const fiberA = { id: "A1", flags: Update, return: HostRootFiber };
const fiberB = { id: "B1", flags: Update, return: fiberA };
const fiberC = { id: "C1", flags: Update, return: fiberA };
function completeUnitOfWork(unitOfWork) {
  const returnFiber = unitOfWork.return;
  if (!returnFiber) return;
  const flags = unitOfWork.flags;
  // flags > 1才说明该节点具有副作用,才可以提交到其父节点中
  if (flags > 1) {
    if (returnFiber.lastEffect) {
      returnFiber.lastEffect.nextEffect = unitOfWork;
    } else {
      returnFiber.firstEffect = unitOfWork;
    }

    returnFiber.lastEffect = unitOfWork;
  }
}

completeUnitOfWork(fiberB);
completeUnitOfWork(fiberC);
completeUnitOfWork(fiberA);
printEffectList(fiberA);

复制这段代码,可以在本地测试一下,会发现控制台只打印了:

更新div#B1
更新div#C1

没有打印 更新div#A1,这是因为在实际的场景中,"div#A1"也是需要向它的父 fiber 节点提交它的整个副作用链表的,同时将自身添加到它的副作用链表末尾。这里我们直接假设 "div#A1" 的父节点就是我们的容器节点"div#root"。整个提交过程如下图所示:

image

我们来完善一下我们的 completeUnitOfWork 以支持向父节点提交当前节点的副作用链表:

function completeUnitOfWork(unitOfWork) {
  const returnFiber = unitOfWork.return;
  if (!returnFiber) return;
  const flags = unitOfWork.flags;
  // flags > 1才说明该节点具有副作用,才可以提交到其父节点中
  if (flags > 1) {
    // 第一步 让父节点的firstEffect指向当前节点的firstEffect
    // 注意,只有当父节点的 firstEffect 不存在时,我们才能将父节点的firstEffect指向当前节点的副作用链表表头
    if (!returnFiber.firstEffect) {
      returnFiber.firstEffect = unitOfWork.firstEffect;
    }
    // 第二步 将当前节点添加到它的副作用链表中,这里需要判断当前节点是否存在副作用链表
    // 如果存在lastEffect,说明当前节点存在副作用链表
    if (unitOfWork.lastEffect) {
      returnFiber.lastEffect = unitOfWork.lastEffect;
    }
    if (returnFiber.lastEffect) {
      // 第三步,将当前节点添加到其副作用链表末尾
      returnFiber.lastEffect.nextEffect = unitOfWork;
    } else {
      returnFiber.firstEffect = unitOfWork;
    }

    returnFiber.lastEffect = unitOfWork;
  }
}

执行代码,观察控制台输出,可以发现符合我们的预期。

这里又有一个问题,假如 fiberA 没有副作用,即 flags 为 0:

const fiberA = { id: "A1", flags: 0, return: HostRootFiber };

这时候执行代码,发现控制台打印为空。这是为什么?原因很简单,这里我们需要注意,即使当前 fiber 节点没有副作用,但是它有副作用链表,比如 fiberA,没有副作用,但是它子节点有副作用,也就是 fiberA 还是存在副作用链表的,即 fiberA 的 firstEffect 以及 lastEffect 都不为空,因此我们也是需要将 fiberA 的副作用链表提交到 fiberA 的父节点中的。

在我们的 completeUnitOfWork 中,前两步都是在向父节点提交副作用链表,我们可以将这个逻辑挪出判断当前 fiber 节点是否有副作用外面去:

const fiberA = { id: "A1", flags: 0, return: HostRootFiber };
const fiberB = { id: "B1", flags: Update, return: fiberA };
const fiberC = { id: "C1", flags: Update, return: fiberA };
function completeUnitOfWork(unitOfWork) {
  const returnFiber = unitOfWork.return;
  if (!returnFiber) return;
  const flags = unitOfWork.flags;
  // 首先,不管当前unitOfWork节点是否有副作用,都需要将它的副作用链表提交到父节点中
  // 第一步 让父节点的firstEffect指向当前节点的firstEffect
  // 注意,只有当父节点的 firstEffect 不存在时,我们才能将父节点的firstEffect指向当前节点的副作用链表表头
  if (!returnFiber.firstEffect) {
    returnFiber.firstEffect = unitOfWork.firstEffect;
  }
  // 第二步 将当前节点添加到它的副作用链表中,这里需要判断当前节点是否存在副作用链表
  // 如果存在lastEffect,说明当前节点存在副作用链表
  if (unitOfWork.lastEffect) {
    returnFiber.lastEffect = unitOfWork.lastEffect;
  }
  // 前面两步都是在向父节点提交当前节点的副作用链表,不需要放在判断当前节点是否有副作用的条件语句里面
  // flags > 1才说明该节点具有副作用,才可以提交到其父节点中
  if (flags > 1) {
    if (returnFiber.lastEffect) {
      // 第三步,将当前节点添加到其副作用链表末尾
      returnFiber.lastEffect.nextEffect = unitOfWork;
    } else {
      returnFiber.firstEffect = unitOfWork;
    }

    returnFiber.lastEffect = unitOfWork;
  }
}

复杂节点更新的情况

render() {
  const { step } = this.state;
  return [
    <div
      style={{ height: "100px" }}
      id={"A" + step}
      onClick={this.handleClick}
    >
      <div id={"B" + step}></div>
      <div id={"C" + step}></div>
    </div>,
    <div id={"E" + step}>
      <div id={"F" + step}></div>
      <div id={"G" + step}></div>
    </div>,
  ];
}

根据 React 渲染流程我们可以知道,节点完成顺序如下:B,C,A,F,G,E

当我们点击页面时,控制台依次打印:

更新div#B1
更新div#C1
更新div#A1
更新div#F1
更新div#G1
更新div#E1

运行我们上一节实现的 completeUnitOfWork:

const fiberA = { id: "A1", flags: Update, return: HostRootFiber };
const fiberB = { id: "B1", flags: Update, return: fiberA };
const fiberC = { id: "C1", flags: Update, return: fiberA };
const fiberE = { id: "E1", flags: Update, return: HostRootFiber };
const fiberF = { id: "F1", flags: Update, return: fiberE };
const fiberG = { id: "G1", flags: Update, return: fiberE };
function completeUnitOfWork(unitOfWork) {
  const returnFiber = unitOfWork.return;
  if (!returnFiber) return;
  const flags = unitOfWork.flags;
  // 第一步 让父节点的firstEffect指向当前节点的firstEffect
  // 注意,只有当父节点的 firstEffect 不存在时,我们才能将父节点的firstEffect指向当前节点的副作用链表表头
  if (!returnFiber.firstEffect) {
    returnFiber.firstEffect = unitOfWork.firstEffect;
  }
  // 第二步 将当前节点添加到它的副作用链表中,这里需要判断当前节点是否存在副作用链表
  // 如果存在lastEffect,说明当前节点存在副作用链表
  if (unitOfWork.lastEffect) {
    returnFiber.lastEffect = unitOfWork.lastEffect;
  }
  // 前面两步都是在向父节点提交当前节点的副作用链表,不需要放在判断当前节点是否有副作用的条件语句里面
  // flags > 1才说明该节点具有副作用,才可以提交到其父节点中
  if (flags > 1) {
    if (returnFiber.lastEffect) {
      // 第三步,将当前节点添加到其副作用链表末尾
      returnFiber.lastEffect.nextEffect = unitOfWork;
    } else {
      returnFiber.firstEffect = unitOfWork;
    }

    returnFiber.lastEffect = unitOfWork;
  }
}
completeUnitOfWork(fiberB);
completeUnitOfWork(fiberC);
completeUnitOfWork(fiberA);
completeUnitOfWork(fiberF);
completeUnitOfWork(fiberG);
completeUnitOfWork(fiberE);
printEffectList(HostRootFiber);

运行完成可以发现控制台只打印了 B1、C1、A1。问题就出在了 completeUnitOfWork(fiberE); E 节点的完成时。

image

根据上图,我们修改一下我们的代码,在向父节点提交自己的副作用链表时,判断一下父节点是否已经存在了副作用链表,如果父节点已经存在副作用链表,则将自己的副作用链表追加到父节点的副作用链表后面:

function completeUnitOfWork(unitOfWork) {
  const returnFiber = unitOfWork.return;
  if (!returnFiber) return;
  const flags = unitOfWork.flags;
  // 第一步 让父节点的firstEffect指向当前节点的firstEffect
  // 注意,只有当父节点的 firstEffect 不存在时,我们才能将父节点的firstEffect指向当前节点的副作用链表表头
  if (!returnFiber.firstEffect) {
    returnFiber.firstEffect = unitOfWork.firstEffect;
  }
  // 第二步 将当前节点添加到它的副作用链表中,这里需要判断当前节点是否存在副作用链表
  // 如果存在lastEffect,说明当前节点存在副作用链表
  if (unitOfWork.lastEffect) {
    // 在向父节点提交自己的副作用链表时,需要判断父节点是否已经存在副作用链表。如果父节点已经有副作用链表,那么将自己的表头
    // 追加到父节点的副作用链表中
    // return.lastEffect存在,说明父节点已经存在副作用链表
    if (returnFiber.lastEffect) {
      returnFiber.lastEffect.nextEffect = unitOfWork.firstEffect;
    }
    returnFiber.lastEffect = unitOfWork.lastEffect;
  }
  // 前面两步都是在向父节点提交当前节点的副作用链表,不需要放在判断当前节点是否有副作用的条件语句里面
  // flags > 1才说明该节点具有副作用,才可以提交到其父节点中
  if (flags > 1) {
    if (returnFiber.lastEffect) {
      // 第三步,将当前节点添加到其副作用链表末尾
      returnFiber.lastEffect.nextEffect = unitOfWork;
    } else {
      returnFiber.firstEffect = unitOfWork;
    }

    returnFiber.lastEffect = unitOfWork;
  }
}

控制台执行,发现输出已经符合我们的预期了。

以上就是最终 React 在 completeUnitOfWork 函数中构建副作用链表的逻辑,这里我们省略了 completeUnitOfWork 函数中的 while 循环,改成手动为每个 fiber 节点调用 completeUnitOfWork,但丝毫不影响我们理解构建副作用链表的过程

既更新又删除节点的复杂情况

render() {
  const { step } = this.state;
  return [
    <div
      style={{ height: "100px" }}
      id={"A" + step}
      onClick={this.handleClick}
    >
      <div id={"B" + step}></div>
      <div id={"C" + step}></div>
      {!(step % 2) && <div id={"D" + step}></div>}
    </div>,
    <div id={"E" + step}>
      <div id={"F" + step}></div>
      {!(step % 2) && <div id={"H" + step}></div>}
      <div id={"G" + step}></div>
      {!(step % 2) && <div id={"I" + step}></div>}
    </div>,
  ];
}

当我们点击页面时,触发更新时,根据 React 渲染流程我们可以知道,在 render 阶段,为 A1 节点协调子节点时,D0 被标记为具有删除的副作用,并且首先添加到父节点 A1 的副作用链表中。其次完成 B1、C1、A1。

同样,在 render 阶段,在为 E1 节点协调子节点时,H0 首先被标记为具有删除的副作用,并且首先添加到父节点 E1 的副作用链表中,其次 I0 被标记为具有删除的副作用,并且添加到父节点 E1 的副作用链表中,最后依次完成 F1、G1、E1

控制台依次打印:

删除div#D0
更新div#B1
更新div#C1
更新div#A1
删除div#H0
删除div#I0
更新div#F1
更新div#G1
更新div#E1

我们新增一个 deleteChild 方法,实现节点删除的情况:

const Update = 4;
const Placement = 2;
const Deletion = 8;
const NoFlags = 0;
const HostRootFiber = { id: "root", flags: 0 };
function printEffectList(finishedWork) {
  let nextEffect = finishedWork.firstEffect;
  while (nextEffect) {
    const id = nextEffect.id;
    const label = "div#" + id;
    let flagOperate = "";
    if ((nextEffect.flags & Placement) !== NoFlags) {
      flagOperate += "插入";
    }
    if ((nextEffect.flags & Update) !== NoFlags) {
      flagOperate += "更新";
    }
    if ((nextEffect.flags & Deletion) !== NoFlags) {
      flagOperate += "删除";
    }
    console.log(flagOperate + label);
    nextEffect = nextEffect.nextEffect;
  }
}
const fiberA = { id: "A1", flags: Update, return: HostRootFiber };
const fiberB = { id: "B1", flags: Update, return: fiberA };
const fiberC = { id: "C1", flags: Update, return: fiberA };
const fiberD = { id: "D0", flags: Deletion, return: fiberA };
const fiberE = { id: "E1", flags: Update, return: HostRootFiber };
const fiberF = { id: "F1", flags: Update, return: fiberE };
const fiberG = { id: "G1", flags: Update, return: fiberE };
const fiberH = { id: "H0", flags: Deletion, return: fiberE };
const fiberI = { id: "I0", flags: Deletion, return: fiberE };
function completeUnitOfWork(unitOfWork) {
  const returnFiber = unitOfWork.return;
  if (!returnFiber) return;
  const flags = unitOfWork.flags;
  // 第一步 让父节点的firstEffect指向当前节点的firstEffect
  // 注意,只有当父节点的 firstEffect 不存在时,我们才能将父节点的firstEffect指向当前节点的副作用链表表头
  if (!returnFiber.firstEffect) {
    returnFiber.firstEffect = unitOfWork.firstEffect;
  }
  // 第二步 将当前节点添加到它的副作用链表中,这里需要判断当前节点是否存在副作用链表
  // 如果存在lastEffect,说明当前节点存在副作用链表
  if (unitOfWork.lastEffect) {
    if (returnFiber.lastEffect) {
      returnFiber.lastEffect.nextEffect = unitOfWork.firstEffect;
    }
    returnFiber.lastEffect = unitOfWork.lastEffect;
  }
  // 前面两步都是在向父节点提交当前节点的副作用链表,不需要放在判断当前节点是否有副作用的条件语句里面
  // flags > 1才说明该节点具有副作用,才可以提交到其父节点中
  if (flags > 1) {
    if (returnFiber.lastEffect) {
      // 第三步,将当前节点添加到其副作用链表末尾
      returnFiber.lastEffect.nextEffect = unitOfWork;
    } else {
      returnFiber.firstEffect = unitOfWork;
    }

    returnFiber.lastEffect = unitOfWork;
  }
}

function deleteChild(returnFiber, childToDelete) {
  // 需要删除的节点总是会被添加到父节点的副作用链表的最前面
  // 当调用deleteChild时,父节点的副作用链表只包含被删除的节点
  const last = returnFiber.lastEffect;
  if (last) {
    last.nextEffect = childToDelete;
    returnFiber.lastEffect = childToDelete;
  } else {
    returnFiber.firstEffect = returnFiber.lastEffect = childToDelete;
  }
  childToDelete.nextEffect = null;
}
deleteChild(fiberA, fiberD);
completeUnitOfWork(fiberB);
completeUnitOfWork(fiberC);
completeUnitOfWork(fiberA);
deleteChild(fiberE, fiberH);
deleteChild(fiberE, fiberI);
completeUnitOfWork(fiberF);
completeUnitOfWork(fiberG);
completeUnitOfWork(fiberE);
printEffectList(HostRootFiber);

总结

以上就是 React17 在 render 阶段构建副作用链表的过程。React17 采用自底向上,逐级向父节点提交副作用链表的方式构建副作用链表。实际上这种方式比较麻烦,还难以理解。理论上可以采用数组存储这些具有副作用的节点,参考issue。在 React18 版本中,已经移除了这种构建方式。

彻底搞懂React函数组件hook原理以及hook链表

hook 链表保存在 fiber 节点的 memoizedState 属性上。

概述

  • 每一个 hook 函数都有对应的 hook 对象保存状态信息
  • useContext是唯一一个不需要添加到 hook 链表的 hook 函数
  • 只有 useEffect、useLayoutEffect 以及 useImperativeHandle 这三个 hook 具有副作用,在 render 阶段需要给函数组件 fiber 添加对应的副作用标记。同时这三个 hook 都有对应的 effect 对象保存其状态信息
  • 每次渲染都是重新构建 hook 链表以及 收集 effect list(fiber.updateQueue)
  • 初次渲染调用 mountWorkInProgressHook 构建 hook 链表。更新渲染调用 updateWorkInProgressHook 构建 hook 链表并复用上一次的 hook 状态信息

Demo

可以用下面的 demo 在本地调试

import React, {
  useState,
  useEffect,
  useContext,
  useCallback,
  useMemo,
  useRef,
  useImperativeHandle,
  useLayoutEffect,
  forwardRef,
} from "react";
import ReactDOM from "react-dom";
const themes = {
  foreground: "red",
  background: "#eeeeee",
};
const ThemeContext = React.createContext(themes);

const Home = forwardRef((props, ref) => {
  debugger;
  const [count, setCount] = useState(0);
  const myRef = useRef(null);
  const theme = useContext(ThemeContext);
  useEffect(() => {
    console.log("useEffect", count);
  }, [count]);
  useLayoutEffect(() => {
    console.log("useLayoutEffect...", myRef);
  });
  const res = useMemo(() => {
    console.log("useMemo");
    return count * count;
  }, [count]);
  console.log("res...", res);
  useImperativeHandle(ref, () => ({
    focus: () => {
      myRef.current.focus();
    },
  }));

  const onClick = useCallback(() => {
    setCount(count + 1);
  }, [count]);
  return (
    <div style={{ color: theme.foreground }} ref={myRef} onClick={onClick}>
      {count}
    </div>
  );
});

ReactDOM.render(<Home />, document.getElementById("root"));

fiber

React 在初次渲染或者更新过程中,都会在 render 阶段创建新的或者复用旧的 fiber 节点。每一个函数组件,都有对应的 fiber 节点。

fiber 的主要属性如下:

var fiber = {
  alternate,
  child,
  elementType: () => {},
  memoizedProps: null,
  memoizedState: null, // 在函数组件中,memoizedState用于保存hook链表
  pendingProps: {},
  return,
  sibling,
  stateNode,
  tag, // fiber的类型,函数组件对应的tag为2
  type: () => {}
  updateQueue: null,
}

在函数组件的 fiber 中,有两个属性和 hook 有关:memoizedStateupdateQueue 属性。

  • memoizedState 属性用于保存 hook 链表,hook 链表是单向链表。
  • updateQueue 属性用于保存useEffectuseLayoutEffectuseImperativeHandle这三个 hook 的 effect 信息,是一个环状链表,其中 updateQueue.lastEffect 指向最后一个 effect 对象。effect 描述了 hook 的信息,比如useLayoutEffect 的 effect 对象保存了监听函数,清除函数,依赖等。

hook 链表

React 为我们提供的以use开头的函数就是 hook,本质上函数在执行完成后,就会被销毁,然后状态丢失。React 能记住这些函数的状态信息的根本原因是,在函数组件执行过程中,React 会为每个 hook 函数创建对应的 hook 对象,然后将状态信息保存在 hook 对象中,在下一次更新渲染时,会从这些 hook 对象中获取上一次的状态信息。

在函数组件执行的过程中,比如上例中,当执行 Home() 函数组件时,React 会为组件内每个 hook 函数创建对应的 hook 对象,这些 hook 对象保存 hook 函数的信息以及状态,然后将这些 hook 对象连成一个链表。上例中,第一个执行的是useState hook,React 为其创建一个 hook:stateHook。第二个执行的是useRef hook,同样为其创建一个 hook:refHook,然后将 stateHook.next 指向 refHook:stateHook.next = refHook。同理,refHook.next = effectHook,...

需要注意:

  • useContext是唯一一个不会出现在 hook 链表中的 hook。
  • useState 是 useReducer 的语法糖,因此这里只需要用 useState 举例就好。
  • useEffectuseLayoutEffectuseImperativeHandle这三个 hook 都是属于 effect 类型的 hook,他们的 effect 对象都需要被添加到函数组件 fiber 的 updateQueue 中,以便在 commit 阶段执行。

上例中,hook 链表如下红色虚线中所示:

image

hook 对象及其属性介绍

函数组件内部的每一个 hook 函数,都有对应的 hook 对象用来保存 hook 函数的状态信息,hook 对象的属性如下:

var hook = {
  memoizedState,,
  baseState,
  baseQueue,
  queue,
  next,
};

注意,hook 对象中的memoizedState属性和 fiber 的memoizedState属性含义不同。next 指向下一个 hook 对象,函数组件中的 hook 就是通过 next 指针连成链表

同时,不同的 hook 中,memoizedState 的含义不同,下面详细介绍各类型 hook 对象的属性含义

useState Hook 对象

  • hook.memoizedState 保存的是 useState 的 state 值。比如 const [count, setCount] = useState(0)中,memoizedState 保存的就是 state 的值。
  • hook.queue 保存的是更新队列,是个环状链表。queue 的属性如下:
hook.queue = {
  pending: null,
  dispatch: null,
  lastRenderedReducer: basicStateReducer,
  lastRenderedState: initialState,
};

比如我们在 onClick 中多次调用setCount

const onClick = useCallback(() => {
  debugger;
  setCount(count + 1);
  setCount(2);
  setCount(3);
}, [count]);

每次调用setCount,都会创建一个新的 update 对象,并添加进 hook.queue 中,update 对象属性如下:

var update = {
  lane: lane,
  action: action, // setCount的参数
  eagerReducer: null,
  eagerState: null,
  next: null,
};

queue.pending 指向最后一个更新对象。queue 队列如下红色实线所示

image

在 render 阶段,会遍历 hook.queue,计算最终的 state 值,并存入 hook.memoizedState 中

useRef Hook

  • hook.memoizedState 保存的是 ref 的值。比如
const myRef = useRef(null);

那么 memoizedState 保存的是 myRef 的值,即:

hook.memoizedState = {
  current,
};

useEffect、useLayoutEffect 以及 useImperativeHandle

  • memoizedState 保存的是一个 effect 对象,effect 对象保存的是 hook 的状态信息,比如监听函数,依赖,清除函数等,属性如下:
var effect = {
  tag: tag, // effect的类型,useEffect对应的tag为5,useLayoutEffect对应的tag为3
  create: create, // useEffect或者useLayoutEffect的监听函数,即第一个参数
  destroy: destroy, // useEffect或者useLayoutEffect的清除函数,即监听函数的返回值
  deps: deps, // useEffect或者useLayoutEffect的依赖,第二个参数
  // Circular
  next: null, // 在updateQueue中使用,将所有的effect连成一个链表
};

这三个 hook 都属于 effect 类型的 hook,即具有副作用的 hook

  • useEffect 的副作用为:Update | Passive,即 516
  • useLayoutEffect 和 useImperativeHandle 的副作用都是:Update,即 4

在函数组件中,也就只有这三个 hook 才具有副作用,在 hook 执行的过程中需要给 fiber 添加对应的副作用标记。然后在 commit 阶段执行对应的操作,比如调用useEffect的监听函数,清除函数等等。

因此,React 需要将这三个 hook 函数的 effect 对象存到 fiber.updateQueue 中,以便在 commit 阶段遍历 updateQueue,执行对应的操作。updateQueue 也是一个环状链表,lastEffect 指向最后一个 effect 对象。effect 和 effect 之间通过 next 相连。

const effect = {
    create: () => { console.log("useEffect", count); },
    deps: [0]
    destroy: undefined,
    tag: 5,
}
effect.next = effect
fiber.updateQueue = {
  lastEffect: effect,
};

fiber.updateQueue 如下图红色实线所示:

image

hook 对应的 effect 对象如下图红色实线所示:
image

useMemo

  • hook.memoizedState 保存的是 useMemo 的值和依赖。比如
const res = useMemo(() => {
  return count * count;
}, [count]);

那么 memoizedState 保存的是返回值以及依赖,即

hook.memoizedState = [count * count, [count]];

useCallback

hook.memoizedState 保存的是回调函数和依赖,比如

const onClick = useCallback(callback dep);

那么 memoizedState=[callback, dep]

构建 Hook 链表的源码

React 在初次渲染更新这两个过程,构建 hook 链表的算法不一样,因此 React 对这两个过程是分开处理的:

var HooksDispatcherOnMount = {
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useMemo: mountMemo,
  useRef: mountRef,
  useState: mountState,
};
var HooksDispatcherOnUpdate = {
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useRef: updateRef,
  useState: updateState,
};

如果是初次渲染,则使用HooksDispatcherOnMount,此时如果我们调用 useState,实际上调用的是HooksDispatcherOnMount.useState,执行的是mountState方法。

如果是更新阶段,则使用HooksDispatcherOnUpdate,此时如果我们调用 useState,实际上调用的是HooksDispatcherOnUpdate.useState,执行的是updateState

初次渲染和更新渲染执行 hook 函数的区别在于:

  • 构建 hook 链表的算法不同。初次渲染只是简单的构建 hook 链表。而更新渲染会遍历上一次的 hook 链表,构建新的 hook 链表,并复用上一次的 hook 状态
  • 依赖的判断。初次渲染不需要判断依赖。更新渲染需要判断依赖是否变化。
  • 对于 useState 来说,更新阶段还需要遍历 queue 链表,计算最新的状态。

renderWithHooks 函数组件执行

不管是初次渲染还是更新渲染,函数组件的执行都是从renderWithHooks函数开始执行。

function renderWithHooks(current, workInProgress, Component, props) {
  currentlyRenderingFiber = workInProgress;
  workInProgress.memoizedState = null;
  workInProgress.updateQueue = null;

  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  var children = Component(props, secondArg);

  currentlyRenderingFiber = null;
  currentHook = null;
  workInProgressHook = null;

  return children;
}

renderWithHooks 的Component参数就是我们的函数组件,在本例中,就是Home函数。

Component 开始执行前,会重置 memoizedState 和 updateQueue 属性,因此每次渲染都是重新构建 hook 链表以及收集 effect list

renderWithHooks 方法初始化以下全局变量

  • currentlyRenderingFiber。fiber 节点。当前正在执行的函数组件对应的 fiber 节点,这里是 Home 组件的 fiber 节点
  • ReactCurrentDispatcher.current。负责派发 hook 函数,初次渲染时,指向 HooksDispatcherOnMount,更新渲染时指向 HooksDispatcherOnUpdate。比如我们在函数组件内部调用 useState,实际上调用的是:
function useState(initialState) {
  var dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}
function resolveDispatcher() {
  var dispatcher = ReactCurrentDispatcher.current;
  return dispatcher;
}

每一个 hook 函数在执行时,都会调用resolveDispatcher方法获取当前的dispatcher,然后调用dispatcher中对应的方法处理 mount 或者 update 逻辑。

以 useEffect 为例,在初次渲染时调用的是:

function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
  var hook = mountWorkInProgressHook();
  var nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HasEffect | hookFlags,
    create,
    undefined,
    nextDeps
  );
}

在更新渲染时,调用的是

function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
  var hook = updateWorkInProgressHook();
  var nextDeps = deps === undefined ? null : deps;
  var destroy = undefined;

  if (currentHook !== null) {
    var prevEffect = currentHook.memoizedState;
    destroy = prevEffect.destroy;

    if (nextDeps !== null) {
      var prevDeps = prevEffect.deps;

      if (areHookInputsEqual(nextDeps, prevDeps)) {
        pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }

  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HasEffect | hookFlags,
    create,
    destroy,
    nextDeps
  );
}

pushEffect 方法构建一个 effect 对象并添加到 fiber.updateQueue 中,同时返回 effect 对象。

mountEffectImpl 方法逻辑比较简单,而 updateEffectImpl 方法还多了一个判断依赖是否变化的逻辑。

mountWorkInProgressHook以及updateWorkInProgressHook方法用来在函数组件执行过程中构建 hook 链表,这也是构建 hook 链表的算法。每一个 hook 函数在执行的过程中都会调用这两个方法

构建 hook 链表的算法

初次渲染和更新渲染,构建 hook 链表的算法不同。初次渲染使用mountWorkInProgressHook,而更新渲染使用updateWorkInProgressHook

  • mountWorkInProgressHook 直接为每个 hook 函数创建对应的 hook 对象
  • updateWorkInProgressHook 在执行每个 hook 函数时,同时遍历上一次的 hook 链表,以复用上一次 hook 的状态信息。这个算法稍稍复杂

React 使用全局变量workInProgressHook保存当前正在执行的 hook 对象。比如,本例中,第一个执行的是useState,则此时workInProgressHook=stateHook。第二个执行的是useRef,则此时workInProgressHook=refHook,...。

可以将 workInProgressHook 看作链表的指针

mountWorkInProgressHook 构建 hook 链表算法

代码如下

function mountWorkInProgressHook() {
  var hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };

  if (workInProgressHook === null) {
    // hook链表中的第一个hook
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 添加到hook链表末尾
    workInProgressHook = workInProgressHook.next = hook;
  }

  return workInProgressHook;
}

可以看出,初次渲染构建 hook 链表的算法逻辑非常简单,为每一个 hook 函数创建对应的 hook 对象,然后添加到 hook 链表末尾就行

updateWorkInProgressHook 构建 hook 链表算法

更新渲染阶段构建 hook 链表的算法就比较麻烦。我们从 fiber 开始

我们知道 React 在 render 阶段会复用 fiber 节点,假设我们第一次渲染完成的 fiber 节点如下:

var firstFiber = {
  ..., // 省略其他属性
  alternate: null, // 由于是第一次渲染,alternate为null
  memoizedState, // 第一次渲染构建的hook链表
  updateQueue, // 第一次渲染收集的effect list
};

经过第一次渲染以后,我们将得到下面的 hook 链表:
image

当我们点击按钮触发更新,renderWithHooks 函数开始调用,但 Home 函数执行前,此时workInProgressHookcurrentHook都为 null。同时新的 fiber 的memoizedStateupdateQueue都被重置为 null
image

workInProgressHook用于构建新的 hook 链表

currentHook用于遍历上一次渲染构建的 hook 链表,即旧的链表,或者当前的链表(即和当前显示的页面对应的 hook 链表)

按照本例中调用 hook 函数的顺序,一步步拆解updateWorkInProgressHook算法的过程

  • 第一步 调用 useState

由于此时 currentHook 为 null,因此我们需要初始化它指向旧的 hook 链表的第一个 hook 对象。

if (currentHook === null) {
  var current = currentlyRenderingFiber.alternate;

  if (current !== null) {
    nextCurrentHook = current.memoizedState;
  } else {
    nextCurrentHook = null;
  }
}

currentHook = nextCurrentHook;

创建一个新的 hook 对象,复用上一次的 hook 对象的状态信息,并初始化 hook 链表

var newHook = {
  memoizedState: currentHook.memoizedState,
  baseState: currentHook.baseState,
  baseQueue: currentHook.baseQueue,
  queue: currentHook.queue,
  next: null, // 注意,next被重置了!!!!!
};

if (workInProgressHook === null) {
  currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
}

image

  • 第二步 调用 useRef

此时 currentHook 已经有值,指向第一个 hook 对象。因此将 currentHook 指向它的下一个 hook 对象,即第二个

if (currentHook === null) {
} else {
  nextCurrentHook = currentHook.next;
}
currentHook = nextCurrentHook;

同样的,也需要为 useRef 创建一个新的 hook 对象,并复用上一次的 hook 状态
image

后面的 hook 的执行过程和 useRef 一样,都是一边遍历旧的 hook 链表,为当前 hook 函数创建新的 hook 对象,然后复用旧的 hook 对象的状态信息,然后添加到 hook 链表中

从更新渲染的过程也可以看出,hook 函数的执行是会遍历旧的 hook 链表并复用旧的 hook 对象的状态信息。这也是为什么我们不能将 hook 函数写在条件语句或者循环中的根本原因,我们必须保证 hook 函数的顺序在任何时候都要一致

完整源码

最终完整的算法如下:

function updateWorkInProgressHook() {
  var nextCurrentHook;

  if (currentHook === null) {
    var current = currentlyRenderingFiber$1.alternate;

    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    nextCurrentHook = currentHook.next;
  }

  var nextWorkInProgressHook;

  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber$1.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // There's already a work-in-progress. Reuse it.
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;
    currentHook = nextCurrentHook;
  } else {
    // Clone from the current hook.
    if (!(nextCurrentHook !== null)) {
      {
        throw Error(formatProdErrorMessage(310));
      }
    }

    currentHook = nextCurrentHook;
    var newHook = {
      memoizedState: currentHook.memoizedState,
      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,
      next: null,
    };

    if (workInProgressHook === null) {
      // This is the first hook in the list.
      currentlyRenderingFiber$1.memoizedState = workInProgressHook = newHook;
    } else {
      // Append to the end of the list.
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }

  return workInProgressHook;
}

React useReducer 原理: How react useReducer work

前言

阅读完本章,可以收获下面几点知识

  • 认识什么是更新队列,什么是 hook 链表
  • 如何查看 fiber 节点中真实的 hook 链表
  • hook 的主流程以及源码剖析
  • 同步更新以及异步更新

建议在阅读主流程源码时,在主流程函数各个入口打个断点,走一遍主流程的源码会更有感觉

本章节所有案例都基于以下示例代码:

import React, { useReducer, useEffect, useState } from "react";
import { render } from "react-dom";

function reducer(state, action) {
  return state + 1;
}

const Counter = () => {
  const [count, setCount] = useReducer(reducer, 0);
  return (
    <div
      onClick={() => {
        debugger;
        setCount(1);
        setCount(2);
      }}
    >
      {count}
    </div>
  );
};

render(<Counter />, document.getElementById("root"));

第一节 环状链表

React 使用环状链表保存更新队列 queue={ pending: null },其中 pending 永远指向最后一个更新。比如多次调用 setState 时:

const [count, setCount] = useReducer(reducer, 0);
setCount(1); // 生成一个更新对象:update1 = { action: 1, next: update1 }
setCount(2); // 生成一个更新对象:update2 = { action: 2, next: update1 }

image

fiber 中存储的 queue 队列如下:

image

环状链表简单实现如下,这个可以动手写一下,找找感觉

const queue = { pending: null }; // queue.pending永远指向最后一个更新

function dispatchAction(action) {
  const update = { action, next: null };
  const pending = queue.pending;
  if (pending === null) {
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }
  queue.pending = update;
}

// 队列
dispatchAction(1);
dispatchAction(2);

第二节 什么是 hook 链表

假设我们有下面这段代码,React 每次执行到 hook 函数时,都会构造一个 hook 对象,并连接成一个链表

const [count, setCount] = useReducer(reducer, 0); // 构造一个hook对象 hook1 = { memoizedState: 0, queue: { pending: null }, next: hook2 }
const [count2, setCount2] = useReducer(reducer, 1000); // 构造一个hook对象 hook2 = { memoizedState: 1000, queue: { pending: null }, next: hook3 }
useEffect(() => {
  // 构造一个hook对象,hook3 = { memoizedState: { create: callback }, next: null}
  console.log("useEffect");
}, []);

hook 对象中,hook.memoizedState 属性用于保存当前状态,比如 hook1.memoizedState 对应的就是 counthook1.next 指向 hook2hook1.queue保存的是调用 setCount 后的更新队列。

每个 hook 都会维护自己的更新队列 queue

注意!!!函数组件中,组件对应的 fiber 节点也有一个 memoizedState 属性,fiber.memoizedState 用于保存组件的 hook 链表

image

image

如何查看真实的 hook 链表?

这里有两种方法,一种是通过容器节点root,一种是在源码中打断点

通过容器节点 root 查找对应的 fiber 节点
image

另一种方法是在源码中打断点,这个需要了解源码。在react-dom.development.js中搜索renderWithHooks方法,在 var children = Component(props, secondArg) 处打一个断点,然后在它下面一行再打一个断点,等 Component(props, secondArg) 函数执行完成,则 hook 链表构造完成,此时可以在控制台打印console.log(workInProgress)即可看到当前 fiber 节点的信息

image

第三节 hook 源码流程

经过前面两小节的铺垫,我们对 hook.queue 以及 hook 有了初步印象。本节开始介绍 hook 源码主流程。

React 对于初次挂载阶段和更新阶段,hook 的流程处理不同。因此这里我分为三个阶段来介绍:

  • 初次挂载阶段。即函数组件第一次执行。
  • 触发更新阶段。比如点击按钮触发 setState 执行,这个阶段就是构造 hook 更新队列 queue 的阶段
  • 更新阶段。即函数组件第二次或者第 n 次执行。

React 内部通过提供各个阶段的 HooksDispatcher 对象,抹平了 API 差异。比如 当我们调用 useReducer(reducer, 0) 时,我们不需要关心函数组件是第一次执行还是第 n 次执行。

React 源码内部维护一个全局变量 ReactCurrentDispatcher。在调用函数组件前,React会判断如果是第一次执行组件,即挂载阶段,则将
ReactCurrentDispatcher 变量设置为 HooksDispatcherOnMount,如果是更新阶段,则设置为 HooksDispatcherOnUpdate。这样当我们调用 useReducer(reducer, 0)时,实际上调用的是 HooksDispatcherOnMount.useReducer 或者 HooksDispatcherOnUpdate.useReducer

image

image

初次挂载阶段

这个阶段,函数组件第一次执行。这个阶段源码主流程图如下,建议在流程图中每个函数的入口处各打一个断点,并根据流程图走一遍 React 源码流程。

image

在整个流程中,最关键的是 renderWithHooks 方法,不管是初次挂载阶段还是更新阶段,都会走这个方法!!!。该方法最最最主要做了以下几件事情:

  • 将全局的 currentlyRenderingFiber 变量指向当前工作的 fiber 节点。
  • 重置 fiberhook 链表为 nullworkInProgress.memoizedState = null。更新阶段一样会重置 hook 链表并重新生成
  • 设置 ReactCurrentDispatcher。如果是初次挂载阶段,则设置为 HooksDispatcherOnMount,更新阶段则设置为 HooksDispatcherOnUpdate。以此决定是调用 mountReducer 还是 updateReducer
  • 调用我们的函数组件 Counter,并将结果 children 返回。并重置 currentlyRenderingFibercurrentHookworkInProgressHooknull

mountWorkInProgressHook 方法主要就是构造 hook 链表

触发更新阶段

const Counter = () => {
  const [count, setCount] = useReducer(reducer, 0);
  return (
    <div
      onClick={() => {
        debugger;
        setCount(1);
        setCount(2);
      }}
    >
      {count}
    </div>
  );
};

当我们点击按钮时,调用 setCount 方法,实际上调用的是 dispatchAction 方法,主要逻辑如下:

  • 构造更新队列。生成一个更新对象 update,并加入 hook 的更新队列 queue
  • 计算新的状态值并缓存起来。通过 update.eagerState 缓存,这是 React 的一种优化手段,当我们多次调用 setCount(2),传的是相同的值时,React 不会再触发更新。
  • 如果判断 update.eagerState 和上一次的 currentState 相同,则不触发更新。否则调用 scheduleUpdateOnFiber 触发更新

更新阶段

这个阶段,函数组件第 2 次执行或者第 n(n > 2)次执行,这个阶段也是从 performUnitOfWork 开始。主流程如下:

image

第四节 hook 主流程源码实现

ReactFiberBeginWork.js

import {
  IndeterminateComponent,
  FunctionComponent,
  HostComponent,
} from "./ReactWorkTags";
import { renderWithHooks } from "./ReactFiberHooks";
export function beginWork(current, workInProgress) {
  if (current) {
    switch (workInProgress.tag) {
      case FunctionComponent:
        return updateFunctionComponent(
          current,
          workInProgress,
          workInProgress.type
        );
      default:
        break;
    }
  } else {
    switch (workInProgress.tag) {
      case IndeterminateComponent:
        return mountIndeterminateComponent(
          current,
          workInProgress,
          workInProgress.type
        );
      default:
        break;
    }
  }
}

function updateFunctionComponent(current, workInProgress, Component) {
  const newChildren = renderWithHooks(current, workInProgress, Component);
  reconcileChildren(null, workInProgress, newChildren);
  return workInProgress.child;
}

function mountIndeterminateComponent(current, workInProgress, Component) {
  const children = renderWithHooks(current, workInProgress, Component);
  workInProgress.tag = FunctionComponent; // 初次渲染后,此时组件类型已经明确,因此需要修改tag
  reconcileChildren(null, workInProgress, children);
  return workInProgress.child; // null
}

ReactFiberHooks.js,最主要的逻辑都在这个文件里面

import { scheduleUpdateOnFiber } from "./ReactFiberWorkLoop";
const ReactCurrentDispatcher = {
  current: null,
};
let workInProgressHook = null; // 当前工作中的新的hook指针
let currentHook = null; // 当前的旧的hook指针
let currentlyRenderingFiber; // 当前正在工作的fiber
const HooksDispatcherOnMount = {
  useReducer: mountReducer,
};
const HooksDispatcherOnUpdate = {
  useReducer: updateReducer,
};

function updateReducer(reducer, initialState) {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue; // 更新队列
  const lastRenderedReducer = queue.lastRenderedReducer; // 上一次reducer方法

  const current = currentHook;
  const pendingQueue = queue.pending;
  if (pendingQueue !== null) {
    // 根据旧的状态和更新队列里的更新对象计算新的状态
    const first = pendingQueue.next; // 第一个更新对象
    let newState = current.memoizedState; // 旧的状态
    let update = first;
    do {
      const action = update.action;
      newState = reducer(newState, action);
      update = update.next;
    } while (update !== null && update !== first);
    queue.pending = null; // 更新完成,清空链表
    hook.memoizedState = newState; // 让新的hook对象的memoizedState等于计算的新状态
    queue.lastRenderState = newState;
  }
  const dispatch = dispatchAction.bind(null, currentlyRenderingFiber, queue);
  return [hook.memoizedState, dispatch];
}
function updateWorkInProgressHook() {
  let nextCurrentHook;
  if (currentHook === null) {
    // 如果currentHook为null,说明这是第一个hook
    const current = currentlyRenderingFiber.alternate; // 旧的fiber节点
    nextCurrentHook = current.memoizedState; // 旧的fiber的memoizedState指向旧的hook链表的第一个节点
  } else {
    nextCurrentHook = currentHook.next;
  }

  currentHook = nextCurrentHook;

  const newHook = {
    memoizedState: currentHook.memoizedState,
    queue: currentHook.queue,
    next: null,
  };

  if (workInProgressHook === null) {
    // 说明这是第一个hook
    currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
  } else {
    workInProgressHook.next = newHook;
    workInProgressHook = workInProgressHook.next = newHook;
  }
  return workInProgressHook;
}
function mountReducer(reducer, initialState) {
  // 构建hooks单向链表
  const hook = mountWorkInProgressHook();
  hook.memoizedState = initialState;
  const queue = (hook.queue = { pending: null }); // 更新队列
  const dispatch = dispatchAction.bind(null, currentlyRenderingFiber, queue);
  return [hook.memoizedState, dispatch];
}
function mountWorkInProgressHook() {
  const hook = {
    // 创建一个hook对象
    memoizedState: null, // 自己的状态
    queue: null, // 自己的更新队列,环形列表
    next: null, // 下一个更新
  };
  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    workInProgressHook = workInProgressHook.next = hook;
  }

  return workInProgressHook;
}

// 不同的阶段useReducer有不同的实现
export function renderWithHooks(current, workInProgress, Component) {
  currentlyRenderingFiber = workInProgress;
  currentlyRenderingFiber.memoizedState = null;
  ReactCurrentDispatcher.current =
    current !== null ? HooksDispatcherOnUpdate : HooksDispatcherOnMount;
  const children = Component();
  currentlyRenderingFiber = null;
  workInProgressHook = null;
  currentHook = null;
  return children;
}

function dispatchAction(currentlyRenderingFiber, queue, action) {
  const update = { action, next: null };
  const pending = queue.pending;
  if (pending === null) {
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }
  queue.pending = update;
  const lastRenderedReducer = queue.lastRenderedReducer; // 上一次的reducer
  const lastRenderState = queue.lastRenderState; // 上一次的state
  const eagerState = lastRenderedReducer(lastRenderState, action); // 计算新的state
  // 如果新的state和旧的state相同,则跳过更新
  if (Object.is(eagerState, lastRenderState)) {
    return;
  }
  scheduleUpdateOnFiber(currentlyRenderingFiber);
}

export function useReducer(reducer, initialState) {
  return ReactCurrentDispatcher.current.useReducer(reducer, initialState);
}

同步更新以及异步更新

超详细React Fiber双缓冲树机制介绍以及React内存泄漏风险分析

大纲

  • 双缓冲树机制
  • 删除节点时如何释放内存,即如何删除旧的 fiber 节点
  • 为什么需要重用 alternate 节点,重新创建不行吗?

背景

React 初次渲染及更新流程一文介绍过 React 渲染更新主要分为两个阶段:render 阶段和 commit 阶段。render 阶段主要是将新的 element tree 和 当前页面对应的 fiber 树(即 curent tree)比较,并构建一棵 workInProgress 树以及收集有副作用的 fiber 节点。render 阶段完成后,我们将得到一棵 finishedWork 树以及一个副作用链表。render 阶段是异步可以中断的

在 commit 阶段主要就是遍历副作用链表,并执行相应的 dom 操作等。commit 阶段是同步且不可中断的

Fiber 双缓冲树

由于 render 阶段构建 workInProgress 树的过程是可以中断的,同时,workInProgress 树最终又会在 commit 阶段渲染到浏览器页面上,这就决定了在 render 阶段,必须要保持浏览器页面不变直到 render 阶段完成。也就是说我们在 render 阶段需要保持 current tree 不变,然后用另一棵树来承载 workInProgress 树。为了实现这个目标,React 借鉴了双缓冲技术。

Fiber 双缓冲树包括一棵 current tree 和一棵 workInProgress tree(render 阶段完成后的 workInProgress 树也叫 finishedWork 树)。current tree 保存的是当前浏览器页面对应的 fiber 节点。workInProgress tree 是在 render 阶段,react 基于 current tree 和新的 element tree 进行比较而构建的一棵树,这棵树是在内存中构建,在 commit 阶段将被绘制到浏览器页面上。

current 树保存在容器节点的 root._reactRootContainer._internalRoot.current 属性上。在 render 阶段构建 workInProgress 树的过程中,我们可以通过root._reactRootContainer._internalRoot.current.alternate 访问到 workInProgress 树。

下面是各个阶段的 current tree 和 workInProgress tree 的状态

render 阶段完成,commit 阶段开始前,我们会得到一棵 finishedWork 树,实际上这就是 render 过程结束后得到的 workInProgress 树,finishedWork 树可以通过root._reactRootContainer._internalRoot.finishedWork属性获取。

render 阶段

在这个阶段,浏览器页面对应的 fiber 树仍然是 current 树,workInProgress 树正在构建

在 render 阶段构建 workInProgress 树的过程主要逻辑在 performUnitOfWork,因此我们可以在这个函数处打个断点查看 render 阶段的 workInProgress 树。

workInProgress 表示当前正在工作的 fiber 节点,这些 workInProgress 节点构成了一棵 workInProgress 树。我们可以通过root._reactRootContainer._internalRoot.current.alternate属性访问当前工作中的 workInProgress 树

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

image

render 阶段完成,commit 阶段开始前

在这个阶段,浏览器页面对应的 fiber 树仍然是 current 树,workInProgress 树已经构建完毕,得到 finishedWork 树

render 阶段完成,commit 阶段开始前,workInProgress 树构建完成,我们得到一棵 finishedWork 树,此时将 workInProgress 树复制给容器的 finishedWork 属性,这段逻辑在 performSyncWorkOnRoot 函数中

function performSyncWorkOnRoot(root) {
  //...
  renderRootSync(root, lanes); // render阶段,构建workInProgress树
  // ...render阶段结束
  var finishedWork = root.current.alternate;
  root.finishedWork = finishedWork; // 将workInProgress树赋值给finishedWork属性
  commitRoot(root); // commit阶段,将finishedWork树更新到浏览器页面
  // ...
}

可以在 performSyncWorkOnRoot 处打断点查看这个过程
image

image

commit 阶段

这个阶段完成后,finishedWork 树就变成了 current 树

可以看出commitRoot函数调用的是commitRootImpl函数,在 commitRootImpl 函数执行的一开始,root.finishedWork就已经被置空,所以finishedWork属性存在的时间是非常短的。

  • commitBeforeMutationEffects。DOM 变更前,主要是调用类组件的getSnapshotBeforeUpdate、函数组件的useEffect的清除函数等
  • commitMutationEffects。DOM 变更,这个函数主要是将 finishedWork 树绘制到浏览器页面!!!
  • commitLayoutEffects。DOM 变更后。

关于 commitBeforeMutationEffectscommitMutationEffects以及commitLayoutEffects这三个函数的主要作用,在深入概述 React 初次渲染以及 setState 状态更新主流程一文中已经有详细介绍,有兴趣的可以看看。

从下面的函数执行可以看出,在commitMutationEffects函数执行之前,浏览器页面对应的依旧是 current 树,在commitMutationEffects执行完成后,React 已经将 finishedWork 树渲染到浏览器页面上,此时 finishedWork 树就变成了 current 树!!

function commitRoot(root) {
  var renderPriorityLevel = getCurrentPriorityLevel();
  runWithPriority$1(
    ImmediatePriority$1,
    commitRootImpl.bind(null, root, renderPriorityLevel)
  );
  return null;
}
function commitRootImpl(root, renderPriorityLevel) {
  // 暂存finishedWork树
  var finishedWork = root.finishedWork;
  // 注意,在commitRootImpl函数执行的开始,finishedWork属性已经被置空
  root.finishedWork = null;

  root.callbackNode = null;

  if (firstEffect !== null) {
    nextEffect = firstEffect;

    commitBeforeMutationEffects();

    nextEffect = firstEffect;
    commitMutationEffects(root, renderPriorityLevel);
    // commitMutationEffects执行完成后,将finishedWork树赋值给current tree。
    root.current = finishedWork;
    commitLayoutEffects(root, lanes);
  }

  return null;
}

image

如果你看完上面介绍的几个阶段中 Fiber 双缓冲树的状态,还是很蒙的话,那一定是我写的太烂了。下面我会用几个 demo 详细介绍双缓冲树的创建过程。在此之前,你只需要记住 render 阶段和 commit 阶段双缓冲树的状态就行了

构建 workInProgress 树主要的源码

本节介绍 render 阶段构建 workInProgress 树的主要源码,在阅读本文时,可以在下面介绍的各个函数入口处打断点调试。

render 阶段主要涉及的入口函数

// render阶段
var __DEBUG_RENDER_COUNT__ = 0;

function renderRootSync(root, lanes) {
  __DEBUG_RENDER_COUNT__++;

  prepareFreshStack(root, lanes);

  workLoopSync();

  return workInProgressRootExitStatus;
}
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork) {
  var current = unitOfWork.alternate;
  next = beginWork$1(current, unitOfWork, subtreeRenderLanes);
  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }
}

function reconcileChildren(current, workInProgress, nextChildren, renderLanes) {
  if (current === null) {
    workInProgress.child = mountChildFibers(workInProgress, null, nextChildren);
  } else {
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren
    );
  }
}

beginWork主要是负责处理各类型 fiber 节点,并调用 reconcileChildren 协调子元素。在 reconcileChildren的过程中,调用 useFiber复用旧的节点或者 createFiberFromElement 创建新的节点。fiber 根节点,即 rootFiber 的创建或者复用在prepareFreshStack函数中完成。

注意,我在 renderRootSync 函数前加了一个__DEBUG_RENDER_COUNT__变量,这个变量在 createWorkInProgress 使用,方便区分当前的 fiber 以及 workInProgress
image

function useFiber(fiber, pendingProps) {
  // We currently set sibling to null and index to 0 here because it is easy
  // to forget to do before returning it. E.g. for the single child case.
  var clone = createWorkInProgress(fiber, pendingProps);
  clone.index = 0;
  clone.sibling = null;
  return clone;
}
// This is used to create an alternate fiber to do work on.
function createWorkInProgress(current, pendingProps) {
  var workInProgress = current.alternate;

  if (workInProgress === null) {
    // We use a double buffering pooling technique because we know that we'll
    // only ever need at most two versions of a tree. We pool the "other" unused
    // node that we're free to reuse. This is lazily created to avoid allocating
    // extra objects for things that are never updated. It also allow us to
    // reclaim the extra memory if needed.
    workInProgress = createFiber(
      current.tag,
      pendingProps,
      current.key,
      current.mode
    );
    workInProgress.alternate = current;
    current.alternate = workInProgress;
  } else {
    workInProgress.pendingProps = pendingProps;
    workInProgress.type = current.type;
    workInProgress.flags = NoFlags;
    workInProgress.nextEffect = null;
    workInProgress.firstEffect = null;
    workInProgress.lastEffect = null;
  }
  workInProgress.child = current.child;
  workInProgress.memoizedProps = current.memoizedProps;
  workInProgress.memoizedState = current.memoizedState;
  workInProgress.updateQueue = current.updateQueue;
  workInProgress.sibling = current.sibling;
  workInProgress.index = current.index;
  workInProgress.AAA__DEBUG_RENDER_COUNT__ = __DEBUG_RENDER_COUNT__;
  return workInProgress;
}

createWorkInProgress用于复用旧的 fiber 节点,并使用 current 的属性覆盖旧的属性。注意在创建新的 fiber 节点时,alternate相互指向。

workInProgress.alternate = current;
current.alternate = workInProgress;

第一次渲染

下面的 Demo 用来演示在 render 阶段如何基于当前的 current 树创建新的 fiber 节点或者复用旧的 fiber 节点,从而构建一棵 workInProgress 树。

import React from "react";
import ReactDOM from "react-dom";

class Home extends React.Component {
  constructor(props) {
    super(props);
    this.state = { step: 0 };
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    this.setState({
      step: this.state.step + 1,
    });
  }
  render() {
    const { step } = this.state;
    return step < 3 ? (
      <div id={step} onClick={this.handleClick}>
        {step}
      </div>
    ) : (
      <p id={step} onClick={this.handleClick}>
        {step}
      </p>
    );
  }
}

ReactDOM.render(<Home />, document.getElementById("root"));

创建 Fiber 树的容器以及 HostRootFiber

第一次渲染时,current 树为空,React 需要构造一棵全新的树。React 在第一次渲染时,首先给 root 容器创建一个FiberRootNode节点,该节点用于承载current树以及finishedWork树,是整个 fiber 树的容器。在创建FiberRootNode节点时,同时为 root 节点创建HostRootFiber,这也是整个 fiber 树的根节点

function createFiberRoot(containerInfo, tag, hydrate, hydrationCallbacks) {
  var root = new FiberRootNode(containerInfo, tag, hydrate);
  // stateNode is any.
  var uninitializedFiber = createHostRootFiber(tag);
  root.current = uninitializedFiber;
  uninitializedFiber.stateNode = root;
  initializeUpdateQueue(uninitializedFiber);
  return root;
}

image

createFiberRoot执行完成,此时 fiber 树的容器已经创建完毕。进入 renderRootSync 函数,render 阶段开始。

prepareFreshStack:为 HostRootFiber 创建对应的 workInProgress 节点

renderRootSync 中, prepareFreshStack函数调用createWorkInProgress(root.current, null) 开始为 HostRootFiber(即容器 root 的 fiber 节点)创建对应的 workInProgress fiber。由于此时的 HostRootFiber 还没有备用节点,即 root.current.alternate 为空,因此createWorkInProgress会新建一个 fiber 节点,并互相关联 alternate 属性

image

接下来进入 workLoopSync render 工作循环。

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

performUnitOfWork(HostRootFiber):为 Home 节点创建 workInProgress 节点

第一个开始工作的 workInProgress 节点就是新创建的 HostRootFiber 节点。performUnitOfWork 为 HostRootFiber 节点协调子元素。在本例中,HostRootFiber 的子元素就是Home类对应的元素。第一次渲染时,Home 没有备用的 fiber 节点,因此需要调用 createFiberFromElement 为 Home 创建全新的 fiber 节点
image

performUnitOfWork(HomeFiber):为 div 节点创建对应的 workInProgress 节点

HostRootFiber 的performUnitOfWork执行完成,开始为Home执行performUnitOfWorkHome开始工作。调用 new Home() 初始化类组件,并挂载到 Home fiberstateNode属性上。同时为 Home 协调子元素,在本例中,Home 的子元素是 div,为 div 创建 fiber 节点
image

由于 div 没有子节点,因此在为 div 调用performUnitOfWork开始工作时,没有子元素协调,至此,workInProgress 树的构建完毕,render 阶段结束

render 阶段结束,commit 阶段开始前

render 阶段结束,workInProgress 树构建完成,此时我们得到一棵 finishedWork 树,将其保存到容器中

image

主要逻辑在这里:

function performSyncWorkOnRoot(root) {
  //...
  renderRootSync(root, lanes); // render阶段,构建workInProgress树
  // ...render阶段结束
  var finishedWork = root.current.alternate;
  root.finishedWork = finishedWork; // 将workInProgress树赋值给finishedWork属性
  commitRoot(root); // commit阶段,将finishedWork树更新到浏览器页面
  // ...
}

commit 阶段结束

commitMutationEffects函数执行完成后,finisheWork 树已经更新到浏览器屏幕上,finishedWork 树就变成了 current 树,因此将 finishedWork 树赋值给 root.current,同时重置 root.finishedWork 为 null

function commitRootImpl(root, renderPriorityLevel) {
  // 暂存finishedWork树
  var finishedWork = root.finishedWork;
  // 注意,在commitRootImpl函数执行的开始,finishedWork属性已经被置空
  root.finishedWork = null;

  root.callbackNode = null;

  if (firstEffect !== null) {
    nextEffect = firstEffect;

    commitBeforeMutationEffects();

    nextEffect = firstEffect;
    commitMutationEffects(root, renderPriorityLevel);
    // commitMutationEffects执行完成后,将finishedWork树赋值给current tree。
    root.current = finishedWork;
    commitLayoutEffects(root, lanes);
  }

  return null;
}

image

第二次渲染

在第一次渲染完成后,我们已经有一棵 current 树。现在让我们点击按钮,触发页面更新。由于是第二次渲染,不需要在创建 Fiber 树的容器。render 阶段直接从 renderRootSync函数开始

prepareFreshStack:为 current HostRootFiber 创建对应的 workInProgress 节点

prepareFreshStack 调用 createWorkInProgressHostRootFiber 创建 workInProgress 节点。createWorkInProgress中发现当前的 HostRootFiber 存在备用的节点,即current.alternate存在,则直接复用备用节点

var workInProgress = current.alternate;

image

performUnitOfWork(New HostRootFiber):为 current Home 节点创建 workInProgress 节点

首先进入工作循环的是新创建的 workInProgress HostRootFiber。在 performUnitOfWork 执行期间,React 为 HostRootFiber 的子元素 Home 创建对应的 workInProgress 节点,这一步工作在 bailoutOnAlreadyFinishedWork 函数中的 cloneChildFibers 完成。cloneChildFibers 调用 createWorkInProgress
方法为 Home 创建对应的 workInProgress 节点。由于 current Home fiber 没有备用节点,即 current home fiber 的 alternate 不存在,因此 createWorkInProgress为 Home 创建全新的 workInProgress 节点。创建完成后,HostRootFiber 的 child 指针指向新的 Home fiber。

image

performUnitOfWork(New HomeFiber):为 current div 节点创建对应的 workInProgress 节点

下一步就是为新创建的 HomeFiber 执行工作。在为 HomeFiber 协调子元素的过程中,发现 新的 element(即 div)的 tag 及 type 和 current div 节点的相同,因此可以调用useFiber复用当前的 fiber 节点

function useFiber(fiber, pendingProps) {
  // We currently set sibling to null and index to 0 here because it is easy
  // to forget to do before returning it. E.g. for the single child case.
  var clone = createWorkInProgress(fiber, pendingProps);
  clone.index = 0;
  clone.sibling = null;
  return clone;
}

调用 createWorkInProgress 为新的子元素 div 创建新的 workInProgress 节点。由于 current div fiber 的 alternate 属性为 null,没有备用的节点,因此创建一个全新的 fiber 节点,并互相关联 alternate

image

由于新的 div 没有子节点,因此 render 阶段结束

render 阶段结束,commit 阶段开始前

render 阶段结束,workInProgress 树构建完成,此时我们得到一棵 finishedWork 树。在 performSyncWorkOnRoot 函数中,我们将 finishedWork 树保存到容器的 finishedWork 属性上。

image

commit 阶段结束

commitMutationEffects函数执行完成后,finisheWork 树已经更新到浏览器屏幕上,finishedWork 树就变成了 current 树,因此将 finishedWork 树赋值给 root.current,同时重置 root.finishedWork 为 null

image

第二次渲染完成后,第二次渲染 render 阶段构建的 finishedWork 树就变成了 current 树,第一次渲染的树就变成了备用树,因此上图我将第一次渲染的树全部用虚线表示。此时内存中同时存在两棵树,一棵 current 树,一棵旧的备用树

第三次渲染

在第二次渲染完成后,内存中同时存在一棵 current 树和一棵旧的 alternate 备用树。现在让我们点击按钮,触发页面更新,看看第三次渲染,React 是如何复用旧的 alternate 备用树上的节点。同样的,由于是第三次渲染,不需要在创建 Fiber 树的容器。render 阶段直接从 renderRootSync函数开始

注意,右图中,虚线表示还没复用的旧的 fiber 节点。实现表示当前复用的节点

prepareFreshStack:为 current HostRootFiber 创建对应的 workInProgress 节点

prepareFreshStack 调用 createWorkInProgressHostRootFiber 创建 workInProgress 节点。createWorkInProgress中发现当前的 HostRootFiber 存在备用的节点,即current.alternate存在,则直接复用备用节点

var workInProgress = current.alternate;

image

performUnitOfWork(New HostRootFiber):为 current Home 节点创建 workInProgress 节点

和第二次渲染一样,React 也是在 cloneChildFibers 中调用 createWorkInProgress 为当前的 Home fiber 创建新的 workInProgress 节点。
由于 current Home fiber 的 alternate 属性不为空,存在旧的备用节点,因此 createWorkInProgress 直接复用旧的备用节点,并将当前 current home fiber 的属性全部复制到旧的备用节点。

image

performUnitOfWork(New HomeFiber):为 current div 节点创建对应的 workInProgress 节点

和第二次渲染一样,在协调 Home Fiber 子元素时,React 发现可以复用 current div 节点,因此调用 useFiber 复用 current div 节点。
image

render 阶段结束,commit 阶段开始前

render 阶段结束,workInProgress 树构建完成,此时我们得到一棵 finishedWork 树。在 performSyncWorkOnRoot 函数中,我们将 finishedWork 树保存到容器的 finishedWork 属性上。

image

commit 阶段结束

commitMutationEffects函数执行完成后,finisheWork 树已经更新到浏览器屏幕上,finishedWork 树就变成了 current 树,因此将 finishedWork 树赋值给 root.current,同时重置 root.finishedWork 为 null

image

小结

从前面三次渲染更新过程可以看出,内存中最多存在两棵树,一棵 current 树,一棵备用的 alternate 树,备用的树在 render 阶段用于构造 workInProgress 树。一个元素最多存在两个版本的 fiber 节点,一个 current 版本,和当前浏览器页面对应,一个 alternate 版本,alternate 版本是备用节点,用于在 render 阶段复用,以构建 workInProgress 节点。

那为什么 React 要复用备用的节点,而不是新创建一个呢?最大的原因是节省内存开销,通过复用旧的备用节点,React 不需要额外申请内存空间,在复用时可以直接将 current fiber 的属性复制到旧的备用节点

通过上面三次渲染更新过程也可以看出,React 在渲染时,会在 current 树和 alternate 树之间交替进行,倒来倒去。比如第四次渲染时,第二次渲染完成的 alternate 树又变成了 current 树,而第三次渲染完成的树又变成了 alternate 树。

看完了渲染更新流程,下面我们看下删除节点的情况又是怎样的。

第四次渲染:节点删除的场景

继续点击按钮,触发第四次渲染。根据我们的 demo,此时 div 节点将会被删除,新的 p 节点将被插入。我们看下这个过程,React 是如何删除节点、创建新的 p 节点以及复用旧的 home 节点的。

  render() {
    const { step } = this.state;
    return step < 3 ? (
      <div id={step} onClick={this.handleClick}>
        {step}
      </div>
    ) : (
      <p id={step} onClick={this.handleClick}>
        {step}
      </p>
    );
  }

同样的,由于是第四次渲染,不需要再创建 Fiber 树的容器。render 阶段直接从 renderRootSync函数开始

prepareFreshStack:为 current HostRootFiber 创建对应的 workInProgress 节点

prepareFreshStack 调用 createWorkInProgressHostRootFiber 创建 workInProgress 节点。createWorkInProgress中发现当前的 HostRootFiber 存在备用的节点,即current.alternate存在,则直接复用备用节点

image

performUnitOfWork(New HostRootFiber):为 current Home 节点创建 workInProgress 节点

和第三次渲染一样,React 也是在 cloneChildFibers 中调用 createWorkInProgress 为当前的 Home fiber 创建新的 workInProgress 节点。
由于 current Home fiber 的 alternate 属性不为空,存在旧的备用节点,因此 createWorkInProgress 直接复用旧的备用节点,并将当前 current home fiber 的属性全部复制到旧的备用节点。

image

performUnitOfWork(New HomeFiber):删除 div 节点,新建 p 节点

轮到为新的 home fiber 协调子元素。这次,我们需要删除 div fiber 节点,新建一个 p 节点

  • 调用 deleteRemainingChildren 删除当前的 div fiber 节点,将 div 添加到父节点,即 home fiber 的副作用链表中
  • 调用 createFiberFromElement 为 p 元素创建对应的 fiber 节点。
  • 将新的 home fiber 的 child 指针指向 p 节点。

到这里,home fiber 的工作就已经完成,此时 div 处于被即将被删除的状态,这里使用虚线表示
image

render 阶段结束,commit 阶段开始前

render 阶段结束,workInProgress 树构建完成,此时我们得到一棵 finishedWork 树,以及一个副作用链表。在 performSyncWorkOnRoot 函数中,我们将 finishedWork 树保存到容器的 finishedWork 属性上。

实际上,React 在每次 render 阶段都会收集副作用节点,并构建副作用链表,我在前三次渲染中省略了这个步骤。第四次渲染介绍一下副作用链表的构建,因为这涉及到后面 commit 阶段遍历副作用链表,删除节点,插入节点的情况,可以查看React 构建副作用链表算法了解 React 如何构建副作用链表

render 阶段结束后,我们最终得到的 finishedWork 树和辅作用链表(图中红线所示)如下图:

image

commit 阶段

commit 阶段遍历副作用节点,根据对应的副作用标志fiber.flags执行对应的操作。在我们的案例中,相应的副作用就是删除 div 节点,插入 p 节点。这两个过程都发生在commitMutationEffects阶段,这个阶段操作真实的 dom 节点,并释放掉 fiber 的内存。

commitMutationEffects遍历副作用链表,第一个节点是 div 节点,这个节点需要删除,调用 commitDeletion 删除节点

commitDeletion主要工作如下:

  • 调用 unmountHostComponents 删除真实的 dom 节点

  • 其次调用detachFiberMutation重置 div 节点(AAA_DEBUG_RENDER_COUNT 属性为 3)的各种属性,以释放内存。重点关注 div fiber 的 return、child、alternate 指针的重置,同时需要注意,sibling 属性和 stateNode 属性不是在这个时候释放掉的。

  • 然后调用 detachFiberMutation重置 div 节点(AAA_DEBUG_RENDER_COUNT 属性为 3)的备用节点,即 AAA_DEBUG_RENDER_COUNT 属性为 2 的 div 节点的属性,以释放内存。此时内存中已经没有节点引用这个备用节点,但是这个备用节点还是会引用 stateNode,

detachFiberMutation 函数如下:

function detachFiberMutation(fiber) {
  // Cut off the return pointers to disconnect it from the tree. Ideally, we
  // should clear the child pointer of the parent alternate to let this
  // get GC:ed but we don't know which for sure which parent is the current
  // one so we'll settle for GC:ing the subtree of this child. This child
  // itself will be GC:ed when the parent updates the next time.
  // Note: we cannot null out sibling here, otherwise it can cause issues
  // with findDOMNode and how it requires the sibling field to carry out
  // traversal in a later effect. See PR #16820. We now clear the sibling
  // field after effects, see: detachFiberAfterEffects.
  //
  // Don't disconnect stateNode now; it will be detached in detachFiberAfterEffects.
  // It may be required if the current component is an error boundary,
  // and one of its descendants throws while unmounting a passive effect.
  fiber.alternate = null;
  fiber.child = null;
  fiber.dependencies = null;
  fiber.firstEffect = null;
  fiber.lastEffect = null;
  fiber.memoizedProps = null;
  fiber.memoizedState = null;
  fiber.pendingProps = null;
  fiber.return = null;
  fiber.updateQueue = null;
}

至此,对于 div 节点的删除工作已经完成,下一个需要执行的副作用节点是 p 节点,调用commitPlacement插入真实的 p dom 节点。

commitMutationEffects 函数执行完成后,此时的双缓冲树如下:

image

commitMutationEffects 函数执行完成,finishedWork 树已经变成了 current 树

image

commitLayoutEffects 执行完成后,此时副作用链表已经没有用处,需要释放掉副作用链表的内存,这段逻辑在 commitRootImpl 函数中

function commitRootImpl(root, renderPriorityLevel) {
  var finishedWork = root.finishedWork;
  root.finishedWork = null;
  //....
  commitBeforeMutationEffects();
  //....
  commitMutationEffects(root, renderPriorityLevel);
  //....
  commitLayoutEffects(root, lanes);
  //....
  // We are done with the effect chain at this point so let's clear the
  // nextEffect pointers to assist with GC. If we have passive effects, we'll
  // clear this in flushPassiveEffects.
  nextEffect = firstEffect;
  while (nextEffect !== null) {
    var nextNextEffect = nextEffect.nextEffect;
    nextEffect.nextEffect = null;
    if (nextEffect.flags & Deletion) {
      detachFiberAfterEffects(nextEffect);
    }
    nextEffect = nextNextEffect;
  }
  //...
}
function detachFiberAfterEffects(fiber) {
  fiber.sibling = null;
  fiber.stateNode = null;
}
  • 首先重置副作用节点的 nextEffect 为 null
  • 其次判断如果节点是被删除的,则调用 detachFiberAfterEffects 函数重置 sibling 和 stateNode 为 null

整个 commit 阶段已经结束,此时内存中的双缓冲树状态如下:

image

根据图中可以看出,左边的 div fiber 节点(AAA_DEBUG_RENDER_COUNT 属性为 2)已经没有任何节点引用它了,可以被 GC 回收内存。但是我们看右边的 div fiber 节点(AAA_DEBUG_RENDER_COUNT 属性为 3)的节点还有 child 以及 firstEffect 指针引用着,因此这个节点不会在本次 GC 期间被回收,而是等下一次渲染更新完成后才会被 GC 回收

子树删除的场景

这次我们使用下面的 demo,看看删除子树的时候,React 是怎么释放内存的

import React from "react";
import ReactDOM from "react-dom";

class Home extends React.Component {
  constructor(props) {
    super(props);
    this.state = { step: 0 };
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    this.setState({
      step: this.state.step + 1,
    });
  }
  render() {
    const { step } = this.state;
    return step < 3 ? (
      <div id={step} onClick={this.handleClick}>
        <div id="test">{step}</div>
      </div>
    ) : (
      <p id={step} onClick={this.handleClick}>
        {step}
      </p>
    );
  }
}

ReactDOM.render(<Home />, document.getElementById("root"));

这里我们直接从第四次点击按钮出发页面更新开始,当 render 阶段结束,commit 阶段开始前,我们将得到下面一棵 finishedWork 树以及副作用链表。这里我使用蓝色标记需要释放内存的 fiber 节点

image

commitMutationEffects阶段调用 commitDeletetion 方法删除 div fiber 节点,并重置 div fiber 的属性为 null

下面就是整个 commit 阶段完成后,内存中双缓冲树的状态
image
这里我将需要删除的节点标记为蓝色并添加 ABCD,方便后续的描述

内存泄漏风险分析

从图中可以看出,A,B,C,D 都是需要被删除的节点。

先来看 B,B 节点所有的属性已经被重置为 null,但是此时还有 home 的 child 以及 firstEffect 等属性引用着 B 节点。在本次更新完成,可想而知 B 节点的内存不会被释放。等到下一次更新完成时,由于 child 及 firstEffect 不再指向 B 节点,B 节点内存得到释放

再来看 A 节点, A 节点(stateNode 属性)还引用着已经被删除的 div 真实 dom,这个 div 真实 dom 的__reactFiber 属性还引用着 A 节点。因此这里有一对循环引用,即

fiberA.stateNode = div。
div.__reactFiber = fiberA

再来看 C 和 D,C 和 D 的 stateNode 都没有被清空,同时 div#test 这个真实的 dom 节点的__reactFiber属性还引用着 C,C 和 D 通过 alternate 属性相互引用,这里的引用情况如下:

fiberC.stateNode = div#test
fiberC.return = fiberB
div#test.__reactFiber = fiberC

fiberD.stateNode = div#test
fiberD.return = fiberA

fiberD.alternate = fiberC.alternate

综上可以看出,如果在采用引用计数的浏览器中,由于这些节点之间存在循环引用的情况,在垃圾回收期间不会被回收,因此有内存泄漏的风险。而在采用标记清除法的浏览器中,这些节点内存会被回收。这也是为什么在谷歌浏览器中并没有内存泄漏的风险

第四次渲染后内存中的 FiberNode 节点
image

第五次渲染后内存中的 FiberNode 节点
image

第六次渲染后,被删除的节点的内存已经被全部回收,因此从第六次开始,FiberNode 节点的数量都保持在 6 个

image

综上也可以看出,被删除的节点至少要在后续两轮渲染更新完成后才能全部回收完毕

阅读React源码:提高React源码debug体验舒适度的一些奇淫技巧

react源码调试

react 源码使用 rollup 打包,将所有的模块都打包到一个文件中,比如 react.development.js 以及 react-dom.development.js,没有对应
sourcemap,导致阅读源码的过程当中无法得知源码位于哪个文件,如下图中红框内的源码无法映射到原文件,阅读体验不好。

image

同时,react 在打包开发环境的代码时,会引入大量的本地调试代码

image

这段代码对应的源码在于:packages/react/src/ReactDebugCurrentFrame.js文件中:

image

react 源码中大量使用 __DEV__ 环境变量判断(参考:__DEV__说明),如果是开发环境,则括号中的代码会被打包进产物中,如果是生产环境,则不会打包进产物中。这样
可以在开发环境注入一些调试代码,比如检查 props 是否合法、创建 element 的时候是否需要校验参数等情况:

image

实际上,这些开发时的校验代码与react主流程没有什么关系,我们不关心这些开发时的场景,只需要专注于主流程,因此如果打包时能够减少这部分代码,对我们阅读
体验来说还是相当不错的。

实现

修改 react 源码打包时 rollup 配置,然后终端运行:

yarn build react, shared, scheduler, react-reconciler, react-dom --type=NODE

打包完成,复制 build/node_modules/react/cjs/react.development.js 以及
build/node_modules/react-dom/cjs/react-dom.development.js,在本地粘贴,本地调试时可以使用这两份源码

// Remove 'use strict' from individual source files.
{
  transform(source, id) {
    id = id.replace('/Users/lizc/Documents/MYProjects/react/', '')
    let sourceStr = source.replace(/['"]use strict["']/g, '');

    sourceStr = `/***************** debugger ${id} == start *****************/\n${source}\n/***************** debugger ${id} == end *****************/`
    return sourceStr;
  },
},
// Turn __DEV__ and process.env checks into constants.
replace({
  __DEV__: 'false', // isProduction ? 'false' : 'true',
  __PROFILE__: isProfiling || !isProduction ? 'true' : 'false',
  __UMD__: isUMDBundle ? 'true' : 'false',
  'process.env.NODE_ENV': isProduction ? "'production'" : "'development'",
  __EXPERIMENTAL__: false,
  // __EXPERIMENTAL__,
  // Enable forked reconciler.
  // NOTE: I did not put much thought into how to configure this.
  __VARIANT__: bundle.enableNewReconciler === true,
}),

image

效果

最终,优化后,打包出来的代码体积,react.development.js 从原先的2334行,减少到1122行。react-dom.development.js 从原先的26263行
减少到20600行。
image

image

image

源码拆分

react打包出来的源码都在一份文件中,比如 react-dom 打包后的代码接近2万行,对于我,只要将源码位置信息注入到打包后的代码中,阅读体验就非常好了。有些同学可能还是习惯于将源码映射到不同的文件,那么也可以通过在 rollup 配置中,修改 transform 插件的逻辑,比如:

  transform(source, id) {
    // 修改id
    id = id.replace('/Users/lizc/Documents/MYProjects/react/', '/Users/lizc/Documents/MYProjects/react/dist/')
    let sourceStr = source.replace(/['"]use strict["']/g, '');
    require('fs-extra').outputFile(id, sourceStr)

    return sourceStr;
  },

这个时候就会在根目录下生成一个dist文件夹,里面就是源码映射的文件

react context源码解析

这篇文章介绍了 react 中 context 的实现原理,以及 context 变化时,React 如何查找所有订阅了 context 的组件并跳过 shouldComponentUpdate 强制更新。可以让我们更加充分认识到 context 的性能瓶颈并能够合理设计全局状态管理。

学习目标

  • React Context 的实现原理
  • 订阅了 context 的组件是如何跳过shouldComponentUpdate强制 render 的
  • React 是如何使用堆栈来存储 Provider 的 value 以支持嵌套 Provider 的
  • context 的存取发生在 React 渲染的哪些阶段
  • fiber.dependencies 用于保存当前组件订阅的 context 依赖,一般情况下组件只有一个 context 依赖。但是通过 useContext 订阅多个 context 时,fiber.dependencies 就是一个链表
  • 为什么我建议尽量少的使用 React Redux?如何合理使用 React Redux 管理全局共享状态?

前言

先来简单回顾一下,React 的渲染分为两大阶段,五小阶段:

  • render 阶段
    • beginWork
    • completeUnitOfWork
  • commit 阶段。
    • commitBeforeMutationEffects
    • commitMutationEffects
    • commitLayoutEffects

beginWork 阶段主要是协调子元素,也就是常说的 dom diff。在 render 阶段,React 为每一个 fiber 节点调用 beginWork 开始执行工作,如果 fiber 没有子节点或者子节点都已经完成了工作,那么这个 fiber 就可以调用 completeUnitOfWork 完成自身的工作,这个过程就是深度优先遍历,具体可以看这篇文章深入概述 React 初次渲染及状态更新主流程

React context 只作用于 beginWork 阶段。 在 beginWork 阶段,如果当前组件订阅了 context,则从 context 中读取 value 值。

context 提供了一种存取全局共享数据的方式

Context API 简介

React 提供的与 Context 相关的 API,按用途可以划分如下:

  • 创建 context: React.createContext
  • 提供 context 值: Context.Provider
  • 订阅 context 值:
    • Class.contextType。用于类组件订阅 Context
    • Context.Consumer。用于函数组件订阅 Context,这种方式也可以间接的订阅多个 context
    • useContext。用于函数组件订阅 Context,唯一的订阅多个 context 的 api。通过 useContext 订阅多个 context 时,函数组件的 fiber.dependencies 就是一个链表

下面逐一介绍每个 API 的原理

React.createContext

createContext 负责创建一个 context 对象,包含 Provider 和 Consumer 属性,其中 _currentValue 用于存储全局共享状态,订阅了 context 的组件都是从 context._currentValue 中读取最新值的

var symbolFor = Symbol.for;
const REACT_CONTEXT_TYPE = symbolFor("react.context");
const REACT_PROVIDER_TYPE = symbolFor("react.provider");
function createContext(defaultValue) {
  var context = {
    $$typeof: REACT_CONTEXT_TYPE,
    _currentValue: defaultValue,
    Provider: null,
    Consumer: null,
  };
  context.Provider = {
    $$typeof: REACT_PROVIDER_TYPE,
    _context: context,
  };
  context.Consumer = {
    $$typeof: REACT_CONTEXT_TYPE,
    _context: context,
  };

  return context;
}
const context = createContext({ count: 0 });
console.log("context....", context);

Context.Provider

Provider 有三个特性:

  • 如果没有对应的 Provider,那么消费组件将读取 context 的默认值,即传递给 createContext 的 defaultValue
  • 多个 Provider 可以嵌套使用,里层的会覆盖外层的数据
  • Provider 的 value 值发生变化时,它内部的所有消费组件都会跳过 shouldComponentUpdate 强制更新

在介绍 Provider 的源码实现前,我们思考一下,如果让我们设计一个类似 Provider 的 API,如何设计才能满足前面两个特性?(第三个特性机制较复杂,后面会详细介绍)

特性 1:Context 默认值的读取

如果没有对应的 Provider,那么消费组件将读取 context 的默认值,即传递给 createContext 的 defaultValue

注意,useContext(CounterContext)等价于 CounterContext._currentValue,为了减少干扰方便演示,这里我直接使用 CounterContext._currentValue 替代 useContext

const CounterContext = React.createContext(-1);

const Counter = () => {
  // const context = useContext(CounterContext);
  const context = CounterContext._currentValue;
  return <div>{context}</div>;
};
class Home extends React.Component {
  constructor(props) {
    super(props);
  }

  render() {
    return <Counter />;
  }
}

由于没有 Provider,Counter 将读取 context 的默认值,即页面显示-1。但如果我们用 Provider 包裹一下:

render() {
  return (
    <CounterContext.Provider value={1}>
      <Counter />
    </CounterContext.Provider>
  );
}

由于有 Provider,Counter 将读取 Provider 的 value 值,即页面显示 1。

在调用 React.createContext 创建 context 时,context._currentValue 的值保存的就是默认值。因此,如果没有 CounterContext.Provider 时,Counter 可以通过 context._currentValue 读取到默认值。

同理,如果有 CounterContext.Provider 包裹 Counter 组件时,我们只需要将 Provider 的 value 值保存到 context._currentValue 中就能让 Counter 读取到。

在 render 阶段,CounterContext.Provider 开始 beginWork 时,我们可以将 CounterContext._currentValue 设置为新的 value 值。这样在后续的渲染阶段,Counter 就能够通过 CounterContext._currentValue 读取到 Provider 最新的 value 值。我们似乎已经满足了第一个特性

function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case ContextProvider:
      CounterContext._currentValue = workInProgress.pendingProps.value;
  }
}

但考虑到下面的案例

render() {
  return [
    <CounterContext.Provider value={1}>
      <Counter />
    </CounterContext.Provider>,
    <Counter />,
  ];
}

第二个 Counter 由于没有 Provider,理论上它要读取 context 的默认值。但是我们在 beginWork 时,已经将 CounterContext._currentValue 修改成最新的值了,第二个 Counter 读取到的也将是最新的值,而不是默认值。我们需要修改一下 beginWork 的逻辑

let valueCursor;
function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case ContextProvider:
      valueCursor = CounterContext._currentValue; // 先将旧值保存起来
      CounterContext._currentValue = workInProgress.pendingProps.value; // pendingProps保存的是新值
  }
}

我们声明一个全局变量,将旧值保存起来,然后再将 CounterContext._currentValue 设置成新的 value 值。那么问题来了,我们应该在哪个阶段将 CounterContext._currentValue 的值恢复成旧值?

React 在 render 阶段会遍历每一个 fiber 节点并调用 beginWork 为 fiber 执行工作,如果 fiber 没有子节点或者子节点都已经完成了工作,那么可以调用 completeUnitOfWork 为 fiber 完成工作。这个过程就是深度优先遍历,我们可以将 beginWork 理解为"进入"fiber 节点,而将 completeUnitOfWork 理解为"离开"fiber 节点,因此我们可以在离开 fiber 节点时,将 context._currentValue 恢复成旧值

completeUnitOfWork 内部调用 completeWork 完成工作,大概如下:

function completeWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case ContextProvider:
      // 在离开Provider节点时,将context._currentValue恢复成旧值
      const oldValue = valueCursor;
      CounterContext._currentValue = oldValue;
      return null;
  }
}

特性 2:多个相同 Provider 可以嵌套使用,里层的会覆盖外层的数据

我们自己设计的 api 已经能够满足 Provider 的第一个特性了,我们在进入 Provider fiber 节点时,将当前的 context._currentValue 值保存起来,然后再将 Provider 新的 value 值保存在 context._currentValue 中,这样 Provider 内部的所有组件都能够通过 context._currentValue 读取到最新的值。然后再离开 Provider fiber 节点时,我们将 context._currentValue 恢复成旧值。

现在让我们考虑下面多个 Provider 嵌套的场景:

render() {
  return [
    <CounterContext.Provider id="provider1" value={1}>
      <CounterContext.Provider id="provider2" value={2}>
        <Counter id="counter1" />
      </CounterContext.Provider>
      <Counter id="counter2" />
    </CounterContext.Provider>,
    <Counter id="counter3" />,
  ];
}

根据 Provider 里层覆盖外层值的特性:

  • counter1读取的是provider2的 value 值,即 2
  • counter2读取的是provider1的 value 值,即 1
  • counter3由于没有 Provider 包裹,因此读取的是 context 的默认值,即-1

页面显示 2,1,-1。

同时,我们必须要理解的一点是,provider1provider2虽然是两个组件,但他们的 context 是同一个引用,三个 Counter 组件都是从 context._currentValue 中读取的值

显然我们在前面设计的 api 满足不了这种场景,为了能实现嵌套的机制,我们遵循 React 遍历 fiber 节点的顺序,来看下这个思路:

  • 在进入provider1时,将当前 context._currentValue 的值(记为oldValue1)保存起来,然后将provider1新的 value 值赋值给 context._currentValue
  • React 继续遍历provider2,进入provider2时,我们又需要将当前 context._currentValue 的值(记为oldValue2)保存起来,然后将provider2新的 value 值赋值给 context._currentValue
  • React 继续遍历counter1counter1读取 context._currentValue 的值,就是provider2的 value 值。遍历完counter1后,就可以调用 completeUnitOfWork 完成工作
  • 当为provider2完成工作时,即离开provider2时,我们需要将 context._currentValue 的值恢复成provider1的 value 值,即oldValue2
  • 开始遍历 counter2,此时 counter2 通过 context._currentValue 读取到的就是 provider1 的 value 值
  • 当为provider1完成工作时,即离开provider1时,我们需要将 context._currentValue 的值恢复成默认值,即oldValue1

看上去这个思路行得通,我们只需要多声明几个全局变量保存 context 的当前值就可以了

let valueCursor1; // 保存oldValue1
let valueCursor2; // 保存oldValue2
function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case ContextProvider:
      if (workInProgress.id === "provider1") {
        valueCursor1 = CounterContext._currentValue; // 进入provider1时,将CounterContext的当前值保存起来,此时是默认值
        CounterContext._currentValue = workInProgress.pendingProps.value; //将provider1新的value值赋值给CounterContext._currentValue
      }
      if (workInProgress.id === "provider2") {
        valueCursor2 = CounterContext._currentValue; // 进入provider2时,将CounterContext的当前值保存起来,此时是provider1的value值
        CounterContext._currentValue = workInProgress.pendingProps.value; //将provider2新的value值赋值给CounterContext._currentValue
      }
  }
}
function completeWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case ContextProvider:
      if (workInProgress.id === "provider2") {
        CounterContext._currentValue = valueCursor2; // 离开provider2节点时,需要将context._currentValue恢复成provider1的value值
      }
      if (workInProgress.id === "provider1") {
        CounterContext._currentValue = valueCursor1; // 离开provider1节点时,需要将context._currentValue恢复成默认值
      }

      return null;
  }
}

似乎这个实现思路已经满足两个 provider 的嵌套,如果有代码洁癖的同学会发现,valueCursor1,valueCursor2,以及 if 判断不太友好。同时,最重要的是,我们无法满足更多层级的 provider 的嵌套:

  render() {
    return [
      <CounterContext.Provider id="provider1" value={1}>
        <CounterContext.Provider id="provider2" value={2}>
          <CounterContext.Provider id="provider3" value={3}>
            <CounterContext.Provider id="provider4" value={4}>
              <Counter id="counter1" />
            </CounterContext.Provider>
          </CounterContext.Provider>
        </CounterContext.Provider>
        <Counter id="counter2" />
      </CounterContext.Provider>,
      <Counter id="counter3" />,
    ];
  }

如果按照我们的做法,得声明好几个变量保存当前值,然后我们又无法确定有多少层级根本无法提前声明这些变量。看到这里,fiber 节点进进出出的很容易让我们想到堆栈,我们可以使用栈来保存当前的 context 值

let valueStack = [];
let index = -1;
function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case ContextProvider: {
      var context = workInProgress.type._context;
      index++;
      valueStack[index] = context._currentValue; // 先将context当前的值保存起来
      context._currentValue = workInProgress.pendingProps.value; // 然后将provider新的value值赋值给context._currentValue
    }
  }
}
function completeWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case ContextProvider: {
      const preValue = valueStack[index];
      valueStack[index] = null;
      index--;
      var context = providerFiber.type._context;
      context._currentValue = preValue;
    }
  }
}

这个版本的实现已经可以满足嵌套任意层的 Provider 了,同时还能满足不同的 Provider 组件嵌套,比如:

render() {
  return [
    <CounterContext.Provider id="provider1" value={1}>
      <UserContext.Provider id="userprovider1" value={"mike"}>
        <Counter id="counter1" />
      </UserContext.Provider>
    </CounterContext.Provider>,
  ];
}

实际上,这正是 React 所采用的实现方式,这种方式既能满足读取默认值的特性,又能满足里层的 Provider 覆盖外层的 Provider 的场景(指的是相同的 Provider 的覆盖)

Context.Provider 源码实现

React 在 beginWork 阶段对 Provider 类型的 fiber 节点执行的主要工作有两点:

  • 调用 pushProvider,将 context._currentValue 保存到 valueStack 栈中。然后将 context._currentValue 设置成 Provider 新的 value 值
  • 使用浅比较判断 Context.Provider 的新旧 value 值是否发生了改变,如果发生了改变,则调用 propagateContextChange 找出所有订阅了这个 context 的组件,然后跳过 shouldComponenentUpdate 强制更新。查找算法放在后面单独一节介绍

在 completeWork 阶段,Provider 类型的 fiber 节点执行的主要工作有一点:

  • 调用 popProvider 将 context._current 恢复成上一个值,即直接从 valueStack 取出第一项即可
function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case ContextProvider:
      return updateContextProvider(current, workInProgress, renderLanes);
  }
}
function completeWork(current, workInProgress, renderLanes) {
  var newProps = workInProgress.pendingProps;
  switch (workInProgress.tag) {
    case ContextProvider:
      popProvider(workInProgress);
      return null;
  }
}
var valueCursor = { current: null };
var valueStack = [];
var index = -1;
function pushProvider(providerFiber, nextValue) {
  var context = providerFiber.type._context;
  index++;
  valueStack[index] = valueCursor.current;
  valueCursor.current = context._currentValue;
  context._currentValue = nextValue;
}
function updateContextProvider(current, workInProgress, renderLanes) {
  var context = workInProgress.type._context; // React.createContext的返回值
  var newProps = workInProgress.pendingProps;
  var oldProps = workInProgress.memoizedProps;
  var newValue = newProps.value;

  pushProvider(workInProgress, newValue);

  if (oldProps !== null) {
    var oldValue = oldProps.value;
    var changedBits = newValue === oldValue;
    if (changedBits === 0) {
      if (oldProps.children === newProps.children && !hasContextChanged()) {
        // 没有改变
        return bailoutOnAlreadyFinishedWork(
          current,
          workInProgress,
          renderLanes
        );
      }
    } else {
      // context变了,遍历Provider所有的子孙fiber节点,查找订阅了该context的组件并标记为强制更新
      propagateContextChange(workInProgress, context, changedBits, renderLanes);
    }
  }

  var newChildren = newProps.children;
  reconcileChildren(current, workInProgress, newChildren, renderLanes);
  return workInProgress.child;
}

function popProvider(providerFiber) {
  var currentValue = valueCursor.current;
  pop(valueCursor);
  var context = providerFiber.type._context;
  context._currentValue = currentValue;
}

消费组件如何读取 Context 的值?

React 提供了三种方式读取 Context 的值:

  • Class.contextType。用于类组件订阅 Context
  • Context.Consumer。用于函数组件订阅 Context
  • useContext。用于函数组件订阅 Context

以上三种方式,都是需要手动传递 context 对象的,比如 useContext(context),Class.contextType = context。

对于函数组件,useContext 本质上就是调的readContext函数,即我们可以直接认为useContext === readContext,readContext 返回 context._currentValue

对于类组件,React 会判断类组件上是否有静态属性 contextType,如果有,则调用 readContext 读取 context 值,并赋值给类实例的 context 属性

const ctor = workInProgress.type;
const instance = new ctor();
const contextType = ctor.contextType;
if (typeof contextType === "object" && contextType !== null) {
  instance.context = readContext(contextType);
}

对于 Context.Consumer,context 本身就存在 Consumer 里面

var context = workInProgress.type;
var newProps = workInProgress.pendingProps;
var render = newProps.children;
prepareToReadContext(workInProgress, renderLanes);
var newValue = readContext(context, newProps.unstable_observedBits);
var newChildren = render(newValue);

可以看出,这三种方式在读取 context 时都要进行两个操作:

  • 在读取 context 前,都需要先调用prepareToReadContext进行准备工作,重置几个和 contex 有关的全局变量,以及判断 context 的 value 是否变更了
  • 都是调用 readContext 方法读取 context 值,readContext 方法返回 context._currentValue 的值

prepareToReadContext主要逻辑如下:

  • 将全局变量 currentlyRenderingFiber 设置为当前正在工作的 fiber,在 readContext 时可以通过这个全局变量拿到正在工作中的 fiber
  • 将全局变量 lastContextWithAllBitsObserved 重置为 null,这个变量在 readContext 函数中会被设置成 context 对象
  • 全局变量 lastContextDependency 在通过 useContext 订阅多个不同的 context 时,用于构造 dependencies 列表
  • 重置 fiber dependencies 列表
function prepareToReadContext(workInProgress, renderLanes) {
  currentlyRenderingFiber = workInProgress;
  lastContextDependency = null;
  lastContextWithAllBitsObserved = null; // 这个全局变量保存的是context对象
  var dependencies = workInProgress.dependencies;

  if (dependencies !== null) {
    var firstContext = dependencies.firstContext;

    if (firstContext !== null) {
      if (includesSomeLane(dependencies.lanes, renderLanes)) {
        // Context list has a pending update. Mark that this fiber performed work.
        didReceiveUpdate = true;
      } // Reset the work-in-progress list
      // 重置fiber context依赖
      dependencies.firstContext = null;
    }
  }
}

readContext 读取 context 最新值

context 本质上就是一个全局变量,我们完全可以在函数组件或者类组件中通过context._currentValue直接访问 context 值,比如:

const Counter = () => {
  return <div>{CounterContext._currentValue}</div>;
};

不信你可以在代码中试试。虽然我们可以直接读取值,但这又引入了两个问题:

  • context 的值变了,如何通知所有读取 context 的组件强制刷新?
  • 怎么知道哪些组件订阅了 context?

为了解决这两个问题,React 引入 Provider,Provider 判断 value 变化,就会通知所有订阅了 context 的组件。同时通过 readContext 读取值,在读取的时候,通过在 fiber.dependencies 中添加 context,标记这个组件订阅了 context。

readContext 的逻辑也比较简单,首先判断 lastContextWithAllBitsObserved === context,如果相等,说明是同一个 context,这种判断是为了防止重复,readContext 的一个主要目标就是收集组件依赖的所有 context,比如:

const CounterContext = React.createContext(-1);
const UserContext = React.createContext("mike");

const Counter = () => {
  const context = useContext(CounterContext);
  const context2 = useContext(CounterContext);
  const usercontext = useContext(UserContext);

  return (
    <div>
      {context}
      {usercontext}
    </div>
  );
};

这个例子中,React 认为 Counter 组件订阅了两个 context,而不是三个,因此将这两个 context 添加到 fiber 的 dependencies 依赖链表中,最终,fiber.dependencies 长这样:

fiber.dependencies = {
  lanes,
  firstContext: {
    context: CounterContext,
    next: {
      context: UserContext,
      next: null,
    },
  },
  responders,
};

readContext 收集依赖的算法如下:

function readContext(context, observedBits) {
  if (lastContextWithAllBitsObserved === context) {
  } else {
    lastContextWithAllBitsObserved = context;
    var resolvedObservedBits = MAX_SIGNED_31_BIT_INT;

    var contextItem = {
      context: context,
      observedBits: resolvedObservedBits,
      next: null,
    };

    if (lastContextDependency === null) {
      // 这是第一个依赖
      lastContextDependency = contextItem;
      currentlyRenderingFiber.dependencies = {
        lanes: NoLanes,
        firstContext: contextItem,
        responders: null,
      };
    } else {
      // 添加到dependencies表尾
      lastContextDependency = lastContextDependency.next = contextItem;
    }
  }

  return context._currentValue;
}

Context.Provider value 变化,React 如何强制更新?

在 Provider 的 value 值变化时,React 会遍历 Provider 内部所有的 fiber 节点,然后查看其 fiber.dependencies,如果 dependencies 中存在一个 context 和当前 Provider 的 context 相等,那说明这个组件订阅了当前的 Provider 的 context,需要将其标记为强制更新

先来看下面的 demo

const CounterContext = React.createContext({
  count: 0,
  addCount: () => {},
});

class Counter extends React.Component {
  static contextType = CounterContext;
  shouldComponentUpdate() {
    return false;
  }
  render() {
    console.log("Counter render");
    return (
      <button id="counter" onClick={this.context.addCount}>
        {this.context.count}
      </button>
    );
  }
}

class CounterWrap extends React.Component {
  render() {
    console.log("CounterWrap render,控制台只会输出一次");
    return <Counter />;
  }
}

class NeverUpdate extends React.Component {
  render() {
    console.log("NeverUpdate render,控制台只会输出一次");
    return <div>永远不会更新</div>;
  }
}

class App extends React.Component {
  constructor(props) {
    super(props);
  }
  shouldComponentUpdate() {
    return false;
  }
  render() {
    console.log("App render,控制台只会输出一次");
    return [<CounterWrap />, <NeverUpdate />];
  }
}

class Home extends React.Component {
  constructor(props) {
    super(props);
    this.addCount = () => {
      console.log("点击按钮触发更新", this.state.count + 1);
      this.setState({
        count: this.state.count + 1,
      });
    };
    this.state = {
      count: 0,
      addCount: this.addCount,
    };
  }

  render() {
    console.log("Home render");
    return (
      <CounterContext.Provider value={this.state}>
        <App />
      </CounterContext.Provider>
    );
  }
}
ReactDOM.render(<Home />, document.getElementById("root"));
  • App 组件的 shouldComponentUpdate 永远返回 false,理论上 App 组件以及它的所有子组件的 render 在更新的过程中都不会执行,即不会更新。
  • Counter 组件的 shouldComponentUpdate 永远返回 false,理论上 Counter 也不会更新

但是我们点击按钮,观察控制台可以发现:

image

  • 第一次渲染时,所有组件都会更新,组件的 render 方法都被执行
  • 在随后的点击过程中,只有 Home 组件以及订阅了 Context 的 Counter 组件更新了,它们对应的 render 方法执行

Home 的更新很容易理解,因为点击按钮触发了它的 state 更新,那么 Counter 组件是如何跳过父组件 App 以及其自身的 shouldComponentUpdate 强制更新的?

前面介绍过在updateContextProvider方法中,使用浅比较判断 Provider 的 value 是否变化,如果变化,则调用propagateContextChange查找所有订阅了这个 context 的组件

propagateContextChange 查找算法

以下面的代码为例:

class Home extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 1,
    };
  }

  render() {
    return [
      <CounterContext.Provider id="provider1" value={this.state.count + 1}>
        <div id="wrap">
          <CounterContext.Provider id="provider2" value={2}>
            <Counter id="counter1" />
          </CounterContext.Provider>
          <UserContext.Provider id="userprovider1" value="mike">
            <Counter id="counter2" />
          </UserContext.Provider>
        </div>
        <Counter id="counter3" />
      </CounterContext.Provider>,
      <button
        onClick={() => {
          this.setState({
            count: this.state.count + 1,
          });
        }}
      >
        click
      </button>,
    ];
  }
}

当点击按钮触发更新时,provider1的 value 发生变更,因此调用propagateContextChange开始查找所有订阅了provider1的 context 的 fiber 节点,按以下顺序:

  • 首先是 div#wrap,由于它没有订阅了 CounterContext,因此没有任何操作,继续遍历它的子节点
  • 由于 provider2provider1 是同一个 context,因此不需要继续遍历provider2内部的子节点,因为即使provider2内部有组件订阅了 CounterContext,那也是读取的是 provider2 的 value 值,而不是 provider1 的 value 值,因此 provider1 的值发生变化不会影响到 provider2 内部的消费组件
  • 继续遍历 userprovider1,没有订阅 CounterContext,因此继续遍历couter2,发现counter2订阅了 CounterContext,因此将其标记为更新
    • 如果 counter2 是类组件,那么会创建一个更新对象 update,并将 update.tag 标记为强制更新
  • 继续遍历 counter3,发现 counter3 也订阅了 CounterContext

如果找到订阅了 context 的消费组件,则将其 fiber.lane 标记为更新,然后合并到父节点。

比如在我们上面那个例子中,Counter 需要更新,但是 App、CounterWrap、NeverUpdate 都不需要更新,因此这三个 fiber 节点在 beginWork 阶段会直接跳过,然后更新 Counter 组件。

至于怎么标记更新,这涉及到 fiber lane 的知识,就不在本节的讨论范围

可以发现,当有 Provider 的 value 发生变化时,React 会遍历这个 Provider 内部所有的 fiber 节点,找出订阅了这个 Provider 的 context 的 fiber 节点。这个查找的过程也是挺耗时的,特别是组件层级很深时

最后,propagateContextChange 查找算法如下:

function propagateContextChange(
  workInProgress,
  context,
  changedBits,
  renderLanes
) {
  var fiber = workInProgress.child;

  while (fiber !== null) {
    var nextFiber = void 0; // Visit this fiber.

    var list = fiber.dependencies;
    // 首先判断这个fiber是否有dependencies,如果没有,说明这个fiber没有订阅任何context
    if (list !== null) {
      nextFiber = fiber.child;
      var dependency = list.firstContext;
      // 如果这个fiber有订阅context,则判断是否是当前Provider的context
      while (dependency !== null) {
        // 检查context是否匹配
        if (
          dependency.context === context &&
          (dependency.observedBits & changedBits) !== 0
        ) {
          // 匹配到了context,说明这个组件订阅了当前Provider的context,我们需要给这个fiber调度更新
          if (fiber.tag === ClassComponent) {
            // 如果是类组件,则创建一个更新对象update,并标记为强制更新
            var update = createUpdate(
              NoTimestamp,
              pickArbitraryLane(renderLanes)
            );
            update.tag = ForceUpdate;
            // 添加到更新队列
            enqueueUpdate(fiber, update);
          }

          fiber.lanes = mergeLanes(fiber.lanes, renderLanes);
          var alternate = fiber.alternate;

          if (alternate !== null) {
            alternate.lanes = mergeLanes(alternate.lanes, renderLanes);
          }

          scheduleWorkOnParentPath(fiber.return, renderLanes);

          list.lanes = mergeLanes(list.lanes, renderLanes);

          break;
        }

        dependency = dependency.next;
      }
    } else if (fiber.tag === ContextProvider) {
      // 如果是相同的Provider,则不用继续遍历了,因为相同的嵌套的Provider,内部的消费组件取最里层的,外层的Provider变化
      // 和里面的消费组件没啥关系
      nextFiber = fiber.type === workInProgress.type ? null : fiber.child;
    } else {
      // 继续遍历子节点
      nextFiber = fiber.child;
    }

    if (nextFiber === null) {
      // 没有子节点,则继续遍历兄弟节点
      nextFiber = fiber;

      while (nextFiber !== null) {
        if (nextFiber === workInProgress) {
          // 所有fiber节点已经遍历完成,退出
          nextFiber = null;
          break;
        }

        var sibling = nextFiber.sibling;

        if (sibling !== null) {
          nextFiber = sibling;
          break;
        }
        // 如果没有兄弟节点,则查找父节点的兄弟节点
        nextFiber = nextFiber.return;
      }
    }

    fiber = nextFiber;
  }
}

如何合理使用 React Redux 管理全局共享状态

从上面 Provider 的 value 变化,查找所有订阅组件的过程可以看出,每次 Provider 一变化,都要遍历一次,像下面的代码:

<CounterContext.Provider value={this.state.count}>
  <UserContext.Provider value={this.state.count + "mike"}>
    <App />
  </UserContext.Provider>
</CounterContext.Provider>

如果 this.state.count 发生变化,则导致在 beginWork 阶段:

  • CounterContext.Provider 的 value 发生了变化,则遍历内部所有的 fiber 节点找出消费组件
  • UserContext.Provider 的 value 也发生了变化,则遍历内部所有的 fiber 节点找出消费组件

如果页面很复杂,组件层级很深数量庞大,这个开销也是很大的。

因此,我们应该尽量少的避免 Provider 的 value 发生变化

在使用 React Redux 时,每次 dispatch 触发状态变更,React 都要查找一次。我们应该要尽可能少的使用 React Redux 管理状态,只在必要的时候,比如全局共享数据,才使用 React Redux 托管状态。而页面级别或者组件级别的状态应该在组件内部闭环,通过 this.state 或者 useState 管理

深入概述 React初次渲染以及状态更新主流程源码

本章主要介绍 ReactDOM.render 初次渲染以及 setState 手动触发更新的主流程。学习 React 渲染的两个阶段:rendercommit 阶段。了解 React 合成事件注册时机、类组件生命周期方法、函数组件 hook 调用时机、reconcile(dom diff)算法等。

深入概述 ReactDOM.render 初次渲染 以及 setState 手动触发状态更新主流程

调试 DEMO

答应我,在阅读本篇文章时,用以下 Demo,在本篇文章介绍的各个函数入口处打断点,边阅读边 debug。书读百遍,真的不如动手一遍。

新建 index.jsx 文件:

import React from "react";
import ReactDOM from "react-dom";
import Counter from "./Counter";

class Home extends React.Component {
  constructor(props) {
    super(props);
    this.state = { step: 0 };
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    this.setState(
      (state) => {
        return { step: state.step + 1 };
      },
      () => {
        console.log("this.setState callback");
      }
    );
  }
  static getDerivedStateFromProps(props, state) {
    console.log("getDerivedStateFromProps======");
    return null;
  }
  getSnapshotBeforeUpdate(prevProps, prevState) {
    const btn = document.getElementById("btn");
    const scrollHeight = btn.scrollHeight;
    console.log("get snapshot before update...", scrollHeight);
    return scrollHeight;
  }
  componentDidUpdate(prevProps, prevState, snapshot) {
    console.log("component did update...", snapshot);
  }
  componentDidMount() {
    console.log("component did mount......");
  }
  componentWillUnmount() {
    console.log("component will unmount...");
  }
  UNSAFE_componentWillMount() {
    console.log("component will mount...");
  }
  UNSAFE_componentWillReceiveProps(nextProps) {
    console.log("component will receive props...", nextProps);
  }
  UNSAFE_componentWillUpdate(nextProps, nextState) {
    console.log("component will update....", nextProps, nextState);
  }
  shouldComponentUpdate() {
    console.log("should component update");
    return true;
  }

  render() {
    return [
      (!this.state.step || this.state.step % 3) && (
        <Counter step={this.state.step} />
      ),
      <button id="btn" key="2" onClick={this.handleClick}>
        类组件按钮:{this.state.step}
      </button>,
    ];
  }
}

ReactDOM.render(<Home />, document.getElementById("root"));

新建 Counter.jsx 文件:

import React, { useState, useEffect, useLayoutEffect } from "react";
const Counter = ({ step }) => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    console.log("useEffect====");
  });
  useLayoutEffect(() => {
    console.log("useLayoutEffect====");
  });
  const onBtnClick = () => {
    setCount(count + 1);
  };
  return (
    <div style={{ margin: "50px" }}>
      <button onClick={onBtnClick}>{count}</button>
      <div>props:{step}</div>
      {!(count % 2) && <div>函数组件,复数显示,单数隐藏</div>}
    </div>
  );
};

export default Counter;

一、前置知识

在阅读本文时,假设你已经有一些 fiber 的基础知识。

1.1 容器 root 节点

我们传递给 ReactDOM.render(element, root) 的第二个参数 root

1.2 fiber 类型

fiber 节点的类型通过 fiber.tag 标识,称为 React work tag。我们重点关注以下几个类型:

  • HostRoot。容器 root 节点对应的 fiber 类型。一般来说,一个 React 应用程序只会有一个 HostRoot 类型的 fiber 节点。
  • ClassComponent。类组件对应的 fiber 类型。
  • FunctionComponent。函数组件对应的 fiber 类型。
  • IndeterminateComponen。函数组件第一次渲染时对应的 fiber 类型
  • HostComponent。原生的 HTML 标签(比如 div)对应的 fiber 类型
  • HostText。文本节点类型。

React 对于文本节点的处理分两种场景:单一节点和多节点。比如:

// 单一节点情况。React在render阶段,不会为div的子节点创建新的fiber节点,而是将 "单一节点情况" 这个字符串当作 div 的 textContent或者nodeValue处理
<div>单一节点情况</div>
// 多节点情况。假设props.count等于0。React 在 render 阶段将子节点视为两个节点:"多节点情况:" 以及 "0"。然后为这两个子节点都创建对应的fiber节点,节点类型就是 HostText
<div>多节点情况:{props.count}</div>

记住这几个 fiber 类型,会贯穿整篇文章。在整个 react 渲染阶段,react 基于 fiber.tag 执行不同的操作。因此你会看到大量的基于 fiber.tag 的 switch 语句。

1.3 副作用

副作用通过 fiber.flags 标记。对于不同的 fiber 类型,副作用含义不同

  • HostRoot。
  • ClassComponent。类组件如果实现了 componentDidMount 等生命周期方法,则对应的 fiber 节点包含副作用
  • FunctionComponent。函数组件如果调用了 useEffect、useLayoutEffect,则对应的 fiber 节点包含副作用
  • HostComponent。原生的 HTML 标签如果属性,比如 style 等发生了变更,则对应的 fiber 节点包含副作用。

在 render 阶段,react 会找出有副作用的 fiber 节点,并自底向上构建单向的副作用链表

1.4 React 渲染流程

React 渲染主要分为两个阶段:render 阶段 和 commit 阶段。

1.4.1 render 阶段

render 阶段支持异步并发渲染,可中断。分为 beginWork 以及 completeUnitOfWork 两个子阶段:

  • beginWork。
    • reconcileChildren。根据当前工作的 fiber 节点最新的 react element 子元素和旧的 fiber 子元素进行比较以决定是否复用旧的 fiber 节点,并标记 fiber 节点是否有副作用。注意这里如果是类组件或者函数组件,则需要调用类组件实例的 render 方法或者执行函数组件获取最新的 react element 子元素
  • completeUnitOfWork。
    • 对于 HostComponent。比较 newProps 和 oldProps,收集发生变更的属性键值对,并存储在 fiber.updateQueue 中
    • 构建副作用链表。自底向上找出有副作用的 fiber 节点,并构建单向链表

render 阶段的结果是一个副作用链表以及一棵 finishedWork 树。

1.4.2 commit 阶段

commit 阶段是同步的,一旦开始就不能再中断。这个阶段遍历副作用链表并执行真实的 DOM 操作。commit 阶段分为 commitBeforeMutationEffectscommitMutationEffects 以及 commitLayoutEffects 三个子阶段。每个子阶段都是一个 while 循环。同时,每个子阶段都是从头开始遍历副作用链表!!!

  • commitBeforeMutationEffects。DOM 变更前。这个阶段除了类组件以外,其他类型的 fiber 节点几乎没有任何处理
    • 调用类组件实例上的 getSnapshotBeforeUpdate 方法
  • commitMutationEffects。操作真实的 DOM
    • 对于 HostComponent
      • 更新 dom 节点上的 __reactProps$md9gs3r7129 属性,这个属性存的是 fiber 节点的 props 值。这个属性很重要,主要是更新 dom 上的 onClick 等合成事件。由于事件委托在容器 root 上,因此在事件委托时,需要通过 dom 节点获取最新的 onClick 等事件
      • 更新发生了变更的属性,比如 style 等
    • 对于 HostText,直接更新文本节点的 nodeValue 为最新的文本值
    • 对于类组件,则什么都不做。
  • commitLayoutEffects。DOM 变更后。
    • 对于 HostComponent,判断是否需要聚焦
    • 对于 HostText,什么都没做
    • 对于类组件
      • 初次渲染,则调用 componentDidMount
      • 更新则调用 componentDidUpdate
      • 调用 this.setState 的 callback

二、ReactDOM.render 初次渲染

初次渲染的入口。初次渲染主要逻辑在 createRootImpl 以及 updateContainer 这两个函数中,React 在初次渲染不会追踪副作用,主要工作:

  • 创建 FiberRootNode 类型节点。这是用于保存 fiber 树的容器。可以通过 root._reactRootContainer._internalRoot 属性访问。
  • 创建 HostRoot Fiber。即容器 root 节点对应的 fiber 节点,这也是 fiber 树的根节点
  • 将 HostRootFiber 挂载到 FiberRootNode 的 current 属性
  • 往容器 root 上注册浏览器支持的所有原生事件。这也是合成事件的入口
  • 调用 scheduleUpdateOnFiber 开始调度更新。

本篇文章中我们重点关注 _internalRoot 中的两个属性:currentfinishedWorkcurrent 保存的是当前页面对应的 fiber 树。finishedWork 保存的是 render 阶段完成,commit 阶段开始前,构建完成但是还没更新到页面的 fiber 树。等 commit 阶段完成后,finishedWork 树就变成了 current

function render(element, container, callback) {
  return legacyRenderSubtreeIntoContainer(...);
}
// container = document.getElementById('root')
function legacyRenderSubtreeIntoContainer(...,container, ...) {
  // container._reactRootContainer只包含_internalRoot属性
  let root = container._reactRootContainer = legacyCreateRootFromDOMContainer(container);
  let fiberRoot = root._internalRoot;
  updateContainer(children, fiberRoot, parentComponent, callback);
}
function legacyCreateRootFromDOMContainer(container, forceHydrate) {
  return createLegacyRoot(container, undefined);
}

function createLegacyRoot(container, options) {
  return new ReactDOMBlockingRoot(container, LegacyRoot, options);
}
function ReactDOMBlockingRoot(container, tag, options) {
  this._internalRoot = createRootImpl(container, tag, options);
}

function createRootImpl(container, tag, options) {
  // createContainer主要逻辑:
  // 1.创建FiberRootNode节点,注意这并不是一个fiber
  // 2.创建 HostRoot Fiber。即容器root节点对应的fiber节点,这也是fiber树的根
  // 3.将 HostRootFiber挂载到FiberRootNode的current属性
  const root = createContainer(container, tag, hydrate);
  // 在根容器上注册所有支持的事件监听器,合成事件的入口
  listenToAllSupportedEvents(container);
  return root;
}

// element就是 react element tree
// container 就是 fiber 树的容器,即 FiberRootNode
function updateContainer(element, container) {
  const current = container.current; // fiber 树的根节点,即 HostRootFiber
  const update = createUpdate(eventTime, lane);
  // 根节点的 update.payload存的是整棵 virtual dom 树
  update.payload = { element: element };
  enqueueUpdate(current, update);
  scheduleUpdateOnFiber(current, lane, eventTime);
}

三、调度更新

3.1 scheduleUpdateOnFiber

更新的入口。不管是初次渲染,还是后续我们通过 this.setState 或者 useState 等手动触发状态更新,都会走 scheduleUpdateOnFiber 方法开始调度更新。scheduleUpdateOnFiber 会从当前 fiber 开始往上找到 HostRootFiber,然后从 HostRootFiber 开始更新。

这里又分为同步更新以及批量更新。同步更新直接走 performSyncWorkOnRoot 方法。批量更新走 ensureRootIsScheduled 方法调度。ensureRootIsScheduled 方法里面会根据环境判断是该走同步更新,即 performSyncWorkOnRoot 还是批量更新,即 performConcurrentWorkOnRoot

本篇文章中,我们只需要关注同步更新,即 performSyncWorkOnRoot 的流程。

function scheduleUpdateOnFiber(fiber, lane, eventTime) {
  //返回的是FiberRootNode,即 fiber 树的容器
  const root = markUpdateLaneFromFiberToRoot(fiber, lane);
  if (同步更新) {
    performSyncWorkOnRoot(root);
  } else {
    ensureRootIsScheduled(root);
  }
}
function ensureRootIsScheduled(root) {
  if (newCallbackPriority === SyncLanePriority) {
    scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
  } else if (newCallbackPriority === SyncBatchedLanePriority) {
    scheduleCallback(
      ImmediatePriority$1,
      performSyncWorkOnRoot.bind(null, root)
    );
  } else {
    scheduleCallback(
      schedulerPriorityLevel,
      performConcurrentWorkOnRoot.bind(null, root)
    );
  }
}

3.2 performSyncWorkOnRoot

performSyncWorkOnRoot 的 render 阶段是同步的。在这里,React 将渲染过程拆分成了两个子阶段:

  • renderRootSync。render 阶段
  • commitRoot。commit 阶段
function performSyncWorkOnRoot(root) {
  // render阶段的入口
  renderRootSync(root, lanes);
  // render阶段完成后得到一棵finishedWork tree以及副作用链表(effect list)
  const finishedWork = root.current.alternate;
  root.finishedWork = finishedWork;
  // commit阶段开始
  commitRoot(root);
}

四、render 阶段

4.1 renderRootSync

render 阶段从 renderRootSync 函数开始。主要逻辑在 prepareFreshStack 以及 workLoopSync 方法。

function renderRootSync(root, lanes) {
  prepareFreshStack(root, lanes);
  workLoopSync();
}
function prepareFreshStack(root, lanes) {
  root.finishedWork = null;
  workInProgressRoot = root;
  workInProgress = createWorkInProgress(root.current, null);
}
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

workInProgress 代表正在工作的 fiber 节点。对于每一个 fiber 节点,都会执行 performUnitOfWork。

4.2 performUnitOfWork

对于每一个 fiber 节点,首先调用 beginWork 协调子节点,如果 beginWork 返回 null,说明当前 fiber 节点已经没有子节点,工作可以完成了,调用 completeUnitOfWork 完成工作。

function performUnitOfWork(unitOfWork) {
  let current = unitOfWork.alternate;
  const next = beginWork(current, unitOfWork, subtreeRenderLanes);
  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }
}

4.3 beginWork

beginWork 函数自身就是一个简单的基于 fiber.tag 的 switch 语句,这个阶段的逻辑主要在各个分支函数中。beginWork 最主要的工作:

  • 协调。根据最新的 react element 子元素和旧的 fiber 子节点 对比,生成新的 fiber 子节点,即 DOM DIFF。
  • 标记副作用。在协调子元素的过程中,会根据子元素是否增删改,从而将新的 newFiber 子节点的 flags 更新为对应的值。
  • 返回新的子 fiber 节点作为下一个工作的 fiber 节点
function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case IndeterminateComponent:
      // 函数组件在第一次渲染时会走 IndeterminateComponent 分支
      return mountIndeterminateComponent(current, workInProgress);
    case FunctionComponent:
      // 函数组件在更新阶段会走FunctionComponent分支
      return updateFunctionComponent(current, workInProgress);
    case ClassComponent: {
      return updateClassComponent(current, workInProgress);
    }
    case HostRoot:
      return updateHostRoot(current, workInProgress);
    case HostComponent:
      return updateHostComponent(current, workInProgress);
    case HostText:
      return updateHostText(current, workInProgress);
  }
}
4.3.1 HostRootFiber beginWork:updateHostRoot

updateHostRoot 函数执行完,由于 HostRootFiber 没有副作用,因此 HostRootFiber.flags 依然是 0

function updateHostRoot(current, workInProgress, renderLanes) {
  cloneUpdateQueue(current, workInProgress);
  processUpdateQueue(workInProgress, nextProps, null, renderLanes);
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}
4.3.2 类组件 beginWork: updateClassComponent

类组件的更新分为初次渲染以及更新两种情况。

  • 初次渲染。逻辑主要在 constructClassInstance 以及 mountClassInstance 两个函数中
    • constructClassInstance 方法主要逻辑:
      • 初始化类组件实例 instance = new ctor(props, context)。
      • 初始化实例的 updater:instance.updater = classComponentUpdater,这是类组件 this.setState 方法的更新器
      • 关联 fiber 和实例:workInprogress.stateNode = instance。instance._reactInternals = workInprogress。这个关联很有必要,比如当我们点击按钮时,能够通过 instance._reactInternals 找到当前的 fiber 节点,并开始调度更新。
    • mountClassInstance 方法主要逻辑:
      • initializeUpdateQueue 初始化更新队列 updateQueue
      • 调用 processUpdateQueue 计算更新队列,获取最新的 state
      • 根据最新的 state 调用 getDerivedStateFromProps 静态生命周期方法
      • 调用 componentWillMount 生命周期方法
      • 如果类组件实现了 componentDidMount 生命周期方法,则更新 flags: workInProgress.flags |= Update
  • 更新阶段。逻辑主要在 updateClassInstance 函数中,按顺序执行以下操作:
    • 调用 componentWillReceiveProps 生命周期方法
    • processUpdateQueue 计算更新队列,获取最新的 state
    • 根据最新的 state 调用 getDerivedStateFromProps 静态生命周期方法
    • 调用 shouldComponentUpdate 生命周期方法
    • 调用 componentWillUpdate 生命周期方法
    • 如果组件实例实现了 componentDidUpdate 或者 getSnapshotBeforeUpdate,则说明这个 fiber 节点有副作用,更新 fiber.flags

最后,调用 finishClassComponent 开始协调子元素

function updateClassComponent(current, workInProgress, Component) {
  const instance = workInProgress.stateNode;
  // instance为null,说明是初次渲染
  if (instance === null) {
    // 初始化类组件实例 instance。
    // 初始化实例的 updater:instance.updater = classComponentUpdater,这是类组件 `this.setState` 方法的更新器
    // 关联 fiber 和实例:workInprogress.stateNode = instance。instances._reactInternals = workInprogress
    constructClassInstance(workInProgress, Component, nextProps);
    // initializeUpdateQueue 初始化更新队列 updateQueue
    // processUpdateQueue 计算更新队列,获取最新的 state
    // 根据最新的 state 调用 getDerivedStateFromProps 静态生命周期方法
    // 调用 componentWillMount 生命周期方法
    // 如果类组件实现了 componentDidMount 生命周期方法,则更新 flags: workInProgress.flags |= Update
    mountClassInstance(workInProgress, Component, nextProps);
  } else if (current === null) {
  } else {
    // 更新阶段
    shouldUpdate = updateClassInstance(current, workInProgress, Component);
  }
  return finishClassComponent(current, workInProgress, Component);
}
function finishClassComponent(current, workInProgress, Component) {
  nextChildren = instance.render();
  workInProgress.flags |= PerformedWork;
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  workInProgress.memoizedState = instance.state;
  return workInProgress.child;
}
4.3.3 函数组件 beginWork:mountIndeterminateComponent & updateFunctionComponent

函数组件在第一次渲染时,会走 IndeterminateComponent 分支,执行 mountIndeterminateComponent 方法

当执行完成 renderWithHooks 方法后,此时 fiber 类型已经确定,因此需要修改 workInProgress.tag

function mountIndeterminateComponent(_current, workInProgress) {
  const props = workInProgress.pendingProps;
  const nextChildren = renderWithHooks(current, workInProgress, Component);

  workInProgress.flags |= PerformedWork; // PerformedWork对应的值为1

  workInProgress.tag = FunctionComponent;

  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

函数组件在更新阶段,会走 FunctionComponent 分支,执行 updateFunctionComponent 方法。

function updateFunctionComponent(current, workInProgress, Component) {
  const newChildren = renderWithHooks(current, workInProgress, Component);
  reconcileChildren(null, workInProgress, newChildren);
  return workInProgress.child;
}

不管是初次渲染还是更新阶段,都会走 renderWithHooks 方法,这是函数组件执行的主要逻辑。React 提供了各种 hook 给我们在函数组件中使用,但是这些 hook 在初次渲染和更新阶段的行为又有点不同,为了屏蔽这些行为,React 在 renderWithHooks 中会判断,如果是初次渲染,则使用 HooksDispatcherOnMount,如果是更新阶段,则使用 HooksDispatcherOnUpdateHooksDispatcherOnMountHooksDispatcherOnUpdate 提供的 API 一模一样,只是实现有细微差别。

function renderWithHooks(current, workInProgress, Component) {
  currentlyRenderingFiber = workInProgress;
  workInProgress.memoizedState = null;
  workInProgress.updateQueue = null;
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;
  const children = Component(props, secondArg); // 调用函数组件
  currentlyRenderingFiber = null;
  workInProgressHook = null;
  currentHook = null;
  return children;
}
4.3.4 原生的 HTML 标签 beginWork:updateHostComponent

调用 shouldSetTextContent 判断是否需要为新的 react element 子节点创建 fiber 节点。如果新的 react element,即 nextChildren 是一个字符串或者数字,则说明 nextChildren 不需要创建 fiber 节点。比如:

// 对于 div 这个fiber节点,由于它只有一个新的子元素,并且是一个字符串,因此不需要为这个 div fiber节点创建新的fiber节点
<div>只有一个子元素并且是字符串或者数字</div>

如果是多节点的情况,比如:

// 假设此时的 count 为数字0,那么对于 div 这个fiber节点,在 reconcile 阶段,会认为它有两个新的子节点:
// 一个是 "接收父组件的props:",一个是 0。React 会为这两个文本节点创建对应的 fiber 节点,fiber.tag 都是
// HostText
<div>接收父组件的props:{props.count}</div>
function updateHostComponent(current, workInProgress, renderLanes) {
  const type = workInProgress.type; // 原生的html标签,如 button
  const nextProps = workInProgress.pendingProps;
  let nextChildren = nextProps.children;
  // 对于原生的html标签,如果只有一个子节点,并且这个子节点是一个字符串或者数字的话,则
  // 不会对此子节点创建fiber
  const isDirectTextChild = shouldSetTextContent(type, nextProps);
  if (isDirectTextChild) {
    nextChildren = null;
  }
  reconcileChildren(current, workInProgress, nextChildren);
  return workInProgress.child;
}
4.3.5 HostText 文本节点 beginWork:updateHostText

HostText 节点在 beginWork 阶段几乎不做任何处理,因此这个节点可以直接完成了。

function updateHostText() {
  return null;
}

4.4 completeUnitOfWork

当一个 fiber 节点没有子节点,或者子节点仅仅是单一的字符串或者数字时,说明这个 fiber 节点当前的 beginWork 已经完成,可以进入 completeUnitOfWork 完成工作。

completeUnitOfWork 主要工作如下:

  • 调用 completeWork。创建真实的 DOM 节点,属性赋值等。
  • 构建副作用链表。
  • 如果有兄弟节点,则返回兄弟节点,兄弟节点执行 beginWork。否则继续完成父节点的工作。
function completeUnitOfWork(unitOfWork) {
  let completedWork = unitOfWork;
  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;
    let next;
    // 完成此fiber对应的真实DOM节点创建和属性赋值的功能
    next = completeWork(current, completedWork, subtreeRenderLanes);
    // 开始构建副作用列表。
    if (returnFiber !== null) {
      if (returnFiber.firstEffect === null) {
        returnFiber.firstEffect = completedWork.firstEffect;
      }
      if (completedWork.lastEffect !== null) {
        if (returnFiber.lastEffect !== null) {
          returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
        }
        returnFiber.lastEffect = completedWork.lastEffect;
      }
      const flags = completedWork.flags;

      if (flags > PerformedWork) {
        if (returnFiber.lastEffect !== null) {
          returnFiber.lastEffect.nextEffect = completedWork;
        } else {
          returnFiber.firstEffect = completedWork;
        }

        returnFiber.lastEffect = completedWork;
      }
    }
    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      // If there is more work to do in this returnFiber, do that next.
      workInProgress = siblingFiber;
      return;
    }

    completedWork = returnFiber;

    workInProgress = completedWork;
  } while (completedWork !== null);
}

completeWork 函数也是一个基于 fiber.tag 的 switch 语句

可以看出,对于函数组件和类组件,completeWork 几乎没有工作。主要的工作集中在 HostRootHostComponentHostText

export function completeWork(current, workInProgress, renderLanes) {
  const newProps = workInProgress.pendingProps;
  switch (workInProgress.tag) {
    case FunctionComponent:
      return null;
    case ClassComponent:
      return null;
    case HostRoot:
      const fiberRoot = workInProgress.stateNode;
      if (current === null || current.child === null) {
        // 添加一个副作用,在下次commit开始前清空容器???
        workInProgress.flags |= Snapshot;
      }
      updateHostContainer(workInProgress); // 空函数
      return null;
    case HostComponent:
      const type = workInProgress.type;
      if (current && workInProgress.stateNode) {
        updateHostComponent(current, workInProgress);
      } else {
        // 第一次渲染,创建真实的DOM节点
        const instance = createInstance(type, newProps, workInProgress);
        // 将子元素对应的dom节点添加到instance中,即instance.appendChild(chid)
        appendAllChildren(instance, workInProgress, false, false);
        workInProgress.stateNode = instance;
        // 给真实dom实例添加属性,比如style等
        finalizeInitialChildren(instance, type, newProps);
      }
      return null;
    case HostText:
      const newText = newProps;
      if (current && workInProgress.stateNode != null) {
        updateHostText(current, workInProgress, oldText, newText);
      } else {
        workInProgress.stateNode = createTextInstance(newText, workInProgress);
      }
      return null;
  }
}
4.4.1 原生的 HTML 标签渲染 completeUnitOfWork

对于 HostComponent,则需要区分第一次渲染以及更新阶段。注意这里的第一次渲染是指这个 DOM 元素第一次渲染。而不是我们的页面第一次渲染。

HostComponent 第一次渲染

  • 创建真实的 DOM 元素。并将 fiber 节点挂载到 DOM 上的 __reactFiber$uqibbgdk1tp 属性, 同时将 newProps 挂载到 DOM 上的 __reactProps$uqibbgdk1tp 属性。所以我们可以看到,浏览器上的每个 DOM 都会有至少两个自定义的属性:__reactProps$uqibbgdk1tp__reactFiber$uqibbgdk1tp。这两个属性名称 $ 后面的是一串随机字符串
  • appendAllChildren 中,调用 parent.appendChild(child) 将 fiber 的 child(对应的真实 dom) 添加到当前的 DOM 上。
  • finalizeInitialChildren 方法中,给真实的 DOM 设置属性,比如 style,id 等。

这里有一点需要注意,appendAllChildren 要区分两个场景:单一节点以及多节点。

我们知道在 beginWork:updateHostComponent 中,如果 HostComponent 只有一个新的子节点并且是字符串或者数字,那么则不会为新的子节点创建对应的 fiber 节点,比如:

// 单一节点情况,div只有一个新的子节点,并且是字符串,因此在 beginWork 阶段不会为这个新的子节点创建对应的 fiber 节点,从而不会走appendAllChildren的逻辑。而是在finalizeInitialChildren函数中设置 div 的textContent
<div>只有一个子元素并且是字符串或者数字</div>
// 多节点情况。假设此时的 count 为数字0,那么对于 div 这个fiber节点,在 reconcile 阶段,会认为它有两个新的子节点:
// 一个是 "接收父组件的props:",一个是 0。React 会为这两个文本节点创建对应的 fiber 节点,fiber.tag 都是
// HostText。然后在 completeUnitOfWork 阶段,针对 HostText 类型的fiber节点,React会调用 document.createTextNode(text) 创建文本DOM节点。在 div 的 completeUnitOfWork 中,就会走 appendAllChildren 的逻辑,将这些文本DOM添加到div中。
<div>接收父组件的props:{props.count}</div>

HostComponent 第一次渲染的逻辑主要集中在 createInstanceappendAllChildrenfinalizeInitialChildren 三个函数中。从这个过程也可以看出,是有对真实的 dom 进行操作的。

HostComponent 更新阶段,主要逻辑在 updateHostComponent 函数中:

  • 调用 prepareUpdate 比较 oldProps 和 newProps 的差异。如果属性发生了变更,则将变更的属性的键值对存入数组 updatePayload 中。
  • updatePayload 复制给 workInProgress.updateQueue
  • 这里有一个特殊场景,如果只是合成事件变了,比如 onClick 变了,其他属性没有变化,React 在 diffProperties 时会特意将 updatePayload 赋值一个空数组。方便在 commit 阶段重新挂载 __reactProps$ 属性
4.4.2 HostText completeUnitOfWork

类似于 HostComponentHostText 也需要区分第一次渲染以及更新阶段。

在第一次渲染阶段,只需要直接调用 document.createTextNode(text) 创建文本 DOM 节点,初次之外没有其他操作。

在更新阶段,判断是否需要更新 fiber.flags,除此之外没有其他操作。

4.4.3 HostRoot completeUnitOfWork

updateHostContainer 方法其实就是一个空函数,HostRoot 在这个过程几乎没有操作。当执行到这里的时候,render 阶段已经完成,进入 commit 阶段。

注意,在初次渲染的过程中,React 不需要追踪副作用,同时在 render 阶段就操作真实的 DOM!!!!!!。当 HostRootcompleteUnitOfWork 执行完成时,我们实际上已经得到一棵真实的 DOM 树,存储在内存中,还没挂载到容器 root 上

五、commit 阶段

render 阶段完成后,我们得到一个副作用链表,以及一棵 finishedWork 树。

commit 阶段从 commitRoot 函数开始。主要逻辑在 commitRootImpl 函数中

5.1 commitRootImpl

commit 阶段分成三个子阶段:

  • 第一阶段:commitBeforeMutationEffects。DOM 变更前

    • 调用 类组件的 getSnapshotBeforeUpdate 生命周期方法
    • 启动一个微任务以刷新 passive effects 异步队列。passive effects 异步队列存的是 useEffect 的清除函数以及监听函数
  • 第二阶段:commitMutationEffects。DOM 变更,操作真实的 DOM 节点。注意这个阶段是 卸载 相关的生命周期方法执行时机

    • 操作真实的 DOM 节点:增删改查
    • 同步调用函数组件 useLayoutEffect清除函数
    • 同步调用类组件的 componentWillUnmount 生命周期方法
    • 将函数组件的 useEffect清除函数 添加进异步队列,异步执行。
    • 所有的函数组件的 useLayoutEffect 的清除函数都在这个阶段执行完成
  • 第三阶段:commitLayoutEffects。DOM 变更后

    • 调用函数组件的 useLayoutEffect 监听函数,同步执行
    • 将函数组件的 useEffect 监听函数放入异步队列,异步执行
    • 执行类组件的 componentDidMount 生命周期方法,同步执行
    • 执行类组件的 componentDidUpdate 生命周期方法,同步执行
    • 执行类组件 this.setState(arg, callback) 中的 callback 回调,同步执行

每一个子阶段都是一个 while 循环,从头开始遍历副作用链表。

function commitRootImpl(root, renderPriorityLevel) {
  const finishedWork = root.finishedWork;
  root.finishedWork = null;
  let firstEffect;
  // 在开始前,需要将 HostRootFiber 的副作用追加在副作用链表末尾。
  if (finishedWork.flags > PerformedWork) {
    if (finishedWork.lastEffect !== null) {
      finishedWork.lastEffect.nextEffect = finishedWork;
      firstEffect = finishedWork.firstEffect;
    } else {
      firstEffect = finishedWork;
    }
  } else {
    firstEffect = finishedWork.firstEffect;
  }
  // firstEffect不为空,说明存在副作用链表,此时firstEffect指向链表的表头
  if (firstEffect !== null) {
    // commie阶段被划分成多个小阶段。每个阶段都从头开始遍历整个副作用链表
    nextEffect = firstEffect;
    // 第一个阶段,调用getSnapshotBeforeUpdate等生命周期方法
    commitBeforeMutationEffects();
    nextEffect = firstEffect; // 重置 nextEffect,从头开始
    commitMutationEffects(root, renderPriorityLevel);
    root.current = finishedWork;
    nextEffect = firstEffect; // 重置 nextEffect,从头开始
    commitLayoutEffects(root, lanes);
  }
}

5.2 commitBeforeMutationEffects

这个函数主要是在 DOM 变更前执行,主要逻辑如下:

  • 调用 类组件的 getSnapshotBeforeUpdate 生命周期方法
  • 启动一个微任务以刷新 passive effects。passive effects 指的是 useEffect 的清除函数以及监听函数
function commitBeforeMutationEffects() {
  while (nextEffect !== null) {
    const current = nextEffect.alternate;
    const flags = nextEffect.flags;
    if ((flags & Snapshot) !== NoFlags) {
      commitBeforeMutationLifeCycles(current, nextEffect);
    }
    if ((flags & Passive) !== NoFlags) {
      setTimeout(() => {
        flushPassiveEffects();
      }, 0);
    }
    nextEffect = nextEffect.nextEffect;
  }
}
function commitBeforeMutationLifeCycles(current, finishedWork) {
  switch (finishedWork.tag) {
    case FunctionComponent: {
      return;
    }
    case ClassComponent: {
      instance.getSnapshotBeforeUpdate(prevProps, prevState);
      return;
    }
    case HostRoot:
      if (finishedWork.flags & Snapshot) {
        var root = finishedWork.stateNode;
        clearContainer(root.containerInfo); // 页面第一次渲染时,清空root的textContent:root.textContent = '';
      }
      return;
    case HostComponent:
    case HostText:
      return;
  }
}

5.3 commitMutationEffects

这个函数操作 DOM,主要有三个方法:

  • commitPlacement。调用 parentNode.appendChild(child); 或者 container.insertBefore(child, beforeChild) 插入 DOM 节点
  • commitWork。
    • 对于 HostText 节点,直接更新 nodeValue
    • 对于类组件,什么都不做
    • 同步调用函数组件 useLayoutEffect清除函数,这个函数对于类组件没有任何操作
  • commitDeletion。主要是删除 DOM 节点,以及调用当前节点以及子节点所有的 卸载 相关的生命周期方法
    • 同步调用函数组件的 useLayoutEffect清除函数,这是同步执行的
    • 将函数组件的 useEffect清除函数 添加进异步刷新队列,这是异步执行的
    • 同步调用类组件的 componentWillUnmount 生命周期方法
function commitMutationEffects(root, renderPriorityLevel) {
  while (nextEffect !== null) {
    // 插入,更新,删除 DOM 节点
    switch (primaryFlags) {
      case PlacementAndUpdate: {
        // 插入
        commitPlacement(nextEffect);
        commitWork(_current, nextEffect);
        break;
      }
      case Deletion: {
        // 删除
        commitDeletion(root, nextEffect);
        break;
      }
    }
    nextEffect = nextEffect.nextEffect;
  }
}
function commitPlacement(finishedWork) {
  if (isContainer) {
    insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
  } else {
    insertOrAppendPlacementNode(finishedWork, before, parent);
  }
}

function insertOrAppendPlacementNodeIntoContainer(node, before, parent) {
  if (before) {
    insertInContainerBefore(parent, stateNode, before);
  } else {
    appendChildToContainer(parent, stateNode);
  }
}
function commitWork(current, finishedWork) {
  switch (finishedWork.tag) {
    case FunctionComponent: {
      // 调用函数组件的清除函数
      commitHookEffectListUnmount(Layout | HasEffect, finishedWork);
      return;
    }
    case ClassComponent:
      // 可以看到 类组件 在这里是不执行任何操作的
      return;
  }
}
// 执行函数组件的 useLayoutEffect 监听函数的回调,即清除函数
function commitHookEffectListUnmount(tag, finishedWork) {
  do {
    // Unmount
    var destroy = effect.destroy;
    effect.destroy = undefined;
    destroy(); // 执行 useLayoutEffect 的清除函数
    effect = effect.next;
  } while (effect !== firstEffect);
}
function commitDeletion(finishedRoot, current, renderPriorityLevel) {
  // 调用所有子节点的 componentWillUnmount() 方法
  unmountHostComponents(finishedRoot, current);
}
function unmountHostComponents(finishedRoot, current, renderPriorityLevel) {
  while (true) {
    commitUnmount(finishedRoot, node);
  }
}
function commitUnmount(finishedRoot, current, renderPriorityLevel) {
  switch (current.tag) {
    case FunctionComponent: {
      do {
        if (effect  useEffect) {
          // 将 useEffect 的清除函数添加进异步刷新队列,useEffect 的清除函数是异步执行的
          enqueuePendingPassiveHookEffectUnmount(current, effect);
        } else {
          // 调用 useLayoutEffect 的清除函数,同步执行的
          // 其实就是直接调用destroy();
          safelyCallDestroy(current, destroy);
        }
        effect = effect.next;
      } while (effect !== firstEffect);
      return;
    }
    case ClassComponent: {
      // 直接调用类组件的 componentWillUnmount() 生命周期方法,同步执行
      safelyCallComponentWillUnmount(current, instance);
      return;
    }
  }
}

5.4 commitLayoutEffects

当执行到这个函数,此时 useLayoutEffect 的清除函数已经全部执行完成。

  • 对于 HostComponent。判断是否需要聚焦
  • 对于 HostText。什么都不做。
  • 对于类组件。
    • 执行类组件的 componentDidMount 生命周期方法,同步执行
    • 执行类组件的 componentDidUpdate 生命周期方法,同步执行
    • 执行类组件 this.setState(arg, callback) 中的 callback 回调,同步执行
  • 调用函数组件的 useLayoutEffect 监听函数,同步执行
  • 将函数组件的 useEffect 监听函数放入异步队列,异步执行
function commitLayoutEffects(root, committedLanes) {
  // 此时所有的 `useLayoutEffect` 的清除函数已经执行完成,在commitMutationEffects阶段执行的
  while (nextEffect !== null) {
    commitLifeCycles(root, current, nextEffect);
    nextEffect = nextEffect.nextEffect;
  }
}
function commitLifeCycles(finishedRoot, current, finishedWork, committedLanes) {
  switch (finishedWork.tag) {
    case FunctionComponent: {
      // 同步执行 useLayoutEffect 的监听函数
      commitHookEffectListMount(Layout | HasEffect, finishedWork);
      // 将 useEffect 的监听函数放入异步队列等待执行
      schedulePassiveEffects(finishedWork);
      return;
    }
    case ClassComponent: {
      // 第一次挂载的时候执行类组件的componentDidMount生命周期方法
      instance.componentDidMount();
      // 组件更新的时候执行类组件的 componentDidUpdate 生命周期方法
      instance.componentDidUpdate(prevProps, prevState, snapshotBeforeUpdate);
      // 调用类组件 this.setState(arg, callback) 的callback回调
      commitUpdateQueue(finishedWork, updateQueue, instance);
      return;
    }
  }
}

// 执行useLayoutEffect监听函数
function commitHookEffectListMount(tag, finishedWork) {
  do {
    if ((effect.tag & tag) === tag) {
      // Mount
      var create = effect.create;
      effect.destroy = create();
    }
    effect = effect.next;
  } while (effect !== firstEffect);
}

React批量(异步)更新以及同步更新原理

React 更新过程相关的代码都在 ReactFiberWorkLoop.js 文件中

批处理(异步更新)机制简述

React 源码中,通过全局变量 executionContext 控制 React 执行上下文,指示 React 开启同步或者异步更新。executionContext 一开始被初始化为 NoContext,因此 React 默认是同步更新的。

当我们在合成事件中调用 setState 时:

const onBtnClick = () => {
  debugger;
  setCount(1);
  setCount(2);
};
<button onClick={onBtnClick}>{count}</button>;

实际上合成事件会调用 batchedEventUpdates(onBtnClick),将我们的函数 onBtnClick 拦截一层。batchedEventUpdates 实现如下:

function batchedUpdates(fn, a) {
  var prevExecutionContext = executionContext; // 保存原来的值
  executionContext |= EventContext;
  try {
    return fn(a); // 调用我们的合成事件逻辑onBtnClick
  } finally {
    executionContext = prevExecutionContext; // 函数执行完成恢复成原来的值

    if (executionContext === NoContext) {
      // Flush the immediate callbacks that were scheduled during this batch
      resetRenderTimer();
      flushSyncCallbackQueue();
    }
  }
}

const batchedEventUpdates = batchedUpdates;

可以看出该方法在执行时会更改 executionContext 指示 React 异步更新。这也是为什么我们在合成事件中多次调用 setState,而 React 只会更新一次的原因。函数执行完成,executionContext 又会恢复成原来的值。如果我们的 setState 逻辑是在 setTimeout 中,当合成事件执行完毕,此时 executionContext 恢复成原来的值, setTimeout 中的 setState 就变成了同步更新

React17 版本中提供了一个 unstable_batchedUpdates API,如果我们希望在 setTimeout 等异步任务中开启批量更新,则可以使用这个方法包裹一下我们的业务代码。

exports.unstable_batchedUpdates = batchedUpdates;

更新队列 syncQueue

React 使用 syncQueue 维护一个更新队列。syncQueue 数组存的是 performSyncWorkOnRootperformSyncWorkOnRoot 这个方法从根节点开始更新

function scheduleSyncCallback(callback) {
  if (syncQueue === null) {
    syncQueue = [callback];

    // 开始调度,其实这部分逻辑相当于queueMicrotask(flushSyncCallbackQueueImpl),让更新在
    // 下一个微任务中执行
    immediateQueueCallbackNode = Scheduler_scheduleCallback(
      Scheduler_ImmediatePriority,
      flushSyncCallbackQueueImpl
    );
  } else {
    // 注意这里不需要再开启一个新的微任务!!
    syncQueue.push(callback);
  }

  return fakeCallbackNode;
}
// flushSyncCallbackQueueImpl简单实现如下:
function flushSyncCallbackQueueImpl() {
  syncQueue.forEach((cb) => cb());
  syncQueue = null;
}

scheduleSyncCallback 函数中如果 syncQueuenull,则初始化一个数组,开启一个微任务调度。而如果 syncQueue 不为 null,则添加进更新队列,此时不需要再重新开启一个微任务调度

如果 executionContext === NoContext 则直接刷新 syncQueue

function scheduleUpdateOnFiber(fiber, lane, eventTime) {
  // 省略前面的代码
  if (executionContext === NoContext) {
    resetRenderTimer();
    flushSyncCallbackQueue();
  }
  // 省略后面的代码
}

批量更新场景

在合成事件等 React 能够接管的场景中,setState批量更新的。点击按钮,查看控制台可以发现只打印了一次:

render====== 2

const Counter = () => {
  const [count, setCount] = useReducer(reducer, 0);
  console.log("render======", count);
  return (
    <button
      onClick={() => {
        debugger;
        setCount(1);
        setCount(2);
      }}
    >
      {count}
    </button>
  );
};

同步更新场景

setTimeoutPromise回调异步任务 场景中,setState同步更新的。点击按钮,查看控制台可以发现打印了两句话:

render====== 1

render====== 2

const Counter = () => {
  const [count, setCount] = useReducer(reducer, 0);
  console.log("render======", count);
  return (
    <button
      onClick={() => {
        setTimeout(() => {
          debugger;
          setCount(1);
          setCount(2);
        }, 0);
      }}
    >
      {count}
    </button>
  );
};

批量更新机制主流程源码

onClick 函数里加一行 debugger。点击按钮,开始 debug。首先执行的是 dispatchAction 函数,但是如果我们追溯函数调用栈,可以发现实际上是会先执行合成事件相关的函数:

image

合成事件调用了 batchedEventUpdates,此时 executionContext 已经被设置为批量更新

image

回到 dispatchAction 方法中,这个方法主要是构造更新队列,然后调用 scheduleUpdateOnFiber 开始调度更新,异步 or 同步更新的逻辑主要在这个函数的流程中!!scheduleUpdateOnFiber 主要流程如下:

const SyncLane = 1;
const SyncLanePriority = 15;
const NoContext = 0;
let executionContext = NoContext;
let syncQueue = [];
const scheduleUpdateOnFiber = (fiber, lane, eventTime) => {
  const root = markUpdateLaneFromFiberToRoot(fiber);
  if (lane === SyncLane) {
    // 开始创建一个任务,从根节点开始进行更新
    ensureRootIsScheduled(root);
    // 如果当前的executionContext执行上下文环境是NoContext(非批量)
    if (executionContext === NoContext) {
      // 需要注意,我们在ensureRootIsScheduled函数中,将flushSyncCallbackQueue放在了微任务中去执行,
      // 但是如果executionContext是同步更新的话,这里会直接调用flushSyncCallbackQueue开始更新任务,更新完成后
      // flushSyncCallbackQueue会清空syncQueue

      flushSyncCallbackQueue();
    }
  }
};
function ensureRootIsScheduled(root) {
  const newCallbackPriority = returnNextLanesPriority();
  const existingCallbackPriority = root.callbackPriority;

  if (existingCallbackPriority === newCallbackPriority) {
    // The priority hasn't changed. We can reuse the)
    return;
  }

  if (newCallbackPriority === SyncLanePriority) {
    newCallbackNode = scheduleSyncCallback(
      performSyncWorkOnRoot.bind(null, root)
    );
  }
  root.callbackPriority = newCallbackPriority;
}

// 其实就是把performSyncWorkOnRoot函数添加到队列里,在下一个微任务里面执行
function scheduleSyncCallback(callback) {
  if (syncQueue === null) {
    syncQueue = [callback]; // Flush the queue in the next tick, at the earliest.

    immediateQueueCallbackNode = Scheduler_scheduleCallback(
      Scheduler_ImmediatePriority,
      flushSyncCallbackQueue
    );
  } else {
    syncQueue.push(callback);
  }
}
// flushSyncCallbackQueue简单实现如下:
function flushSyncCallbackQueue() {
  syncQueue.forEach((cb) => cb());
  syncQueue = null;
}

performSyncWorkOnRoot 从根节点开始更新,这个不属于本节内容。

当我们点击按钮,从合成事件派发到 React 从当前 fiber 节点开始调度更新,并且决定是异步或者同步更新的主要流程如下图:

image

【React Scheduler源码第三篇】React Scheduler原理及手写源码

欢迎关注我的Github一起学习前端各种框架的源码。掘金的文章只是仓库中的一部分,如果对源码感兴趣,可以直接关注github,github里面的文章是最新的

本章是手写 React Scheduler 异步任务调度源码系列的第三篇文章,前两篇可以点击下面链接查看:1.哪些 API 适合用于任务调度。2.scheduler 用法详解。来看看为啥采用 MessageChannel 而不是 setTimeout 等 api 实现异步任务调度。任务切片,时间切片这些概念听着吓人,但原理其实很简单。实际上这篇文章不需要 react 背景即可看懂,给我们提供了一种解决耗时长的任务的思路。

学习目标

  • 同步更新 & 异步更新
  • 为什么不使用 setTimeout
  • 为什么使用 Message Channel
  • 任务切片
  • 时间切片

前置基础知识

如果对 requestAnimationFramerequestIdleCallbacksetTimeoutMessageChannelMutationObserverPromise等 API 还不熟悉的,可以先看这篇文章熟悉一下。如果对 React Scheduler 用法还不熟悉的,可以先看这篇文章熟悉一下。当然,不看也不影响理解本章的内容

故事从一个动画开始

这天,老板让小李开发一个放大缩小的无限循环的动画。这是老板的一句话需求,没有 UI 也没有需求文档。那既然是一句话需求,小李也就三两句代码就实现了:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>schedule源码</title>
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
    />
    <style>
      #animation {
        display: flex;
        align-items: center;
        justify-content: center;
        width: 100px;
        height: 100px;
        background: red;
        animation: myfirst 5s;
        animation-iteration-count: infinite;
      }

      @keyframes myfirst {
        from {
          width: 30px;
          height: 30px;
          border-radius: 0;
          background: red;
        }
        to {
          width: 300px;
          height: 300px;
          border-radius: 50%;
          background: yellow;
        }
      }
    </style>
  </head>

  <body>
    <button id="btn">perform work</button>
    <div id="animation">Animation</div>
    <script>
      const btn = document.getElementById("btn");
      const animate = document.getElementById("animation");
    </script>
  </body>
</html>

呐,老板,我实现了,效果如下,小李开心的说。

schedule-01.jpg

老板看了看,摇了摇头,这是啥玩意啊

同步更新页面

老板说了,他有一组任务,点击按钮的时候,需要遍历执行完这组任务,统计全部任务执行完成的耗时,然后更新到页面。每个任务执行耗时差不多 2ms,如下:

let works = [];
for (let i = 0; i < 3000; i++) {
  works.push(() => {
    const start = new Date().getTime();
    while (new Date().getTime() - start < 2) {}
  });
}

小李看了看,老板的需求总是这么简单,不到 2 秒,小李已经实现了如下:

btn.onclick = () => {
  const startTime = new Date().getTime();
  flushWork();
  const endTime = new Date().getTime();
  animate.innerHTML = endTime - startTime;
};

function flushWork() {
  works.forEach((w) => w());
}

小李屁颠屁颠的跑过去给老板看效果:

schedule-02.jpg

老板心想小伙子能力不错,10 点钟给的需求,10:02 分就已经完成了,真是一个有(压榨)潜力的员工。于是老板满心欢喜的点了下按钮。结果,过了差不多 6 秒页面才更新,同时页面卡死了。。。再次点击按钮都点不了。老板的脸渐渐黑化,这又是啥玩意,赶紧优化一下

问题分析

失望的小李分析了下,点击按钮时,这组任务是同步执行的,所有任务执行完成,总共耗时差不多 6 秒,而在这个过程中,js 引擎一直占用着控制权,浏览器无法绘制页面,也无法响应用户,用户体验相当不好,怪不得老板的脸黑了。所以,这组耗时长的任务不应该同步执行

使用 setTimeout 异步更新页面

这次,小李打算使用异步的方式执行任务,将任务放到 setTimeout 定时器里面执行。为了不长时间占用主线程,阻塞浏览器渲染,小李将任务拆分到定时器执行,每个定时器执行一个任务。每执行一次都判断 works 是否全部执行完成,如果全部执行完成,则更新页面。每执行完一次任务,都主动将控制权让出给浏览器。这次,小李花了 10 分钟整改了下代码:

btn.onclick = () => {
  startTime = new Date().getTime();
  flushWork();
};

function flushWork() {
  setTimeout(workLoop, 0);
}

function workLoop() {
  const work = works.shift();
  if (work) {
    work();
    setTimeout(workLoop, 0);
  } else {
    const endTime = new Date().getTime();
    animate.innerHTML = endTime - startTime;
  }
}

小李这次不太敢屁颠屁颠的去找老板了,转而悄咪咪地过去。老板以为会有惊喜,立马点击按钮,这次页面动画终于不卡顿了,老板似乎看到了希望,嘴角微微上扬,然而等了差不多 19 秒的时间,页面才更新。这又是啥玩意啊,老板突然歇斯底里。

schedule-03.jpg

小李确实大意了,在上一次的时候,任务执行总耗时才 6000 毫秒,每个任务执行耗时 2 毫秒,3000 个任务,最多也就 6000 毫秒,为啥这次执行耗时 19266 毫秒,远比之前多出了 13266 毫秒?

小李看了下 Performance。虽然使用了setTimeout(workLoop, 0)0 毫秒的时间间隔,但是浏览器依然会有 4 到 5 毫秒的间隔时间。如果两次 setTimeout 之间最少间隔 4 毫秒,都有至少 3000 * 4 = 12000 毫秒的耗时了。

schedule-04.jpg

问题分析

即使setTimeout(workLoop, 0)设置了 0 毫秒的时间间隔,但浏览器也会有至少 4 到 5 毫秒的延迟。在执行一组数量不限的任务时,这个耗时是不容忽视的。作为一个专业的前端切图仔,我们在追求页面动画流畅、不卡顿的同时,应该还要快速响应用户的输入从而快速更新页面。显然,setTimeout 由于 4 毫秒间隔的原因,不适用于我们的场景。那还有哪些 API 既可以出发宏任务事件,两次宏任务之间间隔有非常短呢?小李想起了在哪些 API 适用于任务调度一文中学到的知识,MessageChannel在一帧内的调用频率超高,且两次调用的时间间隔极短。于是小李决定尝试一下这个 API

不使用 Promise 或者 MutationObserver 等微任务 API 的原因是,微任务是在页面更新前全部执行完成的,效果和同步执行任务差不多。

使用 MessageChannel 异步更新页面

这次,小李使用 MessageChannel 触发一个宏任务,在宏任务事件中执行工作。每执行完一个工作,判断是否已经执行完全部的工作,如果是,则更新页面,否则调用port.postMessage(null)触发下一个宏任务,继续执行剩余的工作。

var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = workLoop;

let startTime;
btn.onclick = () => {
  startTime = new Date().getTime();
  port.postMessage(null);
};

function workLoop() {
  const work = works.shift();
  if (work) {
    work();
    port.postMessage(null);
  } else {
    const endTime = new Date().getTime();
    animate.innerHTML = endTime - startTime;
  }
}

这次小李学聪明了,自测了下,效果如下,可以发现耗时只用了 6090 毫秒!!!为什么会多出了 90 毫秒?观察 performance 可以看出,虽然两次宏任务之间间隔非常短,但也会导致额外的开销,累积起来就有了几毫秒的差异。不过,这已经很贴近 6000 毫秒的执行耗时了,优势远胜于 setTimeout

schedule-05.jpg

可以看到一帧之内浏览器的绘制时间,以及 message channel 触发的次数

schedule-06.jpg

注意,这里的执行耗时也会受机器性能的影响,目前小李在多台电脑上尝试了下,一样的代码,执行耗时不太一样。当然不影响我们理解 schedule 的原理。在同一台电脑上跑,有时候耗时也不一样,比如:

schedule-07.jpg

老板终于满意了

问题分析

这次,小李能够同时兼顾页面动画流畅、不卡顿以及快速响应用户输入,尽早更新页面。但是还有一点小瑕疵,由于两次任务之间还是会有一点点的时间间隔,执行数量众多的任务时,这些间隔的时间就会累加起来,就会有几毫秒的额外开销。作为一个有追求有理想的专业切图仔,小李是不允许有这种时间消耗的

任务切片:一次宏任务事件尽可能执行更多的任务

在上一节中,额外消耗的时间等于两次宏任务之间的时间间隔 * 工作的数量:

额外消耗的时间 = 两次宏任务之间的时间间隔 * works.length;

显然,我们无法控制两次宏任务之间的时间间隔,但是我们可以减少触发宏任务事件的次数。可以通过在一次宏任务事件中执行更多的任务来达到这个目的。同时,一次宏任务事件的执行耗时又不能超过 1 帧的时间(16.6ms),毕竟我们需要留点时间给浏览器绘制页面

因此,我们需要在一次宏任务事件中尽可能多的执行任务,同时又不能长时间占用浏览器。为了达到这个目的,小李将任务拆分成几小段执行,即任务切片。既然一帧 16.6 毫秒,执行一次任务需要 2 毫秒,那只需要在一次宏任务事件中执行 7 个任务就好,这样浏览器还有 2.6 毫秒绘制页面。

var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = workLoop;

let startTime;
btn.onclick = () => {
  startTime = new Date().getTime();
  port.postMessage(null);
};
function workLoop() {
  let i = 0;
  while (i < 7) {
    let work = works.shift();
    if (work) {
      work();
      i++;
    } else {
      const endTime = new Date().getTime();
      animate.innerHTML = endTime - startTime;
      i = 7; // 没有剩余工作就直接退出循环
    }
  }
  if (works.length) {
    port.postMessage(null);
  }
}

效果如下:

schedule-08.jpg

放大每一帧可以看到:

schedule-09.jpg

问题分析

这次,小李采用任务切片的方法极大减少了触发 message channel 的次数,减少了宏任务之间调度的额外消耗。但是这里还有个问题,任务切片的一个前提是,每个任务执行耗时是确定的,比如这里是 2 毫秒,但真实的业务场景是无法知道任务的执行耗时的,因此我们很难判断该如何将任务进行切片,本例中我们采用的是 7 个任务一个片段,那如果一个任务的执行耗时不确定,我们又怎么设置这个片段的大小?可想而知,任务切片虽然理想,但不太现实

时间切片

我们来探讨一种时间切片的方式。我们知道浏览器一帧只有 16.6ms,同时我们的工作执行耗时又不是确定的。那我们是不是可以,将一次宏任务的执行时间尽可能的控制在一定的时间内,比如 5ms。在当前的宏任务事件内,我们循环执行我们的工作任务,每完成一个工作任务,都判断执行时间是否超出了 5 毫秒,如果超出了 5 毫秒,则不继续执行下一个工作任务,结束本轮宏任务事件,主动让出控制权给浏览器绘制页面。如果没有超过 5 毫秒,则继续执行下一个工作任务。

实现如下:

let works = [];
for (let i = 0; i < 3000; i++) {
  works.push(() => {
    const start = new Date().getTime();
    while (new Date().getTime() - start < 2) {}
  });
}
const btn = document.getElementById("btn");
const animate = document.getElementById("animation");

var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = workLoop;

let endTime;
let startTime;
btn.onclick = () => {
  startTime = new Date().getTime();
  port.postMessage(null);
};
const yieldInterval = 5; // 单位毫秒
function workLoop() {
  const currentEventStartTime = new Date().getTime();
  let work = works.shift();
  while (work) {
    work();
    // 执行完当前工作,则判断时间是否超过5ms,如果超过,则退出while循环
    if (new Date().getTime() - currentEventStartTime > yieldInterval) {
      // 执行耗时超过了5ms,结束本轮事件,主动让出控制权给浏览器绘制页面或者执行其他操作
      break;
    }
    work = works.shift();
  }
  // 如果还有剩余的工作,则放到下一个事件中处理
  if (works.length) {
    port.postMessage(null);
  } else {
    const endTime = new Date().getTime();
    animate.innerHTML = endTime - startTime;
  }
}

效果如下:

schedule-10.jpg

放大每一帧可以看到,每一个宏任务事件执行时间大约 5-6ms。

schedule-11.jpg

问题分析

这次,我们采用时间切片的方式,每个宏任务事件最多执行 5ms,超过 5ms 则主动结束执行,让出控制权给浏览器。时间切片的好处就是我们不用关心每个任务的执行耗时。比如,这里我用随机的方法,让每个工作任务执行耗时在 0-1 毫秒之间。

let works = [];
for (let i = 0; i < 3000; i++) {
  works.push(() => {
    const start = performance.now();
    const time = Math.random();
    while (performance.now() - start < time) {}
  });
}

效果如下:

schedule-12.jpg

放大每一帧可以看到:

schedule-13.jpg

至此,似乎我们的目标已经达成:在尽可能短的时间内完成耗时长的一组工作任务,同时又不会长时间占用浏览器,让浏览器处理高优先级的任务,比如响应用户输入、绘制页面等

小结

到目前为止,效果还是很不错的。小李收获了以下知识:

  • 耗时长的同步任务会长时间占用浏览器导致无法响应用户输入,页面卡顿等问题
  • setTimeout 由于有至少 4 毫秒的延迟,因此不适合用于异步任务的调度
  • MessageChannel 在一帧的时间内调用频率超高,两次 message channel 宏任务事件之间的间隔开销极少,适合用于异步任务的调度。
  • 由于无法提前得知任务执行时间,从而无法计算一帧之内应该执行几个任务,所以任务切片不太适用于一帧内调度异步任务。
  • 时间切片是比较理想的选择

小李决定将这个小工具开源

开源第一步

首先需要将 Message Channel 抽成一个公用的调度方法

const yieldInterval = 5;
let deadline = 0;
const channel = new MessageChannel();
let port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
function performWorkUntilDeadline() {
  if (scheduledHostCallback) {
    // 当前宏任务事件开始执行
    let currentTime = new Date().getTime();
    // 计算当前宏任务事件结束时间
    deadline = currentTime + yieldInterval;
    const hasMoreWork = scheduledHostCallback(currentTime);
    if (!hasMoreWork) {
      scheduledHostCallback = null;
    } else {
      // 如果还有工作,则触发下一个宏任务事件
      port.postMessage(null);
    }
  }
}
function requestHostCallback(callback) {
  scheduledHostCallback = callback;
  port.postMessage(null);
}

我们通过 requestHostCallback 触发一个 message channel 事件,同时在 performWorkUntilDeadline 接收事件,这里需要注意,我们必须在 performWorkUntilDeadline 开始时获取到当前的时间 currentTime,然后计算出本次事件执行的截止时间,performWorkUntilDeadline 的执行时间控制在 5 毫秒内,因此截止时间就是 deadline = currentTime + yieldInterval;

如果 scheduledHostCallback 返回 true,说明还有剩余的工作没完成,则调度下一个宏任务事件执行剩余的工作。

其次,我们需要一个 scheduleCallback 方法给用户添加任务,我们将用户添加的任务保存在 taskQueue 中。然后触发一个 message channel 事件,异步执行任务。

let taskQueue = [];
let isHostCallbackSchedule = false;
function scheduleCallback(callback) {
  var newTask = {
    callback: callback,
  };
  taskQueue.push(newTask);
  if (!isHostCallbackScheduled) {
    isHostCallbackScheduled = true;
    requestHostCallback(flushWork);
  }
  return newTask;
}

最后需要实现 flushwork 方法,在 workLoop 方法中,每执行一个工作,都需要判断当前 performWorkUntilDeadline 事件执行时间是否超过 5ms

let currentTask = null;
function flushWork(initialTime) {
  return workLoop(initialTime);
}

function workLoop(initialTime) {
  currentTask = taskQueue[0];

  while (currentTask) {
    if (new Date().getTime() >= deadline) {
      // 每执行一个任务,都需要判断当前的performWorkUntilDeadline执行时间是否超过了截止时间
      break;
    }
    var callback = currentTask.callback;
    callback();

    taskQueue.shift();
    currentTask = taskQueue[0];
  }
  if (currentTask) {
    // 如果taskQueue中还有剩余工作,则返回true
    return true;
  } else {
    return false;
  }
}

然后我们就可以这样使用:

const btn = document.getElementById("btn");
const animate = document.getElementById("animation");
let startTime;
btn.onclick = () => {
  startTime = new Date().getTime();
  for (let i = 0; i < 3000; i++) {
    if (i === 2999) {
      scheduleCallback(() => {
        const start = new Date().getTime();
        while (new Date().getTime() - start < 2) {}
        const endTime = new Date().getTime();
        animate.innerHTML = endTime - startTime;
      });
    } else {
      scheduleCallback(() => {
        const start = new Date().getTime();
        while (new Date().getTime() - start < 2) {}
      });
    }
  }
};

以上就是 schedule 的简单实现。下一篇文章会继续实现优先级、延迟任务。

setState 主流程及源码

setState 主流程及源码

本篇文章介绍以下知识点:

  • setState 主流程及源码
  • 类组件和函数组件 fiber.memoizedState 的区别
  • 类组件编译后也是一个函数,React 是如何区分函数组件和类组件的

demo

准备工作

  • constructor 中第一行添加 debugger,类组件 Counter 初始化时会调用构造函数
  • handleClick 中第一行添加 debugger,当我们点击按钮时,setState 主流程便从这里开始
import React, { Component } from "react";
import ReactDOM from "react-dom";
class Counter extends Component {
  constructor(props) {
    debugger;
    super(props);
    this.state = {
      number: 0,
    };
  }
  handleClick = (event) => {
    debugger;
    this.setState({ number: 1 });
    this.setState({ number: 2 });
  };

  render() {
    console.log("render===", this.state);
    return <button onClick={this.handleClick}>{this.state.number}</button>;
  }
}

ReactDOM.render(<Counter />, document.getElementById("root"));

React.Component

我们知道类组件一定要继承于 React.Component 或者 React.PureComponent,这两个类位于 packages/react/src/ReactBaseClasses.js 文件中,React.Component 做的事情很简单。下面一步一步 debug 一下

刷新页面,首先进入我们的构造函数断点处

image

注意这个函数调用栈的顺序,可以在每个函数都打一个断点,多看几次类组件初始化的过程

点击下一步,进入 super(props) 函数,实际上就是我们的 React.Component

image

这里有三个需要注意的地方:

  • this.updater = updater || ReactNoopUpdateQueue;。当我们调用 this.setState 时,使用的就是 this.updater.enqueueSetState。这里只是简单的将 this.updater 初始化为空的 ReactNoopUpdateQueue。实际上真正的 this.updaterreact-dom 中初始化。react-domreact-native 等对于 this.updater 的实现都不尽相同。
  • Component.prototype.isReactComponent = {}; isReactComponent 用于后续在创建 fiber 节点时判断是不是类组件。如果函数原型存在 isReactComponent 则说明是类组件
  • Component.prototype.setState 这是我们调用 this.setState 时的逻辑

image

在创建 fiber 节点时需要判断当前组件是类组件还是函数组件
image

shouldConstruct 实现如下:
image

React.Component 的简单实现如下:

const ReactNoopUpdateQueue = {
  isMounted: function (publicInstance) {
    return false;
  },
  enqueueForceUpdate: function (publicInstance, callback, callerName) {},
  enqueueReplaceState: function (
    publicInstance,
    completeState,
    callback,
    callerName
  ) {},
  enqueueSetState: function (
    publicInstance,
    partialState,
    callback,
    callerName
  ) {},
};
class Component {
  constructor(props, context, updater) {
    this.props = props;
    this.updater = updater || ReactNoopUpdateQueue;
    this.isReactComponent = {};
  }
  setState(partialState, callback) {
    this.updater.enqueueSetState(this, partialState);
  }
  forceUpdate(callback) {
    this.updater.enqueueForceUpdate(this, callback, "forceUpdate");
  }
}

实际上真正的 this.updater 的初始化在 adoptClassInstance 方法中:

image

类组件和函数组件的 fiber.memoizedState 的区别

我们在React.useReducer 原理及源码主流程章节中已经知道,函数组件对应的 fiber.memoizedState 是用来保存 hook 链表的。

在类组件中,其对应的 fiber.memoizedState 保存的是上一次更新的 this.state 的值

image

类组件的更新队列

React.useReducer 原理及源码主流程中我们知道如果多次调用 setCount,更新的队列会保存在 hook.queue 链表中

在类组件中,如果我们连续调用多次

this.setState({ number: 1 });
this.setState({ number: 2 });
this.setState({ number: 3 });

实际上更新队列保存在 fiber.updateQueue中,fiber.updateQueue.shared.pending 指向最后一个 this.setState() 生成的更新对象

image

fiber.updateQueue.sharedhook.queue 一样也是环状链表

类组件初始化流程

image

setState 主流程及源码

调用 this.setState 时,实际上调用的是 this.updater.enqueueSetState

function get(key) {
  return key._reactInternals;
}
function createUpdate(eventTime, lane) {
  var update = {
    eventTime: eventTime,
    lane: lane,
    tag: UpdateState,
    payload: null,
    callback: null,
    next: null,
  };
  return update;
}

// enqueueUpdate构造环状列表
function enqueueUpdate(fiber, update) {
  var updateQueue = fiber.updateQueue;

  if (updateQueue === null) {
    return;
  }

  var sharedQueue = updateQueue.shared;
  var pending = sharedQueue.pending;

  if (pending === null) {
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }

  sharedQueue.pending = update;
}
function requestEventTime() {
  // 任务是有优先级的,优先级高的会打断优先级低的
  // 如果低优先级任务超时了,则优先级高的不能再打断优先级低的任务
  return performance.now(); // 程序从启动到现在的时间,是用来计算任务的过期时间的
}
var classComponentUpdater = {
  isMounted: isMounted,
  enqueueSetState: function (inst, payload, callback) {
    var fiber = get(inst);
    var eventTime = requestEventTime();
    var lane = requestUpdateLane(fiber);
    var update = createUpdate(eventTime, lane);
    update.payload = payload;

    if (callback !== undefined && callback !== null) {
      update.callback = callback;
    }
    enqueueUpdate(fiber, update);
    scheduleUpdateOnFiber(fiber, lane, eventTime);
  },
};

class Component {
  constructor() {
    // 这里为了简化流程,直接初始化this.updater为classComponentUpdater
    this.updater = classComponentUpdater;
  }
  setState(partialState, callback) {
    this.updater.enqueueSetState(this, partialState);
  }
}

主流程图:

image

fiber.updateQueue 会在下一个微任务中在 processUpdateQueue 函数中处理

Fiber 内部:React 中新的协调算法的深入概述

Fiber 内部:React 中新的协调算法的深入概述

深入了解 React 的 Fiber 新架构,并了解新协调算法的两个主要阶段。我们将详细了解 React 如何更新 state 和 props 以及处理子节点

React 是一个用于构建用户界面的 JavaScript 库。其核心机制在于跟踪组件状态变化并将更新的状态展示到屏幕。在 React 中,我们将此过程称为协调。我们调用 setState 方法,框架会检查状态(state)或属性(props)是否已更改,并重新渲染组件。

React 官方文档很好的概述了该机制:React 元素的角色、生命周期方法和 render 方法,以及应用于组件子节点的 dom diff 算法。render 方法返回的不可变的 React elements tree 通常被称为“虚拟 DOM”。该术语有助于在早期向人们解释 React,但它也引起了困惑,并且不再在 React 文档中使用。在本文中,我将统一称它为 React elements tree。

除了 React elements tree 之外,还有一个用于保存状态的内部实例(组件、DOM 节点等)树。从 16 版本开始,React 推出了该内部实例树的全新实现,对应的算法称为Fiber。要了解 Fiber 架构带来的优势,请查看 The how and why on React’s usage of linked list in Fiber.

这是本系列的第一篇文章,旨在介绍 React 的内部架构。在本文中,我想深入概述与算法相关的重要概念和数据结构。一旦我们有足够的背景知识,我们将探索用于遍历和处理fiber tree的算法和主要函数。本系列的下一篇文章将演示 React 如何使用该算法来执行初次渲染以及处理状态(state)和属性(props)更新。然后我们将继续详细介绍调度(scheduler)、子元素协调过程以及构建 effects list 的机制。

译者注:我们需要了解 React 第一次渲染是怎样的,当我们调用 setState 时,React 如何处理状态和属性的更新,然后怎么调度更新,当更新开始时,React 如何进行 DOM Diff 并构建副作用列表(effects list)

我并不是要在这里给你介绍一些非常高级的知识。我鼓励你阅读这篇文章以了解 Concurrent React 内部运作的底层原理。如果你打算开始为 React 源码做贡献,本系列文章也会是一个很好的指南。我是Reverse Engineering的坚定信徒,所以会有很多指向最近的 16.6.0 版本源码的链接。

译者注:Reverse Engineering 指不满足于使用某一工具,然后通过阅读源码去了解其内部原理,作者将此称为 reverse engineering(逆向工程)

这里肯定会有相当多的知识需要吸收,所以如果你没有马上理解一些东西,不要感到压力。一切都值得花时间。当然,你无需了解本篇文章的内容也是可以使用 React 的。这篇文章是介绍 React 内部工作原理的。

译者小结:本节主要对这篇文章的内容进行一个概述,本篇文章主要是介绍新的协调算法的两个主要阶段: render 和 commit。在这两个阶段中,生命周期方法是如何调用的。作者也简单介绍了下一篇文章将会介绍初次渲染以及更新,调度,协调过程,构建 effect list 的机制等

前置知识

这是一个简单的应用程序,我将在整个系列中使用它。我们有一个按钮,可以简单地增加屏幕上呈现的数字:

image

这是实现:

class ClickCounter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    this.setState((state) => {
      return { count: state.count + 1 };
    });
  }

  render() {
    return [
      <button key="1" onClick={this.handleClick}>
        Update counter
      </button>,
      <span key="2">{this.state.count}</span>,
    ];
  }
}

这是一个简单的组件,render 方法返回两个子元素:button 和 span 元素。当我们点击按钮时,组件的状态就会更新。这会导致 span 元素的文本更新。

React 在协调期间执行了各种活动。例如,在我们的简单应用程序中,以下是 React 在第一次渲染期间和状态更新后执行的操作

  • 更新 ClickCounter 组件状态中的 count 属性
  • 检索并比较 ClickCounter 的子元素以及他们的属性(props)
  • 更新 span 元素的属性

在协调过程中还会执行其他活动,例如调用生命周期方法或更新 refs。所有这些活动在 Fiber 架构中统称为“工作”。工作的类型通常取决于 React 元素的类型。例如,对于一个类组件,React 需要创建一个实例,而对于一个函数式组件它不会这样做。如你所知,React 中有多种元素,例如类组件和函数组件、宿主(host)组件(DOM 节点)、portals 组件等。React 元素的类型由 createElement 函数的第一个参数定义。这个函数一般用于在 render 方法中创建一个元素。

在我们开始探索这些活动以及主要的 Fiber 算法之前,让我们先熟悉一下 React 内部使用的数据结构。

译者小结:本节主要是需要理解什么是 “工作”,“工作” 的类型是什么。以及都有哪些“工作”

从 React Elements 到 Fiber 节点

React 中的每个组件都有一个从 render 方法返回的 UI 表示,我们可以称为视图或模板。这是我们 ClickCounter 组件的模板:

<button key="1" onClick={this.onClick}>Update counter</button>
<span key="2">{this.state.count}</span>

React Elements

一旦经过 babel 编译,render 方法返回的就是一个 react elements tree,而不是 html。由于我们不是必须使用 JSX,因此我们可以使用 createElement 重写我们的 ClickCounter 组件的 render 方法。

class ClickCounter {
    ...
    render() {
        return [
            React.createElement(
                'button',
                {
                    key: '1',
                    onClick: this.onClick
                },
                'Update counter'
            ),
            React.createElement(
                'span',
                {
                    key: '2'
                },
                this.state.count
            )
        ]
    }
}

React.createElement 方法 将创建两个数据结构,如下所示:

[
    {
        $$typeof: Symbol(react.element),
        type: 'button',
        key: "1",
        props: {
            children: 'Update counter',
            onClick: () => { ... }
        }
    },
    {
        $$typeof: Symbol(react.element),
        type: 'span',
        key: "2",
        props: {
            children: 0
        }
    }
]

你可以看到 React 为这些对象都添加了一个 $$typeof 属性,用于将它们标识为 React elements。type,key 和 props 用于描述 element,这些值从 React.createElement 传递进来。注意 React 如何将文本内容表示为 span 和 button 节点的子节点。button 元素的点击事件也添加到 props 属性中。React 元素上还有其他字段不在本篇文章讨论范围,例如 ref。

ClickCounter 元素没有任何属性或者 key

{
    $$typeof: Symbol(react.element),
    key: null,
    props: {},
    ref: null,
    type: ClickCounter
}

Fiber 节点

在协调过程中,render 方法返回的每个 React 元素的数据都被合并到 Fiber tree 中。每个 React 元素都有一个对应的 Fiber 节点。与 React 元素不同,Fiber 不会在每次渲染时重新创建。Fiber 是保存组件状态和 DOM 的可变数据结构。

我们之前讨论过,根据 React 元素的类型,框架需要执行不同的活动。在我们的示例应用程序中,对于类组件 ClickCounter,它调用生命周期方法和 render 方法,而对于 span 宿主组件(DOM 节点),它执行 DOM 更新。因此,每个 React 元素都被转换为相应类型的 Fiber 节点,该节点描述了需要完成的工作。

你可以将 fiber 当作一种数据结构,它代表一些要完成的工作,或者换句话说,一个工作单元。Fiber 的架构还提供了一种方便的方式来跟踪、调度、暂停和中止工作。

当一个 React 元素第一次转换为一个 Fiber 节点时,React 在 createFiberFromTypeAndProps 函数中使用 element 中的数据创建一个 fiber。在随后的更新中,React 复用 Fiber 节点,并且仅使用对应的 react element 中的数据更新必要的属性。
React 可能需要基于 key 属性移动节点或者如果 render 方法返回的相应的 react element 已经不存在,则删除节点。

ChildReconciler函数列举了所有的 React 为 fiber 节点所执行的所有活动及其对应的函数

因为 React 为每个 React element 创建了一个 fiber,并且由于我们有一个 React element 树,那么对应的我们也会有一个 fiber 节点树。在我们的示例应用程序中,它看起来像这样:

image

所有 fiber 节点都通过 child、sibling 以及 return 属性链接成一个链表。可以阅读我这篇文章 The how and why on React’s usage of linked list in Fiber去了解为什么需要这么做。

Current and work in progress trees

在第一次渲染之后,React 最终会生成一个 fiber 树,它反映了用于渲染 UI 的应用程序的状态。这棵树通常被称为 current。当 React 开始处理更新时,它会构建一个所谓的 workInProgress 树,以反映要刷新到屏幕的最新的状态。

译者注:current tree 就是当前屏幕上显示的页面对应的 fiber tree

所有工作都在 workInProgress 树中的 fiber 节点上执行。当 React 遍历 current 树时,对于每个现有的 Fiber 节点,它都会创建一个构成 workInProgress 树的 alternate (备用)节点。alternate 节点是使用 render 方法返回的 React element 中的数据创建的。处理完更新并完成所有相关工作后,React 将准备好 alternate 树以更新到屏幕上。一旦 workInProgress 树在屏幕上呈现,它就变成了 current 树。

React 的核心原则之一是一致性。React 总是一次性更新 DOM——它不会显示部分结果。workInProgress 树充当用户不可见的“草稿”,因此 React 可以首先处理所有组件,然后将它们的更改刷新到屏幕上。

译者注:更新 DOM 是同步的,根据 render 方法返回的 react element tree 构建 workInProgress tree 这个过程是可以批量,并且可打断的。

在源代码中,你会看到很多函数都使用了 current 和 workInProgress tree 中的 fiber 节点。这是其中一个函数的签名:

function updateHostComponent(current, workInProgress, renderExpirationTime) {...}

每个 fiber 节点都有一个 alternate 字段引用旧的 fiber 树上的节点。current 树中的节点指向 workInProgress 树中的节点,反之亦然。

副作用(Side-effects)

我们可以将 React 中的组件当作使用 state 和 props 来计算 UI 表示的函数。任何活动,如改变 DOM 或调用生命周期方法都应该被视为副作用,或者简单地说,是一种效果(effect)。文档中还提到了效果(Effects):

你之前可能已经执行过数据获取、订阅或手动更改 React 组件中的 DOM。我们将这些操作称为“副作用”(或简称为“效果”),因为它们会影响其他组件并且在渲染期间无法完成。

你可以看到大多数 state 和 props 更新是怎样导致副作用。同时由于应用这些效果(effects)也是一种类型的工作,一个 fiber 节点提供了一种方便的机制去跟踪效果(effects)以及更新。每个 fiber 节点都可以有与之相关的效果。使用 effectTag 字段表示。

因此,在处理完更新后,Fiber 中的效果(effects)基本上定义了需要为实例完成的工作。对于宿主组件(DOM 元素),工作包括添加、更新或删除元素。对于类组件,React 可能需要更新 refs 并调用 componentDidMount 和 componentDidUpdate 生命周期方法。当然还有和其他类型 fiber 对应的效果。

副作用列表(Effects list)

React 处理更新非常快,为了达到这种性能水平,它采用了一些有趣的技术。其中之一是将有副作用的 fiber 节点构建成线性列表,方便快速遍历。 遍历线性列表比树快得多,并且无需在没有副作用的节点上花费时间。

此列表的目标是标记具有 DOM 更新或其他相关联的副作用的节点。此列表是 finishedWork 树的子集,并且使用 nextEffect 属性相连,而不是使用 current 或者 workInProgress 树中的 child 属性

Dan Abramov 提供了一个效果列表的类比。他喜欢把它想象成一棵圣诞树,用“圣诞灯”将所有有效的节点绑定在一起。为了直观的感受这一点,假设我们有以下 fiber 节点树,其中高亮的节点表示有一些工作要做。例如,我们的更新导致 c2 插入到 DOM 中,d2 和 c1 需要更新属性(attributes),b2 调用生命周期方法。这些有副作用的节点会连接成一个链表,这样 React 就可以跳过其他没有副作用的节点

image

你可以看到具有副作用的节点是如何链接在一起的。当遍历节点时,React 使用 firstEffect 指针来确定列表的开始位置。所以上图可以表示为这样的线性列表:

image

译者注:不管是效果列表还是副作用列表,其实都是指的 effect list。一般称为副作用列表会好点,用于指那些有副作用的 fiber 节点构成的链表

Fiber tree 的根(Root of the Fiber tree)

每个 React 应用程序都有一个或多个充当容器的 DOM 元素。在我们的例子中,它是 id 为 container 的 div 元素

const domContainer = document.querySelector("#container");
ReactDOM.render(React.createElement(ClickCounter), domContainer);

React 为每个容器创建一个 fiber root 对象。你可以使用 DOM 元素上的引用来访问它:

const fiberRoot = query("#container")._reactRootContainer._internalRoot;

这个 fiber root 是 React 保存对 fiber tree 的引用的地方。它保存在 fiber root 的 current 属性中

const hostRootFiberNode = fiberRoot.current;

fiber 树的根节点是一种特殊的类型,即 HostRoot。它是在内部创建的,并充当最顶层组件的父级。HostRoot Fiber 节点有个 stateNode 属性 指向 fiberRoot(fiberRoot 即 query("#container")._reactRootContainer._internalRoot)

fiberRoot.current.stateNode === fiberRoot; // true

你可以通过 fiber root 访问最顶层的 HostRoot fiber 节点来探索 fiber tree。或者可以从组件实例中获取单个 fiber 节点,如下所示:

compInstance._reactInternalFiber;

译者注:fiberRoot 的类型是 FiberRootNode,并不是 FiberNode 类型,因此这并不是一个 Fiber 节点。hostRootFiberNode 是 FiberNode 类型, 它是 container 容器对应的 Fiber 节点。也是整个 fiber tree 的根 fiber,因此也称为 HostRoot Fiber。

Fiber 节点结构

现在让我们看一下 ClickCounter 组件对应的 fiber 节点的结构:

{
    stateNode: new ClickCounter,
    type: ClickCounter,
    alternate: null,
    key: null,
    updateQueue: null,
    memoizedState: {count: 0},
    pendingProps: {},
    memoizedProps: {},
    tag: 1,
    effectTag: 0,
    nextEffect: null
}

span 元素对应的 fiber 节点结构:

{
    stateNode: new HTMLSpanElement,
    type: "span",
    alternate: null,
    key: "2",
    updateQueue: null,
    memoizedState: null,
    pendingProps: {children: 0},
    memoizedProps: {children: 0},
    tag: 5,
    effectTag: 0,
    nextEffect: null
}

fiber 节点有相当多的字段。在前面的章节中我已经描述了 alternate,effectTag 以及 nextEffect 的作用。现在让我们看看为什么我们需要其他字段

状态节点(stateNode)

用于保存组件的类实例、DOM 节点或与 Fiber 节点相关的其他 React 元素类型。一般来说,我们可以说这个属性用于保存与 fiber 相关的本地状态。

类型(type)

定义与 fiber 关联的函数或类。对于类组件,它指向构造函数,对于 DOM 元素,它指定 HTML 标记。我经常使用这个字段来了解 fiber 节点是什么类型的元素。

标签(tag)

定义 fiber 的类型。它在协调算法中用于确定需要完成的工作。如前所述,工作因 React 元素的类型而异。函数 createFiberFromTypeAndProps 将 React 元素映射到相应的 fiber 节点类型。在我们的应用程序中,ClickCounter 组件的 tag 属性是 1,表示这是一个 ClassComponent。span 元素的 tag 属性是 5,表示这是一个 HostComponent。

更新队列(updateQueue)

状态更新、回调和 DOM 更新的队列。

译者注:这是在类组件中使用的更新队列

memoizedState

保存 fiber 的状态。在处理更新时,它会反映当前在屏幕上呈现的状态。

译者注:在类组件中,memoizedState 用于保存状态(state),然而在函数组件中,memoizedState 用来保存 hook 链表

memoizedProps

上一次渲染期间使用的 props

pendingProps

新的 React element 中的数据更新后的 props,需要应用到子组件或者 DOM 元素上

key

一组子节点的唯一标志,用于帮助 React 确定哪些元素更改,添加,或者删除。它与此处描述的 React 的“列表和 keys”功能有关。

你可以在此处找到 fiber 节点的完整结构。我在上面的解释中省略了一堆字段。特别是,我跳过了构成树数据结构的 child,sibling 以及 return 指针。我在上一篇文章有介绍过。以及和调度有关的一类字段,比如 expirationTime,childExpirationTime 以及 mode

通用算法(General algorithm)

React 在两个主要阶段执行工作:渲染(render)和提交(commit)

在第一阶段,即 render 阶段,react 将更新应用到组件上,通过 setState 或者 React.render 调度,并找出需要在 UI 中更新的内容。如果是第一次渲染,React 会为 render 方法返回的每个元素创建一个新的 Fiber 节点。在接下来的更新中,已存在的 React 元素的 fiber 节点将被重新使用和更新。render 阶段的结果是一个标有副作用的 fiber 节点树。效果(effects)描述了在下一个阶段(commit 阶段)需要完成的工作。在 commit 阶段,React 得到一个标记有效果的 fiber 树并将它们应用于实例。它遍历效果列表并执行 DOM 更新和用户可见的其他更改。

重要的是要了解 render 阶段的工作可以异步执行。React 可以根据可用时间处理一个或多个 fiber 节点,然后暂停以响应其他事件。然后它从暂停的地方继续。但有时,它可能需要放弃已完成的工作并重新从头开始。这些暂停之所以成为可能,是因为在 render 阶段执行的工作不会导致任何用户可见的更改,例如 DOM 更新。相反,接下来的 commit 阶段总是同步的。这是因为在 commit 阶段执行的工作会导致用户可见的更改,例如 DOM 更新。这就是为什么 React 需要一次性完成它们的原因。

译者注:由于 commit 阶段执行 DOM 的变更,操作真实的 DOM,如果是可中断的,那么用户看到的界面将是不完整的,因此 commit 阶段一旦开始,就不能停止

React 执行的其中一种工作就是调用生命周期方法。有些生命周期方法在 render 阶段调用,另外一些在 commit 阶段调用。以下是在 render 阶段工作时调用的生命周期方法:

  • [UNSAFE_]componentWillMount (deprecated)
  • [UNSAFE_]componentWillReceiveProps (deprecated)
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • [UNSAFE_]componentWillUpdate (deprecated)
  • render

如您所见,从 16.3 版本开始,在 render 阶段执行的一些遗留的生命周期方法被标记为 UNSAFE。它们现在在文档中称为遗留生命周期。它们将在未来的 16.x 版本中被弃用,并且不带 UNSAFE 前缀的将在 17.0 中删除。你可以在此处阅读有关这些更改和建议的迁移路径的更多信息。

你对这其中的原因感到好奇吗?(指的是为什么需要移除这些 API)

好吧,我们刚刚了解到,由于 render 阶段不会产生像 DOM 更新那样的副作用,React 可以异步处理对组件的更新(甚至可能在多个线程中进行)。但是,标记为 UNSAFE 的生命周期方法经常被误解并误用。开发人员倾向于将具有副作用的代码放在这些方法中,在新的异步渲染方案中,这可能会出现问题。尽管只会删除不带 UNSAFE 前缀的对应方法,但它们仍然可能在即将到来的并发模式中引起问题。

译者注:在 17 版本中,React 将会移除不带 UNSAFE 前缀的具有副作用的生命周期方法,即 componentWillMount,componentWillReceiveProps 以及 componentWillUpdate,带 UNSAFE 前缀的目前不会移除,但会在将来移除,因此建议不要使用。

以下是在第二阶段,即 commit 阶段执行的生命周期方法列表:

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

因为这些方法在同步的 commit 阶段执行,它们可能包含副作用并操作 DOM。

好的,现在我们有背景来看看用于遍历树并执行工作的通用算法。让我们深入探讨。

渲染阶段(Render phase)

协调算法总是使用 renderRoot 函数从最顶层的 HostRoot fiber 节点开始。但是,React 会退出(跳过)已处理的 Fiber 节点,直到找到未完成工作的节点。例如,如果你在组件树的深处调用 setState,React 将从顶部开始但快速跳过父节点,直到到达调用了 setState 方法的组件

工作循环的主要步骤(Main steps of the work loop)

所有 fiber 节点都在工作循环中处理。这是循环的同步部分的实现:

function workLoop(isYieldy) {
  if (!isYieldy) {
    while (nextUnitOfWork !== null) {
      nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    }
  } else {...}
}

在上面的代码中,nextUnitOfWork 保存了对 workInProgress 树中 fiber 节点的引用,该节点有一些工作要做。当 React 遍历 Fibers 树时,它使用这个变量来判断是否还有其他未完成工作的 Fiber 节点。处理当前 fiber 后,变量将包含对树中下一个 fiber 节点的引用或 null。 在 null 情况下,React 退出工作循环并准备好提交更改。

有 4 个主要函数用于遍历树,以及启动或完成工作:

  • performUnitOfWork
  • beginWork
  • completeUnitOfWork
  • completeWork

为了演示如何使用它们,请查看以下遍历 fiber 树的动画。我在演示中使用了这些函数的简化实现。每个函数都需要处理一个 fiber 节点,当 React 沿着树向下移动时,你可以看到当前活动的 fiber 节点发生了变化。你可以在视频中清楚地看到算法是如何从一个分支转到另一个分支的。它首先完成了子节点的工作,然后才转移给父节点

image

请注意,垂直连接线表示兄弟节点,而弯曲的连线表示子节点,例如 b1 没有子节点,而 b2 有一个子节点 c1。

这是视频的链接,你可以在其中暂停播放并检查当前节点和函数状态。从概念上讲,你可以将“开始”视为“进入”一个组件,将“完成”视为“走出”它。当我解释这些函数的作用时,你还可以在此处使用示例和实现。

让我们从前两个函数 performUnitOfWork 和 beginWork 开始:

function performUnitOfWork(workInProgress) {
  let next = beginWork(workInProgress);
  if (next === null) {
    next = completeUnitOfWork(workInProgress);
  }
  return next;
}

function beginWork(workInProgress) {
  console.log("work performed for " + workInProgress.name);
  return workInProgress.child;
}

performUnitOfWork 函数从 workInProgress 树中接收一个 fiber 节点并通过调用 beginWork 函数开始工作。performUnitOfWork 函数将启动所有的需要为 fiber 执行的活动。出于演示的目的,我们只输出 fiber 的名称以表示工作已经完成。beginWork 函数总是返回指向下一个需要处理的子节点的指针,或者 null

如果有下一个子节点,它将在 workLoop 函数中被分配 给 nextUnitOfWork。但是,如果没有子节点,React 知道它到达了分支的末尾,因此它可以完成当前节点。一旦节点完成,它需要为兄弟节点执行工作并在此之后回溯到父节点。这是在 completeUnitOfWork 函数中完成的:

function completeUnitOfWork(workInProgress) {
  while (true) {
    let returnFiber = workInProgress.return;
    let siblingFiber = workInProgress.sibling;

    nextUnitOfWork = completeWork(workInProgress);

    if (siblingFiber !== null) {
      // If there is a sibling, return it
      // to perform work for this sibling
      return siblingFiber;
    } else if (returnFiber !== null) {
      // If there's no more work in this returnFiber,
      // continue the loop to complete the parent.
      workInProgress = returnFiber;
      continue;
    } else {
      // We've reached the root.
      return null;
    }
  }
}

function completeWork(workInProgress) {
  console.log("work completed for " + workInProgress.name);
  return null;
}

你可以看到 completeUnitOfWork 函数的要点是一个 while 循环。当一个 workInProgress 节点没有子节点时,React 会进入此函数。在完成当前 fiber 的工作后,它会检查是否有兄弟节点。如果找到,React 退出函数并返回指向兄弟节点的指针。它将被分配给 nextUnitOfWork 变量,React 将从这个兄弟节点开始执行分支的工作。重要的是要理解,此时 React 只完成了前面节点的工作。它还没有完成父节点的工作。只有从子节点开始的所有分支都完成后,它才能完成父节点和回溯的工作。

译者注:即当所有的子节点完成后,父节点才能完成并回溯

从实现中可以看出, performUnitOfWork 和 completeUnitOfWork 函数 都主要用于遍历,而主要的逻辑都在 beginWork 和 completeWork 函数中。在本系列的后续文章中,我们将了解 React 在 beginWork 和 completeWork 函数中如何处理 ClickCounter 组件以及 span 节点

提交阶段(Commit phase)

该阶段从函数 completeRoot 开始。这是 React 更新 DOM 并调用更新前及更新后(pre and post mutation)生命周期方法的地方。

当 React 进入这个阶段时,它有 2 棵树和效果列表(effects list)。第一个树代表当前在屏幕上呈现的状态。另外一棵树是在 render 阶段构建的备用树(alternate tree)。它在源代码中称为 finishedWork 或者 workInProgress,表示需要在屏幕上呈现的状态。和 current 树一样,alternate 树也是通过 child 和 sibling 指针链接在一起。

然后,有一个效果列表(effects list)——finishedWork 树的子集,通过 nextEffect 指针链接在一起。请记住,效果列表(effect list)是 render 阶段的结果渲染的重点是确定哪些节点需要插入、更新或删除,哪些组件需要调用其生命周期方法。这就是效果列表告诉我们的。它正是用于在 commit 阶段遍历的节点集

出于调试目的,current 树可以通过 fiber root 的 current 属性访问。finishedWork 树可以通过 current 树中的 HostFiber 节点的 alternate 属性访问

在 commit 阶段运行的主要函数是 commitRoot。基本上,它执行以下操作:

  • 在标记有 Snapshot 效果(effect)的节点上调用 getSnapshotBeforeUpdate 生命周期方法
  • 在标记有 Deletion 效果(effect)的节点上调用 componentWillUnmount 生命周期方法
  • 执行所有的 DOM 插入、更新和删除操作
  • 将 finishedWork 树设置为 current tree
  • 在标记有 Placement 效果(effect)的节点上调用 componentDidMount 生命周期方法
  • 在标记有 Update 效果(effect)的节点上调用 componentDidUpdate 生命周期方法

在更新前(pre-mutation)调用 getSnapshotBeforeUpdate 方法之后 ,React 会在树中 commit 所有副作用(side-effects)。它分两部分完成。第一部分执行所有 DOM(host)插入、更新、删除和卸载 ref。然后 React 将 finishedWork 树分配给 FiberRoot,将 workInProgress 树标记为 current 树。这是在 commit 阶段的第一部分之后完成的,因此前一棵树(previous tree)在 componentWillUnmount 期间仍然是当前的,但在第二遍之前,在 componentDidMount/Update 期间,finishedWork 树已经被设置为当前的 current tree。在第二部分中,React 调用所有其他生命周期方法和 ref 回调。这些方法作为单独的部分执行,至此整个树中的所有替换、更新和删除都已被调用。

译者注:这里有点拗口。在执行 commit 阶段的第一部分前,当前的有两棵树,一颗 current 树,一棵 finishedWork 树。在我们调用组件的 componentWillUnmount 方法期间,current 树此时还没改变。但是 commit 阶段第一部分执行完成后,finishedWork 树就变成了 current 树,因此在我们调用组件的 componentDidMount/Update 期间,此时的 current 树就已经被设置为 finishedWork 树,具体可以看下面函数的要点加以理解

下面是运行上述步骤的函数的要点:

function commitRoot(root, finishedWork) {
  commitBeforeMutationLifecycles();
  commitAllHostEffects();
  root.current = finishedWork;
  commitAllLifeCycles();
}

译者注:注意 root.current = finishedWork;的时机

每一个子函数都实现了一个循环,遍历效果列表(the list of effects)并检查效果(effects)的类型(type)。当它找到与函数功能匹配的效果(effect)时,它会应用它。

更新前的生命周期方法(Pre-mutation lifecycle methods)

例如,下面是遍历效果列表(effect list)并检查节点是否具有 Snapshot 效果(effect)的代码:

function commitBeforeMutationLifecycles() {
  while (nextEffect !== null) {
    const effectTag = nextEffect.effectTag;
    if (effectTag & Snapshot) {
      const current = nextEffect.alternate;
      commitBeforeMutationLifeCycles(current, nextEffect);
    }
    nextEffect = nextEffect.nextEffect;
  }
}

对于一个类组件,这个效果意味着调用 getSnapshotBeforeUpdate 生命周期方法。

DOM 更新(DOM updates)

commitAllHostEffects 是 React 执行 DOM 更新的函数。该函数基本上定义了需要对节点执行的操作类型并执行它:

function commitAllHostEffects() {
    switch (primaryEffectTag) {
        case Placement: {
            commitPlacement(nextEffect);
            ...
        }
        case PlacementAndUpdate: {
            commitPlacement(nextEffect);
            commitWork(current, nextEffect);
            ...
        }
        case Update: {
            commitWork(current, nextEffect);
            ...
        }
        case Deletion: {
            commitDeletion(nextEffect);
            ...
        }
    }
}

有趣的是,在 commitDeletion 函数中,React 将调用 componentWillUnmount 方法作为删除过程的一部分

更新后的生命周期方法(Post-mutation lifecycle method)

commitAllLifecycles 函数是 React 调用所有剩余的 componentDidUpdate 和 componentDidMount 生命周期方法的地方

我们终于完成了。让我知道你对这篇文章的看法或在评论中提问。可以点击查看本系列的下一篇文章:In-depth explanation of state and props update in React。我还有更多的文章正在编写中,深入解读 scheduler、子元素协调过程(children reconciliation process)、以及如何构建副作用列表(effects list)。我还计划录制一个视频,在其中我将展示如何使用本文作为基础来调试应用程序。

原文链接

requestAnimationFrame、requestIdleCallback、setTimeout、MessageChannel、MutationObserver、Promise等API的介绍及哪些API适合用于任务调度

本章是手写 React Scheduler 源码系列的第一篇文章,原文哪些API适合用于任务调度。第二篇查看Scheduler 基础用法详解

学习目标

了解屏幕刷新率,下面这些 API 的基础用法及执行时机。从浏览器 Performance 面板中看每一帧的执行时间以及工作。探索哪些 API 适合用来调度任务

  • requestAnimationFrame
  • requestIdleCallback
  • setTimeout
  • MessageChannel
  • 微任务
    • MutationObserver
    • Promise

屏幕刷新率

  • 目前大多数设备的屏幕刷新率为 60 次/秒
  • 页面是一帧一帧绘制出来的,当每秒绘制的帧数(FPS)达到 60 时,页面是流畅的,小于这个值时,用户会感觉到卡顿
  • 每帧的预算时间是 16.66 毫秒(1 秒/60),因此在写代码时,注意避免一帧的工作量超过 16ms。在每一帧内,浏览器都会执行以下操作:
    • 执行宏任务、用户事件等。
    • 执行 requestAnimationFrame
    • 执行样式计算、布局和绘制。
    • 如果还有空闲时间,则执行 requestIdelCallback
    • 如果某个任务执行时间过长,则当前帧不会绘制,会造成掉帧的现象。
  • 显卡会在每一帧开始时间给浏览器发送一个 vSync 标记符,从而让浏览器刷新频率和屏幕的刷新频率保持同步。

以下面的例子为例:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Frame</title>
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
    />
    <style>
      #animation {
        width: 30px;
        height: 30px;
        background: red;
        animation: myfirst 5s infinite;
      }
      @keyframes myfirst {
        from {
          width: 30px;
          height: 30px;
          border-radius: 0;
          background: red;
        }
        to {
          width: 300px;
          height: 300px;
          border-radius: 50%;
          background: yellow;
        }
      }
    </style>
  </head>
  <body>
    <div id="animation">test</div>
  </body>
  <script>
    function rafCallback(timestamp) {
      window.requestAnimationFrame(rafCallback);
    }
    window.requestAnimationFrame(rafCallback);

    function timeoutCallback() {
      setTimeout(timeoutCallback, 0);
    }
    setTimeout(timeoutCallback, 0);

    const timeout = 1000;
    requestIdleCallback(workLoop, { timeout });
    function workLoop(deadline) {
      requestIdleCallback(workLoop, { timeout });
      const start = new Date().getTime();
      while (new Date().getTime() - start < 2) {}
    }
  </script>
</html>

在浏览器控制台的 performance 中查看上例的运行结果,如下图所示:

image

从图中可以看出每一帧的执行时间都是 16.7ms,在这一帧内,浏览器执行 raf,计算样式,布局,重绘,requestIdleCallback、定时器,放大每一帧可以看到:

image

在本篇文章中,会复用上面的 html 中的动画 demo

requestAnimationFrame

requestAnimationFrame 在每一帧绘制之前执行,嵌套(递归)调用 requestAnimationFrame 并不会导致页面死循环从而崩溃。每执行完一次 raf 回调,js 引擎都会将控制权交还给浏览器,等到下一帧时再执行。

function rafCallback(timestamp) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 2) {}
  window.requestAnimationFrame(rafCallback);
}
window.requestAnimationFrame(rafCallback);

上面的例子中使用 while 循环模拟耗时 2 毫秒的任务,观察浏览器页面发现动画很流畅,Performance 查看每一帧的执行情况如下:

image

如果将 while 循环改成 100 毫秒,页面动画明显的卡顿,Performance 查看会提示一堆长任务

function rafCallback(timestamp) {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 100) {}
  window.requestAnimationFrame(rafCallback);
}
window.requestAnimationFrame(rafCallback);

image

raf 在每一帧开始绘制前执行,两次 raf 之间间隔 16ms。在执行完一次 raf 回调后,会让出控制权给浏览器。嵌套递归调用 raf 不会导致页面死循环

requestIdleCallback

requestIdleCallback 在每一帧剩余时间执行。

本例中使用deadline.timeRemaining() > 0 || deadline.didTimeout判断如果当前帧中还有剩余时间,则继续 while 循环

const timeout = 1000;
requestIdleCallback(workLoop, { timeout });
function workLoop(deadline) {
  while (deadline.timeRemaining() > 0 || deadline.didTimeout) {}
  requestIdleCallback(workLoop, { timeout });
}

Performance 查看如下,几乎用满了一帧的时间,极致压榨 😁

image

requestIdleCallback 会在每一帧剩余时间执行,两次调用之间的时间间隔不确定,同时这个 API 有兼容性问题。在执行完一次 requestIdleCallback 回调后会主动让出控制权给浏览器,嵌套递归调用不会导致死循环

setTimeout

setTimeout 是一个宏任务,用于启动一个定时器,当然时间间隔并不一定准确。在本例中我将间隔设置为 0 毫秒

function work() {
  const start = new Date().getTime();
  while (new Date().getTime() - start < 2) {}
  setTimeout(work, 0);
}
setTimeout(work, 0);

Performance 查看如下,可以发现,即使我将时间间隔设置为 0 毫秒,两次 setTimeout 之间的间隔差不多是 4 毫秒(如图中红线所示)。可以看出 setTimeout 会有至少 4 毫秒的延迟

image

setTimeout 嵌套调用不会导致死循环,js 引擎执行完一次 settimeout 回调就会将控制权让给浏览器。settimeout 至少有 4 毫秒的延迟

MessageChannel

和 setTimeout 一样,MessageChannel 回调也是一个宏任务,具体用法如下:

var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = work;
function work() {
  port.postMessage(null);
}
port.postMessage(null);

Performance 查看如下:

image

放大每一帧可以看到,一帧内,MessageChannel 回调的调用频次超高

image

从图中可以看出,相比于 setTimeout,MessageChannel 有以下特点:

  • 在一帧内的调用频次超高
  • 两次之间的时间间隔几乎可以忽略不计,没有 setTimeout 4 毫秒延迟的特点

微任务

微任务是在当前主线程执行完成后立即执行的,浏览器会在页面绘制前清空微任务队列,嵌套调用微任务会导致死循环。这里我会介绍两个微任务相关的 API

Promise

在这个例子中,我使用 count 来控制 promise 嵌套的次数,防止死循环

let count = 0;
function mymicrotask() {
  Promise.resolve(1).then((res) => {
    count++;
    if (count < 100000) {
      mymicrotask();
    }
  });
}
function rafCallback(timestamp) {
  mymicrotask();
  count = 0;
  window.requestAnimationFrame(rafCallback);
}
window.requestAnimationFrame(rafCallback);

这里,我在 requestAnimationFrame 调用 mymicrotask,mymicrotask 中会调用 Promise 启用一个微任务,在 Promise then 中又会嵌套调用 mymicrotask 递归的调研 Promise。从图中可以看到,在本次页面更新前执行完全部的微任务

image

如果像下面这样嵌套调用,页面直接卡死,和死循环效果一样

function mymicrotask() {
  Promise.resolve(1).then((res) => {
    mymicrotask();
  });
}
function rafCallback(timestamp) {
  mymicrotask();
  window.requestAnimationFrame(rafCallback);
}
window.requestAnimationFrame(rafCallback);

MutationObserver

和 Promise 一样,为了防止死循环,我使用 count 控制,在一次 raf 中只调用 2000 次 mymicrotask

let count = 0;
const observer = new MutationObserver(mymicrotask);
let textNode = document.createTextNode(String(count));
observer.observe(textNode, {
  characterData: true,
});
function mymicrotask() {
  if (count > 2000) return;
  count++;
  textNode.data = String(count);
}
function rafCallback(timestamp) {
  mymicrotask();
  count = 0;
  window.requestAnimationFrame(rafCallback);
}
window.requestAnimationFrame(rafCallback);

image

当然,如果取消 count 的限制,页面直接卡死,死循环了。

let count = 0;
const observer = new MutationObserver(mymicrotask);
let textNode = document.createTextNode(String(count));
observer.observe(textNode, {
  characterData: true,
});
function mymicrotask() {
  count++;
  textNode.data = String(count);
}
function rafCallback(timestamp) {
  mymicrotask();
  window.requestAnimationFrame(rafCallback);
}
window.requestAnimationFrame(rafCallback);

小结

从上面的例子中可以看出

  • 嵌套递归调用微任务 API 会导致死循环,JS 引擎需要执行完全部微任务才会让出控制权,因此不适用于任务调度
  • requestAnimationFrame、requestIdleCallback、setTimeout、MessageChannel 等 API 嵌套递归调用不会导致死循环,JS 引擎每执行完一次回调都会让出控制权,适用于任务调度。我们需要综合考虑这几个 API 调用间隔、执行时机等因素选择合适的 API

相关 issue

实际上,React 团队也针对这些 API 进行尝试,下面是相关 issue

【React源码系列】React Ref用法详解及源码解析

欢迎关注mini-react一起学习react源码吧

学习目标

  • 为什么 React 不将 ref 存在 fiber 的 props 中,这样在组件中就能通过 props.ref 获取到值
  • ref 的值什么时候设置,什么时候被释放?

前置知识

React Ref 用法可以看这篇文章

React element 中的 ref 属性

React.createElement 对 ref 属性进行特殊处理

我们知道在构建时,JSX 经过 babel 编译为一系列 React.createElement,比如下面的代码

<div ref={this.domRef} id="counter" name="test">
  dom ref
</div>

经过 babel 编译,变成下面的函数调用

React.createElement(
  "div",
  {
    ref: this.domRef,
    id: "counter",
    name: "test",
  },
  "dom ref"
);

React.createElement 最终返回的是一个 react element 对象

var RESERVED_PROPS = {
  key: true,
  ref: true,
};
function createElement(type, config, children) {
  var propName;

  var props = {};
  var key = null;
  var ref = null;

  if (config != null) {
    if (config.ref) {
      ref = config.ref;
    }

    if (config.key) {
      key = "" + config.key;
    }

    for (propName in config) {
      if (
        hasOwnProperty.call(config, propName) &&
        !RESERVED_PROPS.hasOwnProperty(propName)
      ) {
        props[propName] = config[propName];
      }
    }
  }
  var childrenLength = arguments.length - 2;

  if (childrenLength === 1) {
    props.children = children;
  } else if (childrenLength > 1) {
    var childArray = Array(childrenLength);

    for (var i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }

    props.children = childArray;
  }

  return ReactElement(type, key, ref, ReactCurrentOwner.current, props);
}

var ReactElement = function (type, key, ref, owner, props) {
  return {
    $$typeof: REACT_ELEMENT_TYPE,
    type: type,
    key: key,
    ref: ref,
    props: props,
    _owner: owner,
  };
};

可以看出,ref 属性和 key 属性一样都是比较特殊的,不会被添加到 props 中,这也是为什么我们通过 props.ref 或者 props.key 获取到的永远是 undefined 的原因

ref 和 key 都是直接添加到 fiber 的属性当中的。为什么 React 不将 ref 存储在 props 中?

ref 对象

我们在使用 ref,必须显示的调用 React.createRef 或者 React.useRef 方法创建一个 ref 对象(回调 ref 不需要调用这两个方法)

这两个函数都比较简单,都是用于创建 ref 对象,比如:

function createRef() {
  return {
    current: null,
  };
}
// 在函数组件初次渲染阶段,useRef就是mountRef
function mountRef(initialValue) {
  var hook = mountWorkInProgressHook();
  var ref = {
    current: initialValue,
  };

  hook.memoizedState = ref;
  return ref;
}

// 在函数组件更新阶段,useRef就是updateRef
function updateRef(initialValue) {
  var hook = updateWorkInProgressHook();
  return hook.memoizedState;
}

为什么 React 要采用对象保存 ref?这是因为对象是引用类型,方便存值,比如下面的例子中,我们给 div 传递了 ref 属性

<div ref={this.domRef} id="counter" name="test">
  dom ref
</div>

this.domRef 是一个对象:

this.domRef = { current };

在 render 阶段为 div 创建 fiber 节点时,会将 ref 设置给 fiber.ref,即fiber.ref = this.domRef,然后在 commit 阶段,React 会给 fiber.ref.current 设置 dom 实例,此时 this.domRef.current 也就可以访问到 dom 节点

fiber ref 属性是什么时候设置的?

前面说过,React.createElement 在创建 react element 对象时,会将 ref 单独放在 element 对象的属性中,而不是放在 element.props 属性中,element 对象属性如下所示:

{
  $$typeof: Symbol(react.element),
  key: null,
  props: { id: "counter", name: "test", children: "dom ref:0", onClick },
  ref: { current: null },
  type: "div",
};

在 render 阶段,React 会为当前的 fiber 协调子元素,即将当前 fiber 节点的子节点和新的子 element 节点比较,以创建新的 workInProgress 节点。其中,在协调时,会将 element 上的 ref 属性赋值给 fiber ref 属性,fiber ref 属性就是在协调阶段设置的。以下面的例子为例:

<div id="container">
  <div ref={this.domRef} id="counter" name="test">
    dom ref
  </div>
</div>

在 beginWork 阶段,div#container 执行 reconcileChildren 工作,为 div#counter 创建子 fiber 节点,然后给新的 div#counter fiber 节点设置 ref 属性。伪代码如下:

// returnFiber即 div#container,element即是新的div#counter对应的react element对象
// currentFirstChild是returnFiber的第一个子节点
function reconcileSingleElement(returnFiber, currentFirstChild, element) {
  if (!currentFirstChild) {
    // 第一次渲染
    var _created4 = createFiberFromElement(element, returnFiber.mode, lanes);
    _created4.ref = element.ref;
    _created4.return = returnFiber;
    return _created4;
  } else {
    var _existing3 = useFiber(child, element.props);
    _existing3.ref = element.ref;
    _existing3.return = returnFiber;
    return _existing3;
  }
}

从上面的代码可以看出,在 reconcile 阶段,无论是第一次渲染还是更新阶段,都会使用 element.ref 重新赋值给新的 fiber。区别在于,第一次渲染时,会调用 createFiberFromElement 创建新的 fiber 节点,而在更新阶段,会调用 useFiber 复用旧的 fiber 节点。

因此,fiber ref 属性是在父节点的 reconcile 阶段被设置的

fiber ref 副作用标记

render 阶段如果满足下面两个条件之一,会为 fiber 节点添加一个 Ref 副作用标记:

  • 第一次渲染,并且 ref 有值,即 current === null && ref !== null
  • 更新阶段,即第二次或者后续的渲染中,如果 ref 发生了变化,即 current !== null && current.ref !== workInProgress.ref

下面是 HTML 元素和类组件的场景

function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case ClassComponent: {
      return updateClassComponent(current, workInProgress);
    }
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);
  }
}
function updateClassComponent(current, workInProgress) {
  //....
  var nextUnitOfWork = finishClassComponent(current, workInProgress);
  return nextUnitOfWork;
}

function finishClassComponent(current, workInProgress) {
  // 即使是shouldComponentUpdate返回了false,Ref也要更新
  markRef(current, workInProgress);
  //...
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  //...
  return workInProgress.child;
}
function updateHostComponent(current, workInProgress, renderLanes) {
  //...
  markRef(current, workInProgress);
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}
function markRef(current, workInProgress) {
  var ref = workInProgress.ref;

  if (
    (current === null && ref !== null) ||
    (current !== null && current.ref !== ref)
  ) {
    // 添加一个 Ref 副作用(effect)
    workInProgress.flags |= Ref;
  }
}

从上面的代码可以看出,不管是类组件还是 HTML 元素的 fiber,在为他们调用 reconcileChildren 协调子元素之前,都会调用 markRef 判断是否为它们添加 Ref 副作用

ref.current 属性赋值

在 render 阶段,会调用 markRef 为 fiber 节点添加 Ref 副作用。在 commit 阶段,React 会判断 fiber 是否具有 Ref 副作用,如果有,则为 fiber.ref 设置 current 值。

深入概述 React 初次渲染及状态更新主流程中介绍过,commit 分为三个小阶段:

  • commitBeforeMutationEffects
  • commitMutationEffects
  • commitLayoutEffects

与 Ref 操作有关的阶段只有commitMutationEffects以及commitLayoutEffects

function commitRootImpl(root, renderPriorityLevel) {
  //...
  commitBeforeMutationEffects();
  //...
  commitMutationEffects(root, renderPriorityLevel);
  //...
  commitLayoutEffects(root, lanes);
  //...
}

commitMutationEffects:重置 ref 为 null

commitMutationEffects主要是执行节点的增删改操作,在执行这些操作之前,会先调用 commitDetachRef 重置 ref。

function commitDetachRef(current) {
  var currentRef = current.ref;

  if (currentRef !== null) {
    if (typeof currentRef === "function") {
      currentRef(null);
    } else {
      currentRef.current = null;
    }
  }
}
function commitMutationEffects(root, renderPriorityLevel) {
  while (nextEffect !== null) {
    var flags = nextEffect.flags;

    //...

    if (flags & Ref) {
      var current = nextEffect.alternate;
      if (current !== null) {
        commitDetachRef(current);
      }
    }

    var primaryFlags = flags & (Placement | Update | Deletion | Hydrating);

    switch (primaryFlags) {
      //...
      case Deletion: {
        commitDeletion(root, nextEffect);
        break;
      }
    }
    nextEffect = nextEffect.nextEffect;
  }
}

这里,删除节点(commitDeletion)的操作比较特殊,commitDeletion 调用 unmountHostComponents 卸载节点,而 unmountHostComponents 最终又会调用 commitUnmount 卸载节点,在 commitUnmount 中会调用 safelyDetachRef 小心的重置 ref 为 null

function safelyDetachRef(current) {
  var ref = current.ref;

  if (ref !== null) {
    if (typeof ref === "function") {
      try {
        ref(null);
      } catch (refError) {
        captureCommitPhaseError(current, refError);
      }
    } else {
      ref.current = null;
    }
  }
}
function commitUnmount(finishedRoot, current, renderPriorityLevel) {
  onCommitUnmount(current);

  switch (current.tag) {
    //...
    case ClassComponent: {
      safelyDetachRef(current);
      var instance = current.stateNode;
      if (typeof instance.componentWillUnmount === "function") {
        safelyCallComponentWillUnmount(current, instance);
      }
      return;
    }
    case HostComponent: {
      safelyDetachRef(current);
      return;
    }
  }
}

commitLayoutEffects:为 ref 设置新值

commitLayoutEffects 会判断 fiber 是否具有 Ref 副作用,如果有,则调用 commitAttachRef 设置 ref 的值

function commitLayoutEffects(root, committedLanes) {
  while (nextEffect !== null) {
    var flags = nextEffect.flags;

    //...

    if (flags & Ref) {
      commitAttachRef(nextEffect);
    }

    nextEffect = nextEffect.nextEffect;
  }
}

commitAttachRef 主要就是设置 ref 的值,这里会判断 ref 属性是否是函数,如果是函数,则执行。否则直接设置 ref.current 属性

function commitAttachRef(finishedWork) {
  var ref = finishedWork.ref;

  if (ref !== null) {
    var instance = finishedWork.stateNode;

    if (typeof ref === "function") {
      ref(instance);
    } else {
      ref.current = instance;
    }
  }
}

useImperativeHandle

render 阶段

在 render 阶段,执行函数调用 useImperativeHandle 时,React 会为 forwardRef 创建一个 imperativeHandle 类型的 Effect 对象,并添加到 updateQueue 队列中,如下:

function imperativeHandleEffect(create, ref) {
  if (typeof ref === "function") {
    var refCallback = ref;

    var _inst = create();

    refCallback(_inst);
    return function () {
      // 注意这里会返回一个函数!!!
      refCallback(null);
    };
  } else if (ref !== null && ref !== undefined) {
    var refObject = ref;

    var _inst2 = create();

    refObject.current = _inst2;
    return function () {
      refObject.current = null;
    };
  }
}
const imperativeEffect = {
  create: imperativeHandleEffect,
  deps: null,
  destroy: undefined,
  next: null,
  tag: 3,
};
imperativeEffect.next = imperativeEffect;

fiber.updateQueue = {
  lastEffect: imperativeEffect,
};

以下面的代码为例:

const FunctionCounter = (props, ref) => {
  const createInst = () => ({
    focus: () => {
      console.log("focus...");
    },
  });
  useImperativeHandle(ref, createInst);
  return <div>{`计数器:${props.count}`}</div>;
};

const ForwardRefCounter = React.forwardRef(FunctionCounter);

imperativeHandleEffect(create, ref)中的第一个参数create对应useImperativeHandle(ref, createInst);中的第二个参数createInst

imperativeHandleEffect(create, ref)中的第二个参数ref对应useImperativeHandle(ref, createInst);中的第一个参数ref

注意,这里我们用 React.forwardRef 包裹 FunctionCounter,React 会为 forwardRef 创建一个 fiber 节点,但不会为 FunctionCounter 创建一个 fiber 节点。因此 render 阶段执行的工作是针对 forwardRef 类型的 fiber 节点

commitLayoutEffects 阶段:设置 ref.current 的值

commitLayoutEffects 阶段调用 commitLifeCycles。注意,在 commitHookEffectListMount 中会遍历 fiber.updateQueue 的 effect 队列,然后执行 effect.create 方法,就是我们前面说过的 imperativeHandleEffect 方法。

function commitLifeCycles(current, finishedWork) {
  switch (finishedWork.tag) {
    case ForwardRef: {
      commitHookEffectListMount(Layout | HasEffect, finishedWork);
      return;
    }
  }
}
function commitHookEffectListMount(tag, finishedWork) {
  var updateQueue = finishedWork.updateQueue;
  var lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;

  if (lastEffect !== null) {
    var firstEffect = lastEffect.next;
    var effect = firstEffect;

    do {
      if ((effect.tag & tag) === tag) {
        // Mount
        var create = effect.create;
        effect.destroy = create(); // 调用effect.create
      }

      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

在执行 imperativeHandleEffect 方法时,会返回一个函数:

function imperativeHandleEffect(create, ref) {
  if (typeof ref === "function") {
    var refCallback = ref;

    var _inst = create();

    refCallback(_inst);
    return function () {
      // 注意这里会返回一个函数!!!
      refCallback(null);
    };
  } else if (ref !== null && ref !== undefined) {
    var refObject = ref;

    var _inst2 = create();

    refObject.current = _inst2;
    return function () {
      refObject.current = null;
    };
  }
}

这个函数就是用来重置 ref.current 属性为 null 的。返回函数会在 commitMutationEffects 阶段执行

commitMutationEffects 阶段:重置 ref.current 为 null

commitMutationEffects 阶段调用 commitWork

function commitWork(current, finishedWork) {
  switch (finishedWork.tag) {
    case ForwardRef:
      commitHookEffectListUnmount(Layout | HasEffect, finishedWork);
      return;
  }
}
function commitHookEffectListUnmount(tag, finishedWork) {
  var updateQueue = finishedWork.updateQueue;
  var lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;

  if (lastEffect !== null) {
    var firstEffect = lastEffect.next;
    var effect = firstEffect;

    do {
      if ((effect.tag & tag) === tag) {
        // Unmount
        var destroy = effect.destroy;
        effect.destroy = undefined;

        if (destroy !== undefined) {
          destroy();
        }
      }

      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

从这个过程也可以看出,如果 ref 是一个函数,会被执行两次,第一次在 commitMutationEffects 阶段执行,用于重置 ref.current 为 null,第二次在 commitLayoutEffects 阶段执行,用于设置 ref.current 为最新的值

手把手开发极简Fiber版本的React

前言

本文翻译自build-your-own-react,建议先读下原文,是入门fiber的绝佳选择。这篇文章循序渐进地介绍实现以下几个概念,遵循本篇文章基本就能搞懂为啥需要fiber,为啥需要commit和phases、reconciliation阶段等原理。本篇文章又不完全和原文一致,这里会加入我自己的一些思考,比如经过performUnitOfWork处理后fiber tree和element tree的联系等。

  • createElement函数
  • render函数
  • Concurrent Mode
  • Fibers
  • Render and Commit Phases
  • Reconciliation
  • Function Components
  • Hooks

如果对React相关概念已经很熟悉了,可以跳到最后一章直接看源码

第一章 基本概念

以下面代码为例

// 1.jsx语法不是合法的js语法
// const element = <h1 title="foo">Hello</h1>
// 2.经babel等编译工具将jsx转换成js,将jsx转换成createElement函数调用的方式
// const element = React.createElement(
//   "h1",
//   { title: "foo" },
//   "Hello"
// )
// 3.React.createElement返回的最终对象大致如下:
const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
}
const container = document.getElementById("root")
// ReactDOM.render(element, container)
// 4.替换ReactDOM.render函数的逻辑,ReactDOM.render大致处理逻辑:
const node = document.createElement(element.type)
node['title'] = element.props.title
const text = document.createTextNode("")
text["nodeValue"] = element.props.children
node.appendChild(text)
container.appendChild(node)

为了避免歧义,这里使用 element 表示 React elementsnode 表示真实的DOM元素节点。

至此,这段代码无需经过任何编译已经能够在浏览器上跑起来了,不信你可以复制到浏览器控制台试试

这里有几点需要注意:

  • 先通过node.appendChild(text)将子元素添加到父元素,然后再通过container.appendChild(node)将父元素添加到容器container中触发浏览器渲染页面。这个顺序不能反过来,也就是说只有整个真实dom树构建完成才能添加到容器中。假设这个顺序反过来,比如先执行container.appendChild(node),则触发浏览器回流。再执行node.appendChild(text)又触发浏览器回流。性能极差
  • React.createElement返回的最终的对象就是virtual dom树,ReactDOM.render根据这个virtual dom创建真实的dom树

第二章 createElement 函数

以下面的代码为例

React.createElement接收的children有可能是原子值,比如字符串或者数字等,React.createElement('h1', {title: 'foo'}, 'Hello')。为了简化我们的代码,创建一个特殊的TEXT_ELEMENT 类型将其转换成对象

React.createElement = (type, props, ...children) => {
  return {
    type,
    props: {
      ...props,
      children: children.map(child => {
        if(typeof child === 'object'){
          return child
        }
        return {
          type: 'TEXT_ELEMENT',
          props: {
            nodeValue: child,
            children: [],
          }
        }
      })
    }
  }
}
// const element = (
//   <div id="foo">
//     <a>bar</a>
//     <b />
//   </div>
// )
// 将jsx转换成js语法
const element = React.createElement(
  "div",
  { id: "foo" },
  React.createElement("a", null, "bar"),
  React.createElement("b")
)
const container = document.getElementById("root")
ReactDOM.render(element, container)

好了,现在我们已经实现了一个简单的createElement函数,我们可以通过一段特殊的注释来告诉babel在将jsx转换成js时使用我们自己的createElement函数:

const MiniReact = {
  createElement:  (type, props, ...children) => {
    return {
      type,
      props: {
        ...props,
        children: children.map(child => {
          if(typeof child === 'object'){
            return child
          }
          return {
            type: 'TEXT_ELEMENT',
            props: {
              nodeValue: child,
              children: [],
            }
          }
        })
      }
    }
  }
}
/** @jsx MiniReact.createElement */
const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
)
console.log('element======', element)
const container = document.getElementById("root")
ReactDOM.render(element, container)

第三章 render函数

import React from 'react';

function render(element, container) {
  const dom = element.type === 'TEXT_ELEMENT' ? document.createTextNode("") : document.createElement(element.type)

  const isProperty = key => key !== 'children'
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name]
    })

  element.props.children.forEach(child => {
    render(child, dom)
  });

  container.appendChild(dom)
}
const MiniReact = {
  createElement:  (type, props, ...children) => {
    return {
      type,
      props: {
        ...props,
        children: children.map(child => {
          if(typeof child === 'object'){
            return child
          }
          return {
            type: 'TEXT_ELEMENT',
            props: {
              nodeValue: child,
              children: [],
            }
          }
        })
      }
    }
  },
  render
}
/** @jsx MiniReact.createElement */
const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
)
console.log('element======', element)
const container = document.getElementById("root")
MiniReact.render(element, container)

render函数递归创建真实的dom元素,然后将各个元素append到其父元素中,最后整个dom树append到root container中,渲染完成,这个过程一旦开始,中间是无法打断的,直到整个应用渲染完成。这也是React16版本以前的渲染过程

注意,只有当整个dom树append到root container中时,页面才会显示

第四章 Concurrent Mode

在第三章中可以看到,当前版本的render函数是递归构建dom树,最后才append到root container,最终页面才渲染出来。这里有个问题,如果dom节点数量庞大,递归层级过深,这个过程其实是很耗时的,导致render函数长时间占用主线程,浏览器无法响应用户输入等事件,造成卡顿的现象。

因此我们需要将render过程拆分成小的任务单元,每执行完一个单元,都允许浏览器打断render过程并执行高优先级的任务,等浏览器得空再继续执行render过程

如果对requestIdleCallback不熟悉的,可以自行了解一下。真实React代码中并没有使用这个api,因为有兼容性问题。因此React使用scheduler package模拟这个调度过程

let nextUnitOfWork = null
function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

function performUnitOfWork(nextUnitOfWork) {
  // TODO
}

performUnitOfWork接收当前工作单元,并返回下一个工作单元。工作单元可以理解为就是一个fiber对象节点

workLoop循环里会循环调用performUnitOfWork,直到所有工作单元都已经处理完毕,或者当前帧浏览器已经没有空闲时间,则循环终止。等下次浏览器空闲时间再接着继续执行

因此我们需要一种数据结构,能够支持任务打断并且可以接着继续执行,很显然,链表就非常适合

第五章 Fibers

Fibers就是一种数据结构,支持将渲染过程拆分成工作单元,本质上就是一个双向链表。这种数据结构的好处就是方便找到下一个工作单元

Fiber包含三层含义:

  • 作为架构来说,之前React 15Reconciler采用递归的方式执行,数据保存在递归调用栈中,所以被称为stack ReconcilerReact 16Reconciler基于Fiber节点实现,被称为Fiber Reconciler
  • 作为静态的数据结构来说,每个Fiber节点对应一个React Element,保存了该组件的类型(函数组件/类组件/html标签)、对应的DOM节点信息等
  • 作为动态的工作单元来说,每个Fiber节点保存了本次更新中该组件改变的状态、要执行的工作等

Fiber的几点冷知识:

  • 一个Fiber节点对应一个React Element节点,同时也是一个工作单元
  • 每个fiber节点都有指向第一个子元素,下一个兄弟元素,父元素的指针**

以下面代码为例:

MiniReact.render(
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>,
  container
)

对应的fiber tree如下:

image

import React from 'react';
// 根据fiber节点创建真实的dom节点
function createDom(fiber) {
  const dom = fiber.type === 'TEXT_ELEMENT' ? document.createTextNode("") : document.createElement(fiber.type)

  const isProperty = key => key !== 'children'
  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = fiber.props[name]
    })

  return dom
}

let nextUnitOfWork = null
// render函数主要逻辑:
//   根据root container容器创建root fiber
//   将nextUnitOfWork指针指向root fiber
//   element是react element tree
function render(element, container){
  nextUnitOfWork = {
    dom: container,
    props: {
      children: [element], // 此时的element还只是React.createElement函数创建的virtual dom树
    },
  }
}

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

// performUnitOfWork函数主要逻辑:
//   将element元素添加到DOM
//   给element的子元素创建对应的fiber节点
//   返回下一个工作单元,即下一个fiber节点,查找过程:
//      1.如果有子元素,则返回子元素的fiber节点
//      2.如果没有子元素,则返回兄弟元素的fiber节点
//      3.如果既没有子元素又没有兄弟元素,则往上查找其父节点的兄弟元素的fiber节点
//      4.如果往上查找到root fiber节点,说明render过程已经结束
function performUnitOfWork(fiber) {
  // 第一步 根据fiber节点创建真实的dom节点,并保存在fiber.dom属性中
  if(!fiber.dom){
    fiber.dom = createDom(fiber)
  }

  // 第二步 将当前fiber节点的真实dom添加到父节点中,注意,这一步是会触发浏览器回流重绘的!!!
  if(fiber.parent){
    fiber.parent.dom.appendChild(fiber.dom)
  }
  // 第三步 给子元素创建对应的fiber节点
  const children = fiber.props.children
  let prevSibling
  children.forEach((child, index) => {
    const newFiber = {
      type: child.type,
      props: child.props,
      parent: fiber,
      dom: null
    }
    if(index === 0){
      fiber.child = newFiber
    } else {
      prevSibling.sibling = newFiber
    }
    prevSibling = newFiber
  })

  // 第四步,查找下一个工作单元
  if(fiber.child){
    return fiber.child
  }
  let nextFiber = fiber
  while(nextFiber){
    if(nextFiber.sibling){
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
 
}
const MiniReact = {
  createElement:  (type, props, ...children) => {
    return {
      type,
      props: {
        ...props,
        children: children.map(child => {
          if(typeof child === 'object'){
            return child
          }
          return {
            type: 'TEXT_ELEMENT',
            props: {
              nodeValue: child,
              children: [],
            }
          }
        })
      }
    }
  },
  render
}
/** @jsx MiniReact.createElement */
const element = (
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>
)
// const element = (
//   <div id="foo">
//     <a>bar</a>
//     <b />
//   </div>
// )

console.log('element======', element)
const container = document.getElementById("root")
MiniReact.render(element, container)

这里有一点值得细品,React.createElement返回的element treeperformUnitOfWork创建的fiber tree有什么联系。如下图所示:

  • React Element Tree是由React.createElement方法创建的树形结构对象
  • Fiber Tree是根据React Element Tree创建来的树。每个Fiber节点保存着真实的DOM节点,并且保存着对React Element Tree中对应的Element节点的应用。注意,Element节点并不会保存对Fiber节点的应用

image

第六章 Render and Commit Phases

第五章的performUnitOfWork有些问题,在第二步中我们直接将新创建的真实dom节点挂载到了容器上,这样会带来两个问题:

  • 每次执行performUnitOfWork都会造成浏览器回流重绘,因为真实的dom已经被添加到浏览器上了,性能极差
  • 浏览器是可以打断渲染过程的,因此可能会造成用户看到不完整的UI界面

我们需要改造一下我们的代码,在performUnitOfWork阶段不把真实的dom节点挂载到容器上。保存fiber tree根节点的引用。等到fiber tree构建完成,再一次性提交真实的dom节点并且添加到容器上。

import React from 'react';
function createDom(fiber) {
  const dom = fiber.type === 'TEXT_ELEMENT' ? document.createTextNode("") : document.createElement(fiber.type)

  const isProperty = key => key !== 'children'
  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = fiber.props[name]
    })

  return dom
}

let nextUnitOfWork = null
let wipRoot = null
function render(element, container){
  wipRoot = {
    dom: container,
    props: {
      children: [element], // 此时的element还只是React.createElement函数创建的virtual dom树
    },
  }
  nextUnitOfWork = wipRoot
}
function commitRoot(){
  commitWork(wipRoot.child)
  wipRoot = null
}
function commitWork(fiber){
  if(!fiber){
    return
  }
  const domParent = fiber.parent.dom;
  domParent.appendChild(fiber.dom)
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
    shouldYield = deadline.timeRemaining() < 1
  }
  if(!nextUnitOfWork && wipRoot){
    commitRoot()
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

function performUnitOfWork(fiber) {
  // 第一步 根据fiber节点创建真实的dom节点,并保存在fiber.dom属性中
  if(!fiber.dom){
    fiber.dom = createDom(fiber)
  }

  // 第二步 将当前fiber节点的真实dom添加到父节点中,注意,这一步是会触发浏览器回流重绘的!!!
  // if(fiber.parent){
  //   fiber.parent.dom.appendChild(fiber.dom)
  // }
  // 第三步 给子元素创建对应的fiber节点
  const children = fiber.props.children
  let prevSibling
  children.forEach((child, index) => {
    const newFiber = {
      type: child.type,
      props: child.props,
      parent: fiber,
      dom: null
    }
    if(index === 0){
      fiber.child = newFiber
    } else {
      prevSibling.sibling = newFiber
    }
    prevSibling = newFiber
  })

  // 第四步,查找下一个工作单元
  if(fiber.child){
    return fiber.child
  }
  let nextFiber = fiber
  while(nextFiber){
    if(nextFiber.sibling){
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
 
}
const MiniReact = {
  createElement:  (type, props, ...children) => {
    return {
      type,
      props: {
        ...props,
        children: children.map(child => {
          if(typeof child === 'object'){
            return child
          }
          return {
            type: 'TEXT_ELEMENT',
            props: {
              nodeValue: child,
              children: [],
            }
          }
        })
      }
    }
  },
  render
}
/** @jsx MiniReact.createElement */
const element = (
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>
)
// const element = (
//   <div id="foo">
//     <a>bar</a>
//     <b />
//   </div>
// )

console.log('element======', element)
const container = document.getElementById("root")
MiniReact.render(element, container)

第七章 Reconciliation

目前为止,我们只考虑添加dom节点到容器上这一单一场景,更新删除还没实现。

我们需要对比最新的React Element Tree和最近一次的Fiber Tree的差异

我们需要给每个fiber节点添加一个alternate属性来保存旧的fiber节点

alternate保存的旧的fiber节点主要有以下几个用途:

  • 复用旧fiber节点上的真实dom节点
  • 旧fiber节点上的props和新的element节点的props对比
  • 旧fiber节点上保存有更新的队列,在创建新的fiber节点时执行这些队列以获取最新的页面
  const children = fiber.props.children
  reconcileChildren(fiber, children)
  function reconcileChildren(wipFiber, elements) {
    let index = 0
    let oldFiber = wipFiber.alternate && wipFiber.alternate.child
    let prevSibling = null

    while (index < elements.length || oldFiber != null) {
      const element = elements[index]
      let newFiber = null

      const sameType = oldFiber && element && element.type == oldFiber.type

      if (sameType) {
        newFiber = {
          type: oldFiber.type,
          props: element.props,
          dom: oldFiber.dom,
          parent: wipFiber,
          alternate: oldFiber,
          effectTag: "UPDATE",
        }
      }
      if (element && !sameType) {
        newFiber = {
          type: element.type,
          props: element.props,
          dom: null,
          parent: wipFiber,
          alternate: null,
          effectTag: "PLACEMENT",
        }
      }
      if (oldFiber && !sameType) {
        oldFiber.effectTag = "DELETION"
        deletions.push(oldFiber)
      }

      if (oldFiber) {
        oldFiber = oldFiber.sibling
      }

      if (index === 0) {
        wipFiber.child = newFiber
      } else if (element) {
        prevSibling.sibling = newFiber
      }

      prevSibling = newFiber
      index++
    }
}

如上代码所示:

协调过程:

  • 本质上依然是根据新的React Element Tree创建新的Fiber Tree,不过为了节省内存开销,协调过程会判断新的fiber节点能否复用旧的fiber节点上的真实dom元素,如果能复用,就不需要再从头到尾全部重新创建一遍真实的dom元素。同时每个新fiber节点上还会保存着对旧fiber节点的引用,方便在commit阶段做新旧属性props的对比。
  • 如果old fiber.typenew element.type相同,则保留旧的dom节点,只更新props属性
  • 如果type不相同并且有new element,则创建一个新的真实dom节点
  • 如果type不同并且有old fiber节点,则删除该节点对应的真实dom节点
  • 删除节点需要有个专门的数组收集需要删除的旧的fiber节点。由于新的element tree创建出来的新的fiber tree不存在对应的dom,因此需要收集旧的fiber节点,并在commit阶段删除

注意,协调过程,还是以最新的React Element Tree为主去创建一个新的fiber tree,只不过是新的fiber节点复用旧的fiber节点的真实dom元素,毕竟频繁创建真实dom是很消耗内存的。新的fiber节点还是会保存着对旧的fiber节点的引用,方便在commit阶段进行新属性和旧属性的比较。这里会有个问题,如果新fiber节点保留旧fiber节点的引用,那么随着更新次数越来越多,旧的fiber tree是不是也会越来越多,如何销毁?

import React from 'react';
function createDom(fiber) {
  const dom = fiber.type === 'TEXT_ELEMENT' ? document.createTextNode("") : document.createElement(fiber.type)

  updateDom(dom, {}, fiber.props)


  return dom
}

let nextUnitOfWork = null
let wipRoot = null // 保存着对root fiber的引用
let currentRoot = null // 保存着当前页面对应的fiber tree
let deletions = null
function render(element, container){
  wipRoot = {
    dom: container,
    props: {
      children: [element], // 此时的element还只是React.createElement函数创建的virtual dom树
    },
    alternate: currentRoot,
  }
  deletions = []
  nextUnitOfWork = wipRoot
}
function commitRoot(){
  deletions.forEach(commitWork)
  commitWork(wipRoot.child)
  currentRoot = wipRoot
  wipRoot = null
}

const isEvent = key => key.startsWith("on")
const isProperty = key => key !== "children" && !isEvent(key)
const isNew = (prev, next) => key => prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
function updateDom(dom, prevProps, nextProps) {
  //Remove old or changed event listeners
  Object.keys(prevProps)
    .filter(isEvent)
    .filter(
      key =>
        !(key in nextProps) ||
        isNew(prevProps, nextProps)(key)
    )
    .forEach(name => {
      const eventType = name
        .toLowerCase()
        .substring(2)
      dom.removeEventListener(
        eventType,
        prevProps[name]
      )
    })

  // Remove old properties
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach(name => {
      dom[name] = ""
    })

  // Set new or changed properties
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      dom[name] = nextProps[name]
    })

  // Add event listeners
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      const eventType = name
        .toLowerCase()
        .substring(2)
      dom.addEventListener(
        eventType,
        nextProps[name]
      )
    })
}
function commitWork(fiber){
  if(!fiber){
    return
  }
  const domParent = fiber.parent.dom;
  if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
    domParent.appendChild(fiber.dom)
  } else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
    updateDom(
      fiber.dom,
      fiber.alternate.props,
      fiber.props
    )
  } else if (fiber.effectTag === "DELETION") {
    domParent.removeChild(fiber.dom)
  }
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
    shouldYield = deadline.timeRemaining() < 1
  }
  if(!nextUnitOfWork && wipRoot){
    commitRoot()
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)
function reconcileChildren(wipFiber, elements) {
  let index = 0
  let oldFiber =
      wipFiber.alternate && wipFiber.alternate.child
  let prevSibling = null

  while (index < elements.length || oldFiber != null) {
    const element = elements[index]
    let newFiber = null

    const sameType = oldFiber && element && element.type == oldFiber.type

    if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      }
    }
    if (element && !sameType) {
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      }
    }
    if (oldFiber && !sameType) {
      oldFiber.effectTag = "DELETION"
      deletions.push(oldFiber)
    }

    if (oldFiber) {
      oldFiber = oldFiber.sibling
    }

    if (index === 0) {
      wipFiber.child = newFiber
    } else if (element) {
      prevSibling.sibling = newFiber
    }

    prevSibling = newFiber
    index++
  }
}
function performUnitOfWork(fiber) {
  // 第一步 根据fiber节点创建真实的dom节点,并保存在fiber.dom属性中
  if(!fiber.dom){
    fiber.dom = createDom(fiber)
  }

  // 第二步 将当前fiber节点的真实dom添加到父节点中,注意,这一步是会触发浏览器回流重绘的!!!
  // if(fiber.parent){
  //   fiber.parent.dom.appendChild(fiber.dom)
  // }
  // 第三步 给子元素创建对应的fiber节点
  const children = fiber.props.children
  // let prevSibling
  // children.forEach((child, index) => {
  //   const newFiber = {
  //     type: child.type,
  //     props: child.props,
  //     parent: fiber,
  //     dom: null
  //   }
  //   if(index === 0){
  //     fiber.child = newFiber
  //   } else {
  //     prevSibling.sibling = newFiber
  //   }
  //   prevSibling = newFiber
  // })
  reconcileChildren(fiber, children)

  // 第四步,查找下一个工作单元
  if(fiber.child){
    return fiber.child
  }
  let nextFiber = fiber
  while(nextFiber){
    if(nextFiber.sibling){
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
 
}
const MiniReact = {
  createElement:  (type, props, ...children) => {
    return {
      type,
      props: {
        ...props,
        children: children.map(child => {
          if(typeof child === 'object'){
            return child
          }
          return {
            type: 'TEXT_ELEMENT',
            props: {
              nodeValue: child,
              children: [],
            }
          }
        })
      }
    }
  },
  render
}
/** @jsx MiniReact.createElement */
const container = document.getElementById("root")

const updateValue = e => {
  rerender(e.target.value)
}

const rerender = value => {
  const element = (
    <div>
      <input onInput={updateValue} value={value} />
      <h2>Hello {value}</h2>
    </div>
  )
  MiniReact.render(element, container)
}

rerender("World")

第八章 Function Components

本章以下面的代码为例:

/** @jsx MiniReact.createElement */
const container = document.getElementById("root")
function App(props){
  return <h1>Hi { props.name }</h1>
}
const element = <App name="foo" />
MiniReact.render(element, container)

jsx经过babel编译后:

function App(props) {
  return MiniReact.createElement("h1", null, "Hi ", props.name);
}
const element = MiniReact.createElement(App, {
  name: "foo"
});

函数组件有两点不同的地方:

  • 函数组件对应的fiber节点没有对应的真实dom元素
  • 需要执行函数才能获取对应的children节点,而不是直接从props.children获取

由于函数组件没有对应的fiber节点,因此在commit阶段在找父fiber节点对应的dom时,需要判断是否存在该dom元素

本章完整代码:

import React from 'react';
function createDom(fiber) {
  const dom = fiber.type === 'TEXT_ELEMENT' ? document.createTextNode("") : document.createElement(fiber.type)

  updateDom(dom, {}, fiber.props)


  return dom
}

let nextUnitOfWork = null
let wipRoot = null // 保存着对root fiber的引用
let currentRoot = null // 保存着当前页面对应的fiber tree
let deletions = null
function render(element, container){
  wipRoot = {
    dom: container,
    props: {
      children: [element], // 此时的element还只是React.createElement函数创建的virtual dom树
    },
    alternate: currentRoot,
  }
  deletions = []
  nextUnitOfWork = wipRoot
}
function commitRoot(){
  deletions.forEach(commitWork)
  commitWork(wipRoot.child)
  currentRoot = wipRoot
  wipRoot = null
}

const isEvent = key => key.startsWith("on")
const isProperty = key => key !== "children" && !isEvent(key)
const isNew = (prev, next) => key => prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
function updateDom(dom, prevProps, nextProps) {
  //Remove old or changed event listeners
  Object.keys(prevProps)
    .filter(isEvent)
    .filter(
      key =>
        !(key in nextProps) ||
        isNew(prevProps, nextProps)(key)
    )
    .forEach(name => {
      const eventType = name
        .toLowerCase()
        .substring(2)
      dom.removeEventListener(
        eventType,
        prevProps[name]
      )
    })

  // Remove old properties
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach(name => {
      dom[name] = ""
    })

  // Set new or changed properties
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      dom[name] = nextProps[name]
    })

  // Add event listeners
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      const eventType = name
        .toLowerCase()
        .substring(2)
      dom.addEventListener(
        eventType,
        nextProps[name]
      )
    })
}
function commitWork(fiber){
  if(!fiber){
    return
  }
  let domParentFiber = fiber.parent
  while(!domParentFiber.dom){
    domParentFiber = domParentFiber.parent
  }
  const domParent = domParentFiber.dom;
  if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
    domParent.appendChild(fiber.dom)
  } else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
    updateDom(fiber.dom, fiber.alternate.props, fiber.props)
  } else if (fiber.effectTag === "DELETION") {
    // domParent.removeChild(fiber.dom)
    commitDeletion(fiber, domParent)
  }
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

function commitDeletion(fiber, domParent){
  if(fiber.dom){
    domParent.removeChild(fiber.dom)
  } else {
    commitDeletion(fiber.child, domParent)
  }
}
function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
    shouldYield = deadline.timeRemaining() < 1
  }
  if(!nextUnitOfWork && wipRoot){
    commitRoot()
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)
function reconcileChildren(wipFiber, elements) {
  let index = 0
  let oldFiber =
      wipFiber.alternate && wipFiber.alternate.child
  let prevSibling = null

  while (index < elements.length || oldFiber != null) {
    const element = elements[index]
    let newFiber = null

    const sameType = oldFiber && element && element.type == oldFiber.type

    if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      }
    }
    if (element && !sameType) {
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      }
    }
    if (oldFiber && !sameType) {
      oldFiber.effectTag = "DELETION"
      deletions.push(oldFiber)
    }

    if (oldFiber) {
      oldFiber = oldFiber.sibling
    }

    if (index === 0) {
      wipFiber.child = newFiber
    } else if (element) {
      prevSibling.sibling = newFiber
    }

    prevSibling = newFiber
    index++
  }
}

function performUnitOfWork(fiber) {
  // 1.函数组件对应的fiber节点没有真实dom元素
  // 2.函数组件需要运行函数获取children
  const isFunctionComponent = fiber.type instanceof Function
  if(!isFunctionComponent && !fiber.dom){
    fiber.dom = createDom(fiber)
  }
  const children = isFunctionComponent ? [fiber.type(fiber.props)] : fiber.props.children

  // 第二步 为每一个新的react element节点创建对应的fiber节点,并判断旧的fiber节点上的真实dom元素是否可以复用。
  // 节省创建真实dom元素的开销
  reconcileChildren(fiber, children)

  // 第三步,查找下一个工作单元
  if(fiber.child){
    return fiber.child
  }
  let nextFiber = fiber
  while(nextFiber){
    if(nextFiber.sibling){
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
 
}

const MiniReact = {
  createElement:  (type, props, ...children) => {
    return {
      type,
      props: {
        ...props,
        children: children.map(child => {
          if(typeof child === 'object'){
            return child
          }
          return {
            type: 'TEXT_ELEMENT',
            props: {
              nodeValue: child,
              children: [],
            }
          }
        })
      }
    }
  },
  render
}
/** @jsx MiniReact.createElement */
const container = document.getElementById("root")

function App(props){
  return <h1>Hi { props.name }</h1>
}

const element = <App name="foo" />
MiniReact.render(element, container)

第九章 Hooks

本章完整代码

import React from 'react';
function createDom(fiber) {
  const dom = fiber.type === 'TEXT_ELEMENT' ? document.createTextNode("") : document.createElement(fiber.type)

  updateDom(dom, {}, fiber.props)


  return dom
}

let nextUnitOfWork = null
let wipRoot = null // 保存着对root fiber的引用
let currentRoot = null // 保存着当前页面对应的fiber tree
let deletions = null
function render(element, container){
  wipRoot = {
    dom: container,
    props: {
      children: [element], // 此时的element还只是React.createElement函数创建的virtual dom树
    },
    alternate: currentRoot,
  }
  deletions = []
  nextUnitOfWork = wipRoot
}
function commitRoot(){
  deletions.forEach(commitWork)
  commitWork(wipRoot.child)
  currentRoot = wipRoot
  wipRoot = null
}

const isEvent = key => key.startsWith("on")
const isProperty = key => key !== "children" && !isEvent(key)
const isNew = (prev, next) => key => prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
function updateDom(dom, prevProps, nextProps) {
  //Remove old or changed event listeners
  Object.keys(prevProps)
    .filter(isEvent)
    .filter(
      key =>
        !(key in nextProps) ||
        isNew(prevProps, nextProps)(key)
    )
    .forEach(name => {
      const eventType = name
        .toLowerCase()
        .substring(2)
      dom.removeEventListener(
        eventType,
        prevProps[name]
      )
    })

  // Remove old properties
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach(name => {
      dom[name] = ""
    })

  // Set new or changed properties
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      dom[name] = nextProps[name]
    })

  // Add event listeners
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      const eventType = name
        .toLowerCase()
        .substring(2)
      dom.addEventListener(
        eventType,
        nextProps[name]
      )
    })
}
function commitWork(fiber){
  if(!fiber){
    return
  }
  let domParentFiber = fiber.parent
  while(!domParentFiber.dom){
    domParentFiber = domParentFiber.parent
  }
  const domParent = domParentFiber.dom;
  if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
    domParent.appendChild(fiber.dom)
  } else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
    updateDom(fiber.dom, fiber.alternate.props, fiber.props)
  } else if (fiber.effectTag === "DELETION") {
    // domParent.removeChild(fiber.dom)
    commitDeletion(fiber, domParent)
  }
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

function commitDeletion(fiber, domParent){
  if(fiber.dom){
    domParent.removeChild(fiber.dom)
  } else {
    commitDeletion(fiber.child, domParent)
  }
}
function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
    shouldYield = deadline.timeRemaining() < 1
  }
  if(!nextUnitOfWork && wipRoot){
    commitRoot()
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)
function reconcileChildren(wipFiber, elements) {
  let index = 0
  let oldFiber =
      wipFiber.alternate && wipFiber.alternate.child
  let prevSibling = null

  while (index < elements.length || oldFiber != null) {
    const element = elements[index]
    let newFiber = null

    const sameType = oldFiber && element && element.type == oldFiber.type

    if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      }
    }
    if (element && !sameType) {
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      }
    }
    if (oldFiber && !sameType) {
      oldFiber.effectTag = "DELETION"
      deletions.push(oldFiber)
    }

    if (oldFiber) {
      oldFiber = oldFiber.sibling
    }

    if (index === 0) {
      wipFiber.child = newFiber
    } else if (element) {
      prevSibling.sibling = newFiber
    }

    prevSibling = newFiber
    index++
  }
}

function performUnitOfWork(fiber) {
  // 1.函数组件对应的fiber节点没有真实dom元素
  // 2.函数组件需要运行函数获取children
  const isFunctionComponent = fiber.type instanceof Function
  if(!isFunctionComponent && !fiber.dom){
    fiber.dom = createDom(fiber)
  }
  const children = isFunctionComponent ? updateFunctionComponent(fiber) : fiber.props.children

  // 第二步 为每一个新的react element节点创建对应的fiber节点,并判断旧的fiber节点上的真实dom元素是否可以复用。
  // 节省创建真实dom元素的开销
  reconcileChildren(fiber, children)

  // 第三步,查找下一个工作单元
  if(fiber.child){
    return fiber.child
  }
  let nextFiber = fiber
  while(nextFiber){
    if(nextFiber.sibling){
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
 
}
let wipFiber = null
let hookIndex = null
function updateFunctionComponent(fiber){
  wipFiber = fiber
  hookIndex = 0
  wipFiber.hooks = []
  return [fiber.type(fiber.props)]
}
function useState(initial){
  const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]
  const hook = {
    state: oldHook ? oldHook.state : initial,
    queue: [],
  }
  const actions = oldHook ? oldHook.queue : []
  actions.forEach(action => {
    hook.state = action(hook.state)
  })
  const setState = action => {
    hook.queue.push(action)
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    }
    nextUnitOfWork = wipRoot
    deletions = []
  }
  wipFiber.hooks.push(hook)
  hookIndex++
  return [hook.state, setState]
}
const MiniReact = {
  createElement:  (type, props, ...children) => {
    return {
      type,
      props: {
        ...props,
        children: children.map(child => {
          if(typeof child === 'object'){
            return child
          }
          return {
            type: 'TEXT_ELEMENT',
            props: {
              nodeValue: child,
              children: [],
            }
          }
        })
      }
    }
  },
  render,
  useState,
}
/** @jsx MiniReact.createElement */
const container = document.getElementById("root")

function Counter(){
  const [state, setState] = MiniReact.useState(1)
  return (
    <h1 onClick={() => setState(c => c + 1)}>
      Count: { state }
    </h1>
  )
}

const element = <Counter />
MiniReact.render(element, container)

React 是如何防止 XSS 攻击的,论$$typeof 的作用

JSX

先来简单复习一下 JSX 的基础知识。JSX 是React.createElement的语法糖

<div id="container">hello</div>

经过 babel 编译后:

React.createElement(
  "div" /* type */,
  { id: "container" } /* props */,
  "hello" /* children */
);

React.createElement最终返回的结果就是一个对象,如下:

{
  type: 'div',
  props: {
    id: 'container',
    children: 'hello',
  },
  key: null,
  ref: null,
  $$typeof: Symbol.for('react.element'),
}

这就是一个 React element 对象。

我们甚至可以在代码中直接写 React element 对象,React 照样能正常渲染我们的内容:

render() {
  return (
    <div>
      {{
        $$typeof: Symbol.for('react.element'),
        props: {
          dangerouslySetInnerHTML: {
            __html: '<img src="x" onerror="alert(1)">'
          },
        },
        ref: null,
        type: "div",
      }}
    </div>
  );
}

可以复制这段代码本地运行一下,可以发现浏览器弹出一个弹窗,并且img已经插入了 dom 中。

这里,$$typeof 的作用是啥?为什么使用 Symbol() 作为值?

在了解之前,我们先来简单看下 XSS 攻击

XSS 攻击

我们经常需要构造 HTML 字符串并插入到 DOM 中,比如:

const messageEl = document.getElementById("message");
var message = "hello world";
messageEl.innerHTML = "<p>" + message + "</p>";

页面正常显示。但是如果我们插入一些恶意代码,比如:

const messageEl = document.getElementById("message");
var message = '<img src onerror="alert(1)">';
messageEl.innerHTML = "<p>" + message + "</p>";

此时页面就会弹出一个弹窗,弹窗内容显示为 1

因此,直接使用 innerHTML 插入文本内容,存在 XSS 攻击的风险

防止 XSS 攻击的方法

为了解决类似的 XSS 攻击方法,我们可以使用一些安全的 API 添加文本内容,比如:

  • 使用 document.createTextNode('hello world') 插入文本节点。
  • 或者使用 textContent 而不是 innerHTML 设置文本内容。
  • 对于一些特殊字符,比如 <>,我们可以进行转义,将其转换为 &#60; 以及 &#62;
  • 对于富文本内容,我们可以设置黑名单,过滤一些属性,比如 onerror 等。

React 对于文本节点的处理

React 使用 createTextNode 或者 textContent 设置文本内容。对于下面的代码

render() {
  const { count } = this.state
  return (
    <div onClick={() => this.setState({ count: count + 1})}>
      {count}
    </div>
  );
}

React 在渲染过程中会调用setTextContent方法为div节点设置内容,其中,第一次渲染时,直接设置div节点的textContent,第二次或者第二次以后的更新渲染,由于第一次设置了textContent,因此divfirstChild值存在,是个文本节点。此时直接更新这个文本节点的nodeValue即可

var setTextContent = function (node, text) {
  if (text) {
    var firstChild = node.firstChild;
    // 如果当前node节点已经设置过textContent,则firstChild不为空,是个文本节点TEXT_NODE
    if (
      firstChild &&
      firstChild === node.lastChild &&
      firstChild.nodeType === TEXT_NODE
    ) {
      firstChild.nodeValue = text;
      return;
    }
  }
  // 第一次渲染,直接设置textContent
  node.textContent = text;
};

综上,对于普通的文本节点来说,由于 React 是采用 textContent 或者 createTextNode 的方式添加的,因此是不会存在 XSS 攻击的,即使上面示例中,count 的值为 '<img src="x" onerror="alert(1)">'也不会有被攻击的风险

dangerouslySetInnerHTML 处理富文本节点

有时候我们确实需要显示富文本的内容,React 提供了dangerouslySetInnerHTML方便我们显式的插入富文本内容

render() {
  return (
    <div
      id="dangerous"
      dangerouslySetInnerHTML={{ __html: '<img src="x" onerror="alert(1)">' }}
    >
    </div>
  );
}

React 在为 DOM 更新属性时,会判断属性的key是不是dangerouslySetInnerHTML,如果是的话,调用setInnerHTML 方法直接给 dom 的innerHTML属性设置文本内容

function setInitialDOMProperties(
  tag,
  domElement,
  rootContainerElement,
  nextProps
) {
  for (var propKey in nextProps) {
    if (propKey === "dangerouslySetInnerHTML") {
      var nextHtml = nextProp ? nextProp.__html : undefined;
      if (nextHtml != null) {
        setInnerHTML(domElement, nextHtml);
      }
    }
  }
}
var setInnerHTML = function (node, html) {
  node.innerHTML = html;
};

可以看出,React 在处理富文本时,也仅仅是简单的设置 DOM 的innerHTML属性来实现的。

对于富文本潜在的安全风险,交由开发者自行把控。

$$typeof 的作用

render() {
  const { text } = this.state
  return (
    <div>
      {text}
    </div>
  );
}

假设这个text是从后端返回来的,同时后端允许用户存储 JSON 对象,如果用户传入下面这样的一个类似 React element 的对象:

{
  type: "div",
  props: {
    dangerouslySetInnerHTML: {
      __html: '<img src="x" onerror="alert(1)">'
    },
  },
  ref: null
}

别忘了前面说过,我们在 JSX 中直接插入 React element 对象也是能够正常渲染的。

在这种情况下,在React0.13版本时,这是一个潜在的XSS攻击,这个漏洞源于服务端。如果攻击者恶意伪造一个类似 React element 对象的数据返回给前端,React 就会执行恶意代码。但是 React 可以采取措施预防这种攻击。

React0.14版本开始,React 为每个 element 都添加了一个Symbol标志:

{
  $$typeof: Symbol.for('react.element'),
  props: {
    id: 'container'
  },
  ref: null,
  type: "div",
}

这个行得通,是因为 JSON 不支持Symbol。因此即使是服务端有风险漏洞并且返回一个 JSON,这个 JSON 也不会包含Symbol.for('react.element').,在 Reconcile 阶段,React 会检查element.$$typeof标志是否合法。不合法的话直接报错,React 不能接受对象作为 children

专门使用 Symbol.for() 的好处是, Symbols 在 iframe 和 worker 等环境之间是全局的。因此,即使在更奇特的条件下,Symbols 也能在不同的应用程序之间传递受信任的元素。同样,即使页面上有多个 React 副本,它们仍然可以“同意”有效的 $$typeof 值

如果浏览器不支持Symbols,React 使用0xeac7代替

{
  $$typeof: '0xeac7',
}

参考链接

react dom diff

React17 中 DOM DIFF 算法动机

在 React17+中,DOM DIFF 就是根据当前显示的页面对应的 Fiber 树和 render 函数生成的最新的 element tree 对比生成新的 Fiber 树的过程。然而将一棵树转换成另一棵树的最小操作次数,即使使用最优的算法,该算法的复杂程度仍为 O(n^3 ),其中 n 是树中元素的数量

如果在 React 中使用该算法,那么展示 1000 个元素则需要 10 亿次比较。这个开销实在是太过高昂。于是 React 在以下两个假设的基础之上提出了一套 O(n)的启发式算法:

  • 两个不同类型的元素会产生出不同的树
  • 开发者可以通过设置 key 属性,来告知渲染哪些子元素在不同的渲染下可以保存不变

Diffing 算法

  • 如果元素类型不同,则直接拆卸原有的树并重新构建新的树
  • 如果两个元素类型相同,则保留 DOM 节点,仅比对及更新有改变的属性
  • 组件元素类型相同时,组件实例保持不变

在整个 diffing 过程中,同时使用 type 和 key 判断是否复用,首先判断 key,其次判断 type

为什么需要 key

在子元素列表末尾新增元素时,更新开销比较小,比如:

<ul>
  <li>first</li>
  <li>second</li>
</ul>

<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

React 会先匹配两个 <li>first</li> 对应的树,然后匹配第二个元素 <li>second</li> 对应的树,最后插入第三个元素的 <li>third</li> 树。

如果只是简单的将新增元素插入到表头,那么更新开销会比较大,比如:

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

React 并不会意识到应该保留 <li>Duke</li><li>Villanova</li>,而是会重建每一个子元素。这种情况会带来性能问题

可以得知:

插入表尾比表头性能要好很多

因此我们可以使用 key 告诉 react 复用元素

单节点

记住,这里是指 render 函数重新生成的 element tree 的子节点只有一个的情况

单节点,只有 typekey 都相同才可以复用,否则重新创建一个新的节点

如果新的 element tree 子节点只有一个元素:

  • 如果不存在旧的 fiber 的 DOM 节点,则重新生成新的 fiber 节点
  • 如果存在旧的 fiber 的 DOM 节点:
    • 如果 key 和 type 都相同才可以复用
    • 如果 key 或者 type 不同,则给旧的 fiber 添加删除标记,并生成新的 fiber 节点

第一种场景 type 不同

// 旧的 fiber 树
<div>
    <h1 key="null">h1</h1>
</div>
// 更新后的 element tree
<div>
    <h2 key="null">h2</h2>
</div>

第二种场景 多节点变为单节点

// 旧的 fiber 树
<div>
    <h1 key="h1-key">h1</h1>
    <h2 key="h2-key">h2</h2>
</div>
// 更新后的 element tree
<div>
    <h2 key="h2-key">h2</h2>
</div>

首先比较 key

  • h2-keyh1-key 不同,则 h1-key 标记为删除
  • 继续遍历旧的 fiber 树,h2-key 相同,同时 type 相同,则可以复用

如果旧的 fiber 树中,h2 后面还有 h3h4等,依然只是保留 h2,将其他的删除

<div>
    <h1 key="h1-key">h1</h1>
    <h2 key="h2-key">h2</h2>
    <h3 key="h3-key">h3</h3>
    <h4 key="h4-key">h4</h4>
</div>
// 更新后的 element tree
<div>
    <h2 key="h2-key">h2</h2>
</div>

当遍历到 h2-key 时,发现 keytype 都相同,因此 h2 可以复用,此时已经没有必要再继续比较接下来的节点,因此 h3h4 都标记为删除

如果 key 相同, type 不同:

<div>
    <h1 key="h1-key">h1</h1>
    <p key="h2-key">h2</p>
    <h3 key="h3-key">h3</h3>
    <h4 key="h4-key">h4</h4>
</div>
// 更新后的 element tree
<div>
    <h2 key="h2-key">h2</h2>
</div>
  • 比较 h1-key,发现 key 不同,则 h1 标记为删除,继续遍历
  • 比较 h2-key 发现 key 相同,比较 type 发现不同,则标记 p 删除,由于已经找到相同的 key,根据 react 的假设,已经没有必要再继续遍历下去了,因此 h3h4 标记为删除。重新创建一个 h2 节点

多节点

注意,这里是指 render 函数重新生成的 element tree 的子节点有多个的情况

同理,多节点的情况,也是只有 typekey 都相同,才能复用,否则重新创建节点

  • 节点有可能更新、删除、新增
  • 多节点的时候会经历二轮遍历
  • 第一轮遍历主要是处理节点的更新,更新包括属性和类型的更新,第二轮遍历处理新增、删除、移动的情况
  • 移动时的原则是尽量少量的移动,如果必须有一个要动,新地位高的不动,新地位低的动

第一种情况:更新

全部子节点都可以复用,只需要更新即可,这种只需要一轮循环

<ul>
    <li key="A">A</li>
    <li key="B">B</li>
    <li key="C">C</li>
    <li key="D">D</li>
</ul>

// 更新后:
<ul>
    <li key="A">A-new</li>
    <li key="B">B-new</li>
    <li key="C">C-new</li>
    <li key="D">D-new</li>
</ul>

第二种情况:key 相同,type 不同

在这种情况中,由于 key 相同而 type 不同导致不可复用,则将 旧的 fiber 节点标记为删除,并继续遍历,此时不会跳出第一轮循环

<ul>
    <li key="A-key">A</li>
    <li key="B-key">B</li>
    <li key="C-key">C</li>
    <li key="D-key">D</li>
</ul>

// 更新后:
<ul>
    <div key="A-key">A-new</div>
    <li key="B-key">B-new</li>
    <li key="C-key">C-new</li>
    <li key="D-key">D-new</li>
</ul>

首先判断 key

  • A-key 相同,但是 type 不同,因此将 <li key="A-key">A</li> 标记为删除,重新创建 <div key="A-key">A-new</div> 节点
  • 由于其余节点的 keytype 都相同,因此都可以复用

第三种情况:key 不同,退出第一轮循环

如果第一轮遍历的时候,发现 key 不一样,则立刻跳出第一轮循环。key 不一样,说明可能有位置变化

<ul>
    <li key="A-key">A</li>          // oldIndex 为0
    <li key="B-key">B</li>          // oldIndex 为1
    <li key="C-key">C</li>          // oldIndex 为2
    <li key="D-key">D</li>          // oldIndex 为3
    <li key="E-key">E</li>          // oldIndex 为4
    <li key="F-key">F</li>          // oldIndex 为5
</ul>

// 更新后:
<ul>
    <li key="A-key">A-new</li>
    <li key="C-key">C-new</li>
    <li key="E-key">E-new</li>
    <li key="B-key">B-new</li>
    <li key="G-key">G-new</li>
</ul>

第一轮循环:

  • A-key 相同并且 type 相同,能复用,更新 A 就可以
  • B-keyC-key 不同,立即退出第一轮循环,初始化 lastPlacedIndex 为旧的 fiber A-key的索引。
    lastPlacedIndex 表示最近的一个可复用的节点在 旧 fiber 节点中的位置索引
  • B-key 节点开始遍历旧的 fiber 节点,并构造一个 map:
const fiberMap = { "B-key": B, "C-key": C, "D-key": D, "E-key": E, "F-key": F };

fiberMap 的键值是元素的 key,值对应元素的 fiber 节点

  • 继续遍历剩余的 新的子节点
  • C-keyfiberMap 中存在,并且旧的 C-key 节点的 oldIndex = 2 大于 lastPlacedIndex = 0,因此 C-key 不需要移动,标记更新即可。将 lastPlacedIndex 设置为 C-keyoldIndex,此时 lastPlacedIndex = 2。同时将 C-keyfiberMap 中删除,最终得到
lastPlacedIndex = 2;
fiberMap = { "B-key": B, "D-key": D, "E-key": E, "F-key": F };
  • E-keyfiberMap 中存在,同时 E-key 节点的 oldIndex 也大于 lastPlacedIndex,同 C-key 一样不需要移动,标记为更新即可,最终得到:
lastPlacedIndex = 4; // 将lastPlacedIndex设置为 `E-key` 的oldIndex
fiberMap = { "B-key": B, "D-key": D, "F-key": F }; // 从 fiberMap中删除 `E-key`
  • B-keyfiberMap 中存在,需要注意,由于 B-keyoldIndex 小于 lastPlacedIndex,因此这个节点需要标记为移动并且更新,最终得到:
lastPlacedIndex = 4; // lastPlacedIndex不变
fiberMap = { "D-key": D, "F-key": F }; // 从 fiberMap中删除 `B-key`
  • G-keyfiberMap 中不存在,因此这是一个新增的节点

  • 到此,新的节点已经遍历完成,将 fiberMap 中剩余的旧节点都标记为删除

  • 最后,在 commit 阶段,先删除 D 和 F,再更新 A、C 和 E,然后移动并更新 B,最后插入 G

第四种情况:极端场景,前面的节点都需要移动

这种场景将后面的节点移动到了前面,性能不好,因此应该避免这种写法

<ul>
    <li key="A-key">A</li>  // oldIndex 0
    <li key="B-key">B</li>  // oldIndex 1
    <li key="C-key">C</li>  // oldIndex 2
    <li key="D-key">D</li>  // oldIndex 3
</ul>

// 更新后:
<ul>
    <li key="D-key">D</li>
    <li key="A-key">A</li>
    <li key="B-key">B</li>
    <li key="C-key">C</li>
</ul>

第一轮遍历:

  • D-keyA-key 不同,key 改变了,不能复用,跳出第一轮循环

第二轮循环

初始化 lastPlacedIndex = 0,并创建一个旧的 fiber 节点的映射

const fiberMap = { "A-key": A, "B-key": B, "C-key": C, "D-key": D };
  • D-keyfiberMap 中存在,并且 oldIndex > lastPlacedIndex ,因此可以复用并且标记为更新,不需要移动
lastPlacedIndex = 3; // 将 lastPlacedIndex 设置为 D-key 的 oldIndex
const fiberMap = { "A-key": A, "B-key": B, "C-key": C }; // 将 D-key 从 fiberMap 中删除
  • 继续遍历接下来的节点,可想而知,由于 lastPlacedIndex 都大于这些节点的 oldIndex,因此这些节点都需要标记为移动并且更新

从中可以看出,这种更新,react 并不会仅仅将 D-key 节点移动到前面,而是将 A-key,B-key,C-key 都移动到 D-key 后面。

因此我们需要避免将节点从后面移动到前面的操作

全网最详细的React异常捕获及处理机制

React 异常处理最重要的目标之一就是保持浏览器的Pause on exceptions行为。这里你不仅能学到 React 异常捕获的知识,还能学到如何模拟 try catch

大纲

  • React 开发和生产环境捕获异常的实现不同
  • 如何捕获异常,同时不吞没用户业务代码的异常
  • 如何模拟 try catch 捕获异常
  • React 捕获用户所有的业务代码中的异常,除了异步代码无法捕获以外。
  • React 使用 handleError 处理 render 阶段用户业务代码的异常,使用 captureCommitPhaseError 处理 commit 阶段用户业务代码的异常,而事件处理函数中的业务代码异常则简单并特殊处理
  • render 阶段抛出的业务代码异常,会导致 React 从 ErrorBoundary 组件或者 root 节点重新开始执行。而 commit 阶段抛出的业务代码异常,会导致 React 从 root 节点重新开始调度执行!

前置基础知识

如果还不熟悉 JS 异常捕获,比如全局异常捕获,Promise 异常捕获,异步代码异常捕获。自定义事件,以及 dispatchEvent 的用法。React 错误边界等基础知识的,可以参考以下几篇短文。如果已经熟悉了,可以跳过。

为什么 Dev 模式下, React 不直接使用 try catch,而是自己模拟 try catch 机制实现异常捕获?

开发环境的目标:保持 Pause on exceptions 的预期行为

要回答这个问题,我们先看下 React 源码中一段关于异常捕获机制的描述:

image

同时结合这个issue可以知道,React 异常处理最重要的目标之一就是保持浏览器的Pause on exceptions行为。如果对Pause on exceptions不熟悉的,可以看这篇文章

React 将用户的所有业务代码包装在 invokeGuardedCallback 函数中执行,比如构造函数,生命周期方法等。

这些方法内部的逻辑是用户自己实现的,并且大部分在 React 的 render 阶段调用,理论上这些方法内部所抛出的任何异常,都应该让用户自行捕获,比如下面的代码中

useLayoutEffect(() => {
  console.log(aaadd);
}, []);

useLayoutEffect内部的逻辑是用户自己实现的,由于用户没有自己实现 try catch 捕获异常,那么理论上useLayoutEffect内部抛出的异常应该可以被浏览器的Pause on exceptions自动定位到。

在生产环境中,invokeGuardedCallback 使用 try catch,因此所有的用户代码异常都被视为已经捕获的异常,不会被Pause on exceptions自动定位到,当然用户也可以通过开启 Pause On Caught Exceptions 自动定位到被捕获的异常代码位置。

但是这并不直观,因为即使 React 已经捕获了错误,从开发者的角度来说,错误是没有捕获的(毕竟用户没有自行捕获这个异常,而 React 作为库,不应该吞没异常),因此为了保持预期的 Pause on exceptions 行为,React 不会在 Dev 中使用 try catch,而是使用 custom event以及dispatchEvent模拟 try catch 的行为。

防止用户业务代码被第三方库吞没

根据这个issue可以知道,React 异常捕获还有一个目标就是防止用户业务代码被其他第三方库的异步代码吞没。比如 react redux,redux saga 等。例如在 redux saga 中这么调用了 setState:

Promise.resolve()
  .then(() => {
    this.setState({ a: 1 });
  })
  .catch((err) => {
    console.log(err);
  });

如果 React 不经过 invokeguardcallback 处理,那么 setState 的触发的 render 的异常将会被 promise.catch 捕获,在用户的角度看来,这个异常被吞没了。

React16 以后由于有了 invokeguardcallback 处理异常,在异步代码中调用 setState 触发的 render 的异常不会被任何 try catch 或者 promise catch 吞没。比如:

<div
  onClick={() => {
    Promise.resolve()
      .then(() => {
        setCount({ a: 1 });
      })
      .catch((e) => {
        console.log("Swallowed!", e);
      });
  }}
>
  {count}
</div>

Promise 的 catch 虽然可以捕获异常,但是 React 还是可以照样抛出异常,控制台还是会打印 Error 信息

image

<div
  onClick={() => {
    setTimeout(() => {
      try {
        setCount({ a: 1 });
      } catch (e) {
        console.log("e...", e);
      }
    }, 0);
  }}
>
  {count}
</div>

image

这同时也告诉我们一个道理,作为一个库工具开发者,我们不应该吞没用户的异常

使用 dispatchEvent 模拟 try catch,同时又能保持浏览器开发者工具 Pause on exceptions 的预期行为

dispatchEvent 能够模拟 try catch,是基于下面的特性:

  • 通过 dispatchEvent 触发的事件监听器是按顺序同步执行的,具体例子可以看这里
  • 自定义事件监听器内部抛出的异常可以被全局异常监听器监听到并且会立即执行!!!!!同时仍然可以被 Pause on exceptions 自动定位到,具体例子可以看这里

这么说有点抽象,我们再来复习一个简单的例子:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>dispatchEvent</title>
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
    />
  </head>
  <body>
    <div id="root">
      <button id="btn">click me</button>
    </div>
    <script>
      window.onerror = (e) => {
        console.log("全局异常监听器...", e);
      };
      const event = new Event("MyCustomEvent", { bubbles: true });
      root.addEventListener("MyCustomEvent", function (e) {
        console.log("root第一个事件监听器", e);
      });
      btn.addEventListener("MyCustomEvent", function (e) {
        console.log("btn第一个事件监听器", e);
        throw Error("btn事件监听器抛出的异常");
        console.log("这一句不会被执行到,因此不会被打印");
      });
      btn.addEventListener("MyCustomEvent", function (e) {
        console.log("btn第2个事件监听器", e);
      });
      console.log("开始触发自定义事件");
      btn.dispatchEvent(event);
      console.log("自定义事件监听函数执行完毕");
    </script>
  </body>
</html>

这个例子首先注册一个全局异常监听器,然后创建自定义的事件,给 btn、root 添加监听自定义事件的监听器,其中 btn 的第一个监听器抛出一个异常。最后通过 dispatchEvent 触发自定义事件监听器的执行。执行结果如下所示:

image

从图中的执行结果可以看出,btn 的第一个事件监听器抛出的异常会立即被全局异常监听器捕获到,并立即执行。 这个效果和 try catch 完全一致!!!同时,即使自定义事件监听器的异常被全局异常监听器捕获到了,仍然可以被Pause on exceptions自动定位到,这就是 React 想要的效果!!!

console.log("开始");
try {
  console.log("aaaa", dd);
} catch (e) {
  console.log("捕获异常...", e);
}
console.log("结束");

image

在开发环境中,React 将自定义事件(fake event)同步派发到自定义 dom(fake dom noe)上,并在自定义事件监听器内调用用户的回调函数,如果用户的回调函数抛出错误,则使用全局异常监听器捕获错误。这为我们提供了 try catch 的行为,而无需实际使用 try catch,又能保持浏览器 Pause on exceptions 的预期行为。

Dev 模式下,React 如何实现模拟 try catch 的行为

在 dev 环境下,invokeGuardedCallback 的实现如下所示,这里是精简后的代码,func 是用户提供的回调函数,比如在 render 阶段,func 就是 beginWork 函数。

dev 环境下在自定义事件监听器中执行用户的回调函数,如果用户的回调函数抛出异常,则被全局的异常监听器捕获,并且立即执行全局异常监听器

let caughtError = null;
function invokeGuardedCallback(func) {
  const evt = document.createEvent("Event");
  const evtType = "react-invokeguardedcallback";
  const fakeNode = document.createElement("react");

  function callCallback() {
    fakeNode.removeEventListener(evtType, callCallback, false);
    // 执行回调函数
    func();
  }

  function handleWindowError(event) {
    caughtError = event.error;
  }

  // 注册全局异常监听器
  window.addEventListener("error", handleWindowError);
  // 注册自定义事件监听器,在自定义事件中调用用户提供的回调函数
  fakeNode.addEventListener(evtType, callCallback, false);

  evt.initEvent(evtType, false, false);
  fakeNode.dispatchEvent(evt);

  // 移除全局异常监听器
  window.removeEventListener("error", handleWindowError);
}

在生产环境下,invokeGuardedCallback 的实现如下,使用普通的 try catch 捕获用户提供的函数 func 里面的异常

function invokeGuardedCallbackProd(func) {
  try {
    func();
  } catch (error) {
    this.onError(error);
  }
}

React Dev 模式异常捕获及处理

在 Dev 环境下,React 使用 invokeGuardedCallback 包裹几乎所有的用户业务代码,我全局搜索了一下 invokeGuardedCallback 函数的调用,总共有以下几个地方调用了 invokeGuardedCallback 函数捕获异常,涵盖了所有的用户业务代码:

  • 合成事件的回调函数,将第一个错误重新抛出
  • 类组件 componentWillUnmount 生命周期方法,避免 componentWillUnmount 中的异常阻断组件卸载。然后在 captureCommitPhaseError 中处理异常
  • DetachRef,释放 Ref。如果 Ref 是一个函数,在组件卸载的时候会执行 ref,用户业务代码的异常(包括生命周期方法和 refs)都不应该打断删除的过程,因此这些方法都会使用 invokeGuardedCallback 包括执行。然后 ref 中的异常会在 captureCommitPhaseError 中处理
  • useLayoutEffect 以及 useEffect 的清除函数以及 useEffect 的监听函数,然后使用 captureCommitPhaseError 处理异常
  • commit 阶段的 commitBeforeMutationEffects、commitMutationEffects、commitLayoutEffects 函数,然后使用 captureCommitPhaseError 处理异常
  • render 阶段的 beginWork 方法先使用 try catch 捕获异常,如果 beginWork 有异常抛出,则将 beginWork 包裹进 invokeGuardedCallback 重新执行,并重新抛出异常,然后在 handleError 方法中处理异常

可以看出,在 dev 环境中,我们所有的业务代码都被invokeGuardedCallback包裹并且执行,我们业务代码中的异常都会被 invokeGuardedCallback 捕获。除了合成事件中的异常特殊处理外,在 render 阶段调用的方法,比如构造函数,一些生命周期方法中的异常,都在handleError中处理。在 commit 阶段调用的方法,比如 useEffect 的监听函数等方法的异常,都在captureCommitPhaseError中处理。

总的来说,React 使用 invokeGuardedCallback 捕获我们业务代码中的异常,然后在handleError或者captureCommitPhaseError处理异常

但是,我们也需要明白一点,并不是所有的用户业务代码中的异常都会被错误边界处理

并不是用户的所有业务代码都能被 React 错误边界处理!!!

并不是用户的所有业务代码都能被 React 错误边界处理!!!

并不是用户的所有业务代码都能被 React 错误边界处理!!!

一般情况下,React 错误边界能够处理大部分的用户业务代码的异常,包括 render 阶段以及 commit 阶段执行的业务代码,但是并不能捕获并处理以下的用户业务代码异常:

  • 事件处理
  • 异步代码
  • 服务端渲染的异常

下面,逐一介绍合成事件异常捕获及处理、handleError异常处理、captureCommitPhaseError异常处理

合成事件回调函数中的异常捕获及处理

合成事件中的异常不会被 React 错误边界处理

React 会捕获合成事件中的错误,但只会将第一个重新抛出,同时并不会在控制台打印 fiber 栈信息,举个例子:

<div
  onClick={() => {
    console.log("b...", b);
  }}
>
  <div
    onClick={() => {
      console.log("a..", a);
    }}
  >
    click me
  </div>
</div>

当我们点击 'click me' 时,React 会沿着冒泡阶段调用所有的监听函数,并捕获这些错误打印出来。但是,React 只会将第一个错误重新抛出(rethrowCaughtError)。可以发现下图中 React 捕获了这两个监听函数中的错误并打印了出来,但 React 只会将第一个监听函数中的错误重新抛出。

image

handleError 如何处理异常

handleError 只用于处理 render 阶段在beginWork函数中执行的用户业务代码抛出的异常,比如构造函数,类组件的 render 方法、函数组件、生命周期方法等

为了方便演示,我将renderRootSync的主要逻辑简化如下,这也是 React render 阶段的主要逻辑,以下代码可以直接复制在浏览器控制台运行:

let workInProgress = 0;
let caughtError;
function renderRootSync(root, lanes) {
  do {
    try {
      workLoopSync();
      break;
    } catch (thrownValue) {
      console.log("renderRootSync捕获了异常.....", thrownValue);
      // handleError(root, thrownValue);
      return;
    }
  } while (true);
}

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork) {
  const next = beginWork$1(unitOfWork);
  if (next > 4) {
    // 模拟completeUnitOfWork
    // completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }
}
function invokeGuardedCallback(func, arg) {
  const evt = document.createEvent("Event");
  const evtType = "react-invokeguardedcallback";
  const fakeNode = document.createElement("react");

  function callCallback() {
    fakeNode.removeEventListener(evtType, callCallback, false);
    func(arg);
  }

  function handleWindowError(event) {
    caughtError = event.error;
  }

  window.addEventListener("error", handleWindowError);
  fakeNode.addEventListener(evtType, callCallback, false);

  evt.initEvent(evtType, false, false);
  fakeNode.dispatchEvent(evt);

  window.removeEventListener("error", handleWindowError);
}
function beginWork(unitOfWork) {
  console.log("beginWork....", unitOfWork);
  if (unitOfWork === 2) {
    throw Error("unitOfWork等于2时抛出错误,模拟异常");
  }
  return unitOfWork + 1;
}
function beginWork$1(unitOfWork) {
  const originalWorkInProgressCopy = unitOfWork;

  try {
    // 先执行一遍beginWork
    return beginWork(unitOfWork);
  } catch (originalError) {
    // 重置unitOfWork
    unitOfWork = originalWorkInProgressCopy; // assignFiberPropertiesInDEV

    // 重新开始执行beginWork
    invokeGuardedCallback(beginWork, unitOfWork);

    // 重新抛出错误,这次抛出的错误会被handleError捕获并处理
    if (caughtError) {
      throw caughtError;
    }
  }
}

renderRootSync();

从上面代码可以看出,如果beginWork函数发生了异常,那么会被 try catch 捕获,并且 React 会在 catch 里面重新将 beginWork 包裹进invokeGuardedCallback函数中重复执行!!!。前面说过,使用 try catch 捕获异常,会破坏浏览器的Pause on exceptions预期的行为,因此如果 beginWork 抛出了异常,则需要将 beginWork 包裹进Pause on exceptions重复执行,在invokeGuardedCallback抛出的异常不会被吞没

其实我不太明白这里为啥需要重复执行,一开始就完全可以将 beginWork 包裹进invokeGuardedCallback中执行,这样既能捕获异常,还能保持浏览器的预期行为,详情可以查看这个issue,有懂哥可以指教一下。

第二次执行beginWork时,如果抛出异常,则会被handleError捕获并处理,下面我们详细了解下handleError如何处理异常

以下面的代码为例:

import React from "react";
import ReactDOM from "react-dom";
import Counter from "./counter";
import ErrorBoundary from "./error";
class Home extends React.Component {
  constructor(props) {
    super(props);
  }

  render() {
    return (
      <ErrorBoundary>
        <Counter />
      </ErrorBoundary>
    );
  }
}
const Counter = () => {
  const [count, setCount] = useState({});
  return <div id="counter">{count}</div>;
};
ReactDOM.render(<Home />, document.getElementById("root"));

renderRootSync也是一个循环,这里需要注意,循环结束的条件是要么hanleError重新抛出异常终止函数执行,要么workLoopSync正常执行完成,到 break 语句退出。

function renderRootSync(root, lanes) {
  do {
    try {
      workLoopSync();
      break;
    } catch (thrownValue) {
      console.log("renderRootSync捕获了异常.....", thrownValue);
      handleError(root, thrownValue);
    }
  } while (true);
}

workLoopSync执行的过程中发生异常时,会被handleError捕获。handleError 会从当前抛出异常的 fiber 节点开始(这里是 div#counter 对应的 fiber 节点)往上找到最近的错误边界组件,即 ErrorBoundary,如果不存在 ErrorBoundary 组件,则会找到 root fiber。然后 handleError 执行完成。循环继续,此时workLoopSync重新执行,workLoopSync又会从 root fiber 重新执行,这里有两种情况

  • 如果存在 ErrorBoundary,那么workLoopSync会从 ErrorBoundary 开始执行,并渲染 ErrorBoundary 的备用 UI
  • 如果不存在 ErrorBoundary,那么workLoopSync会从 root 节点开始执行,React 会直接卸载整个组件树,页面崩溃白屏。然后在 commit 阶段执行完成后将异常重新抛出,这次抛出的异常会被浏览器的 Pause on exceptions 捕获到

因此,workLoopSync的重复执行,要么会让页面崩溃,要么显示我们的备用 UI。

function handleError(root, thrownValue) {
  do {
    var erroredWork = workInProgress;

    try {
      throwException(root, erroredWork.return, erroredWork, thrownValue);
      completeUnitOfWork(erroredWork);
    } catch (yetAnotherThrownValue) {
      // Something in the return path also threw.
      continue;
    }

    return;
  } while (true);
}

而往上查找 ErrorBoundary 的任务就由throwException函数完成。throwException 主要做两件事:

    1. 调用createCapturedValue从当前抛出异常的 fiber 节点开始往上找出所有的 fiber 节点并收集起来,用于在控制台打印 fiber 栈,如下:

image

    1. while 循环负责往上找 ErrorBoundary 组件,如果找不到 ErrorBoundary 组件,则找到 root fiber 来处理异常。这里需要注意这个查找过程,只会找类组件以及
      root 节点。同时,类组件需要满足实现getDerivedStateFromError或者componentDidCatch方法才能成为 ErrorBoundary

注意!!createRootErrorUpdate 创建的更新对象中,update.element 已经被重置为 null 了,因此在 workLoopSync 第二次执行时,root 的子节点是 null,这也是为啥我们页面白屏的原因。如果是找到了 ErrorBoundary 组件,createClassErrorUpdate 在创建 update 对象时,会将 getDerivedStateFromError 做为 update.payload,这样在 workLoopSync 重复执行时,render 阶段就会执行这个 getDerivedStateFromError 函数以获取 ErrorBoundary 的 state

function throwException(root, returnFiber, sourceFiber, value) {
  sourceFiber.flags |= Incomplete; // 将当前fiber节点标记为未完成
  // 由于当前fiber节点已经抛出异常,他对应的副作用链表已经没用了,需要重置
  sourceFiber.firstEffect = sourceFiber.lastEffect = null;
  // createCapturedValue主要的一个功能就是从发生异常的fiber节点开始,往上继续找出所有的fiber节点信息,用于在控制台
  // 打印fiber栈信息
  value = createCapturedValue(value, sourceFiber);

  var workInProgress = returnFiber;

  do {
    switch (workInProgress.tag) {
      case HostRoot: {
        var _errorInfo = value;
        workInProgress.flags |= ShouldCapture;
        // 注意!!createRootErrorUpdate创建的更新对象中,update.element已经被重置为null了,因此在workLoopSync第二次执行时,root的子节点是null,这也是为啥我们页面白屏的原因
        var _update = createRootErrorUpdate(workInProgress, _errorInfo, lane);

        enqueueCapturedUpdate(workInProgress, _update);
        return;
      }

      case ClassComponent:
        // Capture and retry
        var errorInfo = value;
        var ctor = workInProgress.type;
        var instance = workInProgress.stateNode;

        if (
          (workInProgress.flags & DidCapture) === NoFlags &&
          (typeof ctor.getDerivedStateFromError === "function" ||
            (instance !== null &&
              typeof instance.componentDidCatch === "function"))
        ) {
          workInProgress.flags |= ShouldCapture;

          var _update2 = createClassErrorUpdate(
            workInProgress,
            errorInfo,
            _lane
          );

          enqueueCapturedUpdate(workInProgress, _update2);
          return;
        }

        break;
    }

    workInProgress = workInProgress.return;
  } while (workInProgress !== null);
}

注意,throwException执行完成后,会调用completeUnitOfWork继续完成工作。此时的 completeUnitOfWork 会走 else 的逻辑,主要做几件事:

  • 调用 unwindWork 恢复 context 栈信息,并且找到 ErrorBoundary 组件,如果存在 ErrorBoundary,则将当前的 fiber 返回并终止 completeUnitOfWork 函数执行。否则返回 root 节点。
  • 往上将抛出异常的 fiber 节点的父节点都标记为 Incomplete 并调用 completeUnitOfWork 完成父节点
// 主要两个工作
// 调用unwinkWork重置context,然后往上找到最近的能够处理异常的ErrorBoundary,找不到的话,那就是root节点
function completeUnitOfWork(unitOfWork) {
  var completedWork = unitOfWork;
  do {
    var current = completedWork.alternate;
    var returnFiber = completedWork.return;

    if ((completedWork.flags & Incomplete) === NoFlags) {
    } else {
      // 当前fiber没有完成,因为有异常抛出,因此需要从栈恢复
      var _next = unwindWork(completedWork);
      if (_next !== null) {
        _next.flags &= HostEffectMask;
        workInProgress = _next;
        return;
      }
      if (returnFiber !== null) {
        returnFiber.firstEffect = returnFiber.lastEffect = null;
        returnFiber.flags |= Incomplete;
      }
    }
    completedWork = returnFiber; // Update the next thing we're working on in case something throws.
    workInProgress = completedWork;
  } while (completedWork !== null);
}

看到这里,需要注意一点,workLoopSync 第二次重复执行时,从哪个节点开始,也是分情况的:

  • 如果没有找到 ErrorBoundary,那么从 root fiber 节点开始执行 performUnitOfWork
  • 如果找到 ErrorBoundary 组件,那么只需要从 ErrorBoundary 组件开始执行 performUnitOfWork

handleError 总结

总的来说,handleError 主要是处理 render 阶段抛出的异常。 从当前抛出异常的节点开始,往上找,直到找到 ErrorBoundary 组件或者 root 节点。并将 cotext 恢复到 ErrorBoundary 或者 root 节点,然后重复执行 workLoopSync,第二次执行的 workLoopSync 从 ErrorBoundary 或者 root 节点开始执行 render 的过程

captureCommitPhaseError 如何处理异常

还是以上面的代码为例,这次修改一下 Couter 组件,在 useEffect 中抛出异常:

const Counter = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    console.log("use effect...", a);
  });
  return <div id="counter">{count}</div>;
};

captureCommitPhaseError用来处理 commit 阶段抛出的异常。主要是做了以下几件事:

  • 从当前抛出异常的 fiber 节点开始,往上找,找到 ErrorBoundary 组件或者 root 节点,并创建对应的 update 更新对象。
  • 调用 ensureRootIsScheduled 从 root 节点开始执行。

这里可以看出,render 阶段的异常会导致 React 从 ErrorBoundary 组件或者 root 节点开始重新执行。而 commit 阶段抛出的异常会导致 React 从 root 节点重新调度执行

function captureCommitPhaseError(sourceFiber, error) {
  var fiber = sourceFiber.return;

  while (fiber !== null) {
    if (fiber.tag === HostRoot) {
      // captureCommitPhaseErrorOnRoot(fiber, sourceFiber, error);
      return;
    } else if (fiber.tag === ClassComponent) {
      var ctor = fiber.type;
      var instance = fiber.stateNode;

      if (
        typeof ctor.getDerivedStateFromError === "function" ||
        typeof instance.componentDidCatch === "function"
      ) {
        var errorInfo = createCapturedValue(error, sourceFiber);
        var update = createClassErrorUpdate(fiber, errorInfo, SyncLane);
        enqueueUpdate(fiber, update);

        if (root !== null) {
          markRootUpdated(root, SyncLane, eventTime);
          ensureRootIsScheduled(root, eventTime);
        } else {
        }
        return;
      }
    }
    fiber = fiber.return;
  }
}

【React Scheduler源码第五篇】Scheduler延迟任务原理及源码手写

本章是手写 React Scheduler 异步任务调度源码系列的第五篇文章,在上一篇文章我们已经实现了按优先级调度任务,可以查看手写 scheduler 源码之优先级。本章我们继续实现延迟任务

延迟任务

前面几节介绍的任务是普通任务,需要添加到 taskQueue 中按优先级尽快执行。但有些任务是需要到一定时间后才能执行的。以需求排期类比,需求 A 需要依赖于需求 B,那需求 A 只能等到需求 B 完成后才能开始,如果需求 B 需要 3 天才能完成,那么需求 A 就需要 3 天后才能开始。我们将这类到达一定时间才能执行的任务称为延迟任务,延迟任务存储在 timerQueue 中,并且按照开始时间排序。

延迟任务的开始时间等于当前调度的时间加上 delay,然后我们可以启用定时器 setTimeout(handleDelayTask, delay)处理延迟任务

注意,taskQueue 中的普通任务是按照过期时间 expirationTime 排序的,而 timerQueue 中的延迟任务是按照开始时间 startTime 排序的

如何处理延迟任务

在 scheduler 中,延迟任务到期后会被添加到 taskQueue 中按过期时间重新排序处理。在处理 taskQueue 时,每执行完一次普通任务,都会检查 timerQueue 中是否有延迟任务到期了,如果有,则添加进 taskQueue 中。

以下面的 demo 为例

function printA(didTimeout) {
  sleep(7);
  console.log("A didTimeout:", didTimeout);
}
function printB(didTimeout) {
  sleep(120);
  console.log("B didTimeout:", didTimeout);
}
function printC(didTimeout) {
  sleep(7);
  console.log("C didTimeout:", didTimeout);
}
scheduleCallback(UserBlockingPriority, printA, { delay: 100 });
scheduleCallback(NormalPriority, printB);
scheduleCallback(NormalPriority, printC);

我们通过scheduleCallback添加了一个延迟任务,两个普通任务,此时 timerQueue 和 taskQueue 值如下:

timerQueue = [taskA];
taskQueue = [taskB, taskC];

通过 scheduleCallback 添加延迟任务 A 时,会启动一个定时器,间隔为 100 毫秒。通过scheduleCallback添加任务 B 时,会触发一个 Message Channel 事件。

可想而知,Message Channel 事件先触发,开始处理 taskQueue 中的任务。在处理前,sheduler 会先取消延迟任务的定时器,因为在处理 taskQueue 时,每执行完一个普通任务,都会判断 timerQueue 中的任务是否到时了,如果到时,就添加到 taskQueue 中重新排序。

首先处理的是 taskB,taskB 执行耗时 120 毫秒,taskB 执行完成,判断 timerQueue 中是否有任务到期了,可以发现,taskA 到期了,则添加到 taskQueue 中重新按照过期时间排序,由于 taskA 优先级比 taskC 高,因此 taskQueue=[taskA, taskC]。

如果执行完 taskQueue 中所有的任务,然后 timerQueue 中的任务还没到期,又该如何处理?比如将 printB 的执行时间sleep(120)改成sleep(7)。那么当执行完 taskB 时,发现 taskA 还没到期,则不做处理,继续执行 taskC,执行完 taskC 发现 taskA 还是没到期,这时候,就需要重新启动一个 setTimeout 定时器,定时器到期执行后,将 timerQueue 中的 taskA 取出添加到 taskQueue 中。

为什么延迟任务需要取出并添加到 taskQueue 中处理

延迟任务存储在 timerQueue 中,按 startTime 排序,到期后会被取出添加到 taskQueue 中,重新按照 expirationTime 进行排序。

我们知道 Scheduler 的主要目的是时间切片,即处理任务的时间控制在 5ms 内,不管是延迟任务还是普通任务,都是如此。延迟任务到期后就是普通任务,大可不必针对延迟任务又设计一套时间切片的 API。只需要将到期的延迟任务添加到 taskQueue 中,然后触发一个 messageChannel 事件即可,降低 API 复杂度。

源码实现

scheduleCallback

function scheduleCallback(priorityLevel, callback, options) {
  let delay = 0;
  if (typeof options === "object" && options !== null) {
    delay = options.delay || 0;
  }
  const startTime = new Date().getTime() + delay;
  let timeout;
  // 不同优先级代表不同的过期时间
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = IMMEDIATE_PRIORITY_TIMEOUT;
      break;

    case UserBlockingPriority:
      timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
      break;

    case IdlePriority:
      timeout = IDLE_PRIORITY_TIMEOUT;
      break;

    case LowPriority:
      timeout = LOW_PRIORITY_TIMEOUT;
      break;

    case NormalPriority:
    default:
      timeout = NORMAL_PRIORITY_TIMEOUT;
      break;
  }
  // 计算任务的截止时间
  const expirationTime = startTime + timeout;

  let newTask = {
    callback: callback,
    priorityLevel,
    startTime,
    expirationTime: expirationTime,
    sortIndex: -1,
  };
  if (delay) {
    newTask.sortIndex = startTime;
    push(timerQueue, newTask);
    // 【问题1:为什么需要这个判断?】
    // 如果taskQueue为空,同时新添加的newTask是最早需要执行的延迟任务,则我们需要取消之前的定时器
    // 启动一个更早的定时器
    if (!taskQueue.length && newTask === timerQueue[0]) {
      // 所有的任务都需要执行,但是新添加的这个newTask是最早需要执行的任务,因此我们需要取消之前的定时器
      // 重新启动一个更早的定时器
      if (isHostTimeoutScheduled) {
        // 取消之前的定时器
        console.log("取消之前的定时器");
        cancelHostTimeout();
      } else {
        isHostTimeoutScheduled = true;
      }
      // 启动一个更早的定时器
      // 开启一个settimeout定时器,startTime - currentTime,其实就是options.delay毫秒后执行handleTimeout
      console.log("启动一个定时器,delay:", delay);
      requestHostTimeout(handleTimeout, delay);
    }
  } else {
    newTask.sortIndex = expirationTime;
    push(taskQueue, newTask);
    if (!isHostCallbackScheduled) {
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    }
  }

  return newTask;
}

scheduler 方法增加了 options 参数,支持传递 delay 表示这是一个需要延迟执行的任务。

注意上面代码中的【问题 1:为什么需要这个判断?】。

在 scheduler 中,并不会为每个延迟任务开启一个定时器,不管添加多少个延迟任务,最多启动一个定时器,时间间隔为所有延迟任务中 delay 最小的那个值,可以查看下面的测试用例 1。

如果添加延迟任务时,taskQueue 已经有任务,则不会再启动一个定时器,因为 scheduler 在处理 taskQueue 时,每执行完一个普通任务,都会检查 timerQueue 中是否有延迟任务到期了,这也就没必要再在定时器中检查。但有一种情况例外,如果 taskQueue 都执行完成了,而 timerQueue 中还有延迟任务,则需要重新启动一个定时器,可以查看下面的测试用例 2。

这里的 requestHostTimeout 和 cancelHostTimeout 实现都比较简单

let taskTimeoutID = -1;
function requestHostTimeout(callback, ms) {
  taskTimeoutID = setTimeout(function () {
    callback(new Date().getTime());
  }, ms);
}

function cancelHostTimeout() {
  clearTimeout(taskTimeoutID);

  taskTimeoutID = -1;
}

handleTimeout 实现如下:

function handleTimeout(currentTime) {
  isHostTimeoutScheduled = false;
  advanceTimers(currentTime);

  // 如果已经触发了一个message channel事件,但是事件还没执行。刚好定时器这时候执行了,就会
  // 存在isHostCallbackScheduled为true的情况,此时就没必要再继续里面的逻辑了。因为
  // message channel中就会执行这些操作
  // 【问题2:为什么需要判断是否调度了Message Channel】
  if (!isHostCallbackScheduled) {
    // 如果timerQueue的第一个任务被取消了,则taskQueue可能为null,此时timerQueue后面的任务还是需要延迟执行
    if (taskQueue[0]) {
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    } else {
      // 【问题3:taskQueue什么时候会为空】
      var firstTimer = timerQueue[0];

      if (firstTimer) {
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
      }
    }
  }
}

handleTimeout 主要逻辑就是,定时器到期执行时,将到期的延迟任务从 timerQueue 取出,添加到 taskQueue 中。

对于问题 2,如果 taskQueue 不为空,则触发一个 message channel 处理 taskQueue,但是如果已经触发了 message channel,就没必要再重复触发了。

对于问题 3,如果 taskQueue 为空,说明延迟任务被取消了,重新启动一个 setTimeout 定时器,具体可以查看下面的测试用例 3

advanceTimers

advanceTimers 主要的逻辑如下:

取出 timerQueue 中到期了的任务,即 startTime < currentTime 的任务,然后添加到 taskQueue 中重新按照过期时间排序。由于 timerQueue 已经按照 startTime 从小到大排好序了,因此在和 currentTime 的比较中,如果前面的任务(比如第一个)的 startTime 都大于 currentTime,就无需继续比较后面的任务了,因为后面的任务 startTime 更大。

// 找出那些到时的不需要再延迟执行的任务,添加到taskQueue中
function advanceTimers(currentTime) {
  var timer = timerQueue[0];

  while (timer) {
    if (timer.callback === null) {
      // 任务被取消了
      timerQueue.shift();
    } else if (timer.startTime <= currentTime) {
      // 任务到时了,需要执行,添加到taskQueue调度执行
      timerQueue.shift();
      timer.sortIndex = timer.expirationTime;
      push(taskQueue, timer);
    } else {
      // 如果第一个任务都还没到时,说明剩下的都还需要延迟
      return;
    }

    timer = timerQueue[0];
  }
}

workLoop

在 workLoop 中,每执行完一次普通任务,都会调用 advanceTimers 处理 timerQueue 中的延迟任务,将到期的延迟任务取出并添加到 taskQueue 中

function flushWork(initialTime) {
  // 【问题1:取消延迟任务的定时器】
  if (isHostTimeoutScheduled) {
    // 如果之前启动过定时器,则取消。因为在workLoop内部每执行一个任务,都会调用advanceTimers将
    // timerQueue中到期执行的任务加入到taskQueue中去执行。但taskQueue全部执行完成,
    // 如果timerQueue还有工作,此时就会重新启动定时器延迟执行timerQueue中的任务
    isHostTimeoutScheduled = false;
    cancelHostTimeout();
  }
  return workLoop(initialTime);
}
function workLoop(initialTime) {
  let currentTime = initialTime;
  // 【问题2:开始执行时先检查下timerQueue是否有到期任务】
  advanceTimers(currentTime);

  currentTask = taskQueue[0];

  while (currentTask) {
    if (currentTask.expirationTime > currentTime && shouldYield()) {
      // 当前的currentTask还没过期,但是当前宏任务事件已经到达执行的最后期限,即我们需要
      // 将控制权交还给浏览器,剩下的任务在下一个事件循环中再继续执行
      // console.log("yield");
      break;
    }
    const callback = currentTask.callback;
    if (typeof callback === "function") {
      currentTask.callback = null;
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      callback(didUserCallbackTimeout);
      currentTime = new Date().getTime();
      if (currentTask === taskQueue[0]) {
        taskQueue.shift();
      }
      // 【问题3:每执行完一次普通任务,都检查timerQueue是否有到期任务】
      advanceTimers(currentTime);
    } else {
      taskQueue.shift();
    }
    currentTask = taskQueue[0];
  }

  if (currentTask) {
    // 如果taskQueue中还有剩余工作,则返回true
    return true;
  } else {
    isHostCallbackScheduled = false;
    // 如果taskQueue已经没有工作,同时timerQueue还有工作,则需要启用一个定时器延迟执行
    var firstTimer = timerQueue[0];
    // 【问题4:如果所有的普通任务都已经执行完成,timerQueue还有延迟任务,则需要启动一个定时器】
    if (firstTimer) {
      console.log(
        "taskQueue全部执行完成了,但是timerQueue还有任务,因此启动一个定时器"
      );
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }

    return false;
  }
}

对于问题 1-4,可以查看下面的测试用例 4

测试用例

测试用例 1:只添加多个延迟任务

即使添加多个延迟任务,最多只会启动一个 setTimeout 定时器,定时器的间隔以 delay 最小的为主。本例中,首先添加的是 taskA,启动一个定时器setTimeout(handleTimeout, 2000)。然后又添加的是 taskB,scheduler 会先取消之前的 taskA 的定时器,然后再启动一个更早的定时器setTimeout(handleTimeout, 1000)

btn.onclick = () => {
  function sleep(ms) {
    const start = new Date().getTime();
    while (new Date().getTime() - start < ms) {}
  }
  function printA(didTimeout) {
    sleep(7);
    console.log("A didTimeout:", didTimeout);
  }
  function printB(didTimeout) {
    sleep(7);
    console.log("B didTimeout:", didTimeout);
  }
  scheduleCallback(UserBlockingPriority, printA, { delay: 2000 });
  scheduleCallback(UserBlockingPriority, printB, { delay: 1000 });
};

测试用例 2:先添加普通任务,再添加延迟任务

先添加一个普通任务 A,再添加一个延迟任务 B,由于 taskQueue 有任务,则 scheduler 不会为延迟任务 B 启动一个 setTimeout 定时器。执行完任务 A 后,再判断是否需要为任务 B 启动 setTimeout 定时器

本例中,taskB 延迟 10 毫秒执行,当执行完 taskA 时,taskB 还没到期,此时 taskQueue 为空,需要重新启动一个定时器setTimeout(handleTimeout, 3),可以思考下为啥是 3 毫秒

如果将printAsleep(7)改成sleep(20),那么当 printA 执行完成,此时 taskB 已经到期,直接添加到 taskQueue 中处理,而不用重新启动一个定时器

btn.onclick = () => {
  function sleep(ms) {
    const start = new Date().getTime();
    while (new Date().getTime() - start < ms) {}
  }
  function printA(didTimeout) {
    sleep(7);
    console.log("A didTimeout:", didTimeout);
  }
  function printB(didTimeout) {
    sleep(7);
    console.log("B didTimeout:", didTimeout);
  }
  scheduleCallback(UserBlockingPriority, printA);
  scheduleCallback(UserBlockingPriority, printB, { delay: 10 });
};

测试用例 3:延迟任务被取消

本例中,添加了两个延迟任务,scheduler 会启动一个定时器setTimeout(handleTimeout, 100),但紧接着 taskA 取消了。100 毫秒后,handleTimeout 检查 timerQueue 中到期的任务,发现 taskA 被取消了,因此会为 taskB 再重新启动一个定时器

btn.onclick = () => {
  function sleep(ms) {
    const start = new Date().getTime();
    while (new Date().getTime() - start < ms) {}
  }
  function printA(didTimeout) {
    sleep(7);
    console.log("A didTimeout:", didTimeout);
  }
  function printB(didTimeout) {
    sleep(7);
    console.log("B didTimeout:", didTimeout);
  }
  const taskA = scheduleCallback(UserBlockingPriority, printA, { delay: 100 });
  scheduleCallback(UserBlockingPriority, printB, { delay: 200 });
  // 取消taskA
  taskA.callback = null;
};

测试用例 4:普通任务执行完成,启动定时器处理剩余的延迟任务

首先添加两个延迟任务,并启动一个定时器setTimeout(handleTimeout, 10)。然后添加两个普通任务 C 和 D。然后触发一个 message channel 事件。在 message channel 事件中,我们先取消延迟任务的定时器,因为在对普通任务的处理时,会检查 timerQueue 中是否有到期的延迟任务,就不必在定时器中检查了。执行完所有的普通任务后,发现 timerQueue 中还有一个 taskB,因此再触发一个定时器处理剩余的 timerQueue

btn.onclick = () => {
  function sleep(ms) {
    const start = new Date().getTime();
    while (new Date().getTime() - start < ms) {}
  }
  function printA(didTimeout) {
    sleep(2);
    console.log("A didTimeout:", didTimeout);
  }
  function printB(didTimeout) {
    sleep(3);
    console.log("B didTimeout:", didTimeout);
  }
  function printC(didTimeout) {
    sleep(12);
    console.log("C didTimeout:", didTimeout);
  }
  function printD(didTimeout) {
    sleep(3);
    console.log("D didTimeout:", didTimeout);
  }
  scheduleCallback(UserBlockingPriority, printA, { delay: 10 });
  scheduleCallback(UserBlockingPriority, printB, { delay: 1000 });
  scheduleCallback(UserBlockingPriority, printC);
  scheduleCallback(UserBlockingPriority, printD);
};

小结

至此,我们已经实现延迟任务的问题,完整源码可以看这里

深入理解React中state和props的更新过程

好的文章就像 90 年代的港片让人回味无穷。这篇文章虽然写于 18 年,现在看来对理解 React Fiber 的工作流程依然有很大的帮助。有些 API 在最新版本的 React 中已经被废弃,但丝毫不影响整体流程的理解

深入理解 React 中 state 和 props 的更新

本文使用具有父组件和子组件的简单案例来演示 Fiber 架构中 React 将 props 传播到子组件的内部流程。

在我之前的文章 Fiber 内部:React 中新的协调算法的深入概述中,我奠定了理解本文介绍的更新过程的技术细节所需要的基础知识。

我已经概述了我将在本文中使用的主要数据结构和概念,特别是 Fiber 节点、current tree 和 workInProgress tree、副作用和副作用列表。我还高度概述了主要的算法,并解释了 render 和 commit 阶段之间的区别。如果你还没有读过,我建议你从上一篇文章开始。

我还介绍了示例应用程序,该应用程序带有一个按钮,点击按钮简单地递增屏幕上呈现的数字:

image

这是一个简单的组件,render 方法返回 button 和 span 两个子元素。单击按钮时,组件的状态就会更新。这会导致 span 元素的文本更新:

class ClickCounter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    this.setState((state) => {
      return { count: state.count + 1 };
    });
  }

  componentDidUpdate() {}

  render() {
    return [
      <button key="1" onClick={this.handleClick}>
        Update counter
      </button>,
      <span key="2">{this.state.count}</span>,
    ];
  }
}

在这里,我给组件添加了 componentDidUpdate 生命周期方法。这是为了演示 React 在 commit 阶段是怎样添加副作用并调用 componentDidUpdate 方法。

在本文中,我将介绍 React 如何处理状态更新并构建副作用列表。我们将了解 render 和 commit 阶段的主要函数都做了什么事情。

特别是,我们将在 completeWork函数中看到,React 进行:

  • 更新 ClickCounter 组件中的 state.count 属性
  • 调用 render 方法获取子元素列表并进行比较
  • 更新 span 元素的 props 属性

同时,在 commitRoot 函数中,React 会:

  • 更新 span 元素的 textContent 属性
  • 调用 componentDidUpdate 生命周期方法

但在此之前,让我们快速看一下在 click 事件中调用 setState 时,React 是如何调度的。

请注意,你无需了解任何内容即可使用 React。这篇文章是关于 React 工作原理的。

调度更新(Scheduling updates)

当我们点击按钮时,click 事件被触发,React 执行我们在按钮中绑定的回调。在我们的应用程序中,它只是增加计数器并更新状态:

class ClickCounter extends React.Component {
    ...
    handleClick() {
        this.setState((state) => {
            return {count: state.count + 1};
        });
    }
}

每个 React 组件都有一个关联的 updater,它充当组件和 React 内核之间的桥梁。这允许 ReactDOM、React Native、服务器端渲染和测试实用程序以不同方式实现 setState。

在本文中,我们将探讨 ReactDOM 中 updater 对象的实现,它使用 Fiber reconciler。对于 ClickCounter 组件,它是一个 classComponentUpdater。 它负责检索 Fiber 实例、将更新添加到队列中以及调度。

当添加更新时,它们只是简单的添加到更新队列中以便在 Fiber 节点上处理。在我们的例子中,ClickCounter 组件对应的 Fiber 节点 的结构如下:

{
    stateNode: new ClickCounter,
    type: ClickCounter,
    updateQueue: {
         baseState: {count: 0}
         firstUpdate: {
             next: {
                 payload: (state) => { return {count: state.count + 1} }
             }
         },
         ...
     },
     ...
}

可以看到,updateQueue.firstUpdate.next.payload 里面的函数就是我们在 ClickCounter 组件中传递给 setState 的回调。它代表了 render 阶段中需要处理的第一个更新

处理 ClickCounter Fiber 节点的更新(Processing updates for the ClickCounter Fiber node)

我之前的文章中关于工作循环的章节解释了全局变量 nextUnitOfWork 的作用。特别是,它说明了这个变量保存的是 workInProgress 树中需要处理的 fiber 节点的引用。当 React 遍历 Fibers 树时,它使用这个变量来了解是否有尚未完成工作的 fiber 节点。

假设我们已经调用了 setState 方法。React 将 setState 中的回调添加到 ClickCounter Fiber 节点的 updateQueue 中并开始调度。React 进入 render 阶段。它在 renderRoot 函数里面从最顶层的 HostRoot Fiber 节点开始遍历。但是,它会退出(跳过)已处理的 Fiber 节点,直到找到未完成工作的节点。此时只有一个 Fiber 节点需要处理。它是 ClickCounter Fiber 节点。

所有工作都在这个 Fiber 节点的克隆副本上执行,(副本)存储在 Fiber 节点的 alternate 字段中。如果尚未创建 alternate 节点,那么在处理更新前,React 会在函数 createWorkInProgress 中创建副本。让我们假设变量 nextUnitOfWork 指向 ClickCounter Fiber 节点的 alternate 节点。

开始工作(beginWork)

我们的 Fiber 节点首先经过 beginWork 函数处理。

因为 Fiber 树中每个 Fiber 节点都会经过 beginWork 函数处理,所以如果你想调试 render 阶段,这是一个打断点的好地方。我经常这样做并根据 Fiber 节点的 type 添加条件断点

beginWork 函数就是一个大 switch 语句,它通过 tag 确定一个 Fiber 节点需要完成的工作类型,然后执行相应的函数来执行工作。在本例中, CountClicks 是一个类组件,因此采用此分支:

function beginWork(current$$1, workInProgress, ...) {
    ...
    switch (workInProgress.tag) {
        ...
        case FunctionalComponent: {...}
        case ClassComponent:
        {
            ...
            return updateClassComponent(current$$1, workInProgress, ...);
        }
        case HostComponent: {...}
        case ...
}

我们进入 updateClassComponent 函数。根据组件是第一次渲染还是更新,React 会创建一个实例或者挂载组件并更新

function updateClassComponent(current, workInProgress, Component, ...) {
    ...
    const instance = workInProgress.stateNode;
    let shouldUpdate;
    if (instance === null) {
        ...
        // In the initial pass we might need to construct the instance.
        constructClassInstance(workInProgress, Component, ...);
        mountClassInstance(workInProgress, Component, ...);
        shouldUpdate = true;
    } else if (current === null) {
        // In a resume, we'll already have an instance we can reuse.
        shouldUpdate = resumeMountClassInstance(workInProgress, Component, ...);
    } else {
        shouldUpdate = updateClassInstance(current, workInProgress, ...);
    }
    return finishClassComponent(current, workInProgress, Component, shouldUpdate, ...);
}

处理 ClickCounter Fiber 的更新(Processing updates for the ClickCounter Fiber)

我们已经有了 ClickCounter 组件的实例,所以我们进入 updateClassInstance这是 React 为类组件执行大部分工作的地方。以下是函数中按执行顺序执行的最重要的操作:

  • 调用 UNSAFE_componentWillReceiveProps()钩子(已弃用)
  • 处理 updateQueue 中的更新并生成新状态
  • 使用新状态调用 getDerivedStateFromProps 并得到结果
  • 调用 shouldComponentUpdate 判断组件是否需要更新:
    • 如果是 false,跳过整个渲染过程,不再继续调用这个组件及其子组件的 render 方法
  • 调用 UNSAFE_componentWillUpdate(已弃用)
  • 添加一个 effect 以便后续触发 componentDidUpdate 生命周期钩子

    虽然在 render 阶段添加了触发 componentDidUpdate 调用的 effect,但 componentDidUpdate 方法在接下来的 commit 阶段才会被执行

  • 更新组件实例上的 state 和 props

组件实例上的 state 和 props 必须在调用 render 方法前更新。因为 render 方法的输出依赖于 state 和 props。如果我们不这样做,它将每次返回相同的结果。

这是该函数的简化版本:

function updateClassInstance(current, workInProgress, ctor, newProps, ...) {
    const instance = workInProgress.stateNode;

    const oldProps = workInProgress.memoizedProps;
    instance.props = oldProps;
    if (oldProps !== newProps) {
        callComponentWillReceiveProps(workInProgress, instance, newProps, ...);
    }

    let updateQueue = workInProgress.updateQueue;
    if (updateQueue !== null) {
        processUpdateQueue(workInProgress, updateQueue, ...);
        newState = workInProgress.memoizedState;
    }

    applyDerivedStateFromProps(workInProgress, ...);
    newState = workInProgress.memoizedState;

    const shouldUpdate = checkShouldComponentUpdate(workInProgress, ctor, ...);
    if (shouldUpdate) {
        instance.componentWillUpdate(newProps, newState, nextContext);
        workInProgress.effectTag |= Update;
        workInProgress.effectTag |= Snapshot;
    }

    instance.props = newProps;
    instance.state = newState;

    return shouldUpdate;
}

我在上面的代码片段中删除了一些辅助代码。例如,在调用生命周期方法或者添加触发生命周期方法执行的 effect 之前,React 会使用 typeof 操作符检查组件是否实现了对应的生命周期方法。例如,在添加 effect 之前,React 会检查组件实例是否存在 componentDidUpdate 方法。

if (typeof instance.componentDidUpdate === "function") {
  workInProgress.effectTag |= Update;
}

好的,现在我们知道在 render 阶段 ClickCounter Fiber 节点都执行了哪些操作。现在让我们看看这些操作如何改变 Fiber 节点上的值。当 React 开始工作时,ClickCounter 组件的 Fiber 节点看起来像这样:

{
    effectTag: 0,
    elementType: class ClickCounter,
    firstEffect: null,
    memoizedState: {count: 0},
    type: class ClickCounter,
    stateNode: {
        state: {count: 0}
    },
    updateQueue: {
        baseState: {count: 0},
        firstUpdate: {
            next: {
                payload: (state, props) => {}
            }
        },
        ...
    }
}

工作完成后,我们最终得到一个如下所示的 Fiber 节点:

{
    effectTag: 4,
    elementType: class ClickCounter,
    firstEffect: null,
    memoizedState: {count: 1},
    type: class ClickCounter,
    stateNode: {
        state: {count: 1}
    },
    updateQueue: {
        baseState: {count: 1},
        firstUpdate: null,
        ...
    }
}

花点时间观察属性值的差异。

更新完成后,fiber.memoizedState 以及 fiber.updateQueue.baseState 中的 count 属性都变成了 1。React 还更新了 ClickCounter 组件实例中的 state。

此时,队列中不再有更新,因此 firstUpdate 被设置成 null。重要的是,我们的 effectTag 属性发生了变化。它不再是 0,它变成了 4。在二进制中就是 100,这意味着第三位设置成了 1,这正是 Update 副作用标签的位:

export const Update = 0b00000000100;

总而言之,当在 ClickCounter Fiber (父)节点上工作时,React 会调用 pre-mutation 生命周期方法,更新状态并定义相关的副作用。

协调 ClickCounter Fiber 的子元素(Reconciling children for the ClickCounter Fiber)

一旦完成,React 就会进入 finishClassComponent函数。这是 React 调用组件实例上的 render 方法并将 dom diff 算法应用于组件返回的子元素的地方。文档中有高度概括。这是相关部分:

当比较两个相同类型的 React DOM 元素时,React 会查看两者的属性,复用相同的底层 DOM 节点,并且只更新变化的属性。

然而,如果我们深入挖掘,我们可以了解到它实际上是将 Fiber 节点与 React element 进行了比较。但我现在不会详细介绍,因为这个过程非常复杂。我将写一篇单独的文章,重点介绍子元素协调的过程。

如果你急于了解详细信息,请查看 reconcileChildrenArray 函数,因为在我们的应用程序中,render 方法返回的是一个 React element 数组。

在这一点上,有两件重要的事需要理解。首先,当 React 处理子元素协调过程时,它会为 render 方法返回的子 React 元素创建或更新 Fiber 节点。finishClassComponent 函数返回当前 Fiber 节点的第一个子节点的引用。它将分配给 nextUnitOfWork 并在稍后的工作循环中处理。其次,React 将更新子元素的 props 作为父组件工作的一部分(即子元素的 props 更新是在父组件中完成的)。为此,它使用 render 方法返回的 React 元素中的数据。

例如,在 React 开始协调 ClickCounter Fiber 的子元素前,span 元素对应的 Fiber 节点如下所示:

{
    stateNode: new HTMLSpanElement,
    type: "span",
    key: "2",
    memoizedProps: {children: 0},
    pendingProps: {children: 0},
    ...
}

正如你所看到的,memoizedProps 以及 pendingProps 中的 children 属性都是 0。下面是 render 方法返回的 span 元素的结构:

{
  $$typeof: Symbol(react.element);
  key: "2";
  props: {
    children: 1;
  }
  ref: null;
  type: "span";
}

如你所见,Fiber 节点和返回的 React element 之间的 props 存在差异。 createWorkInProgress函数用于创建 alternate Fiber 节点,在函数内部 React 会将更新后的属性从 React 元素复制到 Fiber 节点。

因此,在 React 完成 ClickCounter 组件的子元素协调之后,span 的 Fiber 节点的 pendingProps 属性更新完成。它们与 span element 的值匹配。

{
    stateNode: new HTMLSpanElement,
    type: "span",
    key: "2",
    memoizedProps: {children: 0},
    pendingProps: {children: 1},
    ...
}

稍后,当 React 为 span Fiber 节点执行工作时,它会将 pendingProps 复制到 memoizedProps 并添加 effects 以更新 DOM。

好吧,这就是 React 在 render 阶段为 ClickCounter Fiber 节点执行的所有工作。由于按钮是 ClickCounter 组件的第一个子节点,它将被分配给 nextUnitOfWork 变量。(按钮节点)没有什么可做的,所以 React 将移动到它的兄弟节点,即 span Fiber 节点。根据这里描述的算法,它发生在 completeUnitOfWork 函数中

处理 Span fiber 的更新(Processing updates for the Span fiber)

变量 nextUnitOfWork 现在指向 span fiber 的备用(alternate)节点,React 开始处理它。与为 ClickCounter 执行的步骤类似,我们从 beginWork 函数开始。

由于我们的 span 节点是 HostComponent 类型的,所以这次在 switch 语句中 React 采用了这个分支:

function beginWork(current$$1, workInProgress, ...) {
    ...
    switch (workInProgress.tag) {
        case FunctionalComponent: {...}
        case ClassComponent: {...}
        case HostComponent:
          return updateHostComponent(current, workInProgress, ...);
        case ...
}

并在 updateHostComponent 函数中结束。同时,你还可以看到为 ClassComponent 调用的 updateClassComponent 函数。对于 FunctionalComponent,它将是 updateFunctionComponent 等等。你可以在ReactFiberBeginWork.js 文件中找到所有这些函数

协调 span fiber 的子元素(Reconciling children for the span fiber)

在我们的例子中,updateHostComponent 函数并没有对 span 节点做任何重要的事情。因此可以简单略过

完成 Span Fiber 节点的工作(Completing work for the Span Fiber node)

beginWork 完成后,节点进入 completeWork 函数。但在此之前,React 需要更新 span fiber 上的 memoizedProps 属性。你可能还记得在为 ClickCounter 组件协调子元素时,React 更新了 span fiber 节点上的 pendingProps 属性

{
    stateNode: new HTMLSpanElement,
    type: "span",
    key: "2",
    memoizedProps: {children: 0},
    pendingProps: {children: 1},
    ...
}

因此,一旦 span fiber 节点的 beginWork 完成了,React 就会更新 memoizedProps:

function performUnitOfWork(workInProgress) {
    ...
    next = beginWork(current$$1, workInProgress, nextRenderExpirationTime);
    workInProgress.memoizedProps = workInProgress.pendingProps;
    ...
}

然后它调用 completeWork 函数,和 beginWork 函数一样,completeWork 函数也只是一个大的 switch 语句:

function completeWork(current, workInProgress, ...) {
    ...
    switch (workInProgress.tag) {
        case FunctionComponent: {...}
        case ClassComponent: {...}
        case HostComponent: {
            ...
            updateHostComponent(current, workInProgress, ...);
        }
        case ...
    }
}

由于我们的 span Fiber 节点是 HostComponent,它调用updateHostComponent 函数。在这个函数中,React 基本上做了以下事情:

  • 准备 DOM 更新
  • 将它们添加到 span fiber 的 updateQueue 中。
  • 添加更新 DOM 的 effect

在执行这些操作之前,span fiber 节点如下所示:

{
    stateNode: new HTMLSpanElement,
    type: "span",
    effectTag: 0
    updateQueue: null
    ...
}

当工作完成后,它看起来像这样:

{
    stateNode: new HTMLSpanElement,
    type: "span",
    effectTag: 4,
    updateQueue: ["children", "1"],
    ...
}

注意 effectTag 和 updateQueue 字段的差异。effectTag 从 0 变成 4。在二进制中这是 100,这意味着第三位设置成了 1,这正是 update 副作用对应的 tag 类型。这是 React 在接下来的 commit 阶段需要为这个节点做的唯一工作。updateQueue 字段保存了将用于更新的数据(payload)。

一旦 React 处理完成 ClickCounter 及其子元素,render 阶段就完成了。它现在可以将完成的 alternate 树分配给 FiberRoot 的 finishedWork 属性。这是需要刷新到屏幕上的新树。它可以在 render 阶段之后立即处理,也可以在浏览器空闲时间处理。

副作用列表(Effects list)

在我们的例子中,由于 span 节点和 ClickCounter 组件都有副作用,React 会将 span fiber 节点的链接添加到 HostFiber 的 firstEffect 属性.

React 在 completeUnitOfWork 函数中构建副作用列表。这是具有更新 span 节点文本和调用 ClickCounter 钩子
副作用的 Fiber 树的样子:

image

这是具有副作用的节点的线性列表:

image

提交阶段(Commit phase)

这个阶段从completeRoot 函数开始。在它开始做任何工作之前,它将 FiberRoot 的 finishedWork 属性重置为 null:

root.finishedWork = null;

与 render 阶段不同,commit 阶段始终是同步的,因此它可以安全地更新 HostRoot 以指示 commit 工作已经开始。

在 commit 阶段, React 更新 DOM 并调用 post mutation 生命周期方法,如 componentDidUpdate。为此,它会遍历在 render 阶段构建的副作用列表并应用它们。

我们在 render 阶段中为我们的 span 和 ClickCounter 节点定义了以下 effects:

{ type: ClickCounter, effectTag: 5 }
{ type: 'span', effectTag: 4 }

ClickCounter 的 effect tag 是 5 或 二进制的 101,这意味着需要调用 componentDidUpdate 方法。最低有效位也设置为表示该 Fiber 节点在 render 阶段的所有工作都已完成。

span 的 effect tag 是 4 或 二进制的 100,定义了需要更新 host component 的 dom 节点的更新工作。对于 span 元素,React 需要更新元素的 textContent 属性。

应用效果(Applying effects)

让我们看看 React 如何应用这些 effects。用于应用 effects 的commitRoot函数由 3 个子函数组成:

function commitRoot(root, finishedWork) {
  commitBeforeMutationLifecycles();
  commitAllHostEffects();
  root.current = finishedWork;
  commitAllLifeCycles();
}

这些子函数中的每一个都实现了一个循环,这些循环遍历副作用列表并检查 effect 的类型。当它找到与函数功能相关的 effect 时,它会应用它。在我们的例子中,它将调用 ClickCounter 组件的 componentDidUpdate 生命周期方法并更新 span 元素的文本。

第一个函数 commitBeforeMutationLifeCycles 查找 Snapshot effect 并调用 getSnapshotBeforeUpdate 方法。但是,由于我们没有在 ClickCounter 组件上实现这个方法,所以 React 没有在 render 阶段添加对应的 Snapshot effect。所以在我们的例子中,这个函数什么都不做。

DOM 更新(DOM updates)

下一步,React 执行commitAllHostEffects 函数。这里 React 将 span 元素的文本从 0 更改为 1。由于类组件对应的节点没有任何 DOM 更新,因此这里不需要处理 ClickCounter fiber。

这个函数的目的是选择正确的 effect 类型并执行相应的操作。在我们的例子中,我们需要更新 span 元素上的文本,所以我们在这里使用 Update 分支:

function updateHostEffects() {
    switch (primaryEffectTag) {
      case Placement: {...}
      case PlacementAndUpdate: {...}
      case Update:
        {
          var current = nextEffect.alternate;
          commitWork(current, nextEffect);
          break;
        }
      case Deletion: {...}
    }
}

继续往下执行 commitWork 函数,我们最终进入updateDOMProperties 函数。它使用 render 阶段添加的 updateQueue 数据更新 span 元素的 textContent 属性。

function updateDOMProperties(domElement, updatePayload, ...) {
  for (let i = 0; i < updatePayload.length; i += 2) {
    const propKey = updatePayload[i];
    const propValue = updatePayload[i + 1];
    if (propKey === STYLE) { ...}
    else if (propKey === DANGEROUSLY_SET_INNER_HTML) {...}
    else if (propKey === CHILDREN) {
      setTextContent(domElement, propValue);
    } else {...}
  }
}

在应用 DOM 更新后,React 将 finishedWork 树分配给 HostRoot。它将 alternate 树设置为 current 树:

root.current = finishedWork;

调用 post mutation 生命周期钩子(Calling post mutation lifecycle hooks)

最后剩下的函数是 commitAllLifecycles。这里 React 调用 post mutational 生命周期方法。在 render 阶段,React 将 Update effect 添加到 ClickCounter 组件中。这是 commitAllLifecycles 函数寻找并调用 componentDidUpdate 方法的效果之一:

function commitAllLifeCycles(finishedRoot, ...) {
    while (nextEffect !== null) {
        const effectTag = nextEffect.effectTag;

        if (effectTag & (Update | Callback)) {
            const current = nextEffect.alternate;
            commitLifeCycles(finishedRoot, current, nextEffect, ...);
        }

        if (effectTag & Ref) {
            commitAttachRef(nextEffect);
        }

        nextEffect = nextEffect.nextEffect;
    }
}

该函数还会更新 refs,但由于我们没有任何此功能,因此不会使用。componentDidUpdate 方法在 commitLifeCycles 函数中被调用:

function commitLifeCycles(finishedRoot, current, ...) {
  ...
  switch (finishedWork.tag) {
    case FunctionComponent: {...}
    case ClassComponent: {
      const instance = finishedWork.stateNode;
      if (finishedWork.effectTag & Update) {
        if (current === null) {
          instance.componentDidMount();
        } else {
          ...
          instance.componentDidUpdate(prevProps, prevState, ...);
        }
      }
    }
    case HostComponent: {...}
    case ...
}

你还可以看到,这是 React 为第一次渲染的组件调用 componentDidMount 方法的地方

原文链接

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.