粘贴到 Web 表单中的文本常常携带着没有人输入、也没有任何字体会渲染的不可见字符:零宽空格、软连字符、字节顺序标记和不换行空格。它们会一路存进你的数据库,悄无声息地破坏精确匹配比较、长度校验和搜索。
如果你曾经追查过这类 bug,你一定清楚它的套路。有人把简历中的一段文字从文字处理软件粘贴到求职申请的输入框里,屏幕上看起来完全正常,但你的校验器却拒绝了它。或者它顺利保存了,然后再也没人能找到这条记录,因为索引里的姓名包含了一个搜索框根本无法输入的字符。
本文将展示实际落入字段中的到底是什么,解释为什么逐个 code point 地打补丁永远不会收敛,并给出一个四步清理函数来处理这一整类问题——包括那些你绝不能删除的不可见字符。
核心要点
- 随粘贴文本一同到来的不可见字符属于 Unicode 通用类别 Format,在 JavaScript 正则表达式中写作
\p{Cf},因此一次类别匹配就能取代一份不断增长的单个 code point 清单。 normalize("NFC")修复的是拼写,而不是不可见性:它让同一个带重音字母的两种编码比较相等,却会原封不动地留下零宽空格。- U+00A0 处的不换行空格是一个空格字符,而非格式字符,因此它能毫发无损地躲过
\p{Cf}剥离,必须由空白折叠步骤来处理。 - U+200D ZERO WIDTH JOINER 承担着实际作用:它把多人 emoji 的各个部分绑定为一个字形,而且连接符在阿拉伯文和印度系文字中携带正字法含义。
- 只有当正则带有
u或v标志时,\p{...}才表示 Unicode 属性;否则它只是字母 p 的恒等转义。
字段里实际收到了哪些不可见字符?
从文字处理软件或富文本编辑器粘贴来的字符串,通常混杂着你看得见的排版替换字符和你看不见的格式字符。弯引号、短破折号和长破折号、省略号字符都是可见的,而且基本无害。不换行空格、软连字符、零宽空格和字节顺序标记则完全不可见,而它们正是破坏相等性检查的元凶。
把字符串按 code point 打印出来,结论不言自明:
function inspect(str) {
return [...str]
.map((ch) => ch.codePointAt(0))
.filter((cp) => cp > 0x7f)
.map((cp) => "U+" + cp.toString(16).toUpperCase().padStart(4, "0"));
}
const pasted = "\uFEFFSenior\u00A0Engineer\u200B, 2019\u20132024";
console.log(inspect(pasted)); // [ 'U+FEFF', 'U+00A0', 'U+200B', 'U+2013' ]
console.log(pasted.length); // 28, for 26 characters a human would count
请使用展开运算符处理字符串,而不要调用 split(""):字符串迭代器产出的是完整的 code point,而 split("") 会在 UTF-16 边界处把星平面字符拦腰切断。
要查看手头字符串中究竟有什么,可以把它粘贴到 invisible character cleaner 并读回其 code point。
这类故障的定义特征就是不可见。字段渲染正常,所以 bug 的截图看不出任何问题,报告者也说不清自己做了什么不一样的操作。会话回放(Session replay)是少数能让它浮出水面的手段之一,因为被放弃表单的回放会显示出粘贴动作以及随后的重试循环:有人清空字段,然后重新输入一个看上去与刚被拒绝的值一模一样的内容。
为什么零宽字符的黑名单会不断变长?
用具体 code point 组成的字符类,问题不在于写得糟糕,而在于修复方式的形态就错了——因为它只能容纳那些已经有人提过 bug 的字符。第一份报告后你删掉 U+200B,CSV 导入出问题时加上 U+FEFF,带连字符的姓氏匹配失败时再加上 U+00AD,于是这个字符类不断膨胀,因为它在枚举一个集合的成员,而不是给这个集合命名。
这个集合是有名字的。零宽空格(U+200B)、零宽不连接符(U+200C)、零宽连接符(U+200D)、软连字符(U+00AD)和字节顺序标记(U+FEFF)都带有 General_Category=Cf,见 Unicode Character Database,而且这些归类在标准的多个版本中一直保持稳定。U+00A0 处的不换行空格不属于该集合:它是 Zs,即空格分隔符,这也正是为什么仅凭类别剥离会把最常见的文字处理软件残留物原样留下。
归一化、剥离、折叠、修剪
修复办法是一次遍历完成四个有序步骤:归一化编码、移除格式字符、把所有空白变体折叠成普通空格,最后修剪。
const FORMAT_CHARS = /[\p{Cf}--[\u200C\u200D]]/gv;
const SPACE_RUN = /[\p{Zs}\t\n\r]+/gu;
export function cleanPastedText(input) {
return input
.normalize("NFC") // one canonical spelling per character
.replace(FORMAT_CHARS, "") // BOM, zero-width space, soft hyphen, bidi controls
.replace(SPACE_RUN, " ") // NBSP, thin spaces, tabs, newlines -> one space
.trim();
}
如果字段是 textarea 且换行本身属于内容,请从 SPACE_RUN 中去掉 \n\r。
这里有两点值得强调。只有当正则处于 Unicode 感知模式时,\p{...} 才具有 Unicode 含义;一旦同时去掉 u 和 v,引擎就会把 \p 读作转义后的字面量 p,于是该模式能够编译、能够运行,却匹配不到任何你想要的东西。另外,折叠步骤使用的是基于 \p{Zs} 显式构建的字符类而非 \s,这样在调用处就能清楚看出 U+00A0 已被覆盖。
顺序很重要。normalize("NFC") 解决的是编码问题,而非不可见性问题,因此它会把零宽空格原样留给剥离步骤处理。先剥离再折叠,意味着字节顺序标记会被删除,而不是被转换成一个多余的空格。而把修剪放在最后,可以清理掉被移除的格式字符旁边所遗留的前导空格。
哪些字符不该剥离
盲目剥离所有格式字符会损坏真实内容。U+200D ZERO WIDTH JOINER 正是把 emoji 绑定为单个字形的那个字符:在 Unicode emoji 标准中,一个字符串之所以成为 emoji ZWJ 序列,前提就是存在连接符,因此去掉连接符会让原本的一个字形变成好几个。
const family = "👨👩👧";
console.log([...family].length); // 5
console.log([...family.replace(/\p{Cf}/gu, "")].length); // 3 -> 👨👩👧
在 emoji 之外,连接符同样不是装饰。阿拉伯文书写者会在两个字母之间放入不连接符,以阻止它们按照草书方式连写;Unicode 核心规范警告说,被剥离这些控制字符的文本要么表达了别的意思,要么变得不知所云。在天城文中,virama 之后的 ZWJ 会选择辅音的半形而非完整合体字。规则是:剥离那些在你的字段中不携带任何含义的不可见字符,保留那些承担结构性作用的字符。
这正是 [\p{Cf}--[\u200C\u200D]] 所表达的含义:v 标志为字符类引入了集合运算符,而 -- 就是执行差集的那个。在不支持 unicodeSets 的运行时中,u 模式下的等价写法是 /(?![\u200C\u200D])\p{Cf}/gu。不要在同一个正则上同时设置这两个标志;它们是互斥的。
为什么应该用 NFC 而不是 NFKC?
NFC 解决的是 Unicode 拼写同一个字符的两种方式,除此之外什么都不改。NFKC 走得更远,会重写兼容性字符:正如 normalize() 示例所示,ff 连字会变成两个 f,带圈的 Ⓓ 会变成普通的 D。这是一个会改变内容的决定,Unicode 归一化附录将其定义为兼容性等价而非规范等价,因此它值得被有意识地做出,而不是作为清理表单字段的副作用被顺带继承。
还有一个相关的陷阱:删除软连字符是你的清理器该干的活,而不是 normalize("NFKC") 的职责。把 U+00AD 映射为空字符串的那个 Unicode 操作是 NFKC_Casefold,它与 String.prototype.normalize("NFKC") 所执行的变换并不相同。
在服务端也要运行清理器
在浏览器中清理是对输入者的一种体贴;而你的数据库和搜索索引所依赖的归一化,必须在服务端运行,因为请求完全可以在从未加载你页面的情况下被发送出去。任何通过公开 API、CSV 导入、webhook 或移动客户端到达的数据都会完全绕过输入处理器,而仅仅一行未经清理的数据就足以让唯一约束或精确匹配查询表现得前后不一。同一个函数在 Node 和浏览器中都能工作,所以请把它运行在数据进入持久化层的边界处,并让客户端调用只充当快速反馈,而不是保证。
别再往字符类里添加 code point 了,开始给类别命名吧。四行代码,应用在每一个入口点,就能移除那些在你的字段中不携带含义的不可见字符,同时保留那些把真实文本维系在一起的字符。写一个 fixture,把组合重音、不换行空格、软连字符、字节顺序标记、零宽空格和一个 ZWJ emoji 混在一起,然后断言你的清理器会合成第一个、把第二个折叠成普通空格、删除接下来的三个,并原样返回该 emoji。
常见问题
trim 会移除不换行空格或零宽空格吗?
trim 只从字符串两端移除空白和行终止符,而 ECMAScript 的空白列表包含不换行空格 U+00A0 和字节顺序标记 U+FEFF,所以这两者在两端都会消失。它永远不会移除零宽空格 U+200B,因为后者是格式字符而非空白;它也永远不会触碰字符串中间的任何这类字符。
剥离格式字符是否也会移除像 U+FE0F 这样的 emoji 变体选择符?
不会。变体选择符 U+FE00 到 U+FE0F 带有 General_Category Mn,即非间距标记,而非 Cf,因此格式类别剥离会原样保留它们,emoji 也能保持预期的呈现形式。只有连接符需要一份保留名单。若把清理器扩大到连标记一并剥离,就会把变体选择符以及所有组合重音一起删掉。
剥离格式字符是否也会移除从右至左覆盖字符?
会。双向控制字符,包括 U+200E、U+200F,U+202A 到 U+202E 的嵌入与覆盖字符,以及 U+2066 到 U+2069 的隔离字符,全都带有 General_Category Cf,因此一次类别匹配就会把它们与零宽空格一并移除。这对显示名称和文件名很重要,因为从右至左覆盖字符会反转渲染出的文本,并可能伪装文件扩展名。
为什么粘贴进来的值看起来够短,却通不过 maxlength?
maxlength 计数的是 UTF-16 代码单元,而不是可见字符,所以每一个不可见的“搭车者”都在消耗额度:一个字节顺序标记或一个零宽空格占一个单元,而由代理对构成的 emoji 占两个。请在校验长度之前清理该值;如果这个上限本意是要匹配人们所看到的内容,就用展开形式来计数。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k