JavaScript正则反向引用:成对标签匹配与嵌套解析 处理 BB-tag 这类成对标记用 JavaScript 正则表达式里的选择alternation加反向引用backreference是最省事的组合——我说的不是那种replace([b], b)的土办法而是能保证开标签和闭标签名字真正一致的真配对。论坛正文、富文本编辑器导出的片段、老系统里存下来的留言内容只要有用户手写标记的地方就一定会出现[b]加粗[/i]这种错配也一定会出现嵌套三层以上的引用。这类脏数据你没法要求用户去改只能在解析侧兜住。这篇内容写给已经会用正则、但一碰到成对匹配就发怵的前端和后端同学。不要求你背过正则语法表但至少要知道什么是捕获组。我会把[b]...[/b]这种最小例子一路推到[url...]带属性、再到嵌套三层的真实场景把每一步为什么这么写讲清楚最后给出一份能直接搬进项目的实现和一堆我踩过的坑。核心就一件事让正则自己记住开标签叫什么然后在闭标签那里回头对一遍。1. 从一次错位的加粗说起朴素替换为什么撑不住1.1 错位的[b]和[/i]是怎么产生的我见过最典型的一坨脏数据长这样[b]重点[/i]普通[/b]。用户想加粗重点手滑打成了[/i]然后又补了个[/b]。如果你用的是逐条替换的思路先跑一遍replace(/\[b\]/g, b)再跑一遍replace(/\[\/b\]/g, /b)结果会变成b重点[/i]普通/b页面上直接露出一截原始标记文本。这种情况还算好的至少只是难看。更麻烦的是[b]第一段[/b][b]第二段[/b]这种正常输入在逐条替换下其实也能过。真正要命的是替换顺序被污染的场景假设你为了处理[i]先做了一轮替换中间产物里恰好包含了[b]的片段第二轮替换就会把不该动的地方也动了。这类问题在字符串处理里有个通用名字叫二次替换污染本质原因是替换操作之间没有共享上下文谁也不知道自己处理的这个标签到底该跟谁配对。还有一种情况是大小写混写。用户写[B]内容[/B]或者[b]内容[/B]。如果你的正则没加i标志第一条能过、第二条过不了结果就是页面上半截正常半截原始文本排查起来特别费劲因为肉眼看上去这些标记长得都差不多。1.2 三种常见做法的失效边界对照我把日常见到的做法列一下方便你判断自己项目里用的是哪一种、风险在哪做法典型写法能处理失效场景逐标签全局替换replace(/\[b\]/g,b)结构完全正确的单层标签错配、嵌套、标签名大小写不一致贪婪匹配/\[(\w)\](.*)\[\/\1\]/单行、单对标签同一行多对标签时一口气吃到最后一个闭标签惰性匹配/\[(\w)\]([\s\S]*?)\[\/\1\]/同一行多对平级标签嵌套同类标签、交叉错配选择 反向引用 负向预查/\[(b|i|u)\]((?:(?!\[\/?\1\])[\s\S])*)\[\/\1\]/单层任意标签、交叉标记任意深度的同名嵌套看清楚这张表你会发现问题的分水岭在于正则需不需要跨过一段内容去寻找配对。一旦需要跨贪婪就成了灾难惰性又会被嵌套骗过去最后必须引入中间不能出现同名标签这条约束也就是负向预查。而这条约束之所以能成立前提是正则引擎支持反向引用——预查里的\1和闭标签处的\1指的是同一个捕获组。1.3 正则的两把钥匙选择管哪些标签反向引用管是不是同一个很多人把这两个特性混在一起理解其实它们解决的是完全不同层面的问题。选择alternation负责划定白名单我只认b、i、u、s、url、quote这几个标签其他的一律当普通文本。写成(b|i|u|s)就是一个捕获组包着若干个分支。这里有个细节容易被忽略这对方括号里的分支是或的关系引擎会从左到右依次尝试第一个能走通整个表达式的分支胜出而不是最长的那个。反向引用backreference负责一致性校验开标签捕获到什么字符串闭标签那里就必须原样再来一遍。写成\1表示引用第 1 个捕获组匹配到的内容。它的语义不是再匹配一次(b|i|u)这个模式而是匹配和刚才那次捕获一模一样的文本。这个区别很关键前者会让[b]...[/i]通过后者不会。两把钥匙合起来表达式骨架就是/\[(b|i|u)\]([\s\S]*?)\[\/\1\]/g拆开读\[匹配左方括号(b|i|u)捕获标签名\]匹配右方括号[\s\S]*?惰性吞掉中间内容\[\/匹配[/\1要求这里必须和开标签同名\]收尾。[\s\S]这个写法是为了跨行——点号在默认模式下不匹配换行符[\s\S]是空白或非空白等价于任意字符这是社区里流传最广的跨行通配写法。2. 反向引用在引擎里到底做了什么2.1 捕获组编号的计数规则反向引用靠编号定位编号又靠左括号的出现顺序决定。规则很简单但很容易数错从左往右每遇到一个未被转义的左括号编号加一从 1 开始。注意是左括号的顺序不是右括号也不是嵌套层级。举个会让人翻车的例子/(\[(b|i|u)\])/这里有三个左括号所以有三个组。\1是整个[b]\2是里面的b。如果你脑子里的模型是最内层是第 1 组那\1就会指错对象整个表达式虽然不报错但语义完全变了。更隐蔽的是括号被转义或者被放在字符类里的情况。\(不算捕获组[(]里的左括号也不算因为字符类里的内容按字面量处理。还有(?:...)这种非捕获组虽然用了括号但不占编号编号会跳过去给后面的组。我建议写复杂表达式时先把所有组列出来标注一下这个习惯能省掉大量调试时间。2.2 匹配阶段的回填过程理解反向引用的执行时机能帮你解释很多看起来应该匹配却没匹配上的现象。整个过程分两步走引擎先按顺序执行到捕获组把这部分实际匹配到的文本存进一个槽位可以理解成一张表第 1 组存什么、第 2 组存什么然后继续往后走走到\1的位置时从槽位里把内容取出来当成一个普通字符串去和当前位置的文本逐字符比较。这就意味着\1匹配的是具体的字符串不是模式。一旦捕获组捕获的是b\1就只认b不会因为后面出现了i就重新选择。反过来如果某条路径下捕获组根本没参与匹配比如它在可选分支里被跳过了那么\1会匹配空字符串这往往不是你想要的效果属于典型的静默错误。还有一个常被问到的点捕获组在量词内部时槽位存的是哪一次的结果。规则是最后一次成功的捕获结果覆盖之前的。比如((a))去匹配aaa第 1 组是aaa第 2 组是最后那个a。这个行为在replace的回调里特别容易搞混需要留意。2.3 字符类里的\1是个陷阱这条我要单独拎出来因为我至少见过三次有人在这上面卡了半天。把反向引用写进方括号里比如[\1]它的含义不是匹配第 1 组捕获到的内容而是匹配八进制转义\1对应的那个字符也就是码位 1 的控制字符。同理[\2]是码位 2。为什么会这样因为字符类的解析规则和普通模式不一样字符类内部只认单字符转义和字符范围多字符结构包括反向引用、断言、量词在这里要么非法要么被降级解释。所以在字符类里做反向校验这条路是走不通的必须改用负向预查// 错误写法[\1] 不等于和捕获组相同的字符 /\[(b|i)\]([^\1]*)\[\/\1\]/ // 语义完全跑偏 // 正确写法用负向预查排除下一个位置开始就是闭标签 /\[(b|i)\]((?:(?!\[\/\1\])[\s\S])*)\[\/\1\]/g这个(?:(?!\[\/\1\])[\s\S])*的读法是反复执行向前看一位确认这里不是[/加上同名标签然后吃掉一个任意字符。它等价于一直吃到遇见同名闭标签为止而且允许跨行、允许中途出现其他标签。2.4 命名捕获组给反向引用一个名字ES2018 之后有了命名捕获组写成(?tagb|i|u)反向引用写成\ktag。功能上和编号完全等价但可读性天差地别尤其是在有多层嵌套括号的表达式里。const re /\[(?tagb|i|u)\](?body[\s\S]*?)\[\/\ktag\]/g; const text [b]粗体[/b]和[i]斜体[/i]; for (const m of text.matchAll(re)) { console.log(m.groups.tag, m.groups.body); } // b 粗体 // i 斜体好处有三个。一是改表达式时插入新的括号不会导致编号错位二是m.groups给了你一个具名的结果对象不用再数m[1]、m[2]分别是谁三是负向预查里也可以直接用\ktag写出来更像自然语言。代价是有兼容性门槛如果你的运行环境非常老旧比如需要兼容某些嵌入式 WebView得先确认引擎版本或者退回编号方案。还有一条经验同一个表达式里不要混用编号引用和命名引用去指同一组虽然规范允许但读代码的人会崩溃。3. 用选择分支搭一张标签白名单3.1 分支顺序与回溯的微妙关系(b|i|u)这种分支长度一样的情况没什么可纠结的但如果标签名有前缀关系顺序就值得研究了。假设你的白名单里有b、br、url、urls写成(b|br|url|urls)会怎样引擎的处理方式是先试b发现后面接不上就回头试br。因为右方括号\]是个强制约束所以在\[(b|br)\]这个表达式里输入[br]最终能匹配成功——引擎先试b发现下一个字符是r而不是]回溯改试br成功。也就是说在有强制后继约束的前提下分支顺序不影响正确性只影响性能。真正会出错的是后继约束宽松甚至可空的场景。比如\[(b|br)\]?遇到[br]先试b后面接\]?\]?可以匹配空但整体是匹配[b然后可选]接下来的r]就没被处理结果只吃掉了[b。这种错误排查起来非常折磨因为正则不报错只是默默少匹配了一段。所以我的做法是分支按长度从长到短排列(urls|url|br|b)既避免了可空后继的陷阱也减少了回溯次数。另外提一句i标志。加了i之后(b|i)会自动匹配大写不需要在分支里写(b|B|i|I)。但如果你的标签名允许中文或者带数字[h1]、[标题]那最好把分支写全别指望标志位能兜住。3.2 带属性的标签[url...]和[color#fff]真实内容里的标签大多带参数。[urlhttp://example.com]链接文字[/url]是最常见的[color#ff0000]红字[/color]、[size14]字号[/size]也不少见。这类标签的处理要点是属性部分要吃掉但不能影响反向引用。const re /\[(?tagurl|img|color|size|quote)\b(?:[^\]]*)?\](?body[\s\S]*?)\[\/\ktag\]/g;拆一下\b是词边界保证url后面要么跟、要么跟]不会误吃urls(?:[^\]]*)?是非捕获的可选组匹配开始到方括号之前的所有内容[^\]]*这个写法比.*?更安全因为它天然不会跨越右方括号\]收尾。\b这个细节值得单独说。如果你不加它写(url|img)遇到[urls]这种非白名单标签正则会先匹配上url然后要求或]但实际是s回溯换img分支也失败整体失败——结果是对的。但如果分支里有前缀相同的项比如白名单同时有url和urls那就可能选错。加上\b相当于给标签名画了个明确的右边界语义更准确读起来也清楚。对于[quote某某]里的中文属性值[^\]]*能正常处理因为它是按字符处理的不区分中英文。3.3 大小写、空白与自闭合标签的容错用户手写的标记在格式上几乎没有一致性可言。我统计过一批真实数据[ b ]、[b ]、[/ b]、[B]、[b]混着来的比例相当高。容错的代价是表达式变长但这一步不做后面的所有逻辑都是在处理噪音。const re /\[\s*(?tagb|i|u|s|url|img|color|size|quote)\b(?:[^\]]*)?\s*\]\s*([\s\S]*?)\s*\[\s*\/\s*\ktag\s*\]/gi;注意这里有两个\s*的位置。开标签前的\[\s*和标签名后的\s*相对安全闭标签里的\[\s*\/\s*也安全。但中间那个([\s\S]*?)\s*里的\s*有副作用它会把闭标签前面的空格也吃进去导致捕获到的 body 内容尾部空白被吞掉。如果你的场景对空白敏感比如代码块内容这个\s*必须去掉改成对结果单独做 trim。关于自闭合标签[img]...[/img]是少数会被用户省略闭标签的标签之一很多人习惯直接写[img]地址就完了。这种就没法用配对思路处理只能做单独的短路径判断先扫一遍有没有[/img]没有的话按自闭合处理。这属于业务层的规约别硬塞进配对正则里。多个标签的组合还要考虑g标志带i的情况加了i之后\ktag的比较其实是区分大小写的——这一点很多人不知道。[B]加粗[/b]在i标志下开标签捕获的是B\ktag要求匹配B而实际文本是b虽然整体有i标志但反向引用的比较在某些引擎实现里仍然有细微差异。稳妥做法是在捕获后统一转小写再比较或者干脆在预处理阶段把所有标签名规范化成小写。我在一个项目里就是先跑一遍text.replace(/\[\/?\s*([a-z])/gi, m m.toLowerCase())做预归一后面所有表达式都不用再操心大小写。4. 嵌套标签正则的能力边界与三种绕法4.1 为什么单条正则搞不定任意深度嵌套这是正则理论里的经典结论正则表达式严格意义上的正则语言只能识别有限状态而任意深度的括号配对需要计数能力属于上下文无关语言范畴。通俗点说正则没有栈这个数据结构它记不住我现在在第几层。有人会说那我用递归引用来表达任意深度行不行确实有些引擎支持(?R)这种递归结构但 JavaScript 的引擎不支持这条路走不通。所以在 JS 里单条正则最多只能处理固定层数的嵌套或者用多轮迭代逼近。实际影响是[quote]外层[b]内层[/b]外层[/quote]这种结构用惰性匹配的/\[(quote)\]([\s\S]*?)\[\/\1\]/会匹配失败——因为惰性会先试最短的[/quote]而这中间还隔着[/b]走到[/b]那里不满足\ktag的要求引擎只能往外扩最后正确匹配到了整个外层。等等这个例子里其实能匹配成功。真正的失败场景是同名嵌套[b]外层[b]内层[/b]外层[/b]。对输入[b]外层[b]内层[/b]外层[/b]跑/\[(b)\]((?:(?!\[\/\1\])[\s\S])*)\[\/\1\]/g负向预查会在遇到第一个[/b]时就停下结果是匹配到[b]外层[b]内层[/b]——开标签是外层的[b]闭标签却是内层的[/b]配错了层。这就是正则的天花板。4.2 有限深度展开法如果你的业务里嵌套深度有明确上限比如超过 3 层就当作非法输入可以用展开法。思路是把允许中间再出现一层同名标签这条规则显式写出来用多个表达式或者一个表达式里的多层结构表达。// 支持一层同名嵌套内层不允许再有同名标签 const inner String.raw(?:(?!\[\/?b\])[\s\S])*; const re new RegExp(String.raw\[b\](${inner}(?:\[b\]${inner}\[\/b\]${inner})*)\[\/b\], g);这个表达式读起来是[b]之后允许出现零到多段普通内容 一层内嵌[b]...[/b] 普通内容最后以[/b]收尾。每多支持一层就多套一层这个结构。缺点是表达式长度指数级增长三层之后就基本没法维护了而且每次都要算好内层不能出现同名标签写法非常啰嗦。我一般只在深度上限是 1 的场景用它比如某些系统规定引用只能嵌套一层。超过两层的需求老老实实上栈。String.raw在这里是个实用技巧。因为正则是用字符串拼出来的普通字符串里的\s、\b会被 JavaScript 解释成转义字符必须写成\\s。用String.raw模板可以原样保留反斜杠可读性提升很大。唯一要注意的是反引号和${}在模板里要转义正则里一般遇不到。4.3 正则负责词法、栈负责配对这是我在生产环境里用的方案思路是把问题拆成两步正则只负责切词配对交给栈。正则这一步变得极其简单不需要任何反向引用只要能把所有标签切出来就行const TOKEN /\[(\/?)\s*([a-zA-Z][\w]*)\b(?:([^\]]*))?\s*\]/g;三个捕获组第一个是/或者空用来区分开闭第二个是标签名第三个是可选属性值。这个表达式没有回溯风险也不会误伤因为它的约束足够紧。然后遍历所有 token遇到开标签压栈遇到闭标签就去栈顶比对。名字一致就弹出并记录一对配对结果不一致的话有两种处理策略一是报错跳过二是弹到匹配为止。我在实际项目里选的是第二种因为用户数据太脏严格报错会导致整段内容渲染失败。function findTagPairs(text) { const pairs []; const stack []; const re /\[(\/?)\s*([a-zA-Z][\w]*)\b(?:([^\]]*))?\s*\]/g; let m; while ((m re.exec(text)) ! null) { const [raw, slash, name, attr] m; const tag name.toLowerCase(); if (slash ) { stack.push({ tag, attr, start: m.index, rawStart: raw }); } else { // 从栈顶往下找第一个同名开标签 for (let i stack.length - 1; i 0; i--) { if (stack[i].tag tag) { pairs.push({ tag, attr: stack[i].attr, from: stack[i].start, to: m.index raw.length, body: text.slice(stack[i].start stack[i].rawStart.length, m.index) }); stack.length i; // 丢弃中间未闭合的标签 break; } } } } return pairs; }这段代码的关键在stack.length i这一行。它一次性把栈里从匹配位置之后的所有未闭合标签全部扔掉相当于遇到不匹配就放弃中间那些。这个策略叫容错闭合是 HTML 解析器里的常见做法。如果你需要更严格的行为改成stack.length i 1也行那就只弹一个。栈方案还有个好处是能拿到精确位置信息from和to是原文里的下标body是内容切片。你后面要做高亮、要做富文本转换这些信息都用得上。纯正则方案拿不到跨层的结构这是它比不了的地方。5. 完整实现一个能直接抄走的标签对查找器5.1 返回值约定与接口设计先把接口想清楚比上来就写正则重要得多。我在几个项目里反复调整过最后稳定下来的是这样几个字段字段类型含义tagstring小写标签名attrstring | undefined属性值没有则为 undefinedfromnumber开标签在原文中的起始下标tonumber闭标签在原文中的结束下标不含bodyStartnumber内容区起始下标bodyEndnumber内容区结束下标bodystring内容文本depthnumber嵌套深度最外层为 0加上depth是好几个版本之后才想到的。渲染时经常需要按深度加不同的样式或者在深度超过阈值时做截断提前算好能省一次遍历。bodyStart和bodyEnd分开存是为了避免重复 slice性能敏感的场景下别直接存字符串。5.2 单层配对的纯正则版本如果确认业务里不存在同名嵌套大多数简单论坛是这种情况或者可以在入口处直接拦截纯正则版本更短更快function findFlatPairs(text, allowTags [b, i, u, s, url, quote]) { const names allowTags .slice() .sort((a, b) b.length - a.length) // 长分支优先减少回溯 .join(|); const re new RegExp( String.raw\[(${names})\b(?:[^\]]*)?\] String.raw((?:(?!\[\/?\1\b)[\s\S])*) String.raw\[\/\1\], gi ); const out []; let m; while ((m re.exec(text)) ! null) { out.push({ tag: m[1].toLowerCase(), body: m[2], from: m.index, to: m.index m[0].length }); // 零长度匹配保护防止死循环 if (m.index re.lastIndex) re.lastIndex; } return out; }几个点要说清楚。names排序是为了长分支优先urls排在url前面减少无效回溯。\b后面的\1是在负向预查里用的它和闭标签的\1是同一组保证中间不会出现同名标签。gi两个标志缺一不可g少了exec会死循环i少了大小写混写就漏。if (m.index re.lastIndex) re.lastIndex这行看着多余其实是保命的。当正则能匹配空字符串时比如标签体是空的[b][/b]某些写法下确实会匹配到零长度exec的lastIndex不会前进while就变成死循环浏览器直接卡死。这个 bug 我在一个线上项目里遇到过用户输入了[u][/u]页面直接无响应排查了两个小时才定位到。5.3 支持嵌套的 token 流版本上一节的栈方案可以直接用但我想补两个改进点。第一个是归一化。在切词之前先做一遍标签名小写化能省掉后面所有的 toLowerCase 调用也让配对逻辑更干脆function normalizeTags(text) { return text.replace(/\[(\/?)\s*([^\s\]])/g, (m, slash, name) { return [ slash name.toLowerCase(); }); }这个替换只动标签名部分属性值和内容都不碰所以不会误伤正文。注意它也会把[图片]这种中文标签名转成小写中文没有大小写概念等于没变安全。第二个是未闭合标签的处理。遍历结束后栈里剩下的都是没找到闭标签的开标签应该单独返回让调用方决定是丢弃还是补一个闭标签function findTagPairsFull(text) { const src normalizeTags(text); const pairs []; const orphans []; const stack []; const re /\[(\/?)\s*([a-zA-Z][\w]*)(?:\s*\s*([^\]]*))?\s*\]/g; let m; while ((m re.exec(src)) ! null) { const slash m[1]; const tag m[2]; const attr m[3]; if (slash ) { stack.push({ tag, attr, start: m.index, end: m.index m[0].length }); } else { let matched -1; for (let i stack.length - 1; i 0; i--) { if (stack[i].tag tag) { matched i; break; } } if (matched -1) { orphans.push({ kind: close, tag, at: m.index }); continue; } const open stack[matched]; // 中间被丢弃的未闭合标签记入 orphans for (let i matched 1; i stack.length; i) { orphans.push({ kind: open, tag: stack[i].tag, at: stack[i].start }); } pairs.push({ tag, attr: open.attr, from: open.start, to: m.index m[0].length, bodyStart: open.end, bodyEnd: m.index, body: src.slice(open.end, m.index), depth: matched }); stack.length matched; } } for (const s of stack) { orphans.push({ kind: open, tag: s.tag, at: s.start }); } return { pairs, orphans }; }depth: matched这个值的含义是被丢弃之后在新栈里的位置最外层的配对matched是 0。这个值在需要按层级渲染时很有用。不过要注意它反映的是容错处理后的实际层级不是原始文本里的理论层级——遇到错配时两者会不一致这也是容错方案必须付出的代价。5.4 测试用例与边界输入正则代码最怕的是跑通一个例子就上线。我习惯至少准备下面这组输入每次改动表达式都跑一遍const cases [ [[b]粗体[/b], 1], [[b]粗体[/b]和[i]斜体[/i], 2], [[B]大写[/B], 1], [[ b ]带空格[/ b ], 1], [[urlhttp://a.com]链接[/url], 1], [[b]未闭合, 0], [[/b]孤儿闭标签, 0], [[b][/b], 1], [[b]外层[b]内层[/b]外层[/b], 2], // 栈版本可处理 [[b]错配[/i], 0], [[b\n]换行标签[/b], 1], [[b]跨\n行内容[/b], 1] ];其中[b][/b]这条最容易被忽略它就是触发零长度匹配死循环的那一例。[b\n]这种换行标签在真实数据里也出现过因为有些编辑器会把长标签折行显示用户复制粘贴时带上了换行。[b]错配[/i]在纯正则版本和栈版本下的结果不一样前者返回 0因为\1不匹配后者返回 0 但同时会产出 orphan 记录这个差异要提前想清楚。6. 上线前必须检查的几件事6.1 灾难性回溯的现实触发场景灾难性回溯不是理论恐吓它在标签解析这个场景里出现的概率比想象中高。触发条件通常是三个因素同时存在嵌套量词、惰性匹配、以及一个大概率失败的后缀。举个会出事的写法// 危险外层 * 套内层 *? /^(\[[a-z]\]([\s\S]*?)\[\/[a-z]\]\s*)$/对一个长度两万、内容里全是[但没有正确闭标签的输入这个表达式会让引擎尝试指数级的组合。实测在一台普通机器上两万字符的畸形输入能让这个正则卡住十几秒直接拖垮接口。规避手段有这么几条。第一内容长度设上限超过就直接拒绝这一步能挡掉 90% 的风险。第二避免在量词内部嵌套量词如果必须就把内层替换成[^x]*这种明确的字符类。第三用负向预查代替惰性量词就是我们前面那个(?:(?!\[\/\1\])[\s\S])*的写法它在语义上等价于惰性匹配但执行路径更可预测。我在一个后台系统里做过对比测试同一份脏数据惰性版本平均耗时 800ms预查版本 40ms。差距主要来自回溯次数数据量越大越明显。6.2 全局标志与lastIndex的连带效应g标志会带来一个很反直觉的行为同一个正则对象被反复使用时lastIndex是共享的。这意味着test()的结果会来回跳const re /\[b\]/g; re.test([b]); // truelastIndex 变成 3 re.test([b]); // false从 3 开始找找不到 re.test([b]); // truelastIndex 归零后又找到了这个坑在封装成工具函数时特别危险。如果你写成// 错误示范 const HAS_TAG /\[(b|i)\]/g; function hasTag(t) { return HAS_TAG.test(t); }那么第二次调用hasTag就可能返回false而且是否出错取决于前一次调用的输入内容属于典型的偶发 bug。正确做法要么每次new RegExp要么在函数内部先把lastIndex归零要么干脆把g去掉——test()不需要g。exec循环里有类似问题。每次exec都会推进lastIndex如果中途break出来下一次从同一个位置继续调用会接着上次的进度走。写循环时务必用局部变量或者每次重置别指望它的状态是干净的。还有一个相关细节String.prototype.matchAll要求正则必须带g否则直接抛TypeError。这是规范层面的硬性要求报错信息还算清楚但第一次遇到会懵。6.3 输入预处理与长度上限最后聊聊工程侧的兜底。再怎么打磨正则也挡不住用户输入一兆字节的纯[字符。所以在进入正则之前加一道硬闸const MAX_LEN 20000; function safeParse(text) { if (typeof text ! string) return { pairs: [], orphans: [] }; if (text.length MAX_LEN) { text text.slice(0, MAX_LEN); } return findTagPairsFull(text); }typeof检查看着啰嗦但接口被上下游乱传的情况太常见了可能是null可能是undefined可能是数字。一个便宜的判断能省掉一堆线上报错。截断策略有两种我倾向于直接截断而不是抛错。理由很实际这类内容大多数是展示用的截断之后用户至少能看到前两万字抛错就整个页面白屏了。如果业务需要严格保证完整性那就在截断的同时往日志里打一条记录方便事后追查。再补一个小技巧。如果同一份内容会被反复解析比如列表页每次渲染都解析一遍把结果缓起来。缓存键可以用内容本身的哈希或者直接用内容字符串当 key。我做过统计一个页面上重复内容的比例能占到三到五成缓存带来的收益不小。缓存时要注意把pairs和orphans一起存不要只存配对的否则调试时看不到问题在哪。正则处理成对标记这件事说到底是在表达能力和可维护性之间找平衡。纯正则方案短、快、一次成型但遇到嵌套就露底栈方案啰嗦一点却能处理任意深度的脏数据。我的实际经验是先看数据源可不可控如果标记是系统自己生成的纯正则足够只要有一点点用户手写成分直接上栈方案别在正则上死磕。至于那个if (m.index re.lastIndex) re.lastIndex的保护我建议无论什么场景都加上——它只多占一行但能避免的是一整类能把页面卡死的输入。