在 JavaScript 中,很多开发者第一次遇到这个问题时都会感觉很奇怪:
javascript
const reg = /a/g
console.log(reg.test('a')) // true
console.log(reg.test('a')) // false
console.log(reg.test('a')) // true
console.log(reg.test('a')) // false
明明字符串始终是 "a",为什么结果会一会 true,一会 false?
这其实和 JavaScript 正则对象内部的一个属性有关:
lastIndex
很多线上 Bug、Vue/React 表单校验异常、Node.js 日志过滤错误,本质上都和它有关。
本文会从:
- 概念
- 原理
- 底层执行机制
g/y标志影响test()/exec()行为差异- 常见坑
- 实战解决方案
- 源码级思维
多个层次彻底讲透这个问题。
一、什么是 lastIndex?
每一个 JavaScript 正则对象都带有一个隐藏状态:
javascript
RegExp.lastIndex
它表示:
下一次正则匹配开始的位置
例如:
javascript
const reg = /a/g
console.log(reg.lastIndex)
输出:
0
表示:
- 下一次匹配从索引
0开始。
二、为什么只有部分正则会出现这个问题?
注意:
不是所有正则都会缓存 lastIndex。
只有带:
g
或者:
y
标志的正则才会。
例如:
bash
/a/g
/a/y
而:
bash
/a/
不会记录状态。
三、复现最经典的 test() 坑
示例
javascript
const reg = /a/g
console.log(reg.test('a'))
console.log(reg.lastIndex)
console.log(reg.test('a'))
console.log(reg.lastIndex)
输出:
arduino
true
1
false
0
四、到底发生了什么?
我们一步一步拆解。
第一次执行
arduino
reg.test('a')
等价于:
ini
从 lastIndex = 0 开始匹配
字符串:
css
a
匹配成功:
css
索引 0 找到 a
于是:
ini
lastIndex = 匹配结束位置
变成:
1
所以:
arduino
console.log(reg.lastIndex)
输出:
1
第二次执行
再次执行:
arduino
reg.test('a')
这次:
ini
从 lastIndex = 1 开始匹配
但字符串:
css
a
长度只有 1。
于是:
已经越界
匹配失败。
根据规范:
匹配失败时
lastIndex 重置为 0
因此:
arduino
false
0
五、底层执行流程(核心)
真正的执行逻辑可以理解为:
arduino
function fakeTest(reg, str) {
// 1. 从 lastIndex 开始匹配
const start = reg.lastIndex
// 2. 尝试匹配
const matched = match(str, start)
// 3. 成功
if (matched) {
reg.lastIndex = matched.end
return true
}
// 4. 失败
reg.lastIndex = 0
return false
}
这就是问题根源。
六、为什么 JS 要这么设计?
这是为了支持:
"连续匹配"
例如:
ini
const reg = /\d+/g
const str = '123 abc 456 def 789'
如果没有 lastIndex:
每次都会从头扫描。
效率会很低。
有 lastIndex 后
sql
let result
while ((result = reg.exec(str))) {
console.log(result[0])
}
输出:
123
456
789
因为:
每次都会从上一次结束的位置继续。
七、exec() 才是 lastIndex 的真正设计目标
很多人不知道:
scss
test()
其实只是:
scss
exec() 的简化版
真正依赖 lastIndex 的核心 API 是:
scss
exec()
例如:
ini
const reg = /\d+/g
const str = '1 22 333'
let match
while ((match = reg.exec(str))) {
console.log(match[0], reg.lastIndex)
}
输出:
1 1
22 4
333 8
八、为什么 test() 最容易踩坑?
因为:
很多人会误以为:
scss
test() 是纯函数
即:
输入一样
输出一定一样
但实际上:
scss
带 g 的 test() 是有状态的
状态就是:
lastIndex
九、最危险的真实业务场景
1. 表单校验
错误写法
javascript
const reg = /^\d+$/g
function validate(value) {
return reg.test(value)
}
结果:
scss
validate('123') // true
validate('123') // false
validate('123') // true
线上非常常见。
为什么?
因为:
ruby
^\d+$
虽然是完整匹配。
但:
g
仍然会修改:
lastIndex
正确写法
方案 1:不要加 g
javascript
const reg = /^\d+$/
这是最推荐方案。
方案 2:手动重置
ini
reg.lastIndex = 0
例如:
javascript
function validate(value) {
reg.lastIndex = 0
return reg.test(value)
}
方案 3:每次创建新正则
javascript
function validate(value) {
return /^\d+$/.test(value)
}
但频繁创建对象有开销。
十、g 和 y 的区别
很多人只知道:
g
但:
y
其实更特殊。
g:全局匹配
特点:
从 lastIndex 开始
向后继续搜索
即使当前位置不匹配:
也会继续往后找。
示例
javascript
const reg = /a/g
reg.lastIndex = 1
console.log(reg.test('ba'))
输出:
arduino
true
因为:
会继续向后找到:
css
索引 1 的 a
y:粘连匹配(Sticky)
特点:
必须严格从 lastIndex 开始匹配
不能跳跃。
示例
javascript
const reg = /a/y
reg.lastIndex = 1
console.log(reg.test('ba'))
输出:
arduino
true
因为:
索引 1 正好是:
css
a
但:
arduino
reg.lastIndex = 0
console.log(reg.test('ba'))
输出:
arduino
false
因为:
索引 0 是:
css
b
y 不会继续向后搜索。
十一、哪些 API 会受 lastIndex 影响?
会影响的
| API | 是否受影响 |
|---|---|
test() |
✅ |
exec() |
✅ |
不会影响的(内部会重置)
| API | 是否安全 |
|---|---|
match() |
相对安全 |
matchAll() |
安全 |
replace() |
安全 |
split() |
安全 |
但:
内部仍然可能使用 lastIndex。
只是 JS 引擎帮你处理了。
十二、最容易误解的一点
很多人以为:
scss
只有 exec() 才会改 lastIndex
实际上:
scss
test() 同样会
这是因为:
ECMAScript 规范中:
javascript
RegExp.prototype.test
底层其实调用:
RegExpExec
也就是:
scss
exec()
十三、源码级理解(V8 思维)
JavaScript 引擎内部:
正则对象不是:
无状态对象
而是:
带状态机的对象
其中:
lastIndex
就是状态机游标。
类似于文件读取指针
例如:
arduino
文件读取:
cursor -> 当前读取位置
正则:
rust
lastIndex -> 当前扫描位置
本质一样。
十四、为什么很多框架源码会避免复用正则?
例如:
- Vue
- React
- Babel
- ESLint
很多源码里:
javascript
const reg = new RegExp(...)
会频繁创建。
原因之一:
就是避免:
lastIndex 状态污染
十五、面试高频题
问题
下面输出什么?
javascript
const reg = /foo/g
console.log(reg.test('foo'))
console.log(reg.test('foo'))
答案:
arduino
true
false
原因:
ini
第一次成功后:
lastIndex = 3
第二次从索引 3 开始匹配
失败
重置为 0
十六、如何彻底避免这个坑?
最佳实践(非常重要)
1. 校验类正则不要加 g
错误:
bash
/^\w+$/g
正确:
ruby
/^\w+$/
2. 循环提取才使用 g
例如:
python
while (reg.exec(str))
3. 复用正则前重置
ini
reg.lastIndex = 0
4. 不要把带 g 的正则当纯函数
错误思维:
dart
相同输入 => 相同输出
实际上:
dart
状态不同 => 输出不同
十七、一个超级经典的线上 Bug
javascript
const hasNumber = /\d/g
export function check(str) {
return hasNumber.test(str)
}
某些用户:
偶现校验失败
原因:
多个请求共享:
javascript
同一个 RegExp 对象
导致:
lastIndex 被污染
修复方案
javascript
export function check(str) {
return /\d/.test(str)
}
或者:
ini
hasNumber.lastIndex = 0
十八、总结(核心记忆)
一句话记忆
bash
带 g/y 的正则是"有状态"的
状态就是 lastIndex
核心规律
| 情况 | 是否修改 lastIndex |
|---|---|
| 普通正则 | ❌ |
| 带 g | ✅ |
| 带 y | ✅ |
test() 行为本质
scss
test() ≈ exec() != null
因此:
同样会修改 lastIndex
最重要结论
不要给"校验型正则"加 g
这是所有问题根源。
推荐阅读方式(进阶)
如果你继续深入研究:
建议下一步学习:
- RegExp 引擎回溯机制
- DFA/NFA
- 贪婪匹配
- 惰性匹配
- 零宽断言
- sticky 模式
- Unicode 正则
- V8 Irregexp 引擎
当你理解这些后:
你会真正掌握 JavaScript 正则底层原理。
本文部分内容借助 AI 辅助生成,并由作者整理审核。