为了使 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
中抛出 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”。抛出响应的能力将模型从“push”更改为“pull”。这与人们更喜欢 async/await 而不是回调,以及 React hook 而不是高阶组件和渲染 props 的原因相同。
与浏览器捆绑包不同,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
中来解决此问题,该代码仅在浏览器中运行。
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 非常有用:
关键是在与浏览器绘制同时执行效果,这样您就不会看到弹出窗口显示在0,0
处,然后弹回原位。布局效果允许绘制和效果同时发生,以避免这种闪烁。
它不适合设置在元素内部渲染的状态。只需确保您没有在元素中使用在useLayoutEffect
中设置的状态,就可以忽略 React 的警告。
如果您知道自己正确调用了useLayoutEffect
,并且只想消除警告,库中一个流行的解决方案是创建您自己的 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在您的应用中修复它。