功能说明
该示例解析并匹配 Express 风格的路由模式。冒号段表示单段参数,星号段表示可跨斜杠的命名通配符。匹配成功后返回参数对象,并自动对百分号编码做解码。类型层面通过模板字面量类型从模式中提取参数名,使返回值拥有具体的键,而不是宽泛的字符串索引。
关键代码
RouteParams 递归匹配字符串中的冒号和星号,ExtractSegment 处理剩余路径,把参数名合并成联合类型。运行时 compile 按斜杠切分模式,普通段做正则转义,冒号段生成不跨斜杠的捕获组,星号段生成贪婪捕获组,同时检查参数名合法且不重复。matchRoute 执行正则,逐个解码捕获值,解码失败时抛出明确错误。
使用注意
该实现不支持可选段、正则约束段与尾部可选斜杠之外的复杂语法。通配符应放在最后,否则后续段的捕获可能不符合预期。当模式变量被拓宽为 string 时,参数名类型会退化为空对象,此时需要手动声明泛型或使用 as const。路由匹配只负责解析,不负责权限校验,敏感参数仍需服务端验证。
验证方式
断言 /users/:userId/posts/:postId 匹配后得到两个参数且空格被解码。测试多余路径段返回 null、根路径匹配成功、通配符捕获多段路径。传入不以斜杠开头的模式、重复参数名或非法参数名分别抛错,并确认大小写与尾部斜杠的行为一致。
代码示例
type ExtractSegment<Segment extends string> =
Segment extends `${infer Name}/${infer Tail}`
? Name | RouteParams<`/${Tail}`>
: Segment;
export type RouteParams<Pattern extends string> =
Pattern extends `${string}:${infer Rest}`
? ExtractSegment<Rest>
: Pattern extends `${string}*${infer Rest}`
? ExtractSegment<Rest>
: never;
export function matchRoute<Pattern extends string>(
pattern: Pattern,
pathname: string,
): Record<RouteParams<Pattern>, string> | null {
if (typeof pattern !== "string" || !pattern.startsWith("/")) {
throw new TypeError("pattern must start with /");
}
if (typeof pathname !== "string") throw new TypeError("pathname must be a string");
const compiled = compile(pattern);
const match = compiled.regex.exec(pathname);
if (match === null) return null;
const params = {} as Record<RouteParams<Pattern>, string>;
compiled.names.forEach((name, index) => {
const value = match[index + 1];
if (value === undefined) return;
try {
(params as Record<string, string>)[name] = decodeURIComponent(value);
} catch {
throw new Error(`invalid percent-encoding for "${name}"`);
}
});
return params;
}
interface CompiledPattern {
readonly regex: RegExp;
readonly names: string[];
}
function compile(pattern: string): CompiledPattern {
if (pattern === "/") return { regex: /^\/$/, names: [] };
const names: string[] = [];
const segments = pattern.split("/").slice(1);
const expressions = segments.map((segment) => {
if (segment === "") return "";
const parameter = /^[:*]/.test(segment);
if (!parameter) return escapeRegExp(segment);
const name = segment.slice(1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
throw new Error(`invalid parameter segment "${segment}"`);
}
if (names.includes(name)) throw new Error(`duplicate parameter "${name}"`);
names.push(name);
return segment.startsWith("*") ? "(.*)" : "([^/]+)";
});
return { regex: new RegExp(`^/${expressions.join("/")}/?$`), names };
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const userPost = matchRoute("/users/:userId/posts/:postId", "/users/42/posts/hello%20world");
console.log(userPost);
console.log(matchRoute("/users/:userId", "/users/42/extra"));
const file = matchRoute("/files/*path", "/files/reports/2026/q3.pdf");
console.log(file);
console.log(matchRoute("/", "/"));