H3 路由规则缓存
swr: 60 一行就能让一条路由带上缓存。但缓存是最容易「看起来对、实际在给所有人发同一个人的数据」的功能——所以 H3 在这里做的不是加法,而是一串减法:把不该进缓存的请求数据剥掉、把不该存的响应丢掉、把会串号的键隔开。
这一节讲清三件事:键怎么生成、什么数据能进 handler、以及在什么情况下根本不缓存。
先注册处理器,否则启动即失败
核心包 h3/rules 不自带缓存实现。用 cache 或 swr 必须注册处理器;规则存在而处理器缺失时,H3 在创建匹配器的阶段就抛错——不让配置静默失效。
import { routeRules } from "h3/rules";
import { cache } from "h3/rules/cache";
// 默认用内存存储,够本地开发和小服务用
app.use(routeRules({ "/blog/**": { swr: 60 } }, { handlers: { cache } }));
h3/rules/cache 背后是 ocache,它是可选的对等依赖(peer dependency)。不用缓存的引用方不会把它打进产物。
要换存储或改默认项,自己造一个处理器实例:
import { createOcacheRuleHandler } from "h3/rules/cache";
app.use(
routeRules(rules, {
handlers: {
cache: createOcacheRuleHandler({
storage: myStorage, // 所有规则共用一个存储实例
defaults: { staleMaxAge: 60 }, // ocache 默认项(规则上的选项优先)
// id: "my-app", // 需要跨进程稳定键时指定
}),
},
}),
);
缓存键由四段组成
cache 规则包住的是命中的路由 handler,所以只有 h3 真的匹配到路由时才可能缓存。默认分组是 h3/route-rules,名字是:
<handlerScope>:<method>:<rulePattern>:<matchedRoute>
四段各挡一类事故:
| 段 | 作用 |
|---|---|
handlerScope |
同时绑定处理器实例与路由 handler。两个应用或匹配器即使共用模块级 cache 导出,也读写不到对方的条目 |
method |
防止无响应体的 HEAD 结果被当成 GET 存起来 |
rulePattern |
归一化后的规则键,与 h3 存储路由的写法一致:/café/** 存成 /caf%C3%A9/** |
matchedRoute |
命中的路由本身,不同路由不共享条目 |
⚠️ 可以照常覆盖
group与name,但显式name会整个替换默认名,连隔离性一起丢掉。除非你清楚键空间里只有自己,否则不要动。
handlerScope 会随进程变化。这意味着用持久化存储时,每个进程各自生成一套条目,worker 之间不共享。要跨进程稳定,用 createOcacheRuleHandler({ id: "my-app" }),并且这个实例只给一个应用用。
💡 默认情况下 ocache 给每个被缓存的 handler 一份独立存储;
createOcacheRuleHandler改成所有规则共用一个存储(你传的storage,或首次缓存时新建的内存存储)。结果是整个应用一个有容量上限的缓存,而不是每个路由一份。相同id的两个实例也会共享这份默认存储。
只有键里体现的数据才进 handler
这条设计是整个缓存安全性的地基:键里没有的东西,不能影响响应。
- 查询串默认被剥掉。 被缓存的 handler 收到的 URL 没有查询串,查询也不进键。要让某些参数参与区分,用
allowQuery: ["page", "q"];全量纳入用allowQuery: true。 - 请求头默认被剥掉,除非列在
varies里。条件请求头、链路追踪头、请求 ID 头同样被剥——它们要在缓存规则外层的中间件里读。
以下响应会被返回但不入缓存:
| 情形 | 原因 |
|---|---|
用了键没覆盖的 Vary 头 |
声明了变化维度却没进键,存下来会串号 |
Cache-Control: no-store / private / no-cache |
明确表示不可共享 |
状态码不是 200、203、301、308 |
错误与非常规响应不该被复用 |
响应体超过 maxBodySize |
防止大对象撑爆存储 |
解析条目有 30 秒截止时间(maxResolveTime):超时后所有等待者被拒、条目被逐出,同时中止 event.req.signal。你的 handler 如果发起了上游 fetch,记得把这个 signal 传下去,否则上游请求会继续跑。
凭据与 Cookie:默认剥掉,是保护不是限制
❗ H3 在调用被缓存的 handler 之前,会移除
Cookie、Authorization、Proxy-Authorization。这三个头不参与自动生成的键。
如果没有这层保护,为某个用户渲染的响应会被存到匿名键下、发给其他用户,还带着 public, s-maxage=N 让 CDN 一起缓存——这是缓存类事故里最经典的一种。
几条配套规则:
- 要让授权信息进入 handler,写
cache: { allowAuthorization: true }。H3 会把它哈希后并入键,并加到Vary,于是每个凭据一条独立条目。 - 有些运行时的头部不可变、请求也无法重建。这种情况下如果 H3 无法安全移除凭据,它会返回 500,而不是把带凭据的响应存到无凭据的键下。
- 列在
varies里的头对 handler 可见,每个值单独成键并写入响应的Vary。但凭据是例外:列在varies里不会转发,得用allowAuthorization。 - handler 设置的
Set-Cookie只发给产生它的那个请求,永不入缓存。allowCookies只管请求侧:它决定哪些 Cookie 值进 handler 并参与区分。每次响应都要种 Cookie 的路由,不要开缓存。
Cache-Control 由谁说了算
| handler 给出的值 | 结果 |
|---|---|
含 private 或 no-store |
原样保留,且拒绝存储 |
| 其他值 | 被替换成规则生成的 public, max-age=N, s-maxage=N |
需要完全掌控最终值时,用 headers 规则(它在 cache 之后执行);或者 sendCacheControl: false 关掉生成的头。
换成自己的缓存实现
不想用 ocache 也可以。核心包提供工厂函数,defineCachedHandler 会收到「命中的 handler + 合并后的规则选项」(group、name 已填好),返回一个带缓存的包装器——Nitro 这类框架就是在这一层接入的:
import { createCacheRuleHandler } from "h3/rules";
// 用核心工厂接入第三方缓存实现:group 与 name 已由 h3 填好
const cache = createCacheRuleHandler({
defineCachedHandler: (handler, opts) => myCachedHandler(handler, opts),
});
注意 RouteRuleConfig["cache"] 里的声明式选项用的是与 ocache 兼容的 CacheRuleOptions 结构。像 getKey、shouldCache、getMaxAge 这类实现钩子不是规则数据,要通过工厂的 defaults 传,不能写在规则里。
执行顺序决定谁能拦住谁
各规则按 order 从小到大执行,外层包裹内层:
cors (-3) → [-2 空位] → headers (-1) → 自定义规则 (0) → redirect (1) → proxy (2) → cache (3) → 路由 handler

展开说:
cors最早,能在任何规则之前直接答掉预检;headers包住内层所有规则,所以它的值能盖掉缓存生成的响应头;redirect、proxy、cache都能不调用下一层就结束请求,各自占一个 order;cache在最内层,它自己调用路由 handler;- 自定义规则默认
order: 0,因此排在redirect、proxy、cache之前。
-2 这个位置是故意留空的,给那种必须在 headers、redirect、proxy、cache 之前短路的自定义规则。同一 order 按规则名排序——这个顺序确定但没有语义,要短路的处理器必须显式指定 order。
最反直觉的一条:cache 会截断全局中间件
因为 cache 规则直接分发路由 handler,而不是调用下一层,所以:
注册在
routeRules()之后的全局中间件,对「被 cache 规则命中且可缓存」的请求永远不会执行——缓存未命中时也不执行。
app.use(routeRules({ "/api/**": { swr: 60 } }, { handlers: { cache } }));
app.use(requireAuth); // 对可缓存的 /api/** 请求永远不跑,连第一次都不跑
app.get("/api/private/:id", handler);
正确顺序是把 routeRules() 放在所有必须执行的全局中间件之后:
app.use(requireAuth); // 先跑,每个请求都过
app.use(routeRules({ "/api/**": { swr: 60 } }, { handlers: { cache } }));
路由级中间件不受影响——它是被缓存规则调用的那个 handler 的一部分,未命中时执行、命中时连响应一起被缓存(这本身也是一条别在路由级中间件里做凭据校验的理由)。
redirect 和 proxy 同样会结束链条,但它们直接答掉请求、从不进入路由 handler,所以只有 cache 会让人意外。
有例外:ocache 处理器会把它不会存的请求照常往下传——GET / HEAD 之外的方法,以及带 Range 头的请求。这类请求会跑 routeRules() 之后注册的中间件、走正常分发,并且保留 Authorization 头。
排查表
| 现象 | 最可能的原因 |
|---|---|
| 所有用户看到同一个人的数据 | 路由每次都种 Cookie 却开了缓存;或自定义 name 丢了隔离 |
| 鉴权中间件「没生效」 | 它注册在 routeRules() 之后,且该路由可缓存 |
| 缓存从不命中 | 用了 POST,或请求带 Range 头,或响应状态码不在允许列表 |
| 多进程部署时命中率极低 | handlerScope 随进程变化,需用固定 id |
| 分页参数改了但返回同一页 | 查询串默认不进键,需 allowQuery |
| 上线后缓存里全是旧响应 | swr 是「先给旧值再后台刷新」,要立即失效得改配置或清存储 |
延伸阅读
- 匹配与合并的基础规则:见 3.1 路由规则入门
- 代理规则与自定义规则处理器:见 3.3
headers在短路响应上为何失效、restricting标记:见 3.4- 中间件与规则的执行先后:见 2.3 中间件

TRAE-AI编程


更多建议: