文本处理的三类故障有一个共同点:它们都不报错。字符串比较不相等、正则匹配不上、CSV 解析后字段错位——程序照常运行、退出码是 0,只有在结果被用到时问题才显现。
本文覆盖三类高频静默故障,工具支持见站内 文本处理速查。
第一类:不可见字符
这是最消耗排查时间的一类,因为它违反直觉 —— 你看到的字符集不等于实际存在的字符集。
常见的「隐形」字符:
| 字符 | 码位 | 从哪来 |
|---|---|---|
| 零宽空格 | U+200B | 网页 之外的隐藏分隔 |
| 零宽连接符 | U+200D | emoji 组合、波斯语等 |
| 不换行空格 | U+00A0 | 、Word 自动排版 |
| BOM | U+FEFF | UTF-8 文件头 |
| 各种引号 | U+2018/2019/201C/201D | Word 自动替换 |
它们的共同特征是不占位、不显示、但参与运算。于是:
trim()之后字符串还有内容(U+00A0 不是空白字符给 JS 的 trim)^abc$匹配不上,因为实际是\u200Babc- 两个文件名看起来一致但不相等
- 数据库 unique 约束莫名冲突
排查的唯一可靠方法是打印字符码,不要靠肉眼。 本站的 文本统计工具 会列出每个字符的码位,肉眼看不出来的差异在这里一目了然。
预防是在入口做规范化,而不是在每个比较点打补丁:
const normalize = s => s
.replace(/[\u200B-\u200D\uFEFF]/g, '') // 零宽字符与 BOM
.replace(/\u00A0/g, ' ') // 不换行空格 → 普通空格
.replace(/[\u2018\u2019]/g, "'") // 中文引号 → ASCII
.normalize('NFKC') // 全角 → 半角、兼容字符归一
.replace(/\r\n/g, '\n') // 换行统一
.trim()
NFKC 归一化尤其值得强调:它会把全角字母数字转成半角(ABC → ABC)、把兼容字符折叠(① → 1),这在处理用户从各种渠道粘贴来的文本时非常关键。
第二类:CSV 解析
「按行 split 处理 CSV」是一个看起来无害实则错误的写法。
RFC 4180 明确规定:字段内可以包含换行,只要用双引号包裹。于是这个内容:
a,b
"line1
line2",c
有三行文本,但只有两条记录。按 \n split 会得到 3 段,其中第 2 段是 line2",c —— 后续所有处理都建立在错误的记录数上。
更麻烦的是它不报错。split 总是成功返回数组,你会以为自己拿到了数据。
正确做法是用真正的 CSV 解析器。它们会处理:
- 引号转义(字段内的
"写成"") - 字段内换行
- 分隔符转义
- 可选的 BOM 处理
Python 用 csv 模块,JavaScript 用 papaparse 或 ExcelJS。本站的 Excel 转 CSV 与 Excel 转 JSON 在导出时也遵循同样的规则。
什么时候可以手动 split?只有一个条件:你确认文件里没有引号包裹的字段。另外,注意 split 应该按分隔符(逗号/分号/制表符)而不是按行 —— 这是两个正交的操作,很多代码里混在一起就出错了。
第三类:正则替换的边界
正则有两个容易误解的性质。
第一,点号不是字面的点。 a.c 匹配 abc、a1c、a-c——任何首尾是 a、c 的三字符串。把它当分隔符会大面积误伤。
第二,全局替换的连带影响。 考虑这个需求:「把所有点号换成逗号」(做欧洲数字格式转换)。直接影响是期望的,但连带影响包括:
- 小数点:
3.14→3,14(某些欧洲格式确实要这样,但你要确认) - 版本号:
1.2.3→1,2,3(一定是错的) - 域名:
example.com→example,com(一定是错的) - IP 地址:
192.168.1.1→192,168,1,1(一定是错的)
正确做法分三步:
-
先统计,不替换。
str.match(re)?.length拿到命中数,如果预期 5 处却统计到 500 处,说明模式写错了。 -
用字符类限定范围。
/\d+\.\d+/g只匹配「数字.数字」形态的小数,不会碰版本号与 IP。 -
用
\b卡边界。 需要匹配完整单词时,\bword\b不会命中wordsmith。
const text = '1.5 and 1.2.3 and v1.0 and 2.25'
console.log((text.match(/\./g) || []).length) // 4 —— 所有点
console.log((text.match(/\d+\.\d+/g) || []).length) // 2 —— 仅小数
text.replace(/\d+\.\d+/g, m => m.replace('.', ','))
// 1,5 and 1.2.3 and v1.0 and 2,25 ← 版本号与 v1.0 未被改动
替换前先统计这一步几乎总被跳过,但它能在几秒内发现模式写错的绝大多数情况。本站的 文本统计工具 也能帮你快速确认。
一个额外的坑:去重要按业务键
顺便提一个常见问题:文本去重时按整行比对。
b,2
a,1
b,2
按整行去重后得到 2 行,但如果 b,2 的重复是因为「同一个用户 ID 出现多次」,那真正的业务问题不是重复行,而是数据本身有问题(要么该聚合,要么该报错)。
去重的正确姿势是明确业务键:按 ID 去重(保留第一条还是最后一条要说明),还是按整行去重。这两个语义完全不同。
自查清单
- [ ] 文本比对失败时打印字符码,不靠肉眼找差异
- [ ] 在数据入口做 NFKC 规范化并去零宽字符
- [ ] 处理 CSV 用真正的解析器,不按行 split
- [ ] 正则替换前先统计命中数
- [ ] 用
\d+\.这类字符类限定边界,不用裸点号 - [ ] 明确去重是按整行还是按业务键
可复现的实测结果
把同一段文本分别从记事本、Word、网页复制三份,粘进本站的 文本统计工具,会看到三份的「不可见字符数」不同——Word 复制的那份通常带 U+00A0 与弯引号,网页那份可能带 U+200B。这解释了为什么「从 Word 粘来的数据就是匹配不上」。用 文本替换工具 试一次点号替换,也能直观看到不限定边界时版本号被改坏的后果。