前端开发曾经很“简单”,你只需要使用 jQuery 就可以了:)。然后,我们有了 Angular、React、构建、模块……“简单”的前端开发仍然可能吗?还是说这真的是一场巨大的灾难?
现如今,React 仍是人气最旺的前端框架,并为 Netflix、Airbnb、Discord 乃至其“生身父母”Meta(包括 Facebook、Instagram 和 Whatsapp)等知名网络厂牌提供支持。面对 React 被用于创建数十亿人日常使用的用户界面,所以我们可以合理假设,整个互联网的流量当中有很大一部分都是由 React 负责“搞定”的。
今年 4 月底,Meta 公司的 React 开发团队发布了 React 19 RC 版本,其中包含“use”API、带新钩子的 Actions、稳定服务器组件以及 Server Actions 等功能。这也是继 2022 年 3 月全面发布 React 18 这两年多以来,React 推出的首个主要版本。
但除了种种闪亮新功能与开发体验改进之外,另有一处小变化直到上周才引起大家的关注和重视,这个小变化有可能显著降低不少依赖 React 的网站的性能表现。
一切的起点,源自前端工具集 TanStack Query 核心维护者之一 Dominik 下面这条推文:
到底是我的错觉,还是说 React 18 和 19 版本之间在 Suspense 的并行获取处理方面确有差别?
在 18 中执行的是“按组件”区分设计,也就是哪怕把两个组件放进同一个 Suspense 边界之内且各自执行获取,触发仍将并行执行:这会并行触发两条查询,等待两个查询解析后再显示整个子树。
但据我所知,在 React 19 中查询现在会以瀑布形式运行。我记得 @rickhanlonii 提到过类似的情况,但现在找不到相关记录了。
Dominik 还抛出示例,表示 React 文档中的示例也受到 React 19 中新瀑布的影响。
“这并不是什么‘陷阱’之类的意思,只是意味着按照 React 官方文档示例,编写代码的人在 V 19 版本中将需要进行重大重构,而发布说明中对此只有一条脚注。一个更合适的描述应该是‘我们彻底改变了 React Suspense 在客户端组件中的工作方式’。Dominik 说道。
有使用了 React 19 的网友表示他在使用 RSC(React Server Components)时,这种方式仍然可以并行工作。Dominik 表示自己的测试是在 vite 应用程序中进行的,因此没有可用的 RSC。
一石激起千层浪,大家随后纷纷表示确有同感。
高级 Web 工程师 Adam Rackis 跟贴表示,“这是个令人抓狂、莫名其妙的变化。从评论来看,客户端组件似乎确实出了这个问题,但并行获取在 RSC 中仍然有效。这毁掉了 react-query,让人没法好好用 React 管理数据。我希望大家能尽量理性讨论,但估计不太现实。”
NozzleIO 联合创始人 Tanner Linsley 也表示,“没错,这个改动让人难受,特别是会影响到现有应用程序及用例并拉低其性能表现。” Tanner Linsley 也是 Dominik 的同事兼技术大牛。
Adam Rackis 表示,我迫切希望 Vercel(一家维护 Next.js Web 开发框架的公司),特别是 React 核心团队能够理解这个问题的严重性。毕竟并不是所有人都在围绕电子商务场景做开发,也就是永远以加快内容推送速度为最高优先级,防止失去耐性的用户点叉退出。
Dominik 说道,“除了 React 核心团队之外,我还没见过任何人支持这项调整。目前 19.0.0 版本尚未正式发布,也许还有一丝希望能让他们重新考虑此事。”
值得注意的是,Dominik 明确自己不是第一个发现这一点的人,Gabriel Valfridsson 在 RC 公告发布后的第二天就第一个发现了这一变化,可惜并未在当时引起广泛关注。Dominik 本人虽然与之互动了但当时也没有特别注意,而是将 React Query 升级到 React 19,后来在实操中才意识到该问题的严重性。
加载变慢,板上钉钉的事实
已经有不少人分享了在 18 中几乎并行获取所有内容的应用程序在 19 中如何导致完全崩溃。
我们可以看下开发者 Matias Gonzalez 的测试。他们在 https://kidsuper.world/的一个分支上更新了 React 和 Next 的 Canary 测试版本,该网站中使用到大量模型和纹理。他们首先记录了原有版本的性能,所有 .glb 模型的加载总耗时约为 2.5 秒:
而在安装了 Canary 版 React 之后,同样的 .glb 模型加载过程需要约 3.5 秒:
更糟糕的是,此番调整不单大大影响到性能表现、对许多依赖该模式的开发者造成冲击,而且 React 核心团队还毫不客气地承认了这一点:
其他重要变化
react:批量同步、默认连续通道
react:不再对 Suspended 的兄弟组件进行预渲染。
这也是为什么 Dominik 否认网友认为该变化是 bug 的原因。
我们再看一个示例。
截至当前版本(React 18.3.1),当在同一 Suspense 边界内使用由 Suspense 实现的数据获取或延迟加载多个组件时,React 会在退出之前尝试渲染所有兄弟组件,即使第一个 sibling 暂停仍可继续。也就是说,这些兄弟组件中发生的数据获取或者延迟加载,将全部以并行方式执行。
function App () { return ( <> <Suspense fallback={"Loading..."}> <ComponentThatFetchesData val={1} /> <ComponentThatFetchesData val={2} /> <ComponentThatFetchesData val={3} /> </Suspense> </> ); } const ComponentThatFetchesData = ({ val }) => { const result = fetchSomethingSuspense (val); return <div>{result}</div>; };
演示: https://stackblitz.com/edit/vitejs-vite-x3nv7r?file=src%2FApp.jsx
在此示例中(React 18),即使 fetchSomethingSuspense 导致第一个 ComponentThatFetchesData 暂停,React 仍将尝试渲染其兄弟组件,相当于实现了各个组件的并行数据获取。
通过控制台就能观察到这种现象,控制台中记录下一每次数据获取的触发时间:
演示: https://stackblitz.com/edit/vitejs-vite-55rddj?file=src%2FApp.jsx
所有数据获取几乎都是同时启动。
但在 React 19(Canary 版本)中运行相同代码时,再次查看控制台,会发现整个执行过程转为瀑布形式,各项数据获取将仅在前一段数据获取完成之后才会启动。
这种情况源自此条 PR:https://github.com/facebook/react/pull/26380
在此 PR 引入变更之后,React 不再尝试渲染同一 Suspense 边界之内的所有兄弟组件,而会在首个组件挂起时直接放弃。就是说,我们尝试渲染第一个组件时,它会挂起且直到其数据获取完毕并渲染完成后,下一个兄弟组件才会开始处理。之后再次挂起,依此类推。
这种新机制不仅会影响到 Suspense 的数据获取过程,也会影响到已受官方支持的延迟加载的组件 React.lazy 的起效方式。这些组件不再并行获取,所以加载时间会是现在两次获取的总和,而非只取二者中的大者。考虑到后者开放时间更早,所以应用也更为广泛。“这无疑是一次重大的倒退。”
React 核心团队怎么想的?
要更清楚地了解这个问题,我们先快速回顾一下 React 中的 Suspense。
Suspense 是 React 中的一个组件,用于显示回退直到其子组件完成加载——这要么是因为这些子组件采取延迟加载,要么是因为它们在使用由 Suspense 实现的数据获取机制。具体用法如下:
<Suspense fallback={<Loading />}> <ComponentThatFetchesDataOrIsLazyLoaded /> </Suspense>
虽然 Suspense 早就被纳入到 React API 当中,但长期以来,官方正式认可的唯一用法就是使用 React.lazy 对组件进行延迟加载。其主要功能就是拆分应用中的代码,并保证仅在必要时加载对应的各个部分。
在配合 React.lazy 使用时,当首次尝试渲染延迟加载的组件时(即在延迟加载之前),其会触发 Suspense 边界(即包裹组件的 Suspense)并渲染回退,直到负责获取组件的代码执行完成,接下来再渲染组件本身。
长久以来,React 核心团队一直承诺在客户端上为 Suspense 提供官方数据获取支持(在使用 RSC 时,此支持已经在服务器端实现),但直到现在才真正实现。尽管如此,不少第三方库(包括 TanStack Query)已经通过解析 React 内部结构提前实现了该功能。也正因为如此,目前许多生产级应用程序正实际使用 Suspense 在客户端上执行数据获取。
而这个改动,让 React 19 禁用了同 Suspense 边界之内的兄弟组件并行渲染,这会导致兄弟组件内的数据获取强制成了瀑布形式执行。
前文提到的 PR 其实也对此做出了解释:之所以有此变更,理由是在 suspending 之前渲染所有兄弟组件是有成本的,而且在本质上会导致回退显示的延迟。此外,这项变更还与 React 团队自 18 版本前引入 Suspense 起就一直在推动的“边获取、边渲染”理念密切相关。
在理想情况下,我们不该在使用数据的同一组件中同时执行渲染和数据获取,而应该尽可能将数据获取的部分提前。虽然这在优化性能方面确有道理,但实际上也带来了重大开发体验缺陷,导致开发者无法直观地将组件及其数据要求统一处理。
目前网上关于这个问题的讨论已经很多,甚至出现了专门解决此事的库。
“如果我理解正确,现在大家似乎不能再轻松地组合数据需求了,除非使用复杂的预取技巧。否则就必须将所有数据获取提升到公共父组件。TaitoUnited 开发者 Teemu Taskula 表示,“我能理解 Dominik 的感受。这难道不是违背了 React 的第一设计原则:组合 (composition) 吗?”
Taskula 表示,Suspense 的主要优点之一就是能够组合多个组件,例如 <Header>
, <Sidebar>
, <Main>
, <Footer>
,并让它们分别获取自己的数据,同时通过 Suspense 等待所有数据就绪,从而避免因加载指示器导致的“爆米花”式 UI (popcorn UI)。
但 Taskula 也指出,尽管将数据获取提升到根组件看似有违 Suspense 初衷,在某些情况下还是有意义的,比如当数据在整个视图树中都被共享时、需要在多个组件之间协调数据加载状态时。如果数据只用于单个组件,并且也不需要在组件间共享加载状态,那么直接查询数据而不使用 Suspense 可能是更加合适的做法。
这里的主要结论是,如果不使用编译器,就不可能在实现最佳性能的同时、继续保持组件及其数据要求的统一。也正因为如此,才需要引入 Relay 延迟机制。
“迷途知返”
“无论 Suspense 如何工作,提升数据要求都是一个好主意,我也建议这样做。然而,随着 React 19 的提议更改,这样做几乎成为强制性要求。”Dominik 说道。
好消息是,事情最终迎来了圆满结局。面对汹涌的民意、激烈的讨论以及内部沟通之后,React 核心团队最终决定暂时叫停这项变更。
好消息是 Suspense 又回来了,见到你真棒!@rickhanlonii @en_jS @acdlite
* 我们非常关注 SPAs,只是团队误判了人们对它的依赖程度。
* 我们仍强烈建议大家采取预加载机制,但有些情况下确实不太可行。
* 我们打算暂停 19.0 版本的发布,直到找出更好的解决方案。
Sophie Alpert 随后补充道,此次讨论的优化并非由 RSC/Vercel 提出,而是来自 Facebook 内部站点。因为这种方式可以降低 CPU 负载,从而提升站点性能。
Alpert 指出,这项简化带来了许多后续的改进,如果要回退的话,即使仅仅出于意愿,也需要花费数周以上的时间。理想的解决方案应该是进行一次重构,以便能够追踪多个正在加载的组件而无需额外的重新执行,这样可以同时获得这两种方式的优势。
在完成重构之前,只有选择两种次优方案可选:对于使用 React Query 进行延迟数据获取的用户,旧的方式更加高效;新方法更适合那些已经将代码结构调整为尽早发起数据获取的团队,那些追求极致性能优化的团队通常已经这样做了,而且他们的性能上限也更高。
“我认为我们当前关于如何组织数据获取 / 以及在不使用服务器的情况下何时获取数据的文档还不够完善,这个问题需要解决。”Alpert 说道,“我完全理解你的沮丧 (我也是),但同时也要承认,目前并没有一个简单易行的解决方案能同时满足所有人。”
这已经不是社区第一次因 React 无视 Meta 与 Vercel 场景之外使用习惯的情况下,对其粗暴引入的重大变更公开表达不满了。之前 React 团队、特别是 Vercel,就曾经想把 RSC 作为 React 构建中的基础性组成部分。
很明显,React 维护者对于未来正确开发方向的判断,已经与项目社区中普通成员的观点发生了冲突。这一切到底是在改善之中,抑或仅仅只是后续冲突全面爆发的起点,目前仍有待观察。
这件事也引发了部分开发者对 React 长期以来没有与公众完全开放的沟通渠道的不满。
“React 需要一个允许即时通信的平台上的永久工作组(只需使用私人 Discord)我知道他们无法与公众建立完全开放的沟通渠道,但这至少可以覆盖一个服务不足的灰色地带,并为支持生态系统的声音/作者/维护者提供一个沟通的场所,而不是电子邮件、面对面的会议活动和一小部分在 X/Twitter 上活跃的管理员。”
参考链接:
https://tkdodo.eu/blog/react-19-and-suspense-a-drama-in-3-acts
https://x.com/sophiebits/status/1801663976973209620
https://blog.codeminer42.com/how-react-19-almost-made-the-internet-slower/
https://github.com/facebook/react/pull/26380#issuecomment-2166178673