单次获取是一种新的数据加载策略和数据流格式。启用单次获取后,Remix 将在客户端过渡期间向您的服务器发出单个 HTTP 调用,而不是并行发出多个 HTTP 调用(每个加载器一个)。此外,单次获取还允许您从您的 loader
和 action
发送裸对象,例如 Date
、Error
、Promise
、RegExp
等。
Remix 在 RFC 中引入了对“单次获取”的支持,在 future.unstable_singleFetch
标志下 v2.9.0
(后来在 v2.13.0
中稳定为 future.v3_singleFetch
)中,它允许您选择这种行为。单次获取将在 React Router v7 中成为默认值。
启用单次获取旨在 upfront 降低成本,然后允许您随着时间的推移迭代地采用所有重大变更。您可以从应用对 启用单次获取 所需的最小更改开始,然后使用 迁移指南 在您的应用程序中进行增量更改,以确保顺利、无缝升级到 React Router v7。
请还查看 重大变更,以便您了解一些底层行为变更,尤其是关于序列化和状态/标头行为方面的变更。
1. 启用 future 标志
export default defineConfig({
plugins: [
remix({
future: {
// ...
v3_singleFetch: true,
},
}),
// ...
],
});
2. 弃用 fetch
polyfill
单次获取需要使用 undici
作为您的 fetch
polyfill,或者在 Node 20+ 上使用内置的 fetch
,因为它依赖于那里提供的 API,这些 API 不在 @remix-run/web-fetch
polyfill 中。请参阅下面 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
导出现在也适用于 loader
/action
数据请求。在很多情况下,您已经为文档请求实现的逻辑几乎足以满足新的单次获取数据请求。
4. 添加 nonce
(如果您使用 CSP)
如果您对脚本有 内容安全策略,并且使用 nonce-sources,则需要在两个地方添加该 nonce
才能实现流式单次获取。
<RemixServer nonce={yourNonceValue}>
- 这会将 nonce
添加到此组件呈现的内联脚本中,这些脚本在客户端处理流式数据。entry.server.tsx
中,将 nonce
参数传递给 renderToPipeableStream
/renderToReadableStream
。另请参见 Remix 的 流式文档5. 替换 renderToString
(如果您正在使用它)
对于大多数 Remix 应用程序,您不太可能使用 renderToString
,但如果您在 entry.server.tsx
中选择使用它,请继续阅读;否则,您可以跳过此步骤。
为了保持文档请求和数据请求之间的一致性,turbo-stream
也用作在初始文档请求中发送数据的格式。这意味着,一旦启用单次获取,您的应用程序将不再能够使用 renderToString
,而必须使用 React 流式渲染器 API(例如 renderToPipeableStream
或 renderToReadableStream
)在 entry.server.tsx
中。
这并不意味着您*必须*流式传输 HTTP 响应,您仍然可以通过利用 renderToPipeableStream
中的 onAllReady
选项,或者 renderToReadableStream
中的 allReady
Promise 来一次发送完整的文档。
在客户端,这也意味着您需要将客户端 hydrateRoot
调用包装在 startTransition
调用中,因为流式传输的数据将被包装在 Suspense
边界中。
单次获取引入了几个重大更改 - 您需要在启用标志时预先处理其中的一些更改,而另一些更改您可以在启用标志后逐步处理。在更新到下一个主要版本之前,您需要确保所有这些更改都已处理完毕。
需要立即解决的更改
fetch
polyfill:旧的 installGlobals()
polyfill 不适用于单次获取,您必须使用原生 Node 20 fetch
API 或在自定义服务器中调用 installGlobals({ nativeFetch: true })
才能获得基于 undici 的 polyfill。headers
导出应用于数据请求:headers
函数现在将同时应用于文档请求和数据请求。需要注意的更改,您可能需要随着时间的推移进行处理
turbo-stream
的一种新的流式格式,这意味着我们能够流式传输比 JSON 更复杂的数据。loader
和 action
函数返回的裸对象不再自动转换为 JSON Response
,而是按原样通过网络序列化。Future
接口,并将 v3_singleFetch
设置为 true
。action
重新验证:action
4xx
/5xx
Response
后的重新验证现在是选择加入,而不是选择退出。启用单次获取后,您可以开始编写利用更强大的流式格式的路由。
Future
接口,并将 v3_singleFetch
设置为 true
。您可以在 类型推断部分 中阅读有关此内容的更多信息。
使用单次获取,您可以从加载器中返回以下数据类型: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,我们建议您逐个路由地开始进行以下更改,因为这是验证更新标头和数据类型不会导致任何问题的最简单方法。
在没有单次获取的情况下,从 loader
或 action
返回的任何普通 Javascript 对象都会自动序列化为 JSON 响应(就像您通过 json
返回它一样)。类型推断假设情况就是如此,并将裸对象返回值推断为 JSON 序列化后的对象。
使用单次获取,裸对象将直接流式传输,因此一旦您启用单次获取,内置的类型推断就不再准确。例如,它们会假设 Date
会在客户端序列化为字符串 😕。
要切换到单次获取类型,您应该 增强 Remix 的 Future
接口,并将 v3_singleFetch
设置为 true
。您可以在 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 }
}
通常,函数无法可靠地在网络上传输,因此它们会序列化为 undefined
。
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
。
使用单次获取,您不再需要返回 Response
实例,而是可以直接通过裸对象返回值返回数据。因此,使用单次获取时,json
/defer
实用程序应该被视为已弃用。这些实用程序将在 v2 中保留,因此您无需立即删除它们。它们很可能在下一个主要版本中被删除,因此我们建议您在现在和那时之间逐步删除它们。
对于 v2,您仍然可以继续返回正常的 Response
实例,它们的 status
/headers
将像在文档请求中一样生效(通过 headers()
函数合并标头)。
随着时间的推移,您应该开始从加载器和操作中消除返回的响应。
loader
/action
返回了 json
/defer
且没有设置任何 status
/headers
,那么您只需删除对 json
/defer
的调用,然后直接返回数据。loader
/action
通过 json
/defer
返回了自定义 status
/headers
,那么您应该切换到使用新的 data()
实用程序。如果您的应用程序有使用 clientLoader
函数的路由,那么需要注意的是单次获取的行为会略有改变。因为 clientLoader
旨在为您提供一种选择退出调用服务器 loader
函数的方法 - 对单次获取调用执行该服务器加载器将是不正确的。但是,我们并行运行所有加载器,并且我们不想*等待*直到我们知道哪些 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
服务器的loader
。
GET /a/b/c.data?_routes=routes/c
由于单次获取使用的新流式数据格式,来自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
)访问时,为了在 v2 中保持向后兼容性,我们将继续这种自动转换为json()
的行为。
Response
对象。export function loader() {
return {
message: "My externally-accessed resource route",
};
}
export function loader() {
return Response.json({
message: "My externally-accessed resource route",
});
}
以前,Remix 使用JSON.stringify
来序列化你的加载器/操作数据,并且需要实现自定义流式传输格式来支持defer
响应。
使用单次获取,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
- 但要注意潜在的 UX 影响。
采用单次获取后,建议你逐步删除应用程序中对json
/defer
的使用,转而返回原始对象。
以前,Remix 在默认的entry.server.tsx
文件中内置了ABORT_TIMEOUT
的概念,该概念将终止 React 渲染器,但它并没有对任何挂起的延迟 promise 进行特殊清理。
现在 Remix 在内部进行流式传输,我们可以取消turbo-stream
处理并自动拒绝任何挂起的 promise 并将这些错误流式传输到客户端。默认情况下,这将在 4950 毫秒后发生 - 这个值被选择为略小于大多数entry.server.tsx
文件中的当前 5000 毫秒ABORT_DELAY
- 因为我们需要取消 promise 并让拒绝在 React 渲染器中止之前通过 React 渲染器流式传输。
你可以通过从你的entry.server.tsx
文件中导出streamTimeout
数值来控制它,Remix 将使用它作为拒绝loader
/action
中任何未完成的 Promise 的毫秒数。建议你将此值与中止 React 渲染器的时间间隔分离 - 并且你应该始终将 React 时间间隔设置为更高的值,以便它有时间将底层拒绝从你的streamTimeout
流式传输下来。
// 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 导航上的默认重新验证行为。以前,Remix 不会重新运行重新使用的祖先路由的加载器,除非你通过shouldRevalidate
选择加入。现在,Remix 会默认情况下在简单的单次获取请求(如GET /a/b/c.data
)中重新运行它们。如果你没有任何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
clientLoader
调用serverLoader()
GET /a/b/c.data?_routes=routes/b
如果这种新行为对你的应用程序不是最佳的,你应该能够通过在你的父路由中添加一个在所需情况下返回false
的shouldRevalidate
来选择退出不重新验证的旧行为。
另一个选择是利用服务器端缓存来进行昂贵的父加载器计算。
以前,Remix 始终在任何操作提交后重新验证所有活动加载器,无论操作的结果如何。你可以通过shouldRevalidate
选择退出按路由重新验证。
使用单次获取,如果action
返回或抛出具有4xx/5xx
状态代码的Response
,Remix 默认情况下不会重新验证加载器。如果action
返回或抛出任何不是 4xx/5xx 响应的东西,那么重新验证行为将保持不变。这里的推理是,在大多数情况下,如果你返回4xx
/5xx
响应,你实际上没有修改任何数据,因此不需要重新加载数据。
如果你想要在 4xx/5xx 操作响应后继续重新验证一个或多个加载器,你可以通过从你的shouldRevalidate
函数中返回true
来选择加入按路由重新验证。还提供了一个新的actionStatus
参数传递给函数,如果你需要根据操作状态代码来决定,可以使用它。
重新验证通过单次获取 HTTP 调用上的?_routes
查询字符串参数进行处理,该参数限制了正在调用的加载器。这意味着,当你进行细粒度重新验证时,你将根据请求的路由进行缓存枚举 - 但所有信息都在 URL 中,因此你不应该需要任何特殊的 CDN 配置(与通过自定义标头执行此操作并要求你的 CDN 尊重Vary
标头的情况相反)。