线上出了个怪问题:用户在备注里写了 a < b 时报警,保存后页面只显示了 a。排查一圈发现不是没转义,是转义做了两次——前端转一次,后端模板又转一次,最终变成了 &amp;lt;b,浏览器再解析一遍就老老实实显示成纯文本。

说这件事是想先把一个常见误解纠正掉:HTML 转义不是「把危险字符变成安全字符」这么简单,它跟输出位置强相关。同一段文本放在正文里、属性里、JS 字符串里、URL 里,需要的转义规则完全不同。

命名实体、十进制、十六进制,到底选哪个

三种写法在浏览器里的显示结果完全一样,都是字符 <:

写法 形式 特点
&lt; 命名实体 可读性最好,肉眼能认出是什么
&#60; 十进制数字实体 不依赖实体表,ASCII 码直接查
&#x3C; 十六进制数字实体 更短,和 Unicode 码点书写一致

实测三种写法塞进 HTML 后渲染结果完全一致,DOM 里取到的都是 <。

那该怎么选:

  • 日常业务代码:用命名实体 &lt; &gt; &amp; &quot;。可读性最重要,同事 review 时一眼能看懂。
  • 必须兼容 XML 的场景:XML 只认 5 个命名实体(&lt; &gt; &amp; &quot; &apos;),其他实体在 XML 里会直接解析失败。要么只用这 5 个,要么用数字实体。
  • 生成的 HTML 要过老系统或邮件客户端:数字实体更稳,不依赖对方的实体表实现。
  • 本来就需要数字实体:中文转 &#20013; 这类,只能是十进制或十六进制。

有一点容易被忽略:HTML5 允许省略分号(&lt 也能解析),但 XML 和大部分解析器要求必须有分号。所以自己拼字符串时一律带分号,别省。

最容易踩的坑:转义顺序写反,变成二次转义

这是线上 bug 的第一大来源。正确顺序是先转 &,再转其他:

function escapeHtml(str) {
  return str
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

如果顺序反过来,先转 < 得到 &lt;,再转 & 时会把刚生成的 & 又变成 &amp;,结果就成了 &amp;lt;——页面上显示 &lt; 而不是 <。

实测对比(Node 18):

输入 顺序正确 顺序错误(先 < 后 &)
a < b & c a &lt; b &amp; c a &amp;lt; b &amp; c

第二列是典型的「转义了但显示不对」现象,看起来很像转义失效,实际是转义过头。

还有一个更隐蔽的版本:前端转一次存进数据库,后端渲染时模板引擎又自动转一次。很多模板引擎默认开启自动转义,你手动转过的内容它还会再转一遍。解决办法二选一:

  • 存库时存原文,渲染时统一由模板转义
  • 存库时存转义结果,渲染时明确标记为 safe(各框架写法不同,Thymeleaf 用 th:utext,Vue 用 v-html)

两种都要坚持,混着用必然出二次转义。

只转 < 和 > 远远不够

不少老代码只做这两个,下面这些场景照样出事:

1. 属性值里的引号

<input value="...">

如果值里含 ",不转义就会提前闭合属性:

value="x" onmouseover="alert(1)"

实测:把 Tom & "Jerry" 直接拼进双引号属性,DOM 解析后多出一个 onmouseover 属性,这就是标准的属性逃逸。所以属性值必须转义 " 为 &quot;,用单引号属性则要转义 &#39;。

2. 属性值没加引号

<div class=box>

这种写法连转义都救不了,空格就能断出新属性。属性值一律加引号,这是前提。

3. 正文里的单引号

业务上常把它转成 &#39;。注意 &apos; 虽然也是标准实体,但老版本 IE 不认,所以统一写 &#39; 更省心。

4. 中文编码问题

&#20013; 这种写法只在字符集不可控的地方有意义(邮件正文、老旧接口、某些短信通道)。现代 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 转义。

一句话总结:先判断输出位置,再选转义方案。位置分不清,转义再勤快也白搭。

反转义:顺序同样不能反

反转义的坑跟转义是对称的。正确做法是一次正则匹配,或者最后才处理 &amp;:

function unescapeHtml(str) {
  return str.replace(/&(lt|gt|amp|quot|#39|#\d+|#x[\da-fA-F]+);/g, function (m) {
    var map = { "&lt;": "<", "&gt;": ">", "&amp;": "&", "&quot;": "\"", "&#39;": "'" };
    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(/&amp;/g, "&") 放在最前面,&amp;lt; 会先变成 &lt;,后面再被还原成 <——明明该显示 &lt; 字面量,结果显示成了 <,又是一次过还原。

实测两组:

输入 正确结果 过早还原 &amp; 的结果
&lt;div&gt; <div> <div>
&amp;lt;div&amp;gt; &lt;div&gt; <div>

第二行就是区别所在:前者是「用户真的输入了实体字面量」,后者是双重编码。数据里两种都可能存在,顺序错了就分不清。

实测清单

在本机用 Node 18 跑了一遍转义和反转义,覆盖到的组合:

输入 转义结果 再反转义
5 < 7 && x != null 5 &lt; 7 &amp;&amp; x != null 原样还原
Tom & "Jerry" Tom &amp; &quot;Jerry&quot; 原样还原
中文测试 &#20013;&#25991;&#27979;&#35797;(开全量编码时) 原样还原
&amp;lt; &amp;amp;lt; &amp;lt;

最后一行是重点:已经转义过的内容再转一次,任何实现都救不回来。所以入口处统一做,别在每个出口各做一遍。

需要临时转一段文本核对结果,可以直接用 HTML 转义工具,支持正转、反转,以及「是否转义引号」「是否全量编码非 ASCII」两个开关,纯前端处理不走服务器。