跳转到内容

Single Fetch

Single Fetch 是一种新的数据加载策略和流式传输格式。启用 Single Fetch 后,Remix 将在客户端转换时向您的服务器发出单个 HTTP 调用,而不是并行发出多个 HTTP 调用(每个加载器一个)。此外,Single Fetch 还允许您从加载器和操作中发送裸对象,例如 Date、Error、Promise、RegExp 等。

概述

Remix 在 v2.9.0 中的 future.unstable_singleFetch 标志后面引入了对 Single Fetch(RFC)的支持(后来在 v2.13.0 中稳定为 future.v3_singleFetch),这允许您选择加入此行为。Single Fetch 将成为 React Router v7 中的默认设置。

启用 Single Fetch 旨在降低前期工作量,然后允许您随着时间的推移迭代地采用所有重大更改。您可以先对 启用 Single Fetch 应用所需的最少更改,然后使用 迁移指南 在您的应用程序中进行增量更改,以确保顺利、无中断地升级到 React Router v7。

另请查看重大变化,以便您了解一些底层行为变化,特别是围绕序列化和状态 / 标头行为的变化。

启用单次获取

1. 启用未来标志

vite.config.ts
export default defineConfig({
plugins: [
remix({
future: {
// ...
v3_singleFetch: true,
},
}),
// ...
],
});

2. 弃用 fetch polyfill

单一提取需要使用 undici 作为 fetch polyfill,或者使用 Node 20+ 上的内置 fetch,因为它依赖于 @remix-run/web-fetch polyfill 中没有的 API。请参阅下面 2.9.0 发行说明中的 Undici 部分以了解更多详细信息。

  • 如果您使用的是 Node 20+,请删除对 installGlobals() 的任何调用并使用 Node 的内置 fetch(这与 undici 相同)。

  • 如果您正在管理自己的服务器并调用 installGlobals(),则需要调用 installGlobals({nativeFetch:true})来使用 undici。

installGlobals();
installGlobals({ nativeFetch: true });
  • 如果您使用 remix-serve,如果启用了单一获取,它将自动使用 undici。

  • 如果您在 remix 项目中使用 miniflare/cloudflare worker,请确保您的 兼容性标志 也设置为 2023-03-01 或更高版本。

3. 调整 headers 实现(如有必要)

启用单一提取后,即使需要运行多个加载器,客户端导航也只会发出一个请求。为了处理调用的处理程序的合并标头,headers 导出现在也将应用于加载器 / 操作数据请求。在许多情况下,您已经在其中为文档请求设置的逻辑应该足以满足您的新单一提取数据请求。

4. 添加 nonce(如果你使用 CSP)

如果您有一个带有 nonce-sources 的 脚本内容安全策略,则需要将该 nonce 添加到两个位置以实现流式单一提取实现:

5. 替换 renderToString(如果您正在使用它)

对于大多数 Remix 应用来说,您不太可能使用 renderToString,但如果您选择在 entry.server.tsx 中使用它,请继续阅读,否则您可以跳过此步骤。

为了保持文档和数据请求之间的一致性,turbo-stream 还用作初始文档请求中向下发送数据的格式。这意味着一旦选择使用 Single Fetch,您的应用程序就不能再使用 renderToString,而必须使用 React 流渲染器 API,例如 renderToPipeableStream 或 renderToReadableStream)(在 entry.server.tsx 中)。

这并不意味着您必须流式传输 HTTP 响应,您仍然可以通过利用 renderToPipeableStream 中的 onAllReady 选项或 renderToReadableStream 中的 allReady 承诺一次发送完整文档。

在客户端,这也意味着您需要将客户端 hydrateRoot 调用包装在 startTransition 调用中,因为流数据将被包裹在 Suspense 边界中。

重大变化

Single Fetch 引入了一些重大更改 - 其中一些需要在启用标志时预先处理,而另一些则可以在启用标志后逐步处理。您需要确保在更新到下一个主要版本之前已处理所有这些更改。

需要提前解决的变化:

  • 弃用的 fetch polyfill:旧的 installGlobals() polyfill 不适用于单一提取,您必须使用本机 Node 20 fetch API 或在自定义服务器中调用 installGlobals({ nativeFetch: true }) 以获取 基于 undici 的 polyfill
  • headers 导出应用于数据请求:headers 函数现在将应用于文档和数据请求

您可能需要加班处理的变更:

  • 新的流式数据格式:单次获取通过 turbo-stream 在后台使用一种新的流式格式,这意味着我们可以传输比 JSON 更复杂的数据
  • 不再自动序列化:从 loader 和 action 函数返回的裸对象不再自动转换为 JSON Response,而是通过网络按原样序列化
  • 类型推断更新:为了获得最准确的类型推断,您应该使用 v3_singleFetch: true [增强][增强] Remix 的 Future 接口
  • GET 导航上的默认重新验证行为更改为选择退出:正常导航上的默认重新验证行为从选择加入更改为选择退出,并且您的服务器加载器将默认重新运行
  • 选择加入 action 重新验证:在操作``4xx/5xx``响应之后重新验证现在是选择加入,而不是选择退出

使用单次获取添加新路由

启用单一获取后,您可以继续创作利用更强大的流格式的路由。

为了获得正确的类型推断,您需要使用 v3_singleFetch: true 来增强 Remix 的 Future 接口。您可以在 类型推断部分 中阅读更多相关信息。

使用单一获取,您可以从加载器返回以下数据类型:BigInt、Date、Error、Map、Promise、RegExp、Set、Symbol 和 URL。

// routes/blog.$slug.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
export async function loader({
params,
}: LoaderFunctionArgs) {
const { slug } = params;
const comments = fetchComments(slug);
const blogData = await fetchBlogData(slug);
return {
content: blogData.content, // <- string
published: blogData.date, // <- Date
comments, // <- Promise
};
}
export default function BlogPost() {
const blogData = useLoaderData<typeof loader>();
// ^? { content: string, published: Date, comments: Promise }
return (
<>
<Header published={blogData.date} />
<BlogContent content={blogData.content} />
<Suspense fallback={<CommentsSkeleton />}>
<Await resolve={blogData.comments}>
{(comments) => (
<BlogComments comments={comments} />
)}
</Await>
</Suspense>
</>
);
}

使用单次获取迁移路由

如果您当前正在从加载器返回 Response 实例(即 json/defer),那么您不需要对应用程序代码进行太多更改即可利用单一获取。

但是,为了更好地准备将来升级到 React Router v7,我们建议您开始逐个路由进行以下更改,因为这是验证更新标题和数据类型不会破坏任何内容的最简单方法。

类型推断

如果没有单一提取,任何从加载器或操作返回的纯 Javascript 对象都会自动序列化为 JSON 响应(就像您通过 json 返回它一样)。类型推断假定情况如此,并推断裸对象返回,就好像它们是 JSON 序列化的一样。

使用单次提取时,裸对象将直接进行流式传输,因此一旦您选择单次提取,内置类型推断就不再准确。例如,他们会假设日期将在客户端上序列化为字符串😕。

启用单一获取类型

要切换到单一提取类型,您应该使用 v3_singleFetch:true 增强 Remix 的 Future 接口。 您可以在 tsconfig.json>include 涵盖的任何文件中执行此操作。 我们建议您在 vite.config.ts 中执行此操作,以使其与 Remix 插件中的 future.v3_singleFetch 未来标志保持在同一位置:

declare module "@remix-run/server-runtime" {
// or cloudflare, deno, etc.
interface Future {
v3_singleFetch: true;
}
}

现在,useLoaderData、useActionData 以及任何其他使用 typeof loader 泛型的实用程序都应该使用单一获取类型:

import { useLoaderData } from "@remix-run/react";
export function loader() {
return {
planet: "world",
date: new Date(),
};
}
export default function Component() {
const data = useLoaderData<typeof loader>();
// ^? { planet: string, date: Date }
}

函数和类实例

一般来说,函数无法通过网络可靠地发送,因此它们被序列化为未定义:

import { useLoaderData } from "@remix-run/react";
export function loader() {
return {
planet: "world",
date: new Date(),
notSoRandom: () => 7,
};
}
export default function Component() {
const data = useLoaderData<typeof loader>();
// ^? { planet: string, date: Date, notSoRandom: undefined }
}

方法也不可序列化,因此类实例被精简为仅具有可序列化的属性:

import { useLoaderData } from "@remix-run/react";
class Dog {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
bark() {
console.log("woof");
}
}
export function loader() {
return {
planet: "world",
date: new Date(),
spot: new Dog("Spot", 3),
};
}
export default function Component() {
const data = useLoaderData<typeof loader>();
// ^? { planet: string, date: Date, spot: { name: string, age: number, bark: undefined } }
}

clientLoader 和 clientAction

确保包含 clientLoader 参数和 clientAction 参数的类型,因为这是我们的类型检测客户端数据函数的方式。

来自客户端加载器和操作的数据永远不会被序列化,因此这些数据的类型会被保留:

import {
useLoaderData,
type ClientLoaderFunctionArgs,
} from "@remix-run/react";
class Dog {
/* ... */
}
// Make sure to annotate the types for the args! 👇
export function clientLoader(_: ClientLoaderFunctionArgs) {
return {
planet: "world",
date: new Date(),
notSoRandom: () => 7,
spot: new Dog("Spot", 3),
};
}
export default function Component() {
const data = useLoaderData<typeof clientLoader>();
// ^? { planet: string, date: Date, notSoRandom: () => number, spot: Dog }
}

标题

当启用单一提取时,headers 函数现在可用于文档和数据请求。您应该使用该函数合并并行执行的加载器返回的任何标头,或返回任何给定的 actionHeaders。

返回的响应

使用 Single Fetch,您不再需要返回 Response 实例,而只需通过裸对象返回直接返回数据即可。因此,在使用 Single Fetch 时,应将 json/defer 实用程序视为已弃用。这些实用程序将保留到 v2 的持续时间,因此您无需立即删除它们。它们可能会在下一个主要版本中被删除,因此我们建议从现在开始逐步删除它们。

对于 v2,您仍然可以继续返回正常的 Response 实例,并且它们的 status/headers 将以与文档请求相同的方式生效(通过 headers() 函数合并标题)。

随着时间的推移,您应该开始从加载器和操作中消除返回的响应。

  • 如果您的 loader/action 返回 json/defer 而没有设置任何 status/headers,那么您只需删除对 json/defer 的调用并直接返回数据即可
  • 如果您的 loader/action 通过 json/defer 返回自定义 status/headers,那么您应该切换它们以使用新的 data() 实用程序。

客户端加载器

如果您的应用有使用 clientLoader 函数的路由,请务必注意,Single Fetch 的行为会略有变化。因为 clientLoader 旨在为您提供一种选择不调用服务器 loader 函数的方法 - Single Fetch 调用执行该服务器加载器是不正确的。但我们并行运行所有加载器,我们不想等到知道哪些 clientLoader 实际请求服务器数据后才进行调用。

例如,考虑以下 /a/b/c 路由:

routes/a.tsx
export function loader() {
return { data: "A" };
}
// routes/a.b.tsx
export function loader() {
return { data: "B" };
}
// routes/a.b.c.tsx
export function loader() {
return { data: "C" };
}
export function clientLoader({ serverLoader }) {
await doSomeStuff();
const data = await serverLoader();
return { data };
}

如果用户从 / -> /a/b/c 导航,那么我们需要运行 a 和 b 的服务器加载器,以及 c 的 clientLoader - 最终可能会(也可能不会)调用它自己的服务器 loader。当我们想要获取 a/b loader 时,我们无法决定在单个获取调用中包含 c 服务器 loader,我们也无法延迟到 c 实际进行 serverLoader 调用(或返回)而不引入瀑布。

因此,当您导出 clientLoader 时,该路由将退出单次提取,而当您调用 serverLoader 时,它将进行单次提取以仅获取其路由服务器 loader。所有未导出 clientLoader 的路由都将在单个 HTTP 请求中进行提取。

因此,在上述路由设置中,从 / -> /a/b/c 导航将导致对路由 a 和 b 进行一次单一获取调用:

GET /a/b/c.data?_routes=routes/a,routes/b

然后当 c 调用 serverLoader 时,它将仅对 c 服务器加载器进行自己的调用:

GET /a/b/c.data?_routes=routes/c

资源路由

由于 Single Fetch 使用新的 流格式,从 loader 和 action 函数返回的原始 JavaScript 对象不再通过 json() 实用程序自动转换为 Response 实例。相反,在导航数据加载中,它们与其他加载器数据相结合,并在 turbo-stream 响应中向下传输。

这对 资源路由 提出了一个有趣的难题,因为它们是独一无二的,因为它们旨在单独访问 - 并且并不总是通过 Remix API。它们也可以通过任何其他 HTTP 客户端(fetch、cURL 等)访问。

如果资源路由旨在供内部 Remix API 使用,我们希望能够利用 turbo-stream 编码来解锁流式传输更复杂结构(例如 Date 和 Promise 实例)的能力。但是,当从外部访问时,我们可能更愿意返回更易于使用的 JSON 结构。因此,如果您在 v2 中返回原始对象,则行为会略显模糊 - 应该通过 turbo-stream 还是 json() 对其进行序列化?

为了简化向后兼容性并简化未来单一提取标志的采用,Remix v2 将根据是从 Remix API 还是外部访问来处理此问题。将来,如果您不希望原始对象被流式传输以供外部使用,Remix 将要求您返回自己的 [JSON 响应][返回响应]。

启用单一获取后的 Remix v2 行为如下:

  • 当从 Remix API(例如 useFetcher)访问时,原始 Javascript 对象将作为 turbo-stream 响应返回,就像普通的加载器和操作一样(这是因为 useFetcher 会将.data 后缀附加到请求)

  • 当从 fetch 或 cURL 等外部工具访问时,我们将继续自动转换为 json(),以实现 v2 中的向后兼容性:

  • 遇到这种情况时,Remix 将记录弃用警告

  • 您可以根据需要更新受影响的资源路由处理程序以返回 Response 对象

  • 解决这些弃用警告将让您更好地为最终的 Remix v3 升级做好准备

export function loader() {
return {
message: "我的外部访问资源路由",
};
}
export function loader() {
return Response.json({
message: "我的外部访问资源路由",
});
}

其他详细信息

流数据格式

以前,Remix 使用 JSON.stringify 通过网络序列化您的加载器 / 动作数据,并且需要实现自定义流格式来支持 defer 响应。

借助 Single Fetch,Remix 现在在底层使用 turbo-stream,它为流式传输提供一流的支持,并允许您自动序列化 / 反序列化比 JSON 更复杂的数据。以下数据类型可以通过 turbo-stream 直接流式传输:BigInt、Date、Error、Map、Promise、RegExp、Set、Symbol 和 URL。只要 Error 的子类型在客户端上具有全局可用的构造函数(SyntaxError、TypeError 等),它们也受支持。

一旦启用单次提取,这可能会或可能不会需要立即更改您的代码:

  • ✅ 从 loader/action 函数返回的 json 响应仍将通过 JSON.stringify 进行序列化,因此如果您返回 Date,您将从 useLoaderData/useActionData 收到一个 string
  • ⚠️ 如果您返回 defer 实例或裸对象,它现在将通过 turbo-stream 进行序列化,因此如果您返回 Date,您将从 useLoaderData/useActionData 收到一个 Date
  • 如果您希望保持当前行为(不包括流式 defer 响应),您可以将任何现有的裸对象返回包装在 json 中

这也意味着您不再需要使用 defer 实用程序通过网络发送 Promise 实例!您可以在裸对象中的任何位置包含 Promise,并在 useLoaderData().whatever 上拾取它。如果需要,您还可以嵌套 Promise - 但要注意潜在的用户体验影响。

一旦采用单一获取,建议您在整个应用程序内逐步删除 json/defer 的使用,转而返回原始对象。

流超时

此前,Remix 在默认的 entry.server.tsx 文件中内置了一个 ABORT_TIMEOUT 概念,它会终止 React 渲染器,但它并没有采取任何特别的措施来清理任何待处理的延期承诺。

现在 Remix 正在内部流式传输,我们可以取消 turbo-stream 处理并自动拒绝任何待处理的承诺并将这些错误流式传输到客户端。默认情况下,这种情况会在 4950 毫秒后发生 - 该值被选择为略低于大多数 entry.server.tsx 文件中当前的 5000 毫秒 ABORT_DELAY - 因为我们需要取消承诺并让拒绝在中止 React 方面之前通过 React 渲染器流式传输。

您可以通过从 entry.server.tsx 导出 streamTimeout 数值来控制这一点,Remix 将使用该值作为拒绝 loader/action 中任何未完成的 Promises 的毫秒数。建议将此值与您中止 React 渲染器的超时时间分离 - 并且您应该始终将 React 超时时间设置为更高的值,以便有时间从 streamTimeout 中流出底层拒绝。

app/entry.server.tsx
// Reject all pending promises from handler functions after 5 seconds
export const streamTimeout = 5000;
// ...
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
/* ... */
},
onShellError(error: unknown) {
/* ... */
},
onError(error: unknown) {
/* ... */
},
}
);
// Automatically timeout the react renderer after 10 seconds
setTimeout(abort, 10000);
});
}

重新验证

正常导航行为

除了更简单的思维模型以及文档和数据请求的一致性之外,单次获取的另一个好处是更简单(希望更好)的缓存行为。通常,与之前的多次获取行为相比,单次获取会发出更少的 HTTP 请求,并且希望更频繁地缓存这些结果。

为了减少缓存碎片,单一获取会更改 GET 导航的默认重新验证行为。以前,除非您通过 shouldRevalidate 选择加入,否则 Remix 不会为重复使用的祖先路由重新运行加载器。现在,对于 GET /a/b/c.data 等单一获取请求的简单情况,Remix 将默认重新运行这些加载器。如果您没有任何 shouldRevalidate 或 clientLoader 函数,这将是您的应用的行为。

向任何活动路由添加 shouldRevalidate 或 clientLoader 将触发细粒度的单一获取调用,其中包括一个_routes 参数,用于指定要运行的路由子集。

如果 clientLoader 在内部调用 serverLoader(),则会为该特定路由触发单独的 HTTP 调用,类似于旧的行为。

例如,如果您在 /a/b 并且导航到 /a/b/c:

  • 当不存在 shouldRevalidate 或 clientLoader 函数时:GET /a/b/c.data
  • 如果所有路由都有加载器,但 routes/a 通过 shouldRevalidate 选择退出:
  • GET /a/b/c.data?_routes=root,routes/b,routes/c
  • 如果所有路由都有加载器,但 routes/b 有 clientLoader:
  • GET /a/b/c.data?_routes=root,routes/a,routes/c
  • 然后,如果 B 的 clientLoader 调用 serverLoader():
  • GET /a/b/c.data?_routes=routes/b

如果这种新行为对于您的应用程序来说不是最优的,您应该能够通过向您的父路由添加在所需场景中返回 false 的 shouldRevalidate 来选择回到不重新验证的旧行为。

另一种选择是利用服务器端缓存进行昂贵的父加载器计算。

提交重新验证行为

以前,Remix 会在提交任何操作后始终重新验证所有活动加载器,无论操作结果如何。您可以通过 shouldRevalidate 根据每个路由选择退出重新验证。

使用单一提取时,如果操作返回或抛出带有 4xx/5xx 状态代码的响应,则默认情况下 Remix 将不会重新验证加载器。如果操作返回或抛出任何非 4xx/5xx 响应,则重新验证行为不会改变。这里的原因是,在大多数情况下,如果您返回 4xx/5xx 响应,则您实际上并没有改变任何数据,因此无需重新加载数据。

如果您希望在 4xx/5xx 操作响应后继续重新验证一个或多个加载器,您可以通过从 shouldRevalidate 函数返回 true 来选择在每个路由上进行重新验证。此外,还有一个新的 actionStatus 参数传递给该函数,如果您需要根据操作状态代码做出决定,您可以使用它。

重新验证通过单个 fetch HTTP 调用上的 ?_routes 查询字符串参数进行处理,该参数限制了被调用的加载器。这意味着,当您进行细粒度重新验证时,您将根据所请求的路由进行缓存枚举 - 但所有信息都在 URL 中,因此您不需要任何特殊的 CDN 配置(与通过自定义标头完成此操作相反,自定义标头要求您的 CDN 遵守 Vary 标头)。