【译】重新思考 React 最佳实践

十多年前,React重新定义了客户端渲染的单页应用程序的最佳实践。

今天,React正处于采用的高峰期,并继续受到批评和怀疑。

React 18React Server Components (RSCs)一起,标志着从其最初标语“视图”转变为客户端MVC的重要阶段转变。

在本文中,我们将试图理解React从库演变为架构的演变过程。

我们将首先了解React的核心约束和过去处理它们的方法,探索连接幸福的React应用程序的基本模式和原则。

最后,我们将了解到在像RemixNext 13中的React框架中发现的变化的思维模式。

让我们从目前为止一直试图解决的根本问题开始。这将帮助我们将React核心团队的建议置于上下文中,即使用与React紧密集成的高级框架,这些框架在服务器、客户端和捆绑器之间有紧密的集成。

正在解决什么问题?

软件工程中通常存在两种类型的问题:技术问题和人员问题。

考虑架构的一种方法是,随着时间的推移,找到有助于管理这些问题的正确约束的过程。

如果没有解决人员问题的适当约束 - 人员合作越多,随着时间的推移,变更变得越复杂、容易出错和风险越大。如果没有解决技术问题的适当约束 - 你发货越多,最终用户的体验通常就会变得越差。

这些约束最终帮助我们管理我们作为构建和与复杂系统交互的人类的最大约束 - 有限的时间和注意力。

React和人员问题

解决人员问题是大面积扩招。我们可以利用有限的时间和注意力来扩展个人、团队和组织的生产力。

团队有限的时间和资源以快速发货。作为个人,我们无法承载大量复杂性。

我们大部分的时间都花在弄清楚发生了什么,以及添加或更改新内容的最佳方法是什么。人们需要能够在不加载和保留整个系统的情况下进行操作。

React成功的一个重要因素是它如何比当时的现有解决方案更好地管理了这个约束。它允许团队并行构建解耦的组件,这些组件可以以声明方式组合在一起,并且在单向数据流下“只需工作”。

其组件模型和扩展属性允许在清晰边界后面抽象出遗留系统和集成的混乱。然而,这种解耦和组件模型的一个效果是很容易造成结构的复杂度

React和技术问题

与当时的现有解决方案相比,React还使实现复杂的交互功能变得更加容易。

其声明性模型导致了一个n元树数据结构,该结构被馈送到特定于平台的渲染器,如react-dom。随着我们扩展团队并寻求现成的软件包,这个树结构往往会很快变得很深。

自2016年重写以来,React已经积极解决了优化需要在最终用户硬件上处理的大型深树的技术问题。

在屏幕的另一端,用户的时间和注意力也是有限的。期望值在上升,而注意力的持续时间却在缩短。用户不关心框架、呈现架构或状态管理。他们希望在没有摩擦的情况下完成需要完成的事情。另一个约束条件——动作要快,不要让他们思考。

正如我们将看到的,许多在下一代React(以及React风格)框架中推荐的最佳实践减轻了纯粹在最终用户CPU上处理的深组件树的影响,因为性能问题变得更加紧迫。

重新审视巨大的分歧

直到现在,技术行业一直充满了在不同轴上的钟摆摆动,比如服务的集中化与去中心化以及薄客户端与厚客户端。

我们从厚客户端桌面应用程序摆动到了随着网络的兴起而变薄。然后,随着移动计算和单页应用程序的兴起,又回到了厚客户端。而React今天的基本思维模式正是根植于这种厚客户端的方法。

这种转变在“前端的前端”开发人员之间创建了一个差距,他们精通CSS、交互设计、HTML和可访问性模式,而另一方面,随着我们在前端后端分离时迁移到客户端。

React生态系统中,钟摆正在回到中间某个位置,因为我们试图协调两个世界的

最佳实践,其中许多“前端的后端”风格的代码迁移到服务器端。

从“MVC中的视图”到应用程序架构

在大型组织中,有一部分工程师作为平台的一部分,倡导并将架构最佳实践融入专有框架中。这些类型的开发人员使其他人能够利用他们有限的时间和注意力来做一些带来利润的事情 - 比如构建新功能。

受限于有限的时间和注意力的一个影响是,我们经常会默认选择最容易的方式。因此,我们希望有这些积极的约束,可以使我们保持在正确的道路上,并且轻松地陷入成功的陷阱。

成功的一个重要部分是速度。这通常意味着减少需要加载和运行在最终用户设备上的代码量。原则是只下载和运行必要的内容。

当我们仅限于纯客户端范式时,这很难做到。打包后资源最终会包含数据获取、处理和格式化库(例如,moment),这些库可以存在于主线程之外。

RemixNext等框架中,React的单向数据流扩展到服务器端,其中MPA的简单请求-响应思维模型与SPA的细粒度交互结合在一起。

回归服务器的旅程

现在让我们了解随着时间的推移我们对这种纯客户端范式应用的优化,这需要重新引入服务器以获得更好的性能。这种背景将帮助我们理解React框架,其中服务器发展成为一等公民。

这是一个提供客户端渲染前端的简单方法 - 带有许多脚本标签的空白HTML页面:

这种方法的好处是快速的TTFB、简单的操作模型和解耦的后端。结合React的编程模型,这种组合简化了许多人员问题。

但我们很快就会遇到技术问题,因为责任都落在用户硬件上。我们必须等到一切都下载并运行完毕,然后再从客户端获取,才能显示出有用的东西。

随着多年的积累,代码只能去一个地方。如果没有仔细管理性能,这可能会导致应用程序变得非常缓慢。

服务器端渲染

我们回到服务器的第一步是尝试解决这些启动时间缓慢的问题。

与其用一个空白的HTML页面响应初始文档请求,我们立即在服务器上开始获取数据,然后将组件树呈现为HTML并用它响应。

在客户端渲染的SPA的背景下,SSR就像是一个技巧,可以在Javascript加载时至少显示一些内容。而不是一个空白的白屏。

SSR可以改善交互性能,特别是对于内容丰富的页面。但这带来了运营成本,并且可能会降低高度互动页面的用户体验 - 因为TTI被推迟了。

这被称为“神奇谷”,在这里用户看到页面上的东西并尝试与之交互,但主线程被锁定。问题仍然是单个线程上饱和的Javascript太多了。

需要速度 - 更多的优化

因此,SSR可以加快速度,但不是灵丹妙药。

还有一个固有的低效性,即在服务器上渲染两次工作,然后在客户端接管后重新播放所有内容。

较慢的TTFB意味着浏览器必须在请求文档后耐心等待,以接收头部元素,以了解要开始下载哪些资产。

这就是流媒体发挥作用的地方,它为这个过程带来了更多的并行性。

我们可以想象如果ChatGPT显示一个加载指示器,直到整个回复完成。大多数人会认为它坏了,并关闭选项卡。因此,我们尽早显示任何可以的内容,通过在数据和内容完成时将数据和内容流式传输到浏览器中。

对于动态页面来说,流式传输是尽早在服务器上开始获取的一种方式。并且同时让浏览器开始下载资产,所有这些都是并行进行的。这比上面的先前图表要快得多,我们等到所有内容都被获取并且所有内容都被渲染后才将带有数据的HTML发送下来。

到目前为止,我们已经将我们的客户端渲染树,并通过在服务器上尽早获取来提高其启动时间,同时通过尽早刷新HTML来使数据在服务器上获取和在客户端下载资产的过程并行。

现在让我们把注意力转向获取和更改数据。

React中的数据获取约束

层次化组件树的一个约束是,“一切都是组件”,节点通常具有多个责任,比如发起获取、管理加载状态、响应事件和渲染。

这通常意味着我们需要遍历树以了解要获取的内容。

在这些优化的早期阶段,使用SSR生成初始HTML通常意味着在服务器上手动遍历树。这涉及到深入了解React内部,收集所有数据依赖项,并在遍历树时顺序获取。

在客户端,

这种“先渲染再获取”的顺序会导致加载指示器的出现和消失,同时伴随着布局变化,因为树的遍历会创建一个顺序网络瀑布。

因此,我们希望有一种方法可以并行获取数据和代码,而无需每次都从树的顶部到底部遍历一遍。

了解Relay

了解Relay的原理以及它如何在Facebook规模上解决这些挑战是有用的。这些概念将帮助我们理解后面将看到的模式。

  • 组件的数据依赖关系是共同定位的
    Relay中,组件以GraphQL片段的形式声明性地定义其数据依赖关系
    与像React Query这样也具有共同定位特性的东西的主要区别在于,组件不会启动获取。

  • 树遍历发生在构建时
    Relay编译器通过组件树,收集每个组件的数据需求,并生成优化的GraphQL查询。
    通常,此查询在运行时在路由边界(或特定入口点)执行。允许组件代码和服务器数据尽可能早地并行加载。

Co-location支持最有价值的体系结构原则之一——删除代码的能力。通过删除组件,它的数据需求也被删除,并且查询将不再包含它们。

Relay缓解了处理大型树数据结构时与网络资源获取相关的许多权衡。

但是,它可能很复杂,需要GraphQL、客户端运行时和先进的编译器来协调DX属性,同时保持性能。

稍后我们将看到React Server Components遵循类似的模式,适用于更广泛的React生态系统。

下一个最佳方案

还有没有另一种方式可以在获取数据和代码时避免遍历树,而又不需要承担所有这些?

这就是像RemixNext等框架中发现的服务器上的嵌套路由发挥作用的地方。

组件的初始数据依赖通常可以映射到URL。URL的嵌套段对应于组件子树。这种映射使框架能够预先识别特定URL所需的数据和组件代码。

例如,在Remix中,子树可以是自包含的,具有自己的数据要求,独立于父路由,编译器确保嵌套路由并行加载。

此封装还通过为独立子路由提供单独的错误边界来提供优雅的降级。它还允许框架通过查看URL来急切地预加载数据和代码,以实现更快的SPA转换。

更多并行化

让我们深入探讨Suspense、并发模式和流媒体如何增强我们正在探索的数据获取模式。

当数据不可用时,Suspense允许子树退回到显示加载UI的状态,并在准备就绪时恢复渲染。

这是一个很好的原始方法,它使我们能够在否则同步的树中声明式地表达异步性。这使我们能够同时并行地获取资源和渲染。

正如我们之前介绍流式传输时所看到的,我们可以在渲染之前尽早开始发送内容,而不必等到所有内容都完成后再进行渲染。

在Remix中,这种模式通过路由级别的数据加载器中的defer函数来表达:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Remix API鼓励在路由边界处获取数据
// 其中嵌套的加载器并行获取
export function loader ({ params }) {
// 非关键,开始获取,但不阻止渲染
const productReviewsPromise = fetchReview(params.id)
// 关键,使用await阻止渲染
const product = await fetchProduct(params.id)

return defer({ product, productReviewsPromise })
}

export default function ProductPage() {
const { product, productReviewsPromise } = useLoaderData()
return (
<>
<ProductView product={product}>
<Suspense fallback={<LoadingSkeleton />}>
<Async resolve={productReviewsPromise}>
{reviews => <ReviewsView reviews={reviews} />}
</Async>
</Suspense>
</>
)
}

在Next中,RSCs利用了类似的数据获取模式,使用服务器上的异步组件可以等待关键数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 服务器组件中类似模式的示例
export default async function Product({ id }) {
// 非关键 - 开始获取但不阻止
const productReviewsPromise = fetchReview(id)
// 关键 - 使用await阻止渲染
const product = await fetchProduct(id)
return (
<>
<ProductView product={product}>
<Suspense fallback={<LoadingSkeleton />}>
{/* 使用use() hook解析承诺 */}
<ReviewsView data={productReviewsPromise} />
</Suspense>
</>
)
}

这里的原则是尽早在服务器上获取数据。理想情况下,使用接近数据源的加载器和RSCs

为了避免任何不必要的等待,我们会流式传输较不关键的数据,因此页面可以逐步以阶段性方式加载 - 这在悬挂的帮助下非常容易实现。

RSCs本身并没有一个内在的API来促进在路由边界获取数据。如果结构不仔细,这可能导致连续的网络瀑布。这是框架在最佳实践中需要走的一条线,从而实现更大的灵活性。

值得注意的是,当RSC部署在数据附近时,与客户端瀑布相比,顺序瀑布的影响大大减小了。强调这些模式突显了RSC需要更高水平的框架集成,具有将URL映射到特定组件的路由器。

在我们更深入地研究RSC之前,让我们花一分钟来了解图片的另一半。

数据转变

在仅客户端范式中管理远程数据的常见模式是在某种归一化存储中进行(例如Redux存储)。

在这种模型中,突变通常会乐观地更新内存中的客户端缓存,然后发送网络请求以更新服务器上的远程状态。

手动管理这一点历来涉及大量样板文件,并且容易出错,因为我们在新的React状态管理的所有边缘情况中讨论过的情况。

钩子的出现导致了像Redux RTKReact Query这样的工具的出现,这些工具专门用于处理所有这些边缘情况。在仅客户端的范式中,这需要通过React上下文传播值,向下传递代码以处理这些问题,这很容易创建效率低下的顺序I/O操作,因为树被遍历。

那么当React的单向数据流扩展到服务器时,这种现有模式会如何改变呢?

这种“前端的后端”风格代码的大量移动到了实际的后端。

下面是从Remix中的数据流中获取的一张图片,展示了框架正在向MPA(多页面应用程序)体系结构中找到的请求-响应模型的转变。

这种转变是从纯由客户端处理一切的模型到服务器发挥更重要作用的模型的转变。

这种模式还扩展到了RSC,使用我们稍后将会介绍的实验性的“服务器动作函数”。在其中,React的单向数据流延伸到服务器中,以简化的请求-响应模型为基础,逐步增强了表单。

从这种方法中删除代码是一个很好的好处。但主要的好处是简化数据管理的思维模型,从而简化了大量现有的客户端代码。

React Server Components

直到目前为止,我们利用服务器来优化纯客户端方法的限制。

如今,我们对React的心智模型深深植根于作为客户端渲染树运行在用户设备上。服务器组件将服务器引入为一等公民,而不是事后的优化。React演变为形成一个强大的外层,其中后端嵌入到组件树中。

这种架构转变导致了现有React应用程序的心智模型和部署方式的许多变化。

最明显的两个影响是我们迄今为止所讨论的优化数据加载模式和自动代码拆分。

在《规模化构建和交付前端》的后半部分,我们涉及了一些在规模上的关键问题,比如依赖管理、国际化和优化的A/B测试。

当仅限于纯客户端环境时,这些问题在规模上可能很难得到最优解。服务器组件以及React 18的许多功能,为框架提供了一组原语,可用于解决这些问题中的许多问题。

一个令人费解的心智模型转变是客户端组件可以渲染服务器组件。

这对于帮助可视化具有RSC的组件树是有用的,因为它们一直连接到树的底部。客户端组件提供客户端交互性的“空白”处。

扩展服务器到组件树下部是强大的,因为我们可以避免发送不必要的代码到网络上。与用户硬件不同,我们对服务器资源有更多的控制权。

树的根系于服务器,主干延伸穿过网络,叶子推送到运行在用户硬件上的客户端组件。

这种扩展的模型要求我们意识到组件树中的序列化边界,这些边界标记为”use client”指令。它还重新强调了掌握组合的重要性,以允许RSC通过子代或客户端组件中的插槽渲染到树的深度所需的任何位置。

服务器操作函数

随着前端领域的一些部分迁移到服务器上,许多创新的想法正在被探索。这些提供了对未来客户端和服务器之间无缝融合的一瞥。

如果我们能够获得与组件的共同定位的好处,而无需使用客户端库、GraphQL或担心运行时效率低下的瀑布,那该多好?

服务器函数的一个示例可以在React风格的元框架Qwik city中看到。类似的想法也正在React(Next)Remix`中进行探讨和讨论。

Wakuwork存储库还提供了一个实现React服务器“ action函数”的概念验证。

与任何实验性方法一样,需要考虑权衡。在涉及客户端-服务器通信时,存在有关安全性、错误处理、乐观更新、重试和竞态条件的问题。我们已经了解到,如果不由框架来管理,这些问题通常会被忽略。

这种探索也强调了实现最佳用户体验和最佳开发人员体验往往需要增加底层复杂性的高级编译器优化。

结论

软件只是帮助人们完成某些事情的工具 - 许多程序员从未理解过这一点。保持对交付价值的关注,不要过于关注工具的细节 —— 约翰·卡马克

随着React生态系统超越纯客户端范式的发展,了解我们下方和上方的抽象是很重要的。

清楚地了解我们操作的基本限制,使我们能够做出更明智的折衷选择。

随着每次摆动,我们都会获得新的知识和经验,以整合到下一轮迭代中。先前方法的优点仍然有效。一如既往,这是一个权衡。

很棒的是,框架越来越多地提供了更多的杠杆,让开发人员能够为特定情况做出更精细的折衷选择。在优化用户体验与优化开发人员体验相结合的地方,以及在一个混合的客户端和服务器模型中,简单的MPA模型与丰富的SPA模型相结合。