跳转到内容

SPA Mode

SPA 模式

从一开始,Remix 就一直认为您拥有自己的服务器架构。这就是为什么 Remix 建立在 Web Fetch API 之上,并且可以通过内置或社区提供的适配器在任何现代 运行时 上运行。虽然我们相信拥有服务器可以为大多数应用提供最佳的用户体验/性能/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 的初始渲染将不包含任何比根更深的路由。这确保了 index.html 文件可以为 /(即 /about)以外的路径提供/水合(如果您配置了 CDN/服务器)。

用法

您可以使用 repo 中的 SPA 模式模板快速开始:

Terminal window
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:

Terminal window
npx remix vite:dev

生产

当您在 SPA 模式下构建应用程序时,Remix 将调用 / 路由的服务器处理程序,并将呈现的 HTML 保存在 index.html 文件中,与客户端资产一起保存(默认情况下为 build/client/index.html)。

Terminal window
npx remix vite:build

预览

您可以使用 vite preview 在本地预览生产版本:

Terminal window
npx vite preview

vite preview 不适用于生产服务器

部署

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

举一个简单的例子,你可以使用 sirv-cli

Terminal window
npx sirv-cli build/client/ --single

或者,如果您通过快速服务器提供服务(尽管此时您可能只想考虑在 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文档进行补充,您可以选择使用 SPA 模式,只对文档的某个子部分(如<div id="app">)进行补充,并进行一些小的更改。

1. 添加 index.html 文件

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

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

我们将用 Remix HTML 替换 <!-- Remix SPA --> HTML 注释。

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

2. 更新 root.tsx

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

app/root.tsx
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 文件中)。

app/entry.server.tsx
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"> 而不是文档:

app/entry.client.tsx
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——如果您从任何其他路由导出一个HydrateFallback,构建将引发错误。

  • 您无法从clientLoader/clientAction方法中调用serverLoader/serverAction,因为没有正在运行的服务器——如果调用它们,将引发运行时错误

服务器构建

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

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

CJS/ESM 依赖问题

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

vite.config.ts
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 路由器应用迁移到 Remix 应用(无论是否是 SPA!)。

迁移的第一步是让您当前的 React Router 应用在 vite 上运行,这样您就可以获得非 JS 代码所需的任何插件(即 CSS、SVG 等)。

如果您目前正在使用BrowserRouter

一旦您使用 vite,您应该能够按照本指南 中的步骤将您的 BrowserRouter 应用程序放入一个 catch-all Remix 路由中。

如果你目前正在使用 RouterProvider

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

  • 根据 Remix 文件约定命名这些文件,以便更轻松地迁移到 Remix (SPA)
  • 将路由组件导出为命名的组件导出(用于 RR)以及默认导出(最终供 Remix 使用)

将所有路由存储在各自的文件中后,您可以:

  • 将这些文件移至 Remix app/ 目录
  • 启用 SPA 模式
  • 将所有 loader/action 函数重命名为 clientLoader/clientAction
  • 将您的 React Router index.html 文件替换为导出 default 组件和 HydrateFallbackapp/root.tsx 路由