贪心正则把中文备注吃掉了——枚举后缀剥离的经典误伤
在某后台管理系统的元数据驱动表格里,枚举列的选项值存储格式是「可读标签 + 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})[\))]$/
// 错误:括号扩了,内容也跟着放宽,等于没有约束
/[\((][^))]+[\))]$/一句话外卖
用正则剥离「类型化后缀」时,永远通过字符集限定 + 长度上限来圈定目标,而不是用「排除其他字符」的贪心方式——贪心匹配永远会把你意想不到的合法内容也一起吃掉。