D
开发工具箱

HTML 实体编码:防止 XSS 的第一道防线

编码解码 2026年6月15日 约 1 分钟阅读

什么是 HTML 实体编码?

简单说,HTML 实体编码就是把那些在 HTML 里有特殊含义的字符,换成浏览器不会误解析的替代写法。

你写前端的时候肯定遇到过这种情况:想在页面上显示一段代码片段 <div>Hello</div>,结果浏览器把它当成了真正的 HTML 标签渲染了,页面上直接冒出个 div 来。这不是浏览器的 bug——<> 在 HTML 里天生就是标签的边界标记。要让它老老实实显示成文本,就得用实体编码:&lt;div&gt;Hello&lt;/div&gt;

说白了,HTML 实体编码就是给特殊字符贴一张”我是普通文本,别解析我”的标签。

这东西的历史比 Web 还老。实体引用(entity reference)的概念来自 SGML——HTML 的祖先标准,1986 年就定下来了。三十多年过去了,浏览器换了一代又一代,但这个 &...; 的转义格式几乎没变过。正是因为它太基础、太稳定,很多开发者反而忽略了它背后的机制。

HTML 实体编码的工作原理

实体编码的格式非常简单:以 & 开头,以 ; 结尾,中间是实体名称或编号。

三种写法

同一个字符有三种写法:

&名字;       ← 命名实体,如 &lt;
&#十进制;    ← 十进制数字实体,如 &#60;
&#x十六进制; ← 十六进制数字实体,如 &#x3C;

三种写法都表示 < 这个字符,效果完全一样。命名实体好记,数字实体覆盖面广——Unicode 的所有字符都能用数字实体表示,但只有大约两千多个常用字符有命名实体。

关键字符对照表

字符名称命名实体数字实体含义
<lt (less than)&lt;&#60;小于号 / 标签开始
>gt (greater than)&gt;&#62;大于号 / 标签结束
&amp (ampersand)&amp;&#38;实体引用的起始符
"quot (quotation)&quot;&#34;双引号 / 属性值边界
'apos (apostrophe)&apos;&#39;单引号(HTML5 新增命名实体)
nbsp (non-breaking space)&nbsp;&#160;不换行空格

特别注意 &amp;——& 本身是实体编码的起始符号,如果你要在 HTML 里显示一个 & 字符,不编码的话浏览器会以为你要开启一个新的实体引用,轻则显示错误,重则整个页面排版乱掉。

浏览器解码过程

浏览器解析 HTML 时,解码实体有一套严格的优先级:

  1. 先扫描 &,标记为”可能的实体开始”
  2. 向后读,直到遇到 ;,中间这段就是实体内容
  3. 匹配命名实体表 → 命中则替换为对应字符
  4. 如果是 # 开头 → 按十进制或十六进制解析 Unicode 码点
  5. 匹配不到?回退,把 &... 当普通文本显示

我之前在做一个富文本编辑器项目时就栽在这一步上——用户输入了 &amp;lt; 这样的字符串,我拿正则一顿替换,结果二次编码变成了 &amp;amp;lt;,页面上直接显示出了一堆乱码。教训是:实体编码和解码必须严格配对,一次编码对应一次解码,绝不能反复编解码

核心特性

特性说明
转义范围最少 4 个(< > & "),最保险 5 个(加上 '
命名实体数量HTML5 定义了约 2200 个命名实体
数字实体覆盖全部 Unicode 字符(0 到 0x10FFFF)
大小写敏感命名实体区分大小写,&LT; 无效
上下文相关HTML 内容、属性值、JavaScript、CSS 各有不同的转义规则
不可逆损失编码和解码完全可逆,无信息丢失

有个细节值得一说:数字实体中的十六进制写法对大小写不敏感——&#x3C;&#x3c; 等价。但命名实体不行,&lt; 能认,&LT; 就直接当普通字符串显示了。这算是一个新手高频掉坑点。

实际应用场景

1. XSS 防御的第一道墙

跨站脚本攻击(XSS)的核心手段就是在页面里注入 <script> 标签或事件处理器。如果你把用户输入原样拼进 HTML,等于给了攻击者一把钥匙。

正确的做法是——输出到 HTML 内容之前做实体编码:

function escapeHtml(str) {
  const map = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;'
  };
  return str.replace(/[&<>"']/g, c => map[c]);
}

<script>alert('xss')</script> 被编码之后变成 &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;——浏览器看到这个只会原样显示文本,不会去执行脚本。不过话说回来,光靠实体编码防 XSS 是不够的。输出到不同上下文(JavaScript 代码块、CSS、URL)需要不同的转义策略,这是另一个坑了。

2. 富文本编辑器的内容清洗

富文本编辑器里,用户输入的是 HTML 片段,但你不能让任意标签生效。像 <script><iframe> 这些必须干掉。

常见的策略是:先白名单过滤标签,再把属性值里的特殊字符实体编码。我之前踩过一个坑——onmouseover 这类事件属性藏得很深,单纯做标签过滤根本挡不住,必须同时对属性值做实体转义,双管齐下。

3. 代码高亮与文档展示

所有代码高亮库(Prism、highlight.js、Shiki)的底层都有一道 HTML 实体编码。代码里的 <>& 必须先编码才能安全地嵌入到 <pre><code> 块里,否则 <template> 这样的标签会把页面结构搞乱。

4. 邮件 HTML 模板

邮件客户端对 HTML 的解析普遍比浏览器严格得多。你会发现同样的 HTML 在浏览器里跑得好好好的,塞到邮件里就挂了。很多时候问题就出在没有严格编码特殊字符——特别是 & 符号,在邮件模板参数的拼接位置尤其容易闯祸。我用 SendGrid 做邮件模板时养成了一个习惯:所有变量输出到 HTML 位置前,一律先跑一遍实体编码函数,这比事后排错省心多了。

5. XML 和 RSS

XML 规范只预定义了 5 个实体:&lt; &gt; &amp; &quot; &apos;。如果你在 XML 文档里用 &nbsp;,解析器会直接报错——XML 不认识 HTML 的那些命名实体,除非你在 DTD 里显式声明了。这点在处理 RSS Feed 生成时特别要注意,RSS 本质上是 XML,不能用 HTML 的实体集合。

常见误区

误区一:编码一遍就万事大吉

这是我见过最多的坑。不是说编码一次不对,而是很多人搞不清楚什么时候该编码、什么时候不该。数据从数据库到后端模板、再到前端渲染、最后到 DOM 更新,中间可能经过四五层。每跨一层边界,实体编码都要重新审视。最常见的问题是”双重编码”——数据进来编一次,存到数据库又编一次,输出时模板引擎再编一次,最后页面上全是 &amp;lt; 这样的乱码。

说实话,我在一个 Node.js 项目里排查这种问题时,打了几十个 console.log 才定位到是 Handlebars 模板自动转义和手动 escapeHtml 干了两遍同样的事。后来统一把模板引擎的自动转义关了,全部在后端手工编码,才彻底解决了。

误区二:实体编码等于安全编码

实体编码只保护 HTML 内容上下文。如果你的用户数据要被填入 <a href="..."><script>...</script><style>...</style> 或者 onclick="..." 里,单独的 HTML 实体编码远远不够。这些位置有自己的语法和转义规则。比如在 JavaScript 字符串里,你需要的是 \x3C 而不是 &lt;

误区三:所有实体浏览器都认识

HTML5 定义的 2200 多个命名实体,主流浏览器全支持。但如果你去翻一个老旧的邮件客户端,或者某些嵌入式设备的浏览器,&geq;(≥)它可能就不认识。保险起见,用数字实体 &#8805; 永远比命名实体更可靠——数字实体不依赖命名表,只依赖 Unicode 码点映射。

误区四:前端框架不需要关心实体编码

React 的 JSX 确实会把 {userInput} 自动转义,Vue 的 {{ }} 也一样。但如果你用了 dangerouslySetInnerHTML 或者 Vue 的 v-html,框架就把手放开了——你塞什么它渲染什么。这些 API 不是不能用,而是用之前你必须确认内容是可信的、已经清洗过的,否则就是把安全门自己拆了。

实体编码 vs URL 编码 vs Unicode 转义

HTML 实体编码URL 编码Unicode 转义
目标上下文HTML 文档内容URL 查询参数/路径字符串字面量(JS/CSS)
格式&name; &#N;%XX(百分号+十六进制)\uXXXX \UXXXXXXXX
转义范围< > & " '非 ASCII + 保留字符所有 Unicode 字符
可读性较高(命名实体直观)
典型场景XSS 防御、代码展示GET 参数、路径片段JS/CSS 源码

三种编码解决的是三种不同的问题。把 HTML 实体编码用在 URL 参数里是没用的——&lt; 里的 & 会被 URL 解析器当成参数分隔符。反过来,把 %3C 直接塞到 HTML 里,浏览器也只会显示一个百分号加数字,不会当标签解析。

我之前做过一个搜索页面,需要把用户的搜索词既显示在页面上(用 HTML 实体编码)又回传到搜索框的 URL 参数里(用 URL 编码),还得在 JavaScript 里处理(用 Unicode 转义)。同一个数据三段不同的编码方式,头一次写的时候完全搞混了。

常见问题

Q: 实体编码最少要转义哪几个字符?

严格来说,最少转义 <>&" 四个。但如果你在单引号包裹的属性值里输出变量,' 也得转。保守方案永远是一口气把 & < > " ' 五个全转义掉,四个和五个之间的性能差距小到可以忽略,但少转一个就可能被利用。

Q: &nbsp; 和普通空格有什么区别?

普通空格在浏览器里可以换行折断,&nbsp;(non-breaking space,不换行空格)会阻止浏览器在它所在的位置断行。所以两个词之间用 &nbsp; 连接,它们永远在同一行。这在排版中文和数字、英文混排时特别有用——比如”iPhone 15”中间用 &nbsp; 就不会被断成两行。

Q: 为什么有时候在 HTML 里直接用 < 也不会出错?

浏览器的 HTML 解析器有强大的纠错机制。大部分情况下,如果你写的是 2 < 3 而不是 <script>,浏览器能根据上下文判断这个 < 不是标签开始符。但这是在依赖浏览器的容错能力——你用 W3C 验证器跑一遍保证报一堆错。生产代码不要赌浏览器的纠错逻辑,老老实实写 &lt;

Q: HTML 实体编码和 XSS 防御是什么关系?

实体编码是 XSS 防御体系里最基础的一环,但不是全部。完整的 XSS 防御至少包括:实体编码(HTML 上下文)+ 属性加引号并编码(属性上下文)+ JavaScript 用 \x 转义(JS 上下文)+ CSS 用 \ 转义(CSS 上下文)+ URL 用百分号编码(URL 上下文)。说白了,就是”到什么山上唱什么歌”。

Q: Markdown 里需要用 HTML 实体编码吗?

Markdown 本身允许内嵌 HTML,如果你在 .md 文件里写 <div>,大部分 Markdown 解析器会原样输出给浏览器,浏览器就把它当标签渲染了。所以如果你在 Markdown 里展示 HTML 代码片段,推荐用反引号包裹(`<div>`),解析器会把代码块内容自动做实体编码。如果直接在正文里写,手动用 &lt;div&gt; 会更保险。