先让我们把问题抛在这儿: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 状态混合)的情况,也即是撕裂状态。
解决方案
那么如何解决撕裂问题呢?
一定程度上撕裂问题大概可以理解为一种交换,置换了异步情况下的性能表现与画面表现。如果只使用了原生的 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
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没有机会中断, 所以生命周期函数只会调用一次.
从代码上来看,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)。但是它挺有趣。
参考资料
- React Concurrent:github.com/reactwg/rea…
- React Tearing:github.com/reactwg/rea…