RegExp lastIndex 与 flags:改变答案的状态
把同一个模式过一遍 test、exec 与 matchAll:global 与 sticky 下 lastIndex 如何前进,为何重复使用 global 模式会翻转答案,以及 unicode 如何计数。
BotBrowser Team
BotBrowser 支持在声明的 Profile 下重复运行经授权的同源页面 fixture,并保留范围受限的观察:模式是否匹配、匹配在哪里;但它不能改变运行时如何实现 RegExp 算法,不能让 global 模式在调用之间停止携带状态,也不会把匹配位置变成身份信号。本文把一组模式过一遍 test、exec、match、replace 与 matchAll,然后记录 lastIndex 在 global 与 sticky 标志下如何前进、每个 String 方法如何不同地重置或忽略该状态、为什么重复使用一个 global 模式会在同一输入上翻转答案,以及 unicode 标志如何改变 . 与量词计数的单位。
摘要
- global 模式携带一个
lastIndex,test与exec会前进它;对同一字符串三次test返回true、true、然后false,因为第三次从上一次匹配之后开始。 - 非 global 模式让
lastIndex保持 0,所以对同一字符串三次test都返回相同答案。 - sticky 标志把每次匹配锚定在
lastIndex,所以/b/y在ab上偏移 0 处失败,只有lastIndex为 1 时才匹配。 - 带 global 标志的
String.match与String.replace之后会把lastIndex重置为 0,所以它们不会像重复使用的test那样泄漏状态。 matchAll对非 global 模式抛TypeError,因为它需要 global 标志来遍历匹配。/^.$/对表情符号失败,/^.$/u匹配,因为不带 unicode 标志时.计一个 UTF-16 单元而表情符号是两个。- 模式的来源与标志是稳定可读的字符串,所以记录可以精确存储它们。
目录
- lastIndex 在哪里在调用之间携带状态
- sticky 标志如何锚定在 lastIndex
- String 方法如何重置或忽略该状态
- unicode 标志在哪里改变模式计数的对象
- 为什么重复使用的 global 模式会翻转答案
- 记录一次匹配读数
lastIndex 在哪里在调用之间携带状态
当模式携带 global 或 sticky 标志时,它就不是其输入的纯函数。RegExp 对象保存一个 lastIndex 数字,test 与 exec 把它读作起始位置,并把它写回为匹配之后的位置。探针对同一字符串用一个 global 模式三次调用 test:
RegExp=function node=v26.9.0
fresh /a/g lastIndex=0
test("a a") -> true lastIndex=1
test("a a") again -> true lastIndex=3
test("a a") third -> false lastIndex=0
non-global lastIndex after test=true then lastIndex=0
第一次调用从索引 0 搜索,找到位置 0 的 a,返回 true 并把 lastIndex 设为 1。第二次从 1 搜索,找到位置 2 的 a,返回 true 并把 lastIndex 设为 3。第三次从 3 搜索,什么也没找到,返回 false 并把 lastIndex 重置为 0。三次调用,一个字符串,两个不同答案,唯一改变的是模式对象内部的一个数字。
这是正则表达式代码路径里最常见的缺陷:一个在模块作用域用 global 标志创建一次、又在把它当作纯谓词读取的循环里重复使用的模式。一个校验器如果一次构造 /^[a-z]+$/g 并对每个提交的名字调用 test,就会接受第一个名字,然后拒绝第二个从第一个最后一次匹配之后开始的名字,而被拒绝的名字看起来与接受的一模一样。失败是间歇性的,且跟随上一个输入,这让它很难用单个测试用例复现。
非 global 那一行是控制组。不带 global 标志的模式在进入时忽略 lastIndex 并让它保持 0,所以对同一字符串三次 test 每次都返回相同布尔值。状态住在模式对象里而不是字符串里,所以两个来源相同但标志不同的模式在相同输入上表现不同。让谓词保持安全的规则是:当模式只用于测试时不要带 global 标志,只有当意图是用 exec 在循环里遍历每次匹配时才加它。
sticky 标志如何锚定在 lastIndex
sticky 标志是 global 的姊妹,但锚定规则不同。global 模式从 lastIndex 向前搜索,sticky 模式只在恰好 lastIndex 处匹配,如果匹配必须开始得更晚就失败。探针手动设置 lastIndex,在同一两字符字符串上展示两种结果:
sticky /b/y at 0 on "ab" -> false
sticky /b/y at 1 on "ab" -> true
exec loop indices on "a a a" -> 0,2,4
当 lastIndex 为 0,sticky /b/y 对 ab 失败,因为索引 0 的字符是 a 而不是 b,而 sticky 匹配不能跳到索引 1 的 b。当 lastIndex 设为 1,同一模式匹配,因为索引 1 的字符正好是 b。模式与字符串从未改变;锚定规则把搜索变成了精确位置测试,而位置来自调用者设置的状态。
这种锚定正是 sticky 标志在做词法分析时有用的原因:词法分析器在每个 token 后有意前进 lastIndex,并期望下一个模式恰好在上一个 token 结束处匹配,所以间隙或重叠是语法错误而不是静默跳过。它也正是 sticky 模式在假定搜索的代码里脆弱的原因:一个把 lastIndex 设为陈旧偏移并期望向前搜索的调用者得到的失败指向偏移而不是字符串。
exec 循环那一行展示了使用 global 模式的良好方式。因为 exec 返回匹配对象并前进 lastIndex,一个运行到 exec 返回 null 为止的 while 循环会按顺序收集每次匹配,并以 lastIndex 回到 0 结束。索引 0, 2, 4 正是三个 a 字符的位置,循环终止是因为第四次 exec 什么也没找到并重置了状态。一个每次迭代调用一次 exec 却没有终止条件的循环,或与也调用 test 的代码共享该模式,会破坏这个契约并跳过或重复匹配。
String 方法如何重置或忽略该状态
String 方法处理 lastIndex 的方式与 test 和 exec 不同,这个差异正是同一 global 模式在一处安全而在另一处不安全的原因。探针把 lastIndex 设为 2,然后调用 match 与 replace:
before match: lastIndex=2
String.match with /g -> ["a","a","a"] lastIndex after=0
String.replace with /g -> "X X" lastIndex after=0
带 global 模式的 String.match 忽略进入的 lastIndex,把字符串里每次匹配返回为只含匹配子串的数组,之后把 lastIndex 重置为 0。带 global 模式的 String.replace 替换每次匹配,同样把 lastIndex 重置为 0,并返回重写后的字符串。两个方法都让模式处于干净状态,所以一个只通过 match 与 replace 使用的 global 模式不会泄漏重复使用的 test 所造成的翻转。
这种清理很方便,也很容易被误读。一个先对 global 模式运行 replace、再对同一模式调用 test 的调用者看到 test 从 0 开始,这没问题;但一个在 match 之前有意设置 lastIndex、希望匹配从那里开始的调用者会吃惊,因为 match 忽略了该偏移。test 与 exec 尊重的偏移不是 match 与 replace 尊重的偏移,所以一个在同一 global 模式上混用两个入口的代码路径,在同一个对象里有两套“从哪里开始”的概念。
matchAll 比 match 更严格,并在门口就强制 global 标志。探针在非 global 模式上调用它得到异常,然后从带捕获组的 global 模式收集匹配:
matchAll on non-global -> THREW TypeError
matchAll groups -> a=1@0 b=2@3 c=3@6
matchAll 对非 global 模式抛 TypeError,因为它返回一个遍历每次匹配的迭代器,而它需要 global 标志来定义这个遍历。对 global 模式它每次匹配产出一个匹配对象,带捕获组和每次匹配的索引,所以收集到的 a=1@0 b=2@3 c=3@6 展示了三次匹配及其两个组和起始位置。与在 exec 上跑 while 循环不同,matchAll 不把前进 lastIndex 的责任留给调用者,所以对一个可能也在别处使用的模式来说,它是遍历每次匹配更安全的方式。
unicode 标志在哪里改变模式计数的对象
unicode 标志把模式计数的单位从 UTF-16 代码单元变成 Unicode 码点,这同时改变 . 与任何针对字符的量词。探针用一个单字符模式和一个双字符量词对表情符号测试:
/^.$/.test("😀") -> false
/^.$/u.test("😀") -> true
/^.{2}$/u.test("😀") -> false (length=2)
不带 unicode 标志时,. 匹配一个 UTF-16 代码单元,而表情符号是两个单元的代理对,所以 /^.$/ 无法用单单元模式匹配两单元的字符串并返回 false。带 unicode 标志时,. 匹配一个码点,而表情符号是单个码点,所以 /^.$/u 返回 true。同一个表情符号,简单模式看成两个单元,unicode 模式看成一个字符,而一个建在 /^.$/ 上的校验器会拒绝一个读者数作一个字符的表情符号。
第三行展示了反向陷阱。在 unicode 标志下写成 {2} 的量词计两个码点而不是两个单元,所以 /^.{2}$/u 对单个码点的表情符号返回 false,尽管字符串的 .length 是 2。一个读 String.length(它永远是 UTF-16 单元数)并写量词去匹配它的人,在 unicode 标志下得到一个与字符串自身长度不一致的模式。这正是文本解码器记录的单位与字符之差的同一处,也是一个模式静默地计算了写它的代码所假定之外的东西的地方。
两行合起来说明,模式计数的单位是标志的属性而不是模式文本的属性。长度检查、.{n} 量词和 . 都在无标志时计 UTF-16 单元、有标志时计码点,所以一个把 String.length 检查与 u 模式混在一起的人是在比较两种不同单位。把标志与模式一起记录,正是让后来的读者复现同一计数的方式。
为什么重复使用的 global 模式会翻转答案
状态缺陷最危险的形式是跨调用重复使用一个 global 模式,因为它会在相同输入上产生交替的答案。探针对字符串 1 用一个 global /\d/g 三次调用 test:
reused /\d/g test three times on "1" -> true,false,true
第一次调用匹配到数字,把 lastIndex 设为 1,返回 true。第二次从 1 开始,字符串长度为 1,所以没有可搜索的剩余,返回 false 并把 lastIndex 重置为 0。第三次又从 0 开始,返回 true。模式是正确的,字符串从未改变,答案却在 true, false, true 之间交替,仅仅因为模式记住了它停在哪里。
空匹配模式需要一条额外规则,探针展示了应用它的结果:
empty-match walk -> [0:"a"] [1:""] [2:""] [3:""]
/a*/g 在每个位置都能匹配空字符串,如果 lastIndex 不越过零长度匹配,exec 会无限循环。探针检查 lastIndex 是否仍等于匹配索引,若是则递增它,这就是为什么遍历会前进 [0:"a"],然后在 1、2、3 处各得到一个空匹配,而不是在同一位移重复。没有这个手动递增,空匹配模式上的 exec 循环永不终止,因为每次调用都在同一 lastIndex 返回零长度匹配而位置从不移动。
重复模式缺陷的修法是把状态排除在共享对象之外。一个只在单个 exec 的 while 循环里使用的 global 模式没问题,因为那个循环拥有 lastIndex。一个被当作可重复使用谓词的 global 模式是错的,修法要么去掉 global 标志,要么在每次 test 前把 lastIndex 重置为 0。source 与 flags 属性是记录一个模式曾经是什么的廉价方式,因为它们是经得住一次记录往返的稳定可读字符串。
记录一次匹配读数
一份匹配读数支撑一个很窄的断言:这个运行时、这个模式文本、这些标志,给定这个输入,在这次位置产生了这次匹配。六个字段让它可复核,且没有一个依赖共享对象的私有状态:
- 模式的
source与它的flags,记录为对象暴露的两个字符串,因为标志集既决定模式计数的单位,也决定它是否携带状态。 - 按提供的输入字符串,理想情况下带它的码点而不是渲染后的字形,因为两个输入可以渲染成一样,而一个模式按其标志计单元或码点。
- 用于运行模式的入口,
test、exec、match、replace或matchAll,因为每一个对lastIndex的处理都不同。 - 当模式为 global 或 sticky 时,调用前后的
lastIndex,因为那是状态所在的数字,而一个没有它的已保存答案无法复现。 - 匹配索引和捕获的组,而不只是匹配的子串,因为同一个文本在不同位置的两个匹配是不同的观察。
- 一次零长度匹配是否需要手动递增
lastIndex,因为空匹配模式需要这一步才能终止,而一个省略它的记录描述的是一个本会挂起的循环。
这样的读数不能显示的是服务器或第二个运行时会如何解释同一模式。一个模式由一个引擎用一套标志语义运行,而一个代理、一个 WAF 或一个服务器框架可能用自己的规则解析同一字符串。对于模式经常运行的字节层,TextDecoder 与 TextEncoder 展示了为什么同样字节在一个标签下解码成一个字符串、在另一个标签下解码成乱码字符串,ArrayBuffer 与 TypedArray 展示了为什么同样八个字节在每个视图下承载不同的元素数。任何一类数据上的模式都继承了这些文章测得的单位差。
两个习惯让读数随时间保持诚实。第一,把标志和入口与答案一起记录,因为来自 global test 的 true 和来自非 global test 的 true 对下一次调用的含义不同。第二,遍历匹配时优先用 matchAll 或一个拥有状态的 exec 循环,可重复使用的谓词优先用非 global 模式,这样翻转答案的状态永远不会到达一个假定纯函数的第二个调用者。
公开来源
- MDN:RegExp
- MDN:RegExp.prototype.test
- MDN:RegExp.prototype.exec
- MDN:String.prototype.matchAll
- MDN:RegExp sticky 标志
- BotBrowser:自动化一致性参考
结论
把标志集当作数据契约的一部分:global 或 sticky 模式携带一个 lastIndex,test 与 exec 会推进它,所以对同一字符串三次调用返回 true、true、然后 false,而非 global 模式让 lastIndex 保持 0 并每次返回相同答案。要知道 sticky 标志锚定在 lastIndex,match 与 replace 把 lastIndex 重置为 0,matchAll 要求 global 标志,以及 unicode 标志让 . 与量词在默认计 UTF-16 单元处计码点。让 global 标志离开可重复使用的谓词,用 matchAll 或一个拥有状态的 exec 循环遍历匹配,并记录来源、标志、入口以及前后的 lastIndex,这样一份已保存的答案能被复现,而不是被误读成纯结果。
延伸阅读
关于模式经常运行的文本层,阅读 TextDecoder 与 TextEncoder;关于模式可能扫描的二进制视图,阅读 ArrayBuffer 与 TypedArray;关于序列化器给模式所匹配对象的键的顺序,阅读 对象键顺序。
本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界