← 返回 昏暗房间里的复古服务器机架,狭窄窗户透出温暖金色光线,35毫米胶片颗粒感

React Server Components解释——没有浮言

2023年初,我的一个客户——一家位于金丝雀码头的金融科技初创公司——把一个由前一个代理机构搭建的Next.js 13代码库交给了我。它使用了新的app/目录。不错。除了到处都是hydration错误,bundle是340 KB gzipped,而且老团队没有人能解释为什么他们在字面上每个文件都放了"use client"。当我问他们关于React Server Components的问题时,他们说:"哦,是的,我们用那些。"他们没有用。

这就是RSC现在的问题。每个人都声称理解它们。几乎没有人真正理解。所以让我给你我希望在2023年初就存在的版本。

RSC实际上是什么(不是营销版本)

React Server Components是仅在服务器上运行且永远不会向浏览器发送JavaScript的组件。就是这样。

不是"服务器端渲染"。不是"预渲染"。这些东西在RSC之前就存在了,它们的工作方式不同。使用传统SSR(Next.js pages路由器所做的),你的组件在服务器上渲染以生成HTML,但随后相同的JavaScript被发送到客户端,以便React可以"hydrate"页面、附加事件监听器并接管。

RSC完全跳过第二部分。组件在服务器上运行,渲染,以序列化的负载形式将其输出发送到客户端,然后...就这样。该组件的JavaScript永远不会进入浏览器。

实际效果:如果你有一个使用40 KB markdown解析器来渲染一些文本的<ProductDescription>组件,并且你把它变成服务器组件,那40 KB永远不会进入用户那里。被解析的HTML会。就是这样。

React团队的原始RFC其实值得读,如果你想要完整的理由的话。它很密集但诚实。

最终让我恍然大悟的心智模型

把你的组件树想象成两个独立的世界,恰好被拼接在一起。

世界 1(服务器)。完全访问你的数据库、文件系统、环境变量、密钥。无法使用 useState、useEffect 或任何浏览器 API。无法附加事件监听器。

世界 2(客户端)。在浏览器中运行。可以使用你知道的所有 React hooks。无法直接与数据库通信。改为向 API 发送请求。

在 RSC 之前,每个组件都生活在世界 2 中,即使它先在服务器上渲染。使用 RSC,你现在可以显式地将组件放在世界 1 中。这些组件可以渲染世界 2 组件作为子组件,向它们传递可序列化的 props。但世界 2 组件无法渲染世界 1 组件。边界是单向的。

这是人们容易混淆的地方:你不需要添加指令来把某个东西变成服务器组件。在 Next.js 的 app/ 目录中,默认情况下一切都是服务器组件。你用 "use client" 来选择进入客户端。与大多数人的假设相反。

Seahawk 去年有一个项目,为一家英国零售商建立的大型电商目录,我们审计了 60 多个组件。结果大约 40 个有 "use client" 但没有理由。移除它将 JavaScript 包大小减少了大约 28%。一个下午的工作。

你可以做什么和不能做什么(具体分解)

服务器组件可以:

  • 直接用 async/await 获取数据,没有 useEffect,没有加载状态,就是 const data = await db.query(...)
  • 导入重型的服务器专用库(比如用于前置内容解析的 gray-matter,或 PDF 生成器),不会影响客户端包
  • 使用 Node 的 fs 模块从文件系统读取
  • 访问你不想暴露给浏览器的环境变量
  • 将数据作为 props 传递给客户端组件

服务器组件不能:

  • 使用 useState useReducer
  • 使用 useEffect useLayoutEffect
  • 附加事件处理器(没有 onClick、没有 onChange
  • 使用浏览器 API(window、document、localStorage)
  • 直接使用 React Context(虽然有办法可以解决)

客户端组件可以做上面服务器组件不能做的一切,但:

  • 他们无法直接调用你的数据库
  • 他们无法使用服务器专用的包
  • 他们的代码传送到浏览器

这句话不仅仅是关于性能。它关乎代码在哪里运行以及它能访问什么。这个框架比"快"和"慢"的思维方式更有用。

数据获取是 RSC 真正闪耀的地方

这是我真正喜欢的部分。在 RSC 之前,在 Next.js 应用中获取数据通常意味着以下几种方式之一:getServerSideProps、getStaticProps、客户端 useEffect 调用,或者这三种方式的某种组合加上自定义 hook 来管理加载状态。很混乱。

使用 RSC,你只需...获取数据。在组件内部。在顶级。

`` async function ProductPage({ id }) { const product = await getProduct(id); // calls your DB directly return <ProductDetails product={product} />; } ``

没有从页面级别函数来的 prop 传递。没有加载动画去等待应该在页面加载时就准备好的数据。该组件是异步的,它等待它需要的数据,然后渲染。

最有意思的部分来了:因为每个服务器组件都可以独立获取自己的数据,你避免了旧的"God 组件"问题,即一个顶级函数必须为整个页面编排所有数据。组件变得自我包含。当你使用 await Promise.all() 或兄弟组件独立获取数据时,并行获取自然发生。

去年春天我在伯明翰物流公司的仪表板项目中使用过这个模式。每个小部件、货运统计、延迟警报、成本摘要,都是自己的异步服务器组件。没有共享加载状态,没有 prop 传递,没有竞态条件。页面的 LCP 从 2.1 秒降到了 0.9 秒。不仅仅是因为 RSC,而是 RSC 使正确的架构更容易实现。

渲染管道(实际发生的事)

当用户在使用 app/ 路由的 Next.js 14 应用中请求一个页面时,大致的流程是:

  1. Next.js 在服务器上运行你的服务器组件
  2. 这些组件生成一种称为 React Server Component Payload(RSC Payload)的特殊格式,不是原始 HTML,而是 UI 树的序列化描述
  3. Next.js 使用该 payload 生成初始 HTML(用于首次绘制)
  4. 该 HTML 发送到浏览器
  5. RSC Payload 也被发送,客户端 React 运行时使用它来hydrate 树中的客户端组件
  6. 客户端组件获得它们的 JavaScript、hydrate 并变成交互式的

第 5 步是 RSC 与普通 SSR 不同的地方。客户端不重新渲染服务器组件。它只是使用 payload 来填补图景,然后将 hydrate 工作集中在交互部分。

Next.js 关于渲染的文档比我读过的大多数博客文章都更好地解释了 payload 格式,如果你想深入了解的话。

RSC 崩溃的地方(诚实的批评)

好吧。我们别假装这一切都是晴天白日。

"use client" 边界会级联。一旦你在组件上放置 "use client",它导入的每个组件也会变成客户端组件。如果你不小心,一个 onClick 处理程序可能会把一个出人意料的大子树拉入客户端 bundle。我在团队中见过这种情况多次困扰初级开发者。

Context 很痛苦。React Context 在服务器组件中不起作用。如果你围绕 Context 构建了应用架构来处理主题、认证状态或全局配置,你需要重构。有一些解决方法(将值作为 props 传递,使用客户端包装器),但这是你之前没有的摩擦。

调试更难。服务器组件错误不会以相同的方式显示在浏览器控制台中。错误边界行为是不同的。你需要检查服务器日志。不是交易破坏者,但如果你习惯于所有错误都在 DevTools 中,预期会有学习曲线。

第三方库通常还没准备好。任何在引擎盖下使用 hook 或浏览器 API 的库都会在服务器组件中崩溃。你会花时间在 "use client" 文件中包装东西,仅仅是为了使用日期选择器或动画库。React 生态系统正在赶上,但截至 2024 年,它仍然不完整。

说实话,对于简单的营销网站或内容博客,你可能根本不需要 RSC。如果你的团队习惯了 pages 路由,性能也没问题,仅仅为了 RSC 支持而升级不值得付出那么大的代价。我已经多次劝阻客户放弃迁移。

在现有项目中采用 RSC 的实用建议

如果你要把现有的 Next.js 应用迁移到 app/ 目录,这是我建议的顺序:

  1. 从叶子组件开始。只展示数据不处理交互的组件最容易实现。先把它们改成服务器组件。
  2. 找出你真正需要交互的地方。通常比你想的要小。表单、模态框、下拉菜单。那些是你需要"use client"的地方。
  3. 把"use client"放得尽可能低。提交表单的按钮应该是客户端组件。它周围的表单布局可能不需要。
  4. 用 [@next/bundle-analyzer](https://www.npmjs.com/package/@next/bundle-analyzer) 审计你的包。迁移前后都运行一次。如果你没看到明显的包体积减小,说明你可能还在向客户端发送太多代码。
  5. 不要共享服务器专用模块。从 npm 安装 server-only 并在任何不应该到达浏览器的模块顶部导入它。如果有东西试图在客户端导入它,它会在构建时抛出错误。

server-only 包看起来很小,但它帮我们避免了一个医疗客户项目中相当尴尬的数据泄露。一个开发者不小心把数据库工具导入到了一个后来被标记为"use client"的组件里。这个包在构建时就捕获到了。要用它。

FAQ

React Server Component 和 SSR 是一样的吗?

不是。SSR(服务端渲染)在服务器上将组件渲染成 HTML,但仍然会把组件的 JavaScript 发送给客户端进行水合。RSC 在服务器上渲染,从不把 JavaScript 发送到浏览器。SSR 和 RSC 可以一起工作,在 Next.js app/ 目录中确实会这样,但它们解决的是不同的问题。

使用 React Server Component 需要 Next.js 吗?

从技术上讲不需要,但对大多数团队来说实际上需要。RSC 需要一个处理服务器基础设施、路由和 RSC 负载管道的框架。Next.js 13+ 配合 app/ 目录目前是最生产就绪的选项。Remix 有不同的模式。自己搭建也可能,但除非你在构建框架本身,否则不值得花时间。

能在同一个页面混用服务器和客户端组件吗?

可以,这正是关键所在。一个页面可能是服务器组件(获取数据,不发送 JS),包含一个用于搜索输入的客户端组件,它又包含通过 children prop 传入的服务器组件作为子组件。树形结构会交错。要记住一点:服务器组件可以渲染客户端组件,但客户端组件不能直接渲染服务器组件。

我现有的自定义 hook 会怎样?

使用 useState、useEffect 或浏览器 API 的自定义 hook 只能在客户端组件中运行。你不需要重写它们,只需要有意识地决定哪些组件使用它们。如果一个 hook 包装了数据获取调用,考虑这些数据是否可以在服务器组件中获取并作为 props 传入。通常是可以的,这样你最后会得到更简洁的代码。

RSC 负载格式有性能成本吗?

可能有,特别是当你通过负载传递大量数据时。RSC 负载不同于 JSON,它是一种自定义格式,但它仍然会给你的响应增加字节。对大多数应用来说,这相比 JS 包体积的节省可以忽略不计。对于通过 props 传递大型数据集的应用,要进行性能分析。不要假设 RSC 在网络上总是更小。

---

RSC 是一个真正的架构转变,不只是一个新的 API。心智模型需要一段时间才能稳定下来。给它这个时间。在提交一个大客户项目之前,先用 app/ 目录构建一些小东西。在我相信自己能把基于 RSC 的架构发布到生产环境之前,我花了大约三周时间进行实验,而我从 2016 年就开始写 React 了。

那些没有用它做过真实东西的人仍然会手舞足蹈。现在你至少懂得够多,能够分辨区别了。

← 返回