H3 路由
一个 URL 该由哪段代码处理,是服务端框架每天都在回答的问题。H3 把答案收在一处:注册时给一条路径和一个 handler,匹配交给底层引擎 rou3。
这一节覆盖 v2 的路由全貌——注册方式、特殊方法(HEAD、QUERY)、模式的归一化规则、动态段与通配段、路由元信息。v1 的 createRouter 已合并进 H3 类,旧写法见 8.1。
📌 路由匹配只做一件事:从已注册的路由中挑出最具体的那一条。同一个请求永远只命中一个路由,不会命中多个。
一、注册路由的三种写法
| 写法 | 形式 | 适用 |
|---|---|---|
app.get(...) 等 |
app.<方法>(path, handler) |
已知方法,代码最简洁 |
app.on(...) |
app.on(method, path, handler) |
方法名来自变量时 |
app.all(...) |
app.all(path, handler) |
任意方法都接同一段逻辑 |
最常用的是第一种。注册一条 GET 路由:
// 注册 GET /hello:路径必须以 / 开头,不写也会自动补
app.get("/hello", () => "Hello world!");
等价的 app.on 写法,把方法名显式放在第一个参数:
// 语义与 app.get 完全一致,方法名是独立参数
app.on("GET", "/hello", () => "Hello world!");
同一个路径可以同时挂多个方法,链式调用即可:
// 三条路由共用同一路径,按方法区分
app
.get("/hello", () => "GET Hello world!")
.post("/hello", () => "POST Hello world!")
.all("/hello", () => "Any other method!"); // 兜底:GET/POST 之外的方法
app.all 不关心方法,所以上面的 .all("/hello") 会接住 PUT、DELETE 等其余请求。handler 里可以从请求对象读真实方法:
// event.req 是标准 Request,method 是标准字段
app.all("/hello", (event) => `This is a ${event.req.method} request!`);
二、HEAD 请求会自动复用 GET
按 RFC 9110 的规定,HEAD 与 GET 语义一致但不需要响应体。H3 直接实现了这条约定:注册 GET 就等于同时注册了 HEAD,响应头与状态码照发,响应体丢弃。
// 只需注册 GET
app.get("/hello", () => "Hello world!");
// HEAD /hello → 200,响应头与 GET 相同,但响应体为空
只有在需要覆盖这个行为时才显式注册 head(),典型场景是省掉计算响应体的开销:
// 显式 HEAD 优先级高于 GET 的自动回退
app.head("/hello", (event) => {
// 手工声明长度,避免为了算长度去生成完整响应体
event.res.headers.set("content-length", "12");
return null; // 返回 null 表示「无响应体」,状态码仍是 200
});
⚠️ 显式
head()一旦注册,就一定优先于自动的GET回退。这也意味着:如果你给某个路径单独写了head(),GET 的响应体长度与它声明的content-length不一致,缓存与客户端会出问题。
三、HTTP QUERY 方法
QUERY 是 RFC 10008 定义的新方法,H3 把它作为一等公民支持。它的定位很直白:像 GET 一样安全、幂等、可缓存,但允许携带请求体(并带 Content-Type)。
这补上了长期存在的缺口:查询条件复杂到装不进 URL 时,以前只能改用 POST,代价是失去「安全且幂等」的语义——CDN 不敢缓存、代理不敢重试、监控看不出这是读操作。
用 app.query() 注册(或 app.on("QUERY", ...)),请求体照常读:
import { readBody } from "h3";
// 注册 QUERY /search:条件放在请求体里,而不是 URL
app.query("/search", async (event) => {
const criteria = await readBody(event); // 读取查询条件
return runSearch(criteria); // 返回值自动转响应(见 2.5)
});
它继承 POST 的风险,但保留 GET 的语义
带 body 就意味着攻击面:请求体大小限制对 QUERY 一样生效,和 POST 同等对待(限制工具见 6.2)。
CORS 上它不是安全方法:GET、HEAD、POST 属于 CORS 安全名单,QUERY 不在其中,所以浏览器会先发预检请求。如果你给 handleCors 传了显式的 methods 白名单,必须把 "QUERY" 加进去,否则跨域调用会被拦。
缓存上它被当作 GET:条件请求工具 handleCacheHeaders 对 QUERY 同样生效,可以正常返回 304(见 6.3)。代理工具 proxy 在转发 QUERY 时会连请求体一起转发,这一点和 GET 不同。
两个配套工具专门服务于这个 RFC:
import { requireContentType, appendAcceptQuery } from "h3";
app.query("/books", async (event) => {
// 断言请求头 content-type:不匹配时自动返回 400 / 415 / 422
requireContentType(event, ["application/json"]);
// 通过 Accept-Query 响应头声明本端点接受哪些查询格式
appendAcceptQuery(event, ["application/json"]);
const criteria = await event.req.json();
return queryBooks(criteria);
});
一个完整可运行的 /books 资源示例见 7.2。
四、路由模式是路径,不是 URL
这一点必须记牢:注册时写的字符串是 pathname,形状与 event.url.pathname 一致,再叠加 rou3 语法。它不是完整 URL。
匹配前,所有注册入口(app.on、app.get、app.all、app.use、app.mount、removeRoute)都会做同一套归一化,所以「用同一个字符串注册的中间件一定能守住同名路由」。
归一化规则共五条:
- 缺前导斜杠会自动补上。
"hello"注册后等价于/hello。 - 拒绝整个 URL。
app.get("http://example.com/admin")直接抛错;//admin不会被当成「省略主机名」,而是一个两段路径//admin。 .与..按 URL 解析器的规则解析。/admin/../admin归一为/admin。- 请求路径里必然被百分号编码的字符会被编码。 空格、非 ASCII、控制字符、
"、#、<、>、反引号都属于这类。所以app.get("/café")实际注册的是/caf%C3%A9——浏览器真正发出来的形式。 - 多余的转义会被解码。
/%40handle归一为/@handle,与请求路径的规范化结果一致。

保留 rou3 语法的字符原样不动,包括反斜杠 \(它在请求路径里永远非法)。要匹配 ?、{、}、^ 的字面值,必须写成百分号编码:
app.get("/u/:id?", () => "optional param"); // :id? 是 rou3 语法,原样保留
app.get("/x%3Fy", () => "literal ?"); // 匹配客户端发来的 /x?y
⚠️ 非 ASCII 与动态语法可以混用(
app.get("/café/:id")注册为/caf%C3%A9/:id,能匹配/café/42),但有两个例外,都是因为编码后的形式落进了 rou3 自己的语法:参数名必须是 ASCII([\w-]),/:naïve会被读成「参数na+ 字面量%C3%AFve」;(...)分组里只有字面量与竖线择一能存活,字符类不行([é]变成[%C3%A9]),要写编码后的择一(?:%C3%A9)。
五、动态路由
用 : 前缀声明参数,值放在 event.context.params:
// GET /hello/Bob → "Hello, Bob!"
app.get("/hello/:name", (event) => {
return `Hello, ${event.context.params.name}!`;
});
参数可以可选,加 ? 即可——这会让同一个 handler 同时接住「有值」和「无值」两种路径:
// 同时匹配 /hello 与 /hello/Bob
app.get("/hello/:name?", (event) => `Hello, ${event.context.params?.name ?? "you"}!`);
注意可选参数要用 ?. 读,因为段不存在时 params 上不会有这个键。
六、通配路由
/hello/:name 只能吃掉一段:/hello/world 命中,/hello/foo/bar 不命中。要跨层级匹配,用 **(或 *):
// GET /hello/world/123 → "Hello world/123!"
app.get("/hello/**", (event) => `Hello ${event.context.params?.[0] ?? ""}!`);
这条路由能匹配 /hello、/hello/world、/hello/123、/hello/world/123,以及更深的层级。
关于捕获值的形态,有三条规则容易记错:
- 无名通配(
**或*)把匹配到的子路径作为一个字符串存进params[0],按位置取;什么都没匹配到时该键不存在。旧的params._仍可用但已弃用。 - 命名通配写作
/hello/**:path,值存在params.path;但它要求至少一段,所以匹配不到/hello。 - 一条路由只能有一个 catch-all,
*、**、:name+、:name*、(.*)只允许出现其中一个。
七、路由元信息
注册时可以附带 meta,任意中间件都能读到当前命中的路由及其元信息:
import { H3 } from "h3";
const app = new H3();
app.use((event) => {
// 在中间件里判断「这条路由是否需要鉴权」
console.log(event.context.matchedRoute?.meta); // { auth: true }
});
// 第三个参数是路由选项,meta 随路由一起登记
app.get("/", (event) => "Hi!", { meta: { auth: true } });
用 defineHandler 的对象语法注册时同样可以写 meta(见 2.4)。这一机制是把「鉴权、限流、埋点」从每个 handler 里挪到中间件的常用手段。
八、容易记错的四件事
| 误区 | 实际行为 |
|---|---|
以为 app.get 会自动匹配子路径 |
只匹配该路径本身,子路径要写 /path/** |
以为 HEAD 要单独注册 |
注册 GET 即自动获得 HEAD,显式 head() 才覆盖 |
以为 ** 的捕获值可用 params._ 长期依赖 |
params._ 已弃用,改用 params[0] |
| 以为注册时可以写完整 URL | 只能写 pathname,写 URL 直接抛错 |
💡 排查匹配失败时,最先确认两件事:路径是否被归一化成了你意想不到的形式(非 ASCII、
./..、多余转义),以及是否有多条路由重叠而命中了更具体的那条。
延伸阅读
- 一个请求在路由之前还要经过哪些阶段:见 2.1 请求生命周期
- 中间件如何与路由配合、返回值的短路规则:见 2.3 中间件
- handler 的三种写法与惰性加载:见 2.4 事件处理器
QUERY方法的完整实战示例:见 7.2

TRAE-AI编程


更多建议: