Skip to content

4. 词法分析:parse 函数的核心正则表达式

欢迎来到本书的核心实现部分。从本章开始,我们将深入 path-to-regexp 的源码世界,亲手构建我们自己的 mini-path-to-regexp。我们的第一站,是整个转换流程的入口——parse 函数,它的核心职责就是进行词法分析,将路径字符串分解为 Token 数组。

而要实现 parse 函数,我们首先需要打造它的引擎:一个强大而精妙的正则表达式。

4.1. parse 函数的目标

在上一章我们已经知道,parse 函数的目标是将一个路径字符串,例如 /user/:id(\d+)?,转换成一个 Token 数组。为了实现这个目标,parse 函数需要在一个循环中,不断地从路径字符串的头部“切割”出一个个有意义的片段(Token),直到整个字符串被消耗完毕。

这些“有意义的片段”主要有两种:

  1. 静态路径片段: 如 /user//about
  2. 动态参数片段: 如 :id:id(\d+):id?

parse 函数的核心挑战在于,如何在一个循环中,高效地识别并捕获这两种完全不同的片段?答案就是利用一个精心设计的正则表达式,它能够一次性地匹配出“下一个”参数片段,或者在没有参数片段时,匹配出尽可能长的静态路径片段。

4.2. 核心正则表达式的构建与解析

path-to-regexp 源码中的 parse 函数,其核心驱动是一个名为 PATH_REGEXP 的正则表达式。让我们先一睹它的真容,然后再逐一拆解它的构成:

javascript
// path-to-regexp/src/index.ts
const PATH_REGEXP = new RegExp(
  [
    // Match escaped characters that would otherwise appear in future matches.
    // This allows the user to escape special characters that won't transform.
    "(\\\\.)",
    // Match Express-style parameters and un-named parameters with a prefix
    // and optional suffixes. Matches appear as:
    //
    // ":test(\\.+)?" => [":test(\\.+)?", ":", "test", "(\\.+)?", "\\.", "?", undefined]
    // ":test?"      => [":test?", ":", "test", undefined, undefined, "?", undefined]
    "([\\.](?:(?::\\w+)(?:\\((?:\\([^)]+\\)|[^\\()]+)+\\))?|\\))|\\(((?:\\([^)]+\\)|[^\\()]+)+)\\))([+*?])?",
    // Match regexp special characters that are always escaped.
    "([.{}])"
  ].join("|"),
  "g"
);

这个正则表达式看起来非常复杂,但别担心,它是由三个主要部分通过 |(或)连接而成的。g 标志意味着它会进行全局匹配。让我们逐一分析这三个部分。

第一部分:转义字符 (\\.)

  • 表达式: (\\.)
  • 解释: 这部分非常简单,它匹配一个反斜杠 \ 后面跟着任意一个字符。例如 \(\.
  • 作用: path-to-regexp 允许用户通过反斜杠来转义那些具有特殊语法意义的字符(如 (, ), :, * 等),使它们被当作普通的静态文本处理。例如,在 /user\(:id\) 中,括号 () 将被视为字面量,而不是参数的自定义模式。这个部分就是用来捕获这些被转义的字符序列的。

第二部分:参数匹配(核心)

这是整个正则表达式中最核心、最复杂的部分。它自身又由两个子部分通过 | 连接而成,分别用于匹配命名参数和未命名参数。

  • 表达式: ([\\.](?:(?::\\w+)(?:\\((?:\\([^)]+\\)|[^\\()]+)+\\))?|\\))|\\(((?:\\([^)]+\\)|[^\\()]+)+)\\))([+*?])?

让我们把它拆开来看:

  1. 命名参数部分: ([\\.](?:(?::\\w+)(?:\\((?:\\([^)]+\\)|[^\\()]+)+\\))?|\\))

    • ([\\.]): 匹配并捕获一个前缀字符,通常是 /.。这是参数的“分隔符”。
    • (?: ... ): 一个非捕获组,内部包含了匹配参数主体逻辑。
    • (?::\\w+): 匹配参数名,如 :id\\w+ 匹配一个或多个字母、数字或下划线。
    • (?:\\((?:\\([^)]+\\)|[^\\()]+)+\\))?: 这是一个非常巧妙的结构,用于匹配括号内的自定义模式,如 (\d+)。它甚至能够正确处理嵌套的括号!我们暂时无需深究其细节,只需知道它能完整地捕获括号内的内容即可。这部分是可选的。
  2. 未命名参数部分: \\(((?:\\([^)]+\\)|[^\\()]+)+)\\))

    • 这部分与上面类似,但它不要求有 : 前缀,直接匹配括号内的正则表达式,用于捕获未命名参数。
  3. 修饰符部分: ([+*?])?

    • ([+*?]): 匹配并捕获一个修饰符 +, *, 或 ?
    • ?: 表示修饰符本身是可选的。

当这个核心部分成功匹配时,它的捕获组会包含参数的各个组成部分:前缀、名称、自定义模式、修饰符等。parse 函数正是利用这些捕获组来构建 Token 对象的。

第三部分:特殊的正则字符 ([.{}])

  • 表达式: ([.{}])
  • 解释: 匹配那些在正则表达式中有特殊意义,但在这里我们希望它们被自动转义的单个字符,如 .{}

4.3. 工作流程模拟

现在,让我们来模拟一下 parse 函数如何使用这个 PATH_REGEXP 来处理字符串 /user/:id?

  1. 初始化: path = "/user/:id?", tokens = [], index = 0
  2. 循环开始: PATH_REGEXP.lastIndex 被设置为 index (0)。
  3. 执行匹配: PATH_REGEXP.exec(path) 开始从头扫描。
    • 它无法匹配到任何参数。exec 返回 null
  4. 处理静态文本: 当 exec 返回 null 时,意味着从当前 index 到字符串末尾都没有参数了。parse 函数会截取从 index 到字符串末尾的所有内容 (/user/:id?),但它发现这其中可能包含参数。正确的做法是,当匹配失败时,应该找到下一个参数出现的位置。

让我们换一个更清晰的思路来描述 parse 的逻辑:

  1. 循环执行 PATH_REGEXP.exec(path): 在全局模式下,每次调用 exec 都会从上一次匹配结束的位置继续向后查找。
  2. 处理匹配结果: 当 exec 找到一个匹配项(例如,它在 /user/:id? 中找到了 :id?):
    • 捕获静态部分: 从上一个 index 到当前匹配开始的位置之间的所有文本(即 /user),都是一个静态路径 Token。将其创建并推入 tokens 数组。
    • 捕获动态部分: 利用 exec 返回的捕获组(包含前缀、名称、修饰符等信息),创建一个参数 Token。将其推入 tokens 数组。
    • 更新 index: 将 index 更新为当前匹配结束的位置。
  3. 处理尾部静态文本: 当循环结束后,如果 index 还没有到达字符串的末尾,那么从 index 到末尾的所有内容都是最后一段静态路径 Token

通过这种“查找-截取”的循环模式,parse 函数就能够利用 PATH_REGEXP 这个强大的引擎,将任意复杂的路径字符串,一步步地分解成结构清晰的 Token 序列。

在下一章,我们将把这个逻辑翻译成具体的代码,并完整地实现 Token 数据结构。

4. 词法分析:parse 函数的核心正则表达式 has loaded