H3 路由

2026-10-30 15:38 更新

一个 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)都会做同一套归一化,所以「用同一个字符串注册的中间件一定能守住同名路由」。

归一化规则共五条:

  1. 缺前导斜杠会自动补上。 "hello" 注册后等价于 /hello。
  2. 拒绝整个 URL。 app.get("http://example.com/admin") 直接抛错;//admin 不会被当成「省略主机名」,而是一个两段路径 //admin。
  3. . 与 .. 按 URL 解析器的规则解析。 /admin/../admin 归一为 /admin。
  4. 请求路径里必然被百分号编码的字符会被编码。 空格、非 ASCII、控制字符、"、#、<、>、反引号都属于这类。所以 app.get("/café") 实际注册的是 /caf%C3%A9——浏览器真正发出来的形式。
  5. 多余的转义会被解码。 /%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
以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号