H3 路由规则入门

2026-10-30 15:39 更新

给一批路径加同样的响应头、把旧地址 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() 注册成全局中间件后,每个请求会经历:

  1. 找出所有匹配的路径模式;
  2. 把这些规则合并成一份配置;
  3. 按固定的执行顺序跑各条规则的中间件——有的包住响应,有的直接答掉请求;
  4. 把合并结果挂到 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 从最宽到最窄依次合并:对象选项做浅合并,越具体的值越赢;标量和其他非对象值整体替换。

两条规则同时命中,合并后写入 event.context.routeRules

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

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号