H3 应用实例 API
new H3() 拿到的实例是整个应用的根:它持有路由表、全局中间件和配置,并对外提供把请求喂进去的入口。前面几章一直在用它,这一节把实例上的东西一次列清:三个入口、路由注册、中间件与插件、配置项与全局钩子。
三个调用入口:request、fetch、handler
这三个方法都能让请求进入应用,差别只在入参形态和是否做规整。
| 入口 | 入参 | 典型场景 |
|---|---|---|
app.request |
相对路径、URL、Request,另可传 RequestInit 与 context |
测试、服务端内部调用、临时改请求头 |
app.fetch |
只接受标准 Request |
交给运行时当默认导出、对接其他 fetch 生态 |
app.handler |
已构造好的 H3Event |
嵌进另一个 H3 应用、写自定义适配层 |

import { H3 } from "h3";
const app = new H3().get("/hello", () => "world");
// 1. request:字符串会先被 toRequest() 规整成标准 Request
const res1 = await app.request("/hello");
// 第二个参数是 RequestInit,可以在不改路由的情况下临时加头
const res2 = await app.request("/hello", { headers: { "x-trace-id": "abc" } });
// 第三个参数预置 event.context,测试里塞假用户最方便
const res3 = await app.request("/hello", undefined, { user: { id: 1 } });
// 2. fetch:不做规整,必须自己给 Request
const res4 = await app.fetch(new Request("http://localhost/hello"));
// 3. handler:已经拿到 event 时直接走应用分发
const res5 = await app.handler(event);
app.request 内部调用的是 toRequest(),所以 app.request("/hello") 成立;app.fetch 直接透传,写 app.fetch("/hello") 不成立——这一点在接 Deno、Bun、Cloudflare Workers 时最容易混,因为它们的默认导出签名就是 fetch(Request)。
💡 第三个参数
context是官方 API 页没写、但源码里存在的能力(request(req, init?, context?))。它把对象直接作为event.context的初始值,测试时比 mock 整个请求更省事。
路由注册:on 是唯一的真身
所有路由注册最终都收敛到 on(method, route, handler, opts?) 这一个方法上,其余写法都是语法糖。
| 写法 | 等价于 | 说明 |
|---|---|---|
app.on("GET", "/x", h) |
本身 | method 大小写不敏感 |
app.on("", "/x", h) |
app.all("/x", h) |
空字符串表示任意方法 |
app.get("/x", h) |
app.on("GET", "/x", h) |
十一个快捷方法之一 |
app.query("/x", h) |
app.on("QUERY", "/x", h) |
QUERY 是一等方法,不是自定义字符串 |
快捷方法一共十一个:all、get、post、put、delete、patch、head、options、connect、trace、query。all() 的实现就是 on("", ...):
import { H3 } from "h3";
const app = new H3()
// 显式写方法,最不容易出错
.on("POST", "/login", () => "ok")
// 小写也认,内部会统一转大写
.on("put", "/profile", () => "ok")
// 任意方法,等价于 app.all("/ping", ...)
.on("", "/ping", () => "pong")
// 快捷方法:注册 QUERY 而不是 GET
.query("/search", () => "queried");
第四个参数 opts 有两个字段:
| 字段 | 作用 |
|---|---|
middleware |
只作用于这条路由的中间件数组,按顺序执行 |
meta |
附加在这条路由记录上的元数据,可在 event.context.matchedRoute 里读到 |
meta 与 handler 自带的 meta 会合并,opts.meta 优先。用它挂「是否需要登录」「权限点」这类声明式标记,比在 handler 里写 if 更清晰。
🚀 一个容易忽略的行为:HEAD 请求在没有注册 HEAD 路由时,会回落到同路径的 GET 路由(依据 RFC 9110),响应体随后被自动剥掉,只留头。所以
curl -I能拿到正确的content-length,不需要为每个 GET 再写一遍 HEAD。
中间件与插件:use 与 register
import { H3 } from "h3";
const app = new H3()
// 全局中间件:所有请求都经过
.use((event) => {
console.log(`[${event.req.method}] ${event.url.pathname}`);
})
// 带路径的中间件:注意 ** 通配,只写 /admin 不匹配子路径
.use("/admin/**", (event) => {
if (!event.req.headers.get("authorization")) {
throw new Error("unauthorized");
}
})
.get("/", () => "ok");
use 支持传另一个 H3 实例,等价于把它的中间件挂过来。路径匹配规则、短路与顺序在 2.3 已经讲透,这里只强调一点:use("/admin", h) 只匹配 /admin 本身,要覆盖子路径必须写 use("/admin/**", h)——这是 v1 升到 v2 最容易踩的一处。
register(plugin) 会立即执行插件函数并返回 this,和在构造参数里写 plugins: [plugin] 完全等价:
import { H3, definePlugin } from "h3";
// 插件就是一个接收 app 的函数;definePlugin 只提供类型推导
const myPlugin = definePlugin((app) => {
app.get("/health", () => ({ ok: true }));
});
const app = new H3().register(myPlugin);
构造时传 plugins 与事后 register 的差别只有一个:前者在构造函数里就跑完了,后者在你调用的那一刻跑。插件本身怎么写见 5.1。
挂载:mount 与 handler 是两条路
这两个名字在文档里都出现,但行为完全不同,混用会得到「路由明明注册了却不匹配」的结果。
| 方式 | 做的事 | 结果 |
|---|---|---|
mount("/api", subApp) |
把子应用的路由逐条搬进主应用,前缀在注册期拼好;子应用的中间件被包成一个带前缀的中间件 | 路由表合并,主应用能看到子应用的路由 |
withBase("/api", subApp.handler) |
运行期转发:剥掉前缀后把 event 交给子应用 | 路由表不合并,主应用只多了一条 /** |
判断标准很简单:还需要在主应用里继续给子应用的路径挂东西吗? 需要就用 mount,只想把请求转过去就用 withBase。两者的完整行为(尤其挂载第三方 Web 应用时前缀会被剥掉)在 2.7 有图示。
四个配置项
import { H3 } from "h3";
const app = new H3({
// 只在开发期开:错误响应里会带堆栈
debug: process.env.NODE_ENV !== "production",
// 静音 console 输出,但不改响应状态码
silent: false,
// 默认拒绝畸形百分号编码(如 /foo%),开启后放行原始路径
allowMalformedURL: false,
});
| 配置项 | 默认 | 作用与风险 |
|---|---|---|
debug |
false |
错误响应里附堆栈。生产必须关,否则等于对外公开内部路径与依赖版本 |
silent |
false |
未处理异常不再打到控制台。只影响输出,不影响状态码与响应体 |
allowMalformedURL |
false |
畸形百分号编码默认返回 400;开启后改用原始 pathname 放行。安全含义见 4.2 |
plugins |
[] |
构造时立即执行的插件数组 |
silent 关掉的是 console.error 输出,不是「错误不再返回给客户端」——要不要返回、返回什么,由 onError 决定。
三个全局钩子
import { H3 } from "h3";
const app = new H3({
// 每个请求最先执行;返回 Promise 时 H3 会等它完成再进路由
onRequest: async (event) => {
event.context.startedAt = Date.now();
},
// 响应已构建完成后执行,适合埋点与耗时统计
onResponse: (response, event) => {
const cost = Date.now() - (event.context.startedAt as number);
console.log(`${event.url.pathname} ${response.status} ${cost}ms`);
},
// 出错时执行;返回非空值就替换默认错误响应体
onError: (error, event) => {
console.error(`[${event.req.method}] ${event.url.pathname}`, error);
},
});
三个钩子的语义差别值得单独记:
| 钩子 | 时机 | 返回值 |
|---|---|---|
onRequest |
构造 event 之后、进路由之前 | 无意义,返回 Promise 会被 await |
onResponse |
响应已构建、交给运行时之前 | 忽略。钩子内部抛错会被吞掉并打日志,响应照旧发出 |
onError |
捕获到异常时 | 非空返回值会替换默认错误响应体;返回 undefined 才走内置渲染 |
onResponse 是「终态副作用钩子」,这一点在用它改响应时会踩坑:返回值不被采用,改 response.headers 也已经晚了。要改响应,请在中间件里动手(2.5 有对照)。
⚠️ 全局钩子只对主应用生效,挂载进来的子应用不会执行它们。源码里
onError有一道!nested判断,正是为此。要按子应用区分逻辑,只能用中间件,且在mount之后注册的中间件看不到已合并的路由细节——所以更稳的做法是让子应用自己带use。
一个属性:app.config
app.config 是只读引用,就是构造时传进去的那份配置(plugins 不在其中,它在构造时就被消费掉了)。它适合在工具函数里读 debug 决定日志粒度;不要试图运行时改它来切换行为——分发函数在 use()、mount() 时才会重建,直接改配置不会生效。
排查表
| 现象 | 最可能原因 | 处理 |
|---|---|---|
app.fetch("/") 报类型错误 |
fetch 只接受 Request |
改用 app.request("/") |
onRequest 在子应用里不执行 |
钩子不继承 | 在子应用里改用中间件 |
/foo% 直接 400,没进 handler |
畸形百分号编码被前置拦截 | 确需放行就开 allowMalformedURL,见 4.2 |
| 生产环境错误响应里出现文件路径 | debug 未关 |
用环境变量控制,见上文配置项 |
curl -I 返回 200 但体为空 |
HEAD 回落 GET 并剥体,属正常 | 无需处理 |
use("/api", h) 不匹配 /api/users |
v2 只匹配本路径 | 改成 use("/api/**", h) |
改了 app.config.debug 但没生效 |
配置只在构造时读取 | 重新创建实例 |
延伸阅读
- 请求进应用之后按什么顺序被处理:见 2.1 请求生命周期
- 中间件的注册范围与短路规则:见 2.3 中间件
- 挂载子应用与第三方 Web 应用的差异:见 2.7 嵌套应用
- 插件怎么写、注册时机有什么讲究:见 5.1 H3 插件

TRAE-AI编程


更多建议: