React Router v7 已发布。 查看文档
模块约束
本页内容

模块约束

为了使 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 导入,因为它在模块中的其他任何地方都没有使用。

有时,构建可能会在摇树优化应该仅在服务器上运行的代码时遇到问题。如果发生这种情况,你可以使用在文件类型之前使用扩展名 .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 });
}

你需要避免任何仅浏览器的模块副作用,例如在模块范围中访问 window 或初始化 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 仅在事件处理程序和 effect 中调用,而它们不是模块的副作用。

使用仅限浏览器的 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 中来解决此问题,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 来在您的应用程序中修复它。

文档和示例根据以下协议许可 MIT