[FE] React 初窥门径(七):hook 状态创建/更新原理

1. 回顾

上一篇 文章我们介绍了 React 函数组件的更新过程。
我们来总结以下 组件加载 和 更新 的全流程。

(1)组件载入时,会创建两棵 Fiber Tree

一棵为当前已写入 DOM 的 Fiber Tree(名为 current)。
commit 阶段 之前这个 Fiber Tree 只有一个根节点。

另一棵为当前正在渲染的 Fiber Tree,(名为 workInProgress)。
render 阶段 就是在创建它。

到了 commit 阶段,React 会将 workInProgress 的 Fiber Tree 实际写到 DOM 中,
然后将 current 指向这个 Fiber Tree。

这样就完成了组件的首次加载。

(2)事件触发组件更新时

首先是由 React 的事件系统监听到用户事件,然后触发用户绑定的事件处理函数。
在这个事件处理函数中,示例中我们用了 hook setState 来更新组件组件状态。
执行过程中,会将 performSyncWorkOnRoot 放到 syncQueue 中。
然后,用户事件就执行完了。

用户事件执行完之后,React 会紧接着执行 flushSyncCallbackQueue
获取到 syncQueue 中的 performSyncWorkOnRoot 进行执行。

performSyncWorkOnRoot 实际上就是组件的 rendercommit 方法。
(在组件的第一次更新时)它会创建一棵 workInProgress 的 Fiber Tree,然后在 commit 阶段 写到 DOM 中(之后,将 current 指向这棵 Fiber Tree)。
(如果是组件非首次更新,此时内存中已经有了两棵 Fiber Tree 了,此时 render 阶段,并不会重新创建一棵全新的 Fiber Tree,而是尽可能利用现有 Fiber Tree 的节点,这个逻辑在 createWorkInProgress 中控制)。

如此这般,就完成了组件的更新。

以上分析中,我们是从 Fiber Tree 的角度,从 rendercommit 的角度来看待组件的更新过程,
略过了组件的状态的计算过程。

在实际开发中,常见的场景是,

  • 有多个 hook(setState
  • 一次更新调用了多次 setState

React 内部是如何处理这个状态计算的呢?本文我们来仔细研究下这个问题。

2. 场景:多个 hook

2.1 示例项目的修改

参考 example-project/src/AppTwoState.js

我们修改了 App 组件如下,

const App = () => {
  debugger;
  const [state1, setState1] = useState(0);
  debugger;
  const [state2, setState2] = useState('a');
  debugger;

  const onDivClick = () => {
    debugger;
    setState1(1);
    debugger;
    setState2('b');
    debugger;
  };

  debugger;
  return <div onClick={onDivClick}>
    {state1}-{state2}
  </div>;
}

其中用到了两个 hook(都是 useState),这样会给 App 组件创建两个独立的状态 state1 state2

2.2 两个 hook 的更新流程

我们来跟踪一下两个 useStatesetState1 setState2 的执行过程。

完整的执行流程在这里:7.1 hook 原理:多个 hook,总共分为三个部分:

  • 组件首次加载时,调用 useState第 1-50 行

  • 用户点击 div 时,setState 调用 lastRenderedReducer 更新状态(第 51-96 行

  • 事件响应完之前,React 调用 flushSyncCallbackQueue 更新状态(第 97-154 行

2.3 多个 hook 是怎么存储的

我们看到组件载入的时候,useState 会调用 mountWorkInProgressHook

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

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

  return workInProgressHook;
}

每次调用 useState 会创建一个新的 hook,多个 hook 构成了一个链表结构(第二个 hooknext 指向 第一个 hook

(1)第一个 hook


currentlyRenderingFiber$1<App /> 节点(Fiber Node),
并且,Fiber Node 的 memorizedState 指向了 hook 链表的第一个 hook

currentlyRenderingFiber$1.memoizedState = workInProgressHook = hook;

(2)第二个 hook
设置 第一个 hooknext 属性指向 第二个 hook

通过 Fiber Node(currentlyRenderingFiber$1)观察一下 hook 链表的结构,

currentlyRenderingFiber$1.memorizedState -> hook1
hook1.next -> hook2
hook2.next -> null

2.4 dispatch(setState1 setState2)

虽然 hook 是通过链表结构来存储的,但实际调用 setState1 setState2 的时候,却并不是通过链表来取的。

这是是因为虽然 setState 只传入了一个参数 action

但实际 React 已通过 bind 传入了其他参数,另外两个参数是 fiberqueue

fiber 就是上文那个 currentlyRenderingFiber$1queue 就是 setState1 对应 hook 的 queue 属性值(hook 相关的 update quque,下文介绍)

所以调用 setState1 setState2 时不用在 hook 链表中进行查找,而是直接进入 dispatchAction 函数中。

3. 场景:多次 dispatch

上文介绍了多个 hook 的存储和调用原理,在实际项目中,还会有一个事件中多次调用了 dispatch(setState),
这些 dispatch 函数也许是同一个状态的 dispatch(多次调用 setState),也许是不同状态的(先后调用 setState1 setState2)。
原理其实是大同小异的,为了简单起见,本文只介绍后者,即,一个事件中,多次调用了同一个 hook 的 dispatch(setState)的执行流程。

3.1 示例项目的修改

示例项目的修改如下,example-project/src/AppAsyncState.js
(为了便于跟踪,setState 采用了回调方式进行编写)

const App = () => {
  debugger;
  const [state, setState] = useState(0);
  debugger;

  const onDivClick = () => {
    debugger;
    setState(s => {
      debugger;
      return s + 1;
    });
    debugger;
    setState(s => {
      debugger;
      return s + 2;
    });
    debugger;
  };

  debugger;
  return <div onClick={onDivClick}>
    {state}
  </div>;
}

3.2 多次调用 setState 的执行流程

完整的执行流程可参考 7.2 hook 原理:多次调用,包含以下两个部分,
(省略了组件首次加载的流程)

(1)用户点击 div 触发事件,事件中调用了两次 setState第 1-65 行


我们看到 React 只执行了第一个状态更新函数(第一次 setStateaction 参数),

s => {
  debugger;
  return s + 1;
}

第二次 setStateaction 并未在这个阶段执行,而是将更新过程,放到了一个名为 update循环队列中。
参考 dispatchAction L16620

function dispatchAction(fiber, queue, action) {
  ...
  var update = {
    lane: lane,
    action: action,
    eagerReducer: null,
    eagerState: null,
    next: null
  };

  var pending = queue.pending;

  if (pending === null) {
    update.next = update;          // 第一次调用 setState 时,循环队列只有一个元素(自己指向自己)
  } else {
    update.next = pending.next;
    pending.next = update;         // <- 将 update 放到循环队列中(逻辑见下文解释)
  }

  queue.pending = update;
  var alternate = fiber.alternate;

  if (fiber === currentlyRenderingFiber$1 || alternate !== null && alternate === currentlyRenderingFiber$1) {
    ...
  } else {
    if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
      ...
      if (lastRenderedReducer !== null) {
        ...
        try {
          var currentState = queue.lastRenderedState;
          var eagerState = lastRenderedReducer(currentState, action);

          update.eagerReducer = lastRenderedReducer;    // <- 用来标记这个 update 元素已经计算过了
          update.eagerState = eagerState;

          if (objectIs(eagerState, currentState)) {
            return;
          }
        } catch (error) {// Suppress the error. It will throw again in the render phase.
        } finally {
          ...
        }
      }
    }
    ...
  }
  ...
}

update 逻辑如下,

  • 每个 hook 维护了一个 update quque,hookpending 属性指向了这个 quque 的队尾(队尾的 next 为队首)
  • 每次调用 setState(= dispatchAction)都会创建一个 update 节点
  • 第一次调用 setState,update quque 只包含了一个元素(自己指向自己),然后设置 hook.pending 指向这个 update 元素
  • 第二次调用 setState,会在 update quque 队尾添加一个元素,再设置当前这个队尾元素指向队首,
hook.pending -> 当前的 update 元素
(当前的 update 元素).next -> 队首
原队尾.next -> 当前的 update 元素

以上这样设置的好处是,可以从队尾元素开始,循环获取 next 元素,将队列按顺序处理一遍。

值得一提的是,React 采用了给 update.eagerReducer 赋值为 lastRenderedReducer 的方式,来标记这个 update 元素已经处理过了,

update.eagerReducer = lastRenderedReducer;

这里要留意一下,下文会用到。

(2)事件完成之前,React 通过 flushSyncCallbackQueue,更新 Fiber Tree,并写入到 DOM 中,第 67-166 行


其中 syncQueue 中保存了 performSyncWorkOnRoot,React 用它在事件结束之前更新页面(见 前一篇 的分析)
update quque 是本文介绍的内容,React 在每次调用 setState 的时候,会创建一个循环队列,然后在 performSyncWorkOnRootrender 阶段 再执行计算。

代码逻辑在这里 updateReducer L15761

function updateReducer(reducer, initialArg, init) {
  var hook = updateWorkInProgressHook();
  var queue = hook.queue;
  ...
  if (baseQueue !== null) {
    ...
    do {                                        // <- 从队首开始处理 update quque
      ...
      if (!isSubsetOfLanes(renderLanes, updateLane)) {
        ...
      } else {
        ...
        if (update.eagerReducer === reducer) {  // 用来标记第一个 setState 已经计算过了
          newState = update.eagerState;
        } else {
          var action = update.action;
          newState = reducer(newState, action); // 后续未计算过的 setState,会按顺序执行计算
        }
      }

      update = update.next;
    } while (update !== null && update !== first);
    ...
  }
  ...
}

这里出现了对 update 元素 update.eagerReducer 的判定,来区分这个元素所表示的 setState 是否已经计算过了。
所以,除了第一个 setState 是 “同步”(setState 返回之前)执行的之外,
后续各个 setState 都是 “异步”(setState 返回后,由 React 通过 flushSyncCallbackQueuerender 阶段) 执行的。

4. fiber, hook, update

Fiber Tree,Fiber Node,hook,Update Queue 四者的关系如下,


  • Fiber Tree 有两棵
    一棵是已写入到的 DOM 的(称为 current),一棵是用于 render 阶段处理的(称为 workInProgress
    Fiber Tree 的根节点的 tagHostRoot
    两棵 Fiber Tree 的根节点通过 stateNode 指向 FiberRootNode,它通过 containerInfo 保存了 html 元素 div#root
    Fiber Tree 的节点有三个属性,return 指向父节点,child 指向子节点,alternate 指向同级的另一棵 Fiber Tree

  • 一个 React 组件可以使用多个 hook(创建多个独立的状态)
    hook 保存在了 Fiber Node (代表 <App /> 元素的那个)的 memorizedState 属性中,多个 hook 以链表形式存储
    (同层级的 Fiber Node 共用一个 hook 对象)(可能会出现复制的情况)
    每一个 hook(比如 useState)返回一个新的 dispatch 方法,
    特定 dispatch 方法的每次调用,都会创建一个 update 元素,并添加到 update quque 中。
    hook.queue.pending 指向了 update queue 的队尾,队尾指向队首(循环队列)。

  • 组件通过 setState 进行状态更新时
    只有第一个 更新 会在 setState 返回值之前执行,不论是 setState(action) 中的 action 是数值还是函数
    后续所有(同一个或其他 hook)的 setState 调用,都会将更新放到 update quque 中,
    然后由 React 通过 flushSyncCallbackQueue 调用 performSyncWorkOnRootrender 阶段按顺序执行计算。


参考

React 初窥门径(六):React 组件的更新过程
github: thzt/react-tour
7.1 hook 原理:多个 hook
7.2 hook 原理:多次调用

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容