React Router Middleware 与 Context API 决策解析:设计目标、执行流程与源码实现
本篇基于 React Router 仓库中已接受的架构决策文档 0014-context-middleware.md,完整解读其 Middleware + Context API 的设计动机与取舍:为什么 context 要从 AppLoadContext 升级为类型安全的新 API、middleware 的 next 链如何在服务端与客户端分别落地、以及文档请求与数据请求的差异如何影响中间件的执行时机。读完后你将掌握 middleware 的导出方式、context 的类型安全读写、客户端/服务端双端实现原理,并能对照仓库源码定位 runMiddlewarePipeline 等关键调用链。
背景:为什么 Middleware 必须等 Single Fetch 落地
该决策文档(2025-01-22,状态 accepted)开篇指出:Middleware 曾是 React Router 仓库中关注度最高的 RFC/Proposal 之一。团队曾尝试过早期落地,但在 SSR 场景下发现缺少 Single Fetch 时 middleware 几乎没有意义,原因有两条:
- 无法减少数据库/API 查询次数:当每个 loader 对应一个独立的 HTTP 请求时,middleware 只是代码层面的便利性封装,没有功能上的实际影响——它不能把 N 个请求合并成 1 个;
- 缺少跨路由的共享请求作用域:独立的 HTTP 请求之间没有共享的 request scope,
context无处安放。
此后项目补齐了四块前置能力,middleware 才成为可能:
- 发布 Single Fetch(一个数据请求返回所有 loader 结果);
- 发布
dataStrategy,让用户在 SPA 中自行组装数据流(相当于 DIY middleware 的底层钩子); - 在 Remix the Web 项目中迭代 middleware/context API 的形态;
- 开发出非侵入式、类型安全且可组合的 context API。
理解这四点很重要:它解释了为什么 middleware 不是一个孤立的功能,而是 Single Fetch + dataStrategy + 类型安全 context 三者之上的“组合产物”。
决策一:用类型安全的 context API 替代 AppLoadContext
为什么不复用现有的 AppLoadContext
团队最初考虑过直接复用服务端传给 loader/action 的 AppLoadContext(通过声明合并增强的全局接口),这会让已有应用迁移成本最低。但有两个决定性缺点:
- 类型不安全:
AppLoadContext只是一个通过 declaration merging 增强的全局接口,本质是“请相信我”(trust me on this)式的约定,不是真正的类型安全。团队早就知道这不是一个好的类型化 API,并一直假设会在 future flag 之后用破坏性变更增强它。而 middleware 的引入会显著放大context的使用面——它不再只服务于自定义 server 适配器的用户,react-router-serve的用户也会开始使用。因此选择在context用户基数还小的时候就落地破坏性变更,而不是等用户面扩大后再改; - 客户端会继承同样的缺陷:要支持客户端 middleware,就必须引入客户端的
context概念,并且希望它与服务端是同一个 API。如果沿用AppLoadContext,就得再造一个全新的ClientAppLoadContext,从出生起就带着同样的类型问题。团队明确拒绝“先上线一个已知次优的客户端 API,之后再快速 break 一次”的路径。
新的 createContext API
决策是:在 middleware 的初始版本中一并落地破坏性的 context 变更。启用后,AppLoadContext 被一个类似 React.createContext 用法的类型安全 API 取代(决策文档中写作 unstable_createContext,当前仓库中该 API 已去掉 unstable 前缀,以下先按决策文档原文呈现):
let userContext = unstable_createContext<User>();
const userMiddleware: Route.unstable_MiddlewareFunction = async ({
context,
request,
}) => {
context.set(userContext, await getUser(request));
};
export const middleware = [userMiddleware];
// In some other route
export async function loader({ context }: Route.LoaderArgs) {
let user = context.get(userContext);
let posts = await getPosts(user);
return { posts };
}
对于已经在使用 AppLoadContext 的应用,文档给出了不拆分现有对象、保持同样形状的迁移路径:把整个旧对象装进一个 context 值里即可:
+ let appContext = unstable_createContext<AppLoadContext>()
function getLoadContext(req, res) {
let appLoadContext = { /* your existing object */ };
- return appLoadContext
+ return new Map([[appContext, appLoadContext]]);
}
function loader({ context }) {
- context.foo.something();
+ // Hopefully this can be done via find/replace or a codemod
+ context.get(appContext).foo.something()
// ...
}
在当前仓库中,这套 API 已经稳定化。createContext 与 RouterContextProvider 从 lib/router/utils.ts 导出(见 index.ts 的 export { createContext, RouterContextProvider })。实现上比决策文档更进一步:
createContext<T>(defaultValue?: T)返回一个RouterContext<T>对象,支持可选默认值——未设置时context.get()返回defaultValue;若没有默认值且未设置,则直接抛错("No value found for context"),见 utils.ts 的RouterContextProvider.get;- 文档中完整的鉴权用法示例(
userContext = createContext<User | null>(null)+authMiddleware+loader中 401 判断)已被收入 docs/api/utils/createContext.md 的 JSDoc 示例中,可直接作为可复制的生产用法。
客户端 Context 与每次导航独立实例
为了在客户端提供同构 API,框架还新增了客户端 context(这也是长期被社区请求的功能)。需要预置初始值(类似服务端的 getLoadContext)时,可用新的 getContext 方法返回一个 Map<RouterContext, unknown>:
let loggerContext = unstable_createContext<(...args: unknown[]) => void>();
function getContext() {
return new Map([[loggerContext, (...args) => console.log(...args)]])
}
// library mode
let router = createBrowserRouter(routes, { unstable_getContext: getContext })
// framework mode
return <HydratedRouter unstable_getContext={getContext}>
一个关键的对称性设计:服务端 context 天然有自动清理——它的生命周期与 request 绑定,请求结束即销毁。客户端要模仿这个行为,做法是每次 navigation/fetch 都新建一个 context 对象。这保证了跨导航的状态不会意外泄漏。
决策二:Middleware API 的设计目标与函数签名
团队为 middleware API 定下三条硬性标准:
- 允许在 handler(loader/action)被调用之前,自上而下(top-down)顺序执行逻辑;
- 允许在 handler 被调用之后,自下而上(bottom-up)修改出站响应;
- 允许每个路由挂载多个 middleware。
最终确定的 API 形态(决策文档版本,Route.unstable_MiddlewareFunction 即当前仓库中的 MiddlewareFunction):
const myMiddleware: Route.unstable_MiddlewareFunction = async (
{ request, context },
next,
) => {
// Do stuff before the handlers are called
context.user = await getUser(request);
// Call handlers and generate the Response
let res = await next();
// Amend the response if needed
res.headers.set("X-Whatever", "stuff");
// Propagate the response up the middleware chain
return res;
};
// Export an array of middlewares per-route which will run left-to-right on
// the server
export const middleware = [myMiddleware];
// You can also export an array of client middlewares that run before/after
// `clientLoader`/`clientAction`
const myClientMiddleware: Route.unstable_ClientMiddlewareFunction = (
{ context },
next,
) => {
//...
};
export const clientMiddleware = [myClientSideMiddleware];
几个值得注意的细节:
- 每个路由导出一个数组,服务端按从左到右顺序执行;
clientMiddleware导出客户端版本,运行在clientLoader/clientAction前后; - 省略
next也被支持:如果只想在请求前执行逻辑,可以不显式调用next,框架会自动帮你调用并把响应向上传播:
const myMiddleware: Route.unstable_MiddlewareFunction = async ({
request,
context,
}) => {
context.user = await getUser(request);
// Look ma, no next!
};
这个“忘记/省略 next 也安全”的行为在源码中有明确实现。见 runMiddlewarePipeline 与 callRouteMiddleware:递归遍历各路由的 middleware 元组列表,每层 next 函数带防重复调用保护(重复调用会抛 "You may only call next() once per middleware");middleware 返回值若符合结果类型则作为短路值直接返回,若调用了 next() 但未 return 响应,框架会自动把 next 的结果向上传播(“grab the response to add a header without re-returning it”);两者都没有时才替用户补调 next()。
服务端与客户端 middleware 的唯一语义差异
文档明确指出服务端与客户端 middleware 之间只有一个微妙差异:
- 服务端要求把
Response沿 middleware 链向上传播,因此next必须既调用 handlers、又生成最终 Response。文档请求中这是渲染好的 HTML 文档,数据请求中则是turbo-stream编码的Response; - 客户端导航没有单一的 Response 概念——它只是更新有状态的 router 并触发 React 重新渲染。因此客户端
next会运行 handlers 但不返回任何东西,中间件链上也没有需要向上冒泡的响应值。
从源码可以印证这一区分:runServerMiddlewarePipeline 的 handler 返回 Promise<Response>,并带一个 processResult 把 data() 返回值升级为真正的 Response;而 runClientMiddlewarePipeline 的 handler 返回 Promise<Record<string, DataStrategyResult>>(即 loader 结果映射),其错误处理逻辑会把错误路由到最近的错误边界,见 router.ts。
客户端实现:寄生于 dataStrategy
在 middleware 落地前,官方对“想要 middleware 的用户”的建议就是用 dataStrategy 自行实现。因此团队直接复用该 API,把 middleware 逻辑内置到默认 dataStrategy 中。这个选择有一个直接收益:实现非常简单;同时它有一个清晰的边界——一旦用户接管了 dataStrategy,就等于接管了整个数据流。为了避免“用户自定义 dataStrategy 想做自己的 middleware,而 router 还在底层跑自己的 middleware”这种令人困惑的叠加行为,文档给出了配套方案:向 dataStrategy 传入一个工具函数,让用户自定义 loader/action 执行逻辑的同时仍可复用官方 middleware 流程:
async function dataStrategy({ request, matches, defaultMiddleware }) {
let results = await defaultMiddleware(() => {
// custom loader/action execution logic here
});
return results;
}
提交请求时客户端 middleware 会执行两次
把 middleware 实现进 dataStrategy 的一个直接后果:客户端提交请求(submission)时,middleware 会先为 action 执行一次,再为 loaders 执行一次。团队权衡后认为这是正确选择,因为它精确模拟了全栈 React Router 应用中 SPA 导航的现有行为——action 和 revalidation 本就是两个独立的 HTTP 请求,各自独立跑 middleware 链。
对昂贵的 middleware,文档给出了标准的防御式写法:action 链与 loader 链共享同一个 context 实例,第二次执行可以跳过重复计算:
const expensiveMiddleware: Route.unstable_ClientMiddleware = async function ({
request,
context,
}) {
// Guard this such that we use the existing value if it exists from the action pass
context.something = context.something ?? (await getExpensiveValue());
};
为什么客户端 middleware 必须在 dataStrategy 里执行
文档中有一段容易被忽略但很关键的说明:客户端 middleware 必须运行在 dataStrategy 内部,否则会错误地为“已选择退出 revalidation”的 loader 执行 middleware。原因是 shouldRevalidate 函数负责解码“哪些 loader 要重跑”,而它把 actionResult 作为输入——也就是说,在 action 跑完之前无法决定哪些 loader 会运行。所以必须先跑一次 action 的 middleware 链,再对选中的 loaders 跑第二次。这也是上一节“执行两次”结论的底层原因。
服务端实现:为什么不能走 dataStrategy,以及 document/data POST 的行为差异
服务端 middleware 更棘手,因为要把 Response 向上冒泡,所以不能通过 dataStrategy 实现:文档 POST 请求需要同时拿到 action 和 loaders 的结果才能渲染 HTML,而 HTML 响应只能在 next 中渲染一次——这意味着 middleware 只能每个请求执行一次,而不是 action 一次、loaders 一次。
文档借此点出一个必须掌握的概念:document 请求与 data 请求在 POST 场景下结构不同:
- document POST 导航(JS 不可用):一次请求/响应,调用 action + loaders,产出单一 HTML 响应;
- data POST 导航(JS 可用):两次独立的请求/响应——一次调 action,第二次是 loaders 的 revalidation 调用。
由此产生一个行为细节:如果你在 middleware 里写请求级逻辑,loaders 阶段的行为在两种模式下会有差异:
function weirdMiddleware({ request }) {
if (request.method === "POST") {
// ✅ Runs before the action/loaders on document submissions
// ✅ Runs before the action on data submissions
// ❌ Does not runs before the loaders on data submission revalidations
}
}
官方建议:尽量避免在 middleware 中做 request 级别的特判逻辑;确实需要时,必须清楚 document 与 data 请求之间的这层行为差异。GET 导航不受影响,因为 document 与 data 的 GET 都是一次单一请求/响应。
执行流程推演:四个典型场景
文档用一组场景展示了请求穿过 middleware 链的完整时序,是理解 top-down 启动 / bottom-up 收尾节奏的最好材料。
场景 1:最简单的 document GET /a/b
- 启动 a 的
middleware - 启动 b 的
middleware - 并行执行 a、b 的
loaders - 渲染 HTML
Response,经next()逐级冒泡 - 结束 b 的
middleware - 结束 a 的
middleware
场景 2:有 clientMiddleware 但没有 clientLoader,客户端导航到 /a/b
- 启动 a 的
clientMiddleware - 启动 b 的
clientMiddleware - 发起
GET /a/b.data - 服务端:启动 a、b 的
middleware→ 并行跑 a/bloaders→ 渲染 HTML Response 冒泡 → 结束 b、a 的middleware - 响应返回客户端
- 结束 b、a 的
clientMiddleware
可以看到客户端 middleware 把整个数据请求“包”在了外面:服务端链在中间完整跑完,客户端链最后收尾。
场景 3:clientLoader 存在且不回调服务端(纯 SPA 模式)
- 启动 a、b 的
clientMiddleware - 并行执行 a、b 的
clientLoaders - 没有 Response 需要渲染——文档在这里留了个开放问题:可以冒泡
undefined,或冒泡一个Location;但Location方案“感觉有点怪”,因为它引入了除throw redirect之外的第二种重定向方式 - 结束 b、a 的
clientMiddleware
场景 4:clientLoader 回调服务端 loader(每个 loader 各自发独立数据请求)
- 启动 a、b 的
clientMiddleware - 并行执行 a、b 的
clientLoaders:- a 的
clientLoader调GET /a/b.data?route=a:启动 a 的middleware→ 执行 a loader → 渲染 turbo-stream Response 冒泡 → 结束 a 的middleware - b 的
clientLoader调GET /a/b.data?route=b:启动 a、b 的middleware→ 执行 b loader → 渲染 turbo-stream Response 冒泡 → 结束 b、a 的middleware
- a 的
- 结束 b、a 的
clientMiddleware
注意场景 4 中每个数据请求都重新走完整的服务端 middleware 链——这正是 Single Fetch 出现之前“middleware 无法减少请求数”问题的镜像,也是为什么 ?route= 参数化的单路由数据请求成为可能。
使用边界:Middleware 是数据关注点,不是事件系统
文档最后的 “Other Thoughts” 给出了明确的边界定义,这部分对避免误用至关重要:
- Middleware 面向数据,不是事件系统:
- 不要依赖 middleware 做“统计多少用户访问了某页面”之类的事件追踪;
- middleware 可能对 action 执行一次、对 loaders 再执行一次;
- 导航触发的 loaders 与 fetcher 触发的 loaders 各自独立执行 middleware;
- revalidation 可能让 middleware 执行多次;
- 选择退出 revalidation 的 loader 不会触发 middleware。
- Middleware 的价值是围绕路由树某个分支执行数据前后逻辑,典型场景包括:日志、鉴权/重定向、404 处理。
决策文档与当前仓库源码的对应关系
决策文档写于 API 的 unstable_ 阶段;在当前仓库中,其中提出的机制均已落地并可逐一对照:
| 决策文档概念 | 当前仓库实现 |
|---|---|
unstable_createContext<T>() |
稳定的 createContext,见 lib/router/utils.ts,并从 index.ts 公开导出 |
context 的 get/set 读写 |
RouterContextProvider 类(utils.ts),内部是 Map<RouterContext, unknown>,与文档中 new Map([[appContext, ...]]) 的形状一致 |
路由导出 middleware 数组 |
DataRouteObject 上的 middleware?: MiddlewareFunction[] 字段(utils.ts) |
next 链 + 省略 next 自动补调 |
runMiddlewarePipeline / callRouteMiddleware 递归实现(router.ts),含 next() 单次调用保护与结果自动冒泡 |
客户端 middleware 内置进默认 dataStrategy |
defaultDataStrategyWithMiddleware:无 middleware 时短路回退到 defaultDataStrategy,有则包裹 runClientMiddlewarePipeline(router.ts) |
| 服务端 Response 冒泡 | runServerMiddlewarePipeline 以 Promise<Response> 为结果类型,并将 data() 返回值升级为 Response(router.ts) |
unstable_MiddlewareFunction 类型 |
稳定的 MiddlewareFunction 类型,第一个参数与 loader/action 相同(request/params/context 等),第二个参数为 next(utils.ts) |
配套的资料与验证材料:
- 面向用户的操作指南在 docs/how-to/middleware.md;
- API 文档:createContext、RouterContextProvider;
- 行为测试:context-middleware-test.tsx 覆盖了 middleware 与 context 的组合行为;
- 该能力还延伸到了可观测性:instrumentation 系统同样为 middleware 提供了插桩入口(
middleware与lazy.middleware槽位,见 instrumentation.ts),说明 middleware 已被视为核心数据流的一部分。
小结
0014-context-middleware 这份决策文档的价值在于它完整记录了三次取舍:放弃“顺手复用 AppLoadContext”以换取真正的类型安全;把客户端 middleware 实现寄生在 dataStrategy 上以换取简单性与正确性(shouldRevalidate 依赖 action 结果决定了 middleware 必须分两次执行);以及接受 document/data POST 在 middleware 执行时机上的细微差异,并建议用户避开 request 级特判逻辑。结合当前仓库源码可以确认,文档中的设计蓝图——createContext + 路由级 middleware 数组 + next 递归链 + 客户端 dataStrategy 集成——都已完整落地并稳定化,阅读 docs/how-to/middleware.md 与上文列出的源码路径即可从 API 到实现完整走通。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00