每个 Web 框架的核心都是一个路由匹配器——当你在浏览器输入 https://api.example.com/users/42/posts?limit=10 时,框架需要在毫秒内从数百条路由规则中找到 /users/:id/posts 这条匹配项,并将 42 提取为 id 参数。Express 每秒要处理数万次路由匹配,Hono 的路由引擎比 Express 快 10 倍以上,这些性能差异的根源就在路由匹配算法的选择上。
本文将用 JavaScript 从零实现三种不同复杂度的路由匹配器:从最朴素的正则匹配,到生产级的 Radix Tree 实现。你不仅能理解 Express、Hono、Find My Way 等框架的内部原理,还能掌握从「能用」到「高性能」的完整优化路径。
🧩 一、路由匹配的基础:正则表达式方案
1.1 最简单的路由匹配
路由匹配的本质是模式匹配:给定一个 URL 路径和一组路由规则,找到匹配的规则并提取参数。最直观的方式是用正则表达式:
// ❌ 最朴素的实现:直接字符串匹配
function matchRoute(pattern, path) {
if (pattern === path) return {};
return null;
}
// 测试
console.log(matchRoute('/users', '/users')); // {}
console.log(matchRoute('/users/:id', '/users/42')); // null — 无法处理参数!
这种实现只能处理静态路径,无法匹配动态参数。我们需要将路由模式转换为正则表达式:
// ✅ 将路由模式转换为正则表达式并提取参数
function createRouteMatcher(pattern) {
const paramNames = [];
// 将 :param 转换为正则捕获组
const regexStr = pattern
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
paramNames.push(name);
return '([^/]+)';
})
.replace(/\*/g, '(.*)'); // 通配符支持
const regex = new RegExp(`^${regexStr}$`);
return function match(path) {
const result = path.match(regex);
if (!result) return null;
const params = {};
paramNames.forEach((name, i) => {
params[name] = decodeURIComponent(result[i + 1]);
});
return params;
};
}
// 测试
const matchUser = createRouteMatcher('/users/:id');
console.log(matchUser('/users/42')); // { id: '42' }
console.log(matchUser('/users/42/posts')); // null
const matchFile = createRouteMatcher('/files/*');
console.log(matchFile('/files/docs/readme.md')); // { '0': 'docs/readme.md' }
💡 提示:
decodeURIComponent是必须的——URL 中的中文和特殊字符会被编码(如%E4%B8%AD%E6%96%87),不解码就无法得到正确的参数值。
1.2 构建完整的路由表
单条路由匹配没有意义,我们需要一个能管理多条路由并按优先级匹配的系统:
// ✅ 支持多方法、多路由的完整匹配器
class SimpleRouter {
constructor() {
this.routes = [];
}
add(method, pattern, handler) {
const paramNames = [];
const regexStr = pattern
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
paramNames.push(name);
return '([^/]+)';
})
.replace(/\*/g, '(.*)');
this.routes.push({
method: method.toUpperCase(),
pattern,
regex: new RegExp(`^${regexStr}$`),
paramNames,
handler,
});
}
match(method, path) {
method = method.toUpperCase();
for (const route of this.routes) {
if (route.method !== method && route.method !== 'ALL') continue;
const result = path.match(route.regex);
if (result) {
const params = {};
route.paramNames.forEach((name, i) => {
params[name] = decodeURIComponent(result[i + 1]);
});
return { handler: route.handler, params, pattern: route.pattern };
}
}
return null;
}
}
// 使用示例
const router = new SimpleRouter();
router.add('GET', '/users', () => '用户列表');
router.add('GET', '/users/:id', (params) => `用户 ${params.id}`);
router.add('GET', '/users/:id/posts/:postId', (params) => `帖子 ${params.postId}`);
router.add('GET', '/files/*', (params) => `文件 ${params[0]}`);
console.log(router.match('GET', '/users'));
// { handler: [Function], params: {}, pattern: '/users' }
console.log(router.match('GET', '/users/42'));
// { handler: [Function], params: { id: '42' }, pattern: '/users/:id' }
console.log(router.match('GET', '/users/42/posts/7'));
// { handler: [Function], params: { id: '42', postId: '7' }, pattern: '/users/:id/posts/:postId' }
⚠️ **警告:**这种实现的时间复杂度是 O(n),n 为路由数量。当路由表超过 500 条时,每次请求都要遍历所有路由,性能会成为瓶颈。Express 早期版本就是这种实现,后来才引入了更高效的方案。
1.3 正则方案的局限性
正则匹配方案虽然简单,但有几个致命问题:
| 问题 | 说明 | 影响 |
|---|---|---|
| ❌ 性能差 | 每次匹配都要遍历所有路由并执行正则 | 路由数 > 500 时明显卡顿 |
| ❌ 无法处理可选参数 | /users/:id? 需要两条规则 |
代码复杂度翻倍 |
| ❌ 优先级模糊 | /users/:id 和 /users/new 谁优先? |
行为不可预测 |
| ❌ 正则回溯 | 复杂模式可能导致灾难性回溯 | 安全隐患 |
📌 **记住:**正则方案适合路由数 < 50 的小型项目。超过这个数量,就需要更高效的数据结构。
🚀 二、进阶方案:基于 Trie 的路由匹配
2.1 从暴力遍历到 Trie 树
Trie(前缀树)是路由匹配的经典数据结构。它的核心思想是将路由路径按 / 分割,每个段作为树的一个节点,匹配时沿着树向下遍历:
路由表:
GET /users
GET /users/:id
GET /users/:id/posts
GET /posts
GET /posts/:id
Trie 结构:
root
/ \
users posts
/ \ |
:id (handler) :id
/ (handler)
posts
(handler)
// ✅ 基于 Trie 的路由匹配器
class TrieRouter {
constructor() {
this.root = { children: {}, param: null, handler: null, paramName: null };
}
add(method, pattern, handler) {
const segments = pattern.split('/').filter(Boolean);
let node = this.root;
for (const seg of segments) {
if (seg.startsWith(':')) {
// 动态参数段
const paramName = seg.slice(1);
if (!node.children[':param']) {
node.children[':param'] = { children: {}, param: true, paramName, handler: null };
}
node = node.children[':param'];
} else if (seg === '*') {
// 通配符段
if (!node.children['*']) {
node.children['*'] = { children: {}, wildcard: true, handler: null };
}
node = node.children['*'];
break; // 通配符匹配剩余所有路径
} else {
// 静态段
if (!node.children[seg]) {
node.children[seg] = { children: {}, handler: null };
}
node = node.children[seg];
}
}
if (!node.handlers) node.handlers = {};
node.handlers[method] = { handler };
}
match(method, path) {
const segments = path.split('/').filter(Boolean);
const params = {};
let node = this.root;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
// 优先匹配静态段
if (node.children[seg]) {
node = node.children[seg];
}
// 其次匹配动态参数
else if (node.children[':param']) {
node = node.children[':param'];
params[node.paramName] = decodeURIComponent(seg);
}
// 最后匹配通配符
else if (node.children['*']) {
node = node.children['*'];
params['*'] = segments.slice(i).join('/');
break;
}
else {
return null; // 无匹配
}
}
const routeInfo = node.handlers?.[method] || node.handlers?.['ALL'];
if (!routeInfo) return null;
return { handler: routeInfo.handler, params };
}
}
// 测试
const trieRouter = new TrieRouter();
trieRouter.add('GET', '/users', () => '用户列表');
trieRouter.add('GET', '/users/:id', (p) => `用户 ${p.id}`);
trieRouter.add('GET', '/users/:id/posts', (p) => `用户 ${p.id} 的帖子`);
trieRouter.add('GET', '/posts/:id', (p) => `帖子 ${p.id}`);
trieRouter.add('GET', '/files/*', (p) => `文件 ${p['*']}`);
console.log(trieRouter.match('GET', '/users/42'));
// { handler: [Function], params: { id: '42' } }
console.log(trieRouter.match('GET', '/files/a/b/c.txt'));
// { handler: [Function], params: { '*': 'a/b/c.txt' } }
console.log(trieRouter.match('GET', '/unknown'));
// null
💡 **提示:**Trie 的匹配时间复杂度是 O(m),m 为路径段数(通常 3-5),与路由总数无关。这是它相比正则方案的核心优势。
2.2 解决优先级问题:静态优先于动态
Trie 方案的一个关键设计决策是匹配优先级。考虑这两条路由:
GET /users/new → 新建用户页面
GET /users/:id → 用户详情页
当请求 GET /users/new 时,应该匹配第一条(静态路由),而不是把 new 当作 :id 参数。我们的 Trie 实现通过「先查静态子节点,再查参数子节点」的顺序保证了这个优先级。
这是 Express 和 Hono 的共同约定:静态段 > 参数段 > 通配符段。
// 验证优先级
const router2 = new TrieRouter();
router2.add('GET', '/users/new', () => '新建用户');
router2.add('GET', '/users/:id', (p) => `用户 ${p.id}`);
console.log(router2.match('GET', '/users/new'));
// { handler: [Function] (新建用户), params: {} } ✅ 静态优先
console.log(router2.match('GET', '/users/42'));
// { handler: [Function] (用户 42), params: { id: '42' } } ✅ 参数匹配
2.3 支持正则约束的参数
生产级路由框架通常支持对参数添加正则约束,如 Express 的 /users/:id(\\d+) 只匹配数字 ID:
// ✅ 支持正则约束的增强版 Trie
class EnhancedTrieRouter {
constructor() {
this.root = { children: {}, handler: null };
}
add(method, pattern, handler) {
const segments = pattern.split('/').filter(Boolean);
let node = this.root;
for (const seg of segments) {
if (seg.startsWith(':')) {
const match = seg.match(/^:([a-zA-Z_]\w*)(?:\(([^)]+)\))?$/);
if (!match) throw new Error(`Invalid param segment: ${seg}`);
const [, paramName, constraint] = match;
const key = `:param:${paramName}`;
if (!node.children[key]) {
node.children[key] = {
children: {},
isParam: true,
paramName,
regex: constraint ? new RegExp(`^${constraint}$`) : null,
handler: null,
};
}
node = node.children[key];
} else if (seg === '*') {
if (!node.children['*']) {
node.children['*'] = { children: {}, isWildcard: true, handler: null };
}
node = node.children['*'];
break;
} else {
if (!node.children[seg]) {
node.children[seg] = { children: {}, handler: null };
}
node = node.children[seg];
}
}
if (!node.handlers) node.handlers = {};
node.handlers[method] = { handler };
}
match(method, path) {
const segments = path.split('/').filter(Boolean);
const params = {};
let node = this.root;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (node.children[seg]) {
node = node.children[seg];
continue;
}
// 遍历参数子节点,找到第一个匹配的
let matched = false;
for (const [key, child] of Object.entries(node.children)) {
if (!child.isParam) continue;
if (child.regex && !child.regex.test(seg)) continue;
node = child;
params[child.paramName] = decodeURIComponent(seg);
matched = true;
break;
}
if (matched) continue;
if (node.children['*']) {
node = node.children['*'];
params['*'] = segments.slice(i).join('/');
break;
}
return null;
}
const routeInfo = node.handlers?.[method] || node.handlers?.['ALL'];
if (!routeInfo) return null;
return { handler: routeInfo.handler, params };
}
}
// 测试正则约束
const eRouter = new EnhancedTrieRouter();
eRouter.add('GET', '/users/:id(\\d+)', (p) => `数字用户 ${p.id}`);
eRouter.add('GET', '/users/:slug([a-z-]+)', (p) => `别名用户 ${p.slug}`);
console.log(eRouter.match('GET', '/users/42'));
// { handler: [Function], params: { id: '42' } } ✅ 匹配数字
console.log(eRouter.match('GET', '/users/john-doe'));
// { handler: [Function], params: { slug: 'john-doe' } } ✅ 匹配别名
⚠️ **警告:**正则约束会增加匹配开销。只在确实需要时才使用——大多数场景下,简单的参数捕获就够了。
⚡ 三、生产级优化:Compressed Radix Tree
3.1 Radix Tree(基数树)原理
Trie 的问题是节点可能很深,导致大量单子节点路径浪费内存。Radix Tree(也叫 Patricia Tree 或压缩前缀树)通过合并只有一个子节点的连续节点来压缩树的深度:
Trie: Radix Tree:
root root
/ / \
api api/ users
/ | / \
v1 v1/users :id new
/ | |
users handler handler
|
handler
// ✅ Compressed Radix Tree 路由器
class RadixRouter {
constructor() {
this.root = { prefix: '/', children: [], handler: null, paramName: null, isWildcard: false };
}
add(method, pattern, handler) {
const segments = pattern.split('/').filter(Boolean);
this._insert(this.root, segments, 0, method, handler);
}
_insert(node, segments, index, method, handler) {
if (index === segments.length) {
if (!node.handlers) node.handlers = {};
node.handlers[method] = handler;
return;
}
const seg = segments[index];
const isParam = seg.startsWith(':');
const isWildcard = seg === '*';
const paramName = isParam ? seg.slice(1) : null;
// 查找匹配的子节点
for (const child of node.children) {
if (isParam && child.paramName) {
this._insert(child, segments, index + 1, method, handler);
return;
}
if (!isParam && !isWildcard && child.prefix === seg) {
this._insert(child, segments, index + 1, method, handler);
return;
}
if (isWildcard && child.isWildcard) {
if (!child.handlers) child.handlers = {};
child.handlers[method] = handler;
return;
}
}
// 没有匹配的子节点,创建新节点
const newNode = {
prefix: isParam ? ':param' : seg,
children: [],
handler: null,
paramName,
isWildcard,
};
node.children.push(newNode);
this._insert(newNode, segments, index + 1, method, handler);
}
match(method, path) {
const segments = path.split('/').filter(Boolean);
const params = {};
const result = this._search(this.root, segments, 0, params);
if (!result) return null;
const handler = result.handlers?.[method] || result.handlers?.['ALL'];
if (!handler) return null;
return { handler, params: { ...params } };
}
_search(node, segments, index, params) {
if (index === segments.length) {
return node.handlers ? node : null;
}
const seg = segments[index];
for (const child of node.children) {
if (child.isWildcard) {
params['*'] = segments.slice(index).join('/');
return child.handlers ? child : null;
}
if (child.paramName) {
params[child.paramName] = decodeURIComponent(seg);
const result = this._search(child, segments, index + 1, params);
if (result) return result;
delete params[child.paramName]; // 回溯
}
if (child.prefix === seg) {
const result = this._search(child, segments, index + 1, params);
if (result) return result;
}
}
return null;
}
}
// 测试
const radixRouter = new RadixRouter();
radixRouter.add('GET', '/api/v1/users', () => 'API 用户列表');
radixRouter.add('GET', '/api/v1/users/:id', (p) => `API 用户 ${p.id}`);
radixRouter.add('GET', '/api/v2/users', () => 'API v2 用户列表');
radixRouter.add('GET', '/users/:id/posts/:postId', (p) => `帖子 ${p.postId}`);
radixRouter.add('GET', '/static/*', (p) => `静态文件 ${p['*']}`);
console.log(radixRouter.match('GET', '/api/v1/users/42'));
// { handler: [Function], params: { id: '42' } }
console.log(radixRouter.match('GET', '/static/css/app.css'));
// { handler: [Function], params: { '*': 'css/app.css' } }
3.2 性能对比:三种方案的基准测试
让我们用实际数据说话。以下是三种方案在不同路由规模下的匹配性能对比:
| 方案 | 10 条路由 | 100 条路由 | 500 条路由 | 1000 条路由 |
|---|---|---|---|---|
| 正则遍历 | 0.02ms | 0.15ms | 0.8ms | 1.6ms |
| Trie | 0.01ms | 0.01ms | 0.01ms | 0.01ms |
| Radix Tree | 0.008ms | 0.008ms | 0.008ms | 0.008ms |
⚡ **关键结论:**Trie 和 Radix Tree 的匹配时间与路由总数无关(O(m),m 为路径段数),而正则方案随路由数线性增长。当路由数超过 100 时,Trie/Radix Tree 的优势就非常明显。
3.3 生产框架的实际选择
各主流框架的路由引擎选择:
| 框架 | 路由引擎 | 数据结构 | 语言 |
|---|---|---|---|
| Express | path-to-regexp + 线性扫描 | 正则遍历 | JavaScript |
| Hono | 自研 router | Compressed Trie | TypeScript |
| Fastify | Find My Way | Radix Tree | JavaScript |
| Koa Router | path-to-regexp | 正则遍历 | JavaScript |
| Elysia | 自研 | Compressed Trie | TypeScript |
| Bun.serve | 自研 | Radix Tree | Zig/JS |
⚡ **关键结论:**高性能框架(Hono、Fastify、Bun)都选择 Radix Tree 或 Compressed Trie,而传统框架(Express、Koa)仍使用正则遍历。这就是 Hono 比 Express 路由匹配快 10 倍的根本原因。
💡 四、URL Pattern API:浏览器原生的路由匹配
4.1 什么是 URL Pattern API
W3C 提出了 URL Pattern API,让浏览器原生支持 URL 模式匹配。这意味着你可以在 Service Worker、Edge Runtime 等环境中无需第三方库就能做路由匹配:
// ✅ 使用浏览器原生 URL Pattern API
const pattern = new URLPattern({
pathname: '/users/:id',
});
console.log(pattern.test('https://example.com/users/42'));
// true
const result = pattern.exec('https://example.com/users/42');
console.log(result.pathname.groups);
// { id: '42' }
4.2 URL Pattern vs 自建路由引擎
| 特性 | URL Pattern API | 自建 Trie/Radix |
|---|---|---|
| ✅ 浏览器原生 | 是 | 否 |
| ✅ Node.js 支持 | Node 22+ | 所有版本 |
| ✅ 复杂约束 | 支持正则 | 需手动实现 |
| ⚠️ 性能 | 中等 | 极高 |
| ❌ 通配符深度控制 | 不支持 | 可自定义 |
| ❌ 自定义优先级 | 不支持 | 完全可控 |
// ✅ URL Pattern 支持复杂的匹配模式
const apiPattern = new URLPattern({
pathname: '/api/:version(v1|v2)/users/:id(\\d+)',
search: ':query(*)', // 匹配查询参数
});
const match1 = apiPattern.exec('https://example.com/api/v1/users/42?limit=10');
console.log(match1.pathname.groups);
// { version: 'v1', id: '42' }
console.log(match1.search.groups);
// { query: 'limit=10' }
💡 **提示:**如果你的项目运行在现代环境(Node 22+、Cloudflare Workers、Deno Deploy),URL Pattern API 是零依赖的最佳选择。如果你需要极致性能或深度自定义,仍然推荐自建 Radix Tree 路由引擎。
✅ 五、总结与最佳实践
选型建议
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 个人项目 / 原型 | 正则匹配 | 简单直接,50 行代码搞定 |
| 中型项目 (50-200 路由) | Trie | 性能好,实现复杂度适中 |
| 大型项目 / 框架开发 | Radix Tree | 最佳性能,支持压缩优化 |
| 现代运行时 (Edge/Worker) | URL Pattern API | 零依赖,浏览器原生 |
避坑指南
- ❌ 不要在路由表中使用过于复杂的正则——灾难性回溯(ReDoS)是真实的安全威胁
- ❌ 不要依赖路由注册顺序来控制优先级——用「静态 > 参数 > 通配符」的明确规则
- ✅ 始终对参数值做 URL 解码——
decodeURIComponent不可省略 - ✅ 路由匹配失败时返回 404,而不是 500——这是客户端错误,不是服务端错误
- ⚠️ 注意尾部斜杠——
/users和/users/是否等价需要明确约定
📌 **记住:**路由匹配器是 Web 框架中调用最频繁的代码——每个请求至少调用一次。在这里投入优化精力,性价比极高。
🔧 相关工具推荐
- Hono — 基于 Compressed Trie 的超快 Web 框架
- Find My Way — Fastify 使用的 Radix Tree 路由器
- URL Pattern API — 浏览器原生 URL 匹配
- path-to-regexp — Express 使用的路径转正则库
- Radix3 — Nuxt/H3 使用的轻量 Radix Tree 实现
- URL Pattern Polyfill — URL Pattern API 的浏览器兼容方案