零宽空格是 U+200B,一个真正的 Unicode 字符:屏幕上什么都不显示,却在字符串里占着一个位置。它原本用来在没有空格的文本里标出“这里可以换行”。正因为它看不见又被计入长度,它会悄无声息地破坏相等比较、搜索和 Ctrl+F、表单校验、去重和解析,而屏幕上的文字看起来完全正确。在编程意义上它也不算空白字符:JavaScript 的 \s 匹配不到它,trim() 也删不掉它。
零宽空格是什么?
零宽空格是 Unicode 字符 U+200B ZERO WIDTH SPACE。它没有字形、没有宽度,也没有任何可见效果。但它仍然是一个字符:有码位,在字符串里占一个位置,计入长度,并且像任何字母一样经得住复制、保存和传输。
它的作用是标出一个可以换行的位置,而不在那里放一个空格。这在两种情况下有用:
- 词与词之间不写空格的文字,比如泰文、高棉文、老挝文。换行程序需要提示才知道哪里可以断开,U+200B 提供了提示,又不改变文本的样子。中文本身允许在任意两个汉字之间换行,所以中文排版其实很少用到它,它出现在中文文本里,几乎都是从别处带进来的。
- 很长、中间没有断点的字符串,比如网址、哈希值、文件路径,或者窄表格列里的长标识符。插入零宽空格能让这些字符串在指定位置折行,而不是撑破容器。这就是网页里到处都是它的原因,也是你从网页复制东西时它会跟着过来的原因。
| 属性 | 值 |
|---|---|
| 名称 | ZERO WIDTH SPACE |
| 码位 | U+200B |
| 十进制 | 8203 |
| UTF-8 字节 | E2 80 8B |
| Unicode 类别 | Cf(格式字符) |
| White_Space 属性 | 否 |
| HTML 实体 | ​ 或 ​ |
| JavaScript / Python 转义 | \u200B |
最惹麻烦的是中间那两行:它的类别是格式字符,而不是空格分隔符,Unicode 的 White_Space 属性为否。下一节的所有问题都源于这两个事实。
零宽空格算空白字符吗?
看上去算。但对几乎所有编程语言来说,不算。这是关于它最有用的一条知识,因为它让开发者最先尝试的两种办法都失效了。
JavaScript。 无论有没有 u 标志,\s 字符类都匹配不到 U+200B,String.prototype.trim() 也删不掉它:
/\s/.test("\u200B"); // false
"\u200B".trim().length; // 1 — 还在
"Ada" === "Ada\u200B"; // false
"a\u200Bb".split(/\s/).length; // 1 — 没有被切开
Python。 两个方向都一样:
"\u200b".isspace() # False
(" a" + "\u200b").strip() # 'a\u200b' — 零宽空格还在
re.search(r"\s", "\u200b") # None
对比一下不换行空格 U+00A0:按 Unicode 的定义它是空白字符,在这两种语言里 \s 都能匹配、trim() 都能删除。日常交流里两者常被统称为“看不见的空格”,但在代码里它们的表现正好相反。以为 trim 能同时处理两者的清理代码,会在零宽空格上一再失败。
还有一个区别值得记准,因为它在 JavaScript 里尤其容易让人栽跟头:JavaScript 把 U+FEFF 当作空白字符,\s 能匹配它、trim() 能删除它,因为 ECMAScript 把它列入了自己的 WhiteSpace 定义。Python 不这么认为:"\ufeff".isspace() 是 False,strip() 也不动它。于是同一个字节序标记(BOM),在一个运行环境里悄悄消失,到了下游的另一个环境里却还在,造出一个只在一半技术栈里出现的 bug。
实用规则:处理零宽字符时,永远不要依赖 trim 或 \s。 按码位去匹配。
零宽字符家族速览
U+200B 有几个同样什么都不显示的近亲。分清它们很重要,因为删除它们的风险并不一样。
| 字符 | 码位 | 用途 | 能放心删除吗? |
|---|---|---|---|
| 零宽空格 | U+200B | 标出可以换行的位置 | 通常可以 |
| 零宽不连字符 | U+200C | 阻止两个字母连写或形成连字 | 不行,在阿拉伯文、波斯文和印度诸文字中有实际意义 |
| 零宽连字符 | U+200D | 强制连写;把表情符号拼成一个序列 | 不行,删掉会把表情符号拆开 |
| 零宽不换行空格 | U+FEFF | 文件开头的字节序标记(BOM) | 在开头可以;在中间出现时按残留物处理 |
| 软连字符 | U+00AD | 只在单词恰好在此处换行时才显示的连字符 | 通常可以 |
其中两个需要特别说明。
U+200D 在表情符号里是承重的。 一个看起来单独的表情 👩💻 其实是三个码位:女性、零宽连字符、笔记本电脑。删掉连字符,一个图案就变成两个:👩💻。任何对含表情符号的面向用户文本执行的“删除不可见字符”操作,如果不排除 U+200D,就会出这个问题。
U+FEFF 的含义取决于位置。 在文件最开头,它是字节序标记,是关于编码的信号而不是内容,删掉它通常是对的。在其他任何位置,它是零宽不换行空格;Unicode 现在不提倡这种用法,建议改用 U+2060 WORD JOINER,所以它出现在中间几乎总是拼接文件、或者把带 BOM 的文件当文本读入时留下的残留。
完整的清单,包括方向控制符、字词连接符、行分隔符和段落分隔符,以及可以用来伪装文件名的那些字符,见不可见 Unicode 字符参考手册。
零宽空格是从哪里来的?
没有人会不小心打出一个零宽空格,它们都是跟着别处来的文本进来的。
- 从网页复制。 这是最常见的来源,远超其他。网站会在长网址、文件路径、哈希值和标识符里插入 U+200B,让它们能在窄栏里折行。选中、复制,换行提示就跟着过来了。
- 公众号和富文本编辑器。 通过 HTML 来回转换内容的编辑器(包括常见的公众号排版工具、各类 CMS),有时会留下零宽字符,它们是光标定位的残留,或者是某个必须占住位置的空行内元素。
- 从 PDF 提取文字。 从 PDF 复制时经常会得到零宽空格和软连字符,它们出现在排版软件控制换行的地方,混在你想要的文字中间。
- AI 聊天输出。 从聊天窗口复制回复,复制的是渲染后的 HTML,所以排版用到的各种空格字符都会一起带过来。这是复制带来的,不是生成时藏进去的信号,详见ChatGPT 会隐藏不可见字符吗。
- 故意插入。 零宽字符被用来给文档加“指纹”,这样泄露出去的副本可以追溯到具体的收件人;网上流传的“零宽字符隐写”“文本隐形水印”就是这个原理。它也被用来拆开词语,让关键词过滤和垃圾信息检测匹配不上:
微\u200B信看上去是“微信”,但匹配“微信”的过滤规则抓不到它。 - 钓鱼信息中的混淆。 同样的手法用在品牌名和网址上,让消息绕过简单的字符串匹配,而在人眼里读起来完全正常。
所有不可见字符问题都有同一条主线:当文本从展示场景(它们在那里有正经用处)进入数据场景(没有任何东西预料到它们)时,它们就混进来了。
零宽空格会弄坏什么?
下面每一种故障都是同一个模样:文字看起来是对的,所以所有排查直觉都指向错误的地方。
字符串相等和去重。 "Ada" === "Ada\u200B" 的结果是 false,同时装着两者的 Set 大小是 2。在人看来完全一样的两行,对机器来说是两个不同的值,于是去重漏掉它们,分组把它们拆开。
=== 比较和单元测试。 断言失败了,期望值和实际值打印出来却一模一样,diff 也显示没有差别。这是经典的一小时排查,线索是长度不一致:先查 .length,再查别的。
搜索和 Ctrl+F。 词语中间夹着一个零宽空格,搜索这个词就什么都找不到,哪怕它明明就显示在页面上。浏览器查找、编辑器查找和数据库的 LIKE 都会以同样无声的方式失败。中文用户常说的“文本里有看不见的字符、明明有却搜不到”,多半就是它。
表单校验和邮箱、手机号字段。 末尾带一个 U+200B 的邮箱地址过不了格式校验;从别处复制来的手机号夹着一个零宽空格,就不再是 11 位数字。用户看到的是一个正确的号码和一条莫名其妙的报错,而由于 trim() 删不掉它,处理函数里惯常的防御性 trim 也救不了你。
数据库唯一约束。 user@example.com 和 user@example.com\u200B 是不同的字符串,所以唯一索引会照单全收。结果是两个显示完全相同、在后台界面里根本分不出来的账号。
代码。 含有零宽空格的标识符或对象键,看起来和你想写的那个一模一样。obj.total 返回 undefined,因为之前定义的键是 total\u200B。导入解析不到,配置项被忽略,而报错从不提及真正的原因。
CSV 和 JSON 解析。 表头单元格里有一个零宽空格,列名就和你的映射对不上。在 JSON 里,左花括号之前多出一个 U+FEFF,会让 JSON.parse 抛出“unexpected token”错误,指向一段看起来完全合法的 JSON 的第 0 个位置。
字数统计和简历筛选。 字数和字符数会比看到的多,限字数的表单会拒绝明明没超的输入。招聘系统按关键词匹配,从 PDF 或网页资料里复制时沾上零宽空格的职位名称或技能,就不再匹配系统搜索的关键词,匹配数悄悄下降。
怎样找到并删除零宽空格?
先看症状。 最可靠的信号是长度和看到的不一致。如果一个看上去只有 3 个字符的字符串报告长度为 4,或者一个“显然”等于另一个值的值偏偏不相等,你面对的几乎肯定是不可见字符。
扫描文本。 把文本粘贴到删除不可见字符工具里。它会报告有哪些不可见字符、每种各有多少,再按你的要求删除。全部在浏览器里运行,不上传。先看到数量再动手,能告诉你是一个零星的字符还是系统性的污染,而这决定了你要修的是什么。
在编辑器里。 VS Code 默认通过 editor.unicodeHighlight 系列设置高亮大多数不可见字符。在开启正则的查找替换里,下面这个字符类覆盖了常见的零宽字符:
[\u200B-\u200D\u2060\uFEFF]
也就是零宽空格、零宽不连字符、零宽连字符、字词连接符,以及零宽不换行空格 / BOM。如果还想处理软连字符,加上 \u00AD。
JavaScript。 只删除零宽空格:
const clean = input.replace(/\u200B/g, "");
删除整个家族,同时保留表情符号用的连字符:
const clean = input.replace(/[\u200B\u200C\u2060\uFEFF\u00AD]/g, "");
Python。 同样两种写法,分别用 str.replace 和 re:
clean = text.replace("\u200b", "")
import re
clean = re.sub(r"[\u200b\u200c\u2060\ufeff\u00ad]", "", text)
注意这些写法刻意漏掉了什么。U+200D 被排除,以保证表情符号序列完整;U+00A0 被排除,因为不换行空格是真正的空格,删掉它会把两个英文单词粘在一起。如果你清理的文本里有不换行空格,应把它替换成普通空格,而不是删除。
在哪里处理。 在边界处统一规范化:文本进来时处理,而不是用到时处理。在输入端清理一次,胜过在代码各处零散地做防御性替换,而且存进数据库的就是干净的值。在标识符、键名、邮箱、手机号和代码里大胆清除;在面向用户的正文里谨慎处理,因为 U+200C 和 U+200D 可能承载着意义。
怎样输入或复制一个零宽空格?
有时你确实需要一个:让长字符串可以折行,或者测试自己的清理代码是否管用。可靠的方法都是文本式的,因为你看不见自己要选中的东西。中文输入法里没有打出它的按键。
| 在哪里 | 写法 |
|---|---|
| HTML | ​ 或 ​ |
| JavaScript / TypeScript / JSON | "\u200B" |
| Python | "\u200b" 或 "\N{ZERO WIDTH SPACE}" |
CSS content |
"\200B"(转义后的空格表示转义结束) |
| Microsoft Word | 输入 200B,然后按 Alt + X |
| Windows | Win + . 的符号页,或者从代码转义粘贴 |
| Linux(IBus) | Ctrl + Shift + U,输入 200b,再按 Enter |
| macOS | 没有系统快捷键;用字符检视器或转义写法 |
从网页上复制一个零宽空格本质上是靠不住的,从本页复制也一样。你看不见这个字符,也就看不见选区的边界:可能连旁边的空格一起选上了,也可能根本没选中它,屏幕上什么都不会告诉你是哪种情况。“点击复制零宽空格”的按钮只是把同一个问题往后推了一步:剪贴板里有了点东西,但你没法确认是什么。
所以如果目标位置接受代码(HTML、模板、源文件、配置文件),就用转义写法。它明确、能在 diff 里审查,也不可能只选中一半。如果目标是文字处理软件,就用 Word 的 Alt + X,同样明确。
特别是在 HTML 里,先想想你是否真的需要 U+200B。​ 是内容字符,会成为文本的一部分,任何人选中复制时都会把它带走。CSS 属性 overflow-wrap: anywhere 和 word-break: break-word 能达到同样的折行效果,却不往字符串里放任何东西;想在断行处显示连字符时,­(软连字符,U+00AD)才是正确的选择。只有当你需要在某个 CSS 推断不出来的特定位置提供断行机会时,才用真正的字符。
如果你真正想要的恰恰相反,是把这类字符连同破折号、弯引号和不换行空格一次性清掉,清理 ChatGPT 和 AI 文本可以处理整套字符,并报告改动了什么,而不是悄悄编辑。
常见问题
什么是零宽空格?
零宽空格是 U+200B,一个显示为空、但仍属于字符串的 Unicode 字符:它有码位,占一个位置,也计入长度。它用来在不插入可见空格的情况下标出可以换行的位置,适用于词间不写空格的文字,以及网址这类很长、中间没有断点的字符串。
怎样删除零宽空格?
按码位匹配,而不是按空白字符匹配。JavaScript 里用 input.replace(/\u200B/g, "");整个家族是 /[\u200B\u200C\u2060\uFEFF\u00AD]/g。Python 里用 re.sub(r"[\u200b\u200c\u2060\ufeff\u00ad]", "", text)。也可以把文本粘贴到我们的删除不可见字符工具,它会先统计数量,再在浏览器里删除。
trim() 能删除零宽空格吗?
不能。JavaScript 的 trim() 删除的是具有 Unicode White_Space 属性的字符外加 U+FEFF,而 U+200B 两者都不是。Python 的 str.strip() 也一样,因为 "\u200b".isspace() 的结果是 False。零宽空格属于 Unicode 的 Cf(格式字符)类别,不属于空格分隔符,所以所有基于空白字符的处理都会忽略它。
为什么我的文本里有看不见的字符?
几乎总是因为文本是从某个本来就带着它的地方复制来的。网页插入零宽空格来控制长字符串在哪里折行,PDF 带有换行残留,富文本编辑器和公众号排版工具会留下残余字符。它们也可能是被故意插入的,用来给文档加指纹,或者拆开某个词让关键词过滤匹配不上。
怎样输入零宽空格?
用代码转义,而不要试图复制一个。HTML 里写 ​,JavaScript 或 Python 里写 \u200B,CSS 的 content 里写 \200B。在 Microsoft Word 里输入 200B 再按 Alt + X。中文输入法打不出它;从网页复制不可见字符也不可靠,因为你看不见自己选中了什么。
零宽空格危险吗?
与其说危险,不如说是捣乱。它让相等比较、搜索、校验和去重失效,而文字看起来完全正确。不过它确实可以被恶意利用:用它拆开一个词,就能骗过简单的关键词过滤;钓鱼信息也用同样的手法让品牌名绕过字符串匹配。
零宽空格和不换行空格是一回事吗?
不是,而且两者的表现正好相反。不换行空格(U+00A0)是一个可见、不会折行的空格,属于真正的空白字符,\s 能匹配它、trim() 能删除它。零宽空格(U+200B)没有宽度,标出可以换行的位置,根本不是空白字符。不换行空格应替换成普通空格,零宽空格则直接删除。