模块约束
本页内容

模块约束

为了使 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中抛出 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 });
}

您需要避免任何浏览器端模块副作用,例如在模块范围内访问 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 仅在事件处理程序和效果内部调用,这些都不是模块副作用。

使用浏览器端 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在初始渲染时不会被访问,这将适用于服务器。在浏览器中,该状态将在水合后立即填充。希望它不会导致很大的内容布局偏移!如果确实如此,也许可以将该状态移动到您的数据库或 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在您的应用中修复它。

文档和示例许可证 MIT