这是用户在 2024-5-26 9:54 为 https://frontendmasters.com/blog/combining-react-server-components-with-react-query-for-easy-data-ma... 保存的双语快照页面,由 沉浸式翻译 提供双语支持。了解如何保存?
Search  搜索

Combining React Server Components with react-query for Easy Data Management
将 React Server 组件与 React-query 相结合以实现轻松的数据管理

React Server Components (RSC) are an exciting innovation in web development. In this post we’ll briefly introduce them, show what their purpose and benefits are, as well as their limitations. We’ll wrap up by showing how to pair them with react-query to help solve those limitations. Let’s get started!
React Server Components (RSC) 是 Web 开发领域一项令人兴奋的创新。在这篇文章中,我们将简要介绍它们,展示它们的目的和优点,以及它们的局限性。最后,我们将展示如何将它们与反应查询配对以帮助解决这些限制。让我们开始吧!

Why RSC? 为什么选择RSC?

React Server Components, as the name implies, execute on the server—and the server alone. To see why this is significant, let’s take a whirlwind tour of how web development evolved over the last 10 or so years.
React 服务器组件,顾名思义,在服务器上执行,并且仅在服务器上执行。为了了解为什么这很重要,让我们快速浏览一下过去 10 年左右的 Web 开发是如何发展的。

Prior to RSC, JavaScript frameworks (React, Svelte, Vue, Solid, etc) provided you with a component model for building your application. These components were capable of running on the server, but only as a synchronous operation for stringifying your components’ HTML to send down to the browser so it could server render your app. Your app would then render in the browser, again, at which point it would become interactive. With this model, the only way to load data was as a side-effect on the client. Waiting until your app reached your user’s browser before beginning to load data was slow and inefficient.
在 RSC 之前,JavaScript 框架(React、Svelte、Vue、Solid 等)为您提供了用于构建应用程序的组件模型。这些组件能够在服务器上运行,但只能作为同步操作,用于对组件的 HTML 进行字符串化以发送到浏览器,以便服务器可以渲染您的应用程序。然后,您的应用程序将再次在浏览器中呈现,此时它将变得具有交互性。使用此模型,加载数据的唯一方法是对客户端产生副作用。等到您的应用程序到达用户的浏览器后再开始加载数据是缓慢且低效的。

To solve this inefficiency, meta-frameworks like Next, SvelteKit, Remix, Nuxt, SolidStart, etc were created. These meta-frameworks provided various ways to load data, server-side, with that data being injected by the meta-framework into your component tree. This code was non-portable, and usually a little awkward. You’d have to define some sort of loader function that was associated with a given route, load data, and then expect it to show up in your component tree based on the rules of whatever meta-framework you were using.
为了解决这种低效率问题,创建了 Next、SvelteKit、Remix、Nuxt、SolidStart 等元框架。这些元框架提供了各种在服务器端加载数据的方法,这些数据由元框架注入到组件树中。这段代码是不可移植的,而且通常有点尴尬。您必须定义某种与给定路由关联的加载器函数,加载数据,然后期望它根据您使用的任何元框架的规则显示在组件树中。

This worked, but it wasn’t without issue. In addition to being framework-specific, composition also suffered; where typically components are explicitly passed props by whichever component renders them, now there are implicit props passed by the meta-framework, based on what you return from your loader. Nor was this setup the most flexible. A given page needs to know what data it needs up front, and request it all from the loader. With client-rendered SPAs we could just render whatever components we need, and let them fetch whatever data they need. This was awful for performance, but amazing for convenience.
这有效,但并非没有问题。除了特定于框架之外,组合也受到影响;通常,组件由渲染它们的组件显式传递 props,现在元框架根据您从加载器返回的内容传递隐式 props。这种设置也不是最灵活的。给定的页面需要预先知道它需要什么数据,并向加载器请求所有数据。通过客户端渲染的 SPA,我们可以渲染我们需要的任何组件,并让它们获取所需的任何数据。这对于性能来说很糟糕,但对于便利性来说却令人惊奇。

RSC bridges that gap and gives us the best of both worlds. We get to ad hoc request whatever data we need from whichever component we’re rendering, but have that code execute on the server, without needing to wait for a round trip to the browser. Best of all, RSC also supports streaming, or more precisely, out-of-order streaming. If some of our data are slow to load, we can send the rest of the page, and push those data down to the browser, from the server, whenever they happen to be ready.
RSC 弥补了这一差距,为我们提供了两全其美的解决方案。我们可以从正在渲染的任何组件中临时请求所需的任何数据,但让该代码在服务器上执行,而无需等待与浏览器的往返。最重要的是,RSC 还支持流式传输,或更准确地说,支持无序流式传输。如果我们的某些数据加载速度很慢,我们可以发送页面的其余部分,并在这些数据恰好准备好时将这些数据从服务器推送到浏览器。

How do I use them?
我该如何使用它们?

At time of writing RSC are mostly only supported in Next.js, although the minimal framework Waku also supports it. Remix and TanStack Router are currently working on implementations, so stay tuned. I’ll show a very brief overview of what they look like in Next; consult those other frameworks when they ship. The ideas will be the same, even if the implementations differ slightly.
在撰写本文时,RSC 大多数情况下仅在 Next.js 中受支持,尽管最小框架 Waku 也支持它。 Remix 和 TanStack Router 目前正在实施,敬请期待。我将在下一步中简要概述它们的外观;发布时请参阅其他框架。即使实现略有不同,想法也是一样的。

In Next, when using the new “app directory” (it’s literally a folder called “app” that you define your various routes in), pages are RSC by default. Any components imported by these pages are also RSC, as well as components imported by those components, and so on. When you’re ready to exit server components and switch to “client components,” you put the "use client" pragma at the top of a component. Now that component, and everything that component imports are client components. Check the Next docs for more info.
在 Next 中,当使用新的“app 目录”(它实际上是一个名为“app”的文件夹,您可以在其中定义各种路由)时,页面默认为 RSC。这些页面导入的任何组件以及这些组件导入的组件也是 RSC。当您准备退出服务器组件并切换到“客户端组件”时,可以将 "use client" pragma 放在组件的顶部。现在该组件以及该组件导入的所有内容都是客户端组件。检查下一个文档以获取更多信息。

How do React Server Components work?
React 服务器组件如何工作?

React Server Components are just like regular React Components, but with a few differences. For starters, they can be async functions. The fact that you can await asynchronous operations right in the component makes them well suited for requesting data. Note that asynchronous client components are a thing coming soon to React, so this differentiation won’t exist for too long. The other big difference is that these components run only on the server. Client components (i.e. regular components) run on the server, and then re-run on the client in order to “hydrate.” That’s how frameworks like Next and Remix have always worked. But server components run only on the server.
React 服务器组件就像常规的 React 组件一样,但有一些差异。对于初学者来说,它们可以是异步函数。事实上,您可以在组件中等待异步操作,这使得它们非常适合请求数据。请注意,异步客户端组件很快就会出现在 React 中,因此这种差异不会存在太久。另一个很大的区别是这些组件仅在服务器上运行。客户端组件(即常规组件)在服务器上运行,然后在客户端上重新运行以“水合”。这就是 Next 和 Remix 等框架一直以来的工作原理。但服务器组件仅在服务器上运行。

Server components have no hydration, since, again, they only execute on the server. That means you can do things like connect directly to a database, or use Server-only api’s. But it also means there are many things you can’t do in RSCs: you cannot use effects or state, you cannot set up event handlers, or use browser-specific APIs like localStorage. If you violate any of those rules you’ll get errors.
服务器组件没有水合作用,因为它们只在服务器上执行。这意味着您可以执行诸如直接连接到数据库或使用仅服务器 api 之类的操作。但这也意味着您在 RSC 中无法执行许多操作:您无法使用效果或状态、无法设置事件处理程序或使用特定于浏览器的 API(例如 localStorage )。如果您违反任何这些规则,您就会收到错误。

For a more thorough introduction to RSC, check the Next docs for the app directory, or depending on when you read this, the Remix or TanStack Router docs. But to keep this post a reasonable length, let’s keep the details in the docs, and see how we use them.
有关 RSC 的更全面介绍,请查看应用程序目录的下一个文档,或者根据您阅读本文的时间,查看 Remix 或 TanStack Router 文档。但为了使这篇文章保持合理的长度,让我们将详细信息保留在文档中,并看看我们如何使用它们。

Let’s put together a very basic proof of concept demo app with RSC, see how data mutations work, and some of their limitations. We’ll then take that same app (still using RSC) and see how it looks with react-query.
让我们用 RSC 构建一个非常基本的概念验证演示应用程序,看看数据突变是如何工作的,以及它们的一些局限性。然后,我们将使用同一个应用程序(仍然使用 RSC)并使用 React-query 来查看它的外观。

The demo app 演示应用程序

As I’ve done before, let’s put together a very basic, very ugly web page for searching some books, and also updating the titles of them. We’ll also show some other data on this page: the various subjects, and tags we have, which in theory we could apply to our books (if this were a real web app, instead of a demo).
正如我之前所做的那样,让我们​​构建一个非常基本、非常丑陋的网页,用于搜索一些书籍,并更新它们的标题。我们还将在此页面上显示一些其他数据:我们拥有的各种主题和标签,理论上我们可以将其应用于我们的书籍(如果这是一个真正的网络应用程序,而不是演示)。

The point is to show how RSC and react-query work, not make anything useful or beautiful, so temper your expectations 🙂 Here’s what it looks like:
重点是展示 RSC 和 React-query 是如何工作的,而不是做出任何有用或漂亮的东西,所以调整你的期望 🙂 这是它的样子:

The page has a search input which puts our search term into the url to filter the books shown. Each book also has an input attached to it for us to update that book’s title. Note the nav links at the top, for the RSC and RSC + react-query versions. While the pages look and behave identically as far as the user can see, the implementations are different, which we’ll get into.
该页面有一个搜索输入,将我们的搜索词放入 URL 中以过滤显示的书籍。每本书还附有一个输入,供我们更新该书的标题。请注意顶部的导航链接,适用于 RSC 和 RSC + React-query 版本。虽然就用户而言,页面的外观和行为相同,但实现是不同的,我们将对此进行介绍。

The data is all static, but the books are put into a SQLite database, so we can update the data. The binary for the SQLite db should be in that repo, but you can always re-create it (and reset any updates you’ve made) by running npm run create-db.
数据都是静态的,但是书籍被放入SQLite数据库中,因此我们可以更新数据。 SQLite 数据库的二进制文件应该位于该存储库中,但您始终可以通过运行 npm run create-db 来重新创建它(并重置您所做的任何更新)。

Let’s dive in. 让我们深入了解一下。

A note on caching
关于缓存的注释

At time of writing, Next is about to release a new version with radically different caching APIs and defaults. We won’t cover any of that for this post. For the demo, I’ve disabled all caching. Each call to a page, or API endpoint will always run fresh from the server. The client cache will still work, so if you click between the two pages, Next will cache and display what you just saw, client-side. But refreshing the page will always recreate everything from the server.
在撰写本文时,Next 即将发布一个具有完全不同的缓存 API 和默认值的新版本。我们不会在这篇文章中介绍任何内容。对于演示,我禁用了所有缓存。对页面或 API 端点的每次调用都将始终从服务器新鲜运行。客户端缓存仍然有效,因此如果您在两个页面之间单击,“下一步”将缓存并显示您刚刚在客户端看到的内容。但刷新页面总是会重新创建服务器上的所有内容。

Loading the data 加载数据

There are API endpoints inside of the api folder for loading data and for updating the books. I’ve added artificial delays of a few hundred ms for each of these endpoints, since they’re either loading static data, or running simple queries from SQLite. There’s also console logging for these data, so you can see what’s loading when. This will be useful in a bit.
api 文件夹内有 API 端点,用于加载数据和更新书籍。我为每个端点添加了数百个 ms 的人为延迟,因为它们要么加载静态数据,要么从 SQLite 运行简单查询。这些数据还有控制台日志记录,因此您可以看到加载的内容。这稍后会有用。

Here’s what the terminal console shows for a typical page load in either the RSC or RSC + react-query version.
以下是终端控制台在 RSC 或 RSC + React-query 版本中显示的典型页面加载内容。

Let’s look at the RSC version
我们来看看RSC版本

RSC Version RSC版本

export default function RSC(props: { searchParams: any }) {
  const search = props.searchParams.search || "";

  return (
    <section className="p-5">
      <h1 className="text-lg leading-none font-bold">Books page in RSC</h1>
      <Suspense fallback={<h1>Loading...</h1>}>
        <div className="flex flex-col gap-2 p-5">
          <BookSearchForm />
          <div className="flex">
            <div className="flex-[2] min-w-0">
              <Books search={search} />
            </div>
            <div className="flex-1 flex flex-col gap-8">
              <Subjects />
              <Tags />
            </div>
          </div>
        </div>
      </Suspense>
    </section>
  );
}Code language: JavaScript (javascript)

We have a simple page header. Then we see a Suspense boundary. This is how out-of-order streaming works with Next and RSC. Everything above the Suspense boundary will render immediately, and the Loading... message will show until all the various data in the various components below have finished loading. React knows what’s pending based on what you’ve awaited. The BooksSubjects and Tags components all have fetches inside of them, which are awaited. We’ll look at one of them momentarily, but first note that, even though three different components are all requesting data, React will run them in parallel. Sibling nodes in the component tree can, and do load data in parallel.
我们有一个简单的页眉。然后我们看到一个悬念边界。这就是乱序流与 Next 和 RSC 一起工作的方式。 Suspense 边界之上的所有内容都将立即渲染,并且 Loading... 消息将显示,直到下面各个组件中的所有各种数据完成加载。 React 根据您等待的内容知道待处理的内容。 BooksSubjectsTags 组件内部都有等待的提取。我们将立即查看其中一个,但首先请注意,即使三个不同的组件都在请求数据,React 也会并行运行它们。组件树中的同级节点可以并行加载数据。

But if you ever have a parent / child component which both load data, then the child component will not (cannot) even start util the parent is finished loading. If the child data fetch depends on the parent’s loaded data, then this is unavoidable (you’d have to modify your backend to fix it), but if the data do not depend on each other, then you would solve this waterfall by just loading the data higher up in the component tree, and passing the various pieces down.
但是,如果您有一个父/子组件都加载数据,那么子组件甚至不会(无法)启动直到父组件完成加载。如果子级数据获取依赖于父级加载的数据,那么这是不可避免的(您必须修改后端来修复它),但是如果数据不相互依赖,那么您只需加载即可解决此瀑布组件树中较高层的数据,并将各个部分向下传递。

Loading data 加载数据中

Let’s see the Books component”
让我们看看 Books 组件”

import { FC } from "react";
import { BooksList } from "../components/BooksList";
import { BookEdit } from "../components/BookEditRSC";

export const Books: FC<{ search: string }> = async ({ search }) => {
  const booksResp = await fetch(`http://localhost:3000/api/books?search=${search}`, {
    next: {
      tags: ["books-query"],
    },
  });
  const { books } = await booksResp.json();

  return (
    <div>
      <BooksList books={books} BookEdit={BookEdit} />
    </div>
  );
};Code language: JavaScript (javascript)

We fetch and await our data right in the component! This was completely impossible before RSC. We then then pass it down into the BooksList component. I separated this out so I could re-use the main BookList component with both versions. The BookEdit prop I’m passing in is a React component that renders the textbox to update the title, and performs the update. This will differ between the RSC, and react-query version. More on that in a bit.
我们在组件中获取并等待数据!这在 RSC 之前是完全不可能的。然后我们将其传递到 BooksList 组件中。我将其分开,以便我可以在两个版本中重复使用主要 BookList 组件。我传入的 BookEdit 属性是一个 React 组件,它渲染文本框以更新标题,并执行更新。这在 RSC 和 React-query 版本之间会有所不同。稍后会详细介绍。

The next property in the fetch is Next-specific, and will be used to invalidate our data in just a moment. The experienced Next devs might spot a problem here, which we’ll get into very soon.
获取中的 next 属性是特定于 Next 的,稍后将用于使我们的数据无效。经验丰富的 Next 开发人员可能会在这里发现一个问题,我们很快就会讨论这个问题。

So you’ve loaded data, now what?
那么您已经加载了数据,现在该怎么办?

We have a page with three different RSCs which load and render data. Now what? If our page was just static content we’d be done. We loaded data, and displayed it. If that’s your use case, you’re done. RSCs are perfect for you, and you won’t need the rest of this post.
我们有一个页面,其中包含三个不同的 RSC,用于加载和呈现数据。怎么办?如果我们的页面只是静态内容,我们就完成了。我们加载数据并显示它。如果这是您的用例,那么您就完成了。 RSC 非常适合您,您不需要本文的其余部分。

But what if you want to let your user interact with, and update your data?
但是,如果您想让用户与您的数据交互并更新数据怎么办?

Updating your data with Server Actions
使用服务器操作更新数据

To mutate data with RSC you use something called Server Actions. Check the docs for specifics, but here’s what our server action looks like
要使用 RSC 改变数据,您可以使用称为“服务器操作”的东西。查看文档了解具体信息,但我们的服务器操作如下所示

"use server";

import { revalidateTag } from "next/cache";

export const saveBook = async (id: number, title: string) => {
  await fetch("http://localhost:3000/api/books/update", {
    method: "POST",
    body: JSON.stringify({
      id,
      title,
    }),
  });
  revalidateTag("books-query");
};Code language: JavaScript (javascript)

Note the "use server" pragma at the top. That means the function we export is now a server action. saveBook takes an id, and a title; it posts to an endpoint to update our book in SQLite, and then calls revalidateTag with the same tag we passed to our fetch, before.
请注意顶部的 "use server" 编译指示。这意味着我们导出的函数现在是服务器操作。 saveBook 需要一个 id 和一个标题;它会发送到一个端点以在 SQLite 中更新我们的书,然后使用我们之前传递给 fetch 的相同标签调用 revalidateTag

In real life, we wouldn’t even need the books/update endpoint. We’d just do the work right in the server action. But we’ll be re-using that endpoint in a bit, when we update data without server actions, and it’s nice to keep these code samples clean. The books/update endpoint opens up SQLite, and executes an UPDATE.
在现实生活中,我们甚至不需要书籍/更新端点。我们只需在服务器操作中正确完成工作即可。但是,当我们在没有服务器操作的情况下更新数据时,我们稍后会重用该端点,并且保持这些代码示例干净是很好的。 books/update 端点打开SQLite,并执行更新。

Let’s see the BookEdit component we use with RSC:
让我们看看与 RSC 一起使用的 BookEdit 组件:

"use client";

import { FC, useRef, useTransition } from "react";
import { saveBook } from "../serverActions";
import { BookEditProps } from "../types";

export const BookEdit: FC<BookEditProps> = (props) => {
  const { book } = props;
  const titleRef = useRef<HTMLInputElement>(null);
  const [saving, startSaving] = useTransition();

  function doSave() {
    startSaving(async () => {
      await saveBook(book.id, titleRef.current!.value);
    });
  }

  return (
    <div className="flex gap-2">
      <input className="border rounded border-gray-600 p-1" ref={titleRef} defaultValue={book.title} />
      <button className="rounded border border-gray-600 p-1 bg-blue-300" disabled={saving} onClick={doSave}>
        {saving ? "Saving..." : "Save"}
      </button>
    </div>
  );
};Code language: JavaScript (javascript)

It’s a client component. We import the server action, and then just call it in a button’s event handler, wrapped in a transition so we can have saving state.
它是一个客户端组件。我们导入服务器操作,然后在按钮的事件处理程序中调用它,包装在转换中,这样我们就可以保存状态。

Stop and consider just how radical this is, and what React and Next are doing under the covers. All we did was create a vanilla function. We then imported that function, and called it from a button’s event handler. But under the covers a network request is made to an endpoint that’s synthesized for us. And then the revalidateTag tells Next what’s changed, so our RSC can re-run, re-request data, and send down updated markup.
停下来想一想这是多么激进,以及 React 和 Next 在幕后所做的事情。我们所做的只是创建一个普通函数。然后我们导入该函数,并从按钮的事件处理程序中调用它。但在幕后,网络请求是向为我们合成的端点发出的。然后 revalidateTag 告诉 Next 发生了什么变化,这样我们的 RSC 就可以重新运行、重新请求数据并发送更新的标记。

Not only that, but all this happens in one round trip with the server.
不仅如此,所有这一切都发生在与服务器的一次往返中。

This is an incredible engineering achievement, and it works! If you update one of the titles, and click save, you’ll see updated data show up in a moment (the update has an artificial delay since we’re only updating in a local SQLite instance)
这是一项令人难以置信的工程成就,而且它确实有效!如果您更新其中一个标题,然后单击“保存”,您将看到更新的数据立即显示(更新有人为延迟,因为我们只在本地 SQLite 实例中更新)

What’s the catch? 有什么问题吗?

This seems too good to be true. What’s the catch? Well, let’s see what the terminal shows when we update a book:
这似乎好得令人难以置信。有什么问题吗?好吧,让我们看看当我们更新一本书时终端会显示什么:

Ummmm, why is all of our data re-loading? We only called revalidateTag on our books, not our subjects or tags. The problem is that revalidateTag doesn’t tell Next what to reload, it tells it what to eject from its cache. The fact is, Next needs to reload everything for the current page when we call revalidateTag. This makes sense when you think about what’s really happening. These server components are not stateful; they run on the server, but they don’t live on the server. The request executes on our server, those RSCs render, and send down the markup, and that’s that. The component tree does not live on indefinitely on the server; our servers wouldn’t scale very well if they did!
嗯,为什么我们所有的数据都要重新加载?我们只在我们的书籍上调用 revalidateTag ,而不是我们的主题或标签。问题是 revalidateTag 并没有告诉 Next 重新加载什么,而是告诉它从缓存中弹出什么。事实是,当我们调用 revalidateTag 时,Next 需要重新加载当前页面的所有内容。当你思考到底发生了什么时,这是有道理的。这些服务器组件没有状态;它们在服务器上运行,但并不存在于服务器上。请求在我们的服务器上执行,这些 RSC 进行渲染并发送标记,仅此而已。组件树不会无限期地存在于服务器上;如果这样做的话,我们的服务器将无法很好地扩展!

So how do we solve this? For a use case like this, the solution would be to not turn off caching. We’d lean on Next’s caching mechanisms, whatever they look like when you happen to read this. We’d cache each of these data with different tags, and invalidate the tag related to the data we just updated.
那么我们如何解决这个问题呢?对于这样的用例,解决方案是不关闭缓存。我们会依赖 Next 的缓存机制,无论你碰巧读到这篇文章时它们是什么样子。我们将使用不同的标签缓存每个数据,并使与我们刚刚更新的数据相关的标签无效。

The whole RSC tree will still re-render when we do that, but the requests for cached data would run quickly. Personally, I’m of the view that caching should be a performance tweak you add, as needed; it should not be a sine qua non for avoiding slow updates.
当我们这样做时,整个 RSC 树仍然会重新渲染,但对缓存数据的请求会快速运行。就我个人而言,我认为缓存应该是您根据需要添加的性能调整;它不应该是避免缓慢更新的必要条件。

Unfortunately, there’s yet another problem with server actions: they run serially. Only one server action can be in flight at a time; they’ll queue if you try to violate this constraint.
不幸的是,服务器操作还存在另一个问题:它们是串行运行的。一次只能执行一项服务器操作;如果你试图违反这个限制,他们就会排队。

This sounds genuinely unbelievable; but it’s true. If we artificially slow down our update a LOT, and then quickly click 5 different save buttons, we’ll see horrifying things in our network tab. If the extreme slowdown on the update endpoint seems unfair on my part, remember: you should never, ever assume your network will be fast, or even reliable. Occasional, slow network requests are inevitable, and server actions will do the worst possible thing under those circumstances.
这听起来确实令人难以置信;但这是真的。如果我们人为地减慢更新速度,然后快速单击 5 个不同的保存按钮,我们将在网络选项卡中看到可怕的东西。如果更新端点的极度减慢对我来说似乎不公平,请记住:您永远不应该假设您的网络会很快,甚至可靠。偶尔缓慢的网络请求是不可避免的,在这种情况下,服务器操作将做出最糟糕的事情。

This is a known issue, and will presumably be fixed at some point. But the re-loading without caching issue is unavoidable with how Next app directory is designed.
这是一个已知问题,可能会在某个时候得到解决。但是,Next app 目录的设计方式不可避免地会导致重新加载而没有缓存的问题。

Just to be clear, server actions are still, even with these limitations, outstanding (for some use cases). If you have a web page with a form, and a submit button, server actions are outstanding. None of these limitations will matter (assuming your form doesn’t depend on a bunch of different data sources). In fact, server actions go especially well with forms. You can even set the “action” of a form (in Next) directly to a server action. See the docs for more info, as well as on related hooks, like useFormStatus hook.
需要明确的是,即使有这些限制,服务器操作仍然很突出(对于某些用例)。如果您有一个带有表单和提交按钮的网页,那么服务器操作就很突出。这些限制都不重要(假设您的表单不依赖于一堆不同的数据源)。事实上,服务器操作尤其适合表单。您甚至可以将表单(在“下一步”中)的“操作”直接设置为服务器操作。请参阅文档以获取更多信息以及相关挂钩(例如 useFormStatus 挂钩)。

But back to our app. We don’t have a page with a single form and no data sources. We have lots of little forms, on a page with lots of data sources. Server actions won’t work well here, so let’s see an alternative.
但回到我们的应用程序。我们没有一个只有单一表单且没有数据源的页面。我们在包含大量数据源的页面上有很多小表单。服务器操作在这里不起作用,所以让我们看看替代方案。

react-query 反应查询

React Query is probably the most mature, well-maintained data management library in the React ecosystem. Unsurprisngly, it also works well with RSC.
React Query 可能是 React 生态系统中最成熟、维护良好的数据管理库。毫不奇怪,它也与 RSC 配合良好。

To use react-query we’ll need to install two packages: npm i @tanstack/react-query @tanstack/react-query-next-experimental. Don’t let the experimental in the name scare you; it’s been out for awhile, and works well.
要使用react-query,我们需要安装两个包: npm i @tanstack/react-query @tanstack/react-query-next-experimental 。不要让名字中的实验吓到你;它已经推出一段时间了,而且效果很好。

Next we’ll make a Providers component, and render it from our root layout
接下来我们将创建一个 Providers 组件,并从我们的根布局中渲染它

"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { FC, PropsWithChildren, useEffect, useState } from "react";

export const Providers: FC<PropsWithChildren<{}>> = ({ children }) => {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <QueryClientProvider client={queryClient}>
      <ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration>
    </QueryClientProvider>
  );
};Code language: JavaScript (javascript)

Now we’re ready to go.
现在我们准备好了。

Loading data with react-query
使用react-query加载数据

The long and short of it is that we use the useSuspenseQuery hook from inside client components. Let’s see some code. Here’s the Books component from the react-query version of our app.
简而言之,我们在客户端组件内部使用 useSuspenseQuery 钩子。让我们看一些代码。这是我们应用程序的反应查询版本中的 Books 组件。

"use client";

import { FC } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { BooksList } from "../components/BooksList";
import { BookEdit } from "../components/BookEditReactQuery";
import { useSearchParams } from "next/navigation";

export const Books: FC<{}> = () => {
  const params = useSearchParams();
  const search = params.get("search") ?? "";

  const { data } = useSuspenseQuery({
    queryKey: ["books-query", search],
    queryFn: async () => {
      const booksResp = await fetch(`http://localhost:3000/api/books?search=${search}`);
      const { books } = await booksResp.json();

      return { books };
    },
  });

  const { books } = data;

  return (
    <div>
      <BooksList books={books} BookEdit={BookEdit} />
    </div>
  );
};Code language: JavaScript (javascript)

Don’t let the "use client" pragma fool you. This component still renders on the server, and that fetch also happens on the server during the initial load of the page.
不要让 "use client" pragma 欺骗你。该组件仍然在服务器上呈现,并且在页面初始加载期间,该获取也会在服务器上发生。

As the url changes, the useSearchParams result changes, and a new query is fired off by our useSuspenseQuery hook, from the browser. This would normally suspend the page, but I wrap the call to router.push in startTransition, so the existing content stays on the screen. Check the repo for details.
当 url 发生变化时, useSearchParams 结果也会发生变化,并且我们的 useSuspenseQuery 钩子会从浏览器中触发一个新的查询。这通常会挂起页面,但我将对 router.push 的调用包装在 startTransition 中,因此现有内容保留在屏幕上。检查存储库以获取详细信息。

Updating data with react-query
使用react-query更新数据

We already have the /books/update endpoint for updating a book. How do we tell react-query to re-run whichever queries were attached to that data? The answer is the queryClient.invalidateQueries API. Let’s take a look at the BookEdit component for react-query
我们已经有了用于更新书籍的 /books/update 端点。我们如何告诉react-query重新运行附加到该数据的查询?答案是 queryClient.invalidateQueries API。我们来看看react-query的 BookEdit 组件

"use client";

import { FC, useRef, useTransition } from "react";
import { BookEditProps } from "../types";
import { useQueryClient } from "@tanstack/react-query";

export const BookEdit: FC<BookEditProps> = (props) => {
  const { book } = props;
  const titleRef = useRef<HTMLInputElement>(null);
  const queryClient = useQueryClient();
  const [saving, startSaving] = useTransition();

  const saveBook = async (id: number, newTitle: string) => {
    startSaving(async () => {
      await fetch("/api/books/update", {
        method: "POST",
        body: JSON.stringify({
          id,
          title: newTitle,
        }),
      });

      await queryClient.invalidateQueries({ queryKey: ["books-query"] });
    });
  };

  return (
    <div className="flex gap-2">
      <input className="border rounded border-gray-600 p-1" ref={titleRef} defaultValue={book.title} />
      <button className="rounded border border-gray-600 p-1 bg-blue-300" disabled={saving} onClick={() => saveBook(book.id, titleRef.current!.value)}>
        {saving ? "Saving..." : "Save"}
      </button>
    </div>
  );
};Code language: JavaScript (javascript)

The saveBook function calls out to the same book updating endpoint as before. We then call invalidateQueries with the first part of the query key, books-query. Remember, the actual queryKey we used in our query hook was queryKey: ["books-query", search]. Calling invalidate queries with the first piece of that key will invalidate everything that’s starts with that key, and will immediately re-fire any of those queries which are still on the page. So if you started out with an empty search, then searched for X, then Y, then Z, and updated a book, this code will clear the cache of all those entries, and then immediately re-run the Z query, and update our UI.
saveBook 函数调用与以前相同的书籍更新端点。然后,我们使用查询键的第一部分 books-query 调用 invalidateQueries 。请记住,我们在查询挂钩中使用的实际 queryKeyqueryKey: ["books-query", search] 。使用该键的第一部分调用无效查询将使以该键开头的所有内容无效,并将立即重新触发仍在页面上的任何查询。因此,如果您从空搜索开始,然后搜索 X,然后搜索 Y,然后搜索 Z,并更新一本书,则此代码将清除所有这些条目的缓存,然后立即重新运行 Z 查询,并更新我们的用户界面。

And it works. 它有效。

What’s the catch? 有什么问题吗?

The downside here is that we need two roundtrips from the browser to the server. The first roundtrip updates our book, and when that finishes, we then, from the browser, call invalidateQueries, which causes react-query to send a new network request for the updated data.
这里的缺点是我们需要从浏览器到服务器两次往返。第一次往返更新我们的书,完成后,我们从浏览器调用 invalidateQueries ,这会导致react-query发送更新数据的新网络请求。

This is a surprisingly small price to pay. Remember, with server actions, calling revalidateTag will cause your entire component tree to re-render, which by extension will re-request all their various data. If you don’t have everything cached (on the server) properly, it’s very easy for this single round trip to take longer than the two round trips react-query needs. I say this from experience. I recently helped a friend / founder build a financial dashboard app. I had react-query set up just like this, and also implemented a server action to update a piece of data. And I had the same data rendered, and updated twice: once in an RSC, and again adjacently in a client component from a useSuspenseQuery hook. I basically fired off a race to see which would update first, certain the server action would, but was shocked to see react-query win. I initially thought I’d done something wrong until I realized what was happening (and hastened to roll back my server action work).
这是一个令人惊讶的小代价。请记住,对于服务器操作,调用 revalidateTag 将导致整个组件树重新渲染,从而扩展将重新请求所有各种数据。如果您没有正确缓存所有内容(在服务器上),则此单次往返所需的时间很容易比两次往返反应查询所需的时间更长。我是根据经验这么说的。我最近帮助一位朋友/创始人构建了一个财务仪表板应用程序。我像这样设置了反应查询,并且还实现了服务器操作来更新一条数据。我渲染了相同的数据,并更新了两次:一次在 RSC 中,另一次在 useSuspenseQuery 挂钩的客户端组件中相邻。我基本上发起了一场竞赛,看看哪一个会先更新,当然服务器操作会,但我很震惊地看到反应查询获胜。我最初以为我做错了什么,直到我意识到发生了什么(并赶紧回滚我的服务器操作工作)。

Playing on hard mode
在困难模式下玩

There’s one obnoxious imperfection hiding. Let’s find it, and fix it.
隐藏着一个令人讨厌的缺陷。让我们找到它并修复它。

Fixing routing when using react-query
使用react-query时修复路由

Remember, when we search our books, I’m calling router.push which adds a querystring to the url, which causes useSearchParams() to update, which causes react-query to query new data. Let’s look at the network tab when this happens.
请记住,当我们搜索书籍时,我调用 router.push ,它将查询字符串添加到 url,这会导致 useSearchParams() 更新,从而导致 React-query 查询新数据。让我们看看发生这种情况时的网络选项卡。

before our books endpoint can be called, it looks like we have other things happening. This is the navigation we caused when we called router.push. Next is basically rendering to a new page. The page we’re already on, except with a new querystring. Next is right to assume it needs to do this, but in practice react-query is handling our data. We don’t actually need, or want Next to render this new page; we just want the url to update, so react-query can request new data. If you’re wondering why it navigates to our new, changed page twice, well, so am I. Apparently, the RSC identifier is being changed, but I have no idea why. If anyone does, please reach out to me.
在我们的书籍端点被调用之前,看起来我们还发生了其他事情。这是我们调用 router.push 时引起的导航。接下来基本上是渲染到新页面。我们已经在的页面,除了一个新的查询字符串。 Next 假设它需要这样做是正确的,但实际上,react-query 正在处理我们的数据。我们实际上并不需要,也不希望 Next 渲染这个新页面;我们只是希望 url 更新,以便 React-query 可以请求新数据。如果您想知道为什么它会两次导航到我们更改后的新页面,那么我也是。显然,RSC 标识符正在更改,但我不知道为什么。如果有人这样做,请与我联系。

Next has no solutions for this.
Next 对此没有解决方案。

The closest Next can come is to let you use window.history.pushState. That will trigger a client-side url update, similar to what used to be called shallow routing in prior versions of Next. This does in fact work; however, it’s not integrated with transitions for some reason. So when this calls, and our useSuspenseQuery hook updates, our current UI will suspend, and our nearest Suspense boundary will show the fallback. This is awful UI. I’ve reported this bug here; hopefully it gets a fix soon.
最接近的 Next 可以是让您使用 window.history.pushState 。这将触发客户端 URL 更新,类似于 Next 早期版本中所谓的浅层路由。这实际上确实有效;但是,由于某种原因,它没有与转换集成。因此,当调用此函数并且我们的 useSuspenseQuery 挂钩更新时,我们当前的 UI 将暂停,并且最近的 Suspense 边界将显示回退。这是糟糕的用户界面。我已经在这里报告了这个错误;希望它很快得到修复。

Next may not have a solution, but react-query does. If you think about it, we already know what query we need to run, we’re just stuck waiting on Next to finish navigating to an unchanging RSC page. What if we could pre-fetch this new endpoint request, so it’s already running for when Next finally finishes rendering our new (unchanged) page. We can, since react-query has an API just for this. Let’s see how.
Next可能没有解决方案,但react-query有。如果您想一想,我们已经知道需要运行什么查询,我们只是等待 Next 完成导航到不变的 RSC 页面。如果我们可以预取这个新的端点请求,那么当 Next 最终完成渲染我们的新(未更改)页面时它已经运行了。我们可以,因为 React-query 有一个专门用于此目的的 API。让我们看看如何。

Let’s look at the react-query search form component. In particular, the part which triggers a new navigation:
让我们看一下react-query搜索表单组件。特别是触发新导航的部分:

startTransition(() => {
  const search = searchParams.get("search") ?? "";
  queryClient.prefetchQuery({
    queryKey: ["books-query", search],
    queryFn: async () => {
      const booksResp = await fetch(`http://localhost:3000/api/books?search=${search}`);
      const { books } = await booksResp.json();

      return { books };
    },
  });

  router.push(currentPath + (queryString ? "?" : "") + queryString);
});Code language: JavaScript (javascript)

The call to queryClient.prefetchQueryprefetchQuery takes the same options as useSuspenseQuery, and runs that query, now. Later, when Next is finished, and react-query attempts to run the same query, it’s smart enough to see that the request is already in flight, and so just latches onto that active promise, and uses the result.
queryClient.prefetchQuery 的调用。 prefetchQuery 采用与 useSuspenseQuery 相同的选项,并立即运行该查询。稍后,当 Next 完成时,react-query 尝试运行相同的查询,它足够聪明,可以看到请求已经在进行中,因此只需锁定该活动的 Promise,并使用结果。

Here’s our network chart now:
这是我们现在的网络图:

Now nothing is delaying our endpoint request from firing. And since all data loading is happening in react-query, that navigation to our RSC page (or even two navigations) should be very, very fast.
现在没有什么可以延迟我们的端点请求的触发。由于所有数据加载都发生在反应查询中,因此导航到我们的 RSC 页面(甚至两个导航)应该非常非常快。

Removing the duplication
删除重复项

If you’re thinking the duplication between the prefetch and the query itself is gross and fragile, you’re right. So just move it to a helper function. In a real app you’d probably move this boilerplate to something like this:
如果您认为预取和查询本身之间的重复是严重且脆弱的,那么您是对的。所以只需将其移至辅助函数即可。在真正的应用程序中,您可能会将此样板移至如下所示:

export const makeBooksSearchQuery = (search: string) => {
  return {
    queryKey: ["books-query", search ?? ""],
    queryFn: async () => {
      const booksResp = await fetch(`http://localhost:3000/api/books?search=${search}`);
      const { books } = await booksResp.json();

      return { books };
    },
  };
};Code language: JavaScript (javascript)

and then use it:
然后使用它:

const { data } = useSuspenseQuery(makeBooksSearchQuery(search));Code language: JavaScript (javascript)

as needed: 如所须:

queryClient.prefetchQuery(makeBooksSearchQuery(search));Code language: JavaScript (javascript)

But for this demo I opted for duplication and simplicity.
但对于这个演示,我选择了重复和简单。

Before moving on, let’s take a moment and point out that all of this was only necessary because we had data loading tied to the URL. If we just click a button to set client-side state, and trigger a new data request, none of this would ever be an issue. Next would not route anywhere, and our client-side state update would trigger a new react-query.
在继续之前,让我们花点时间指出,所有这些都是必要的,因为我们将数据加载与 URL 绑定在一起。如果我们只需单击一个按钮来设置客户端状态并触发新的数据请求,那么这一切都不会成为问题。 Next 不会路由到任何地方,我们的客户端状态更新将触发一个新的反应查询。

What about bundle size?
捆绑包大小如何?

When we did our react-query implementation, we changed our Books component to be a client component by adding the "use client" pragma. If you’re wondering whether that will cause an increase in our bundle size, you’re right. In the RSC version, that component only ever ran on the server. As a client component, it now has to run in both places, which will increase our bundle size a bit.
当我们执行react-query实现时,我们通过添加 "use client" pragma将Books组件更改为客户端组件。如果您想知道这是否会导致我们的捆绑包大小增加,那么您是对的。在 RSC 版本中,该组件仅在服务器上运行。作为客户端组件,它现在必须在两个地方运行,这将稍微增加我们的包大小。

Honestly, I wouldn’t worry about it, especially for apps like this, with lots of different data sources that are interactive, and updating. This demo only had a single mutation, but it was just that; a demo. If we were to build this app for real, there’d be many mutation points, each with potentially multiple queries in need of invalidation.
老实说,我不会担心这一点,特别是对于像这样的应用程序,它有许多不同的交互数据源和更新数据源。这个演示只有一个突变,但仅此而已;一个演示。如果我们要真正构建这个应用程序,就会有很多突变点,每个突变点都可能有多个查询需要失效。

If you’re curious, it’s technically possible to get the best of both worlds. You could load data in an RSC, and then pass that data to the regular useQuery hook via the initialData prop. You can check the docs for more info, but I honestly don’t think it’s worth it. You’d now need to define your data loading (the fetch call) in two places, or manually build an isomorphic fetch helper function to share between them. And then with actual data loading happening in RSCs, any navigations back to the same page (ie for querystrings) would re-fire those queries, when in reality react-query is already running those query udates client side. To fix that so you’d have to be certain to only ever use window.history.pushState like we talked about. The useQuery hook doesn’t suspend, so you wouldn’t need transitions for those URL changes. That’s good since pushState won’t suspend your content, but now you have to manually track all your loading states; if you have three pieces of data you want loaded before revealing a UI (like we did above) you’d have to manually track and aggregate those three loading states. It would work, but I highly doubt the complexity would be worth it. Just live with the very marginal bundle size increase.
如果您好奇,从技术上讲,可以两全其美。您可以在 RSC 中加载数据,然后通过 initialData 属性将该数据传递到常规 useQuery 挂钩。您可以查看文档以获取更多信息,但老实说,我认为这不值得。您现在需要在两个地方定义数据加载(提取调用),或者手动构建一个同构提取辅助函数以在它们之间共享。然后,当 RSC 中发生实际数据加载时,任何返回同一页面(即查询字符串)的导航都会重新触发这些查询,而实际上,react-query 已经在客户端运行这些查询更新。要解决这个问题,您必须确保只使用 window.history.pushState ,就像我们讨论的那样。 useQuery 挂钩不会挂起,因此您不需要为这些 URL 更改进行转换。这很好,因为 pushState 不会暂停您的内容,但现在您必须手动跟踪所有加载状态;如果您想要在显示 UI 之前加载三段数据(就像我们上面所做的那样),您必须手动跟踪和聚合这三个加载状态。它会起作用,但我非常怀疑复杂性是否值得。只需忍受捆绑包大小的微小增加即可。

Just use client components and let react-query remove the complexity with useSuspenseHook.
只需使用客户端组件,并让react-query通过 useSuspenseHook 消除复杂性。

Wrapping up 包起来

This was a long post, but I hope it was a valuable. Next’s app directory is an incredible piece of infrastructure that let’s us request data on the server, render, and even stream component content from that data, all using the single React component model we’re all used to.
这是一篇很长的文章,但我希望它是有价值的。 Next 的应用程序目录是一个令人难以置信的基础设施,它让我们可以在服务器上请求数据、渲染甚至从该数据流式传输组件内容,所有这些都使用我们都习惯的单个 React 组件模型。

There’s some things to get right, but depending on the type of app you’re building, react-query can simplify things a great deal.
有一些事情需要做对,但是根据您正在构建的应用程序的类型,react-query 可以大大简化事情。

Need an intro to Next.js?
需要 NEXT.JS 简介吗?

Frontend Masters logo

We have a great Introduction to Next.js from Scott Moss of Netflix. Next.js can be used to build anything from static blogs and documentation to full-stack applications and APIs.
Netflix 的 Scott Moss 为我们做了精彩的 Next.js 介绍。 Next.js 可用于构建从静态博客和文档到全栈应用程序和 API 的任何内容。

Leave a Reply  发表评论

Your email address will not be published. Required fields are marked *

Which learning path is right for you?

Answer three short questions and we'll recommend the best learning path for your experience level and goals