零宽字符:看不见的Unicode字符与前端坑点

2022-06-15 前端

你有没有遇到过这样的情况:一段文字看起来完全一样,但程序判断却不相等?或者数据库中有一条用户记录,但无论如何都查不到?这很可能就是零宽字符(Zero-Width Characters)在作怪。这些看不见的 Unicode 字符,是前端和后端开发中的隐形地雷。

什么是零宽字符?

零宽字符是一类不占用可见宽度的特殊 Unicode 字符。它们在屏幕上不可见,但在字符串中确实存在,可以通过 length 属性或调试工具看到它们的存在。

字符串 "abc"  vs  "a​b‌c"  (后者插入了零宽字符)

屏幕上看起来:  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!第一个字符是 BOM

5. 左/右方向标记(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 = 'ali‌ce';
-- 查不到!因为 'ali‌ce' 包含零宽字符

问题三: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(但实际上是不同的字符串)

实际危害

  1. 钓鱼攻击:用户 admin​ 可以冒充 admin
  2. 绕过唯一性检查:注册系统只检查可见字符,不检查零宽字符
  3. 社交工程:假账号看起来和真账号一样
  4. 日志污染:安全审计时难以区分真假用户

防御方案

// 方案 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 技巧

  1. 查看字符串的实际内容:在 Console 中用 JSON.stringify() 可以看到转义字符

    console.log(JSON.stringify('ali\u200Bce'));
    // "ali\u200bce"  ← 可以看到转义表示
  2. 复制粘贴到十六进制编辑器:可以看到实际字节

  3. 使用 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 编码

总结

零宽字符是前端开发中的隐形陷阱:

  1. 看不见但存在:length 增加,字符串比较失败
  2. 来源多样:复制粘贴、文件 BOM、输入法、恶意攻击
  3. 防御策略:
    • 输入时清理(注册、表单提交)
    • 存储前规范化(数据库层)
    • 显示时警惕(用户名相似检测)
  4. 调试工具:JSON.stringify()、charCodeAt()、十六进制编辑

对于用户名字段等与身份相关的数据,始终在保存前进行零宽字符清理,这是一条值得遵守的安全规范。

扩展阅读