模块约束
On this page

模块约束

¥Module Constraints

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

¥In order for Remix to run your app in both the server and browser environments, your application modules and third-party dependencies need to be careful about module side effects.

  • 纯服务器代码 — Remix 将删除纯服务器代码,但如果你有使用纯服务器代码的模块副作用,则无法删除。

    ¥Server-only code — Remix will remove server-only code, but it can't if you have module side effects that use server-only code.

  • 纯浏览器代码 - Remix 在服务器上渲染,因此你的模块不能有模块副作用或调用纯浏览器 API 的首次渲染逻辑。

    ¥Browser-only code — Remix renders on the server, so your modules can't have module side effects or first-rendering logic that call browser-only APIs

服务器代码修剪

¥Server Code Pruning

Remix 编译器将自动从浏览器包中移除服务器代码。我们的策略实际上非常简单,但需要你遵循一些规则。

¥The Remix compiler will automatically remove server code from the browser bundles. Our strategy is actually pretty straightforward but requires you to follow some rules.

  1. 它会在路由模块前面创建一个 "proxy" 模块。

    ¥It creates a "proxy" module in front of your route module

  2. 代理模块仅导入特定于浏览器的导出函数

    ¥The proxy module only imports the browser-specific exports

假设一个路由模块导出 loadermeta 和一个组件:

¥Consider a route module that exports loader, meta, and a component:

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 模块,它将完全崩溃。这玩意儿全是 Node.js 独有的 API!

¥The server needs everything in this file, but the browser only needs the component and meta. In fact, it'll be completely broken if it includes the prisma module in the browser bundle. That thing is full of node-only APIs!

为了从浏览器包中移除服务器代码,Remix 编译器会在路由前创建一个代理模块,并将其打包。此路由的代理如下所示:

¥To remove the server code from the browser bundles, the Remix compiler creates a proxy module in front of your route and bundles that instead. The proxy for this route would look like:

export { meta, default } from "./routes/posts.tsx";

编译器现在将分析 app/routes/posts.tsx 中的代码,并只保留 meta 和组件内部的代码。结果如下:

¥The compiler will now analyze the code in app/routes/posts.tsx and only keep code that's inside of meta and the component. The result is something like this:

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} />;
}

非常巧妙!现在可以安全地打包到浏览器了。那么问题是什么?

¥Pretty slick! This is now safe to bundle up for the browser. So what's the problem?

无模块端效果

¥No Module Side Effects

如果你不熟悉副作用,你并不孤单!我们现在将帮助你识别它们。

¥If you're unfamiliar with side effects, you're not alone! We'll help you identify them now.

简而言之,副作用是指任何可能执行某些操作的代码。模块副作用是指在模块加载时可能执行某些操作的任何代码。

¥Simply put, a side effect is any code that might do something. A module side effect is any code that might do something when a module is loaded.

模块副作用是指只需导入模块即可执行的代码

从之前的代码来看,我们看到了编译器如何删除未使用的导出及其导入。但是,如果我们添加这行看似无害的代码,你的应用将会崩溃!

¥Taking our code from earlier, we saw how the compiler can remove the exports and their imports that aren't used. But if we add this seemingly harmless line of code, your app will break!

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 做了一些事情。模块导入后会立即记录到控制台。编译器不会移除它,因为它必须在模块导入时运行。它会打包类似以下内容的内容:

¥That console.log does something. The module is imported and then immediately logs to the console. The compiler won't remove it because it has to run when the module is imported. It will bundle something like this:

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} />;
}

loader 消失了,但 Prisma 依赖保留了下来!如果我们记录了像 console.log("hello!") 这样无害的内容,那就没问题了。但是我们记录了 prisma 模块,因此浏览器会很难处理。

¥The loader is gone, but the prisma dependency stayed! Had we logged something harmless like console.log("hello!") it would be fine. But we logged the prisma module so the browser's going to have a hard time with that.

要修复此问题,只需将代码移至 loader 即可消除副作用。

¥To fix this, remove the side effect by simply moving the code into the loader.

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

¥This is no longer a module side effect (runs when the module is imported), but rather a side effect of the loader (runs when the loader is called). The compiler will now remove both the loader and the prisma import because it isn't used anywhere else in the module.

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

¥Occasionally, the build may have trouble tree-shaking code that should only run on the server. If this happens, you can use the convention of naming a file with the extension .server before the file type, for example db.server.ts. Adding .server to the filename is a hint to the compiler to not worry about this module or its imports when bundling for the browser.

高阶函数

¥Higher Order Functions

一些 Remix 新手尝试使用 "高阶函数" 抽象他们的加载器。类似这样:

¥Some Remix newcomers try to abstract their loaders with "higher order functions". Something like this:

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);
  };
}

然后尝试像这样使用它:

¥And then try to use it like this:

import { json } from "@remix-run/node"; // or cloudflare/deno

import { removeTrailingSlash } from "~/http";

export const loader = removeTrailingSlash(({ request }) => {
  return json({ some: "data" });
});

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

¥You can probably now see that this is a module side effect so the compiler can't prune out the removeTrailingSlash code.

引入这种抽象类型是为了尝试尽早返回响应。由于你可以在 loader 中抛出一个 Response,我们可以简化这个过程并同时消除模块的副作用,从而精简服务器代码:

¥This type of abstraction is introduced to try to return a response early. Since you can throw a Response in a loader, we can simplify this and remove the module side effect at the same time so that the server code can be pruned:

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,
    });
  }
}

然后像这样使用它:

¥And then use it like this:

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" });
};

当你有很多这样的代码时,它的读取效果会更好:

¥It reads much nicer as well when you've got a lot of these:

// 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 上搜索 "推送与拉取 API"。抛出响应的能力将模型从 "push" 更改为 "pull"。这也是人们更喜欢 async/await 而不是回调、React hooks 而不是高阶组件和渲染 props 的原因。

¥If you want to do some extracurricular reading, google around for "push vs. pull API". The ability to throw responses changes the model from a "push" to a "pull". This is the same reason folks prefer async/await over callbacks and React hooks over higher order components and render props.

服务器上的纯浏览器代码

¥Browser-Only Code on the Server

与浏览器包不同,Remix 不会尝试从服务器包中删除仅用于浏览器的代码,因为路由模块要求每个导出代码都在服务器上渲染。这意味着你需要注意哪些代码只能在浏览器中执行。

¥Unlike the browser bundles, Remix doesn't try to remove browser-only code from the server bundle because the route modules require every export to render on the server. This means it's your job to be mindful of code that should only execute in the browser.

这将导致你的应用崩溃:

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

¥Initializing Browser-Only APIs

最常见的场景是在导入模块时初始化第三方 API。有几种方法可以轻松解决这个问题。

¥The most common scenario is initializing a third-party API when your module is imported. There are a couple ways to easily deal with this.

文档防护

¥Document Guard

这可以确保仅在存在 document(即你在浏览器中)时才初始化库。我们推荐使用 document 而不是 window,因为像 Deno 这样的服务器运行时环境提供了全局的 window 可用。

¥This ensures the library is only initialized if there is a document, meaning you're in the browser. We recommend document over window because server runtimes like Deno have a global window available.

import firebase from "firebase/app";

if (typeof document !== "undefined") {
  firebase.initializeApp(document.ENV.firebase);
}

export { firebase };

延迟初始化

¥Lazy Initialization

此策略将初始化推迟到实际使用库时:

¥This strategy defers initialization until the library is actually used:

import { loadStripe } from "@stripe/stripe-js";

export async function redirectToStripeCheckout(
  sessionId: string
) {
  const stripe = await loadStripe(window.ENV.stripe);
  return stripe.redirectToCheckout({ sessionId });
}

你可能希望通过将库存储在模块作用域变量中来避免多次初始化库。

¥You may want to avoid initializing the library multiple times by storing it in a module-scoped variable.

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 渲染

¥Rendering with Browser Only APIs

另一种常见情况是在渲染过程中调用仅浏览器可用的 API。在 React(不仅仅是 Remix)中进行服务器渲染时,必须避免这种情况,因为服务器上不存在 API。

¥Another common case is code that calls browser-only APIs while rendering. When server rendering in React (not just Remix), this must be avoided because the APIs don't exist on the server.

这将导致你的应用崩溃,因为服务器会尝试使用本地存储。

function useLocalStorage(key: string) {
  const [state, setState] = useState(
    localStorage.getItem(key)
  );

  const setWithLocalStorage = (nextState) => {
    setState(nextState);
  };

  return [state, setWithLocalStorage];
}

你可以通过将代码移到 useEffect 中来解决此问题,因为它仅在浏览器中运行。

¥You can fix this by moving the code into useEffect, which only runs in the browser.

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 中,以便你可以在服务器端访问它。

¥Now localStorage is not being accessed on the initial render, which will work for the server. In the browser, that state will fill in immediately after hydration. Hopefully, it doesn't cause a big content layout shift though! If it does, maybe move that state into your database or a cookie, so you can access it server side.

useLayoutEffect

如果你使用此钩子,React 会在服务器上警告你不要使用它。

¥If you use this hook, React will warn you about using it on the server.

当你为以下内容设置状态时,此钩子非常有用:

¥This hook is great when you're setting state for things like:

  • 元素弹出时的位置(例如菜单按钮)

    ¥The position of an element when it pops up (like a menu button)

  • 响应用户交互的滚动位置

    ¥The scroll position in response to user interactions

关键在于与浏览器绘制同时执行效果,这样你就不会看到弹出窗口在 0,0 出现,然后又弹回到原位。布局效果可以让绘制和效果同时发生,从而避免这种闪烁。

¥The point is to perform the effect at the same time as the browser paint so that you don't see the popup show up at 0,0 and then bounce into place. Layout effects let the paint and the effect happen at the same time to avoid this kind of flashing.

它不适合设置元素内部渲染的状态。确保你没有在元素中使用 useLayoutEffect 中设置的状态,这样你就可以忽略 React 的警告。

¥It is not good for setting state rendered inside elements. Make sure you aren't using the state set in a useLayoutEffect in your elements, and you can ignore React's warning.

如果你知道自己正确调用了 useLayoutEffect,只是想消除警告,那么库中一个流行的解决方案是创建自己的钩子,使其不调用服务器上的任何代码。useLayoutEffect 只在浏览器中运行,所以这应该可以解决问题。请谨慎使用,因为出现警告是有原因的!

¥If you know you're calling useLayoutEffect correctly and just want to silence the warning, a popular solution in libraries is to create your own hook that doesn't call anything on the server. useLayoutEffect only runs in the browser anyway, so this should do the trick. Please use this carefully, because the warning is there for a good reason!

import * as React from "react";

const canUseDOM = !!(
  typeof window !== "undefined" &&
  window.document &&
  window.document.createElement
);

const useLayoutEffect = canUseDOM
  ? React.useLayoutEffect
  : () => {};

第三方模块的副作用

¥Third-Party Module Side Effects

一些第三方库有自己的模块副作用,与 React 服务器渲染不兼容。通常它会尝试访问 window 进行功能检测。

¥Some third party libraries have their own module side effects that are incompatible with React server rendering. Usually it's trying to access window for feature detection.

这些库与 React 中的服务端渲染不兼容,因此与 Remix 也不兼容。幸运的是,React 生态系统中很少有第三方库支持此功能。

¥These libraries are incompatible with server rendering in React and therefore incompatible with Remix. Fortunately, very few third-party libraries in the React ecosystem do this.

我们建议寻找替代方案。但是,如果你无法做到这一点,我们建议你使用 patch-package 在你的应用中修复它。

¥We recommend finding an alternative. But if you can't, we recommend using patch-package to fix it up in your app.

Remix v2.17 中文网 - 粤ICP备13048890号