宇宙厂:为何Reactv18并发模式会造成UI撕裂?还敢用么?

前有科技后进阶 2024-05-12 05:44:15

大家好,很高兴又见面了,我是"高级前端‬进阶‬",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。

1. 什么是 React 18 口中的撕裂

撕裂(Tearing)是图形编程使用的一个术语,指的是视觉不一致。

在用户界面中,“撕裂” 是指 UI 显示同一状态的多个值,例如:在列表中显示同一商品的不同价格,或者错误价格的表单,或者外部 store store 中过时的值。

由于 JavaScript 是单线程的,因此在 Web 开发中通常不会出现这个问题。 但在 React 18 中,并发渲染使得 React 在渲染期间会产生打断 (yield),即:当使用 startTransition 或 Suspense 等并发功能时 React 可以暂停当前工作以进行其他优先级更高的工作。

在暂停当前工作的时间间隙,可能会更改上一次用于渲染的数据从而导致 UI 对同一数据显示两个不同的值。

但该问题并非 React 特有,是并发的必然结果。 如果开发者希望能够中断渲染以响应用户输入以获得更好的体验,则需要能适应正在渲染的数据更改而导致用户界面撕裂。

2. 撕裂的本质是什么2.1 同步渲染 (Synchronous Rendering) 无撕裂

如下图所示,在第一个面板中 React 树开始渲染 ,此时遇到一个需要访问某些外部 store 并获取颜色值的组件。 外部 store 返回颜色是蓝色,因此该组件渲染为蓝色。

在第二个面板中,由于是同步渲染,React 会继续渲染所有组件而不会停止,因此外部 store 不可能改变。 最终,所有组件在外部 store 中都获得相同的值。在第三个面板中看到所有组件都渲染为蓝色,UI 始终以一致的状态显示,即屏幕上的任何位置都以相同的值渲染。

在第四个面板中,React 渲染完成,主线程空闲,外部 store 可以更新。 由于是在 React 未渲染时更新 store,下次渲染 React 树时将从第一个面板开始,并且所有组件依然能获得相同的值。

这就是为什么在 React 18 并发渲染之前以及在大多数其他 UI 框架中 UI 始终渲染一致。注意, React 17 以及在 React 18 中都默认不开启并发渲染。

2.2 并发渲染 (Concurrent Rendering) 的撕裂恶魔

首先声明,大多数时候并发渲染会生成一致的 UI,但有些边缘情况可能会在某些条件下导致问题。

第一个面板将组件渲染为蓝色,接着就可能存在分歧。

由于使用并发功能(即并发渲染),React 可以在完成当前渲染工作之前停止,从而允许其他工作先行,比如:更高的优先级。 这对于页面响应来说是一个巨大好处,因为用户能够继续与页面交互而不会被 React 阻止。 比如,此时用户单击按钮,将 store 从蓝色更改为红色。 在并发渲染之前,这不可能发生,因为对用户来说,该页面似乎暂停了,即无法单击任何内容。 但通过并发渲染,React 可以响应点击,让用户感觉页面流畅且依然具有交互性。

结果就是,用户交互(或其他工作,如网络请求或超时)依然可以更改用于渲染在屏幕上的内容的状态值,即所谓的边缘情况。 在第二个面板中, React 渲染已经 yield,而外部 store 已经根据用户交互发生了变化。

而问题也随之而来,此时第一个组件已经渲染为蓝色(当时 store 的值),但是在此之后渲染的任何组件都将获得新值,即红色,表现就是第三个面板。 即,存在访问外部状态的组件渲染时能获取当前值,且值是红色。

在最后一个面板中,可以看到以前始终为蓝色的组件现在是红色和蓝色的混合,即相同的数据显示两个不同的值,这也就是所谓的撕裂。

3. 撕裂对库或者框架维护有什么影响

React 18 添加了许多在底层使用并发渲染的新功能可供开发者选择,从而允许渐进式添加并发渲染能力。

但是,应用程序本身依赖于整个应用程序中使用的库。 因此,为了让应用程序支持并发渲染则所有使用的库都需要支持 React 18 的并发渲染。可喜的是,这通常只会影响少数依赖外部可变状态 (mutable state) 的库。

3.1 哪些类型的库受并发影响

React 官方声明生态中的大多数库都不会受到并发渲染的影响。比如,在渲染时不访问外部可变数据并且仅使用 React Props、State 或 Context 传递信息的组件和自定义 Hooks ,因为 React 本身也会处理此类数据。

而相比之下,诸如:处理数据获取 (Data Fetching)、状态管理 (State Management) 或样式 (styling) 的库可能需要些许改动,这是因为这些库在 React 之外的 store 存储状态。 并发渲染使得 store 可能在渲染过程中更新,而这对 React 框架来说是黑盒。

3.2 为什么这只会影响使用外部状态的库

仅使用 React 状态的库不必处理撕裂问题,因为 React 状态已经直接集成到并发渲染中以防止撕裂。

当改变 React 状态时,React 不会立即生效。 相反,React 会将更新排队并安排渲染。 当 React 开始渲染时会查看整个更新队列,并使用不同的启发式方法和算法来确定下一个要处理的更新。 此过程具有适当的保护措施和语义,以确保渲染始终一致。

在这种排队更新的机制下,暂停当前渲染并切换到另一工作时会存在两种情况:

更新的 state 与暂停渲染的 state 无关:此时 React 会将更新排队,而在 React 开始渲染时会发现更新无关并继续当前的渲染。 渲染完成后,如果没有其他更新,React 将处理新的更新。更新的 state 与正在渲染的 state 有关: 此时 React 仍会排队,但当渲染时会发现同一状态有更新。 此时 React 会丢弃已渲染的过时内容并开始进行新的更新。这种直接丢弃的方式对性能、体验是一个好的权衡,即不会浪费时间处理过时的更新也不会向用户闪现过时的状态。

因此从本质上讲,React 是一个用于处理状态更新队列以生成一致 UI 的库。而当使用外部状态时,则无法享受 React 为保证状态一致性而做的所有努力。

使用外部状态,无需将更新安排到可以按正确顺序处理的队列,而是可以在渲染过程中直接改变状态。 因此,为了支持外部 store,开发者可以采用以下任一方法:

告诉 React store 在渲染期间已更新以便 React 可以再次重新渲染当外部状态发生变化时强制丢弃过时的渲染,运行 React 重新渲染允许 React 渲染,而在渲染时不会更改状态

这些解决方案分别对应于三个不同级别的并发渲染支持。

4. 库或者框架应对撕裂的三个层次4.1 ⚠️ 让并发用起来

最低限度的支持是允许 UI 暂时撕裂,开发人员可以正常使用具有并发功能的库,但可能会暂时在应用程序中看到不一致的 UI。

比如:开发者可以使用 useSubscription Hooks,该 Hooks 可能会在初始渲染期间撕裂,但随后会触发同步更新以 “修复” 撕裂。 如果撕裂在渲染过程中导致错误,React 将自动从最近的错误边界同步重试渲染。 该方式虽然可以自动 “修复” 错误,但也失去了并发渲染的所有好处。

// 使用 use-subscription 来订阅事件调度程序(例如 DOM 元素)import React, {useMemo} from "react";import {useSubscription} from "use-subscription";function Example({input}) { const subscription = useMemo( () => ({ getCurrentValue: () => input.value, subscribe: callback => { input.addEventListener("change", callback); return () => input.removeEventListener("change", callback); } }), [input] ); const value = useSubscription(subscription);}

使用此选项,在最坏的情况下,用户可能会在屏幕上看到不一致的值闪烁,然后同步重新渲染以显示一致的 UI。

4.2 ✅ 让并发能正确执行

这是许多库的最佳情况,即完全不撕裂,但有时需要更长的时间来渲染。 有了这种级别的支持,开发人员就可以使用具有并发功能的库,而不会出现任何视觉不一致,但并没有达到应有的优化程度。

// 在组件的顶层调用 useSyncExternalStore 以从外部数据存储中读取值。import {useSyncExternalStore} from 'react';import {todosStore} from './todoStore.js';function TodosApp() { const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot); // ...}

比如 useMutableSource useSyncExternalStore Hooks,其将在渲染期间检测外部状态的变化并在向用户显示不一致的 UI 之前重新开始渲染。 最糟糕的情况是渲染需要很长时间,但用户总是会看到一致的 UI。

4.3 让并发更快

理想的并发支持是始终显示一致的 UI,开发人员在将并发功能与库一起使用时可以获得并发渲染的全部好处。 大多数仅依赖于 React 状态的库已经达到了这个水平。

比如:使用 React 状态,其不会撕裂或放弃 React 官方所做的所有努力。 另一个例子是使用具有不可变快照 (Immutable Snapshots) 的可变 store,快照在渲染期间不会改变,通过简单对比而跳过多余的渲染等,但这种策略仍在研究中。

注意:仅依赖于 React 状态的库应该已经是第 3 级。第 1 级和第 2 级适用于少数依赖外部可变状态或以非典型方式依赖 React 状态的库。

参考资料

https://github.com/reactwg/react-18/discussions/70

https://github.com/reactwg/react-18/discussions/69

https://blog.openreplay.com/ultimate-guide-to-upgrading-to-react-18/

0 阅读:0

前有科技后进阶

简介:感谢大家的关注