意图升值

我的博客

AI 时代工程师的稀缺资产:定义可验证的意图。

方法 · 2026-08-18

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. 工具拦截(机器兜底)

工具作用
lintESLint id-match + 禁止非 ASCII 正则提交前拦命名
格式化Prettier统一空白 / 引号,消除编辑器隐形替换
编码检测chardet、Python unicodedataxxd定位隐藏字符来源
sanitizeDOMPurify收口用户输入
测试Playwright 多浏览器矩阵专测边界字符 interop
扫描仓库级非 ASCII 可疑字符扫描脚本存量体检

3. AI 前置(让 bug 不诞生)

  • 规则注入:在 AGENTS.md / 编码规范写「属性名只允许 ASCII」——agent 生成代码时主动遵守,bug 根本不产生(即「上下文外移」)
  • AI review:让 AI 审查 diff 时专门检查隐藏字符(零宽空格、U+2011、全角标点)
  • 边界测试:针对 setAttributequerySelector 生成 Unicode 边界用例矩阵

关键洞察:AI 是这类问题的解药

AI 在这类问题上是双重角色:既是潜在来源,也是最好用的预防工具。一个值得注意的细节:这类 bug 大多由人从网页 / 文档复制粘贴带入,而 agent 是纯文本管道,反而不会自然带入隐形字符——除非喂给它的内容里本来就有。所以规则注入后,agent 生成的代码在此类问题上天然更干净。

一句话

不要依赖「浏览器会不会放行」,而是让它根本见不到这种字符。 用 ASCII 白名单而非黑名单,在源头拒绝非 ASCII,而不是逐个字符堵漏——这样下次换一个字符,也不会再爆。