React 撕裂问题

1,023 阅读5分钟

先让我们把问题抛在这儿:React 中的撕裂(Tearing)问题是什么?我们为什么需要了解撕裂问题?

撕裂问题

撕裂是什么?撕裂是当你站在白沙湖边细腻松软的沙石上,眺望着视线尽头同湖水交融的朦胧雾气与白象似的群山,感受着湖岸猛烈的风握住手臂托起肩膀,试图将你的身体里某些轻盈的存在高高的送往云层之上,但躯壳的沉重负荷仍旧将你的靴子牢牢的楔进地里,同剐蹭着轮胎的公路与尖叫着横冲直撞的车流捆在一块。

好吧,换一种表达方式。撕裂是计算机视觉中的常用词汇,在电子游戏中没有开启垂直同步时,撕裂就表现为看到的帧画面没有得到同步的情况。在同一个视频帧中,上半部分的画面可能已经刷新到下一个状态,但是下半部分的画面还没来得及更新,就像这样:

JavaScript 本身是单线程的,本身不应该出现撕裂的问题。然而在 React 18 的大版本中,React 默认转为了 Concurrent 模式,引入了“并发渲染”的能力。异步意味着状态的同步可能出现不一致的情况,也就是 React 中潜在的撕裂问题。

那么,在 React 中的撕裂问题表现如何呢?

使用原生的 React setState 和 useReducer 等 hook 时是没有撕裂问题的,但是在一些状态库外部状态管理中(例如老版本的 react-redux 以及 jotai),就可能出现撕裂的状态:

(具体代码可以参照这个 codesandbox 示例:codesandbox.io/p/sandbox/t…。示例中通过 while (performance.now() - now < 100) 这样的循环代码来模拟了组件卡顿状态)

以下图中的状态表现为例,在一个依赖外部状态的 Fiber 树中,三个叶子节点均依赖了外部的状态。

  • 首先左下角的叶子结点更新到了蓝色的 V1 状态;
  • 随后由于 React 的并发执行逻辑,第一个叶子结点执行完成之后,workloop 的时间调度可能会判断当前时间分片已到期,让出了线程的执行权限,于是外部状态更新到了 V2;
  • workloop 继续调度下一个时间分片的执行,执行其他两个叶子的更新并更新到红色的 V2 状态。

此时,React 的 UI 状态并不一致,出现了蓝色与红色混合(V1 状态和 V2 状态混合)的情况,也即是撕裂状态。

github.com/reactwg/rea…

解决方案

那么如何解决撕裂问题呢?

一定程度上撕裂问题大概可以理解为一种交换,置换了异步情况下的性能表现与画面表现。如果只使用了原生的 useState & useReducer 等 api,没有使用外部状态库或者 React 外维护的状态时是不会遇到撕裂问题的。因此,最简单的办法就是:不使用外部状态

如果你确实需要维护自己的外部数据状态的话,可以通过使用 useSyncExternalStore 的 api 来避免撕裂状态的出现。从 api 本身的名称也可以看出,useSyncExternalStore 的作用就是为外部的状态提供了一个同步的执行环境,从而保证状态执行的结果始终是一致的(例如 Zustand 的实现中就使用了 useSyncExternalStore)。

如果撕裂问题比较重要影响了技术选型的话,可以参照 daishi 提供的各个状态库的在撕裂问题下的效果测试:

  • With useTransition
    • Level 1
      • 1: No tearing finally on update
      • 2: No tearing finally on mount
    • Level 2
      • 3: No tearing temporarily on update
      • 4: No tearing temporarily on mount
    • Level 3
      • 5: Can interrupt render (time slicing)
      • 6: Can branch state (wip state)
  • With useDeferredValue
    • Level 1
      • 7: No tearing finally on update
      • 8: No tearing finally on mount
    • Level 2
      • 9: No tearing temporarily on update
      • 10: No tearing temporarily on mount

github.com/dai-shi/wil…

React Concurrent 模式

解释了 React 撕裂问题,让我们延伸了解一下 React 的 Concurrent 模式。

事实上,在 React 17 的版本中就已经存在 Concurrent 模式了。Fiber Root 在 React 17 的版本中对应的 RootTag 分为 LegacyRoot | ConcurrentRoot | BlockingRoot 三种,但是在 18 中仅有 LegacyRoot | ConcurrentRoot 两种,RootTag 则会导致 fiber 初始化时拥有不同的 mode。

不同的 React 入口函数调用形式会导致 RootTag 设置为不同的值。

react 中最广为人知的可中断渲染 (render 可以中断, 部分生命周期函数有可能执行多次, UNSAFE\_componentWillMount,UNSAFE\_componentWillReceiveProps) 只有在HostRootFiber.mode === ConcurrentRoot | BlockingRoot才会开启。 如果使用的是legacy, 即通过 ReactDOM.rende r(<App/>, dom) 这种方式启动时HostRootFiber.mode = NoMode, 这种情况下无论是首次 render 还是后续 update 都只会进入同步工作循环, reconciliation没有机会中断, 所以生命周期函数只会调用一次.

github.com/7kms/react-…

从代码上来看,legacy 模式和 concurrent 模式会导致后续不同的执行流程:

  • Legacy 模式 render 容器更新:legacyCreateRootFromDOMContainer
    // Initial mount should not be batched.
    // In legacy mode, we flush pending passive effects at the beginning of the
    // next event, not at the end of the previous one.    
    flushSync(() => {
      updateContainer(initialChildren, root, parentComponent, callback);
    });
  • Concurrent 模式 render 容器更新:
  updateContainer(children, root, null, null);

updateContainer 结尾时调用 scheduleUpdateOnFiber,进入 react-reconciler 调度过程,执行 fiber 更新,从而进入真正的可中断渲染过程。

写在最后

最后,让我们来回来文首的几个问题。

撕裂问题是什么?React 中的撕裂也就是在异步执行的过程中表现出的状态不一致的问题。

那么,我们为什么需要了解撕裂问题?不,我们并不需要了解它。在大部分的 React 开发过程中,开发者都不需要过多关注于撕裂问题本身(除非你对现有的诸多 React 状态库感到厌烦,决心自己鼓捣一份新的轮子来干掉沉重的 redux 或是简陋的 zustand)。但是它挺有趣。

参考资料