CSS 文件

CSS 文件

在 Remix 中管理 CSS 文件主要有两种方式

本指南涵盖了每种方法的优缺点,并根据您项目的具体需求提供了一些建议。

CSS 捆绑

CSS 捆绑是 React 社区中管理 CSS 文件最常见的方法。在此模型中,样式被视为模块副作用,并根据捆绑程序的决定捆绑到一个或多个 CSS 文件中。它更易于使用,需要更少的样板代码,并且让捆绑程序拥有更多优化输出的能力。

例如,假设您有一个带有某些样式的基本 Button 组件

.Button__root {
  background: blue;
  color: white;
}
import "./Button.css";

export function Button(props) {
  return <button {...props} className="Button__root" />;
}

要使用此组件,您只需导入它并在您的路由文件中使用它即可

import { Button } from "../components/Button";

export default function HelloRoute() {
  return <Button>Hello!</Button>;
}

使用此组件时,您无需担心管理各个 CSS 文件。CSS 被视为组件的私有实现细节。这在许多组件库和设计系统中是一种常见模式,并且可以很好地扩展。

某些 CSS 解决方案需要 CSS 捆绑

某些管理 CSS 文件的方法需要使用捆绑的 CSS。

例如,CSS 模块 是基于 CSS 被捆绑的假设构建的。即使您明确地将 CSS 文件的类名作为 JavaScript 对象导入,样式本身仍被视为副作用并自动捆绑到输出中。您无法访问 CSS 文件的基础 URL。

另一个需要 CSS 捆绑的常见用例是,当您使用第三方组件库时,该库将 CSS 文件作为副作用导入,并依赖您的捆绑程序为您处理它们,例如 React Spectrum

开发和生产环境中 CSS 顺序可能不同

将 CSS 捆绑与 Vite 的按需编译方法结合使用时,会带来一个值得注意的权衡。

使用前面介绍的 Button.css 示例,此 CSS 文件将在开发过程中转换为以下 JavaScript 代码

import {createHotContext as __vite__createHotContext} from "/@vite/client";
import.meta.hot = __vite__createHotContext("/app/components/Button.css");
import {updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle} from "/@vite/client";
const __vite__id = "/path/to/app/components/Button.css";
const __vite__css = ".Button__root{background:blue;color:white;}"
__vite__updateStyle(__vite__id, __vite__css);
import.meta.hot.accept();
import.meta.hot.prune(()=>__vite__removeStyle(__vite__id));

值得强调的是,此转换仅在开发环境中发生。**生产环境构建不会像这样**,因为会生成静态 CSS 文件。

Vite 这样做是为了能够在导入时延迟编译 CSS,并在开发过程中热重载。导入此文件后,CSS 文件的内容会作为副作用注入到页面中。

这种方法的缺点是这些样式不与路由生命周期绑定。这意味着在导航离开路由时,样式不会被卸载,导致在应用程序中导航时文档中积累旧样式。这可能导致开发和生产环境中 CSS 规则顺序不同。

为了缓解此问题,最好以一种使其能够抵御文件排序更改的方式编写 CSS。例如,您可以使用 CSS 模块 来确保 CSS 文件的作用域限定为导入它们的那些文件。您还应该尽量减少针对单个元素的 CSS 文件数量,因为这些文件的顺序没有保证。

捆绑的 CSS 可能会在开发环境中消失

Vite 在开发期间对 CSS 捆绑的方法的另一个值得注意的权衡是,React 可能会无意中从文档中删除样式。

当 React 用于渲染整个文档(就像 Remix 一样)时,当元素被动态注入到 head 元素中时,您可能会遇到问题。如果文档重新挂载,则现有的 head 元素将被删除并替换为一个全新的元素,从而删除 Vite 在开发过程中注入的任何 style 元素。

在 Remix 中,此问题可能由于水合错误而发生,因为它会导致 React 从头开始重新渲染整个页面。水合错误可能是由您的应用程序代码引起的,但也可能是由操纵文档的浏览器扩展引起的。

这是一个已知的 React 问题,已在其 金丝雀发布渠道 中修复。如果您了解所涉及的风险,可以将您的应用程序固定到特定的 React 版本,然后使用 包覆盖 来确保这是整个项目中使用的唯一 React 版本。例如

{
  "dependencies": {
    "react": "18.3.0-canary-...",
    "react-dom": "18.3.0-canary-..."
  },
  "overrides": {
    "react": "18.3.0-canary-...",
    "react-dom": "18.3.0-canary-..."
  }
}

作为参考,这是 Next.js 如何代表您在内部处理 React 版本控制的方式,因此这种方法比您想象的更广泛使用,即使它不是 Remix 默认提供的内容。

再次强调,Vite 注入的样式出现的此问题仅在开发环境中发生。**生产环境构建不会出现此问题**,因为会生成静态 CSS 文件。

CSS URL 导入

管理 CSS 文件的另一种主要方法是使用 Vite 的显式 URL 导入

Vite 允许您将 ?url 附加到 CSS 文件导入以获取文件的 URL(例如 import href from "./styles.css?url")。然后,可以通过路由模块的 links 导出 将此 URL 传递给 Remix。这将 CSS 文件绑定到 Remix 的路由生命周期中,确保在应用程序中导航时样式被注入和从文档中删除。

例如,使用前面相同的 Button 组件示例,您可以导出一个 links 数组以及组件,以便使用者可以访问其样式。

import buttonCssUrl from "./Button.css?url";

export const links = [
  { rel: "stylesheet", href: buttonCssUrl },
];

export function Button(props) {
  return <button {...props} className="Button__root" />;
}

导入此组件时,使用者现在还需要导入此 links 数组并将其附加到其路由的 links 导出

import {
  Button,
  links as buttonLinks,
} from "../components/Button";

export const links = () => [...buttonLinks];

export default function HelloRoute() {
  return <Button>Hello!</Button>;
}

这种方法在规则排序方面更具可预测性,因为它可以让您细粒度地控制每个文件并在开发和生产环境之间提供一致的行为。与开发环境中的捆绑 CSS 不同,当不再需要样式时,它们会从文档中删除。如果页面的 head 元素被重新挂载,则路由定义的任何 link 标签也将被重新挂载,因为它们是 React 生命周期的组成部分。

这种方法的缺点是可能导致大量样板代码。

如果您有很多可重用的组件,每个组件都有自己的 CSS 文件,则需要手动将每个组件的所有 links 传递到您的路由组件,这可能需要通过多个组件级别向上传递 CSS URL。这也可能容易出错,因为很容易忘记导入组件的 links 数组。

尽管它有优点,但您可能会发现与 CSS 捆绑相比,这太麻烦了,或者您可能会发现额外的样板代码是值得的。对此没有正确或错误的方法。

结论

在 Remix 应用程序中管理 CSS 文件时,最终取决于个人喜好,但以下是一个很好的经验法则

  • 如果您的项目只有少量 CSS 文件(例如,使用 Tailwind 时,在这种情况下您可能只有一个 CSS 文件),则应使用 CSS URL 导入。增加的样板代码最少,并且您的开发环境将更接近生产环境。
  • 如果您的项目包含大量与较小的可重用组件绑定的 CSS 文件,您可能会发现 CSS 捆绑减少样板代码的方式更加符合人体工程学。请注意权衡取舍,并以能够抵御文件排序更改的方式编写 CSS。
  • 如果您在开发过程中遇到样式消失的问题,您应该考虑使用 React Canary 版本,这样 React 重新挂载页面时就不会删除现有的 head 元素。
文档和示例根据以下许可证授权 MIT