首页
/ React Router Middleware 与 Context API 决策解析:设计目标、执行流程与源码实现

React Router Middleware 与 Context API 决策解析:设计目标、执行流程与源码实现

2026-09-06 17:28:54作者:房伟宁

本篇基于 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/actionAppLoadContext(通过声明合并增强的全局接口),这会让已有应用迁移成本最低。但有两个决定性缺点:

  1. 类型不安全AppLoadContext 只是一个通过 declaration merging 增强的全局接口,本质是“请相信我”(trust me on this)式的约定,不是真正的类型安全。团队早就知道这不是一个好的类型化 API,并一直假设会在 future flag 之后用破坏性变更增强它。而 middleware 的引入会显著放大 context 的使用面——它不再只服务于自定义 server 适配器的用户,react-router-serve 的用户也会开始使用。因此选择在 context 用户基数还小的时候就落地破坏性变更,而不是等用户面扩大后再改;
  2. 客户端会继承同样的缺陷:要支持客户端 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 已经稳定化。createContextRouterContextProviderlib/router/utils.ts 导出(见 index.tsexport { 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>,并带一个 processResultdata() 返回值升级为真正的 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

  1. 启动 a 的 middleware
  2. 启动 b 的 middleware
  3. 并行执行 a、b 的 loaders
  4. 渲染 HTML Response,经 next() 逐级冒泡
  5. 结束 b 的 middleware
  6. 结束 a 的 middleware

场景 2:有 clientMiddleware 但没有 clientLoader,客户端导航到 /a/b

  1. 启动 a 的 clientMiddleware
  2. 启动 b 的 clientMiddleware
  3. 发起 GET /a/b.data
  4. 服务端:启动 a、b 的 middleware → 并行跑 a/b loaders → 渲染 HTML Response 冒泡 → 结束 b、a 的 middleware
  5. 响应返回客户端
  6. 结束 b、a 的 clientMiddleware

可以看到客户端 middleware 把整个数据请求“包”在了外面:服务端链在中间完整跑完,客户端链最后收尾。

场景 3:clientLoader 存在且不回调服务端(纯 SPA 模式)

  1. 启动 a、b 的 clientMiddleware
  2. 并行执行 a、b 的 clientLoaders
  3. 没有 Response 需要渲染——文档在这里留了个开放问题:可以冒泡 undefined,或冒泡一个 Location;但 Location 方案“感觉有点怪”,因为它引入了除 throw redirect 之外的第二种重定向方式
  4. 结束 b、a 的 clientMiddleware

场景 4:clientLoader 回调服务端 loader(每个 loader 各自发独立数据请求)

  1. 启动 a、b 的 clientMiddleware
  2. 并行执行 a、b 的 clientLoaders
    • a 的 clientLoaderGET /a/b.data?route=a:启动 a 的 middleware → 执行 a loader → 渲染 turbo-stream Response 冒泡 → 结束 a 的 middleware
    • b 的 clientLoaderGET /a/b.data?route=b:启动 a、b 的 middleware → 执行 b loader → 渲染 turbo-stream Response 冒泡 → 结束 b、a 的 middleware
  3. 结束 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 公开导出
contextget/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,有则包裹 runClientMiddlewarePipelinerouter.ts
服务端 Response 冒泡 runServerMiddlewarePipelinePromise<Response> 为结果类型,并将 data() 返回值升级为 Responserouter.ts
unstable_MiddlewareFunction 类型 稳定的 MiddlewareFunction 类型,第一个参数与 loader/action 相同(request/params/context 等),第二个参数为 nextutils.ts

配套的资料与验证材料:

小结

0014-context-middleware 这份决策文档的价值在于它完整记录了三次取舍:放弃“顺手复用 AppLoadContext”以换取真正的类型安全;把客户端 middleware 实现寄生在 dataStrategy 上以换取简单性与正确性(shouldRevalidate 依赖 action 结果决定了 middleware 必须分两次执行);以及接受 document/data POST 在 middleware 执行时机上的细微差异,并建议用户避开 request 级特判逻辑。结合当前仓库源码可以确认,文档中的设计蓝图——createContext + 路由级 middleware 数组 + next 递归链 + 客户端 dataStrategy 集成——都已完整落地并稳定化,阅读 docs/how-to/middleware.md 与上文列出的源码路径即可从 API 到实现完整走通。

登录后查看全文
热门项目推荐
相关项目推荐