H3 路由规则入门
给一批路径加同样的响应头、把旧地址 301 到新地址、给 /api 开跨域、把 /assets 的缓存交出去、把某个前缀反代到另一台机器——这五件事如果都用中间件写,注册表很快就乱到没人敢动。
路由规则换一种表达方式:声明「对这些路径该发生什么」,具体的执行交给 H3。
// 五件横切的事收进一张表,不再各写一个中间件
routeRules({
"/old/**": { redirect: "/new/**" }, // 旧地址 307 到新路径
"/assets/**": { headers: { "cache-control": "s-maxage=31536000" } }, // 静态资源交给 CDN 缓存一年
"/api/**": { cors: true }, // 接口开跨域
});
这不是新概念——CDN 和反向代理一直这么配。区别是它跑在你的应用里,所以在 Node、Bun、Deno、Workers 上行为一致;也因为配置是纯数据,构建工具能提前分析它(见 3.4 的构建期编译器)。
它一次做四件事
把 routeRules() 注册成全局中间件后,每个请求会经历:
- 找出所有匹配的路径模式;
- 把这些规则合并成一份配置;
- 按固定的执行顺序跑各条规则的中间件——有的包住响应,有的直接答掉请求;
- 把合并结果挂到
event.context.routeRules,供 handler 与中间件读取。
三个入口,按需引入
| 导入路径 | 提供什么 | 何时需要 |
|---|---|---|
h3/rules |
routeRules 与 headers / redirect / cors |
基础三件套,无额外依赖 |
h3/rules/cache |
cache 与 swr 的处理器(基于 ocache) |
用到缓存时 |
h3/rules/proxy |
proxy 的处理器 |
用到反向代理时 |
缓存与代理是可选处理器:不注册就只在上下文里留一份数据,不产生运行时行为;规则存在却没有注册处理器,H3 在创建匹配器时直接抛错——宁可启动失败,也不让配置静默失效。
完整起步示例
import { H3, serve } from "h3";
import { routeRules } from "h3/rules";
import { cache } from "h3/rules/cache"; // swr / cache 需要它(依赖 ocache)
import { proxy } from "h3/rules/proxy"; // proxy 需要它
const app = new H3();
app.use(
routeRules(
{
"/blog/**": { swr: 60 }, // 60 秒内复用,过期后后台刷新
"/old/**": { redirect: { to: "/new/**", status: 301 } },
"/api/proxy/**": { proxy: "https://example.com/**" },
"/assets/**": { headers: { "cache-control": "s-maxage=31536000" } },
"/api/**": { cors: true },
"GET /api/cached/**": { swr: 60 }, // 只对 GET 生效
},
{ handlers: { cache, proxy } }, // 关键:把可选处理器注册进来
),
);
serve(app);
内置规则:五条 + 一个简写
| 规则 | 作用 |
|---|---|
headers |
设置响应头 |
redirect |
服务端重定向 |
cors |
交给 handleCors 处理,预检请求(OPTIONS)直接答掉 |
cache |
缓存命中的路由 handler 的响应(需注册处理器) |
swr |
cache: { swr: true, maxAge } 的简写 |
proxy |
转发到另一个源或应用内路径(需注册处理器) |
表里六行,但规则只有五条——swr 是 cache 的简写,不单独算一条。下面按条说明。
headers:作用在最终响应上
它在 cache、redirect、proxy 之后执行,所以这里的 cache-control 会盖掉缓存处理器自己生成的值:
// headers 在 cache / redirect / proxy 之后执行,所以这里的值最终生效
routeRules({
"/assets/**": { headers: { "cache-control": "s-maxage=31536000" } },
});
redirect:默认 307,可指定状态
传字符串用默认的 307(临时重定向,保留请求方法);要改状态码就传对象:
routeRules({
"/old/**": { redirect: "/new/**" }, // /old/a?x=1 → /new/a?x=1(307)
"/moved/**": { redirect: "/new?from=**" }, // /moved/a/b → /new?from=a/b
"/search": { redirect: "https://example.com/s?lang=en" }, // 追加原查询串
"/legacy": { redirect: { to: "/", status: 301 } }, // 永久重定向
});
重定向与代理共享两条行为,值得背下来:
通配尾部替换。 模式以 /**(或 /**:name)结尾时,目标里的 ** 会被替换成匹配到的路径尾巴。写在末尾是追加(/new/**),写在其他位置是原地插值(/new/**/edit、/new?from=**)。请求正好命中模式基路径时,尾巴是空字符串。在路径位置按字节转发,在查询或片段里会被百分号编码,以免尾巴额外拼出参数。
查询串转发。 原请求的查询串会保留(重复键与编码都保留),目标本身带查询时追加在后面。
还有一个安全边界:尾巴永远不能改变目标的主机。会顶掉主机的 **(如 "**"、"**.cdn.example/x"、"https://**.example.com")在启动时就被拒绝;运行时若某请求的尾巴仍能挪动 origin,直接返回 400。
自环也不需要你手动处理——请求已经在目标地址上时不再触发:
routeRules({
"/docs/**": { redirect: "/docs/v2/**" }, // /docs/v2/intro 不会再被重定向
"/blog/**": { redirect: "/blog/**.md" }, // 已经是 .md 的路径不动
});
判断只比对目标的路径文本:目标没有 ** 时只跳过完全相同的路径;绝对 URL、段中间的 **、以及裸 "/**" 一律照常重定向。
cors:true 是宽松默认
routeRules({
"/api/**": { cors: { origin: ["https://example.com"], credentials: true } },
});
cors: true 内部会变成空对象 {}——注意这里不会重置:更具体的模式会继承外层已配置的选项,而不是退回宽松默认。想彻底去掉继承来的跨域行为,写 cors: false。
cache 与 swr:需要先注册处理器
swr: 60 等价于 cache: { swr: true, maxAge: 60 },0 是合法值;swr: false 用来撤销继承来的缓存规则。具体的行为、键的构成与凭据处理见 3.2。
proxy:需要先注册处理器
转发语义与 redirect 目标一致(尾部替换、查询转发),细节见 3.3。
为什么没有 auth 规则
官方明确不给:鉴权需要能查用户库的可执行逻辑,属于中间件。用 basicAuth 或自己写。如果一定要用自定义规则做鉴权门,必须先读 3.4 的 restricting 部分——漏了那个标记,false 重置会在编码路径上失效。
匹配:全部应用,从宽到窄合并
路由规则和路由匹配共用 rou3 引擎,都对着 event.url.pathname 匹配。但两者有一条关键差异:
路由只取最具体的一条,路由规则却应用全部匹配项。
H3 从最宽到最窄依次合并:对象选项做浅合并,越具体的值越赢;标量和其他非对象值整体替换。

routeRules({
"/**": { headers: { "x-app": "demo" } },
"/api/**": { headers: { "x-api": "1" } },
});
// GET /api/users → x-app: demo, x-api: 1
用 false 撤销继承
routeRules({
"/api/**": { cors: { origin: ["https://example.com"] } },
"/api/public/**": { cors: false }, // /api/public 下不再处理跨域
});
按方法限定
在模式前加 HTTP 方法,这条规则就只对该方法生效;方法名不区分大小写。方法专属规则最后合并,所以能盖掉通用规则:
routeRules({
"/api/**": { headers: { "x-api": "1" } }, // 所有方法
"GET /api/**": { swr: 60 }, // 只给 GET
});
H3 会把等价拼写归为一组:/users/:id 与 /users/:userId 是同一个路由组,/users/* 与 /users/** 也是。所以用其中一种写法写的通用规则,能和用另一种写法写的方法专属规则合并。不过——同一组里如果拼写的具体性不同(/a/** 与 /a/**:rest),更具体的那个后解析并获胜。建议一个项目里只用一种写法,避免这种意外。
读合并结果
handler 与中间件都能从 event.context.routeRules 拿到最终配置,按规则名取,简写已经展开:
app.get("/blog/:slug", (event) => {
const rules = event.context.routeRules;
rules?.cache; // { swr: true, maxAge: 60 } —— swr 已展开
rules?.redirect?.to;
rules?.headers?.["x-a"];
});
❗ 匹配结果默认被记忆化,同一个结果对象会被多个请求共享。把
event.context.routeRules及其嵌套值一律当只读,不要改写。
上下文里只有合并后的值,没有「它来自哪条模式」。框架集成如果需要来源,可以自己用匹配器取(见 3.3)。
routeRules() 可以注册多次:后注册的在同名规则上覆盖先注册的,正好让「框架给默认值、应用覆盖一部分」这种分层成立。
数据型规则
没注册处理器的规则叫数据型规则:照样匹配、合并、挂到上下文,但运行时不改响应。构建工具或框架可以消费它们:
routeRules({
"/docs/**": { prerender: true }, // 只给构建期读
});
这类自定义键要写进类型声明才有补全,见 3.4。
六个常见误区
| 误区 | 实际行为 |
|---|---|
| 以为规则也像路由那样只取最具体一条 | 全部匹配项都会应用,窄的覆盖宽的同名键 |
以为 cors: true 会把继承来的选项重置为宽松 |
更具体的模式继承外层选项,要清掉得写 cors: false |
以为 cache / proxy 写了就生效 |
必须注册对应处理器,否则创建匹配器时抛错 |
以为 redirect 默认 301 |
默认 307,永久重定向要显式写 status: 301 |
以为 swr: 60 是「60 秒后删缓存」 |
是「60 秒内直接给旧值,过期后后台刷新」 |
在 handler 里改 event.context.routeRules |
对象被多个请求共享,改了会污染其他请求 |
延伸阅读
- 缓存键怎么构成、凭据为何被剥掉:见 3.2 路由规则缓存
- 代理、自定义规则与性能机制:见 3.3
- 类型扩展与安全边界(尤其
restricting):见 3.4 - 中间件与规则如何取舍:见 2.3 中间件

TRAE-AI编程


更多建议: