React Router v7 已发布。 查看文档
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 插件配置中设置 ssr: false 来手动选择在你的 Remix+Vite 应用中启用 SPA 模式

// 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 插件 时有效

  • 你不能使用诸如 headersloaderaction 之类的服务器 API——如果你导出它们,构建将抛出错误

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

  • 你不能从你的 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 文件替换为导出一个 default 组件和 HydrateFallbackapp/root.tsx 路由
文档和示例在以下许可下 MIT