← 中文资源

简体中文版本

强类型路由模式解析器

匹配 Express 风格路由并把参数名推断为 TypeScript 联合类型,支持命名通配符与百分号解码。

阅读英文原文

功能说明

该示例解析并匹配 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("/", "/"));