← 路由设计 · 稳定入口与可变目标 / 路由匹配引擎 · path 怎么找到 handler 待审核 3 / 23

路由匹配引擎 · path 怎么找到 handler

服务端的 Express、前端的 React Router,内核都是同一件事:拿一张路由表,自上而下用一条 path 去匹配每一条 pattern,第一条命中者胜,并把 path 里对应的片段提取成 params 交给 handler。本页把这台引擎拆开:三种 segment、参数提取、以及「顺序即优先级」这个最常见的易错点。

顺序即优先级。 路由表是线性扫描、第一条命中就停。所以越具体的越要排前面/users/new 必须排在 /users/:id 之前——否则匹配 /users/new 时会先撞上 :id、把 new 当成一个 id 吃掉 (id = "new"),后面真正的 /users/new handler 永远不会被执行。这种「被排在前面、更宽的规则提前匹配走」叫 route shadowing。勾选上面的开关可以重现一次。

路由匹配的内核——就是这台引擎在跑。

// 一张路由表上跑匹配: 第一条命中者胜 (顺序即优先级)
function matchTable(routes, path) {
  for (const route of routes) {       // 自上而下, 线性扫
    const r = matchPattern(route.pattern, path);
    if (r.ok) return { route, params: r.params };  // ← 命中即停, 不再检查后面的
  }
  return null;                        // 落空 → 交给 catch-all / 404
}

// 单条 pattern 对单条 path: 按 "/" 切段, 逐段对齐
function matchPattern(pattern, path) {
  const ps = segsOf(pattern), xs = segsOf(path), params = {};
  for (let i = 0; i < ps.length; i++) {
    const p = ps[i];
    if (p[0] === '*') { params['*'] = xs.slice(i).join('/'); return ok(params); }
    if (i >= xs.length) return miss();          // path 太短
    if (p[0] === ':') params[p.slice(1)] = xs[i];  // 命名参数: 吃一段
    else if (p !== xs[i]) return miss();         // 字面段: 必须相等
  }
  return xs.length > ps.length ? miss() : ok(params);  // path 还有剩 → 不匹配
}

// 把 path 按 "/" 切成 segment 数组: 先去掉 fragment 与 query, 再丢掉空段
// "/users/42/posts/" → ["users", "42", "posts"]
function segsOf(path) {
  return path.split('#')[0].split('?')[0].split('/').filter(Boolean);
}

1 · 一条 path 进来,引擎做了什么

匹配 = 把 pattern 和 path 都按 / 切成 segment 数组,再逐段对齐:字面段要求相等、:param 消费一段并记下值、* 匹配剩余全部。任一段对不上、或两边段数不齐(且没有通配 catch-all),这条就 miss。全表没有一条命中,通常落到末尾的 * catch-all 渲染 404——这也是「为什么 404 页本质上也是一条路由」。

同一个目标,三条路。 让「最该命中的那条」命中,真实世界有三种做法——顺序(Express 风格):你写的先后就是优先级,自己负责把具体的排前面(上面那个 demo + 开关演示的就是它,排错就 shadowing);按 specificity 自动排序(React Router / path-to-regexp 风格):框架给每条路由算具体度、自动重排,与你写的先后无关;radix tree:换数据结构,让优先级内建在下行顺序里,连排序都省了。上面讲完了顺序法,下面把 specificity 与 radix tree 各演示一遍——三者都附核心代码,对照着看。

2 · 第二条路:按 specificity 自动排序(React Router 风格)

与其要你把具体的手动排前面,不如让框架代劳:给每条路由算一个 specificity(具体度)、自动重排——字面段 (3) > :param (2) > * (0)。于是不管你写表时多乱,/users/new 都会被排到 /users/:id 前面、* catch-all 沉到最后,「取第一条命中」就不再 shadowing。下面这张表故意打乱了顺序,勾上开关看框架把它重排成什么样。

一个关键细节:不能把整条路由压成一个标量分求和——那会丢掉位置信息,让 /users/:id [3,2]/:resource/new [2,3] 算成同分。正确做法是按逐段 score 数组字典序比较:第一处不同的 segment 就定胜负、越靠前权重越高;完全同分才退回定义顺序。每行末尾的 [..] 就是它的 score 数组。

specificity 比较器——逐段字典序,不求和。

// pattern → 逐段 specificity 数组   /users/:id → [3,2]   /files/* → [3,0]
const S = { static: 3, param: 2, end: 1, wild: 0 };   // 注意: end > wild
function scoreOf(pattern) {
  return segsOf(pattern).map(s =>
    (s === '*' || s[0] === '*') ? S.wild : s[0] === ':' ? S.param : S.static);
}

// 逐段字典序比较: 第一处不同的 segment 定胜负 (越靠前权重越高)。
// 不求和! 求和会让 /users/:id 和 /:resource/new 打平。
function compareSpecificity(a, b) {
  const sa = scoreOf(a), sb = scoreOf(b);
  for (let i = 0; i < Math.max(sa.length, sb.length); i++) {
    const va = i < sa.length ? sa[i] : S.end;   // 已走完 = end, 比 wild 具体
    const vb = i < sb.length ? sb[i] : S.end;
    if (va !== vb) return vb - va;              // specificity 高的排前面
  }
  return 0;                                     // 全等 → 退回定义顺序
}

// 按具体度重排; 同分回退到原始下标 i (stable)
const rankRoutes = (routes) => routes
  .map((r, i) => ({ r, i }))
  .sort((x, y) => compareSpecificity(x.r, y.r) || x.i - y.i)
  .map(o => o.r);

3 · 进阶 · 第三条路:radix tree 把优先级「编进结构」

前面两种策略(Express 的顺序、React Router 的 specificity 打分)都在一张平表上做文章。高性能 router(Fastify 的 find-my-way、Go 的 httprouter、Echo)走第三条路:把整张路由表编译成一棵 radix tree(前缀树)——每个节点是一段 path,共同前缀只存一份。优先级不再靠你排表、也不靠打分,而是内建在下行的尝试顺序里

规则只有一条:在每个节点要匹配 path 的下一段时,固定先试 static 子节点(逐字相等)→ 不中再试 :param 节点 → 最后才是 * 通配。这个顺序就是优先级。于是上面那个 route shadowing 自动消失:试试 /users/new——在 users 节点下,new (static) 天生先于 :id (param) 被试到,你把 :id 写在表的最前面也没用(对比上面那个开关:Express 风格一写反就被遮蔽,radix tree 对你的书写顺序免疫)。

radix tree 下行匹配——static → param → wild,失配则回溯。

// 沿树下行: 每个节点按 static → param → wild 试子节点, 失配则回溯。
// children 在建树时已排成 static → param → wild —— 这个顺序就是优先级。
function descend(node, xs, i = 0) {
  if (i === xs.length) return node.handler || null;        // 段吃完, 此处须是 handler
  for (const c of node.children) {
    if (c.type === 'static' && c.seg !== xs[i]) continue;  // 静态: 逐字不等, 跳过
    if (c.type === 'wild') return c.handler;               // 通配: 吞掉剩余全部
    const hit = descend(c, xs, i + 1);    // 静态命中 / 参数吃一段 → 递归下行
    if (hit) return hit;                  // 子树没出路会回溯, 继续试下一个兄弟
  }
  return null;                            // 全不中 → 交给上层 catch-all
}

它换来什么、又有什么代价。 :匹配是沿树下行,约 O(path 段数),和路由总条数无关——平表逐条扫是 O(n),这是它在大路由表下的核心优势。回溯:static 分支若深入后失配,要退回上一个节点改试 :param / *,并非一路到底。约束:* 一般只能在末尾;给参数加正则约束 :id(\d+) 会让「这段算不算 static 命中」的判定更复杂。一句话:换数据结构,把「谁更具体」从运行时比较挪成了编译期结构——排序的烦恼没了,换来一棵要维护的树。

4 · 相关页

  • 本系列 · URLPattern——这台手工实现的 matcher,浏览器已经标准化、内建成了 URLPattern——而且匹配范围从 pathname 扩到整条 URL,语法多了正则约束 :id(\d+)、可选段 {/:slug}? 等。
  • 本系列 · HTTP 重定向——匹配到 handler 之后,handler 可以选择不渲染内容、而是回一个 3xx 把客户端「指」去别处——那是路由的另一种出口。
  • 本系列 · 分享地址统一入口——真实应用:用一条 /s/:code 路由接住所有分享链接,code 正是这里提取出来的 param。
  • URL Anatomy · 一条网址怎么拆开——匹配用的 path 只是 URL 的一段。那个系列把 scheme/host/path/query/fragment 七块逐段拆开。
  • find-my-way · Fastify 的 radix tree router · github.com——对应「进阶」章节:真实的 radix tree 路由实现,看它如何用前缀树把 static / param / wildcard 的优先级编进结构(Go 的 julienschmidt/httprouter 同一思路)。