Remix 工具

這個套件包含一些簡單的實用函式,可與 React Router 一起使用。

安裝

npm install remix-utils

可能需要其他可選的相依性,所有可選的相依性為

  • react-router
  • @oslojs/crypto
  • @oslojs/encoding
  • is-ip
  • intl-parse-accept-language
  • react
  • zod

需要額外可選相依性的工具會在它們的文件中提及。

如果要全部安裝,請執行

npm add @oslojs/crypto @oslojs/encoding is-ip intl-parse-accept-language zod

React 和 React Router 套件應該已經安裝在您的專案中。

從 Remix 工具 v6 升級

請查看 v6 到 v7 升級指南

API 參考

promiseHash

promiseHash 函式與 Remix 沒有直接關係,但在使用 loaders 和 actions 時,它是個有用的函式。

這個函式是 Promise.all 的物件版本,可讓您傳遞帶有 Promise 的物件,並取得具有相同鍵且包含已解析值的物件。

import { promiseHash } from "remix-utils/promise";

export async function loader({ request }: LoaderFunctionArgs) {
  return json(
    await promiseHash({
      user: getUser(request),
      posts: getPosts(request),
    })
  );
}

您可以使用巢狀的 promiseHash 來取得包含已解析值的巢狀物件。

import { promiseHash } from "remix-utils/promise";

export async function loader({ request }: LoaderFunctionArgs) {
  return json(
    await promiseHash({
      user: getUser(request),
      posts: promiseHash({
        list: getPosts(request),
        comments: promiseHash({
          list: getComments(request),
          likes: getLikes(request),
        }),
      }),
    })
  );
}

timeout

timeout 函式可讓您為任何 Promise 附加逾時時間,如果 Promise 在逾時時間之前沒有解析或拒絕,則會使用 TimeoutError 拒絕。

import { timeout } from "remix-utils/promise";

try {
  let result = await timeout(fetch("https://example.com"), { ms: 100 });
} catch (error) {
  if (error instanceof TimeoutError) {
    // Handle timeout
  }
}

在這裡,提取需要在 100 毫秒內完成,否則會擲回 TimeoutError

如果 Promise 可以使用 AbortSignal 取消,則您可以將 AbortController 傳遞給 timeout 函式。

import { timeout } from "remix-utils/promise";

try {
  let controller = new AbortController();
  let result = await timeout(
    fetch("https://example.com", { signal: controller.signal }),
    { ms: 100, controller }
  );
} catch (error) {
  if (error instanceof TimeoutError) {
    // Handle timeout
  }
}

在這裡,經過 100 毫秒後,timeout 將呼叫 controller.abort(),這會將 controller.signal 標記為中止。

cacheAssets

注意 這只能在 entry.client 內執行。

此函式可讓您輕鬆地將 Remix 建立的每個 JS 檔案快取到瀏覽器的快取儲存中。

要使用它,請開啟您的 entry.client 檔案並新增此項

import { cacheAssets } from "remix-utils/cache-assets";

cacheAssets().catch((error) => {
  // do something with the error, or not
});

此函式接收一個可選的選項物件,其中包含兩個選項

  • cacheName 是要使用的 Cache 物件 的名稱,預設值為 assets
  • buildPath 是所有 Remix 建立的資產的路徑名稱前置詞,預設值為 /build/,這也是 Remix 本身的預設建立路徑。

重要的是,如果您在 remix.config.js 中變更了您的建立路徑,則您必須將相同的值傳遞給 cacheAssets,否則它將找不到您的 JS 檔案。

cacheName 可以保持不變,除非您要將 Service Worker 新增到您的應用程式中,並想要共用快取。

import { cacheAssets } from "remix-utils/cache-assets";

cacheAssests({ cacheName: "assets", buildPath: "/build/" }).catch((error) => {
  // do something with the error, or not
});

ClientOnly

注意 這取決於 react

ClientOnly 元件可讓您僅在用戶端上轉譯子元素,避免在伺服器端轉譯它。

您可以提供一個在 SSR 上使用的回退元件,雖然是可選的,但強烈建議您提供一個,以避免內容版面配置位移問題。

import { ClientOnly } from "remix-utils/client-only";

export default function Component() {
  return (
    <ClientOnly fallback={<SimplerStaticVersion />}>
      {() => <ComplexComponentNeedingBrowserEnvironment />}
    </ClientOnly>
  );
}

當您有一些需要瀏覽器環境才能運作的複雜元件(例如圖表或地圖)時,此元件非常方便。這樣,您可以避免在伺服器端轉譯它,而是使用更簡單的靜態版本,例如 SVG 甚至是載入 UI。

轉譯流程將會是

  • SSR:一律轉譯回退。
  • CSR 首次轉譯:一律轉譯回退。
  • CSR 更新:更新以轉譯實際元件。
  • CSR 未來轉譯:一律轉譯實際元件,無需轉譯回退。

此元件在內部使用 useHydrated hook。

ServerOnly

注意 這取決於 react

ServerOnly 元件與 ClientOnly 元件相反,它可讓您僅在伺服器端轉譯子元素,避免在用戶端轉譯它。

您可以提供一個在 CSR 上使用的回退元件,雖然是可選的,但強烈建議您提供一個,以避免內容版面配置位移問題,除非您僅轉譯視覺上隱藏的元素。

import { ServerOnly } from "remix-utils/server-only";

export default function Component() {
  return (
    <ServerOnly fallback={<ComplexComponentNeedingBrowserEnvironment />}>
      {() => <SimplerStaticVersion />}
    </ServerOnly>
  );
}

此元件很方便,可用於僅在伺服器端轉譯某些內容,例如稍後可用於知道是否已載入 JS 的隱藏輸入。

將它視為 <noscript> HTML 標籤,即使 JS 無法載入但在瀏覽器上啟用,它仍然可以運作。

轉譯流程將會是

  • SSR:一律轉譯子元素。
  • CSR 首次轉譯:一律轉譯子元素。
  • CSR 更新:更新以轉譯回退元件(如果已定義)。
  • CSR 未來轉譯:一律轉譯回退元件,無需轉譯子元素。

此元件在內部使用 useHydrated hook。

CORS

CORS 函式可讓您在您的 loaders 和 actions 上實作 CORS 標頭,以便您可以將它們當作其他用戶端應用程式的 API 使用。

使用 cors 函式主要有兩種方式。

  1. 在每個您想要啟用它的 loader/action 上使用它。
  2. 在 entry.server handleRequest 和 handleDataRequest 匯出上全域使用它。

如果您想在每個 loader/action 上使用它,您可以這樣做

import { cors } from "remix-utils/cors";

export async function loader({ request }: LoaderFunctionArgs) {
  let data = await getData(request);
  let response = json<LoaderData>(data);
  return await cors(request, response);
}

您也可以在一行中執行 jsoncors 呼叫。

import { cors } from "remix-utils/cors";

export async function loader({ request }: LoaderFunctionArgs) {
  let data = await getData(request);
  return await cors(request, json<LoaderData>(data));
}

而且因為 cors 會改變回應,您也可以呼叫它,稍後再傳回。

import { cors } from "remix-utils/cors";

export async function loader({ request }: LoaderFunctionArgs) {
  let data = await getData(request);
  let response = json<LoaderData>(data);
  await cors(request, response); // this mutates the Response object
  return response; // so you can return it here
}

如果您想全域設定一次,您可以像這樣在 entry.server 中執行

import { cors } from "remix-utils/cors";

const ABORT_DELAY = 5000;

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  let callbackName = isbot(request.headers.get("user-agent"))
    ? "onAllReady"
    : "onShellReady";

  return new Promise((resolve, reject) => {
    let didError = false;

    let { pipe, abort } = renderToPipeableStream(
      <RemixServer context={remixContext} url={request.url} />,
      {
        [callbackName]: () => {
          let body = new PassThrough();

          responseHeaders.set("Content-Type", "text/html");

          cors(
            request,
            new Response(body, {
              headers: responseHeaders,
              status: didError ? 500 : responseStatusCode,
            })
          ).then((response) => {
            resolve(response);
          });

          pipe(body);
        },
        onShellError: (err: unknown) => {
          reject(err);
        },
        onError: (error: unknown) => {
          didError = true;

          console.error(error);
        },
      }
    );

    setTimeout(abort, ABORT_DELAY);
  });
}

export let handleDataRequest: HandleDataRequestFunction = async (
  response,
  { request }
) => {
  return await cors(request, response);
};

選項

此外,cors 函式接受一個 options 物件作為第三個可選引數。這些是選項。

  • origin:設定 Access-Control-Allow-Origin CORS 標頭。可能的值有
    • true:為任何來源啟用 CORS(與 "*" 相同)
    • false:不設定 CORS
    • string:設定為特定來源,如果設定為 "*",則允許任何來源
    • RegExp:設定為與來源比對的 RegExp
    • Array<string | RegExp>:設定為與字串或 RegExp 比對的來源陣列
    • Function:設定為將使用請求來源呼叫的函式,並應傳回一個布林值,表示是否允許該來源。預設值為 true
  • methods:設定 Access-Control-Allow-Methods CORS 標頭。預設值為 ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"]
  • allowedHeaders:設定 Access-Control-Allow-Headers CORS 標頭。
  • exposedHeaders:設定 Access-Control-Expose-Headers CORS 標頭。
  • credentials:設定 Access-Control-Allow-Credentials CORS 標頭。
  • maxAge:設定 Access-Control-Max-Age CORS 標頭。

CSRF

注意 這取決於 react@oslojs/crypto@oslojs/encoding 和 React Router。

CSRF 相關函式可讓您在您的應用程式上實作 CSRF 保護。

Remix 工具的這部分需要 React 和伺服器端程式碼。

首先建立新的 CSRF 執行個體。

// app/utils/csrf.server.ts
import { CSRF } from "remix-utils/csrf/server";
import { createCookie } from "react-router"; // or cloudflare/deno

export const cookie = createCookie("csrf", {
  path: "/",
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "lax",
  secrets: ["s3cr3t"],
});

export const csrf = new CSRF({
  cookie,
  // what key in FormData objects will be used for the token, defaults to `csrf`
  formDataKey: "csrf",
  // an optional secret used to sign the token, recommended for extra safety
  secret: "s3cr3t",
});

然後您可以使用 csrf 來產生新的權杖。

import { csrf } from "~/utils/csrf.server";

export async function loader({ request }: LoaderFunctionArgs) {
  let token = csrf.generate();
}

您可以透過傳遞位元組大小來自訂權杖大小,預設大小為 32 個位元組,這會在編碼後產生一個長度為 43 的字串。

let token = csrf.generate(64); // customize token length

您需要將此權杖儲存在 Cookie 中,並從載入器傳回它。為了方便起見,您可以使用 CSRF#commitToken 協助程式。

import { csrf } from "~/utils/csrf.server";

export async function loader({ request }: LoaderFunctionArgs) {
  let [token, cookieHeader] = await csrf.commitToken();
  return json({ token }, { headers: { "set-cookie": cookieHeader } });
}

注意 您可以在任何路由上執行此操作,但我建議您在 root 載入器上執行。

現在您已傳回權杖並將其設定在 Cookie 中,您可以使用 AuthenticityTokenProvider 元件將權杖提供給您的 React 元件。

import { AuthenticityTokenProvider } from "remix-utils/csrf/react";

let { csrf } = useLoaderData<LoaderData>();
return (
  <AuthenticityTokenProvider token={csrf}>
    <Outlet />
  </AuthenticityTokenProvider>
);

在您的 root 元件中轉譯它,並使用它包裝 Outlet

當您在某些路由中建立表單時,您可以使用 AuthenticityTokenInput 元件將真實性權杖新增至表單。

import { Form } from "react-router";
import { AuthenticityTokenInput } from "remix-utils/csrf/react";

export default function Component() {
  return (
    <Form method="post">
      <AuthenticityTokenInput />
      <input type="text" name="something" />
    </Form>
  );
}

請注意,真實性權杖僅在會以某種方式變更資料的表單中真正需要。如果您有一個發出 GET 請求的搜尋表單,則無需在此處新增真實性權杖。

AuthenticityTokenInput 將從 AuthenticityTokenProvider 元件取得真實性權杖,並將其新增至表單,作為名稱為 csrf 的隱藏輸入值。您可以使用 name prop 自訂欄位名稱。

<AuthenticityTokenInput name="customName" />

如果也在 createAuthenticityToken 上變更了名稱,您才應自訂名稱。

如果需要使用 useFetcher(或 useSubmit)而不是 Form,您也可以使用 useAuthenticityToken hook 來取得真實性權杖。

import { useFetcher } from "react-router";
import { useAuthenticityToken } from "remix-utils/csrf/react";

export function useMarkAsRead() {
  let fetcher = useFetcher();
  let csrf = useAuthenticityToken();
  return function submit(data) {
    fetcher.submit(
      { csrf, ...data },
      { action: "/api/mark-as-read", method: "post" }
    );
  };
}

最後,您需要在接收請求的動作中驗證真實性權杖。

import { CSRFError } from "remix-utils/csrf/server";
import { redirectBack } from "remix-utils/redirect-back";
import { csrf } from "~/utils/csrf.server";

export async function action({ request }: ActionFunctionArgs) {
  try {
    await csrf.validate(request);
  } catch (error) {
    if (error instanceof CSRFError) {
      // handle CSRF errors
    }
    // handle other possible errors
  }

  // here you know the request is valid
  return redirectBack(request, { fallback: "/fallback" });
}

如果您需要自行將主體剖析為 FormData(例如,為了支援檔案上傳),您也可以使用 FormData 和 Headers 物件呼叫 CSRF#validate

let formData = await parseMultiPartFormData(request);
try {
  await csrf.validate(formData, request.headers);
} catch (error) {
  // handle errors
}

警告 如果您使用請求執行個體呼叫 CSRF#validate,但您已讀取其主體,則會擲回錯誤。

如果 CSRF 驗證失敗,則會擲回 CSRFError,可用於正確識別其與可能擲回的其他錯誤。

可能的錯誤訊息清單如下

  • missing_token_in_cookie:請求遺失 Cookie 中的 CSRF 權杖。
  • invalid_token_in_cookie:CSRF 權杖無效(不是字串)。
  • tampered_token_in_cookie:CSRF 權杖與簽名不符。
  • missing_token_in_body:請求遺失主體 (FormData) 中的 CSRF 權杖。
  • mismatched_token:Cookie 中的 CSRF 權杖與主體中的權杖不符。

您可以使用 error.code 來檢查上述其中一個錯誤碼,並使用 error.message 來取得使用者友好的描述。

警告:請勿將這些錯誤訊息傳送給終端使用者,它們僅供除錯之用。

現有的搜尋參數

import { ExistingSearchParams } from "remix-utils/existing-search-params";

注意:此功能相依於 reactreact-router

當您提交 GET 表單時,瀏覽器會將 URL 中的所有搜尋參數替換為您的表單資料。此元件會將現有的搜尋參數複製到隱藏的輸入欄位中,使其不會被覆寫。

exclude 屬性接受一個搜尋參數陣列,以將其從隱藏的輸入欄位中排除。

  • 將此表單處理的參數新增至此列表。
  • 將您希望在提交時清除的其他表單的參數新增至此列表。

例如,想像一個包含分頁、篩選和搜尋等獨立表單元件的資料表。變更頁碼不應影響搜尋或篩選參數。

<Form>
  <ExistingSearchParams exclude={["page"]} />
  <button type="submit" name="page" value="1">
    1
  </button>
  <button type="submit" name="page" value="2">
    2
  </button>
  <button type="submit" name="page" value="3">
    3
  </button>
</Form>

透過從搜尋表單中排除 page 參數,使用者將返回第一個搜尋結果頁面。

<Form>
  <ExistingSearchParams exclude={["q", "page"]} />
  <input type="search" name="q" />
  <button type="submit">Search</button>
</Form>

外部腳本

注意:此功能相依於 reactreact-router

如果需要在特定路由上載入不同的外部腳本,可以使用 ExternalScripts 元件以及 ExternalScriptsFunctionScriptDescriptor 類型。

在您要載入腳本的路由中,新增一個帶有 scripts 方法的 handle 導出,將 handle 的類型設為 ExternalScriptsHandle。此介面可讓您將 scripts 定義為函式或陣列。

如果您想根據載入器資料定義要載入的腳本,可以使用函式形式的 scripts

import { ExternalScriptsHandle } from "remix-utils/external-scripts";

type LoaderData = SerializeFrom<typeof loader>;

export let handle: ExternalScriptsHandle<LoaderData> = {
  scripts({ id, data, params, matches, location, parentsData }) {
    return [
      {
        src: "https://unpkg.com/htmx.org@1.9.6",
        integrity: "sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni",
        crossOrigin: 'anonymous"
      }
    ];
  },
};

如果要載入的腳本列表是靜態的,您可以直接將 scripts 定義為陣列。

import { ExternalScriptsHandle } from "remix-utils/external-scripts";

export let handle: ExternalScriptsHandle = {
  scripts: [
    {
      src: "https://unpkg.com/htmx.org@1.9.6",
      integrity: "sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni",
      crossOrigin: 'anonymous",
      preload: true, // use it to render a <link rel="preload"> for this script
    }
  ],
};

您也可以自行導入 ExternalScriptsFunctionScriptDescriptor 介面,以建構自訂的 handle 類型。

import {
  ExternalScriptsFunction,
  ScriptDescriptor,
} from "remix-utils/external-scripts";

interface AppHandle<LoaderData = unknown> {
  scripts?: ExternalScriptsFunction<LoaderData> | ScriptDescriptor[];
}

export let handle: AppHandle<LoaderData> = {
  scripts, // define scripts as a function or array here
};

或者您可以擴展 ExternalScriptsHandle 介面。

import { ExternalScriptsHandle } from "remix-utils/external-scripts";

interface AppHandle<LoaderData = unknown>
  extends ExternalScriptsHandle<LoaderData> {
  // more handle properties here
}

export let handle: AppHandle<LoaderData> = {
  scripts, // define scripts as a function or array here
};

然後,在根路由中,將 ExternalScripts 元件新增到某個位置,通常您會將其載入到 <head> 內部或 <body> 的底部,在 Remix 的 <Scripts> 元件之前或之後皆可。

<ExternalScripts /> 的確切位置將取決於您的應用程式,但一個安全的位置是 <body> 的末尾。

import { Links, LiveReload, Meta, Scripts, ScrollRestoration } from "remix";
import { ExternalScripts } from "remix-utils/external-scripts";

type Props = { children: React.ReactNode; title?: string };

export function Document({ children, title }: Props) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        {title ? <title>{title}</title> : null}
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <ExternalScripts />
        <Scripts />
        <LiveReload />
      </body>
    </html>
  );
}

現在,您在 ScriptsFunction 中定義的任何腳本都將被新增到 HTML 中。

您可以將此工具與 useShouldHydrate 結合使用,以在某些路由中停用 Remix 腳本,但仍然為分析或需要 JS 但不需要啟用完整應用程式 JS 的小型功能載入腳本。

let shouldHydrate = useShouldHydrate();

return (
  <html lang="en">
    <head>
      <meta charSet="utf-8" />
      <meta name="viewport" content="width=device-width,initial-scale=1" />
      {title ? <title>{title}</title> : null}
      <Meta />
      <Links />
    </head>
    <body>
      {children}
      <ScrollRestoration />
      {shouldHydrate ? <Scripts /> : <ExternalScripts />}
      <LiveReload />
    </body>
  </html>
);

useGlobalNavigationState

注意:此功能相依於 reactreact-router

這個 Hook 讓您可以讀取 transition.state、應用程式中每個 fetcher.staterevalidator.state 的值。

import { useGlobalNavigationState } from "remix-utils/use-global-navigation-state";

export function GlobalPendingUI() {
  let states = useGlobalNavigationState();

  if (state.includes("loading")) {
    // The app is loading.
  }

  if (state.includes("submitting")) {
    // The app is submitting.
  }

  // The app is idle
}

useGlobalNavigationState 的傳回值可以是 "idle""loading""submitting"

注意:以下 Hook 使用此值來判斷應用程式是否正在載入、提交或兩者(pending)。

useGlobalPendingState

注意:此功能相依於 reactreact-router

此 Hook 讓您知道全域導覽、其中一個使用中的 fetcher 是否正在載入或提交,或者 revalidator 是否正在執行。

import { useGlobalPendingState } from "remix-utils/use-global-navigation-state";

export function GlobalPendingUI() {
  let globalState = useGlobalPendingState();

  if (globalState === "idle") return null;
  return <Spinner />;
}

useGlobalPendingState 的傳回值是 "idle""pending"

注意:此 Hook 結合了 useGlobalSubmittingStateuseGlobalLoadingState Hook,以判斷應用程式是否處於 pending 狀態。

注意pending 狀態是此 Hook 引入的 loadingsubmitting 狀態的組合。

useGlobalSubmittingState

注意:此功能相依於 reactreact-router

此 Hook 讓您知道全域轉換或其中一個使用中的 fetcher 是否正在提交。

import { useGlobalSubmittingState } from "remix-utils/use-global-navigation-state";

export function GlobalPendingUI() {
  let globalState = useGlobalSubmittingState();

  if (globalState === "idle") return null;
  return <Spinner />;
}

useGlobalSubmittingState 的傳回值是 "idle""submitting"

useGlobalLoadingState

注意:此功能相依於 reactreact-router

此 Hook 讓您知道全域轉換、其中一個使用中的 fetcher 是否正在載入,或者 revalidator 是否正在執行。

import { useGlobalLoadingState } from "remix-utils/use-global-navigation-state";

export function GlobalPendingUI() {
  let globalState = useGlobalLoadingState();

  if (globalState === "idle") return null;
  return <Spinner />;
}

useGlobalLoadingState 的傳回值是 "idle""loading"

useHydrated

注意 這取決於 react

此 Hook 讓您偵測元件是否已完成水合。這表示元素的 JS 在用戶端載入,並且 React 正在執行。

使用 useHydrated,您可以在伺服器和用戶端上呈現不同的內容,同時確保水合不會出現不匹配的 HTML。

import { useHydrated } from "remix-utils/use-hydrated";

export function Component() {
  let isHydrated = useHydrated();

  if (isHydrated) {
    return <ClientOnlyComponent />;
  }

  return <ServerFallback />;
}

在執行 SSR 時,isHydrated 的值永遠為 false。第一個用戶端渲染 isHydrated 仍將為 false,然後會變為 true

在第一個用戶端渲染之後,未來呼叫此 Hook 渲染的元件將會收到 true 作為 isHydrated 的值。這樣,您的伺服器備用 UI 永遠不會在路由轉換時呈現。

useLocales

注意 這取決於 react

此 Hook 讓您可以取得根載入器傳回的地區設定。它遵循一個簡單的慣例,您的根載入器傳回值應該是一個具有鍵 locales 的物件。

您可以將其與 getClientLocal 結合使用,以在根載入器上取得地區設定並傳回該設定。useLocales 的傳回值是 Locales 類型,即 string | string[] | undefined

import { useLocales } from "remix-utils/locales/react";
import { getClientLocales } from "remix-utils/locales/server";

// in the root loader
export async function loader({ request }: LoaderFunctionArgs) {
  let locales = getClientLocales(request);
  return json({ locales });
}

// in any route (including root!)
export default function Component() {
  let locales = useLocales();
  let date = new Date();
  let dateTime = date.toISOString;
  let formattedDate = date.toLocaleDateString(locales, options);
  return <time dateTime={dateTime}>{formattedDate}</time>;
}

useLocales 的傳回類型已準備好與 Intl API 一起使用。

useShouldHydrate

注意:此功能相依於 react-routerreact

如果您正在建構一個大部分路由都是靜態的 Remix 應用程式,並且想要避免載入用戶端 JS,您可以使用此 Hook 加上一些慣例,以偵測一個或多個使用中的路由是否需要 JS,並且僅在該情況下渲染 Scripts 元件。

在您的文件元件中,您可以呼叫此 Hook 以在需要時動態渲染 Scripts 元件。

import type { ReactNode } from "react";
import { Links, LiveReload, Meta, Scripts } from "react-router";
import { useShouldHydrate } from "remix-utils/use-should-hydrate";

interface DocumentProps {
  children: ReactNode;
  title?: string;
}

export function Document({ children, title }: DocumentProps) {
  let shouldHydrate = useShouldHydrate();
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <link rel="icon" href="/favicon.png" type="image/png" />
        {title ? <title>{title}</title> : null}
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        {shouldHydrate && <Scripts />}
        <LiveReload />
      </body>
    </html>
  );
}

現在,您可以在任何路由模組中導出一個具有 hydrate 屬性為 truehandle 物件。

export let handle = { hydrate: true };

這將標記該路由需要 JS 水合。

在某些情況下,路由可能需要根據載入器傳回的資料來決定是否需要 JS。例如,如果您有一個購買產品的元件,但只有通過身份驗證的使用者才能看到它,則在使用者通過身份驗證之前,您不需要 JS。在這種情況下,您可以讓 hydrate 成為一個接收載入器資料的函式。

export let handle = {
  hydrate(data: LoaderData) {
    return data.user.isAuthenticated;
  },
};

useShouldHydrate Hook 將會偵測到 hydrate 是一個函式,並使用路由資料來呼叫它。

getClientIPAddress

注意:此功能相依於 is-ip

此函式接收 Request 或 Headers 物件,並會嘗試取得發出請求的用戶端(使用者)的 IP 位址。

import { getClientIPAddress } from "remix-utils/get-client-ip-address";

export async function loader({ request }: LoaderFunctionArgs) {
  // using the request
  let ipAddress = getClientIPAddress(request);
  // or using the headers
  let ipAddress = getClientIPAddress(request.headers);
}

如果找不到 IP 位址,傳回值將為 null。請記住在使用之前檢查是否已找到 IP 位址。

此函式會依優先順序使用以下標頭清單

  • X-Client-IP
  • X-Forwarded-For
  • HTTP-X-Forwarded-For
  • Fly-Client-IP
  • CF-Connecting-IP
  • Fastly-Client-Ip
  • True-Client-Ip
  • X-Real-IP
  • X-Cluster-Client-IP
  • X-Forwarded
  • Forwarded-For
  • Forwarded
  • DO-Connecting-IP
  • oxygen-buyer-ip

當找到包含有效 IP 位址的標頭時,它將會傳回,而不會檢查其他標頭。

警告:在本地開發中,此函式很可能會傳回 null。這是因為瀏覽器不會傳送上述任何標頭,如果您想要模擬這些標頭,您需要將其新增到 Remix 在您的 HTTP 伺服器中接收的請求中,或執行一個反向代理(例如 NGINX),以便為您新增這些標頭。

getClientLocales

注意:此功能相依於 intl-parse-accept-language

此函式讓您可以取得發出請求的用戶端(使用者)的地區設定。

import { getClientLocales } from "remix-utils/locales/server";

export async function loader({ request }: LoaderFunctionArgs) {
  // using the request
  let locales = getClientLocales(request);
  // or using the headers
  let locales = getClientLocales(request.headers);
}

傳回值是 Locales 類型,即 string | string[] | undefined

傳回的地區設定可以直接在格式化日期、數字等時在 Intl API 上使用。

import { getClientLocales } from "remix-utils/locales/server";
export async function loader({ request }: LoaderFunctionArgs) {
  let locales = getClientLocales(request);
  let nowDate = new Date();
  let formatter = new Intl.DateTimeFormat(locales, {
    year: "numeric",
    month: "long",
    day: "numeric",
  });
  return json({ now: formatter.format(nowDate) });
}

該值也可以由載入器傳回並在 UI 上使用,以確保用戶的地區設定在伺服器和用戶端格式化的日期上都使用。

isPrefetch

此函式讓您可以識別請求是否是由於使用 <Link prefetch="intent"><Link prefetch="render"> 觸發的預先擷取而建立的。

這將讓您僅為預先擷取請求實施短暫的快取,以便您避免在 Remix 中預先擷取時重複請求資料

import { isPrefetch } from "remix-utils/is-prefetch";

export async function loader({ request }: LoaderFunctionArgs) {
  let data = await getData(request);
  let headers = new Headers();

  if (isPrefetch(request)) {
    headers.set("Cache-Control", "private, max-age=5, smax-age=0");
  }

  return json(data, { headers });
}

回應

重定向返回

此函式是 Remix 中 redirect 輔助函式的包裝函式。與 Remix 的版本不同,這個版本接收整個請求物件作為第一個值,以及一個帶有回應初始化和備用 URL 的物件。

使用此函式建立的回應將具有指向請求中 Referer 標頭的 Location 標頭,如果不可用,則指向第二個參數中提供的備用 URL。

import { redirectBack } from "remix-utils/redirect-back";

export async function action({ request }: ActionFunctionArgs) {
  throw redirectBack(request, { fallback: "/" });
}

當在通用動作中使用以將使用者傳送到之前所在的 URL 時,此輔助函式最有用。

未修改

輔助函式,用於建立沒有主體和任何標頭的未修改 (304) 回應。

import { notModified } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
  return notModified();
}

JavaScript

輔助函式,用於建立帶有任何標頭的 JavaScript 檔案回應。

這對於根據資源路由中的資料建立 JS 檔案很有用。

import { javascript } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
  return javascript("console.log('Hello World')");
}

樣式表

輔助函式,用於建立帶有任何標頭的 CSS 檔案回應。

這對於根據資源路由中的資料建立 CSS 檔案很有用。

import { stylesheet } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
  return stylesheet("body { color: red; }");
}

PDF

輔助函式,用於建立帶有任何標頭的 PDF 檔案回應。

這對於根據資源路由中的資料建立 PDF 檔案很有用。

import { pdf } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
  return pdf(await generatePDF(request.formData()));
}

HTML

輔助函式,用於建立具有任何標頭的 HTML 檔案回應。

這對於根據資源路由中的資料建立 HTML 檔案很有用。

import { html } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
  return html("<h1>Hello World</h1>");
}

XML

輔助函式,用於建立具有任何標頭的 XML 檔案回應。

這對於根據資源路由中的資料建立 XML 檔案很有用。

import { xml } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
  return xml("<?xml version='1.0'?><catalog></catalog>");
}

純文字

輔助函式,用於建立具有任何標頭的 TXT 檔案回應。

這對於根據資源路由中的資料建立 TXT 檔案很有用。

import { txt } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
  return txt(`
    User-agent: *
    Allow: /
  `);
}

類型化的 Cookie

注意 這取決於 zod 和 React Router。

Remix 中的 Cookie 物件允許任何類型,Remix Utils 中的類型化 Cookie 可讓您使用 Zod 來解析 Cookie 值並確保它們符合綱要。

import { createCookie } from "react-router";
import { createTypedCookie } from "remix-utils/typed-cookie";
import { z } from "zod";

let cookie = createCookie("returnTo", cookieOptions);
// I recommend you to always add `nullable` to your schema, if a cookie didn't
// come with the request Cookie header Remix will return null, and it can be
// useful to remove it later when clearing the cookie
let schema = z.string().url().nullable();

// pass the cookie and the schema
let typedCookie = createTypedCookie({ cookie, schema });

// this will be a string and also a URL
let returnTo = await typedCookie.parse(request.headers.get("Cookie"));

// this will not pass the schema validation and throw a ZodError
await typedCookie.serialize("a random string that's not a URL");
// this will make TS yell because it's not a string, if you ignore it it will
// throw a ZodError
await typedCookie.serialize(123);

您也可以將類型化的 Cookie 與 Remix 中的任何 sessionStorage 機制一起使用。

let cookie = createCookie("session", cookieOptions);
let schema = z.object({ token: z.string() }).nullable();

let sessionStorage = createCookieSessionStorage({
  cookie: createTypedCookie({ cookie, schema }),
});

// if this works then the correct data is stored in the session
let session = sessionStorage.getSession(request.headers.get("Cookie"));

session.unset("token"); // remove a required key from the session

// this will throw a ZodError because the session is missing the required key
await sessionStorage.commitSession(session);

現在,Zod 將確保您嘗試儲存到會話中的資料有效,移除任何多餘的欄位,並且如果您未在會話中設定正確的資料,則會拋出錯誤。

注意 會話物件實際上不是類型化的,因此執行 session.get 將不會傳回正確的類型,您可以執行 schema.parse(session.data) 來取得會話資料的類型化版本。

您也可以在綱要中使用非同步精煉,因為類型化的 Cookie 使用 Zod 的 parseAsync 方法。

let cookie = createCookie("session", cookieOptions);

let schema = z
  .object({
    token: z.string().refine(async (token) => {
      let user = await getUserByToken(token);
      return user !== null;
    }, "INVALID_TOKEN"),
  })
  .nullable();

let sessionTypedCookie = createTypedCookie({ cookie, schema });

// this will throw if the token stored in the cookie is not valid anymore
sessionTypedCookie.parse(request.headers.get("Cookie"));

最後,為了能夠刪除 Cookie,您可以在綱要中新增 .nullable() 並使用 null 作為值進行序列化。

// Set the value as null and expires as current date - 1 second so the browser expires the cookie
await typedCookie.serialize(null, { expires: new Date(Date.now() - 1) });

如果您未在綱要中新增 .nullable(),則需要提供一個模擬值,並將到期日設定為過去。

let cookie = createCookie("returnTo", cookieOptions);
let schema = z.string().url().nullable();

let typedCookie = createTypedCookie({ cookie, schema });

await typedCookie.serialize("some fake url to pass schema validation", {
  expires: new Date(Date.now() - 1),
});

類型化的會話

注意 這取決於 zod 和 React Router。

Remix 中的會話物件允許任何類型,Remix Utils 中的類型化會話可讓您使用 Zod 來解析會話資料並確保它們符合綱要。

import { createCookieSessionStorage } from "react-router";
import { createTypedSessionStorage } from "remix-utils/typed-session";
import { z } from "zod";

let schema = z.object({
  token: z.string().optional(),
  count: z.number().default(1),
});

// you can use a Remix's Cookie container or a Remix Utils' Typed Cookie container
let sessionStorage = createCookieSessionStorage({ cookie });

// pass the session storage and the schema
let typedSessionStorage = createTypedSessionStorage({ sessionStorage, schema });

現在您可以使用 typedSessionStorage 來替代您一般的 sessionStorage。

let session = typedSessionStorage.getSession(request.headers.get("Cookie"));

session.get("token"); // this will be a string or undefined
session.get("count"); // this will be a number
session.get("random"); // this will make TS yell because it's not in the schema

session.has("token"); // this will be a boolean
session.has("count"); // this will be a boolean

// this will make TS yell because it's not a string, if you ignore it it will
// throw a ZodError
session.set("token", 123);

現在,Zod 將確保您嘗試儲存到會話中的資料有效,不允許您取得、設定或取消設定資料。

注意 請記住,您需要將欄位標記為選用或在綱要中設定預設值,否則將無法呼叫 getSession 來取得新的會話物件。

您也可以在綱要中使用非同步精煉,因為類型化的會話使用 Zod 的 parseAsync 方法。

let schema = z.object({
  token: z
    .string()
    .optional()
    .refine(async (token) => {
      if (!token) return true; // handle optionallity
      let user = await getUserByToken(token);
      return user !== null;
    }, "INVALID_TOKEN"),
});

let typedSessionStorage = createTypedSessionStorage({ sessionStorage, schema });

// this will throw if the token stored in the session is not valid anymore
typedSessionStorage.getSession(request.headers.get("Cookie"));

伺服器傳送事件

注意 這取決於 react

伺服器傳送事件是一種從伺服器向客戶端傳送資料的方式,而無需客戶端請求資料。這對於聊天應用程式、即時更新等很有用。

提供兩個工具來協助在 Remix 中使用

  • eventStream
  • useEventSource

eventStream 函式用於建立將事件傳送到客戶端所需的新事件串流回應。這必須存在於資源路由中。

// app/routes/sse.time.ts
import { eventStream } from "remix-utils/sse/server";
import { interval } from "remix-utils/timers";

export async function loader({ request }: LoaderFunctionArgs) {
  return eventStream(request.signal, function setup(send) {
    async function run() {
      for await (let _ of interval(1000, { signal: request.signal })) {
        send({ event: "time", data: new Date().toISOString() });
      }
    }

    run();
  });
}

然後,在任何元件中,您可以使用 useEventSource 鉤子來連線到事件串流。

// app/components/counter.ts
import { useEventSource } from "remix-utils/sse/react";

function Counter() {
  // Here `/sse/time` is the resource route returning an eventStream response
  let time = useEventSource("/sse/time", { event: "time" });

  if (!time) return null;

  return (
    <time dateTime={time}>
      {new Date(time).toLocaleTimeString("en", {
        minute: "2-digit",
        second: "2-digit",
        hour: "2-digit",
      })}
    </time>
  );
}

事件串流和鉤子中的 event 名稱是選用的,在這種情況下,它將預設為 message,如果已定義,您必須在兩端使用相同的事件名稱,這也允許您從同一個事件串流發出不同的事件。

為了使伺服器傳送事件正常運作,您的伺服器必須支援 HTTP 串流。如果您無法讓 SSE 正常運作,請檢查您的部署平台是否支援它。

由於 SSE 會計入每個網域的 HTTP 連線限制,因此 useEventSource 鉤子會根據提供的 URL 和選項保留連線的全域對應。只要它們相同,鉤子就會開啟單一 SSE 連線並在鉤子的執行個體之間共用它。

一旦沒有更多重複使用連線的鉤子執行個體,它將關閉並從對應中移除。

您可以使用 <EventSourceProvider /> 元件來控制對應。

let map: EventSourceMap = new Map();
return (
  <EventSourceProvider value={map}>
    <YourAppOrPartOfIt />
  </EventSourceProvider>
);

這樣,您可以為應用程式的特定部分使用新的對應覆寫對應。請注意,此提供者是選用的,如果您不提供,將使用預設對應。

滾動 Cookie

注意 這取決於 zod 和 React Router。

滾動 Cookie 可讓您透過更新每個 Cookie 的到期日來延長 Cookie 的到期時間。

rollingCookie 函式已準備好在 entry.server 匯出的函式中使用,以更新 Cookie 的到期日(如果沒有載入器設定它)。

對於文件請求,您可以在 handleRequest 函式中使用它

import { rollingCookie } from "remix-utils/rolling-cookie";

import { sessionCookie } from "~/session.server";

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  await rollingCookie(sessionCookie, request, responseHeaders);

  return isbot(request.headers.get("user-agent"))
    ? handleBotRequest(
        request,
        responseStatusCode,
        responseHeaders,
        remixContext
      )
    : handleBrowserRequest(
        request,
        responseStatusCode,
        responseHeaders,
        remixContext
      );
}

對於資料請求,您可以在 handleDataRequest 函式中執行

import { rollingCookie } from "remix-utils/rolling-cookie";

export let handleDataRequest: HandleDataRequestFunction = async (
  response: Response,
  { request }
) => {
  let cookieValue = await sessionCookie.parse(
    responseHeaders.get("set-cookie")
  );
  if (!cookieValue) {
    cookieValue = await sessionCookie.parse(request.headers.get("cookie"));
    responseHeaders.append(
      "Set-Cookie",
      await sessionCookie.serialize(cookieValue)
    );
  }

  return response;
};

注意 > 深入了解 Remix 中的滾動 Cookie

具名動作

注意 這取決於 React Router。

通常需要在同一個路由中處理多個動作,這裡有很多選項,例如將表單傳送到資源路由或使用動作縮減器namedAction 函式使用一些慣例來實作動作縮減器模式。

import { namedAction } from "remix-utils/named-action";

export async function action({ request }: ActionFunctionArgs) {
  return namedAction(await request.formData(), {
    async create() {
      // do create
    },
    async update() {
      // do update
    },
    async delete() {
      // do delete
    },
  });
}

export default function Component() {
  return (
    <>
      <Form method="post">
        <input type="hidden" name="intent" value="create" />
        ...
      </Form>

      <Form method="post">
        <input type="hidden" name="intent" value="update" />
        ...
      </Form>

      <Form method="post">
        <input type="hidden" name="intent" value="delete" />
        ...
      </Form>
    </>
  );
}

此函式可以遵循此慣例

您可以將 FormData 物件傳遞給 namedAction,然後它會嘗試尋找名為 intent 的欄位,並使用該值作為動作名稱。

如果在任何情況下都找不到動作名稱,則 actionName 會嘗試呼叫名為 default 的動作,類似於 JavaScript 中的 switch

如果未定義 default,它將拋出 ReferenceError,並顯示訊息 Action "${name}" not found

如果程式庫完全找不到名稱,它將拋出 ReferenceError,並顯示訊息 Action name not found

預先載入路由資產

[!CAUTION] 這可能會產生很大的 Link 標頭,並且可能會導致極難除錯的問題。某些提供者的負載平衡器已為剖析傳出回應的標頭設定了特定的緩衝區,並且由於 preloadRouteAssets,您可以在中等大小的應用程式中輕鬆達到該緩衝區。您的負載平衡器可能會隨機停止回應或開始擲回 502 錯誤。若要解決此問題,請不要使用 preloadRouteAssets,如果您擁有負載平衡器,則為處理回應標頭設定更大的緩衝區,或在 Vite 設定中使用 experimentalMinChunkSize 選項(這並不能永久解決問題,只能延遲它)

Link 標頭允許回應將文件所需的資產推送至瀏覽器,這對於透過更早傳送這些資產來提高應用程式效能很有用。

一旦支援 Early Hints,這也允許您在文件準備就緒之前傳送資產,但現在您可以受益於在瀏覽器剖析 HTML 之前傳送要預先載入的資產。

您可以使用函式 preloadRouteAssetspreloadLinkedAssetspreloadModuleAssets 來執行此操作。

所有函式都遵循相同的簽章

import { preloadRouteAssets, preloadLinkedAssets, preloadModuleAssets } from "remix-utils/preload-route-assets";

// entry.server.tsx
export default function handleRequest(
  request: Request,
  statusCode: number,
  headers: Headers,
  context: EntryContext,
) {
  let markup = renderToString(
    <RemixServer context={context} url={request.url} />,
  );
  headers.set("Content-Type", "text/html");

  preloadRouteAssets(context, headers); // add this line
  // preloadLinkedAssets(context, headers);
  // preloadModuleAssets(context, headers);

  return new Response("<!DOCTYPE html>" + markup, {
    status: statusCode,
    headers: headers,
  });
}

preloadRouteAssetspreloadLinkedAssetspreloadModuleAssets 的組合,因此您可以使用它來預先載入路由的所有資產,如果您使用此函式,則不需要其他兩個函式

preloadLinkedAssets 函式將預先載入使用 Remix 的 LinkFunction 新增的任何具有 rel: "preload" 的連結,因此您可以在路由中設定要預先載入的資產,並在標頭中自動傳送它們。它還將額外預先載入任何連結的樣式表檔案(具有 rel: "stylesheet"),即使未預先載入,它也會更快載入。

preloadModuleAssets 函式將預先載入 Remix 在為其進行水合時新增到頁面的所有 JS 檔案,Remix 已經為每個檔案呈現一個 <link rel="modulepreload">,現在在用於啟動應用程式的 <script type="module"> 之前,這將使用 Link 標頭來預先載入這些資產。

安全重新導向

在執行重新導向時,如果 URL 是使用者提供的,我們就不能信任它,如果您這樣做,您就會開啟一個網路釣魚詐騙的漏洞,允許惡意行為者將使用者重新導向至惡意網站。

https://remix.utills/?redirectTo=https://malicious.app

為了協助您防止這種情況,Remix Utils 為您提供了一個 safeRedirect 函式,可用於檢查 URL 是否「安全」。

注意 在此內容中,安全表示 URL 以 / 開頭,但不以 // 開頭,這表示 URL 是同一個應用程式內的 pathname,而不是外部連結。

import { safeRedirect } from "remix-utils/safe-redirect";

export async function loader({ request }: LoaderFunctionArgs) {
  let { searchParams } = new URL(request.url);
  let redirectTo = searchParams.get("redirectTo");
  return redirect(safeRedirect(redirectTo, "/home"));
}

safeRedirect 的第二個參數是預設重新導向,當未設定時為 /,這可讓您告知 safeRedirect 如果值不安全,將使用者重新導向到哪裡。

JSON 雜湊回應

loader 函式傳回 json 時,您可能需要從不同的資料庫查詢或 API 請求取得資料,通常您會執行類似以下的操作

export async function loader({ params }: LoaderData) {
  let postId = z.string().parse(params.postId);
  let [post, comments] = await Promise.all([getPost(), getComments()]);
  return json({ post, comments });

  async function getPost() {
    /* … */
  }
  async function getComments() {
    /* … */
  }
}

jsonHash 函式可讓您直接在 json 中定義這些函式,減少建立額外函式和變數的需求。

import { jsonHash } from "remix-utils/json-hash";

export async function loader({ params }: LoaderData) {
  let postId = z.string().parse(params.postId);
  return jsonHash({
    async post() {
      // Implement me
    },
    async comments() {
      // Implement me
    },
  });
}

它還使用 Promise.all 呼叫您的函式,因此您可以確保平行擷取資料。

此外,您可以傳遞非非同步函式、值和 Promise。

import { jsonHash } from "remix-utils/json-hash";

export async function loader({ params }: LoaderData) {
  let postId = z.string().parse(params.postId);
  return jsonHash({
    postId, // value
    comments: getComments(), // Promise
    slug() {
      // Non-async function
      return postId.split("-").at(1); // get slug from postId param
    },
    async post() {
      // Async function
      return await getPost(postId);
    },
  });

  async function getComments() {
    /* … */
  }
}

jsonHash 的結果是 TypedResponse,並且已正確輸入,因此將其與 typeof loader 一起使用可完美運作。

export default function Component() {
  // all correctly typed
  let { postId, comments, slug, post } = useLoaderData<typeof loader>();

  // more code…
}

將錨點委派給 Remix

使用 Remix 時,您可以使用 <Link> 元件在頁面之間導覽。但是,如果您有一個連結到應用程式中頁面的 <a href>,它將導致整個頁面重新整理。這可能是您想要的,但有時您希望在這裡改用客戶端導覽。

useDelegatedAnchors 鉤子可讓您將客戶端導覽新增至應用程式一部分中的錨點標籤。這在處理來自 CMS 的 HTML 或 Markdown 等動態內容時特別有用。

import { useDelegatedAnchors } from "remix-utils/use-delegated-anchors";

export async function loader() {
  let content = await fetchContentFromCMS();
  return json({ content });
}

export default function Component() {
  let { content } = useLoaderData<typeof loader>();

  let ref = useRef<HTMLDivElement>(null);
  useDelegatedAnchors(ref);

  return <article ref={ref} dangerouslySetInnerHTML={{ __html: content }} />;
}

預先擷取錨點

此外,如果您希望能夠預先擷取錨點,您可以使用 PrefetchPageAnchors 元件。

此元件會將您的內容包裝在錨點內部,它會偵測任何懸停的錨點以預先擷取它,並將它們委派給 Remix。

import { PrefetchPageAnchors } from "remix-utils/use-delegated-anchors";

export async function loader() {
  let content = await fetchContentFromCMS();
  return json({ content });
}

export default function Component() {
  let { content } = useLoaderData<typeof loader>();

  return (
    <PrefetchPageAnchors>
      <article ref={ref} dangerouslySetInnerHTML={{ __html: content }} />
    </PrefetchPageAnchors>
  );
}

現在您可以在 DevTools 中看到,當使用者將滑鼠懸停在錨點上時,它會預先擷取它,當使用者按一下它時,它會執行客戶端導覽。

防抖動擷取器和提交

注意:此功能相依於 reactreact-router

useDebounceFetcheruseDebounceSubmituseFetcheruseSubmit 的包裝函式,新增了防抖動支援。

這些鉤子基於 @JacobParis文章

import { useDebounceFetcher } from "remix-utils/use-debounce-fetcher";

export function Component({ data }) {
  let fetcher = useDebounceFetcher<Type>();

  function handleClick() {
    fetcher.submit(data, { debounceTimeout: 1000 });
  }

  return (
    <button type="button" onClick={handleClick}>
      Do Something
    </button>
  );
}

useDebounceSubmit 的用法類似。

import { useDebounceSubmit } from "remix-utils/use-debounce-submit";

export function Component({ name }) {
  let submit = useDebounceSubmit();

  return (
    <input
      name={name}
      type="text"
      onChange={(event) => {
        submit(event.target.form, {
          navigate: false, // use a fetcher instead of a page navigation
          fetcherKey: name, // cancel any previous fetcher with the same key
          debounceTimeout: 1000,
        });
      }}
      onBlur={() => {
        submit(event.target.form, {
          navigate: false,
          fetcherKey: name,
          debounceTimeout: 0, // submit immediately, canceling any pending fetcher
        });
      }}
    />
  );
}

衍生擷取器類型

注意 這取決於 @remix-route/react

從擷取器和導覽資料衍生已棄用的 fetcher.type 的值。

import { getFetcherType } from "remix-utils/fetcher-type";

function Component() {
  let fetcher = useFetcher();
  let navigation = useNavigation();
  let fetcherType = getFetcherType(fetcher, navigation);
  useEffect(() => {
    if (fetcherType === "done") {
      // do something once the fetcher is done submitting the data
    }
  }, [fetcherType]);
}

您也可以使用 React Hook API,讓您避免呼叫 useNavigation

import { useFetcherType } from "remix-utils/fetcher-type";

function Component() {
  let fetcher = useFetcher();
  let fetcherType = useFetcherType(fetcher);
  useEffect(() => {
    if (fetcherType === "done") {
      // do something once the fetcher is done submitting the data
    }
  }, [fetcherType]);
}

如果您需要在各處傳遞 fetcher 類型,您也可以匯入 FetcherType 類型。

import { type FetcherType } from "remix-utils/fetcher-type";

function useCallbackOnDone(type: FetcherType, cb) {
  useEffect(() => {
    if (type === "done") cb();
  }, [type, cb]);
}

使用 respondTo 進行內容協商

如果您正在建構資源路由,並且希望根據客戶端請求的內容類型發送不同的回應(例如,以 PDF、XML 或 JSON 格式發送相同的數據),則需要實作內容協商,這可以使用 respondTo 標頭來完成。

import { respondTo } from "remix-utils/respond-to";

export async function loader({ request }: LoaderFunctionArgs) {
  // do any work independent of the response type before respondTo
  let data = await getData(request);

  let headers = new Headers({ vary: "accept" });

  // Here we will decide how to respond to different content types
  return respondTo(request, {
    // The handler can be a subtype handler, in `text/html` html is the subtype
    html() {
      // We can call any function only really need to respond to this
      // content-type
      let body = ReactDOMServer.renderToString(<UI {...data} />);
      headers.append("content-type", "text/html");
      return new Response(body, { headers });
    },
    // It can also be a highly specific type
    async "application/rss+xml"() {
      // we can do more async work inside this code if needed
      let body = await generateRSSFeed(data);
      headers.append("content-type", "application/rss+xml");
      return new Response(body, { headers });
    },
    // Or a generic type
    async text() {
      // To respond to any text type, e.g. text/plain, text/csv, etc.
      let body = generatePlain(data);
      headers.append("content-type", "text/plain");
      return new Response(body, { headers });
    },
    // The default will be used if the accept header doesn't match any of the
    // other handlers
    default() {
      // Here we could have a default type of response, e.g. use json by
      // default, or we can return a 406 which means the server can't respond
      // with any of the requested content types
      return new Response("Not Acceptable", { status: 406 });
    },
  });
}

現在,respondTo 函數將檢查 Accept 標頭並呼叫正確的處理程序。為了知道要呼叫哪個處理程序,它將使用也從 Remix Utils 匯出的 parseAcceptHeader 函數。

import { parseAcceptHeader } from "remix-utils/parse-accept-header";

let parsed = parseAcceptHeader(
  "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, image/*, */*;q=0.8"
);

結果是一個包含類型、子類型和額外參數(例如,q 值)的陣列。順序將與標頭中遇到的順序相同,在上面的範例中,text/html 將是第一個,接著是 application/xhtml+xml

這表示 respondTo 輔助函數將優先處理任何符合 text/html 的處理程序。在上面的範例中,這將是 html 處理程序,但如果我們將其移除,則會改為呼叫 text 處理程序。67

表單蜜罐

注意:這取決於 react@oslojs/crypto@oslojs/encoding

蜜罐是一種簡單的技術,可防止垃圾郵件機器人提交表單。其原理是在表單中新增一個隱藏欄位,機器人會填寫該欄位,但人類不會填寫。

Remix Utils 中有一對輔助函數可協助您實作此功能。

首先,建立一個 honeypot.server.ts 檔案,您將在其中實例化和配置您的蜜罐。

import { Honeypot } from "remix-utils/honeypot/server";

// Create a new Honeypot instance, the values here are the defaults, you can
// customize them
export const honeypot = new Honeypot({
  randomizeNameFieldName: false,
  nameFieldName: "name__confirm",
  validFromFieldName: "from__confirm", // null to disable it
  encryptionSeed: undefined, // Ideally it should be unique even between processes
});

然後,在您的 app/root loader 中,呼叫 honeypot.getInputProps() 並傳回它。

// app/root.tsx
import { honeypot } from "~/honeypot.server";

export async function loader() {
  // more code here
  return json({ honeypotInputProps: honeypot.getInputProps() });
}

然後在 app/root 組件中,渲染 HoneypotProvider 組件以包覆其餘的 UI。

import { HoneypotProvider } from "remix-utils/honeypot/react";

export default function Component() {
  // more code here
  return (
    // some JSX
    <HoneypotProvider {...honeypotInputProps}>
      <Outlet />
    </HoneypotProvider>
    // end that JSX
  );
}

現在,在您想要保護免受垃圾郵件攻擊的每個公開表單(例如登入表單)中,渲染 HoneypotInputs 組件。

import { HoneypotInputs } from "remix-utils/honeypot/react";

function SomePublicForm() {
  return (
    <Form method="post">
      <HoneypotInputs label="Please leave this field blank" />
      {/* more inputs and some buttons */}
    </Form>
  );
}

注意:上面的標籤值是預設值,使用它可讓標籤本地化,如果您不想變更標籤,則可以將其移除。

最後,在表單提交到的動作中,您可以呼叫 honeypot.check

import { SpamError } from "remix-utils/honeypot/server";
import { honeypot } from "~/honeypot.server";

export async function action({ request }) {
  let formData = await request.formData();
  try {
    honeypot.check(formData);
  } catch (error) {
    if (error instanceof SpamError) {
      // handle spam requests here
    }
    // handle any other possible error here, e.g. re-throw since nothing else
    // should be thrown
  }
  // the rest of your action
}

Sec-Fetch 解析器

注意:這取決於 zod

Sec-Fetch 標頭包含有關請求的資訊,例如,資料將在哪裡使用,或是否由使用者發起。

您可以使用 remix-utils/sec-fetch 輔助函數來解析這些標頭,並取得您需要的資訊。

import {
  fetchDest,
  fetchMode,
  fetchSite,
  isUserInitiated,
} from "remix-utils/sec-fetch";

Sec-Fetch-Dest

Sec-Fetch-Dest 標頭指示請求的目的地,例如 documentimagescript 等。

如果值為 empty,則表示它將由 fetch 呼叫使用,這表示您可以透過檢查是否為 document(沒有 JS)或 empty(已啟用 JS)來區分使用和不使用 JS 發出的請求。

import { fetchDest } from "remix-utils/sec-fetch";

export async function action({ request }: ActionFunctionArgs) {
  let data = await getDataSomehow();

  // if the request was made with JS, we can just return json
  if (fetchDest(request) === "empty") return json(data);
  // otherwise we redirect to avoid a reload to trigger a new submission
  return redirect(destination);
}

Sec-Fetch-Mode

Sec-Fetch-Mode 標頭指示如何發起請求,例如,如果值為 navigate,則表示是由使用者載入頁面觸發的;如果值為 no-cors,則可能是正在載入的圖片。

import { fetchMode } from "remix-utils/sec-fetch";

export async function loader({ request }: LoaderFunctionArgs) {
  let mode = fetchMode(request);
  // do something based on the mode value
}

Sec-Fetch-Site

Sec-Fetch-Site 標頭指示請求的來源位置,例如,same-origin 表示請求正在發送到相同的網域,cross-site 表示請求正在發送到不同的網域。

import { fetchSite } from "remix-utils/sec-fetch";

export async function loader({ request }: LoaderFunctionArgs) {
  let site = fetchSite(request);
  // do something based on the site value
}

Sec-Fetch-User

Sec-Fetch-User 標頭指示請求是否由使用者發起,這可用於區分使用者發出的請求和瀏覽器發出的請求,例如,瀏覽器發出的載入圖片的請求。

import { isUserInitiated } from "remix-utils/sec-fetch";

export async function loader({ request }: LoaderFunctionArgs) {
  let userInitiated = isUserInitiated(request);
  // do something based on the userInitiated value
}

計時器

計時器輔助函數提供了一種在執行某些操作之前等待一段時間,或每隔一段時間執行某些程式碼的方法。

intervaleventStream 結合使用,我們可以每隔一段時間將一個值傳送到客戶端。並確保如果連線關閉,則會取消間隔。

import { eventStream } from "remix-utils/sse/server";
import { interval } from "remix-utils/timers";

export async function loader({ request }: LoaderFunctionArgs) {
  return eventStream(request.signal, function setup(send) {
    async function run() {
      for await (let _ of interval(1000, { signal: request.signal })) {
        send({ event: "time", data: new Date().toISOString() });
      }
    }

    run();
  });
}

作者

授權

  • MIT 授權