H3 条件缓存与更多请求工具
这一节是请求侧剩下的全部工具:条件缓存、方法断言、取 IP 与 Host、读路由参数、以及几个底层请求构造器。它们的共同点是都在解决「怎么安全地读请求信息」,所以每个函数下面都能看到一条安全说明。
条件缓存:handleCacheHeaders
协商缓存的标准流程是「客户端带上 If-None-Match 或 If-Modified-Since,服务端判断没变就回 304」。这个函数把两头都做了:
import { H3, handleCacheHeaders } from "h3";
const app = new H3().get("/data.json", (event) => {
const etag = "v42";
// 命中就回 304(空体),没命中返回 false,继续走下面的正常响应
const notModified = handleCacheHeaders(event, {
etag,
lastModified: new Date("2026-10-01"),
maxAge: 60,
});
if (notModified) {
return notModified;
}
return { version: 42 };
});
三个细节容易被忽略:
public是默认加的,但只要你自己传了private或no-store,它就不会再加——所以cacheControls: ["private"]不会产出矛盾的public, private。- 一次调用同时负责「读条件头」和「写缓存头」,不要只写一半。
- 它返回的是响应或
false,必须自己判断并按需return,属于 6.1 说的第三类工具。
缓存隔离:withCacheIsolation
这个函数解决的是一类很隐蔽的 bug:别人设置的头被缓存下来,然后回放给所有用户。
场景是这样的——全局中间件在 event.res 上暂存了某个头(比如根据用户身份加的 x-user-id),而缓存未命中时,缓存库会把 handler 结果连同这些暂存头一起存进缓存条目;之后所有命中该条目的请求都会带着这个头返回。一个人触发写入,所有人拿到他的数据。
import { handleCacheHeaders, toResponse, withCacheIsolation } from "h3";
import { defineCachedHandler } from "ocache";
// 用 withCacheIsolation 包一层:暂存头不再进入缓存条目
const cached = withCacheIsolation(
defineCachedHandler(() => "hello", {
maxAge: 60,
toResponse: (value, event) => toResponse(value, event),
handleCacheHeaders: (event, conditions) => handleCacheHeaders(event, conditions),
}),
);
app.get("/cached", cached);
它的处理规则很讲究,值得逐条看:
| 头 | 处理方式 |
|---|---|
| 请求期暂存的普通头 | 从事件上摘下来,命中与未命中都单独合并到响应,不进缓存 |
暂存的 Cache-Control、Vary(origin 除外) |
保持可见给缓存,否则「不该被缓存」的判断会失效 |
暂存的 private / no-store |
替换缓存 handler 自己设的值,让前面的 CDN 也不存 |
Vary: Origin |
不共享,因为 CORS 头本来就是按请求暂存的 |
| 错误响应(4xx/5xx) | 不合并暂存头,与 H3 默认行为一致 |
| 被包装 handler 的属性 | invalidate、expire、resolveKeys 一并复制到返回的 handler |
一句话概括:决定「能不能缓存」的头让它看见,决定「这个响应长什么样」的头拦在外面。
方法断言:assertMethod 与 isMethod
import { H3, assertMethod, isMethod } from "h3";
const app = new H3().all("/item", (event) => {
// 不满足就抛 405,响应里自带 Allow 头(RFC 9110 要求)
assertMethod(event, "GET");
return "get";
// 也可以用 isMethod 自己分支:不抛错,只返回布尔
// if (isMethod(event, ["POST", "PUT"])) { ... }
});
两个函数都支持第三个参数 allowHead:为 true 时,期望 GET 的检查也放行 HEAD——因为 HEAD 本来就该走 GET 的逻辑,只是响应体被剥掉。
isMethod 与 assertMethod 的区别就是 6.1 那条命名规律:is* 不抛错,assert* 抛 405。
取请求信息:四个函数,三层风险
这四个函数一起用才能拼出完整的请求 URL,而它们都有同一个设计:转发头默认不信。
import { H3, getRequestHost, getRequestIP, getRequestProtocol, getRequestURL } from "h3";
const app = new H3().get("/whoami", (event) => {
// 默认读 Host 头;xForwardedHost: true 才看 x-forwarded-host
const host = getRequestHost(event);
// 默认读运行时解析出的 event.req.ip,而不是 x-forwarded-for
const ip = getRequestIP(event);
// 默认 http;xForwardedProto: true 才看 x-forwarded-proto
const protocol = getRequestProtocol(event);
return { host, ip, protocol, url: getRequestURL(event).href };
});
为什么要默认不信?因为这三个头都是客户端可伪造的:
| 参数 | 打开后读什么 | 风险 |
|---|---|---|
xForwardedHost: true |
x-forwarded-host |
影响绝对链接、缓存键、同源判断 |
xForwardedProto: true |
x-forwarded-proto(多值取第一个) |
影响生成的 URL 协议 |
xForwardedFor: true |
x-forwarded-for 的第一个条目 |
那条就是客户端自己写的,等于让调用方自选 IP |
IP 这条最值得展开:代理通常追加到链尾(nginx 的 $proxy_add_x_forwarded_for、多数 CDN 都是),所以客户端自己发的那条永远在最左边。取第一条,等于任何调用者都能伪装成任意 IP,IP 白名单、限流、地域判断、审计日志全部失效。正确做法是让服务器信任代理(srvx 的 trustProxy 从右往左跳过可信跳数),把 xForwardedFor 留着别开。
getRequestHost 与 getRequestURL 还有一条:返回的 .host、.origin 来自可伪造的头,不要用于安全判断(CSRF 校验、缓存键、生成发给别人的绝对链接),除非上游已经把 Host 固定成白名单。
请求指纹:getRequestFingerprint
给同一次「客户端 + 请求」生成一个稳定标识,典型用途是限流和去重。它把若干组件拼成一个字符串再哈希(默认 SHA-256),关键是组件默认几乎只含 IP,其余一律 opt-in:
| 选项 | 默认 | 含义 |
|---|---|---|
ip |
true |
用 getRequestIP 的结果(同样默认不信 x-forwarded-for) |
xForwardedFor |
false |
打开后改用 x-forwarded-for 的第一条——会重蹈上面 IP 伪造的坑,一般不开 |
method |
false |
把 event.req.method 纳入指纹 |
url |
false |
把 event.req.url 纳入指纹 |
userAgent |
false |
把 user-agent 纳入指纹 |
hash |
"SHA-256" |
改成 "SHA-1"/"SHA-384"/"SHA-512",或 false 直接返回拼接串(不哈希) |
import { H3, getRequestFingerprint } from "h3";
const app = new H3().get("/api/limited", async (event) => {
// 默认只按 IP 限流;想更细可以开 method / url
const fp = await getRequestFingerprint(event, { ip: true });
if (!fp) {
return; // IP 取不到(无代理又没运行时信息)时为 null
}
// const count = await rateLimit.consume(fp);
});
两点提醒:返回值是 Promise<string | null>,IP 取不到且其余都没开时会得到 null;这个工具标注了 @experimental,行为未来可能调整,别把它当稳定契约写进核心逻辑。
读路由参数:只解码一次
import { H3, getRouterParam, getRouterParams } from "h3";
const app = new H3().get("/files/**:rest", (event) => {
// 默认返回原样:仍然是百分号编码的字符串
const raw = getRouterParams(event); // { rest: "%252e%252e/x" }
// decode: true 只解一层:%25 解成 %,所以还剩 %2e%2e
const once = getRouterParams(event, { decode: true }); // { rest: "%2e%2e/x" }
// 取单个参数
const rest = getRouterParam(event, "rest");
return { raw, once, rest };
});
三条规则必须记住:
- 编码的路径分隔符永远不解(
%2f、%5c,任意嵌套深度)。所以参数里不可能凭空多出一个/,路由按一段匹配的,就还是一段。 - 只解一层。
%25XX解出来是字面上的%XX,结果里仍然可能带转义——包括点段和控制字符(%2500→ ``)。 - 绝对不要再解一次。第二次
decodeURIComponent会把%2e%2e/x变成../x,把 `` 变成 NUL 字节——这正是路由和中间件从没见过的穿越。
要拿解码后的参数做文件系统或上游路径,用 resolveDotSegments 规范化(6.6),而不是继续解码。
带校验地取查询与参数
import { H3, getValidatedQuery, getValidatedRouterParams } from "h3";
import { z } from "zod";
const app = new H3().get("/users/:id", async (event) => {
// query 与 params 各有一个校验版本,用法与 readValidatedBody 一致
const query = await getValidatedQuery(event, z.object({ page: z.coerce.number().default(1) }));
const params = await getValidatedRouterParams(event, z.object({ id: z.string().uuid() }));
return { page: query.page, id: params.id };
});
getValidatedRouterParams 同样支持 decode: true 与 onError,语义与 getRouterParams 完全一致。
三个底层构造器
平时用不到,写适配层或测试时会遇到:
| 函数 | 作用 |
|---|---|
toRequest(input, options?) |
把字符串 / URL / Request 统一转成标准 Request,相对路径会按 host 补全 |
requestWithURL(req, url) |
只替换 URL 的轻量代理,不克隆整个请求(省一次分配) |
requestWithBaseURL(req, base, opts) |
剥掉 base 前缀的轻量代理,opts.url 建议传 event.url |
toRequest 有一条安全说明值得记:host 头只用来当合成 URL 的 authority(缺失或畸形时退回 localhost),永远不会影响路径,而且 x-forwarded-proto 被忽略,协议固定为 http。想控制 origin 就传绝对 URL。
requestWithBaseURL 的 opts.url 为什么要传 event.url?因为非规范路径下 req.url 是线格式、event.url 是归一化格式,用其中一个的偏移去切另一个,正是挂载前缀错位的根源(4.2 讲过)。
排查表
| 现象 | 最可能原因 | 处理 |
|---|---|---|
| 304 一直不生效 | 只调了 handleCacheHeaders 但没 return 它的结果 |
判断返回值后 return |
| CDN 缓存了带用户信息的响应 | 暂存头进了缓存条目 | 用 withCacheIsolation 包住缓存 handler |
405 响应没有 Allow 头 |
手写了状态码而没用 assertMethod |
改用 assertMethod |
| 拿到的 IP 全是同一个 | 前面有代理,但没配信任 | 用 trustProxy,别开 xForwardedFor |
路由参数里出现 ../ |
自己又 decode 了一次 | 只解一层,需要规范化用 resolveDotSegments |
| 生成的绝对链接域名不对 | Host 头被伪造 |
用白名单校验或固定配置 |
延伸阅读
- 路由参数的归一化与匹配规则:见 2.2 路由
event.url与event.req.url的区别:见 4.2 H3Event 事件对象- CORS 与路径规范化:见 6.6 安全工具
- 路由规则自带的缓存(
cache/swr):见 3.2 路由规则缓存

TRAE-AI编程


更多建议: