实体解码的边界
HTML 实体解码不是一次性的字符串替换,它由 HTML 解析器在词法阶段完成,并带有「无分号命名实体」「无效码位回退」等规定动作。多数解码 bug 的根源是把解码当成普通的查找替换,忽略了分号、上下文与实体表的覆盖范围。解码只应发生在数据读取的那一刻,并且只做一次。
基本信息
| 实体形式 | &name; 命名实体、&#十进制; 与 &#x十六进制; 数字实体 |
|---|---|
| 分号规则 | XML/XHTML 必须带分号;HTML 对历史命名实体允许缺分号,但后接字母数字或 = 时不生效 |
| 覆盖面 | HTML5 命名实体表条目规模上千,远大于 XML 的 5 个预定义实体 |
| 常见实现 | JS 的 DOMParser 或 he 库、Python html.unescape、Go html.UnescapeString |
| 方向 | 写入 HTML 前编码,读取时解码,两者不可互换 |
典型使用场景
- 爬虫保存网页正文:把 &、中 还原成可读字符
- 富文本编辑器回显:先解码再交给编辑器,避免用户看到 < 字面量
- 日志与告警渲染:把接口返回的转义文本转成可读中文
- 反查乱码:页面显示 &nbsp; 说明被编码了两次
- 数据迁移:从 HTML 备份里还原字段原文
可靠解码与校验
可靠解码与校验
// 交给浏览器解析器解码,不写 replace 链
const doc = new DOMParser().parseFromString(src, 'text/html');
const text = doc.documentElement.textContent; // 已解码的纯文本
// 解码前先看有没有二次编码痕迹
const twice = /&(#\d+|#x[0-9a-f]+|[a-z]+);?/i.test(src);
// 反向操作:输出前按上下文编码
const enc = s => s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');高频陷阱对照
| 现象 | 原因 | 规避 |
|---|---|---|
| 二次解码后冒出标签 | 上游已解一次,本地又解一次,&lt; 变成 < | 解码前检测 & 痕迹,约定只在一层解码 |
| € 显示成 € | HTML 解析器把 0x80-0x9F 数字实体按 Windows-1252 映射 | 不要用数字实体表示 C1 控制区 |
| � 变� | 码位越界,解析器回退到 U+FFFD | 解码后校验码位范围 |
| 属性里 " 撑破结构 | 属性值中同样会做实体解码 | 写入属性前按属性上下文单独编码 |
|   正常但 XML 报错 | 缺分号,XML 不认 | XML/XHTML 里一律补分号 |
解码方向搞反就是 XSS 入口:把用户输入先解码再拼进 HTML,<script> 这类编码载荷会还原成真实标签。解码只用于读取文本,输出必须按新上下文重新编码。
Python 的 html.unescape 与 Go 的 html.UnescapeString 都比较宽松,缺分号的命名实体会被一并解码;要与浏览器行为严格一致时,宁可用浏览器解析器,并以 WHATWG HTML 标准的实体表为准。