為了讓 Remix 應用程式在伺服器和瀏覽器環境中都能執行,您的應用程式模組和第三方相依性需要注意模組副作用。
Remix 編譯器會自動從瀏覽器套件中移除伺服器程式碼。我們的策略其實相當簡單,但需要您遵循一些規則。
考慮一個路由模組,它會匯出 loader
、meta
和一個元件
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 });
}
最常見的情況是在導入模組時初始化第三方 API。有幾種方法可以輕鬆處理這個問題。
這可確保只有在有 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 });
}
另一種常見的情況是在渲染時呼叫瀏覽器專用 API。在 React 中進行伺服器渲染時(不只是 Remix),必須避免這種情況,因為伺服器上不存在這些 API。
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 在你的應用程式中修正它。