SPA 模式
本页内容

SPA 模式

从一开始,Remix 就始终认为您拥有自己的服务器架构。这就是 Remix 建立在 Web Fetch API 之上的原因,并且可以通过内置或社区提供的适配器在任何现代 运行时 上运行。虽然我们认为拥有服务器为大多数应用程序提供了最佳的 UX/性能/SEO 等,但不可否认的是,在现实世界中确实存在大量对单页应用程序的有效用例

  • 您不想管理服务器,而是希望通过 Github Pages 或其他 CDN 上的静态文件部署您的应用程序
  • 您不想运行 Node.js 服务器
  • 您想 将 React Router 应用程序迁移 到 Remix
  • 您正在开发一种特殊的嵌入式应用程序,无法进行服务器端渲染
  • "您的老板可能根本不在乎 SPA 架构的 UX 天花板,并且不会给您的开发团队时间/能力来重新架构事物" - Kent C. Dodds

这就是我们在 2.5.0 (RFC) 中添加对SPA 模式支持的原因,它大量构建在 客户端数据 API 之上。

SPA 模式要求您的应用程序使用 Vite 和 Remix Vite 插件

什么是 SPA 模式?

SPA 模式基本上是如果您使用 createBrowserRouter/RouterProvider 使用 React Router + Vite 设置,但还带有一些额外的 Remix 好处

  • 基于文件的路由(或通过 routes() 基于配置)

  • 通过 route.lazy 自动进行基于路由的代码分割
  • 支持 <Link prefetch> 预加载路由模块
  • 通过 Remix 的 <Meta>/<Links> API 管理 <head>

SPA 模式告诉 Remix 你不打算在运行时运行 Remix 服务器,并且希望在构建时生成一个静态的 index.html 文件,并且你只使用 客户端数据 API 进行数据加载和修改。

index.html 是根据 root.tsx 路由中的 HydrateFallback 组件生成的。生成 index.html 的初始“渲染”将不包含任何深度超过根路由的路由。这确保了如果将你的 CDN/服务器配置为这样做,则可以为超出 / 的路径(例如 /about)提供服务/水合 index.html 文件。

使用

你可以使用仓库中的 SPA 模式模板快速入门

npx create-remix@latest --template remix-run/remix/templates/spa

或者,你可以在你的 Remix+Vite 应用中手动选择加入 SPA 模式,方法是在你的 Remix Vite 插件配置中设置 ssr: false

// vite.config.ts
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    remix({
      ssr: false,
    }),
  ],
});

开发

在 SPA 模式下,你以与传统 Remix SSR 应用相同的方式进行开发,并且实际上使用运行中的 Remix 开发服务器来启用 HMR/HDR

npx remix vite:dev

生产

在 SPA 模式下构建应用时,Remix 会调用 / 路由的服务器处理程序,并将渲染的 HTML 保存到 index.html 文件中,以及你的客户端资源(默认情况下为 build/client/index.html)。

npx remix vite:build

预览

你可以使用 vite preview 在本地预览生产构建

npx vite preview

vite preview 不是设计用于作为生产服务器

部署

要进行部署,你可以从你选择的任何 HTTP 服务器提供你的应用服务。服务器应配置为从单个根 /index.html 文件(通常称为“SPA 回退”)提供多个路径的服务。如果服务器不直接支持此功能,则可能需要其他步骤。

例如,你可以使用 sirv-cli

npx sirv-cli build/client/ --single

或者,如果你通过 express 服务器提供服务(尽管此时你可能希望考虑在 SSR 模式下运行 Remix 😉)

app.use("/assets", express.static("build/client/assets"));
app.get("*", (req, res, next) =>
  res.sendFile(
    path.join(process.cwd(), "build/client/index.html"),
    next
  )
);

水合 div 而不是整个文档

如果你不想水合整个 HTML document,你可以选择使用 SPA 模式,并且只水合文档的一个子部分,例如 <div id="app">,并进行一些小的更改。

1. 添加 index.html 文件

由于 Remix 不会渲染 HTML 文档,因此你需要在 Remix 之外提供该 HTML。最简单的方法是保留一个 app/index.html 文档,其中包含一个占位符,你可以在构建时用 Remix 渲染的 HTML 替换它,以生成最终的 index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>My Cool App!</title>
  </head>
  <body>
    <div id="app"><!-- Remix SPA --></div>
  </body>
</html>

<!-- Remix SPA --> HTML 注释是我们将在其中替换 Remix HTML 的位置。

因为空格在 DOM/VDOM 树中是有意义的 - 因此不要在其周围和周围的 div 中包含任何空格,否则你会遇到 React 水合问题

2. 更新 root.tsx

更新你的根路由以仅渲染 <div id="app"> 的内容

export function HydrateFallback() {
  return (
    <>
      <p>Loading...</p>
      <Scripts />
    </>
  );
}

export default function Component() {
  return (
    <>
      <Outlet />
      <Scripts />
    </>
  );
}

3. 更新 entry.server.tsx

在你的 app/entry.server.tsx 文件中,你需要获取 Remix 渲染的 HTML 并将其插入到你的静态 app/index.html 文件占位符中。你还要停止像默认的 entry.server.tsx 文件那样预先添加 <!DOCTYPE html> 声明,因为该声明应该在你的 app/index.html 文件中)。

import fs from "node:fs";
import path from "node:path";

import type { EntryContext } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { renderToString } from "react-dom/server";

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  const shellHtml = fs
    .readFileSync(
      path.join(process.cwd(), "app/index.html")
    )
    .toString();

  const appHtml = renderToString(
    <RemixServer context={remixContext} url={request.url} />
  );

  const html = shellHtml.replace(
    "<!-- Remix SPA -->",
    appHtml
  );

  return new Response(html, {
    headers: { "Content-Type": "text/html" },
    status: responseStatusCode,
  });
}

如果你在应用中当前没有 app/entry.server.tsx 文件,则可能需要运行 npx remix reveal

4. 更新 entry.client.tsx

更新 app/entry.client.tsx 以水合 <div id="app"> 而不是文档

import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";

startTransition(() => {
  hydrateRoot(
    document.querySelector("#app"),
    <StrictMode>
      <RemixBrowser />
    </StrictMode>
  );
});

如果你在应用中当前没有 app/entry.client.tsx 文件,则可能需要运行 npx remix reveal

注意事项/警告

  • SPA 模式仅在使用 Vite 和 Remix Vite 插件 时有效

  • 你不能使用服务器 API,例如 headersloaderaction - 如果导出它们,构建将抛出错误

  • 你只能从 SPA 模式下的 root.tsx 导出 HydrateFallback - 如果从任何其他路由导出它,构建将抛出错误。

  • 你不能从你的 clientLoader/clientAction 方法中调用 serverLoader/serverAction,因为没有运行的服务器 - 如果调用它们,它们将抛出运行时错误

服务器构建

需要注意的是,Remix SPA 模式通过在构建期间对服务器上的根路由执行“预渲染”来生成你的 index.html 文件

  • 这意味着,虽然你正在创建 SPA,但你仍然有一个“服务器构建”和“服务器渲染”步骤,因此你需要小心使用引用客户端方面的依赖项,例如 documentwindowlocalStorage 等。
  • 一般来说,解决这些问题的方法是从 entry.client.tsx 导入任何仅限浏览器的库,这样它们就不会出现在服务器构建中
  • 否则,你通常可以通过使用 React.lazy 或来自 remix-utils<ClientOnly> 组件来解决这些问题

CJS/ESM 依赖项问题

如果你遇到应用依赖项的 ESM/CJS 问题,则可能需要使用 Vite 的 ssr.noExternal 选项将某些依赖项包含在你的服务器包中

import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [
    remix({
      ssr: false,
    }),
    tsconfigPaths(),
  ],
  ssr: {
    // Bundle `problematic-dependency` into the server build
    noExternal: ["problematic-dependency"],
  },
  // ...
});

这些问题通常是由于依赖项的已发布代码对于 CJS/ESM 的配置不正确造成的。通过在 ssr.noExternal 中包含特定依赖项,Vite 会将依赖项捆绑到服务器构建中,并且可以帮助避免在运行服务器时出现运行时导入问题。

如果你有相反的用例,并且你特别希望将依赖项保持在包外部,则可以使用相反的 ssr.external 选项。

从 React Router 迁移

我们还预计 SPA 模式将有助于人们将现有的 React Router 应用迁移到 Remix 应用(无论是否为 SPA!)。

此迁移的第一步是让你的当前 React Router 应用在 vite 上运行,以便你拥有非 JS 代码(即 CSS、SVG 等)所需的任何插件。

如果你当前正在使用 BrowserRouter

一旦你使用 vite,你应该能够根据 本指南 中的步骤将你的 BrowserRouter 应用放入一个通配符 Remix 路由中。

如果你当前正在使用 RouterProvider

如果你当前正在使用 RouterProvider,那么最好的方法是将你的路由移动到单独的文件中,并通过 route.lazy 加载它们

  • 根据 Remix 文件约定命名这些文件,以便更容易迁移到 Remix(SPA)
  • 将你的路由组件导出为命名 Component 导出(用于 RR)以及 default 导出(用于 Remix 最终使用)

一旦你将所有路由都放在自己的文件中,你可以

  • 将这些文件移到 Remix app/ 目录中
  • 启用 SPA 模式
  • 将所有 loader/action 函数重命名为 clientLoader/clientAction
  • 将你的 React Router index.html 文件替换为一个 app/root.tsx 路由,该路由导出一个 default 组件和 HydrateFallback
文档和示例根据以下许可证授权 MIT