React Router v7 已發布。 查看文件
單次提取
本頁內容

單一提取

單一提取是一種新的資料載入策略和串流格式。當您啟用單一提取時,Remix 在客戶端轉換時將會向您的伺服器發出單一 HTTP 呼叫,而不是平行發出多個 HTTP 呼叫(每個載入器一個)。此外,單一提取還允許您從 loaderaction 發送裸物件,例如 DateErrorPromiseRegExp 等。

概觀

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/renderToReadableStreamoptions.nonce 參數中。另請參閱 Remix 串流文件

5. 取代 renderToString (如果您正在使用它)

對於大多數 Remix 應用程式而言,您不太可能會使用 renderToString,但如果您選擇在 entry.server.tsx 中使用它,請繼續閱讀,否則您可以跳過此步驟。

為了保持文件請求和資料請求之間的一致性,turbo-stream 也被用作在初始文件請求中傳輸資料的格式。這表示一旦選擇加入 Single Fetch,您的應用程式就不能再使用 renderToString,而必須使用 React 串流渲染 API,例如 renderToPipeableStreamrenderToReadableStream,在 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 函式現在會同時應用於文件請求和資料請求。

您可能需要隨著時間處理的變更

使用 Single Fetch 新增路由

啟用 Single Fetch 後,您可以開始撰寫利用更強大串流格式的路由。

為了獲得正確的類型推論,您需要使用 v3_singleFetch: true 擴充 Remix 的 Future 介面。您可以在類型推論章節中閱讀更多相關資訊。

使用 Single Fetch,您可以從您的 loader 返回以下資料類型:BigIntDateErrorMapPromiseRegExpSetSymbolURL

// 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>
    </>
  );
}

使用 Single Fetch 遷移路由

如果您目前從您的 loader 返回 Response 實例 (例如,json/defer),那麼您不應該需要對您的應用程式碼進行太多變更,即可利用 Single Fetch。

然而,為了更好地為您將來升級到 React Router v7 做準備,我們建議您開始逐個路由進行以下變更,因為這是驗證更新標頭和資料類型不會破壞任何內容的最簡單方法。

類型推論

在沒有 Single Fetch 的情況下,從 loaderaction 返回的任何純 JavaScript 物件都會自動序列化為 JSON 回應 (就像您透過 json 返回它一樣)。類型推論假設這是事實,並將裸物件返回推斷為它們已經過 JSON 序列化。

使用 Single Fetch,裸物件將直接串流傳輸,因此一旦您選擇加入 Single Fetch,內建的類型推論就不再準確。例如,他們會假設 Date 會在客戶端序列化為字串 😕。

啟用 Single Fetch 類型

若要切換到 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;
  }
}

現在 useLoaderDatauseActionData 以及任何使用 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 } }
}

clientLoaderclientAction

請務必包含 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() 實用程式。

用戶端 Loader

如果您的應用程式有使用 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 導航,那麼我們需要執行 ab 的伺服器載入器(server loaders),以及 cclientLoader。而 cclientLoader 最終可能會(或可能不會)呼叫它自己的伺服器 loader。當我們想要獲取 a/bloader 時,我們無法決定在單次 fetch 呼叫中包含 c 的伺服器 loader,也無法在 c 實際發出 serverLoader 呼叫(或返回)之前延遲,否則會引入瀑布效應。

因此,當您導出一個 clientLoader 時,該路由會選擇退出單次 Fetch。當您呼叫 serverLoader 時,它將進行單次 fetch 以僅獲取其路由伺服器 loader。所有未導出 clientLoader 的路由將在單個 HTTP 請求中獲取。

因此,在上述路由設定中,從 / -> /a/b/c 的導航將導致預先對路由 ab 進行單次 fetch 呼叫。

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

然後,當 c 呼叫 serverLoader 時,它將針對 c 的伺服器 loader 發出自己的呼叫。

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

資源路由

由於單次 Fetch 使用了新的串流格式,從 loaderaction 函式返回的原始 JavaScript 物件不再通過 json() 工具自動轉換為 Response 實例。相反,在導航數據載入中,它們會與其他載入器數據結合,並在 turbo-stream 回應中串流傳輸。

這為資源路由帶來了一個有趣的難題,因為它們的目的是被單獨調用,而不總是通過 Remix API 調用。它們也可以通過任何其他 HTTP 客戶端(fetchcURL 等)訪問。

如果資源路由旨在供內部 Remix API 使用,我們希望能夠利用 turbo-stream 編碼來解鎖串流傳輸更複雜結構(例如 DatePromise 實例)的能力。然而,當從外部訪問時,我們可能更希望返回更容易使用的 JSON 結構。因此,如果您在 v2 中返回原始物件,行為會有些模糊 - 應該通過 turbo-stream 還是 json() 序列化?

為了簡化向後兼容性並促進採用單次 Fetch 未來標誌,Remix v2 將根據它是從 Remix API 還是從外部訪問來處理此問題。未來,如果您不希望將原始物件串流傳輸給外部使用,Remix 將要求您返回自己的 JSON 回應

啟用單次 Fetch 時,Remix v2 的行為如下

  • 當從 Remix API(例如 useFetcher)訪問時,原始 JavaScript 物件將作為 turbo-stream 回應返回,就像正常的載入器和操作一樣(這是因為 useFetcher 會將 .data 後綴附加到請求中)。

  • 當從外部工具(例如 fetchcURL)訪問時,我們將繼續自動轉換為 json(),以在 v2 中實現向後兼容性。

    • 當遇到這種情況時,Remix 將記錄棄用警告。
    • 您可以方便地更新受影響的資源路由處理程式以返回 Response 物件。
    • 解決這些棄用警告將更好地為您準備最終的 Remix v3 升級。
    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 串流傳輸:BigIntDateErrorMapPromiseRegExpSetSymbolURLError 的子類型也受支援,只要它們在客戶端上具有全域可用的建構函式 (SyntaxErrorTypeError 等)。

啟用單次 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 預設重新執行這些載入器。如果您沒有任何 shouldRevalidateclientLoader 函式,這將是您的應用程式的行為。

向任何活動路由新增 shouldRevalidateclientLoader 將會觸發包含 _routes 參數的細粒度單次 Fetch 呼叫,該參數指定要執行的路由子集。

如果 clientLoader 在內部呼叫 serverLoader(),則會觸發針對該特定路由的單獨 HTTP 呼叫,類似於舊的行為。

例如,如果您在 /a/b 上並導航到 /a/b/c

  • 當不存在 shouldRevalidateclientLoader 函式時: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

如果此新行為對您的應用程式而言不是最佳的,您應該能夠通過在所需的場景中新增一個返回 falseshouldRevalidate 來選擇返回不重新驗證的舊行為。

另一個選項是為昂貴的父載入器計算利用伺服器端快取。

提交重新驗證行為

以前,無論操作結果如何,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 標頭的自訂標頭完成的情況相反)。

文件和範例根據以下許可 MIT