你有没有遇到过这样的情况:一段文字看起来完全一样,但程序判断却不相等?或者数据库中有一条用户记录,但无论如何都查不到?这很可能就是零宽字符(Zero-Width Characters)在作怪。这些看不见的 Unicode 字符,是前端和后端开发中的隐形地雷。
什么是零宽字符?
零宽字符是一类不占用可见宽度的特殊 Unicode 字符。它们在屏幕上不可见,但在字符串中确实存在,可以通过 length 属性或调试工具看到它们的存在。
字符串 "abc" vs "abc" (后者插入了零宽字符)
屏幕上看起来: abc abc
实际长度: 3 5 ← 多了不可见字符!常见的零宽字符
1. 零宽空格(ZWSP, Zero-Width Space)
- Unicode:
U+200B - 作用:在文本中插入一个不可见的空格,用于断词或排版
- 常见场景:从 Word 复制文本、某些 CMS 系统生成的内容
const zwsp = '\u200B';
const text1 = 'hello';
const text2 = `he${zwsp}llo`;
console.log(text1 === text2); // false
console.log(text1.length); // 5
console.log(text2.length); // 6(多了一个零宽字符)2. 零宽非连接符(ZWNJ, Zero-Width Non-Joiner)
- Unicode:
U+200C - 作用:阻止某些语言(如阿拉伯语、波斯语)中字符的连接
- 常见场景:从波斯语/阿拉伯语网站复制文本
const zwnj = '\u200C';
console.log('波斯语示例: صفحه\u200Cاول'); // 阻止 ا 和 ل 连接3. 零宽连接符(ZWJ, Zero-Width Joiner)
- Unicode:
U+200D - 作用:强制某些语言中的字符连接,或用于 Emoji 组合
- 常见场景:Emoji 序列(如肤色修饰符)、印度语系文本
// ZWJ 用于组合 Emoji
console.log('\u{1F468}'); // 👨
console.log('\u{200D}'); // ZWJ
console.log('\u{1F469}'); // 👩
// 组合:👨👩(家庭 Emoji)
console.log('\u{1F468}\u200D\u{1F469}');4. 零宽非断空格(ZWNBSP, Zero-Width No-Break Space)
- Unicode:
U+FEFF - 别名:BOM(Byte Order Mark)
- 作用:原用于标记 UTF-16 字节序,在现代 Unicode 中已被弃用
- 坑点:
U+FEFF经常作为「隐形污染」出现在文件开头
// BOM 字符的历史:UTF-8 文件开头的 EF BB BF
const bom = '\uFEFF';
const text = `${bom}Hello`;
console.log(text); // "Hello"(看起来正常)
console.log(text[0] === 'H'); // false!第一个字符是 BOM5. 左/右方向标记(LRM/RLM)
- LRM:
U+200E(Left-to-Right Mark) - RLM:
U+200F(Right-to-Left Mark) - 作用:在混合文字方向时控制排版
零宽字符速查表
| 名称 | Unicode | 转义序列 | 主要用途 | 危险程度 |
|---|---|---|---|---|
| 零宽空格 | U+200B |
\u200B |
断词、排版 | ⚠️ 中等 |
| 零宽非连接符 | U+200C |
\u200C |
阻止连接 | ⚠️ 中等 |
| 零宽连接符 | U+200D |
\u200D |
强制连接、Emoji | 🔵 低 |
| 零宽不换行空格 | U+FEFF |
\uFEFF |
BOM标记 | 🔴 高 |
| 左方向标记 | U+200E |
\u200E |
文字方向 | ⚠️ 中等 |
| 右方向标记 | U+200F |
\u200F |
文字方向 | ⚠️ 中等 |
零宽字符带来的问题
问题一:字符串比较失败
// 用户从复制粘贴的文字触发判断
const username1 = 'alice'; // 直接输入
const username2 = 'ali\u200Bce'; // 从某些网站复制来的
console.log(username1 === username2); // false!
console.log(username1.length); // 5
console.log(username2.length); // 6问题二:数据库查询失败
-- 数据库中存的是 "alice"
SELECT * FROM users WHERE username = 'alice';
-- 查不到!因为 'alice' 包含零宽字符问题三:URL 异常
// 零宽字符出现在 URL 中
const url = 'https://example.com/\u200Bpage';
// 实际请求的是 "https://example.com/\u200Bpage"
// 服务器路由匹配不到,返回 404问题四:JSON 解析失败
const json = '{"name": "ali\u200Bce"}';
// 某些严格解析器可能报错
// 或者解析后 name 字段包含不可见字符,后续处理出错问题五:正则表达式匹配异常
const text = 'hello\u200Bworld';
const pattern = /^helloworld$/;
console.log(pattern.test(text)); // false!因为有零宽字符检测零宽字符
方法一:看字符串长度
function hasInvisibleChars(str) {
// 将字符串转为可见形式,比较长度
const visible = str.replace(/[\u200B\u200C\u200D\uFEFF\u200E\u200F]/g, '');
return str.length !== visible.length;
}
console.log(hasInvisibleChars('hello')); // false
console.log(hasInvisibleChars('hel\u200Blo')); // true方法二:编码点检查
function findZeroWidthChars(str) {
const results = [];
for (let i = 0; i < str.length; i++) {
const code = str.codePointAt(i);
if (code === 0x200B || code === 0x200C || code === 0x200D ||
code === 0xFEFF || code === 0x200E || code === 0x200F) {
results.push({ index: i, char: str[i], code: 'U+' + code.toString(16).toUpperCase() });
}
}
return results;
}
const text = 'ali\u200Bce';
console.log(findZeroWidthChars(text));
// [{ index: 3, char: "", code: "U+200B" }]方法三:可视化输出
function visualize(str) {
return str.split('').map(ch => {
const code = ch.codePointAt(0);
if (code === 0x200B) return '⟨ZWSP⟩';
if (code === 0x200C) return '⟨ZWNJ⟩';
if (code === 0x200D) return '⟨ZWJ⟩';
if (code === 0xFEFF) return '⟨BOM⟩';
return ch;
}).join('');
}
console.log(visualize('ali\u200Bce')); // ali⟨ZWSP⟩ce方法四:使用正则检测
// 检测字符串是否包含任何零宽字符
function containsZeroWidth(str) {
return /[\u200B\u200C\u200D\uFEFF\u200E\u200F]/.test(str);
}
// 检测 BOM 字符
function hasBOM(str) {
return str.charCodeAt(0) === 0xFEFF;
}清理零宽字符
通用清理函数
function cleanZeroWidth(str) {
if (typeof str !== 'string') return str;
return str.replace(/[\u200B\u200C\u200D\uFEFF\u200E\u200F]/g, '');
}
// 使用示例
const dirty = 'ali\u200Bce\u200C@example.com';
const clean = cleanZeroWidth(dirty);
console.log(clean); // alice@example.com清理 BOM 字符(文件读取场景)
function removeBOM(str) {
if (typeof str === 'string' && str.charCodeAt(0) === 0xFEFF) {
return str.slice(1);
}
return str;
}
// 读取文件时清理 BOM
const fs = require('fs');
const content = fs.readFileSync('file.txt', 'utf8');
const cleanContent = removeBOM(content);深度清理(包含更多不可见字符)
function deepClean(str) {
return str
// 零宽字符
.replace(/[\u200B\u200C\u200D\uFEFF\u200E\u200F]/g, '')
// 其他不可见字符(控制字符)
.replace(/[\u0000-\u001F\u007F-\u009F]/g, '')
// 不间断空格(NBSP)
.replace(/\u00A0/g, ' ')
// 全角空格
.replace(/\u3000/g, ' ');
}用户名欺骗安全隐患
这是零宽字符最危险的应用场景:用户名欺骗攻击。
攻击原理
攻击者利用零宽字符创建与合法用户「看起来一模一样」的用户名:
// 合法用户
const legitUser = 'alice';
// 攻击者注册的用户名(插入零宽字符)
const fakeUser = 'ali\u200Bce'; // 看起来是 "alice"
const fakeUser2 = 'alice\u200B'; // 看起来是 "alice"
const fakeUser3 = '\u200Balice'; // 看起来是 "alice"
// 在用户界面上,三个名字看起来完全一样!
console.log(legitUser); // alice
console.log(fakeUser); // alice(但实际上是不同的字符串)实际危害
- 钓鱼攻击:用户
admin可以冒充admin - 绕过唯一性检查:注册系统只检查可见字符,不检查零宽字符
- 社交工程:假账号看起来和真账号一样
- 日志污染:安全审计时难以区分真假用户
防御方案
// 方案 1:注册时清理用户名
function sanitizeUsername(username) {
return username
.replace(/[\u200B\u200C\u200D\uFEFF\u200E\u200F]/g, '')
.trim();
}
// 方案 2:规范化后检查唯一性
function isUsernameAvailable(rawUsername, existingUsers) {
const normalized = sanitizeUsername(rawUsername);
return !existingUsers.has(normalized);
}
// 方案 3:在显示时添加警告(检测相似用户名)
function isSuspiciouslySimilar(username, existingUser) {
const clean1 = cleanZeroWidth(username);
const clean2 = cleanZeroWidth(existingUser);
return clean1 === clean2 && username !== existingUser;
}数据库层防御
-- 在数据库层面,可以用触发器或约束
-- PostgreSQL 示例:插入前清理零宽字符
CREATE OR REPLACE FUNCTION clean_username()
RETURNS TRIGGER AS $$
BEGIN
NEW.username = regexp_replace(NEW.username, '[\u200B\u200C\u200D\uFEFF]', '', 'g');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER clean_username_trigger
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION clean_username();实际案例
案例 1:React 中的 key 警告
// 列表渲染时,如果数据包含零宽字符
const items = ['apple\u200B', 'banana', 'cherry'];
// React 会为每个 key 生成不同的标识符
// 导致不必要的重新渲染
{items.map((item, i) => <div key={item}>{item}</div>)}案例 2:JWT Token 异常
// 从 URL 参数或 localStorage 读取 token 时
const token = localStorage.getItem('token');
// 如果 token 包含 BOM 字符
// fetch 请求时 Authorization header 会带上不可见字符
// 服务器验证失败案例 3:Git 提交信息异常
# 提交信息中包含零宽字符
git commit -m "fix: 修复登录\u200B问题"
# 在 git log 中看起来正常,但钩子脚本可能报错调试技巧
Chrome DevTools 技巧
-
查看字符串的实际内容:在 Console 中用
JSON.stringify()可以看到转义字符console.log(JSON.stringify('ali\u200Bce')); // "ali\u200bce" ← 可以看到转义表示 -
复制粘贴到十六进制编辑器:可以看到实际字节
-
使用
charCodeAt检查:'ali\u200Bce'.charCodeAt(3); // 8203(即 0x200B)
Node.js 调试
// 打印字符串的十六进制表示
function hexDump(str) {
return Array.from(str).map(ch =>
ch.codePointAt(0).toString(16).padStart(4, '0')
).join(' ');
}
console.log(hexDump('ali\u200Bce'));
// 0061 006c 0069 200b 0063 0065
// ↑ 零宽字符的 Unicode 编码总结
零宽字符是前端开发中的隐形陷阱:
- 看不见但存在:
length增加,字符串比较失败 - 来源多样:复制粘贴、文件 BOM、输入法、恶意攻击
- 防御策略:
- 输入时清理(注册、表单提交)
- 存储前规范化(数据库层)
- 显示时警惕(用户名相似检测)
- 调试工具:
JSON.stringify()、charCodeAt()、十六进制编辑
对于用户名字段等与身份相关的数据,始终在保存前进行零宽字符清理,这是一条值得遵守的安全规范。