返回博客

贪心正则把中文备注吃掉了——枚举后缀剥离的经典误伤

在某后台管理系统的元数据驱动表格里,枚举列的选项值存储格式是「可读标签 + ASCII 编码后缀」,比如 职称(ZC)中级工(M)。为了让列表展示更干净,我们在单元格渲染层加了一行剥离逻辑,把括号里的编码去掉。上线后,一位同事发现客户名称列里有个测试账号 潘骁(测) 在表格里只剩了 潘骁——备注标记消失了。

现象

  • API 响应完全正确,字段值是 潘骁(测)
  • 表格中显示 潘骁,末尾括号内容被静默删除。
  • 同一页面的枚举列(职称、级别)剥离效果正常。
  • 问题在一次"优化枚举显示"的重构后才出现。

根因

出事的代码只有一行:

rawText.replace(/\([^)]+\)$/, '')

这个正则的语义是:删除字符串末尾任意非 ) 内容组成的括号对。它完全不关心括号里装的是 ASCII 编码 ZC 还是中文备注 测试VIP——只要末尾有括号,一律剥掉。

更坏的是,当有人把正则升级为同时匹配全角括号 () 时:

rawText.replace(/[\((][^))]+[\))]$/, '')

韩远(测试) 也逃不过了。

坑:贪心字符类无法区分「枚举编码」和「中文业务备注」

[^)]+ 的含义是「除右括号外的任意字符」,中文字符同样满足这个条件。正则本身没有能力判断括号里装的是类型码还是业务标注——这个判断必须由工程师显式给出约束。

正解

用三层约束把「枚举编码后缀」和「中文业务备注」彻底隔开:

export function formatEnumDisplayText(
  rawText: string,
  type: ColumnType,
  optionColors?: Record<string, string>
): string {
  // 约束一:只对枚举类型列生效,自由文本列(如客户姓名)直接跳过
  if ((type !== 'enum' && type !== 'preset_enum') || !optionColors) return rawText
 
  // 约束二:括号内只允许 ASCII 字母 / 数字 / 下划线 / 连字符
  // 约束三:长度上限 10,真实枚举编码通常 1-5 位
  return rawText.replace(/[\((]([A-Za-z0-9_-]{1,10})[\))]$/, '')
}

三层防御各自负责一个维度:

| 约束 | 拦截的误伤场景 | |---|---| | 类型守卫 type === 'enum' | 姓名、备注等自由文本列根本不进入剥离路径 | | 字符类 [A-Za-z0-9_-] | 中文字符(VIP中文)不满足,直接放行 | | 长度上限 {1,10} | 过长的英文词组、UUID 片段不会被当成编码后缀删除 |

正解:字符类 + 长度上限 + 类型守卫,三层合一

枚举编码的特征是:ASCII 字符集、短(通常 ≤5 位)。把这两个特征写进正则,中文备注天然被排除在外。类型守卫则是最便宜也最可靠的第一道门——自由文本列永远不应该进入剥离路径。

补充:全角括号是另一个坑

中文界面里用户和运营人员经常混用全角括号 ()。若正则只处理半角 (),全角版本会漏网;若贸然把两者都加进去却不同时收紧字符类,误伤面积就翻倍了。正确做法是:括号匹配全角+半角,但内容字符类永远只认 ASCII

// 正确:括号兼容全角,内容只认 ASCII
/[\((]([A-Za-z0-9_-]{1,10})[\))]$/
 
// 错误:括号扩了,内容也跟着放宽,等于没有约束
/[\((][^))]+[\))]$/

一句话外卖

用正则剥离「类型化后缀」时,永远通过字符集限定 + 长度上限来圈定目标,而不是用「排除其他字符」的贪心方式——贪心匹配永远会把你意想不到的合法内容也一起吃掉。