线上出了个怪问题:用户在备注里写了 a < b 时报警,保存后页面只显示了 a。排查一圈发现不是没转义,是转义做了两次——前端转一次,后端模板又转一次,最终变成了 &lt;b,浏览器再解析一遍就老老实实显示成纯文本。
说这件事是想先把一个常见误解纠正掉:HTML 转义不是「把危险字符变成安全字符」这么简单,它跟输出位置强相关。同一段文本放在正文里、属性里、JS 字符串里、URL 里,需要的转义规则完全不同。
命名实体、十进制、十六进制,到底选哪个
三种写法在浏览器里的显示结果完全一样,都是字符 <:
| 写法 | 形式 | 特点 |
|---|---|---|
< |
命名实体 | 可读性最好,肉眼能认出是什么 |
< |
十进制数字实体 | 不依赖实体表,ASCII 码直接查 |
< |
十六进制数字实体 | 更短,和 Unicode 码点书写一致 |
实测三种写法塞进 HTML 后渲染结果完全一致,DOM 里取到的都是 <。
那该怎么选:
- 日常业务代码:用命名实体
<>&"。可读性最重要,同事 review 时一眼能看懂。 - 必须兼容 XML 的场景:XML 只认 5 个命名实体(
<>&"'),其他实体在 XML 里会直接解析失败。要么只用这 5 个,要么用数字实体。 - 生成的 HTML 要过老系统或邮件客户端:数字实体更稳,不依赖对方的实体表实现。
- 本来就需要数字实体:中文转
中这类,只能是十进制或十六进制。
有一点容易被忽略:HTML5 允许省略分号(< 也能解析),但 XML 和大部分解析器要求必须有分号。所以自己拼字符串时一律带分号,别省。
最容易踩的坑:转义顺序写反,变成二次转义
这是线上 bug 的第一大来源。正确顺序是先转 &,再转其他:
function escapeHtml(str) {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
如果顺序反过来,先转 < 得到 <,再转 & 时会把刚生成的 & 又变成 &,结果就成了 &lt;——页面上显示 < 而不是 <。
实测对比(Node 18):
| 输入 | 顺序正确 | 顺序错误(先 < 后 &) |
|---|---|---|
a < b & c |
a < b & c |
a &lt; b & c |
第二列是典型的「转义了但显示不对」现象,看起来很像转义失效,实际是转义过头。
还有一个更隐蔽的版本:前端转一次存进数据库,后端渲染时模板引擎又自动转一次。很多模板引擎默认开启自动转义,你手动转过的内容它还会再转一遍。解决办法二选一:
- 存库时存原文,渲染时统一由模板转义
- 存库时存转义结果,渲染时明确标记为 safe(各框架写法不同,Thymeleaf 用
th:utext,Vue 用v-html)
两种都要坚持,混着用必然出二次转义。
只转 < 和 > 远远不够
不少老代码只做这两个,下面这些场景照样出事:
1. 属性值里的引号
<input value="...">
如果值里含 ",不转义就会提前闭合属性:
value="x" onmouseover="alert(1)"
实测:把 Tom & "Jerry" 直接拼进双引号属性,DOM 解析后多出一个 onmouseover 属性,这就是标准的属性逃逸。所以属性值必须转义 " 为 ",用单引号属性则要转义 '。
2. 属性值没加引号
<div class=box>
这种写法连转义都救不了,空格就能断出新属性。属性值一律加引号,这是前提。
3. 正文里的单引号
业务上常把它转成 '。注意 ' 虽然也是标准实体,但老版本 IE 不认,所以统一写 ' 更省心。
4. 中文编码问题
中 这种写法只在字符集不可控的地方有意义(邮件正文、老旧接口、某些短信通道)。现代 UTF-8 页面完全不需要——转了只会让源码变长、可读性变差。
XSS 防护的真实边界:转义管不到的地方
这一节是最容易产生安全感的错觉。HTML 转义只能防「字符被当成标签解析」,防不了下面这些:
1. URL 里的协议
<a href="javascript:alert(1)">点我</a>
这里的 javascript: 全是普通字母,没有任何需要转义的字符。转义对它完全无效。必须做协议白名单:只允许 http、https、mailto,其他一律拒绝或加 #。
2. 富文本
用户要发带格式的内容,你不能把 <b> 也转掉。这时的正确做法不是「转义」,而是白名单过滤标签和属性(服务端用成熟库,别自己写正则)。
3. JS 上下文
<script>var msg = "用户输入";</script>
如果用户输入里含 "; alert(1);,转义 HTML 字符毫无作用,因为这里不是 HTML 上下文。要么用 JSON 序列化后再转义 < /,要么别把数据往内联脚本里塞。
4. 内嵌 JSON 里的 </script>
<script>var data = {"name": "用户输入"};</script>
只要用户输入里出现 </script>,页面就被截断。这时需要把 < 转成 \u003c(JSON 转义),而不是 HTML 转义。
一句话总结:先判断输出位置,再选转义方案。位置分不清,转义再勤快也白搭。
反转义:顺序同样不能反
反转义的坑跟转义是对称的。正确做法是一次正则匹配,或者最后才处理 &:
function unescapeHtml(str) {
return str.replace(/&(lt|gt|amp|quot|#39|#\d+|#x[\da-fA-F]+);/g, function (m) {
var map = { "<": "<", ">": ">", "&": "&", """: "\"", "'": "'" };
if (map[m]) return map[m];
if (m.indexOf("&#x") === 0) return String.fromCodePoint(parseInt(m.slice(3, -1), 16));
return String.fromCodePoint(parseInt(m.slice(2, -1), 10));
});
}
如果写成链式 .replace(/&/g, "&") 放在最前面,&lt; 会先变成 <,后面再被还原成 <——明明该显示 < 字面量,结果显示成了 <,又是一次过还原。
实测两组:
| 输入 | 正确结果 | 过早还原 & 的结果 |
|---|---|---|
<div> |
<div> |
<div> |
&lt;div&gt; |
<div> |
<div> |
第二行就是区别所在:前者是「用户真的输入了实体字面量」,后者是双重编码。数据里两种都可能存在,顺序错了就分不清。
实测清单
在本机用 Node 18 跑了一遍转义和反转义,覆盖到的组合:
| 输入 | 转义结果 | 再反转义 |
|---|---|---|
5 < 7 && x != null |
5 < 7 && x != null |
原样还原 |
Tom & "Jerry" |
Tom & "Jerry" |
原样还原 |
中文测试 |
中文测试(开全量编码时) |
原样还原 |
&lt; |
&amp;lt; |
&lt; |
最后一行是重点:已经转义过的内容再转一次,任何实现都救不回来。所以入口处统一做,别在每个出口各做一遍。
需要临时转一段文本核对结果,可以直接用 HTML 转义工具,支持正转、反转,以及「是否转义引号」「是否全量编码非 ASCII」两个开关,纯前端处理不走服务器。