React Router v7 已發布。 檢視文件
模組限制
本頁內容

模組限制

為了讓 Remix 應用程式在伺服器和瀏覽器環境中都能執行,您的應用程式模組和第三方相依性需要注意模組副作用

  • 僅限伺服器程式碼 - Remix 會移除僅限伺服器程式碼,但如果您有使用僅限伺服器程式碼的模組副作用,則無法移除。
  • 僅限瀏覽器程式碼 - Remix 在伺服器上渲染,因此您的模組不能有模組副作用或在首次渲染邏輯中呼叫僅限瀏覽器的 API

伺服器程式碼修剪

Remix 編譯器會自動從瀏覽器套件中移除伺服器程式碼。我們的策略其實相當簡單,但需要您遵循一些規則。

  1. 它會在您的路由模組前面建立一個「Proxy」模組
  2. Proxy 模組只會匯入瀏覽器特定的匯出

考慮一個路由模組,它會匯出 loadermeta 和一個元件

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

import { prisma } from "../db";
import PostsView from "../PostsView";

export async function loader() {
  return json(await prisma.post.findMany());
}

export function meta() {
  return [{ title: "Posts" }];
}

export default function Posts() {
  const posts = useLoaderData<typeof loader>();
  return <PostsView posts={posts} />;
}

伺服器需要這個檔案中的所有內容,但瀏覽器只需要元件和 meta。事實上,如果瀏覽器套件包含 prisma 模組,它會完全壞掉。那個模組充滿了僅限 Node 的 API!

為了從瀏覽器套件中移除伺服器程式碼,Remix 編譯器會在您的路由前面建立一個 Proxy 模組,並改為綑綁該模組。此路由的 Proxy 會看起來像這樣

export { meta, default } from "./routes/posts.tsx";

編譯器現在會分析 app/routes/posts.tsx 中的程式碼,並且只保留 meta 和元件中的程式碼。結果會像這樣

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

import PostsView from "../PostsView";

export function meta() {
  return [{ title: "Posts" }];
}

export default function Posts() {
  const posts = useLoaderData<typeof loader>();
  return <PostsView posts={posts} />;
}

相當巧妙!現在可以安全地將其綑綁起來供瀏覽器使用。那問題是什麼?

無模組副作用

如果您不熟悉副作用,您並不孤單!我們現在會協助您識別它們。

簡單來說,副作用指的是任何可能會執行某些操作的程式碼。而模組副作用指的是任何可能在模組載入時執行某些操作的程式碼。

模組副作用指的是僅透過導入模組就會執行的程式碼。

以前面的程式碼為例,我們看到編譯器如何移除未使用的輸出(exports)及其導入(imports)。但如果我們加入這行看似無害的程式碼,你的應用程式就會崩潰!

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

import { prisma } from "../db";
import PostsView from "../PostsView";

console.log(prisma);

export async function loader() {
  return json(await prisma.post.findMany());
}

export function meta() {
  return [{ title: "Posts" }];
}

export default function Posts() {
  const posts = useLoaderData<typeof loader>();
  return <PostsView posts={posts} />;
}

這個 console.log執行某些操作。模組被導入後會立即將內容記錄到主控台。編譯器不會移除它,因為它必須在模組被導入時執行。它會打包類似這樣的程式碼:

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

import { prisma } from "../db"; //😬
import PostsView from "../PostsView";

console.log(prisma); //🥶

export function meta() {
  return [{ title: "Posts" }];
}

export default function Posts() {
  const posts = useLoaderData<typeof loader>();
  return <PostsView posts={posts} />;
}

載入器(loader)消失了,但 prisma 相依性仍然存在!如果我們記錄的是像 console.log("hello!") 這樣無害的內容,那還好。但我們記錄的是 prisma 模組,瀏覽器將難以處理。

要解決這個問題,只需將程式碼移到載入器中,即可移除副作用。

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

import { prisma } from "../db";
import PostsView from "../PostsView";

export async function loader() {
  console.log(prisma);
  return json(await prisma.post.findMany());
}

export function meta() {
  return [{ title: "Posts" }];
}

export default function Posts() {
  const posts = useLoaderData<typeof loader>();
  return <PostsView posts={posts} />;
}

這不再是模組副作用(在模組導入時執行),而是載入器的副作用(在載入器被呼叫時執行)。編譯器現在會移除載入器*和 prisma 導入*,因為它在模組中的其他地方都沒有使用到。

有時,建置工具可能在 tree-shaking 僅應在伺服器上執行的程式碼時遇到問題。如果發生這種情況,你可以使用在檔案類型之前,以 .server 副檔名命名檔案的慣例,例如 db.server.ts。在檔案名稱中加入 .server 是給編譯器的一個提示,表示在為瀏覽器打包時,不必擔心這個模組或其導入。

高階函數

一些 Remix 的新手會嘗試使用「高階函數」來抽象化他們的載入器,例如這樣:

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

export function removeTrailingSlash(loader) {
  return function (arg) {
    const { request } = arg;
    const url = new URL(request.url);
    if (
      url.pathname !== "/" &&
      url.pathname.endsWith("/")
    ) {
      return redirect(request.url.slice(0, -1), {
        status: 308,
      });
    }
    return loader(arg);
  };
}

然後嘗試這樣使用它:

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

import { removeTrailingSlash } from "~/http";

export const loader = removeTrailingSlash(({ request }) => {
  return json({ some: "data" });
});

你現在可能可以看到這是一個模組副作用,因此編譯器無法刪除 removeTrailingSlash 程式碼。

這種抽象化的目的是嘗試提前回傳一個 response。由於你可以在 loader 中拋出 Response,我們可以讓這個更簡單,同時移除模組副作用,這樣伺服器程式碼就可以被刪除:

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

export function removeTrailingSlash(url) {
  if (url.pathname !== "/" && url.pathname.endsWith("/")) {
    throw redirect(request.url.slice(0, -1), {
      status: 308,
    });
  }
}

然後像這樣使用它:

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

import { removeTrailingSlash } from "~/http";

export const loader = async ({
  request,
}: LoaderFunctionArgs) => {
  removeTrailingSlash(request.url);
  return json({ some: "data" });
};

當你有很多這樣的情況時,它看起來也會好得多。

// this
export const loader = async ({
  request,
}: LoaderFunctionArgs) => {
  return removeTrailingSlash(request.url, () => {
    return withSession(request, (session) => {
      return requireUser(session, (user) => {
        return json(user);
      });
    });
  });
};
// vs. this
export const loader = async ({
  request,
}: LoaderFunctionArgs) => {
  removeTrailingSlash(request.url);
  const session = await getSession(request);
  const user = await requireUser(session);
  return json(user);
};

如果你想進行一些課外閱讀,請在 Google 上搜尋「push vs. pull API」。拋出 response 的能力將模型從「push」改為「pull」。這也是為什麼人們喜歡 async/await 勝過 callbacks,以及 React hooks 勝過高階元件和 render props 的原因。

伺服器上的瀏覽器專用程式碼

與瀏覽器 bundles 不同,Remix 不會嘗試從伺服器 bundle 中移除*瀏覽器專用程式碼*,因為 route 模組需要每個輸出都可在伺服器上渲染。這表示你必須留意只應在瀏覽器中執行的程式碼。

這會讓你的應用程式崩潰:

import { loadStripe } from "@stripe/stripe-js";

const stripe = await loadStripe(window.ENV.stripe);

export async function redirectToStripeCheckout(
  sessionId: string
) {
  return stripe.redirectToCheckout({ sessionId });
}

你需要避免任何瀏覽器專用的模組副作用,例如存取 window 或在模組範圍內初始化 API。

初始化瀏覽器專用 API

最常見的情況是在導入模組時初始化第三方 API。有幾種方法可以輕鬆處理這個問題。

Document 保護

這可確保只有在有 document 時(表示你在瀏覽器中)才會初始化程式庫。我們建議使用 document 而不是 window,因為像 Deno 這樣的伺服器執行環境會有可用的全域 window

import firebase from "firebase/app";

if (typeof document !== "undefined") {
  firebase.initializeApp(document.ENV.firebase);
}

export { firebase };

延遲初始化

此策略會將初始化延遲到實際使用程式庫時。

import { loadStripe } from "@stripe/stripe-js";

export async function redirectToStripeCheckout(
  sessionId: string
) {
  const stripe = await loadStripe(window.ENV.stripe);
  return stripe.redirectToCheckout({ sessionId });
}

你可能會想避免多次初始化程式庫,方法是將它儲存在模組範圍的變數中。

import { loadStripe } from "@stripe/stripe-js";

let _stripe;
async function getStripe() {
  if (!_stripe) {
    _stripe = await loadStripe(window.ENV.stripe);
  }
  return _stripe;
}

export async function redirectToStripeCheckout(
  sessionId: string
) {
  const stripe = await getStripe();
  return stripe.redirectToCheckout({ sessionId });
}

雖然這些策略都無法從伺服器 bundle 中移除瀏覽器模組,但沒關係,因為 API 只會在事件處理常式和 effects 內部呼叫,而這些都不是模組副作用。

使用瀏覽器專用 API 進行渲染

另一種常見的情況是在渲染時呼叫瀏覽器專用 API。在 React 中進行伺服器渲染時(不只是 Remix),必須避免這種情況,因為伺服器上不存在這些 API。

這會讓你的應用程式崩潰,因為伺服器會嘗試使用 local storage:

function useLocalStorage(key: string) {
  const [state, setState] = useState(
    localStorage.getItem(key)
  );

  const setWithLocalStorage = (nextState) => {
    setState(nextState);
  };

  return [state, setWithLocalStorage];
}

你可以將程式碼移到 useEffect 中來解決這個問題,該 hook 只會在瀏覽器中執行。

function useLocalStorage(key: string) {
  const [state, setState] = useState(null);

  useEffect(() => {
    setState(localStorage.getItem(key));
  }, [key]);

  const setWithLocalStorage = (nextState) => {
    setState(nextState);
  };

  return [state, setWithLocalStorage];
}

現在,伺服器不會在初始渲染時存取 localStorage,這對伺服器來說是可行的。在瀏覽器中,該狀態會在 hydration 後立即填入。但願它不會造成很大的內容佈局偏移!如果會,或許可以將該狀態移到你的資料庫或 cookie 中,這樣你就可以在伺服器端存取它。

useLayoutEffect

如果你使用此 hook,React 會警告你不要在伺服器上使用它。

當你為以下情況設定狀態時,這個 hook 非常棒:

  • 元素彈出時的位置(例如選單按鈕)
  • 回應使用者互動的捲軸位置

重點是在瀏覽器繪製的同時執行 effect,這樣你就不會看到彈出視窗在 0,0 顯示,然後彈跳到正確的位置。Layout effects 可讓繪製和 effect 同時發生,以避免這種閃爍。

適合設定在元素內部渲染的狀態。請確保你沒有在元素中使用 useLayoutEffect 中設定的狀態,這樣你就可以忽略 React 的警告。

如果你知道自己正確地呼叫 useLayoutEffect,並且只是想消除警告,程式庫中一個常見的解決方案是建立自己的 hook,該 hook 不會在伺服器上呼叫任何內容。useLayoutEffect 反正只會在瀏覽器中執行,所以這應該可以解決問題。請小心使用這個方法,因為警告是有充分理由的!

import * as React from "react";

const canUseDOM = !!(
  typeof window !== "undefined" &&
  window.document &&
  window.document.createElement
);

const useLayoutEffect = canUseDOM
  ? React.useLayoutEffect
  : () => {};

第三方模組副作用

某些第三方程式庫有其自己的模組副作用,與 React 伺服器渲染不相容。通常是嘗試存取 window 來進行功能檢測。

這些程式庫與 React 中的伺服器渲染不相容,因此與 Remix 不相容。幸運的是,React 生態系統中很少有第三方程式庫會這樣做。

我們建議尋找替代方案。但如果你找不到,我們建議使用 patch-package 在你的應用程式中修正它。

文件和範例授權條款為 MIT