單一提取是一種新的資料載入策略和串流格式。當您啟用單一提取時,Remix 在客戶端轉換時將會向您的伺服器發出單一 HTTP 呼叫,而不是平行發出多個 HTTP 呼叫(每個載入器一個)。此外,單一提取還允許您從 loader
和 action
發送裸物件,例如 Date
、Error
、Promise
、RegExp
等。
Remix 在 future.unstable_singleFetch
標誌後面的 RFC 中引入了對「單一提取」的支援(在 v2.9.0
中,後來在 v2.13.0
中穩定為 future.v3_singleFetch
),這讓您可以選擇加入此行為。單一提取將會是 React Router v7 中的預設值。
啟用單一提取的目的是在初期減少工作量,然後讓您可以隨著時間逐步採用所有重大變更。您可以從應用最低限度的必要變更來啟用單一提取開始,然後使用遷移指南在您的應用程式中進行逐步變更,以確保順利且無破壞地升級至 React Router v7。
另請檢閱重大變更,以便您可以了解一些底層行為變更,特別是關於序列化和狀態/標頭行為。
1. 啟用 future 標誌
export default defineConfig({
plugins: [
remix({
future: {
// ...
v3_singleFetch: true,
},
}),
// ...
],
});
2. 已棄用的 fetch
填充
單一提取要求使用 undici
作為您的 fetch
填充,或是使用 Node 20+ 上的內建 fetch
,因為它依賴於其中可用的 API,而這些 API 不在 @remix-run/web-fetch
填充中。請參考下方 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
的實作方式 (如有必要)
啟用 Single Fetch 後,即使需要執行多個 loader,在客戶端導航時也只會發出一個請求。為了處理呼叫處理程式時合併標頭的問題,headers
導出現在也會應用於 loader
/action
資料請求。在許多情況下,您現有的文件請求邏輯應該足以應付新的 Single Fetch 資料請求。
4. 新增 nonce
(如果您使用 CSP)
如果您有一個針對腳本的內容安全策略並使用了 nonce-sources,您需要將該 nonce
新增到兩個地方,以支援串流 Single Fetch 的實作。
<RemixServer nonce={yourNonceValue}>
- 這會將 nonce
新增到此元件呈現的內嵌腳本中,該腳本處理客戶端上的串流資料。entry.server.tsx
中,將 nonce
參數加入到 renderToPipeableStream
/renderToReadableStream
的 options.nonce
參數中。另請參閱 Remix 串流文件。5. 取代 renderToString
(如果您正在使用它)
對於大多數 Remix 應用程式而言,您不太可能會使用 renderToString
,但如果您選擇在 entry.server.tsx
中使用它,請繼續閱讀,否則您可以跳過此步驟。
為了保持文件請求和資料請求之間的一致性,turbo-stream
也被用作在初始文件請求中傳輸資料的格式。這表示一旦選擇加入 Single Fetch,您的應用程式就不能再使用 renderToString
,而必須使用 React 串流渲染 API,例如 renderToPipeableStream
或 renderToReadableStream
,在 entry.server.tsx
中使用。
這並不表示您必須串流傳輸 HTTP 回應,您仍然可以透過利用 renderToPipeableStream
中的 onAllReady
選項,或 renderToReadableStream
中的 allReady
Promise,一次傳送完整的文件。
在客戶端,這也表示您需要在 startTransition
呼叫中包裝您的客戶端 hydrateRoot
呼叫,因為串流資料將被包裝在 Suspense
邊界中傳輸下來。
Single Fetch 引入了一些重大變更,其中一些需要在您啟用標誌時預先處理,而其他一些則可以在啟用標誌後逐步處理。您需要確保在更新到下一個主要版本之前,所有這些變更都已處理完畢。
需要預先處理的變更
fetch
Polyfill:舊的 installGlobals()
Polyfill 不適用於 Single Fetch,您必須使用原生 Node 20 fetch
API,或者在您的自訂伺服器中呼叫 installGlobals({ nativeFetch: true })
以取得基於 undici 的 Polyfill。headers
導出應用於資料請求:headers
函式現在會同時應用於文件請求和資料請求。您可能需要隨著時間處理的變更
turbo-stream
使用新的串流格式,這表示我們可以串流傳輸比 JSON 更複雜的資料。loader
和 action
函式返回的裸物件不再自動轉換為 JSON Response
,而是以原樣透過網路序列化。v3_singleFetch: true
擴充 Remix 的 Future
介面。action
重新驗證:在 action
4xx
/5xx
Response
之後的重新驗證現在是選擇加入,而不是選擇退出。啟用 Single Fetch 後,您可以開始撰寫利用更強大串流格式的路由。
v3_singleFetch: true
擴充 Remix 的 Future
介面。您可以在類型推論章節中閱讀更多相關資訊。
使用 Single Fetch,您可以從您的 loader 返回以下資料類型: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>
</>
);
}
如果您目前從您的 loader 返回 Response
實例 (例如,json
/defer
),那麼您不應該需要對您的應用程式碼進行太多變更,即可利用 Single Fetch。
然而,為了更好地為您將來升級到 React Router v7 做準備,我們建議您開始逐個路由進行以下變更,因為這是驗證更新標頭和資料類型不會破壞任何內容的最簡單方法。
在沒有 Single Fetch 的情況下,從 loader
或 action
返回的任何純 JavaScript 物件都會自動序列化為 JSON 回應 (就像您透過 json
返回它一樣)。類型推論假設這是事實,並將裸物件返回推斷為它們已經過 JSON 序列化。
使用 Single Fetch,裸物件將直接串流傳輸,因此一旦您選擇加入 Single Fetch,內建的類型推論就不再準確。例如,他們會假設 Date
會在客戶端序列化為字串 😕。
若要切換到 Single Fetch 類型,您應該使用 v3_singleFetch: true
擴充 Remix 的 Future
介面。您可以在 tsconfig.json
> include
涵蓋的任何檔案中執行此操作。我們建議您在 vite.config.ts
中執行此操作,以將其與 Remix 外掛程式中的 future.v3_singleFetch
future 標誌放在一起。
declare module "@remix-run/server-runtime" {
// or cloudflare, deno, etc.
interface Future {
v3_singleFetch: true;
}
}
現在 useLoaderData
、useActionData
以及任何使用 typeof loader
泛型的其他實用程式都應該使用 Single Fetch 類型。
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
引數的類型,因為這是我們的類型偵測用戶端資料函式的方式。
來自用戶端 loader 和 action 的資料永遠不會被序列化,因此這些資料的類型會被保留。
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 }
}
啟用 Single Fetch 後,headers
函式現在同時用於文件請求和資料請求。您應該使用該函式來合併平行執行的 loader 所返回的任何標頭,或返回任何給定的 actionHeaders
。
使用 Single Fetch,您不再需要返回 Response
實例,而可以透過裸物件返回直接返回您的資料。因此,在使用 Single Fetch 時,json
/defer
實用程式應被視為已棄用。這些實用程式將在 v2 期間保留,因此您不需要立即移除它們。它們很可能會在下一個主要版本中移除,因此我們建議您從現在到那時逐步移除它們。
對於 v2,您仍然可以繼續返回普通的 Response
實例,它們的 status
/headers
將以與文件請求相同的方式生效 (透過 headers()
函式合併標頭)。
隨著時間推移,您應該開始從您的 loader 和 action 中消除返回的 Response。
loader
/action
返回 json
/defer
時沒有設定任何 status
/headers
,那麼您可以直接移除對 json
/defer
的呼叫並直接返回資料。loader
/action
透過 json
/defer
返回自訂的 status
/headers
,您應該將它們切換為使用新的 data()
實用程式。如果您的應用程式有使用 clientLoader
函式的路由,請務必注意 Single Fetch 的行為會稍微改變。因為 clientLoader
的目的是讓您可以選擇退出呼叫伺服器 loader
函式,所以 Single Fetch 呼叫執行該伺服器 loader 是不正確的。但是我們平行執行所有 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
的伺服器載入器(server loaders),以及 c
的 clientLoader
。而 c
的 clientLoader
最終可能會(或可能不會)呼叫它自己的伺服器 loader
。當我們想要獲取 a
/b
的 loader
時,我們無法決定在單次 fetch 呼叫中包含 c
的伺服器 loader
,也無法在 c
實際發出 serverLoader
呼叫(或返回)之前延遲,否則會引入瀑布效應。
因此,當您導出一個 clientLoader
時,該路由會選擇退出單次 Fetch。當您呼叫 serverLoader
時,它將進行單次 fetch 以僅獲取其路由伺服器 loader
。所有未導出 clientLoader
的路由將在單個 HTTP 請求中獲取。
因此,在上述路由設定中,從 / -> /a/b/c
的導航將導致預先對路由 a
和 b
進行單次 fetch 呼叫。
GET /a/b/c.data?_routes=routes/a,routes/b
然後,當 c
呼叫 serverLoader
時,它將針對 c
的伺服器 loader
發出自己的呼叫。
GET /a/b/c.data?_routes=routes/c
由於單次 Fetch 使用了新的串流格式,從 loader
和 action
函式返回的原始 JavaScript 物件不再通過 json()
工具自動轉換為 Response
實例。相反,在導航數據載入中,它們會與其他載入器數據結合,並在 turbo-stream
回應中串流傳輸。
這為資源路由帶來了一個有趣的難題,因為它們的目的是被單獨調用,而不總是通過 Remix API 調用。它們也可以通過任何其他 HTTP 客戶端(fetch
、cURL
等)訪問。
如果資源路由旨在供內部 Remix API 使用,我們希望能夠利用 turbo-stream
編碼來解鎖串流傳輸更複雜結構(例如 Date
和 Promise
實例)的能力。然而,當從外部訪問時,我們可能更希望返回更容易使用的 JSON 結構。因此,如果您在 v2 中返回原始物件,行為會有些模糊 - 應該通過 turbo-stream
還是 json()
序列化?
為了簡化向後兼容性並促進採用單次 Fetch 未來標誌,Remix v2 將根據它是從 Remix API 還是從外部訪問來處理此問題。未來,如果您不希望將原始物件串流傳輸給外部使用,Remix 將要求您返回自己的 JSON 回應。
啟用單次 Fetch 時,Remix v2 的行為如下
當從 Remix API(例如 useFetcher
)訪問時,原始 JavaScript 物件將作為 turbo-stream
回應返回,就像正常的載入器和操作一樣(這是因為 useFetcher
會將 .data
後綴附加到請求中)。
當從外部工具(例如 fetch
或 cURL
)訪問時,我們將繼續自動轉換為 json()
,以在 v2 中實現向後兼容性。
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
回應。
使用單次 Fetch,Remix 現在在幕後使用 turbo-stream
,它為串流提供了一流的支援,並允許您自動序列化/反序列化比 JSON 更複雜的資料。以下資料類型可以直接通過 turbo-stream
串流傳輸:BigInt
、Date
、Error
、Map
、Promise
、RegExp
、Set
、Symbol
和 URL
。Error
的子類型也受支援,只要它們在客戶端上具有全域可用的建構函式 (SyntaxError
、TypeError
等)。
啟用單次 Fetch 後,這可能不需要對您的程式碼進行任何立即的更改。
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 影響。
一旦採用單次 Fetch,建議您逐步移除整個應用程式中 json
/defer
的使用,而傾向於返回原始物件。
以前,Remix 在預設的 entry.server.tsx
檔案中內建了 ABORT_TIMEOUT
的概念,該概念會終止 React 渲染器,但它並沒有特別做任何事情來清理任何待處理的延遲 Promise。
現在 Remix 在內部進行串流傳輸,我們可以取消 turbo-stream
處理並自動拒絕任何待處理的 Promise,並將這些錯誤串流傳輸到客戶端。預設情況下,這會在 4950 毫秒後發生 - 選擇此值是因為它略低於大多數 entry.server.tsx 檔案中目前的 5000 毫秒 ABORT_DELAY
- 因為我們需要在中止 React 端的處理之前取消 Promise 並讓拒絕通過 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);
});
}
除了更簡單的心智模型以及文件和資料請求的對齊之外,單次 Fetch 的另一個好處是更簡單(且希望更好)的快取行為。一般而言,與之前的多次 fetch 行為相比,單次 Fetch 將發出更少的 HTTP 請求,並希望更頻繁地快取這些結果。
為了減少快取片段,單次 Fetch 會更改 GET 導航上的預設重新驗證行為。以前,除非您通過 shouldRevalidate
選擇加入,否則 Remix 不會重新執行已重複使用的祖先路由的載入器。現在,在 GET /a/b/c.data
之類的單次 Fetch 請求的簡單情況下,Remix 將 預設重新執行這些載入器。如果您沒有任何 shouldRevalidate
或 clientLoader
函式,這將是您的應用程式的行為。
向任何活動路由新增 shouldRevalidate
或 clientLoader
將會觸發包含 _routes
參數的細粒度單次 Fetch 呼叫,該參數指定要執行的路由子集。
如果 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
選擇退出每個路由的重新驗證。
使用單次 Fetch,如果 action
返回或拋出具有 4xx/5xx
狀態碼的 Response
,Remix 預設情況下將不重新驗證載入器。如果 action
返回或拋出任何不是 4xx/5xx 回應的內容,則重新驗證行為保持不變。這樣做的原因是,在大多數情況下,如果您返回 4xx
/5xx
回應,您實際上沒有變更任何資料,因此無需重新載入資料。
如果您想要在 4xx/5xx 操作回應後繼續重新驗證一個或多個載入器,您可以通過從您的 shouldRevalidate
函式返回 true
來選擇加入每個路由的重新驗證。還有一個新的 actionStatus
參數傳遞給該函式,如果需要根據操作狀態碼進行判斷,可以使用該參數。
重新驗證通過單次 fetch HTTP 呼叫上的 ?_routes
查詢字串參數處理,該參數限制了正在呼叫的載入器。這意味著當您執行細粒度的重新驗證時,您將根據正在請求的路由進行快取列舉 - 但所有資訊都在 URL 中,因此您應該不需要任何特殊的 CDN 配置(與通過需要您的 CDN 尊重 Vary
標頭的自訂標頭完成的情況相反)。