断言
00:00
正则表达式先行与后行断言
概述
正则表达式中的断言(Assertion)是一种零宽匹配机制,用于判断某个位置前后是否满足特定模式,但不会消耗字符。断言分为先行断言(Lookahead)和后行断言(Lookbehind),各有正向和负向两种形式。先行断言从 ES3 就已支持,后行断言则是 ES2018 引入的特性。断言在数据提取、格式验证和文本处理等场景中非常实用。
基础概念
先行断言(Lookahead):从当前位置向右查看,判断后面的字符是否匹配指定模式。正向先行 (?=pattern) 要求后面必须匹配,负向先行 (?!pattern) 要求后面不能匹配。
后行断言(Lookbehind):从当前位置向左查看,判断前面的字符是否匹配指定模式。正向后行 (?<=pattern) 要求前面必须匹配,负向后行 (?<!pattern) 要求前面不能匹配。
零宽特性:断言匹配的是位置而非字符,匹配结果不包含断言中的内容。这意味着断言不会消耗输入字符串中的字符,同一个位置可以被多次断言。
四种断言类型:
| 类型 | 语法 | 说明 |
|---|---|---|
| 正向先行断言 | (?=pattern) | 后面必须匹配 pattern |
| 负向先行断言 | (?!pattern) | 后面不能匹配 pattern |
| 正向后行断言 | (?<=pattern) | 前面必须匹配 pattern |
| 负向后行断言 | (?<!pattern) | 前面不能匹配 pattern |
快速上手
先行断言基础
// 正向先行断言:匹配后面跟着"元"的数字
const priceRegex = /\d+(?=元)/g;
const result1 = '苹果5元,香蕉3元'.match(priceRegex);
console.log(result1); // ['5', '3']
// 注意:匹配结果只有数字,"元"字不被包含
// 负向先行断言:匹配后面不跟"px"的数字
const noPxRegex = /\d+(?!px)/g;
const result2 = '12px 34em 56'.match(noPxRegex);
console.log(result2); // ['1', '34', '56']
// "12px" 中的 1 后面是 2 不是 px,所以 1 匹配
// 更精确的写法需要加边界
后行断言基础
// 正向后行断言:匹配"价格:"后面的数字
const afterLabelRegex = /(?<=价格:)\d+/g;
const result3 = '苹果价格:5,香蕉价格:3'.match(afterLabelRegex);
console.log(result3); // ['5', '3']
// 负向后行断言:匹配前面不是"不"的"喜欢"
const positiveVerbRegex = /(?<!不)喜欢/g;
const result4 = '我喜欢你,他不喜欢你'.match(positiveVerbRegex);
console.log(result4); // ['喜欢']
// 只匹配第一个"喜欢",因为第二个前面有"不"
详细用法
密码强度验证
// 使用正向先行断言实现多条件密码验证
// 要求:至少8位,包含小写字母、大写字母和数字
const strongPassword = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/;
console.log(strongPassword.test('Abc12345')); // true
console.log(strongPassword.test('abc12345')); // false(缺大写)
console.log(strongPassword.test('ABC12345')); // false(缺小写)
console.log(strongPassword.test('Abc1234')); // false(不足8位)
// 解析:
// ^ 从开头匹配
// (?=.*[a-z]) 先行断言:后面必须有小写字母
// (?=.*[A-Z]) 先行断言:后面必须有大写字母
// (?=.*\d) 先行断言:后面必须有数字
// .{8,} 实际匹配:至少8个任意字符
// $ 到结尾
// 更严格的密码:还要求包含特殊字符
const veryStrongPassword = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{12,}$/;
文件名过滤
// 负向先行断言:排除 .log 文件
const notLogRegex = /^(?!.*\.log$).+$/;
console.log(notLogRegex.test('app.log')); // false
console.log(notLogRegex.test('app.txt')); // true
console.log(notLogRegex.test('data.json')); // true
// 排除多种后缀
const excludeRegex = /^(?!.*\.(log|tmp|bak)$).+$/;
console.log(excludeRegex.test('file.tmp')); // false
console.log(excludeRegex.test('file.js')); // true
// 只匹配图片文件
const imageRegex = /(?=\.(jpg|png|gif|webp)$).+/i;
// 更简洁的写法:/\.(jpg|png|gif|webp)$/i
HTML 标签内容提取
// 组合使用先行和后行断言提取标签内文本
const innerTextRegex = /(?<=<\w+>)[^<]+(?=<\/\w+>)/g;
const html = '<div>Hello</div><span>World</span><p>Test</p>';
const texts = html.match(innerTextRegex);
console.log(texts); // ['Hello', 'World', 'Test']
// 提取 HTML 属性值
const attrValueRegex = /(?<=class=")[^"]+(?=")/g;
const htmlWithClass = '<div class="container"><span class="highlight">text</span></div>';
const classes = htmlWithClass.match(attrValueRegex);
console.log(classes); // ['container', 'highlight']
CSV 解析
// 匹配不在引号内的逗号(用于 CSV 分割)
// 这是一个经典的先行断言应用
const outsideQuotesRegex = /,(?=(?:[^"]*"[^"]*")*[^"]*$)/g;
const csvLine = 'name,"Smith, John",age,"30",city';
const fields = csvLine.split(outsideQuotesRegex);
console.log(fields);
// ['name', '"Smith, John"', 'age', '"30"', 'city']
// 引号内的逗号不会被分割
// 解析原理:
// , 匹配逗号
// (?= 先行断言开始
// (?:[^"]*"[^"]*")* 零或多对引号对
// [^"]*$ 后面到行尾没有引号
// ) 断言结束
常见场景
货币格式处理
// 提取金额数字
const extractAmount = /(?<=¥|¥|\$)\s*[\d,]+\.?\d*/g;
const text = '商品A ¥1,299.00,商品B $99.50,商品C ¥3,500';
const amounts = text.match(extractAmount);
console.log(amounts); // ['1,299.00', '99.50', '3,500']
// 千分位格式化(使用先行断言)
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
console.log(formatNumber(1234567)); // '1,234,567'
console.log(formatNumber(1234567.89)); // '1,234,567.89'
// 解析:
// \B 非单词边界(不在开头)
// (?=(\d{3})+) 后面是3的倍数个数字
// (?!\d) 后面不再有数字
文本清洗与替换
// 移除 HTML 标签但保留内容
function stripTags(html) {
return html.replace(/(?<=<[^>]+>)|(?=<[^>]+>)/g, '');
// 更简单的写法
return html.replace(/<[^>]+>/g, '');
}
// 移除行首行尾空格(每行独立处理)
function trimLines(text) {
return text.replace(/(?<=^|\n)[ \t]+|[ \t]+(?=\n|$)/g, '');
}
// 在数字和字母之间插入空格
function separateNumAndLetter(str) {
return str.replace(/(?<=\d)(?=[a-zA-Z])/g, ' ').replace(/(?<=[a-zA-Z])(?=\d)/g, ' ');
}
console.log(separateNumAndLetter('abc123def456')); // 'abc 123 def 456'
URL 参数提取
// 提取 URL 中的查询参数值
function getQueryParam(url, param) {
const regex = new RegExp(`(?<=${param}=)[^&]+`);
const match = url.match(regex);
return match ? match[0] : null;
}
const url = 'https://example.com/page?id=123&name=test&lang=zh';
console.log(getQueryParam(url, 'id')); // '123'
console.log(getQueryParam(url, 'name')); // 'test'
console.log(getQueryParam(url, 'page')); // null
// 提取所有参数键值对
function getAllParams(url) {
const paramRegex = /(?<=\?|&)([^=]+)=([^&]+)/g;
const params = {};
let match;
while ((match = paramRegex.exec(url)) !== null) {
params[match[1]] = match[2];
}
return params;
}
console.log(getAllParams(url)); // { id: '123', name: 'test', lang: 'zh' }
注意事项
- 浏览器兼容性:后行断言(Lookbehind)是 ES2018 特性,在 IE 和旧版浏览器中不支持。如果需要兼容旧环境,应使用替代方案。
- 性能考量:嵌套或复杂的断言可能导致回溯爆炸,特别是在处理长字符串时。对于简单的匹配需求,优先考虑非断言方案。
- 后行断言长度限制:在部分旧版浏览器中,后行断言中的模式必须是固定长度的(不能使用量词如
*或+)。现代浏览器已支持可变长度的后行断言。 - 零宽理解:断言是零宽匹配,不会消耗字符。这意味着在同一个位置可以使用多个断言,每个断言独立判断。
- 转义特殊字符:断言中的模式如果包含特殊字符(如
?、*),需要正确转义,否则可能导致正则表达式语法错误。
进阶用法
条件替换
// 只替换不在引号内的关键字
function replaceOutsideQuotes(text, keyword, replacement) {
// 先行断言确保后面有偶数个引号
const regex = new RegExp(keyword + '(?=(?:[^"]*"[^"]*")*[^"]*$)', 'g');
return text.replace(regex, replacement);
}
const code = 'const name = "old value"; // old comment';
const result = replaceOutsideQuotes(code, 'old', 'new');
console.log(result); // 'const name = "old value"; // new comment'
// 引号内的 "old" 未被替换,注释中的被替换了
平衡组模拟
// JavaScript 不支持 .NET 风格的平衡组
// 但可以用断言模拟简单的嵌套匹配
// 匹配最外层的括号内容
function matchOuterParens(str) {
const regex = /\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\)/g;
return str.match(regex);
}
const expr = '(a + (b * c)) + (d / e)';
console.log(matchOuterParens(expr));
// ['(a + (b * c))', '(d / e)']
自定义词法分析
// 使用断言实现简单的词法分析器
function tokenize(code) {
const tokens = [];
const regex = /(?<=^|\s)(\d+)|(?<=^|\s)([a-zA-Z_]\w*)|("(?:[^"\\]|\\.)*")|([+\-*/=])/g;
let match;
while ((match = regex.exec(code)) !== null) {
if (match[1]) tokens.push({ type: 'NUMBER', value: match[1] });
else if (match[2]) tokens.push({ type: 'IDENTIFIER', value: match[2] });
else if (match[3]) tokens.push({ type: 'STRING', value: match[3] });
else if (match[4]) tokens.push({ type: 'OPERATOR', value: match[4] });
}
return tokens;
}
const code = 'let x = 42 + "hello"';
console.log(tokenize(code));
// [
// { type: 'IDENTIFIER', value: 'let' },
// { type: 'IDENTIFIER', value: 'x' },
// { type: 'OPERATOR', value: '=' },
// { type: 'NUMBER', value: '42' },
// { type: 'OPERATOR', value: '+' },
// { type: 'STRING', value: '"hello"' }
// ]