overreactedby Dan Abramov

面向 Astro 开发者的 RSC 指南

May 6, 2025

Astro 中,你主要会接触到两种东西:

  • Astro 组件: 这类组件以 .astro 为扩展名。它们只会在服务器端或构建时执行。换句话说,它们的代码绝不会被发送到客户端。因此,它们可以做客户端代码无法做到的事情,比如读取文件系统、调用内部服务,甚至访问数据库。但它们无法实现交互,除非借助原生 HTML 或你自己写的 <script>。Astro 组件可以渲染其他 Astro 组件,也可以渲染客户端岛屿(Client Islands)。
  • 客户端岛屿(Client Islands): 这类组件通常用 React、Vue 等前端框架编写。这里就是你添加交互逻辑的地方。这些客户端岛屿可以通过各自框架的机制继续渲染同一框架下的其他组件,比如 React 组件可以渲染另一个 React 组件,正如你所期望的那样。但你无法在客户端岛屿中渲染 Astro 组件——那样没有意义,因为 Astro 的部分早已运行完毕。

下面是一个 PostPreview.astro Astro 组件渲染 LikeButton 岛屿的例子:

---
import { readFile } from 'fs/promises';
import { LikeButton } from './LikeButton';
 
const { slug } = Astro.props;
const title = await readFile(`./posts/${slug}/title.txt`, 'utf8');
---
<article>
  <h1>{title}</h1>
  <LikeButton client:load />
</article>
import { useState } from 'react';
 
export function LikeButton() {
  const [liked, setLiked] = useState(false);
  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? '❤️' : '🤍'} Like
    </button>
  );
}

可以看到,Astro 组件和客户端岛屿本质上生活在两个不同的“世界”中,数据只能自上而下流动。所有预处理逻辑都在 Astro 组件中完成,然后把交互部分“交接”给客户端岛屿。

现在我们来看看 React Server Components(RSC)。

在 RSC 中,这两类东西分别叫做 服务器组件客户端组件 下面是如何用 React Server Component 实现上面 Astro 组件的写法:

import { readFile } from 'fs/promises';
import { LikeButton } from './LikeButton';
 
async function PostPreview({ slug }) {
  const title = await readFile(`./posts/${slug}/title.txt`, 'utf8');
  return (
    <article>
      <h1>{title}</h1>
      <LikeButton />
    </article>
  );
}
'use client';
 
import { useState } from 'react';
 
export function LikeButton() {
  const [liked, setLiked] = useState(false);
 
  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? '❤️' : '🤍'} Like
    </button>
  );
}

你会发现,这两者背后的思维模型非常相似!如果你已经熟悉 Astro,那么你已经掌握了 React Server Components 80% 的思路。(即使你觉得 React Server Components 不太好,Astro 依然值得学习。)

我们来总结一下上面代码中你可能注意到的语法差异:

  • 与 Astro 组件不同,React 服务器组件就是普通的 JavaScript 函数。它们不是“单文件”格式。props 通过函数参数传递,而不是通过 Astro.props,也没有单独的“模板”部分。
  • 在 Astro 中,Astro 组件和客户端岛屿的分离是通过 .astro 文件实现的。一旦你引入了客户端岛屿,你就不再处于 .astro 文件中,也就“离开”了 Astro 的世界。在 RSC 中,这一目的通过 'use client' 指令实现。'use client' 指令标记了服务器世界的“终点”——它是两个世界之间的一扇门
  • 在 Astro 中,有类似 client:load 这样的指令,可以让你选择岛屿是作为静态 HTML 还是可以在客户端水合。React Server Components 并不会把这种区分暴露给用户代码。从 React 的角度看,如果一个组件本来就是交互式的,移除这种交互是错误的。如果一个组件确实不需要交互,只需去掉 'use client',然后从服务器世界导入即可,它就会保持为服务器专属。

最后这一点很有意思。在 Astro 中,不同的语法(.astro 文件 vs 客户端岛屿)让两个世界有着鲜明且直观的区分。同一个组件不能根据上下文既作为 Astro 组件又作为客户端岛屿——它们是两种不同的东西,有着不同的语法。

但在 RSC 中,“Astro”部分其实也“只是 React”。所以如果你有一个组件,既没有用到客户端特性(比如 State),也没有用到服务器特性(比如读数据库),它就可以在两边都用。

比如一个 <Markdown /> 组件自己解析 Markdown。它既不依赖客户端特性(没有 State),也不依赖服务器特性(不读数据库),那么它可以被两边导入。如果你从服务器世界导入,它就像“Astro 组件”;如果你从客户端世界导入,它就像“客户端岛屿”。这并不是新概念,这只是函数导入的常规用法!

在 RSC 中,从服务器世界导入的内容会在服务器世界运行;从客户端世界导入的内容会在客户端世界运行;而那些在任一世界都不支持的内容(比如客户端访问数据库,或服务器用 useState)会导致构建报错,强制你用 'use client' “开一扇门”。

这既是优点,也是缺点。

它的缺点在于,刚上手 RSC 时会让人觉得难以直观把握。你会一直担心“自己现在处于哪个世界”。需要多加练习,才能适应其实不用太在意,因为你总可以局部推理:需要用数据库的地方就写在服务器文件里,需要用 State 的地方就写在客户端文件里,哪里出错构建时会报错,然后你看下模块调用栈,决定在哪里“开新门”让“岛屿”进来。

确实是个缺点,但也是优点。正因为两边都是 React,RSC 的模型解决了你在 Astro 中可能遇到的一些限制:

  • 有时候,你写了一堆 Astro 组件,后来发现需要把 UI 移到客户端岛屿(还得调整语法),甚至要重复写一份,因为某些动态 UI 也想驱动它们。在 RSC 中,你可以把公共部分提取出来,两边都能导入。你不必每次都纠结“这部分大概会更动态”还是“这部分大概会更静态”,因为你可以随时加或去掉 'use client',或者在导入链上上下移动它,几乎没有阻力。你只需要决定在哪里“开门”,而不用来回“转换”组件类型。
  • 在 Astro 中,你可以在客户端岛屿中嵌套 Astro 组件,但如果这些 Astro 组件里又有更多客户端岛屿,它们在你的前端框架(比如 React)里依然会被视为独立的根节点。这就是为什么嵌套交互行为不像纯客户端应用那样自然,比如 React 或 Vue 的 context 不能在 Astro 岛屿间传递。 在 RSC 中,这不是问题——整个 UI 在底层就是一棵 React 树。你可以在某个服务器子树之上放一个客户端 context provider,然后在任意下层的客户端组件中读取 context。RSC 就像分形岛屿
  • Astro 组件最终只能输出 HTML。这也是为什么在 Astro 站点点击链接时,浏览器需要完全重新加载页面。如果你觉得这种用户体验可以接受,那很好!你可以通过手动逻辑和 View Transitions 做些优化,但本质上页面的 HTML 确实会被替换。如果你想要类似 SPA 的导航,始终保持导航栏的状态,无论是 React 状态还是 DOM 状态(比如输入框和滚动位置),RSC 就能填补这个空白。RSC 用一种类似 JSON 的格式来描述 React 树——它既可以转化为 HTML(用于首次渲染),也可以在导航时重新以 JSON 形式获取。换句话说,RSC 让你可以 MPA 的思维模式开发——但体验却像 SPA。
  • 这也意味着,与 Astro 不同,RSC UI 的服务器部分可以原地刷新。如果你真的运行了服务器(而不是像我写博客那样只在构建时用 RSC),RSC 允许你随时“刷新”界面,让最新的服务器 props 流入你已有的有状态客户端树。比如,在 Astro 中,如果某个组件需要响应交互刷新,你只能选择整页刷新或把逻辑移到客户端岛屿。而在 RSC 中,你只需请求服务器返回新的 JSX,然后合并进现有树即可。

在 Astro 中,最终输出格式是 HTML。由于前端框架本质上并不直接操作 HTML(而是操作可以通过 HTML 初始化的有状态 DOM),Astro 采用“一次性交接”模式。这让它更容易上手,但也限制了服务器特性只能服务于“首次渲染”,其余交互部分基本靠你自己实现。随着你让更多内容变得可交互,你可能会逐渐遇到 Astro 模型的限制,最终不得不把更多逻辑迁移到 SPA 风格但彼此隔离的岛屿中。

在 RSC 中,最终输出格式是一棵 React 树(可以转为 HTML,也可以作为 JSON 重新获取)。由于 RSC 两边都是 React,没有视觉上的“世界分界”,所以上手时更有挑战。但一旦你习惯了边界的移动,它们就变得非常灵活,解决了“因为某部分比预期更静态或动态而不得不把代码‘移入 Astro’或‘移回岛屿’”的问题。无论 UI 是只读的还是需要响应变更重新获取数据,你都可以保持“只需把数据映射到 UI”这一思维模式。服务器部分可以深入树的任意层级——与它们的客户端部分交错分布

而且因为两边都是 React,所有 React 特性都能端到端集成:比如 <Suspense> 声明式加载状态在客户端会“知道”要等待哪些异步数据(来自服务器)、JS 和 CSS(客户端加载)、字体和图片(有合理超时),甚至还能触发 View Transitions(见这里)。在 React 中,每个特性都设计为服务器和客户端部分可以任意嵌套、组合,并且原地刷新。它就是一棵树。缺点是,选择 RSC 就意味着选择 React。RSC 就是全栈 React。

最后需要注意,Astro 是一个框架,而 RSC 本身是更底层的东西——可以把它看作是构建框架的积木,或者是框架可以实现的标准。目前官方支持的两种 RSC 实现包括 Next.js App Router(一个框架)和 Parcel RSC(不是框架)。

就我个人而言,我觉得 RSC 的开发体验还比较粗糙,但我依然建议你去了解一下。它有很多有趣的理念。

另外,如果你从没用过 Astro,也可以试试!如果 RSC 让你觉得难以上手,Astro 也许能让你更平滑地理解这些思想。而如果你只用过客户端 React,Astro 也可能帮你解决一些你之前没意识到的问题。

Pay what you like

Discuss on Bluesky  ·  Edit on GitHub