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

avatar

原文链接,如有侵权,请联系删除。

深入探讨 React 从客户端视图库到应用程序架构的演变。

介绍

十多年前,React 针对基于客户端渲染的单页面应用(SPA)进行了全新的思考,重新定义了其最佳实践。

如今,React 已经被广泛应用,同时也持续受到相当多的批评和质疑。

React 18 的出世,以及随之而来的 React 服务端组件(RSCs),意味着 React 迎来了一个重大的阶段转变,而不再局限于其最初宣称的用于客户端 MVC 的“视图层“。

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

安娜卡列尼娜原则指出:

所有幸福的家庭都是相似的,而每个不幸的家庭都有其自身的不幸之处。
(译者注:出自小说《安娜·卡列尼娜》,这句话用在成功上也是一样的:成功者有着相似的成功,失败者各有各的失败。相似的成功不是说他们取得的成就相似,而是成功者具有类似的特质和优势。)

作为开始,我们将尝试理解使用 React 需要考虑和遵循的核心约束,以及过去管理这些核心约束的方法,探索一个成功的 React 应用程序所遵循的基本模式和原则。

到最后,我们将能够理解 Remix 和 Next 13(App Router)等框架的思维模型是如何不断演变的。

首先,让我们从了解迄今为止我们一直在试图解决的潜在问题开始。这有助于我们从一个整体上下文中思考 React 核心团队的使用建议和 React 高级框架的设计思路。也能帮助我们更好的理解这些 React 高级框架是如何紧密地将服务器、客户端以及打包工具集成在一起的。

正在解决什么问题?

软件工程中通常有两类问题:技术问题和人员问题。

关于架构,我们可以从这种角度思考:将其视为伴随着时间的推移,找到适当的约束条件来帮助解决诸多问题的过程。

如果没有解决人员问题的适当约束条件,协作的人越多,随着时间的推移,事情就会越来越复杂,风险就会增加,问题出现的概率也就越大;如果没有解决技术问题的适当约束条件,随着项目的功能越多,最终用户的体验通常会变得越差。

这些约束条件最终帮助我们管理在构建复杂系统时面临的最大限制 —— 有限的时间和注意力

React 和人员问题

解决人员问题是一种高效的方式。我们可以用有限的时间和注意力提高个人、团队和组织的生产力。

团队在快速交付方面的时间和资源是有限的。作为个人,我们在头脑中承载大量复杂性的能力也是有限的。

我们大部分的时间都花在弄清楚发生了什么,以及如何更好地进行变更或添加新内容上。人们需要能够在不必将整个系统细节都记在脑中的情况下进行操作。

React 之所以成功,其中一个重要原因是它在管理这一限制方面相比当时的其他解决方案更出色。它使团队能够合理的拆分组件(解耦),并行的开发,然后再将它们通过声明式的方式进行组合,并且通过单向数据流串起来,实现了“即插即用”的效果。

React 的组件模型和扩展机制允许我们将遗留系统和集成的复杂性隐藏在清晰的边界之后。然而,这种解耦和组件模型的一个副作用是很容易陷入纠结于细节而忽视整体的情况(见树不见林)。

React 和技术问题

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

它的声明式模型产生了一个 n 元树数据结构,该结构被送入特定于平台的渲染器,如 react-dom 。随着团队规模的扩大,我们开始更多地寻求并使用现成的软件包,这种树形结构往往会迅速变得更深。

自 2016 年重写以来,React 主动解决了大型深度树的优化问题,尽管这些优化依然发生在用户的硬件设备上。

然而,从用户体验出发,用户的时间和注意力也是有限的。用户的期望值是不断升高的,而注意力是被各种事情所分散的。用户根本就不关心应用背后所采用的框架、渲染架构、或者状态管理等细节。他们希望能够顺利完成需要完成的任务,不受阻碍。另一个限制是要快速执行,不留给他们思考的空间。

正如我们看到的,随着性能问题变得更加紧迫,下一代 React(以及 React 风格)框架中推荐的许多最佳实践,都在着力解决深度组件树对最终用户 CPU 的占用问题。

重温重大分歧

正所谓,天下大事,分分合合。到目前为止,科技行业也充满了这种周期性的摆动现象,比如服务的集中化与分散化,瘦客户端与胖客户端。

随着网络的兴起,我们已经经历了从胖桌面客户端到瘦客户端的转变。随着移动计算和 SPA 的兴起,我们又重回胖客户端。当今的 React 就是立足于这种胖客户端的大背景。

然而这种转变产生了新的分歧,这种分歧发生在精通 CSS 、交互设计、HTML 以及可访问性的“前端的前端”工程师,和前后端分离的大背景下的“前端的后端”工程师之间。

在 React 生态系统中,当我们试图调和这“两种前端”工程师的问题时,我们又产生了新的技术周期,也就是许多”前端的后端“风格的代码,又有重新回到服务端的趋势。

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

在大型组织中,一部分工程师会成立平台或者架构组,去推动并将架构最佳实践融入专有框架中。这类开发人员使其他人能够将有限的时间和注意力集中在像构建新功能这样的利益最大化的事情上。

受到有限时间和注意力的限制,我们通常会选择最容易的方式作为默认选择。因此,我们希望有一些积极的约束,使我们保持正确的方向,并且让我们轻松地避免“掉入成功的陷阱”。

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

当我们局限于纯客户端范式时,要遵循这个原则是很困难的。客户端 JS 包中包括了大量数据获取、处理和格式化的代码和库(例如 moment ),这些代码其实是可以不在客户端运行的。

在像 Remix 和 Next 这样的框架中,这种情况正在发生转变,React 的单向数据逻辑流延伸到了服务器端,将 MPA 的简单请求-响应的思维模型与 SPA 的细粒度交互性相结合。

重回服务端之旅

现在,让我们了解随着时间推移,我们对这种仅客户端范式所采用的一些优化,这些优化需要重新引入服务器以提高性能。通过本章内容,我们将会明白服务端是如何逐步进化成为 React 框架的第一等公民的。

下图简单直观的描述了一个客户端渲染页面的方式 —— 一个带有若干 script 标签的空白 HTML 页面

Diagram showing the basics of client-side rendering.

这种方式的好处是快速的 TTFB 、简单的操作模型、以及分离的后端。结合 React 的编程模型,这种组合简化了很多人的问题。

但是我们很快就会遇到技术问题,因为这种方式意味着用户设备要做所有的事情。我们必须等待直到一切都下载并运行,然后继续等待客户端去获取任何有用的信息,并展示在屏幕上。

随着多年的积累,代码只能越来越多,但主入口只有一个。如果没有仔细管理性能,这可能导致应用程序运行非常缓慢,甚至无法正常运行。

进入服务端渲染

我们重回服务端的第一步是尝试解决这些慢启动的问题。

我们不再使用空白的 HTML 页面来响应初始文档请求,而是立即开始在服务器上获取数据,然后将组件树渲染为 HTML 并将其返回给客户端。

从客户端渲染的 SPA 来看,SSR 就像一个技巧,可以在 JavaScript 加载之间至少展示一些内容,而不是白屏。

Diagram showing the basics of server-side rendering with client-side hydration.

SSR 可以提升感知性能,尤其对于内容密集型页面。但它会带来运营成本,并且可能会降低高交互页面的用户体验 —— 因为 TTI 被进一步推迟了。

这被称为“神秘山谷”现象,用户在页面上看到内容并尝试与之交互,但由于主线程被锁定,交互无法完成。根本问题仍然是过多的 Javascript 占据了单个线程。

对速度的需求 —— 更多的优化

所以 SSR 可以加快速度,但不是银弹(编程领域用语,没有银弹)。

这里还存在着一个重复执行的低效问题,即先在服务器上执行了一次渲染,然后在客户端 React 还需要接管页面再执行一次(Hydration)。

较慢的 TTFB 意味着浏览器在请求文档后必须耐心等待,直到接收到 head 元素以确定要开始下载的资源。

这就是流式传输发挥作用的地方,它为整个过程带来了更多的并行性

我们可以想象一下,如果 ChatGPT 在完全回复之前一直显示一个加载指示器,大多数人会认为它出错了并关闭标签页。因此,我们通过将已经完成的数据和内容流式传输到浏览器,以尽早展示我们可以展示的一切。

动态页面的流式传输是一种尽早在服务器上开始获取数据的一种方式,并同时让浏览器开始并行下载资源。这比先前那张图片所描述的流程要快得多,在前面那种情况下,我们需要等待所有数据获取和渲染完成后才发送包含数据的HTML。

更多关于流式传输的知识

流式传输技术依赖后端服务栈或者边缘运行节点支持流式数据。

对于 HTTP/2 ,可以使用 HTTP 流(一个允许持续多次发送请求和响应的特性)。但是对于 HTTP/1 ,可以通过 设置 Transfer-Encoding: chunked 支持多次发送分割的数据片段(chunk)的功能。

现代化的浏览器都已经内置 Fetch API ,它可以通过一个可读流来处理接口响应结果。

该响应的 body 属性是一个可读流,它允许客户端逐块接收数据,而不需要等待所有块一次性下载完成。这样可以实现数据的逐步传输,提高响应速度。

这种方式首先需要服务端支持并开启流式数据响应的能力,然后客户端以流的方式来消费数据。非常类似的一个实现案例可以参考 Remix 的 Streaming

值得注意的是,相比传统方式,流式传输存在一些细微差别,例如缓存考虑、HTTP 状态码和错误的处理,以及实际的最终用户体验。在快速的首字节时间(TTFB)和布局变化之间也需要做出权衡。

到目前为止,我们已经优化了原来的纯客户端渲染的方式,通过在服务器上尽早获取数据并流式地返回 HTML ,使服务器上的数据获取与客户端上的资源下载同时进行,从而提高了启动时间。

现在让我们将注意力转移到获取和改变数据上。

React 中的数据获取限制

在一个层级化的组件树中,“一切皆组件”的一个限制是,节点通常具有多个责任,例如发起数据获取、管理加载状态、响应事件和渲染等。

这通常意味着我们需要遍历树才能知道要获取什么

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

在客户端,“先渲染再获取数据”的顺序会导致加载指示器和布局变化交替发生,因为在遍历组件树时会创建一个顺序网络瀑布。

所以我们想要一种方法来并行获取数据和代码,而不必每次都从上到下遍历树。

了解 Relay

了解 Relay 背后的原则以及它如何在 Facebook 大规模应对这些挑战非常有用。这些概念将帮助我们理解稍后将会看到的模式。

  • 组件和其数据依赖绑定在一起

    在 Relay 中,组件以声明方式将其数据依赖项定义为 GraphQL 片段(GraphQL Fragments)。

    与其他类似定位的库如 React Query 的最大不同是,组件不会真正请求数据

    译者注:这里可以去 Relay 的官网简单看一下,虽然 Relay 诞生了很多年了,在国内貌似也不火,但其实它真的很有意思

  • 树遍历发生在构建阶段

    Relay 编译器遍历组件树,收集每个组件的数据需求并生成最终优化后的 GraphQL 查询。

    在运行阶段,通常此查询会在路由边界(或特定入口点)处执行。如此,我们可以尽可能早地并行加载组件代码和服务器数据。

将组件代码和数据模型放在一起,意味着我们可以支持最有价值的架构原则之一 —— 删除代码的能力。通过删除组件,它的数据模型依赖也被删除,然后 GraphQL 请求就不会再包含着一部分。

Relay 在解决大型树状结构数据的网络传输方面,做了很多权衡。

但是,它可能很复杂,需要 GraphQL、客户端运行时和高级编译器,来保证追求性能的同时,保持较高的开发体验。

稍后我们将看到 React 服务器(RSC)组件如何为更广泛的 React 生态系统遵循类似的模式。

下一个最好的事情

还有什么方法可以避免在获取数据和代码时遍历树,还不用承担类似 Relay 带来的复杂性?

这就是 Remix 和 Next 等框架中服务器上的嵌套路由发挥作用的地方。

组件的初始数据依赖关系通常可以映射到 URL 上。 URL 的嵌套片段映射到组件子树的位置。此映射使框架能够提前识别特定 URL 所需的数据和组件代码。

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

这种封装还提供了优雅的降级处理,通过为独立的子路由提供单独的错误边界。它还允许框架通过分析 URL 来主动预加载数据和代码,以实现更快的 SPA 过渡效果。

更多并行化

让我们深入研究 Suspense并发模式流式处理如何增强我们一直在探索的数据获取模式。

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(React Server Components)提供了类似的数据获取模式,可以使用服务端的异步组件来等待关键数据。

// 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(React Server Components)与数据源靠近。

为避免任何不必要的等待,我们流式传输不太重要的数据,因此页面可以分阶段逐步加载 —— 使用 Suspense 可以轻松实现这一点。

RSC 本身没有提供 API 来支持在路由边界处进行数据获取。如果使用不当,还是可能会导致顺序网络瀑布。这是框架在提供最佳实践和保持更大灵活性之间需要牢记的一点。框架需要平衡更多的功能与灵活性之间的关系。

值得注意的是,当 RSC 部署在数据附近时,与客户端瀑布效应相比,顺序瀑布的影响会大大降低。这凸显了 RSC 对内置了强大路由器的高级框架的需求,该路由器需要能够将 URL 映射到特定的组件。

在我们更深入地了解 RSC 之前,让我们额外花一点时间了解一些其他的情况。

数据变更

在一个纯客户端范式中,管理远程数据的常见模式是将数据存放到某种规范化存储中(例如 Redux Store)。

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

在过去,手动管理这一过程通常涉及大量样板代码,并容易在我们讨论过的所有边缘情况中出现错误,如 The new wave of React state management 中所述。

Hooks 的出现导致了诸如 Redux RTKReact Query 这样的工具,它们专注于处理所有这些边缘情况。在纯客户端范式中,这需要通过网络传输代码来处理这些问题,其中值通过 React 上下文进行传播。但是,在遍历树时,这样做很容易创建低效的顺序 I/O 操作。

那么,当 React 的单向数据流向上延伸到服务端时,这种现有模式如何改变?

大量这种“前端的后端”风格的代码移到了真正的后端。

下图来自 Data Flow in Remix ,它正好说明了框架正在向 MPA(多页面应用程序)架构中的请求-响应模型转变。

这种转变是从完全由客户端处理一切的模式转变为服务端发挥更重要作用的模式。

Diagram from Remix showing how reacts unidirectional data flow extends up into the server

您还可以查看 The Web’s Next Transition 以更深入地了解这一转变。

这种模式还延伸到了 RSC ,通过实验性的“服务器操作函数”(server action functions),我们稍后会介绍。在这种模式下,React 的单向数据流延伸到了服务端,在一个简化的请求-响应模型中实现了逐步增强的表单处理。

通过这种方法,我们可以从客户端中剥离代码,这是一个很好的好处。但主要的好处是简化数据管理的思维模型,从而简化了许多现有的客户端代码。

了解 React 服务端组件

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

如今,我们对 React 的认知模式主要是在用户机器上运行的客户端渲染树。RSC 将服务端作为一等公民引入,而不仅仅是一种事后的优化手段。React 的轮廓进一步变大,后端也已经被嵌入到一个更大的组件树中。

这种架构转变对于现有的 React 应用的开发模式以及部署方式带来了许多改变。

其中最明显的两个变化,一个是我们前面一直在讨论的数据加载方式的优化,还有一个是自动代码拆分(Code Splitting)。

“大规模构建和交付前端”的后半部分,我们谈到了一些大规模的关键问题,如依赖管理、国际化和优化的 A/B 测试。

在纯客户端环境下,这些问题在大规模情况下往往难以有效解决。RSC 与 React 18的许多功能一起提供了一套机制,框架可以使用这一套机制来解决其中的许多问题。

一个令人费解的思维模式变化是,客户端组件可以渲染服务器端组件

这有助于可视化带有 RSC 的组件树,构建一个完整的树。通过树上的一些孔,可以将客户端组件插入进去以提供客户端交互能力。

Diagram showing a React tree mixed with server and client components composed together

将服务器延伸到组件树的底部是非常强大的,因为我们可以避免将不必要的代码发送到客户端。而且,与用户的硬件不同,我们对服务器资源有着更多的控制权。

组件树的根部扎根于服务器,树干延伸到网络中,叶子被推送到在用户硬件上运行的客户端组件中。

这种可扩展的模型要求我们注意识别组件树中的序列化边界,这些边界使用 'use client' 指令进行标记。它还重新强调了 mastering composition 的重要性,以允许 RSC 通过 children 或插槽在客户端组件中传递,以便其根据需要在树的深层进行渲染。

服务端操作函数

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

如果我们可以将数据和组件放在一起,但又不需要使用客户端库、GraphQL,也不必担心运行时的效率问题,那该有多好?

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

Wakuwork 也提出并验证了一个概念,可以实现 React 服务端的“操作函数”来实现数据变更操作。

像任何其他实验性的方法一样,有许多方面需要仔细思考,权衡利弊。在客户端和服务器之间的通信方面,涉及到安全性、错误处理、乐观更新、重试和竞态条件等问题。我们了解到,如果没有框架管理,这些问题往往被忽视。

这种探索还强调了这样一个事实,既想要实现最佳的用户体验,又想要好的开发者体验,往往需要更加复杂的高级编译器来保证。

结语

软件只是一种帮助人们完成某些事情的工具——许多程序员从来不理解这一点。关注交付的价值,不要过分关注工具的细节——John Carmack

随着 React 生态系统的发展超越了仅限于客户端的范式,理解我们所处的抽象层次是很重要的。

对我们所面临的基本情况及其限制有清晰的理解,可以帮助我们做出更明智的权衡。

随着每一次技术周期的来临,我们获得新的知识和经验,以融入下一轮的迭代中。先前方法的优点仍然有效。一如既往,这是一个权衡。

令人欣喜的是,框架越来越多地提供更多的手段,使开发人员能够针对特定情况做出更细粒度的权衡。优化的用户体验与优化的开发者体验相得益彰,简单模型的多页面应用程序(MPAs)与复杂模型的单页面应用程序(SPAs)在客户端和服务器混合应用中和谐共存。

参考资料