Remix 3 自定义中间件怎么写:用 context.set 提供类型化请求值
【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix
在 Remix 3 的应用里,你经常会遇到这类需求:每个请求都要带上一个请求 ID、一个数据库连接、或一个解析好的用户信息,并且下游的 controller、action 希望以带类型的方式读取这些值。Remix 3 的路由器通过(context, next)中间件契约解决这个问题:中间件用context.set(key, value)写入请求级值,下游代码用context.get(key)读取,并且 TypeScript 能根据中间件的类型推导出读取值的准确类型。本文基于 Request Handling 章节 的 "Custom middleware" 与 "Typed request context" 部分,配合仓库中的真实示例,走一遍从定义中间件到在 action 中类型安全地读取值的完整路径。
先明确中间件能拿到什么、能做什么
Remix 3 的每个中间件接收(context, next),返回一个Response,或者返回next()的结果。同一次router.fetch(...)调用里,所有中间件和 action 拿到的是同一个请求上下文对象,它初始带有原始request、解析后的url、可变的Headers副本、生效的method、匹配到的params和当前router。这些内置字段在 Routing and Controllers 章节 的表格中有完整列出,其中与本文直接相关的三个方法是:
context.set(key, value) // 在共享的请求上下文上存储一个请求级值 context.get(key) // 读取某个 context key 存储的值 context.has(key) // 判断某个 context key 是否已有值两条执行规则决定你写中间件时的位置:
await next()之前的代码在请求进入时执行,之后的代码在响应回程执行,可以检查或替换下游返回的响应;- 不调用
next()直接返回Response会中断链条——静态文件、CORS 预检、鉴权拒绝和缓存都靠这个机制提前应答。
Remix 有三种中间件作用域:router 中间件在路由匹配前对每个请求运行;controller 中间件运行于该 controller 直接拥有的 action;action 中间件只运行于单个 action。控制器和 action 中间件只在路由匹配后才执行。
第一步:用 createContextKey 定义一个类型化 key
不要直接用字符串当 key。remix/router导出的createContextKey创建一个类型安全的 key,它的泛型参数就是存储值的类型:
import { createContextKey, type Middleware } from "remix/router"; export const RequestId = createContextKey<string>();createContextKey也可以带一个默认值:createContextKey<value>(defaultValue)。有默认值时,context.get(key)在未 set 的情况下返回默认值;没有默认值时返回undefined。这一点在 RequestContext 源码 的get实现中可以直接核对。
第二步:写中间件,并用 Middleware 类型声明它提供的值
文档给出的示例是一个请求 ID 中间件:在 action 执行前写入 ID,在响应回程把同一个 ID 加进响应头:
import { createContextKey, type Middleware } from "remix/router"; export const RequestId = createContextKey<string>(); export function requestId(): Middleware<{ key: typeof RequestId; value: string; }> { return async (context, next) => { let id = crypto.randomUUID(); context.set(RequestId, id); let response = await next(); let headers = new Headers(response.headers); headers.set("X-Request-Id", id); return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); }; }Middleware<{ key, value }>这个类型参数是关键:它向 Remix 的类型系统声明"这个中间件往上下文里加了什么"。一旦requestId()出现在有类型的 router 中间件栈里,下游 controller 里context.get(RequestId)的类型就是string,而不是string | undefined。
如果中间件只是拒绝请求而不提供值,就不需要类型参数,直接返回Response且不调用next():
function requireJson(): Middleware { return (context, next) => { let mediaType = context.headers.get("Content-Type")?.split(";", 1)[0].trim().toLowerCase(); if (mediaType !== "application/json") { return new Response("Expected JSON", { status: 415 }); } return next(); }; }第三步:把中间件放进 router,让类型流入 controller
在app/router.ts中,把requestId()加进createRouter的middleware数组,然后用RouterContext<typeof router>从当前 router 推导出完整的应用上下文,再通过模块扩展把它设为 controller 的默认上下文:
import { render } from "remix/middleware/render"; import { staticFiles } from "remix/middleware/static"; import { createRouter, type RouterContext } from "remix/router"; import { requestId } from "./middleware/request-id.ts"; import controller from "./actions/controller.tsx"; import { routes } from "./routes.ts"; export const router = createRouter({ middleware: [staticFiles("./public", { index: false }), requestId(), render()], }); export type AppContext = RouterContext<typeof router>; declare module "remix/router" { interface RouterTypes { context: AppContext; } } router.map(routes, controller);RouterContext<typeof router>按中间件数组的顺序逐项收集每个中间件声明的 context 条目,所以放在栈里的顺序必须满足依赖关系:提供者要放在消费者前面(文档给的例子是formData()在methodOverride()之前、compression()在它要包裹的staticFiles()之前)。
模块扩展之所以有用,是因为 controller 在独立文件里创建、之后才被router.map(...)映射到这个 router,它们不可能显式引用这个 router 的类型。文档同时提醒:一个应用里有多个 router 时,应改为给每个 controller 传显式的 context 类型,而不是设一个全局默认。
到此,controller 里的 action 就能直接以string类型读取请求 ID:
// inside an action: action(context) { let id = context.get(RequestId); // 类型是 string // ... }可选:把值安装为 context 的直接属性
context.set(key, value)接受第三个参数{ property },用于把值安装成请求上下文上的一个只读直接属性。仓库里的真实例子是 bookstore 演示的数据库中间件:
import { createContextKey, type Middleware } from 'remix/router' export const databaseContext = createContextKey<Database>() export function loadDatabase(): Middleware<{ key: typeof databaseContext value: Database property: 'db' }> { return (context, next) => { context.set(databaseContext, db, { property: 'db' }) return next() } }安装后下游可以像context.db一样直接访问,而不是每次context.get(databaseContext)。内置的formData()提供context.formData、render()提供context.render(...)走的就是同一机制。
从 RequestContext 实现 可以看到这个机制的三条约束,违反时会在运行时抛错:
- 属性名不能与
RequestContext上已有的属性(如request、url、headers)重名; - 同一个 context key 不能先后用两个不同的属性名;
- 不同的 context key 不能安装到同一个属性名。
验证:类型层面和响应头两个检查点
类型检查。判断中间件是否正确接入的最直接方式是编译:requestId()进入createRouter的middleware数组后,context.get(RequestId)推导为string;如果中间件不在栈里,同一次读取推导为string | undefined(key 无默认值时运行结果是undefined)。用tsc或你 IDE 的类型提示确认这一点,不需要运行服务器。
响应头检查。按上面的requestId()示例,每个经过该中间件的响应都会带上X-Request-Id请求头。启动server.ts(默认端口见 Request Handling 章节 的 Node 入口示例,默认 44100)后,对任意路由发起请求,检查响应头中是否存在X-Request-Id。它由crypto.randomUUID()生成,每次请求值不同,文档没有给出固定预期值,检查时应以"该请求的响应头里存在且与后续日志一致"为判断依据。
边界与替代路径
- 中间件链存变量时。内联
middleware: [...]数组是默认写法。只有当一条可复用的中间件链必须存进变量时,才用createMiddleware(...)保留它的 tuple 类型,并用MiddlewareContext<typeof middleware>推导结果上下文。 - action 之外的辅助代码要读值时。请求上下文是显式的 per-request 值,不是全局。中间件和 action 之外的 helper 要拿它,从
remix/middleware/async-context引入asyncContext()加进中间件栈,然后在 helper 里调getContext();该中间件依赖 Node 的 async context 支持,跨运行时可移植的做法是把需要的值作为函数参数传进去。 - 职责边界。文档建议自定义中间件聚焦单一请求生命周期关注点:路由特定的数据加载或校验放 action;跨多路由共享的行为——请求 ID、session、认证、安全头、数据库访问——才放进中间件管线。
- 运行时可移植性。router 契约本身可移植,但中间件栈里每个包未必:例如静态文件和压缩中间件用了 Node 的 API,在 worker 上应改用平台能力。
写完后对照两条文档给出的选型标准自查一遍:这个值是否真正请求级(换掉它应该随请求变化)?下游读取点是否需要类型保证而不是每次判空?两者都成立时,createContextKey+context.set+Middleware<{ key, value }>就是完整方案。
【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考