H3 应用实例 API

2026-10-30 17:48 更新

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 插件
以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号