overreactedby Dan Abramov

每次导航只需一次往返

May 29, 2025

导航到另一个页面应当需要多少次请求?

在最简单的情况下,一次导航只需一次请求。你点击一个链接,浏览器请求新 URL 的 HTML 内容,然后显示出来。

但在实际应用中,一个页面还可能需要显示一些图片、加载客户端 JavaScript、加载额外的样式等等。所以会有一堆请求。其中有些是渲染阻塞的(浏览器会等它们加载完再显示页面),其余的则是“可有可无”的。也许它们对完整的交互性很重要,但浏览器可以在它们加载时先显示页面。

那么,数据 的加载又如何呢?

获取下一个页面的数据,应该需要多少次 API 请求?


HTML

在大部分 Web 开发还未转向客户端之前,这个问题甚至没有意义。那时没有“调用 API”这个概念,因为你不会把服务器当作 API 服务器——它就是 服务器,返回 HTML。

在传统的“HTML 应用”,也就是网站中,获取数据总是只需一次往返。 用户点击链接,服务器返回 HTML,所有展示下一个页面所需的数据都已经嵌入在 HTML 里了。HTML 本身 就是 数据。它无需进一步处理——拿来就能显示:

<article>
  <h1>每次导航只需一次往返</h1>
  <p>导航到另一个页面应当需要多少次请求?</p>
  <ul class="comments">
    <li>你只是在重造 HTML</li>
    <li>你只是在重造 PHP</li>
    <li>你只是在重造 GraphQL</li>
    <li>你只是在重造 Remix</li>
    <li>你只是在重造 Astro</li>
  </ul>
</article>

(没错,技术上来说,一些静态、可复用且可缓存的内容如图片、脚本和样式会被“外联”,但你也可以在需要时直接内联它们。)


“REST”

随着越来越多的应用逻辑转移到客户端,情况发生了变化。我们需要获取的数据通常由需要展示的 UI 决定。比如要展示一篇帖子,就要获取该帖子;要展示评论,就要获取评论。那么,我们需要发起多少次请求?

在 JSON API 中,一种被称为 REST 的技术建议为每个概念上的“资源”暴露一个端点。没人能确切说清“资源”是什么,但通常由后端团队来定义。于是你可能有一个 Post“资源”和一个 Post Comments“资源”,这样加载包含帖子和评论的页面数据就需要两次请求。

但这两次请求 发生在什么地方

在以服务器为中心的 HTML 应用(也就是网站)中,你可以在一次请求中在服务器端调用两个 REST API,然后把所有数据合并成一个响应返回。因为这些 REST API 请求发生在 服务器 上。REST API 更多是作为数据层的显式边界,但其实并非必须(很多人乐于直接用进程内数据层,比如 Rails 或 Django)。无论是否用 REST,数据(HTML)总是一次性送到客户端(浏览器)。

随着我们为了更丰富的交互把 UI 逻辑迁移到客户端,继续使用已有的 REST API 并从客户端 fetch 这些 API 似乎很自然。毕竟这正是 JSON API 的灵活之处?一切都变成了 JSON API:

const [post, comments] = await Promise.all([
  fetch(`/api/posts/${postId}`).then(res => res.json()),
  fetch(`/api/posts/${postId}/comments`).then(res => res.json())
]);

然而,结果就是 Network 面板里出现了两次请求:一次获取帖子,一次获取评论。 一个页面——一次点击——往往需要从多个 REST“资源”获取数据。最理想的情况是只需访问几个端点就够了。最糟的情况是要为 N 个条目访问 N 个端点,或者在客户端/服务器之间不断往返(先获取一部分数据,再用它计算,再用结果获取更多数据)。

效率问题悄然出现了。当请求发生在服务器时,发起一堆 REST 请求很便宜,因为我们能控制代码的部署方式。如果 REST 端点很远,可以把服务器搬近,甚至把它们的代码合并进进程。可以用复制或服务端缓存。即使有低效的地方,在服务器端我们有很多 杠杆 可以优化。没有什么 阻止 我们在服务器端改进。

但如果你把服务器当作黑盒,就无法优化它提供的 API。如果服务器没法一次性返回所有数据,就无法优化客户端/服务器的瀑布式请求;如果服务器不支持批量返回数据,也无法减少并发请求的数量。

最终你会遇到瓶颈。


组件

如果不是因为效率和封装之间的矛盾,上述问题还不算太糟。作为开发者,我们总想把数据加载逻辑放在用到数据的地方。有人说这样会导致“意大利面代码”,但其实未必!这个想法本身没错。回想一下——UI 决定 数据。你需要什么数据,取决于你要展示什么。数据获取逻辑和 UI 逻辑 天然耦合——一方变化,另一方也要跟着变。你不希望因为“取少了”而出错,也不想因为“取多了”而臃肿。但如何让 UI 逻辑和数据获取保持同步呢?

最直接的方式,就是把数据加载逻辑直接写在 UI 组件里。这就是“在 Backbone.View 里用 $.ajax”或者“在 useEffect 里用 fetch”的做法。随着客户端 UI 的流行,这种方式非常受欢迎——现在也依然常见。它的好处是 就近性:声明要加载什么数据的代码就在用到它的地方。不同的人可以写依赖不同数据源的组件,然后组合起来:

function PostContent({ postId }) {
  const [post, setPost] = useState()
  useEffect(() => {
    fetch(`/api/posts/${postId}`)
      .then(res => res.json())
      .then(setPost);
  }, []);
  if (!post) {
    return null;
  }
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <Comments postId={postId} />
    </article>
  );
}
 
function Comments({ postId }) {
  const [comments, setComments] = useState([])
  useEffect(() => {
    fetch(`/api/posts/${postId}/comments`)
      .then(res => res.json())
      .then(setComments);
  }, [])
  return (
    <ul className="comments">
      {comments.map(c => <li key={c.id}>{c.text}</li>)}
    </ul>
  );
}

然而,这种方式让前面提到的问题更加严重。不仅渲染一个页面需要多次请求,这些请求还 分散在代码库的各个角落。你怎么审查效率问题?

某人修改了一个组件,给它加了数据加载逻辑,结果在用到该组件的十几个页面都引入了新的客户端/服务器瀑布式请求。如果组件 在服务器端运行——比如 Astro 组件——数据获取延迟要么不存在,要么可预测。但在客户端,把数据获取逻辑分散在各个组件,会让低效问题层层传递,却没有什么好办法解决——我们无法 让用户 更靠近服务器。(而且 固有 的瀑布式请求,客户端根本无法解决——即使用预取也无济于事。)

让我们看看,给数据获取代码加点结构,能否有所帮助。


查询

有趣的是,像 React QueryuseQuery)这样的结构化数据获取方案,本身也不能幸免于此。它们比在 useEffect 里用 fetch 更有原则(缓存也有帮助),但依然会遇到“有 N 个条目就有 N 个查询”以及“客户端/服务器查询瀑布”的问题。

function usePostQuery(postId) {
  return useQuery(
    ['post', postId],
    () => fetch(`/api/posts/${postId}`).then(res => res.json())
  );
}
 
function usePostCommentsQuery(postId) {
  return useQuery(
    ['post-comments', postId],
    () => fetch(`/api/posts/${postId}/comments`).then(res => res.json())
  );
}
 
function PostContent({ postId }) {
  const { data: post } = usePostQuery(postId);
  if (!post) {
    return null;
  }
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <Comments postId={postId} />
    </article>
  );
}
 
function Comments({ postId }) {
  const { data: comments } = usePostCommentsQuery(postId);
  return (
    <ul className="comments">
      {comments.map(c => <li key={c.id}>{c.text}</li>)}
    </ul>
  );
}

实际上,客户端缓存本身有点像“伪命题”。对于客户端应用来说,缓存对返回上一页的瞬时体验至关重要(因此不能忽视),也有助于在切换标签页等场景复用缓存。但对于大多数导航——主要是点击链接——用户其实 期望 看到最新内容。这也是为什么浏览器在“HTML 应用”里会等待页面加载!用户也许不希望 整个 页面被替换(尤其是有导航壳的应用),但内容区在点击链接后理应是最新的。(当然,如果能在悬停时预取,实现既快又新的导航体验就更好了。)

反直觉的是,更快并不 总是 更好。对于用户体验来说,先闪现一段过时缓存内容然后立刻用新内容替换(即“stale-while-revalidate”)往往更糟。这违背了用户的预期。点击链接意味着期望看到新内容。我不想“以防万一”还得手动刷新。

客户端缓存适用于内容 还不可能发生变化 或者你根本不在乎变化,但它不是万能药,也无法解决其它问题。它能解决一部分问题,但当我们 需要 最新数据时,并不能减少请求数量,也无法避免客户端/服务器瀑布。

所以现在出现了一个矛盾:我们希望把 UI 和它的数据需求放在一起,但又想避免客户端/服务器瀑布和过多的并发请求。仅靠客户端查询缓存 并不能 解决这个问题。

那该怎么办?


客户端 Loader

我们可以尝试放弃就近性。假设每个路由都定义一个函数,负责加载该路由所需的所有数据。我们称之为 loader

async function clientLoader({ params }) {
  const { postId } = params;
  const [post, comments] = await Promise.all([
    fetch(`/api/posts/${postId}`).then(res => res.json()),
    fetch(`/api/posts/${postId}/comments`).then(res => res.json())
  ]);
  return { post, comments };
}

这个例子用的是 React Router 的 clientLoader API,但核心思想更通用。每次导航时,假设路由器会运行下一个路由的 loader,然后把数据传递给你的组件树。

这种方式的缺点是,数据需求不再和需要它的组件放在一起。现在每个路由“顶层”都有一段代码需要“知道”下面整个组件树需要什么数据。从这个角度看,这似乎比在组件或查询中获取数据倒退了一步。

但好处是,避免客户端/服务器瀑布变得容易多了。虽然瀑布式请求 仍然可能(有时无法避免),因为 clientLoader 运行在客户端——但它们是 可见 的。你不会像在组件或查询中那样,默认就产生瀑布式请求。


服务端 Loader

Loader 的另一个好处是,如果每个路由都有独立的 loader,那么很容易把这部分逻辑 移到服务器。因为 loader 独立于组件(它在组件运行 之前 执行),你可以让它成为 HTML 或 API 服务器的一部分,甚至单独做成一个“BFF”服务器(backend for frontend)。

// 这段代码可以在服务器上运行
async function loader({ params }) {
  const { postId } = params;
  const [post, comments] = await Promise.all([
    fetch(`/api/posts/${postId}`).then(res => res.json()),
    fetch(`/api/posts/${postId}/comments`).then(res => res.json())
  ]);
  return { post, comments };
}

这正是 React Router 的 loader 函数 以及老版 Next.js getServerSideProps() 的模式。(通常会有代码转换,把服务端 loader 代码和客户端代码分离。)

为什么要把 loader 移到服务器?

如果你不把服务器当作无法控制的黑盒,服务器就是 最自然 的数据获取位置。在服务器上,你有各种优化性能问题的手段。你通常能控制延迟——比如可以把 BFF 服务器部署到数据源附近。这样即使有 固有 的瀑布式请求,代价也很低。如果数据源很慢,还可以加服务端跨请求缓存。你甚至可以完全不用微服务,把数据层做成进程内调用,比如 Rails:

import { loadPost, loadComments } from 'my-data-layer';
 
async function loader({ params }) {
  const { postId } = params;
  const [post, comments] = await Promise.all([
    loadPost(postId),
    loadComments(postId)
  ]);
  return { post, comments };
}

进程内数据层为优化提供了极大空间。你可以在需要时降级到底层(比如为某个页面调用优化过的存储过程)。每次请求的内存缓存和批处理还能进一步减少数据库访问次数。你不用担心取多取少——每个 loader 只需返回该页面需要的数据。再也不用“扩展”REST“资源”了。

但即使你还是调用 REST API,也已经恢复了传统“HTML 应用”的很多优点——就像用 Rails 或 Django 的架构。从客户端视角看,数据(这次是 JSON)只需一次往返就能到达。 客户端/服务器瀑布在这种模式下 永远不会发生

好了,这就是服务端 loader 的优点。那缺点呢?


服务端函数

还记得我们决定用 loader 时,已经放弃了就近性。

如果我们把 loader 放在服务器上,但为每个组件都写一个 loader,这样就能重新获得就近性?这可能需要进一步模糊服务端和客户端代码的边界,但不妨先试试看。

具体怎么做,取决于你用什么“边界模糊”机制。先以 TanStack Server Functions 为例。

它允许我们声明一些 TanStack Server Functions,客户端可以直接 import:

import { createServerFn } from '@tanstack/react-start'
import { loadPost, loadComments } from 'my-data-layer';
 
export const getPost = createServerFn({ method: 'GET' }).handler(
  async (postId) => loadPost(postId)
);
 
export const getComments = createServerFn({ method: 'GET' }).handler(
  async (postId) => loadComments(postId)
);

下面是 React Server Functions 语法的另一种写法:

'use server';
 
import { loadPost, loadComments } from 'my-data-layer';
 
export async function getPost(postId) {
  return loadPost(postId);
}
 
export async function getComments(postId) {
  return loadComments(postId);
}

这里不详细区分两者——对于本文来说,它们是等价的。两者都会隐式创建 RPC 端点

关键在于,客户端组件可以直接 import 它们。无需手动设置 REST 端点或“API 路由”。通过 import 就有了隐式 API 路由。

现在我们又有了就近性!PostContent 只需用 getPost

import { getPost } from './my-server-functions';
import { Comments } from './Comments';
 
function usePostQuery(postId) {
  return useQuery(['post', postId], () => getPost(postId));
}
 
function PostContent({ postId }) {
  const { data: post } = usePostQuery(postId);
  if (!post) {
    return null;
  }
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <Comments postId={postId} />
    </article>
  );
}

同样,Comments 也可以直接从服务器 import getComments

import { getComments } from './my-server-functions';
 
function usePostCommentsQuery(postId) {
  return useQuery(['post-comments', postId], () => getComments(postId));
}
 
export function Comments({ postId }) {
  const { data: comments } = usePostCommentsQuery(postId);
  return (
    <ul className="comments">
      {comments.map(c => <li key={c.id}>{c.text}</li>)}
    </ul>
  );
}

但等等……

这其实并没有解决前面提到的任何问题!

实际上,性能特性反而退回到了在组件查询中获取数据的阶段。Server Functions 唯一的好处是 语法更优雅import 代替 API 路由)。但如果用它们来实现 就近式数据获取,Server Functions 反而比服务端 Loader 倒退了一步。它们没有强制一次往返获取数据,也无法防止客户端/服务器瀑布。Server Functions 简化了 调用服务器 的流程,但并没有提升数据获取效率。

那还有什么办法吗?


GraphQL 片段

GraphQL 经常被误解,其实它正是高效就近式数据获取的一种方案。

GraphQL 的理念——最初的设计——是让每个组件声明自己的数据依赖(片段),然后把这些片段组合起来。(多年之后,Apollo 也终于支持了这种方式

这意味着 Comment 组件可以声明自己需要哪些数据:

function Comments({ comments }) {
  return (
    <ul className="comments">
      {comments.map(comment => (
        <Comment key={comment.id} comment={comment} />
      ))}
    </ul>
  );
}
 
function Comment({ comment }) {
  const data = useFragment(
    graphql`
      fragment CommentFragment on Comment {
        id
        text
      }
    `,
    comment
  );
  return <li>{data.text}</li>;
}

注意,Comment 组件 本身并不负责数据获取。 它只是 声明 自己需要哪些数据。再来看 PostContent 组件。

PostContent 组件会把 Comment 的片段 组合 进自己的片段:

function PostContent({ post }) {
  const data = useFragment(
    graphql`
      fragment PostContentFragment on Post {
        title
        content
        comments {
          id
          ...CommentFragment
        }
      }
    `,
    post
  );
  return (
    <article>
      <h1>{data.title}</h1>
      <p>{data.content}</p>
      <Comments comments={data.comments} />
    </article>
  );
}

实际的数据获取发生在顶层。所有片段会组合成这样一个 GraphQL 查询,描述 整个路由 需要的数据:

query PostPageQuery($postId: ID!) {
  post(id: $postId) {
    # 来自 PostContentFragment
    title
    content
    comments {
      # 来自 CommentFragment
      id
      text
    }
  }
}

这就像自动生成的 loader!

对于每个页面,现在都可以生成一个精确描述该页面所需数据的查询,完全由组件的源码决定。如果你想修改某个组件需要哪些数据,只需改动该组件的片段,所有查询会自动同步。有了 GraphQL 片段,每次导航只需一次往返加载数据。

GraphQL 并不适合所有人。我对它的语法有点陌生(毕竟没深度用过),而且用好它需要一定的团队知识——无论是服务端还是客户端。我不是来推销 GraphQL 的。

但必须指出,GraphQL 是为数不多 真正 解决了这个难题的方案之一。它让你能把数据需求和 UI 就近声明,但 不会 像天真地在组件查询中获取数据那样带来性能惩罚(无论是否用服务端函数)。换句话说,GraphQL 既有服务端 Loader的性能特性,又有查询的就近性和模块化。

还有另一种方案也在尝试解决这个问题。


RSC

React Server Components(RSC)是 React 团队对“React 里的数据获取怎么做?”这个困扰了整个 2010 年代的问题的答案。

想象一下,每个需要数据的组件都可以有自己的服务端 Loader。这是最简单的方案——每个组件一个函数。

但我们知道,从组件里调用服务端 Loader 获取数据会是个错误——会直接回到客户端/服务器瀑布式请求。所以我们反过来做。让服务端 Loader 返回 组件:

import { loadPost, loadComments } from 'my-data-layer';
import { PostContent, Comments } from './client';
 
function PostContentLoader({ postId }) {
  const post = await loadPost(postId);
  return (
    <PostContent post={post}>
      <CommentsLoader postId={postId} />
    </PostContent>
  );
}
 
function CommentsLoader({ postId }) {
  const comments = await loadComments(postId);
  return <Comments comments={comments} />;
}
'use client';
 
export function PostContent({ post, children }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      {children}
    </article>
  );
}
 
export function Comments({ comments }) {
  return (
    <ul className="comments">
      {comments.map(c => <li key={c.id}>{c.text}</li>)}
    </ul>
  );
}

数据自上而下流动。服务器是数据的唯一来源。想要“从服务器接收 props”的组件通过 'use client' 指令来声明。我们的“服务端 Loader”其实和组件没区别,我们称之为服务端组件。它们是可组合的服务端 Loader。

这让人想起以前的“容器组件 vs 展示组件”模式,只不过现在所有“容器”都在服务器上运行,避免了额外的往返。

这种方式带来了什么?

  • 获得了服务端 Loader的高效性。 所有适用于服务端 Loader 的性能优化策略(每请求缓存、跨请求缓存、部署到数据源附近)同样适用于服务端组件。可以保证不会发生客户端/服务器瀑布。数据总是一次往返到达。
  • 获得了组件(或GraphQL 片段)的就近性。 虽然数据依赖不是字面上写在同一个文件里,但只隔一层。你总能“查找所有引用”,找到 服务端 props 的来源,就像在 React 里找 props 来源一样。
  • 获得了HTML 应用的“原生”心智模型。 没有单独的“API”(当然你可以加),也没有长期的客户端归一化缓存。你返回的是一棵树,但构建树的材料是 React 组件(而不是 HTML)。无需学习特殊语言或数据加载 API。某种意义上,根本没有 API

事实上,上面的例子可以简化为:

import { loadPost, loadComments } from 'my-data-layer';
 
async function PostContent({ postId }) {
  const post = await loadPost(postId);
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <Comments postId={postId} />
    </article>
  );
}
 
async function Comments({ postId }) {
  const comments = await loadComments(postId);
  return (
    <ul className="comments">
      {comments.map(c => <li key={c.id}>{c.text}</li>)}
    </ul>
  );
}

无论是初始加载还是后续导航,用户请求页面时,客户端都会向服务器发起一次请求。服务器会从 <PostContent postId={123} /> 开始序列化输出,递归展开,流式传输 React 树,最终变成 HTML 或 JSON。

对于客户端来说,每次导航只需一次请求到服务器。对于服务器来说,数据加载逻辑可以按需模块化拆分。服务器通过 返回 客户端组件树来传递数据。


总结

本文尝试展示了 RSC 与现有数据获取方案的不同侧面。还有很多内容未涉及,但这里补充几点:

  • 如果某部分加载很慢,一次往返获取数据看起来可能不是好主意。(RSC 通过流式传输解决,GraphQL 用 @defer 指令解决。)
  • 如果用预取,客户端/服务器瀑布似乎不成问题。(其实不然;对于 固有 的客户端/服务器瀑布,预取无济于事。)
  • 在组件中获取数据似乎会导致服务端瀑布问题。(某些情况下确实如此,取决于你是否用低延迟数据层。我认为这并不是 RSC 的缺点,留待以后详谈。)

如果你只想记住一件事,那就是:能同时解决就近性 效率的方案并不多。HTML 模板可以做到(Astro 是现代代表),GraphQL 可以做到,RSC 也可以做到。

值得问问你最喜欢的框架,是否也能做到!

Pay what you like

Discuss on Bluesky  ·  Edit on GitHub