深入探讨 React 从客户端视图库到应用程序架构的演变。
十多年前,React 重新思考了客户端渲染 SPA 世界的最佳实践。如今,React 正处于采用的高峰期,并继续受到健康的批评和怀疑。React 18 与 React 服务器组件 (RSC) 一起,标志着从其最初的标语到客户端 MVC 中的“视图”的重大阶段转变。在这篇文章中,我们将尝试理解 React 从库 React 到 React 架构的演变。
我们将首先了解 React 的核心约束以及过去管理它们的方法,探索将快乐的 React 应用程序结合在一起的底层模式和原则。
最后,我们将了解 Remix 等 React 框架和 Next 13 中的应用目录中不断变化的心智模型。
让我们从了解我们迄今为止一直试图解决的潜在问题开始。这将有助于我们将 React 核心团队的建议与 React 一起使用更高级别的框架,这些框架在服务器、客户端和捆绑器之间具有紧密的集成。
1、正在解决哪些问题?
软件工程中通常有两种类型的问题:技术问题和人员问题。
考虑架构的一种方式是随着时间的推移找到正确的约束,这有助于管理这些问题。
如果没有解决人员问题的正确约束,协作的人越多,随着时间的推移,更改就越复杂、越容易出错、风险就越大。如果没有管理技术问题的适当约束,您交付的越多,最终用户的体验通常就越差。
这些约束最终帮助我们管理人类构建复杂系统并与之交互的最大限制——有限的时间和注意力。
1.1、React和人员问题
解决人的问题就是高杠杆。我们可以在有限的时间和精力下扩展个人、团队和组织的生产力。
团队的时间和资源有限,无法快速交付。作为个人,我们的能力有限,无法将大量的复杂性放在脑海中。
我们的大部分时间都花在弄清楚发生了什么,以及改变或添加新东西的最佳方式上。人们需要能够在不加载和将整个系统放在脑海中的情况下进行操作。
React 成功的很大一部分原因是与当时现有的解决方案相比,它很好地管理了这种约束。它允许团队并行构建解耦组件,这些组件可以声明性地组合在一起,并“只是工作”与单向数据流。
它的组件模型和逃生舱口允许在清晰的边界后面抽象出混乱的遗留系统和集成。然而,这种解耦和组件模型的一个效果是,人们很容易忽视通过树木看清森林的大局。
1.2、React和技术问题
与当时的现有解决方案相比,React 还使实现复杂的交互功能变得更加容易。
它的声明式模型会产生一个 n 元树数据结构,该结构被输入到特定于平台的渲染器(如 react-dom
)中。随着我们扩大团队规模并获取现成的软件包,这种树状结构往往会很快深入。
自 2016 年重写以来,React 主动解决了优化大型深树的技术问题,这些问题需要在最终用户硬件上进行处理。
顺着线,在屏幕的另一边,用户的时间和注意力也有限。人们的期望在上升,而注意力却在缩小。用户不关心框架、渲染架构或状态管理。他们想在没有摩擦的情况下做需要做的事情。另一个限制 - 要快,不要让他们思考。
正如我们将看到的,随着性能问题变得更加紧迫,下一代 React(和 React 风格)框架中推荐的许多最佳实践减轻了处理纯粹在最终用户 CPU 上处理的深度组件树的影响。
2、重新审视巨大的鸿沟
到目前为止,科技行业一直充满了不同轴的钟摆摆动,例如服务的集中化与分散化以及瘦客户端与胖客户端。
随着网络的兴起,我们已经从厚桌面客户端转向瘦客户端。随着移动计算和 SPA 的兴起,又回到了厚实的层面。今天 React 的基本思维模型植根于这种厚重的客户端方法。
这种转变在精通 CSS、交互设计、HTML 和可访问性模式的“前端前端”开发人员与“前端后端”之间造成了分歧,因为我们在前端后端拆分期间迁移到客户端。
在 React 生态系统中,当我们试图调和两全其美的优点时,钟摆正在中间的某个地方摆动,其中大部分“前端后端”风格的代码都迁移回了服务器。
3、从“MVC 中的视图”到应用程序架构
在大型组织中,一定比例的工程师作为平台的一部分工作,该平台倡导并将架构最佳实践融入专有框架中。这些类型的开发人员使其他人能够利用他们有限的时间和注意力来做那些能把培根带回家的事情——比如构建新功能。
受限于有限的时间和注意力的一个影响是,我们经常会默认为感觉最容易的事情。因此,我们希望这些积极的约束使我们走在正确的道路上,并使我们很容易陷入成功的深渊。
这种成功的很大一部分是速度快。这通常意味着减少需要在最终用户设备上加载和运行的代码量。原则上只下载并运行必要的内容。
当我们局限于纯粹的客户范式时,这很难坚持。捆绑包最终包括数据获取、处理和格式化库(例如,moment
),这些库可以独立于主线程。
在像 Remix 和 Next 这样的框架中,这种情况正在发生变化,其中 React 的单向数据流一直延伸到服务器,其中 MPA 的简单请求-响应思维模型与 SPA 的细粒度交互性相结合。
4、返回服务器的旅程
现在,让我们了解一段时间内对这种仅限客户端的范式应用的优化,这需要重新引入服务器以获得更好的性能。这个上下文将帮助我们理解 React 框架,其中服务器进化为一等公民。
下面是提供客户端呈现前端的简单方法 - 包含许多脚本标记的空白 HTML 页面:
这种方法的好处是快速的 TTFB、简单的操作模型和解耦的后端。结合 React 的编程模型,这种组合简化了许多人的问题。
但是我们很快就会遇到技术问题,因为用户硬件有责任做所有事情。我们必须等到所有内容都下载并运行,然后从客户端获取,然后才能显示任何有用的东西。
随着代码多年来的积累,它只有一个地方可以去。如果没有仔细的性能管理,这可能会导致应用程序运行缓慢。
4.1、进入服务器端渲染
我们回到服务器的第一步是尝试解决这些缓慢的启动时间。
我们不是用空白的 HTML 页面来响应最初的文档请求,而是立即开始在服务器上获取数据,然后将组件树渲染为 HTML 并做出响应。
在客户端呈现的 SPA 的上下文中,SSR 就像一个技巧,在加载 Javascript 时最初至少显示一些内容。而不是空白的白屏。
SSR 可以提高感知性能,尤其是对于内容繁重的页面。但它会带来运营成本,并且可能会降低高度互动页面的用户体验 - 因为TTI被进一步推出。
这被称为“不可思议的山谷”,用户在页面上看到内容并尝试与之交互,但主线程被锁定。问题仍然是太多的 Javascript 使单个线程饱和。
4.2、对速度的需求 - 更多优化
因此,SSR可以加快速度,但不是灵丹妙药。还有一个固有的低效率,即在服务器上进行两次渲染,并在 React 接管客户端后重放所有内容。较慢的 TTFB 意味着浏览器在请求文档接收 head
元素后必须耐心等待,以便知道要开始下载哪些资产。这就是流媒体发挥作用的地方,它为这张图片带来了更多的并行性。
我们可以想象,如果 ChatGPT 显示一个微调器,直到整个回复完成。大多数人会认为它坏了并关闭选项卡。因此,我们通过在完成时将数据和内容流式传输到浏览器中来展示我们所能展示的一切。
动态页面的流式传输是一种尽早在服务器上开始提取的方法。并且还可以让浏览器同时开始下载资产,所有这些都是并行的。这比上图要快得多,在上图中,我们等到所有内容都已获取并呈现所有内容后,再发送带有数据的 HTML。
有关流媒体的更多信息
这种流式处理技术取决于后端服务器堆栈或边缘运行时是否能够支持流式处理数据。
对于 HTTP/2,使用 HTTP 流(允许同时发送多个请求和响应的功能),而对于 HTTP/1,使用
Transfer-Encoding: chunked
机制,该机制允许在较小的单独块中发送数据。现代浏览器内置了 Fetch API,它可以将 fetch 响应用作可读流。
响应的 body 属性是一个可读流,它允许客户端在数据从服务器可用时逐块接收数据。而不是等待所有块一次完成下载。
此方法需要设置从服务器发送流式响应并在客户端上读回它们的功能,因此客户端和服务器之间需要密切协作。
流式处理有一些细微差别值得注意,例如缓存注意事项、处理 HTTP 状态代码和错误,以及最终用户体验在实践中的样子。在布局变化和快速 TTFB 之间进行权衡。
到目前为止,我们已经采用了客户端呈现的树,并通过在服务器上早期获取,同时提前刷新 HTML,以同时在服务器上获取数据和在客户端上下载资产来并行化其启动时间。
现在让我们把注意力转向获取和更改数据。
4.3、React 中的数据获取约束
分层组件树的一个约束条件是,节点通常具有多种职责,例如启动提取、管理加载状态、响应事件和渲染。
这通常意味着我们需要遍历树才能知道要获取什么。
在这些优化的早期,使用 SSR 生成初始 HTML 通常意味着手动遍历服务器上的树。这涉及到深入 React 内部以收集所有数据依赖关系,并在遍历树时按顺序获取。
在客户端上,这种“渲染然后获取”序列会导致加载微调器随着布局偏移而来来去去,因为树被遍历以创建顺序网络瀑布。
因此,我们想要一种并行获取数据和代码的方法,而不必每次都从上到下遍历树。
4.4、了解继电器
了解 Relay 背后的原则以及它如何在 Facebook 大规模应对这些挑战非常有用。这些概念将帮助我们理解稍后将看到的模式。
- 组件具有位于同一位置的数据依赖关系
在 Relay 中,组件以声明方式将其数据依赖项定义为 GraphQL 片段。
与同样具有托管功能的 React Query 等产品的主要区别在于组件不会启动提取。
- 树遍历在生成期间发生
Relay 编译器遍历组件树,收集每个组件的数据需求并生成优化的 GraphQL 查询。
通常,此查询在运行时在路由边界(或特定入口点)处执行。允许组件代码和服务器数据尽早并行加载。
主机托管支持最有价值的架构原则之一 - 删除代码的能力。通过删除组件,其数据要求也会被删除,并且查询将不再包含它们。
Relay 缓解了在通过网络获取资源时与处理大型树数据结构相关的许多权衡。
但是,它可能很复杂,需要 GraphQL、客户端运行时和高级编译器来协调 DX 属性,同时保持性能。
稍后我们将看到 React 服务器组件如何在更广泛的 React 生态系统中遵循类似的模式。
4.5、下一个最好的事情
在获取数据和代码时,还有什么方法可以避免遍历树,而不承担所有这些?
这就是 Remix 和 Next 等框架中服务器上的嵌套路由发挥作用的地方。
组件的初始数据依赖关系通常可以映射到 URL。其中,URL 的嵌套段映射到组件子树。此映射使框架能够提前识别特定 URL 所需的数据和组件代码。
例如,在 Remix 中,子树可以独立存在,具有自己的数据要求,独立于父路由,其中编译器确保嵌套路由并行加载。
这种封装还通过为独立的子路由提供单独的误差边界来提供优雅的降级。它还允许框架通过查看 URL 来急切地预加载数据和代码,以实现更快的 SPA 转换。
4.6、更多并行化
让我们深入研究悬念、并发模式和流式处理如何增强我们一直在探索的数据获取模式。
Suspense 允许子树在数据不可用时回退到显示加载 UI,并在准备好时恢复呈现。
这是一个很好的基元,它允许我们在其他同步树中以声明方式表达异步性。这使我们能够同时并行获取资源和渲染。
正如我们之前引入流式处理时所看到的,我们可以更快地开始发送内容,而无需等待所有内容完成后再进行渲染。
在 Remix 中,这种模式用路由级数据加载器中的 defer
函数表示:
// Remix APIs encourage fetching data at route boundaries
// where nested loaders fetch in parallel
export function loader ({ params }) {
// not critical, start fetching, but don't block rendering
const productReviewsPromise = fetchReview(params.id)
// critical to display page with this data - so we 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 中的 RSC 使用服务器上的异步组件提供类似的数据提取模式,这些组件可以等待关键数据。
// Example of similar pattern in a server component
export default async function Product({ id }) {
// non critical - start fetching but don't block
const productReviewsPromise = fetchReview(id)
// critical - block rendering with await
const product = await fetchProduct(id)
return (
<>
<ProductView product={product}>
<Suspense fallback={<LoadingSkeleton />}>
{/* Unwrap promise inside with use() hook */}
<ReviewsView data={productReviewsPromise} />
</Suspense>
</>
)
}
这里的原则是尽早在服务器上获取数据。理想情况下,加载程序和 RSC 靠近数据源。
为了避免任何不必要的等待,我们流式传输不太重要的数据,因此页面可以分阶段逐步加载 - 这很容易使用 Suspense。
RSC 本身没有促进路由边界数据获取的固有 API。如果结构不周密,这可能会导致顺序网络瀑布。这是框架需要在最佳实践中的烘焙与为脚枪提供更多表面积实现更大的灵活性之间需要走的一条线。
值得注意的是,当 RSC 部署在靠近数据的地方时,与客户端瀑布相比,顺序瀑布的影响大大降低。突出显示这些模式强调 RSC 需要与路由器的更高级别的框架集成,该路由器可以将 URL 映射到特定组件。
在我们深入研究 RSC 之前,让我们花点时间了解图片的另一半。
4.7、数据突变
在仅客户端范式中管理远程数据的常见模式是在某种规范化存储(例如 Redux 存储)中。在此模型中,突变通常会乐观地更新内存中的客户端缓存,然后发送网络请求以更新服务器上的远程状态。从历史上看,手动管理它涉及很多样板,并且很容易出现我们在 React 状态管理的新浪潮中讨论的所有边缘情况的错误。钩子的出现导致了 Redux RTK 和 React Query 等专门管理所有这些边缘情况的工具。在仅客户端的范式中,这需要向下交付代码来处理这些问题,其中值通过 React 上下文传播。除了在遍历树时容易创建低效的顺序 I/O 操作之外。
那么,当 React 的单向数据流扩展到服务器时,这种现有的模式会如何变化呢?
这种“前端后部”样式代码中有很大一部分移动到实际的后端。下面是从 Remix 中的数据流中截取的图片,它说明了框架正在向 MPA(多页应用程序)架构中的请求-响应模型转变。这种转变偏离了完全由客户端处理所有事情的模式,而服务器扮演更重要的角色。
您还可以查看 The Web's Next Transition,更深入地了解这一转变。这种模式也扩展到了具有实验性“服务器操作函数”的 RSC,我们稍后会谈到。React 的单向数据流以简化的请求-响应模型扩展到服务器,并逐步增强了形式。从客户端中抄录代码是这种方法的一个很好的好处。但主要好处是简化了数据管理思维模式,这反过来又简化了许多现有的客户端代码。
5、了解 React 服务器组件
到目前为止,我们一直利用服务器来优化纯客户端方法的局限性。今天,我们的 React 心智模型已经深深植根于在用户机器上运行的客户端渲染树。RSC 将服务器作为一等公民引入,而不是事后优化。React 发展成一个强大的外层,后端嵌入到组件树中。
这种架构转变导致对现有的 React 应用程序是什么以及它如何部署的心理模型进行了许多更改。
两个最明显的效果是,到目前为止,我们一直在讨论的优化数据加载模式的可用性,以及自动代码拆分。
在大规模构建和交付前端的后半部分,我们谈到了一些大规模的关键问题,如依赖管理、国际化和优化的 A/B 测试。当仅限于纯粹的客户端环境时,这些问题可能很难大规模地以最佳方式解决。RSC 与 React 18 的许多特性一起,提供了一组基元,框架可以使用这些基元来解决其中的许多问题。
一个令人费解的思维模型变化是客户端组件可以呈现服务器组件。这对于帮助可视化带有 RSC 的组件树非常有用,因为它们一直连接到树的下方。通过客户端组件卡入以提供客户端交互性的“孔”。
将服务器扩展到组件树是很强大的,因为我们可以避免向下发送不必要的代码。与用户硬件不同,我们对服务器资源有更多的控制权。树的根部种植在服务器中,树干延伸到整个网络,树叶被推送到在用户硬件上运行的客户端组件。
这个扩展模型要求我们了解组件树中的序列化边界,这些边界标有'use client'
指令。它还再次强调了母带合成的重要性,以允许 RSC 通过客户端组件中的children
项或插槽,以根据需要在树中进行深入渲染。
6、服务器操作函数
当我们将前端区域迁移回服务器时,正在探索许多创新的想法。这些提供了对客户端和服务器之间无缝融合的未来的一瞥。如果我们能够获得与组件共置的好处,而不需要客户端库 GraphQL,也不必担心运行时的瀑布效率低下,那会怎样?
服务器函数的一个例子可以在 React 风格的元框架 Qwik city 中看到。在React(Next)和Remix中,人们正在探索和讨论类似的想法。Wakuwork 存储库还为实现数据突变的 React 服务器“操作函数”提供了概念证明。
与任何实验方法一样,需要考虑权衡取舍。在客户端-服务器通信方面,存在安全性、错误处理、乐观更新、重试和争用条件等问题。我们已经了解到,如果不由框架管理,往往不会得到解决。
这种探索还强调了这样一个事实,即实现最佳用户体验和最佳 DX 通常需要高级编译器优化,从而增加底层的复杂性。
7、结论
软件只是一个帮助人们完成某事的工具——许多程序员从未理解过这一点。把目光放在交付的价值上,不要过分关注工具的细节——John Carmack
随着 React 生态系统的发展超越了仅限客户端的范式,了解我们下面和上面的抽象是很重要的。
清楚地了解我们运营的基本限制使我们能够做出更明智的权衡。每一次钟摆摆动,我们都会获得新的知识和经验,以融入下一轮迭代。以前方法的优点仍然有效。与往常一样,这是一种权衡。最棒的是,框架越来越多地提供更多的杠杆,使开发人员能够针对特定情况做出更细粒度的权衡。优化的用户体验与优化的开发人员体验相遇,MPA的简单模型与客户端和服务器混合混合的SPA的丰富模型相遇。