为什么不做出反应?
这是我在被问及 Kroger.com 是否可以像我的 Marko 演示那样做相同的 MPA 速度技巧时写的一篇内部分析,但仍然使用 React,因为,嗯,你知道的。1
这篇文章写于 2021 年左右,所以有些问题得到了解答,有些预测被证明是错误的或回想起来很无聊,而且 React 官方的 SSR 故事也正式发布了。我曾经计划重写,但我已经筋疲力尽,懒得再写了。(请在评论区告诉我我哪里错了。)
但是,我还是发布它(附带一些更新的超链接),因为这篇文章可能对某些人仍然有用:
- React 的流式传输存在一些有趣的缺陷,并且官方的 RSC 实现速度并不快
- 低端设备两年后依然停滞不前
- React 确实表示它正在以不为人熟知的方式发生变化
- 这篇简介的意义比我写它时所想象的还要重要。
不相信?好吧,一个 WebPageTest 抵得上一千篇文章:
Next.js RSC + 流 | 马尔科 | HackerNews(控制) | |
---|---|---|---|
网址 | next-news-rsc.vercel.sh | marko-hackernews.ryansolid.workers.dev |
news.ycombinator.com |
WPT链接 | 230717_BiDcVJ_9GN | 230717_AiDc35_A1Q | 230717_AiDcNY_A0G |
JS | 94.9 千字节 | 0.3 千字节 | 1.9 千字节 |
HTML | 9.5 千字节 | 3.8 千字节 | 5.8 千字节 |
text/x-component |
111.1 千字节 | 0 千字节 | 0 千字节 |
瀑布图 | ![]() |
![]() |
![]() |
我没有包含时间,因为很遗憾,WPT 似乎已经停止使用真正的 Android 设备,而且Chrome 的 CPU 节流对于低端设备来说过于乐观了。不过,请注意,浏览器无法text/x-component
原生解析2,因此解析会被 JS 拦截,导致低端设备上的解析时间比平时更长。
我也不确定是否应该测试next-edge-demo.netlify.app/rsc,但它的结果似乎如此不一致,我不确定它是否正常运行。
无论如何,是时候进行原始分析了。
我们可以让 React 像 Kroger Lite 演示一样快吗?
可能不是。
好吧,聪明的家伙,为什么不呢?
这不会是一个简短的回答;请耐心听我说完。我将从一个抽象的观点开始,然后用具体的证据来支持我的悲观主义观点。
代码很难摆脱其创建初衷。你常常可以追溯到最新版本的优缺点,并最终追溯到原作者的目标。这是因为向后兼容性?开发者文化?反馈循环?是的,还有许多其他原因。更广泛的效应被称为路径依赖,预测一项技术未来的最佳方法是回顾它的过去。
前端框架也不例外:
- Svelte 的发明是为了将数据可视化嵌入到其他网页中。
- 💪 Svelte 具有一流的转换和细粒度的更新,因为这些对于良好的数据可视化非常重要。
- 🤕如果您考虑到 Svelte 发明的代码一开始 就没有做这些事情,那么 Svelte 的流媒体不支持和反传统的“𝑋 组件↦捆绑包大小”曲线就是有意义的。
- Angular 是为了快速构建内部桌面 Web 应用程序而创建的。
- 💪 Angular 粉丝对于如何快速制作出功能性 UI 的看法是正确的。
- 🤕 如果您考虑一下大公司工作站的样子以及内联网桌面 Web 应用程序全天在选项卡中打开的特性,那么 Angular 的性能权衡是完全合理的 - 直到您尝试将其用于移动设备。
- React 的创建是为了阻止 Facebook 的组织结构图在其桌面网站前端全面应用康威定律。
- 💪 React 已经充分证明了其可以让任何规模、任何合作程度、任何技能水平的团队在大型代码库上协同工作的能力。
-
🤕 至于 React 最初的弱点……好吧,不要相信我的话,而是相信 React 团队的话:
于是大家开始在内部尝试,每个人的反应都一样。他们会说:“好吧,A.) 我不知道它的性能如何,但 B.)用起来很有趣。” 对吧?每个人都觉得:“这太酷了,我不在乎它是否太慢——总有人会让它更快。”
这句话解释了很多事情,比如:
- React 的历史承诺是新版本将使您的应用程序运行速度更快
- React 网站任命专门的前端性能团队的模式
- 为什么大公司喜欢 React,因为将关注点划分为各个部门,这样其他部门就不用担心了,这是大公司的运作方式
我并不是说这些是错的!我只是说这是一种始终如一的理念。这一次,我甚至没有对 React 的性能嗤之以鼻;React 团队非常坦诚地表示,他们的策略是让框架作者替他们解决性能问题,从而减轻框架使用者的负担。我甚至认为这个策略对(大多数)Meta 网站都有效!
但到目前为止,它并没有让网站整体速度变得更快。而且 React 团队所采取的措施也变得越来越奇怪和复杂。最后,我们的网站(实际上,大多数网站)与 facebook.com 并不十分相似。
🔮来自未来的笔记
因为这是一个显而易见的问题:Marko 是在 eBay 开发人员想要使用 Node.js 时开始的,而业务部门说“好吧,但它不能降低性能”。💪
优势:它没有。这对于 JS 框架来说并不常见。🤕
劣势:Marko 早期专注于性能,而不是 eBay 使用范围之外的推广/集成,这也解释了为什么大多数开发人员没有听说过它。
但 React 真的能那么快吗?它只是代码而已,可以用来操作。
它涉及代码、路径依赖、文化以及支持生态系统,每个都有各自的价值观、黄金路径和白镴路径。让我们具体分析一下——我们将看看从技术上讲,让 React 以与 Kroger Lite 演示相同的速度运行是否可行。
首先来看一下 MPA 所期望的特征:
- 📃 流式 HTML
- 将页面的各个部分逐步刷新到 HTTP 流,这样页面就不会像最慢的部分那么慢。
- 🥾 快速启动
- 如果 JS 在每次导航时重新启动,它应该快速完成。
- 🥀 补水正确性
- 就像飞机起飞一样,补水也是个关键时刻,任何一件小事都可能毁掉接下来的旅程。
- 在 MPA 中,在加载期间协调 DOM 更新和用户输入至关重要,因为这种“边缘情况”会重复发生。
- 🏸 快速的服务器运行时间
- 如果我们依赖服务器,它最好能够高效地渲染。
- 对于启动分布式数据中心实例、边缘渲染、服务工作者渲染和其他近用户处理器来说更为重要。
- 🍂 可摇树的框架
- SPA 假设最终会用到框架的所有功能,因此会将它们打包以便尽早进入 JS 引擎的缓存。MPA 则希望将关键路径中未使用的代码移除,从而在各个页面之间分摊框架成本。
- 🧠 多页面思维模型
- 如果组件仅在服务器上呈现,您是否应该假装它在 DOM 中?
- 如果服务器和客户端之间的 API 无法保持一致,请提供清晰明显的界限。
📃 流式 HTML
我认为流式 HTML对于持续高性能服务器渲染至关重要。如果你还没读过这篇文章,那么它带来的区别如下:
<track>
元素。
增量 HTML 流式传输的重要方面
- 显式和隐式刷新;早期
<head>
资产加载、API 调用、TCP 窗口大小边界…… - 所有刷新,尤其是隐式刷新,都应避免过多的分块:过度刷新会破坏压缩、增加 HTTP 编码开销、困扰 TCP 调度/碎片,并影响 Node 的事件循环。
- 嵌套组件刷新有助于避免扭曲代码以在渲染器的顶层公开刷新细节。
- 无序服务器渲染,当 API 没有按照其使用的顺序返回时。
- 无序刷新,因此不必要的组件不会阻碍页面的其余部分(例如 Facebook 的旧 BigPipe)。
- 控制嵌套和无序刷新的渲染依赖关系对于防止显示奇怪的 UI 状态非常重要。
- 中途渲染错误应该干净地完成而不浪费资源,并
error
在流上发出事件,以便HTTP 层可以正确地发出错误状态信号。 - 分块组件水合,因此组件交互性与组件可见性相匹配。
近十年来,Marko 一直在优化这些重要的细节,而 React……却没有。
此外,我们还征收了“棕地税”。Kroger.com 没有将其 React 应用渲染到流中,因此该应用存在许多如下所述的流不兼容性:
一般来说,任何使用服务器渲染过程来生成需要在SSR 块之前添加到文档的标记的模式从根本上来说都与流式传输不兼容。
🥾 快速启动
SPA 的核心权衡:为了快速进行后续交互,首次页面加载可能会比较痛苦。但在 MPA 中,每次页面加载都必须快速。
JS 运行时启动成本
- 下载时间
- 解析时间和精力
- 编译:编译时间、字节码内存压力和 JIT 救援
- 执行(重复每一页,无论 JIT 缓存如何)
- 初始内存流失/垃圾收集
在以后的页面加载时,只能跳过其中的一部分成本,并且难度会随着页面加载的减少而增加:
- 使用 HTTP 缓存可以跳过下载。
- 现代浏览器足够智能,可以进行后台和缓存解析,但并非适用于所有
<script>
浏览器——无论是从脚本特性还是并行化限制来看。 - 编译器缓存有意要求多次执行来存储整个脚本,并且编译救助可能会持续很长时间。
- 执行永远不会被跳过,但是热 JIT 缓存和存储的执行上下文可能会削减正确计划的运行时间。
- 加载期间的内存流失和开销是无法避免的——甚至是 ECMAScript 标准故意为之。
幸运的是,v8 将大多数解析工作移到了后台线程。然而,虽然这不会阻塞主线程,但解析仍然需要完成。Android 设备的big.LITTLE 架构加剧了这种情况,其中可用的 LITTLE 核心比主线程、网络线程和合成器线程中已用于核心浏览器任务的核心慢得多。
有关 v8 的 JS 成本和缓存的更多信息,因为它是低规格 Android 和 JavaScript 服务器的主要目标:
React 能与 JS 引擎的启动过程兼容吗?
还记得 React 以前的 demo 交互速度比竞争对手的框架快得多吗?React 更懒惰的单向数据流是关键,因为它不像大多数同行那样花时间构建依赖关系图。
不幸的是,这是我发现的 React 和 JS 引擎唯一的优点。
-
+的
react
react-dom
庞大尺寸会对 JS 启动的每个步骤产生负面影响:解析时间、在廉价 Android 上进入 ZRAM 以及从编译器缓存中逐出。 -
React 组件是具有大量方法的函数或类,这不利于立即求值,通过惰性编译
render
重复解析工作负载,并且不利于代码缓存:
代码缓存的一个缺点是,它只缓存正在被积极编译的代码。这通常只是运行一次以设置全局值的顶层代码。函数定义通常是惰性编译的,并不总是被缓存。
JavaScript启动性能
-
React 优先考虑稳定的页面内性能,但以启动时的协调和内存流失为代价。
-
React 渲染和 VDOM 结果是出了名的超态,因此 JS 编译器浪费了周期进行优化和救援。
-
React 的合成事件系统减慢了事件监听器的附着速度,并使每次加载时早期用户输入变得迟缓。
补液性能
Rehydration 涵盖了上述所有 JS 启动的考量,并且是我们性能追踪中的主要问题。对于理论上理想的 React MPA 来说,Rehydration 的成本不容忽视。
从使用 SSR Rehydration 的真实网站收集的性能指标表明,强烈建议不要使用 SSR Rehydration。归根结底,原因在于用户体验:它很容易让用户陷入“恐怖谷”效应。
因此,在 React 生态系统中,更快的 rehydration 几乎与增量 HTML 一样常见:
- 异步初始化 React.js 组件的服务器端渲染策略
- 为什么使用 React Server § 流式客户端初始化
- next-super-performance —部分水合的案例(使用 Next 和 Preact)
- React-lightyear
除了各自的注意事项之外,每个实现都会遇到 React 本身的相同限制:
-
强制
[data-reactroot]
包装器会影响 DOM 大小和重排时间。 -
React 在每个渲染根上监听事件,这会增加内存使用量并减慢事件处理速度,因为先前的
===
不变量不再得到保证。 -
虚拟 DOM 会带来很多 rehydration 开销:
- 渲染整个组件树
- 读回现有的 DOM
- 区分两者
- 渲染协调后的组件树
为了展示与开始时几乎完全相同的东西,需要做大量的工作!
🥀 补液正确性
粗略的研究发现,很多人都在努力将 SSR 的 HTML 交给 React:
- 为什么 React 中的服务器端渲染如此困难
- 补液的危险
- 大型电商应用中 React SSR 的案例研究
- 修复 Gatsby 的补液问题
- gatsbyjs#17914:[讨论] Gatsby、React 和 Hydration
- React 中“服务器渲染”的 bug
不,真的,浏览一下这些链接。他们问题的性质强烈表明,React 并非为 SSR 设计的,因此在 SSR 方面遇到了独特的困难。如果你认为这只是一个观点,请考虑以下几点:
-
React尽可能不优雅地处理客户端和服务器渲染之间的有意差异。
-
如果我们在整个页面中使用 React 来处理更复杂的交互区域,那么处理页面其余部分的最佳方法是什么?
- 在同一个页面上的多个 React “孤岛”之间共享 state/props 等会很麻烦吗?如果这样做,Hook 的行为会不会很奇怪?
- 我们可以使用 Portal 来解决这个问题吗?(注意 Portal 没有 SSR。)
-
React 在 rehydrating 时的错误处理……根本不存在。默认情况下,它拒绝向用户显示任何错误,而是闪现内容或将整个 DOM 树拆成空白节点。
-
React 16 无法修复 SSR 生成的 HTML 属性不匹配的问题
这……真是一件大事。
-
在水合过程中丢失交互状态,例如焦点、选择
<details>
、编辑<input>
。丢失用户的工作成果在最好的情况下也令人抓狂,而这个问题在网络速度慢/信号差/设备配置低的情况下会更加严重。 -
当启动过程赶上并触发所有事件监听器时,受控元素会出现问题。请注意,上面的演示使用了未来的 Suspense API 来解决目前所有 React 应用都可能遇到的问题。
🏸 快速的服务器运行时间
在本次分析中,React 的服务器端优化比其他任何优化都更常见:
(愤世嫉俗的看法是,由于开发人员必须为 React 在服务器上的低效率付出代价,因此他们直接受到激励去修复这些问题,而不是客户端的低效率。)
-
同构渲染对于调整服务器端和客户端之间的性能来说并不是一个有用的抽象——Web 应用程序通常在任意慢速的用户设备上最终受到 CPU 限制,但在资源在连接之间分配的服务器上受到内存限制。
-
快速、高效的运行时可以兼作Service Worker 渲染流以进行离线渲染,而无需为不需要它的东西运送更重的 CSR。
不幸的是,几乎所有用于优化 React 服务器渲染的现有技术都涉及缓存输出,这对 Service Worker、EdgeWorkers、云功能等没有帮助。因此,建议的“三态渲染”模式(演示中押注离线)可能不适用于 React。
🍂 可摇树的运行时
省略代码可以节省加载时间、内存使用和评估成本——包括在服务器上!Svelte 的“消失的框架”理念将是可摇树运行时的逻辑结论——或者,对于Svelte 的病态情况来说,这可能是荒谬的。
Facebook 曾考虑过对 React 进行模块化,但最终认为其使用方式并不成功。他们还尝试过一个事件系统,可以对未监听的事件进行摇树优化,但最终也放弃了。
简而言之:不。
🧠 多页面思维模型
最极端的 MPA 思维模型可能属于 Ruby on Rails。Rails 完全依赖于服务器端渲染,甚至将其 JavaScript API 抽象为类似于 HTTP 调用的 Controller/Model/View 范例。
另一方面,你有 React:
- JSX 更倾向于使用属性而不是 HTML 属性
- 服务器端 React 假装在浏览器中
- 生态系统努力在服务器上模仿 DOM 特性
- 服务器和浏览器渲染之间的差异被认为是同构 JavaScript 的失败,应该予以修复
如果您仅将 HTML 用作精美的骨架屏幕,并且 hydration 仅在长时间会话开始时出现问题,那么将服务器假装成浏览器是合理的。但是,原始网页生命周期发生的次数越多,这种抽象就会变得越漏洞百出,越烦人。className
如果标记只能渲染为 ,那为什么还要费心使用别名呢class
?既然 SSR 标记只是一条流,为什么还要将其视为树?
-
React 加表单(以及其他 DOM 存储状态)很烦人,无论是否受控——当我们更多地依赖原生浏览器功能时,这会很痛苦吗?
.defaultValue
React 对and.defaultChecked
与.value
and的特殊处理.checked
可能会在 SSR-only、CSR-only 和两者之间造成很大混淆- React
onChange
伪装成input
事件,以及由此产生的 bug
-
如果我们继续使用 React,我们是否更有可能坚持 MPA 反模式的 SPA 习惯?
-
对于非 React 位,使用另一种语言/模板系统/无论什么似乎并不理想,但无论如何,多年来人们都是这样做的 — —特别是因为 React 无法直接处理网页的外部支架。
-
React 的抽象/功能旨在在运行时工作
- 即使没有构建步骤,这对于有构建步骤的应用来说也是一种阻抗不匹配。这并非一个棘手的问题,但它会产生连锁反应,不利于解决问题。
- 导致 JS 引擎尚未优化的异常代码,例如臭名昭著的 throws
Promise
或通过多个闭包填充调用索引状态图的 Hooks。
-
许多新的 React 功能目前无法在服务器上运行,并且支持时间表摇摆不定或不明确:
.lazy()
和悬念 [编者注:是的,我知道他们现在这样做了]- 门户
- 错误边界
- React 通常需要多长时间才能将客户端 API 提升到与服务器同等的水平?这种滞后可能预示着未来还会出现类似的问题。
说到未来的问题……
React 的未来走向如何?
正如我们所见,快速 SSR 所需的功能有很多:增量 HTML 流、针对无序渲染的页面内修补、省略客户端上永不重新渲染的组件的 JS、编译为服务器的更高效的输出目标、SSRing React API,否则就不会这样,等等。
没有理由认为这些单独的升级本质上是不兼容的——有了足够的粘合代码、调试和 linting,React 理论上就可以拥有我在 Marko 中发现的有用的渲染技巧。
但是,我们已经尝到了 React SSR 依赖已弃用 API 所带来的兼容性问题的滋味。那么 React 即将推出的 API 呢?我们是否要在每个主要版本中重新进行 SSR 优化?
即将推出的 API 的预期缺点使这种风险更加令人烦恼:
根据我们的经验,使用惯用 React 模式且不依赖外部状态管理解决方案的代码最容易在并发模式下运行。
这让我担心,前面提到的状态管理器同步多个 React “孤岛”的可能性会与并发模式互斥。更确切地说,我怀疑我们是否会坚持使用“惯用的 React 模式”。
更好的比喻是硬分叉,我们根本不维护任何向后兼容性。React 本身可能就是沉没成本谬误。
本质上,未来的 React 将足够不同,从而打破当今企业依赖它的许多原因:
更糟糕的是 Suspense 会影响 React 的内存消耗。VDOM 大约会使 DOM 的内存使用量增加三倍(真实 DOM + 传入的 VDOM + 差异化的 VDOM),而Suspense 示例中使用的“双缓冲”会使情况更加恶化。React 也考虑过精简其合成事件系统,但如果没有它,并发模式就会崩溃。
如果你看一下 Fiber 的编写方式,就会发现该架构确实毫无意义,而且不必要地复杂......除了支持并发模式。
钩子的设计也类似。如果不是为了并发,我们可能就直接用变异了。
— 安德鲁·克拉克
因此,并发模式承诺对长 JavaScript 任务进行智能调度,以便可以中断 React 的成本,但 React 必须为并发模式做出改变的方式可能会导致其他问题:
- 频繁放弃主线程会导致原始性能下降
- Hooks 带来的垃圾收集压力
- 多个同时发生的 React 树的内存消耗
- 补水过程中有“撕裂”的风险
- 加倍使用合成事件,而不是为了减少 bundle 大小而忽略它们
- 现有的模式以前没有被破坏,但将来会破坏——通常是库需要维持性能的模式
如果我们不试图巧妙地在浏览器中完成大量工作,而是减少在浏览器中的工作量,会怎么样?这就是Kroger Lite 速度如此之快的原因。这不仅仅是因为我提到的那些功能,而是因为它的技术选择和应用代码的编写都遵循了这一原则。
或许,评判未来的 React 并非将其视为一次迁移,而是一个完全不同的框架。它的保障、风险、思维模式和优势都已今非昔比。而且,它似乎正在不断探索框架层面的深度。
假设我们做到了以上几点:增强 React 只需很少的时间,没有不可预见的 Bug,并且团队可以快速更新组件以获得回报。然而,我们仍然会面临一些缺点:
- 升级到包含内部或重大更改的 React 版本会更加痛苦
- 需要 linting/CI/等来确保 React 功能不会以会导致问题的方式或环境使用
- 与生态系统代码(如 Jest、第三方组件、React DevTools 等)存在未知的兼容性问题。
未解决的问题:
- 在并发模式、异步渲染和时间分片中我们会看到哪些新的补液问题?
- 钩子调用顺序是否会在 React 岛、Suspense、延迟更新、不同的渲染器、所有这些的组合或我没有预料到的场景中保持一致?
结论
这种分析对于 React 的 MPA 适用性来说有些苛刻。但这真的很奇怪吗?
它最初是为了在客户端渲染 Facebook 的非核心部分而创建的。它的维护者最近才将其用于服务器渲染、导航或传统的 Web 内容交付。事实上,它的服务器端渲染 (SSR) 是一个美好的意外。最后,长期以来的证据表明,React 的趋势不利于性能。
为什么React能够很好地完成我们要求它做的事情?
随着 FB5 的重新设计,Facebook 终于可以像我们一样使用 React,并且他们也发现了它的不足之处。一方面,这意味着 React 肯定会在理想的 SSR 功能方面做得更好。另一方面,这种情况何时发生尚不确定,它将极大地改变 React 的路线图,而且 React 可能会发生巨大的变化,以至于熟悉它目前的运作方式可能成为一种负担,而不是优势。
-
对于农村/新兴/网络较差的目标用户,Facebook 真的会使用 React 来服务他们吗?FB5 有什么变化吗?还是说它
m.facebook.com
仍然没有使用 React? -
如果我们想要一个和演示版本一样快的 Kroger.com 版本,但使用与现有网站相同的框架、流程、管理和开发人员——那它不就变成了我们现有的网站吗?我们无法更换员工,但我们可以改变我们赖以构建的技术。
-
最后,但同样重要的一点是:您能否使用行业标准的部件制作出行业领先的应用程序?
-
嗯,某种程度上来说,一个浏览器本身就能解析
text/x-component
。我猜 RSC 复用了 MIME 类型,当作彩蛋了? ↩ -
或者美国的 50%,取决于你如何计算。↩