H3 路由规则代理与自定义

2026-10-30 15:39 更新

内置规则覆盖不了所有场景:还要把请求转给另一台机器、还要按自己的业务做一条规则、还要在几千条规则下保持匹配速度。这一节讲这三件事——代理、自定义规则处理器、以及让规则跑得更快的两个机制。

代理:也是可选处理器

proxy 和 cache 一样是按需引入的。处理器内部用 proxyRequest,所以单独放在 h3/rules/proxy,不代理的应用不会把它打进产物。规则存在却没注册处理器,创建匹配器时抛错。

import { routeRules } from "h3/rules";
import { proxy } from "h3/rules/proxy";

app.use(
  routeRules(
    { "/api/proxy/**": { proxy: "https://example.com/**" } },
    { handlers: { proxy } },
  ),
);

代理目标的行为与 redirect 目标完全一致:/** 尾部会被替换,原查询串会被转发。所以 3.1 里讲的两条规则在这里同样成立——尾巴不能改变目标主机;请求已在目标上时不会自己转自己。

只留数据、不真正代理

有时框架想把 proxy 当作一份声明,由外层构建工具去实现。传 handlers: { proxy: undefined } 即可:规则照样匹配、合并、挂到上下文,但不产生任何运行时行为。cache 同理。

目标安全:两条必须知道的拒绝条件

代理比重定向更危险——它会把请求送到别处并带回响应。H3 在两条线上设了闸:

第一条:尾巴不能逃出目标基路径。 拼好的路径会与目标自身基路径比对,会逃逸的请求(例如用编码的 ..%2f 做目录穿越)直接 400。

第二条:前缀段数必须稳定。 H3 靠「按段数移除规则模式的前缀」得到尾巴,所以同一模式的每次匹配,前缀都得是同样的段数。下列模式会让前缀段数变化,H3 对这类请求返回 400,而不是转发一个删错前缀的路径:

模式 结果 原因
/:lang?/old/** 拒绝 可选参数,前缀可能是 1 段或 2 段
/x{/a}?/old/** 拒绝 分组跨越了分隔符
/:lang/old/** 正常 普通参数,恒定 1 段
/x/:id(\d+)/old/** 正常 正则参数,恒定 1 段
/blog{-:title}?/old/** 正常 单段内的分组
/x/*/old/** 创建时就拒绝 * 是 catch-all,一条路由只能有一个

需要「一段但内容不定」时,用 :param 或 ([^\x2f]*),不要用 *。

自定义规则处理器

内置规则不够时,自己写一个。处理器定义形状是 { handler, order? }:

  • handler 把命中的规则变成 H3 中间件;
  • order 决定执行时机,越小越早,默认 0。内置规则占 -3 到 -1 与 1 到 3。

处理器收到四样东西:{ options, route, params?, handler? }。options 是合并后的规则值;route 是贡献该值的最具体模式——这份来源信息在 event.context.routeRules 上是拿不到的。

app.use(
  routeRules(
    { "/x/**": { shout: "hello" } },
    {
      handlers: {
        // 返回值必须是中间件:(event) => ...
        shout: {
          handler: (matched) => (event) => {
            event.res.headers.set("x-shout", String(matched.options).toUpperCase());
          },
        },
      },
    },
  ),
);

❗ 如果你的自定义处理器限制访问(鉴权门、限流、IP 白名单),必须同时设置 restricting: true。默认值是 false——对「授权」是安全的选择,对「限制」却是错的选择,而且不会有任何警告。原因在 3.4 讲 false 重置时展开。

性能:两个开关

记忆化(默认开)

同一个 method + pathname 永远得到同样的合并结果,所以 routeRules() 默认按 method + pathname 记忆化。重复请求跳过模式查找、路径归一化、合并与中间件构造,热路径退化成一次表查询。

  • 默认上限 1024 条,动态路径不会把表撑爆;用 memoize: { max } 调整。
  • 驱逐用 SIEVE:按插入顺序淘汰,但在淘汰指针上次扫过之后被访问过的条目能活过这一轮。这样一小撮热点路径不会被大量一次性动态路径冲掉——纯 FIFO 会一起淘汰;而命中仍然只是一次查表,不像 LRU 要在读时调整顺序。
  • memoize: false 则每个请求都重新解析,并生成全新的结果对象。

底层匹配器不自动开记忆化,要显式包一层 memoizeRouteRulesMatcher(matcher, opts?);不包,打包器就能把这段代码摇掉。

预合并(preMerge)

设 preMerge: true,每条模式的继承链在匹配器启动时(或构建期由编译器)提前合并好。之后每个请求只解析最具体的那一层,而不是把每层都合并一遍。方法专属规则、通用规则、false 重置、每规则的 params 行为都不变。

但它只对「关系可以事先算清」的规则集成立。像 /a/:x/c 与 /a/b/:y 这种部分重叠(最具体匹配本身有歧义),或带正则参数的路径,无法安全分析:

场景 结果
运行时匹配器 启动时抛错(宁可启动失败,不带着错误结果跑)
构建期编译器 安全降级:打一条警告,改用普通编译,产物依然正确

直接使用匹配器

多数应用用 routeRules() 就够。需要在中间件之外解析规则的框架,可以直接用底层导出:

import {
  createRouteRulesMatcher,
  normalizeRouteRules,
  memoizeRouteRulesMatcher,
} from "h3/rules";
import { cache } from "h3/rules/cache";

// 注意:createRouteRulesMatcher 要求「已归一化」的规则
const matcher = memoizeRouteRulesMatcher(
  createRouteRulesMatcher(normalizeRouteRules(config), {
    baseURL: "/base",
    preMerge: true,
    handlers: { cache },
  }),
);

const { routeRules, matchedRules, routeRuleMiddleware } = matcher("GET", "/blog/post");

三个返回值的分工:

返回值 内容
routeRules 合并后的值,与挂在 event.context.routeRules 上的是同一份
matchedRules 每项带上来源模式(route)与 rou3 参数(params)
routeRuleMiddleware 排好序的中间件链

normalizeRouteRules() 负责把 swr 这类简写展开、把字符串与布尔形式归一、把键规范化——routeRules() 内部会自动做,createRouteRulesMatcher() 则期待你已经做完。这条分工是为了让不需要归一化的运行时产物不必打包这段代码。

另外两个导出:mergeMatchedRouteRules() 是纯合并函数(输入匹配层、输出匹配规则);ruleHandlers 是 headers、redirect、cors 的默认处理器注册表。

⚠️ 匹配器 API 收到的方法必须是大写。routeRules() 中间件会替你归一化,但手写的包装器要自己传 event.req.method.toUpperCase()——否则一个用小写拼的请求不会命中任何方法限定规则,连带跳过它的鉴权门。

一个容易搞坏的写法

routeRules() 要放在 app.use() 里,不要挂到单条路由上:

// 能跑,但每条请求会把整条规则链跑两遍
app.get("/api/x", handler, { middleware: [routeRules(rules, { handlers: { cache } })] });

这样写时,整条规则链被塞进了 cache 规则要分发的那个 handler 内部,分发会重新进入自己。规则能识别重入并继续走到路由 handler(响应与缓存结果都是对的),但其余每条命中的规则会在两趟里各跑一次。

排查表

现象 原因
启动就抛错,说某规则没有处理器 cache / proxy 忘了在 handlers 里注册
代理返回 400 尾巴会逃出目标基路径,或模式的前缀段数不固定
自定义鉴权规则被编码路径绕过 漏了 restricting: true(见 3.4)
自定义规则没按预期顺序执行 默认 order 是 0,短路类必须显式指定(-2 是留给它们的空位)
开了 preMerge 启动报错 规则集里有部分重叠的模式或正则参数,无法预合并
手写匹配器匹配不到方法限定规则 方法没转大写
日志里每条规则跑两次 routeRules() 被挂到了路由中间件上

延伸阅读

  • 匹配与合并的基础规则:见 3.1
  • 缓存键、凭据剥离与执行顺序:见 3.2
  • 类型扩展、restricting 与构建期编译器:见 3.4
  • proxyRequest 与代理工具函数的细节:见 6.7 H3 代理工具
以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号