React Router v7 已發布。 查看文件
資料載入
本頁內容

資料載入

Remix 的主要功能之一是簡化與伺服器的互動,以便將資料載入到元件中。當您遵循這些慣例時,Remix 可以自動執行以下操作

  • 伺服器端渲染您的頁面
  • 在 JavaScript 無法載入時,能夠適應網路狀況
  • 在使用者與您的網站互動時進行優化,使其快速,僅載入頁面變更部分的資料
  • 在轉換時並行獲取資料、JavaScript 模組、CSS 和其他資源,避免渲染 + 獲取瀑布式流程,從而導致不流暢的 UI
  • 通過在動作後重新驗證,確保 UI 中的資料與伺服器上的資料同步
  • 在返回/前進點擊時(甚至跨網域)提供出色的滾動恢復
  • 使用錯誤邊界處理伺服器端錯誤
  • 使用錯誤邊界為「找不到」和「未授權」啟用可靠的 UX
  • 幫助您保持 UI 的順暢路徑

基本概念

每個路由模組都可以匯出一個元件和一個loaderuseLoaderData 會將 loader 的資料提供給您的元件

import { json } from "@remix-run/node"; // or cloudflare/deno
import { useLoaderData } from "@remix-run/react";

export const loader = async () => {
  return json([
    { id: "1", name: "Pants" },
    { id: "2", name: "Jacket" },
  ]);
};

export default function Products() {
  const products = useLoaderData<typeof loader>();
  return (
    <div>
      <h1>Products</h1>
      {products.map((product) => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  );
}

該元件在伺服器和瀏覽器中渲染。loader 僅在伺服器上運行。這表示我們的硬式編碼產品陣列不會包含在瀏覽器套件中,並且可以安全地將僅限伺服器使用的 API 和 SDK 用於資料庫、支付處理、內容管理系統等項目。

如果您的伺服器端模組最終出現在用戶端套件中,請參考我們關於伺服器與用戶端程式碼執行的指南。

路由參數

當您使用 $ 命名檔案時,例如 app/routes/users.$userId.tsxapp/routes/users.$userId.projects.$projectId.tsx,動態區段(以 $ 開頭的區段)將會從 URL 中解析,並以 params 物件傳遞給您的 loader。

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  console.log(params.userId);
  console.log(params.projectId);
};

給定以下 URL,參數將會解析如下

URL params.userId params.projectId
/users/123/projects/abc "123" "abc"
/users/aec34g/projects/22cba9 "aec34g" "22cba9"

這些參數對於查詢資料最有用

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  return json(
    await fakeDb.project.findMany({
      where: {
        userId: params.userId,
        projectId: params.projectId,
      },
    })
  );
};

參數類型安全

因為這些參數來自 URL 而非您的原始碼,您無法確定它們是否一定會被定義。這就是為什麼參數鍵的類型會是 string | undefined。最佳實務是在使用它們之前進行驗證,特別是在 TypeScript 中以取得類型安全。使用 invariant 可以輕鬆完成這件事。

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import invariant from "tiny-invariant";

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.userId, "Expected params.userId");
  invariant(params.projectId, "Expected params.projectId");

  params.projectId; // <-- TypeScript now knows this is a string
};

invariant 失敗時,您可能會對拋出像這樣的錯誤感到不自在,但請記住,在 Remix 中,您知道使用者最終會進入 錯誤邊界,他們可以在那裡從問題中恢復,而不是顯示損壞的 UI。

外部 API

Remix 會在您的伺服器上 polyfill fetch API,因此從現有的 JSON API 提取資料非常容易。您可以從 loader (在伺服器上) 進行 fetch,並讓 Remix 處理剩下的事情,而無需自己管理狀態、錯誤、競爭條件等等。

import { json } from "@remix-run/node"; // or cloudflare/deno
import { useLoaderData } from "@remix-run/react";

export async function loader() {
  const res = await fetch("https://api.github.com/gists");
  return json(await res.json());
}

export default function GistsRoute() {
  const gists = useLoaderData<typeof loader>();
  return (
    <ul>
      {gists.map((gist) => (
        <li key={gist.id}>
          <a href={gist.html_url}>{gist.id}</a>
        </li>
      ))}
    </ul>
  );
}

當您已經有一個可用的 API,並且不在意或不需要直接連接到 Remix 應用程式中的資料來源時,這非常棒。

資料庫

由於 Remix 在您的伺服器上執行,您可以直接在您的路由模組中連接到資料庫。例如,您可以使用 Prisma 連接到 Postgres 資料庫。

import { PrismaClient } from "@prisma/client";
const db = new PrismaClient();
export { db };

然後您的路由可以導入它並對其進行查詢

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno
import { useLoaderData } from "@remix-run/react";

import { db } from "~/db.server";

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  return json(
    await db.product.findMany({
      where: {
        categoryId: params.categoryId,
      },
    })
  );
};

export default function ProductCategory() {
  const products = useLoaderData<typeof loader>();
  return (
    <div>
      <p>{products.length} Products</p>
      {/* ... */}
    </div>
  );
}

如果您正在使用 TypeScript,您可以在呼叫 useLoaderData 時使用型別推論來使用 Prisma Client 產生的型別。這可以讓您在編寫使用載入資料的程式碼時獲得更好的型別安全性和智能提示。

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno
import { useLoaderData } from "@remix-run/react";

import { db } from "~/db.server";

async function getLoaderData(productId: string) {
  const product = await db.product.findUnique({
    where: {
      id: productId,
    },
    select: {
      id: true,
      name: true,
      imgSrc: true,
    },
  });

  return product;
}

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  return json(await getLoaderData(params.productId));
};

export default function Product() {
  const product = useLoaderData<typeof loader>();
  return (
    <div>
      <p>Product {product.id}</p>
      {/* ... */}
    </div>
  );
}

Cloudflare KV

如果您選擇 Cloudflare Pages 或 Workers 作為您的環境,Cloudflare Key Value 儲存允許您將資料持久化在邊緣,就像它是靜態資源一樣。

對於 Pages,要開始本地開發,您需要將一個具有命名空間名稱的 --kv 參數添加到 package.json 任務中,它看起來會像這樣

"dev:wrangler": "cross-env NODE_ENV=development wrangler pages dev ./public --kv PRODUCTS_KV"

對於 Cloudflare Workers 環境,您需要進行一些其他設定

這使您可以在 loader 上下文中使用 PRODUCTS_KV (KV 儲存會由 Cloudflare Pages 適配器自動添加到 loader 上下文)

import type { LoaderFunctionArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import { useLoaderData } from "@remix-run/react";

export const loader = async ({
  context,
  params,
}: LoaderFunctionArgs) => {
  return json(
    await context.PRODUCTS_KV.get(
      `product-${params.productId}`,
      { type: "json" }
    )
  );
};

export default function Product() {
  const product = useLoaderData<typeof loader>();
  return (
    <div>
      <p>Product</p>
      {product.name}
    </div>
  );
}

找不到

在載入資料時,記錄「找不到」是很常見的。一旦您知道無法按預期渲染元件,請 throw 一個響應,Remix 將停止在當前 loader 中執行程式碼,並切換到最近的 錯誤邊界

export const loader = async ({
  params,
  request,
}: LoaderFunctionArgs) => {
  const product = await db.product.findOne({
    where: { id: params.productId },
  });

  if (!product) {
    // we know we can't render the component
    // so throw immediately to stop executing code
    // and show the not found page
    throw new Response("Not Found", { status: 404 });
  }

  const cart = await getCart(request);
  return json({
    product,
    inCart: cart.includes(product.id),
  });
};

URL 搜尋參數

URL 搜尋參數是 URL 中 ? 之後的部分。其他名稱包括「查詢字串」、「搜尋字串」或「位置搜尋」。您可以透過從 request.url 建立 URL 來存取這些值

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno

export const loader = async ({
  request,
}: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const term = url.searchParams.get("term");
  return json(await fakeProductSearch(term));
};

這裡有一些 Web 平台類型在起作用

  • request 物件具有 url 屬性
  • URL 建構子,它將 URL 字串解析為物件
  • url.searchParamsURLSearchParams 的實例,它是位置搜尋字串的已解析版本,可以輕鬆讀取和操作搜尋字串

給定以下 URL,搜尋參數將按如下方式解析

URL url.searchParams.get("term")
/products?term=stretchy+pants "stretchy pants"
/products?term= ""
/products null

資料重新載入

當多個巢狀路由正在渲染,且搜尋參數變更時,所有路由都將重新載入(而不僅僅是新的或已變更的路由)。這是因為搜尋參數是一個跨領域的問題,可能會影響任何 loader。如果您想防止某些路由在這種情況下重新載入,請使用 shouldRevalidate

元件中的搜尋參數

有時您需要從元件中讀取和變更搜尋參數,而不是從 loader 和 action 中讀取和變更。根據您的使用案例,有幾種方法可以執行此操作。

設定搜尋參數

設定搜尋參數最常見的方法可能是讓使用者使用表單來控制它們

export default function ProductFilters() {
  return (
    <Form method="get">
      <label htmlFor="nike">Nike</label>
      <input
        type="checkbox"
        id="nike"
        name="brand"
        value="nike"
      />

      <label htmlFor="adidas">Adidas</label>
      <input
        type="checkbox"
        id="adidas"
        name="brand"
        value="adidas"
      />

      <button type="submit">Update</button>
    </Form>
  );
}

如果使用者只選擇一個

  • Nike
  • Adidas

那麼 URL 將會是 /products/shoes?brand=nike

如果使用者選擇了兩個

  • Nike
  • Adidas

那麼 URL 將會是:/products/shoes?brand=nike&brand=adidas

請注意,由於兩個複選框都命名為 "brand",因此 brand 在 URL 搜尋字串中重複出現。在您的 loader 中,您可以使用 searchParams.getAll 存取所有這些值

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno

export async function loader({
  request,
}: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const brands = url.searchParams.getAll("brand");
  return json(await getProducts({ brands }));
}

連結到搜尋參數

作為開發人員,您可以透過連結到帶有搜尋字串的 URL 來控制搜尋參數。連結會將 URL 中目前的搜尋字串(如果有的話)替換為連結中的內容

<Link to="?brand=nike">Nike (only)</Link>

在元件中讀取搜尋參數

除了在 loader 中讀取搜尋參數之外,您通常也需要在元件中存取它們

import { useSearchParams } from "@remix-run/react";

export default function ProductFilters() {
  const [searchParams] = useSearchParams();
  const brands = searchParams.getAll("brand");

  return (
    <Form method="get">
      <label htmlFor="nike">Nike</label>
      <input
        type="checkbox"
        id="nike"
        name="brand"
        value="nike"
        defaultChecked={brands.includes("nike")}
      />

      <label htmlFor="adidas">Adidas</label>
      <input
        type="checkbox"
        id="adidas"
        name="brand"
        value="adidas"
        defaultChecked={brands.includes("adidas")}
      />

      <button type="submit">Update</button>
    </Form>
  );
}

您可能希望在任何欄位變更時自動提交表單,為此可以使用 useSubmit

import {
  useSubmit,
  useSearchParams,
} from "@remix-run/react";

export default function ProductFilters() {
  const submit = useSubmit();
  const [searchParams] = useSearchParams();
  const brands = searchParams.getAll("brand");

  return (
    <Form
      method="get"
      onChange={(e) => submit(e.currentTarget)}
    >
      {/* ... */}
    </Form>
  );
}

命令式地設定搜尋參數

雖然不常見,但您也可以隨時基於任何原因命令式地設定搜尋參數。這裡的使用案例很少,少到我們甚至想不出一個好的案例,但這裡有一個愚蠢的例子

import { useSearchParams } from "@remix-run/react";

export default function ProductFilters() {
  const [searchParams, setSearchParams] = useSearchParams();

  useEffect(() => {
    const id = setInterval(() => {
      setSearchParams({ now: Date.now() });
    }, 1000);
    return () => clearInterval(id);
  }, [setSearchParams]);

  // ...
}

搜尋參數和受控輸入

通常,您希望保持某些輸入(例如複選框)與 URL 中的搜尋參數同步。這可能會因為 React 的受控元件概念而變得有點棘手。

只有當搜尋參數可以透過兩種方式設定時才需要這樣做,並且我們希望輸入與搜尋參數保持同步。例如,<input type="checkbox">Link 都可以變更此元件中的品牌

import { useSearchParams } from "@remix-run/react";

export default function ProductFilters() {
  const [searchParams] = useSearchParams();
  const brands = searchParams.getAll("brand");

  return (
    <Form method="get">
      <p>
        <label htmlFor="nike">Nike</label>
        <input
          type="checkbox"
          id="nike"
          name="brand"
          value="nike"
          defaultChecked={brands.includes("nike")}
        />
        <Link to="?brand=nike">(only)</Link>
      </p>

      <button type="submit">Update</button>
    </Form>
  );
}

如果使用者點擊複選框並提交表單,則 URL 會更新,複選框狀態也會變更。但是如果使用者點擊連結,只有 URL 會更新,而複選框不會。這不是我們想要的。您可能熟悉 React 的受控元件,並認為應該將其切換為 checked 而不是 defaultChecked

<input
  type="checkbox"
  id="adidas"
  name="brand"
  value="adidas"
  checked={brands.includes("adidas")}
/>

現在我們遇到了相反的問題:點擊連結會更新 URL 和複選框狀態,但是複選框不再起作用,因為 React 會阻止狀態變更,直到控制它的 URL 變更——而它永遠不會變更,因為我們無法變更複選框並重新提交表單。

React 希望您使用一些狀態來控制它,但是我們希望使用者在提交表單之前控制它,然後我們希望 URL 在變更時控制它。因此,我們處於這種「半受控」狀態。

您有兩種選擇,您的選擇取決於您想要的使用者體驗。

第一種選擇:最簡單的方法是在使用者點擊複選框時自動提交表單

import {
  useSubmit,
  useSearchParams,
} from "@remix-run/react";

export default function ProductFilters() {
  const submit = useSubmit();
  const [searchParams] = useSearchParams();
  const brands = searchParams.getAll("brand");

  return (
    <Form method="get">
      <p>
        <label htmlFor="nike">Nike</label>
        <input
          type="checkbox"
          id="nike"
          name="brand"
          value="nike"
          onChange={(e) => submit(e.currentTarget.form)}
          checked={brands.includes("nike")}
        />
        <Link to="?brand=nike">(only)</Link>
      </p>

      {/* ... */}
    </Form>
  );
}

(如果您也在表單 onChange 時自動提交,請務必 e.stopPropagation(),這樣事件就不會冒泡到表單,否則您每次點擊複選框都會獲得雙重提交。)

第二種選擇:如果您希望輸入是「半受控的」,複選框反映 URL 狀態,但使用者也可以在提交表單和變更 URL 之前開啟和關閉它,則需要接線一些狀態。這有點工作量,但很簡單

  • 從搜尋參數初始化一些狀態
  • 當使用者點擊複選框時更新狀態,以便框變更為「已選取」
  • 當搜尋參數變更時(使用者提交了表單或點擊了連結)更新狀態,以反映 URL 搜尋參數中的內容
import {
  useSubmit,
  useSearchParams,
} from "@remix-run/react";

export default function ProductFilters() {
  const submit = useSubmit();
  const [searchParams] = useSearchParams();
  const brands = searchParams.getAll("brand");

  const [nikeChecked, setNikeChecked] = React.useState(
    // initialize from the URL
    brands.includes("nike")
  );

  // Update the state when the params change
  // (form submission or link click)
  React.useEffect(() => {
    setNikeChecked(brands.includes("nike"));
  }, [brands, searchParams]);

  return (
    <Form method="get">
      <p>
        <label htmlFor="nike">Nike</label>
        <input
          type="checkbox"
          id="nike"
          name="brand"
          value="nike"
          onChange={(e) => {
            // update checkbox state w/o submitting the form
            setNikeChecked(true);
          }}
          checked={nikeChecked}
        />
        <Link to="?brand=nike">(only)</Link>
      </p>

      {/* ... */}
    </Form>
  );
}

您可能想要為像這樣的複選框建立一個抽象概念

<div>
  <SearchCheckbox name="brand" value="nike" />
  <SearchCheckbox name="brand" value="reebok" />
  <SearchCheckbox name="brand" value="adidas" />
</div>;

function SearchCheckbox({ name, value }) {
  const [searchParams] = useSearchParams();
  const paramsIncludeValue = searchParams
    .getAll(name)
    .includes(value);
  const [checked, setChecked] = React.useState(
    paramsIncludeValue
  );

  React.useEffect(() => {
    setChecked(paramsIncludeValue);
  }, [paramsIncludeValue]);

  return (
    <input
      type="checkbox"
      name={name}
      value={value}
      checked={checked}
      onChange={(e) => setChecked(e.target.checked)}
    />
  );
}

選項 3:我們說只有兩個選項,但是如果對 React 非常熟悉,您可能會被第三個不神聖的選項所誘惑。您可能想要使用 key 屬性技巧來清除輸入並重新安裝它。雖然很聰明,但這會導致協助工具問題,因為使用者在點擊它之後,React 從文件中移除節點時會失去焦點。

不要這樣做,這會導致協助工具問題

<input
  type="checkbox"
  id="adidas"
  name="brand"
  value="adidas"
  key={"adidas" + brands.includes("adidas")}
  defaultChecked={brands.includes("adidas")}
/>

Remix 優化

Remix 會透過僅載入導覽時頁面中正在變更的部分的資料來優化使用者體驗。例如,考慮您現在在這些文件中使用的 UI。側邊的導覽列位於一個父路由中,該父路由提取了所有文件的動態生成選單,而子路由提取了您現在正在閱讀的文件。如果您點擊側邊欄中的連結,Remix 知道父路由將保留在頁面上 - 但子路由的資料將會變更,因為文件的 URL 參數會變更。透過這個見解,Remix 將不會重新提取父路由的資料

如果沒有 Remix,下一個問題是「我該如何重新載入所有資料?」。這也內建在 Remix 中。每當呼叫 action 時(使用者提交了表單,或者您,程式設計師,從 useSubmit 呼叫了 submit),Remix 會自動重新載入頁面上的所有路由,以擷取可能發生的任何變更。

您不必擔心快取過期或在使用者與您的應用程式互動時過度提取資料,這一切都是自動的。

在以下三種情況下,Remix 會重新載入您所有的路由:

  • 在執行動作之後(表單、useSubmitfetcher.submit
  • 如果 URL 搜尋參數變更(任何 loader 都可以使用它們)
  • 使用者點擊連結到他們已在的完全相同的 URL(這也會替換歷史堆疊中的目前條目)

所有這些行為都在模擬瀏覽器的預設行為。在這些情況下,Remix 對於您的程式碼沒有足夠的了解來優化資料載入,但您可以使用 shouldRevalidate 自行優化它。

資料庫

由於 Remix 的資料慣例和巢狀路由,您通常會發現您不需要使用 React Query、SWR、Apollo、Relay、urql 等客戶端資料庫。如果您主要使用 Redux 等全域狀態管理庫與伺服器上的資料互動,您也可能不需要這些。

當然,Remix 並不阻止您使用它們(除非它們需要打包工具整合)。您可以攜帶任何您喜歡的 React 資料庫,並在您認為它們比 Remix API 更能服務您的 UI 的地方使用它們。在某些情況下,您可以使用 Remix 進行初始伺服器渲染,然後在後續互動中切換到您最喜歡的庫。

也就是說,如果您引入外部資料庫並繞過 Remix 自己的資料慣例,Remix 將無法自動:

  • 伺服器端渲染您的頁面
  • 在 JavaScript 無法載入時,能夠適應網路狀況
  • 在使用者與您的網站互動時進行優化,使其快速,僅載入頁面變更部分的資料
  • 在轉換時並行獲取資料、JavaScript 模組、CSS 和其他資源,避免渲染 + 獲取瀑布式流程,從而導致不流暢的 UI
  • 透過在動作後重新驗證,確保 UI 中的資料與伺服器上的資料同步。
  • 在返回/前進點擊時(甚至跨網域)提供出色的滾動恢復
  • 使用錯誤邊界處理伺服器端錯誤
  • 使用錯誤邊界為「找不到」和「未授權」啟用可靠的 UX
  • 幫助您保持 UI 正常運作。

相反,您需要額外的工作來提供良好的使用者體驗。

Remix 的設計旨在滿足您可以設計的任何使用者體驗。雖然您需要外部資料庫是意料之外的事情,但您可能仍然想要一個,這沒關係!

當您學習 Remix 時,您會發現自己從考慮客戶端狀態轉變為考慮 URL,並且當您這樣做時,您會免費獲得許多東西。

注意事項

Loaders 只會在伺服器上透過瀏覽器的 fetch 呼叫,因此您的資料會使用 JSON.stringify 序列化並透過網路傳送,然後才會到達您的元件。這表示您的資料需要可序列化。例如:

這不會工作!

export async function loader() {
  return {
    date: new Date(),
    someMethod() {
      return "hello!";
    },
  };
}

export default function RouteComp() {
  const data = useLoaderData<typeof loader>();
  console.log(data);
  // '{"date":"2021-11-27T23:54:26.384Z"}'
}

並非所有東西都可以傳遞!Loaders 用於資料,而資料需要可序列化。

某些資料庫(如 FaunaDB)會傳回帶有方法的物件,您需要小心地在從 loader 傳回之前序列化它們。通常這不是問題,但了解您的資料正在透過網路傳輸是很重要的。

此外,Remix 會為您呼叫您的 loaders,在任何情況下您都不應該嘗試直接呼叫您的 loader。

這不會工作

export const loader = async () => {
  return json(await fakeDb.products.findMany());
};

export default function RouteComp() {
  const data = loader();
  // ...
}
文件和範例授權於 MIT