为了使 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
模块,它将完全崩溃。 那东西充满了仅节点 API!
为了从浏览器包中删除服务器代码,Remix 编译器在你的路由前面创建一个代理模块,并将其打包。 此路由的代理如下所示
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} />;
}
非常巧妙! 现在可以安全地将其打包到浏览器中。 那么问题是什么?
如果你不熟悉副作用,你并不孤单! 我们现在将帮助你识别它们。
简而言之,副作用是任何可能执行某些操作的代码。模块副作用是任何可能在加载模块时执行某些操作的代码。
从之前的代码中,我们看到了编译器如何删除未使用的导出及其导入。但是,如果我们添加这行看似无害的代码,你的应用程序将会崩溃!
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} />;
}
加载器消失了,但 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 导入,因为它在模块中的其他任何地方都没有使用。
有时,构建可能会在摇树优化应该仅在服务器上运行的代码时遇到问题。如果发生这种情况,你可以使用在文件类型之前使用扩展名 .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
代码。
引入这种类型的抽象是为了尝试尽早返回响应。 由于你可以在 loader
中抛出响应,我们可以使其更简单,并同时消除模块副作用,以便可以修剪服务器代码
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”。 抛出响应的能力将模型从“推送”更改为“拉取”。 这也是人们喜欢使用 async/await 而不是回调,以及使用 React hooks 而不是高阶组件和渲染道具的原因。
与浏览器包不同,Remix 不会尝试从服务器包中删除仅浏览器代码,因为路由模块需要每个导出才能在服务器上呈现。 这意味着你有责任注意应该仅在浏览器中执行的代码。
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
中来解决此问题,useEffect
仅在浏览器中运行。
function useLocalStorage(key: string) {
const [state, setState] = useState(null);
useEffect(() => {
setState(localStorage.getItem(key));
}, [key]);
const setWithLocalStorage = (nextState) => {
setState(nextState);
};
return [state, setWithLocalStorage];
}
现在,localStorage
不会在初始渲染时被访问,这适用于服务器。在浏览器中,该状态将在水合后立即填充。希望它不会导致大的内容布局偏移!如果确实如此,也许可以将该状态移动到您的数据库或 cookie 中,以便您可以在服务器端访问它。
useLayoutEffect
如果您使用此 hook,React 会警告您在服务器上使用它。
当您为以下类似内容设置状态时,此 hook 非常有用:
关键是在浏览器绘制的同时执行 effect,这样您就不会看到弹出窗口出现在 0,0
,然后弹到正确的位置。Layout effect 允许绘制和 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 来在您的应用程序中修复它。