SPA 模式
On this page

SPA 模式

¥SPA Mode

从一开始,Remix 就秉持着“你拥有自己的服务器架构”的理念。这就是 Remix 建立在 Web Fetch API 之上,并且可以通过内置或社区提供的适配器在任何现代 runtime 上运行的原因。虽然我们相信服务器可以为大多数应用提供最佳的用户体验/性能/SEO 等,但不可否认的是,在现实世界中,单页应用存在大量有效的用例:

¥From the beginning, Remix's opinion has always been that you own your server architecture. This is why Remix is built on top of the Web Fetch API and can run on any modern runtime via built-in or community-provided adapters. While we believe that having a server provides the best UX/Performance/SEO/etc. for most apps, it is also undeniable that there exist plenty of valid use cases for a Single Page Application in the real world:

  • 你不想管理服务器,更愿意通过 GitHub Pages 或其他 CDN 上的静态文件部署你的应用。

    ¥You don't want to manage a server and prefer to deploy your app via static files on GitHub Pages or another CDN

  • 你不想运行 Node.js 服务器。

    ¥You don't want to run a Node.js server

  • 你想将 迁移 React Router 应用 转换为 Remix

    ¥You want to migrate a React Router app to Remix

  • 你正在开发一种特殊类型的嵌入式应用,它无法进行服务器渲染。

    ¥You're developing a special type of embedded app that can't be server rendered

  • "你的老板根本不在乎 SPA 架构的用户体验上限,也不会给你的开发团队时间/能力去重新架构。" * Kent C.Dodds

    ¥"Your boss couldn't care less about the UX ceiling of SPA architecture and won't give your dev teams time/capacity to re-architect things" - Kent C. Dodds

这就是为什么我们在 2.5.0RFC)中添加了对 SPA 模式的支持,该模式很大程度上建立在 客户端数据 API 之上。

¥That's why we added support for SPA Mode in 2.5.0 (RFC), which builds heavily on top of the Client Data APIs.

SPA 模式要求你的应用使用 Vite 和 Remix Vite 插件

什么是 SPA 模式?

¥What is SPA Mode?

SPA 模式基本上就是你使用 createBrowserRouter/RouterProvider 设置自己的 React Router + Vite 时所获得的,但还附带一些额外的 Remix 功能:

¥SPA Mode is basically what you'd get if you had your own React Router + Vite setup using createBrowserRouter/RouterProvider, but along with some extra Remix goodies:

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

    ¥File-based routing (or config-based via routes())

  • 通过 route.lazy 自动进行基于路由的代码拆分。

    ¥Automatic route-based code-splitting via route.lazy

  • <Link prefetch> 支持快速预取路由模块

    ¥<Link prefetch> support to eagerly prefetch route modules

  • 通过 Remix <Meta>/<Links> API 管理 <head>

    ¥<head> management via Remix <Meta>/<Links> APIs

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

¥SPA Mode tells Remix that you do not plan on running a Remix server at runtime and that you wish to generate a static index.html file at build time, and you will only use Client Data APIs for data loading and mutations.

index.html 是由 root.tsx 路由中的 HydrateFallback 组件生成的。用于生成 index.html 的初始 "render" 不会包含任何比根路径更深的路由。如果你配置了 CDN/服务器,这可以确保 index.html 文件能够为 /(即 /about)之后的路径提供/补充。

¥The index.html is generated from the HydrateFallback component in your root.tsx route. The initial "render" to generate the index.html will not include any routes deeper than root. This ensures that the index.html file can be served/hydrated for paths beyond / (i.e., /about) if you configure your CDN/server to do so.

用法

¥Usage

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

¥You can get started quickly using the SPA Mode template in the repo:

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

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

¥Or, you can manually opt-into SPA mode in your Remix+Vite app by setting ssr: false in your Remix Vite plugin config:

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

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

开发

¥Development

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

¥In SPA Mode, you develop the same way you would for a traditional Remix SSR app, and you actually use a running Remix dev server to enable HMR/HDR:

npx remix vite:dev

生产

¥Production

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

¥When you build your app in SPA Mode, Remix will call the server handler for the / route and save the rendered HTML in an index.html file alongside your client side assets (by default build/client/index.html).

npx remix vite:build

预览

¥Preview

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

¥You can preview the production build locally with vite preview:

npx vite preview

vite preview 并非设计用作生产服务器。

部署

¥Deployment

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

¥To deploy, you can serve your app from any HTTP server of your choosing. The server should be configured to serve multiple paths from a single root /index.html file (commonly called "SPA fallback"). Other steps may be required if the server doesn't directly support this functionality.

对于简单示例,你可以使用 sirv-cli

¥For a simple example, you could use sirv-cli:

npx sirv-cli build/client/ --single

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

¥Or, if you are serving via an express server (although at that point you may want to consider just running Remix in SSR mode 😉):

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 而不是整个文档

¥Hydrating a div instead of the full document

如果你不想对完整的 HTML document 进行数据合并,你可以选择使用 SPA 模式,只对文档的一小部分(例如 <div id="app">)进行数据合并,并进行一些细微的更改。

¥If you don't want to hydrate the full HTML document, you can choose to use SPA mode and only hydrate a subsection of the document such as <div id="app"> with a few minor changes.

1.

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

¥Since Remix won't render the HTML document, you will need to provide that HTML outside Remix. The easiest way to do this is to just keep an app/index.html document with a placeholder you can replace with the Remix rendered HTML at build time to generate the final 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 注释。

¥The <!-- Remix SPA --> HTML comment is what we'll replace with the Remix HTML.

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

2.

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

¥Update your root route to render just the contents of <div id="app">:

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

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

3.

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

¥In your app/entry.server.tsx file, you'll want to take the Remix-rendered HTML and insert it into your static app/index.html file placeholder. You'll also want to stop pre-pending the <!DOCTYPE html> declaration like the default entry.server.tsx file does since that should be in your app/index.html file.

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.

更新 app/entry.client.tsx,将 <div id="app"> 而不是文档合并:

¥Update app/entry.client.tsx to hydrate the <div id="app"> instead of the document:

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

说明/警告

¥Notes/Caveats

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

    ¥SPA Mode only works when using Vite and the Remix Vite plugin

  • 你无法使用服务器 API,例如 headersloaderaction - 如果导出它们,构建会抛出错误。

    ¥You cannot use server APIs such as headers, loader, and action -- the build will throw an error if you export them

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

    ¥You can only export a HydrateFallback from your root.tsx in SPA Mode -- the build will throw an error if you export one from any other routes.

  • 由于没有正在运行的服务器,你无法从 clientLoader/clientAction 方法中调用 serverLoader/serverAction ——如果调用这些方法,将会引发运行时错误。

    ¥You cannot call serverLoader/serverAction from your clientLoader/clientAction methods since there is no running server -- those will throw a runtime error if called

服务器构建

¥Server Build

需要注意的是,Remix SPA 模式会在构建期间通过在服务器上执行根路由的 "pre-render" 来生成 index.html 文件。

¥It's important to note that Remix SPA mode generates your index.html file by performing a "pre-render" of your root route on the server during the build

  • 这意味着在创建 SPA 时,你仍然需要完成 "服务器构建" 和 "服务器渲染" 步骤,因此在使用引用仅客户端方面的依赖(例如 documentwindowlocalStorage 等)时务必谨慎。

    ¥This means that while you're creating a SPA, you still have a "server build" and "server render" step, so you do need to be careful about using dependencies that reference client-only aspects such as document, window, localStorage, etc.

  • 一般来说,解决这些问题的方法是从 entry.client.tsx 导入任何仅用于浏览器的库,这样它们就不会最终出现在服务器构建中。

    ¥Generally speaking, the way to resolve these issues is to import any browser-only libraries from entry.client.tsx so they don't end up in the server build

  • 否则,你通常可以使用 React.lazyremix-utils 中的 <ClientOnly> 组件来解决这些问题。

    ¥Otherwise, you can generally solve these by using React.lazy or the <ClientOnly> component from remix-utils

CJS/ESM 依赖问题

¥CJS/ESM Dependency Issues

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

¥If you are running into ESM/CJS issues with your app dependencies, you may need to play with the Vite ssr.noExternal option to include certain dependencies in your server bundle:

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 会将依赖打包到服务器构建中,并有助于避免运行服务器时出现运行时导入问题。

¥These issues are usually due to dependencies whose published code is incorrectly configured for CJS/ESM. By including the specific dependency in ssr.noExternal, Vite will bundle the dependency into the server build and can help avoid runtime import issues when running your server.

如果你有相反的用例,并且你明确希望将依赖保留在 bundle 外部,则可以使用相反的 ssr.external 选项。

¥If you have the opposite use-case and you specifically want to keep dependencies external to the bundle, you can use the opposite ssr.external option.

从 React Router 迁移

¥Migrating from React Router

我们还希望 SPA 模式能够帮助用户将现有的 React 路由应用迁移到 Remix 应用(无论是否是 SPA 应用!)。

¥We also expect SPA Mode to be useful in helping folks migrate existing React router apps over to Remix apps (SPA or not!).

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

¥The first step towards this migration is getting your current React Router app running on vite, so that you've got whatever plugins you need for your non-JS code (i.e., CSS, SVG, etc.).

如果你当前正在使用 BrowserRouter

¥If you are currently using BrowserRouter

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

¥Once you're using vite, you should be able to drop your BrowserRouter app into a catch-all Remix route per the steps in this guide.

如果你当前正在使用 RouterProvider

¥If you are currently using RouterProvider

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

¥If you are currently using RouterProvider, then the best approach is to move your routes to individual files and load them via route.lazy:

  • 请根据 Remix 文件约定命名这些文件,以便更轻松地迁移到 Remix (SPA)。

    ¥Name these files according to the Remix file conventions to make the move to Remix (SPA) easier

  • 将你的路由组件导出为名为 Component 的导出(用于 RR)和 default 导出(最终供 Remix 使用)

    ¥Export your route components as a named Component export (for RR) and also a default export (for eventual use by Remix)

将所有路由分别保存到各自的文件中后,你可以:

¥Once you've got all your routes living in their own files, you can:

  • 将这些文件移至 Remix app/ 目录

    ¥Move those files over into the Remix app/ directory

  • 启用 SPA 模式

    ¥Enable SPA Mode

  • 将所有 loader/action 函数重命名为 clientLoader/clientAction

    ¥Rename all loader/action function to clientLoader/clientAction

  • 将 React Router index.html 文件替换为导出 default 组件和 HydrateFallbackapp/root.tsx 路由

    ¥Replace your React Router index.html file with an app/root.tsx route that exports a default component and HydrateFallback

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