跳转到内容

模块约束

为了让 Remix 在服务器和浏览器环境中运行您的应用程序,您的应用程序模块和第三方依赖项需要注意模块副作用

  • 仅限服务器的代码 - Remix 将删除仅限服务器的代码,但如果您有使用仅限服务器的代码的模块副作用,则无法删除。
  • 仅限浏览器的代码 - Remix 在服务器上渲染,因此您的模块不能有模块副作用或调用仅限浏览器的 API 的首次渲染逻辑

服务器代码修剪

Remix 编译器会自动从浏览器包中删除服务器代码。我们的策略其实很简单,但需要你遵循一些规则。

  1. 它会在路由模块前面创建一个代理模块
  2. 代理模块仅导入浏览器特定的导出

考虑一个导出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模块,它将完全崩溃。那个东西充满了仅限节点的 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 导入,因为它未在模块中的其他任何地方使用。

有时,构建可能会遇到仅应在服务器上运行的 tree-shaking 代码问题。如果发生这种情况,您可以使用在文件类型前使用扩展名 .server 命名文件的惯例,例如 db.server.ts。在文件名中添加 .server 是向编译器发出的提示,在为浏览器打包时不必担心此模块或其导入。

高阶函数

有些 Remix 新手尝试使用高阶函数来抽象他们的加载器。例如:

app/http.ts
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);
};
}

然后尝试像这样使用它:

app/root.ts
import { json } from "@remix-run/node"; // or cloudflare/deno
import { removeTrailingSlash } from "~/http";
export const loader = removeTrailingSlash(({ request }) => {
return json({ some: "data" });
});

您现在可能已经明白这是一个模块副作用,因此编译器无法删除removeTrailingSlash代码。

引入这种抽象是为了尽早返回响应。由于你可以在加载器中抛出响应,我们可以简化这个过程,同时移除模块副作用,这样就可以精简服务器代码:

app/http.ts
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,
});
}
}

然后像这样使用它:

app/root.tsx
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);
};

如果你想做一些课外阅读,可以谷歌搜索推送与拉取 API。抛出响应的能力将模型从推送更改为拉取。这也是人们更喜欢 async/await 而不是回调、React hooks 而不是高阶组件和渲染 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。

初始化仅限浏览器的 API

最常见的情况是导入模块时初始化第三方 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 只在事件处理程序和效果内部调用,而这些不是模块的副作用。

使用仅限浏览器的 API 进行渲染

另一种常见情况是代码在渲染时调用仅浏览器可用的 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,这对于服务器来说是可行的。在浏览器中,该状态将在 hydration 后立即填充。但愿它不会导致内容布局发生重大变化!如果确实如此,也许可以将该状态移动到数据库或 cookie 中,以便您可以在服务器端访问它。

useLayoutEffect

如果您使用这个钩子,React 将会警告您在服务器上使用它。

当你设置如下状态时,这个钩子非常有用:

  • 元素弹出时的位置(如菜单按钮)
  • 响应用户交互的滚动位置

重点是让效果与浏览器绘制同时执行,这样您就不会看到弹出窗口出现在 0,0 处然后弹回到原位。布局效果让绘制和效果同时发生,以避免这种闪烁。

它不适合设置在元素内部呈现的状态。只需确保您没有在元素中使用useLayoutEffect中设置的状态,您就可以忽略 React 的警告。

如果您知道您正在正确调用 useLayoutEffect 并且只想消除警告,那么库中一个流行的解决方案是创建自己的钩子,该钩子不会调用服务器上的任何东西。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 来修复您的应用。