Remix 3 中的数据加载机制详解
了解 Remix 3 如何用控制器操作取代 loader 加载数据,以及路由、流式 Frame、等待状态、请求取消和错误处理的运作方式。
Remix 3 在处理 GET 路由的 controller action 中加载页面数据,该 action 返回一个标准的 Web Response,通常由 context.render() 生成。
如果你曾上线过 Remix 2 应用,第一反应可能是去找 loader 导出以及读取它的那个 hook。但在新版指南中你找不到它们。一些旧文章声称 loader 被沿用到了 Remix 3,这与 Remix 3 官方文档的内容并不相符。
Remix 3(v3.0.0)于 2026 年 10 月 1 日发布,它采用的模型不同于 Remix 1/2 基于 loader 的方式(参见这篇关于 Remix 早期 loader 架构的概述)。本文将介绍数据获取逻辑现在位于何处、首次加载和导航如何进行流式传输、加载中状态(pending UI)如何实现,以及错误最终流向哪里。
核心要点
- Remix 3 在 GET 路由对应的
createController()action 中获取页面数据,然后通过context.render()将数据作为 props 传给要渲染的组件。 - 路由在
app/routes.ts中声明,由 controller 实现,并通过router.map()注册。每个 controller 只负责其路由映射中的直接叶子节点。 - 没有
fallback的Frame会阻塞初始 HTML 的发送,直到其路由返回响应。带有fallback的Frame会先流式输出该 fallback,稍后再将其替换。 - 对于记录不存在等预期内的失败,应返回状态码为 404 或 400 的
Response对象。抛出的错误会导致router.fetch()reject,并由服务端边界返回 500。 - 如果延迟加载的 frame 在其 fallback 已发送后才失败,错误会被上报,而 fallback 会保留在页面上。
Remix 3 中用什么取代了 Loader?
在 Remix 3 中,controller action 承担了以往路由 loader 的职责。路由映射(route map)声明 URL 和 HTTP 方法,createController() 为每个路由叶子节点绑定一个 action。该 action 从 context.params 中读取带类型的值,await 所需数据,并返回一个 Response。Routing and Controllers 指南展示了这一模式,其中完全没有出现 loader 导出。
// app/actions/albums/controller.tsx
import { createController } from "remix/router";
import { routes } from "../../routes.ts";
// getAlbum and getRecommendations are app data functions, not Remix APIs.
import { getAlbum, getRecommendations } from "./data.ts";
import { AlbumRecommendations } from "./recommendations.tsx";
import { AlbumPage } from "./show-page.tsx";
export default createController(routes.albums, {
actions: {
async show(context) {
// The second argument is an app-defined signal parameter.
let album = await getAlbum(context.params.albumId, context.request.signal);
if (!album) return new Response("Album not found", { status: 404 });
return context.render(<AlbumPage album={album} />);
},
async recommendations(context) {
let album = await getAlbum(context.params.albumId, context.request.signal);
if (!album) return new Response("Album not found", { status: 404 });
let recommendations = await getRecommendations(album);
return context.render(<AlbumRecommendations albums={recommendations} />);
},
},
});
与 Remix 2 相比,最主要的变化在于数据流的方向。在 Remix 2 中,组件通过 hook “拉取”数据;而在 Remix 3 中,action 将数据以 props 的形式”推送”给组件。只有在 router 上安装了 render() 中间件时,context.render 才可用。若未安装,action 仍然可以返回普通的 Response 或 Response.json()。
Remix 3 中数据加载逻辑位于何处?
Remix 3 应用在 app/routes.ts 中声明 URL 和 HTTP 方法,通过 createController() 为每个路由叶子节点实现一个 action,并使用 router.map() 注册每个 controller。
// app/routes.ts
import { get, route } from "remix/routes";
export const routes = route({
albums: route("/albums", {
show: get("/:albumId"),
recommendations: get("/:albumId/recommendations"),
}),
});
routes.albums.show.href({ albumId: "thriller" }); // "/albums/thriller"
每个路由上的 href() 辅助函数都是带类型的。由于 :albumId 是路径变量,albumId 就是必填参数。如果之后重命名了该模式,任何仍传入旧键名的调用都无法通过类型检查。
// app/router.ts
import { createRouter } from "remix/router";
import albumsController from "./actions/albums/controller.tsx";
import { render } from "./middleware/render.tsx"; // the app's own render middleware, built with renderWith()
import { routes } from "./routes.ts";
export const router = createRouter({
middleware: [render()],
});
router.map(routes.albums, albumsController);
Controller 只负责传入映射中的直接叶子节点,因此嵌套映射需要单独调用 router.map()。Remix 会在初始化阶段检查每一个映射:如果某个 controller 缺少对应某个叶子节点的 action,初始化就会在处理任何请求之前抛出错误。实践中,建议每个页面规划一个 GET 路由,再为每个需要独立加载的区域各增加一个。
Remix 3 中的首次加载与导航如何运作?
首次加载时,Remix 3 的 render 中间件会流式输出一份完整的 HTML 文档。导航时,一旦浏览器中的 run() 启动,同源链接和表单就可以只重新填充某个具名 Frame,而无需替换整个文档。当某个区域需要独立请求时,页面使用 Frame 渲染它,并将其指向该区域的 GET 路由。该路由背后的 action 只返回这个区域的标记,不包含应用的文档外壳(document shell)。
// app/actions/albums/show-page.tsx
import { Frame } from "remix/component";
import type { Handle } from "remix/component";
import { routes } from "../../routes.ts";
import { Document } from "../document.tsx"; // app-defined shell component
import type { Album } from "./data.ts";
export function AlbumPage(handle: Handle<{ album: Album }>) {
return () => {
let { album } = handle.props;
return (
<Document title={album.title}>
<h1>{album.title}</h1>
<Frame
name="album-recommendations"
src={routes.albums.recommendations.href({ albumId: album.id })}
fallback={<p>Loading recommendations…</p>}
/>
</Document>
);
};
}
没有 fallback 的 Frame 是阻塞式的:在该 frame 的路由返回响应之前,Remix 不会发送第一块 HTML,因此页面会完整地一次性到达。添加 fallback 后,它会随初始 HTML 一起发送,待 frame 路由的 action 完成后再被替换为其输出。如果缺少该区域页面就失去意义,选择阻塞式;如果页面其余部分值得先展示,则使用 fallback。
浏览器中的 run() 启动后,同源链接和表单可以填充某个具名 frame,而不是对整个文档进行导航。这些属性记录在 Streaming UI with Frames 中:
| 属性 | 作用 |
|---|---|
data-rmx-target | 指定一个已挂载的具名 frame |
data-rmx-src | 设置用于填充该 frame 的请求 URL |
data-rmx-reset-scroll="false" | 保持当前滚动位置 |
data-rmx-history="push|replace" | 覆盖默认的历史记录行为 |
data-rmx-document | 让该链接或表单退出拦截 |
在链接上,地址栏仍会跳转到 href,而加载到 frame 中的内容由 data-rmx-src 决定。
<form
action={routes.albums.recommendations.href({ albumId: album.id })}
method="get"
data-rmx-target="album-recommendations"
>
<button type="submit">Refresh recommendations</button>
</form>
组件也可以自行重新加载 frame。handle.frame.reload() 会重新加载包含该组件的 frame,而 await handle.frames.get("album-recommendations")?.reload() 则用于定位页面上其他位置的具名 frame。如果没有挂载该名称的 frame,handle.frames.get() 会返回 undefined。Frame 路由应当只返回可信的 HTML,因此请先在服务端对不可信内容进行清理。
加载中状态:Fallback 与取消
Remix 3 通过 Frame fallback 和请求取消来处理加载中 UI。Frame fallback 就是初始的加载 UI,指南中并未提供全局的导航加载状态 hook。
取消机制需要由应用自行接入。传给 run() 的浏览器端 frame resolver 会接收一个 signal,指南建议将其传给 fetch(),这样当 frame 被移除或被更新的请求取代时,该 frame 请求就会停止。在服务端,render 中间件会将 request.signal 传给 renderToStream() 及其发起的 frame 请求。若要让取消信号传递到你自己的代码中,也应将 request.signal 传入数据库调用和 fetch 调用。根据 Errors and Cancellation 的说明,当请求被中止时,router 会以 request.signal.reason 进行 reject。
错误状态:Response 与上报 Hook
在 Remix 3 中,记录不存在或输入无效时,应返回状态码为 404 或 400 的 Response。抛出的错误会导致 router.fetch() reject,随后由服务端边界返回 500。Remix 3 没有兜底式的组件错误边界(error boundary),每种失败都有各自的去向:
| 失败类型 | 去向 |
|---|---|
| 预期内失败(记录不存在、输入错误) | 返回状态码为 404 或 400 的 Response |
| action 或中间件中抛出错误 | router.fetch() reject;服务端边界上报错误并返回 500 |
| 根节点或阻塞式 frame 渲染失败 | 错误被上报,流被 reject |
| fallback 已发送后,延迟加载的 frame 失败 | 错误被上报;fallback 保留在页面中 |
| 浏览器运行时错误(hydration、组件、调度器、frame) | 在 run() 返回值上触发 error 事件 |
在服务端边界,remix/node-fetch-server 中的 createRequestListener 接受一个 onError 选项。未捕获的错误会在这里记录日志,它也可以选择性地返回一个 Response,用以替代默认的 500。render 中间件上的 onError 选项和浏览器端的 error 事件仅负责上报错误。对于 frame,由应用自己的 resolver 决定失败响应的处理方式:要么为该区域生成限定范围的 HTML,要么将错误交给上述上报 hook。指南中的 resolver 会在任何非 OK 状态时抛出错误,因此每个失败的 frame 请求都会被上报。
当延迟加载的 frame 在其 fallback 已发送后才失败时,服务端无法再将页面替换为错误响应,因为响应体的字节已经发送出去了。错误会被上报,fallback 则保留在页面上。服务端日志会记录这一错误,但无法反映用户实际看到了什么。会话回放(session replay)可以弥补这一缺口:你可以看到用户面对一条始终未结束的”Loading…”提示,同时继续使用页面的其他部分。编写 fallback 时,应确保即使该区域始终没有填充完成,页面依然合理可用。
从 Remix 2 迁移
将 Remix 2 应用迁移到 Remix 3 是一次重写,而非升级。路由要迁移到带类型的路由映射中,数据加载要迁移到 controller action 中,UI 要迁移到 Remix 自有的组件运行时 remix/component 上。指南中没有迁移章节。Remix 2 的原地升级路径是 React Router v7,而 Remix 3 是一个独立的代码库。像这篇针对 Remix 1/2 撰写的 Next.js 迁移到 Remix 教程这类资料,只能作为背景参考,不能当作 Remix 3 的指南。
总结
Remix 3 的数据加载可以归结为:一个 GET 路由,一个 await 数据并返回 Response 的 controller action,以及为独立加载或独立失败的区域准备的 frame。上手时,可以先在 app/routes.ts 中映射某个页面的路由,将其数据获取逻辑迁移到 controller 中,再决定哪些区域需要 fallback。如需进一步了解如何使用这一新框架,请参阅 Remix 3 如何为 AI 编程智能体而设计。
常见问题
Remix 3 使用 React 吗?
不使用。Remix 3 保留了 JSX 语法,但并不基于 React 或 React Router 运行。它使用自有的组件模型,从 remix/component 导出。一个组件由两部分组成:接收 handle 且只运行一次的外层 setup 函数,以及每次更新时运行的内层 render 函数。useState 等 React hook 不属于该模型,因此 React 组件库未经修改无法在 Remix 3 中使用。
Remix 3 中的表单提交和数据变更(mutation)如何运作?
在 app/routes.ts 中使用 form() 路由辅助函数声明表单。默认情况下,它会在同一 URL 下创建两个叶子节点:一个用于展示表单的 index GET 路由,以及一个用于处理提交的 action POST 路由。两个叶子节点属于同一个 controller。在成功执行创建、更新或删除操作后,Remix 3 指南建议返回 303 重定向,以避免刷新页面时重复提交表单。
Remix 3 的 controller action 可以返回 JSON 而不是 HTML 吗?
可以。Controller action 可以返回任意 Web Response,包括 Response.json()、重定向、文件和流。Remix 3 的 Forms and Mutations 指南建议在自动补全、轮询、自包含组件、API 以及服务间调用等场景中使用 JSON。同时它也提醒,对于已经通过 HTML 响应加重定向正常工作的表单,不要再额外增加一条 JSON 变更路径。
Remix 3 和 React Router v7 是一回事吗?
不是。Remix v2 已并入 2024 年 11 月发布的 React Router v7,成为其 Framework Mode。对于希望继续使用 loader 和 React 的 Remix 2 应用,这才是升级路径。Remix 3 是一个独立项目,于 2025 年 5 月公布,基于自有的组件模型构建。迁移到 Remix 3 意味着需要重写路由、数据加载和 UI。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k