Unicode 陷阱:同段代码在不同浏览器「忽对忽错」
不要依赖浏览器会不会放行,而是让它根本见不到这种字符。
一个真实的坑
一段再普通不过的代码,换个浏览器就「忽对忽错」:
// 注意:这里面的横杠是 U+2011(非断行连字符),不是普通减号
const badAttr = "destroy‑on-close";
const div = document.createElement('div');
try {
div.setAttribute(badAttr, "");
console.log("当前浏览器:容错放行");
} catch (e) {
console.error("当前浏览器抛出异常:", e);
}
在部分浏览器里它静默放行,在另一部分(或旧版本)里直接抛 InvalidCharacterError。同一段代码,行为完全取决于跑在哪个引擎上。
现象本质:规范与实现的分歧
setAttribute 的规范只拒绝 ASCII 空白、<>"'/= 等非法字符,U+2011 按规范应当合法。但某些引擎的字符校验实现更严格,或存在 bug,把「视觉上像 - 的 Unicode 字符」也判为非法。
这正是 interop divergence(实现漂移):不能依赖浏览器的宽容行为。宽容是引擎的实现细节,永远不保证一致。
这一整类问题的全集
这类 bug 不是孤例,而是一个家族。它们的共同点:视觉 ≈ ASCII 对应字符,字节却完全不同。
- 不可见 / 相似字符污染:U+00A0 不断行空格、U+200B 零宽空格(完全不可见)、U+00AD 软连字符、U+FEFF BOM、U+2013 / U+2014 en/em dash、U+FF0D 全角连字符
- 输入链带入:复制粘贴(网页 / Word / PDF / 微信)、编辑器自动替换(智能引号、自动连字符)、OCR 与翻译输出——不是程序员主动输入,而是从「外部内容」静默带入
- 规范执行漂移:同一 DOM API 在不同引擎 / 版本校验严格度不同;HTML parser(宽容)与 DOM API(严格)可能不一致
- 序列化 / 往返不一致:
setAttribute设置后,经innerHTML序列化、getAttribute读取、querySelector选择器匹配时表现可能不同
三层防线
1. 规范定标准(最有效)
- HTML attribute / class / id 名只允许 ASCII 小写字母数字 +
-(W3C 本有data-*小写 ASCII 约定) - 编码规范写死:「标识符与属性名禁止非 ASCII」,配正则
^[a-zA-Z0-9][a-zA-Z0-9-_.]*$ - 文本内容遵循 UAX #15 做 NFC 规范化;关键字段做映射表(U+2011→U+002D、U+00A0→U+0020)
- 输入统一 UTF-8,杜绝 GBK / UTF-8 混用
2. 工具拦截(机器兜底)
| 层 | 工具 | 作用 |
|---|---|---|
| lint | ESLint id-match + 禁止非 ASCII 正则 | 提交前拦命名 |
| 格式化 | Prettier | 统一空白 / 引号,消除编辑器隐形替换 |
| 编码检测 | chardet、Python unicodedata、xxd | 定位隐藏字符来源 |
| sanitize | DOMPurify | 收口用户输入 |
| 测试 | Playwright 多浏览器矩阵 | 专测边界字符 interop |
| 扫描 | 仓库级非 ASCII 可疑字符扫描脚本 | 存量体检 |
3. AI 前置(让 bug 不诞生)
- 规则注入:在 AGENTS.md / 编码规范写「属性名只允许 ASCII」——agent 生成代码时主动遵守,bug 根本不产生(即「上下文外移」)
- AI review:让 AI 审查 diff 时专门检查隐藏字符(零宽空格、U+2011、全角标点)
- 边界测试:针对
setAttribute、querySelector生成 Unicode 边界用例矩阵
关键洞察:AI 是这类问题的解药
AI 在这类问题上是双重角色:既是潜在来源,也是最好用的预防工具。一个值得注意的细节:这类 bug 大多由人从网页 / 文档复制粘贴带入,而 agent 是纯文本管道,反而不会自然带入隐形字符——除非喂给它的内容里本来就有。所以规则注入后,agent 生成的代码在此类问题上天然更干净。
一句话
不要依赖「浏览器会不会放行」,而是让它根本见不到这种字符。 用 ASCII 白名单而非黑名单,在源头拒绝非 ASCII,而不是逐个字符堵漏——这样下次换一个字符,也不会再爆。