HTML实体编码

把 & < > " ' 五个保留字符转成命名实体或数字实体,并按文本节点、双引号属性、单引号属性三种上下文给出不同结果。反向解码一次正则回调完成,遇到代理区码位或超出 U+10FFFF 一律按标准替换成 U+FFFD,不会抛异常。

示例含全部五个保留字符,共 54 个码点、62 个 UTF-8 字节。按 Ctrl + Enter(macOS 用 Cmd + Enter)触发,Enter 留给换行。
输入码点54
输入 UTF-8 字节62
输出码点104
膨胀倍数1.93 倍

上面四格是默认示例(文本上下文之外选「全部五字符 / 命名实体」)的服务端直出值,点任意按钮后会随输入重算。

使用方法

  1. 页面打开时输入框里已经有一段示例:<a href="/x?a=1&b=2" title='语文 & 数学'>5 > 3 & 2 < 4</a>,共 54 个码点、62 个 UTF-8 字节,五个保留字符一个不少。
  2. 选「上下文」。文本节点只转 & < > 三个;双引号包住的属性值要多转 ",单引号包住的属性值要多转 ';不确定就选「全部五字符」,代价只是输出略长。
  3. 选「实体写法」。命名实体(&lt;)源码可读性最好;十进制(<)兼容性最好;十六进制(<)与码位表能直接对上,排查时最省事。
  4. 点「开始编码 / 解码」,或把光标放在输入框里按 Ctrl + Enter(Enter 本身留给换行)。结果区给出输出文本与四个统计值;选「解码」时方向相反,会同步把命名实体、十进制、十六进制三种写法一起还原。
  5. 下方对照表默认显示 12 行,点「加载更多」看完全部 30 行;「导出 CSV」带 BOM,Excel 打开中文不乱码;「复制对照表」把整张表以 TSV 放进剪贴板。

这个HTML实体编码能解决什么问题

一是把用户输入或数据库取出的文本安全地放进网页。内容里只要出现一个未转义的 <,浏览器就会把它当成标签开始;一个未转义的 & 则会开启字符引用。OWASP 的 XSS 防护清单把 & < > " ' 五个字符列为必须编码的集合,HTML实体编码就是这件事最直接的实现。

二是区分「文本节点」和「属性值」两种上下文。同一个字符在不同位置的风险并不一样:双引号包住的属性值里要防 " 提前收尾,单引号包住的属性值里要防 '。很多在线工具只给一种全量模式,本页把四种上下文分开,同一段示例的输出长度分别是 84、94、94、104 个码点,差多少一目了然。

三是把不可见字符显示出来。零宽空格 U+200B、字节序标记 U+FEFF、不换行空格 U+00A0 在源码里肉眼看不见,实体化之后立刻可见,这是排查「两段文本看起来一模一样却不相等」的常规手段。

四是在命名实体与数字实体之间做正确取舍。带分号的命名实体只有 2125 条,覆盖不了全部 Unicode 字符;而数字实体 &#x...; 对任何合法码位都成立。本页因此把命名表之外的一切字符统一回落成数字实体,而不是留白、丢弃或报错。

本页不做这些事:不做 URL 编码——那套规则转义的是 ; , / ? : @ & = + $ # 而不是尖括号引号,要把参数编码进查询串请用URL编码;不做 Base64,那是二进制到文本的另一套映射,见Base64编码;也不剥离 HTML 标签,要删标签请用去除HTML标签。

原理与规则说明

HTML实体编码要处理的字符引用只有三种写法:命名引用 &name;、十进制数字引用 &#NN;、十六进制数字引用 &#xNN;。解析规则由 WHATWG 的 HTML Living Standard 定义,命名实体表就是标准附带的那份 entities.json。

先把命名实体的三个数字分清。entities.json 共 2231 条,其中 2125 条以分号结尾,另外 106 条是无分号的历史写法(如 &amp、&lt),解析器识别它们时会同时记一个 parse error。HTML 4.01 的 DTD 里只有 252 条命名实体,而且没有 &apos;——它来自 XML/SGML,是 HTML5 才补进 HTML 实体表的;要兼容老代码写 &#39; 更稳。所以「2231」不等于「2231 个字符可以取名字」:一个字符常有多个名字,绝大部分条目是带变音符的拉丁字母、数学符号与箭头,日常真正会用到的不过几十个。

五个字符必须转,理由各不相同。& 在任何位置都会开启字符引用;< 在文本里开启标签,在属性值里同样危险;> 单独出现其实不改变解析,但 OWASP 仍要求一并编码,因为属性与标签的边界都靠它判定;" 只在与属性分隔符同为双引号时才致命;' 同理。注意 > 的「不危险」是有条件的:一旦前面出现未闭合的 <,它就会参与构成标签,所以稳妥做法仍然是编码。

★ 转义顺序是这一页最重要的规则:& 必须最先替换。如果先把 < 写成 &lt;、回头再替换 &,刚写出来的那个 & 会被再编码一次。实测:输入 <b>,按正确顺序得到 &lt;b&gt; 共 9 个码点;把 & 挪到最后,输出变成 &amp;lt;b&amp;gt; 共 17 个码点,膨胀 1.89 倍,浏览器会把后者渲染成字面字符串 &lt;b&gt; 而不是 <b>。

双重编码是不可逆的。原文 AT&T 编码一次得 AT&amp;T,把结果再编码一次得 AT&amp;amp;T;这时解码一次只能回到 AT&amp;T,与原串并不相等。它是「多了一层 &」的累积,不是可以靠再解一次抹掉的临时状态,所以正确做法只有一条:编码前先确认输入是原文,而不是上一轮的输出。

解码侧的顺序正好相反。正确的解码器用一次正则回调把所有引用一次性替换——本页用的是 /&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g 配一个回调。这样 &amp;lt;(字面 6 个字符)会被还原成字面文本 &lt;,而不会继续被还原成 < 字符。如果分两步、先把 &amp; 解掉再解其它,&amp;lt; 就会先变成 &lt; 再变成 <,把一段本该显示为文本的内容还原成真标签——这正是二次解码型 XSS 的成因。

数字引用有明确的码位边界。Unicode 合法码位是 U+0000 到 U+10FFFF 共 1114112 个;代理区 U+D800 到 U+DFFF(十进制 55296 到 57343,共 2048 个码位)不许作为字符单独出现。HTML5 规定数值引用解析到代理区、或数值超过 0x10FFFF 时,一律替换成 U+FFFD 替换符而不是丢弃。实测:&#1114111; 正常解出 U+10FFFF,&#1114112; 与 &#99999999; 都变成 U+FFFD。这一条必须自己实现:String.fromCodePoint(1114112) 会直接抛 RangeError: Invalid code point 1114112,不做夹紧就是页面崩溃与否的分界。

浏览器没有内置的 HTML 转义函数。三个看起来像的东西都不是:encodeURIComponent("<>'"&") 实测得到 %3C%3E%22'%26,尖括号和引号原样保留,因为它只管 URL;JSON.stringify({a:"<&>"}) 只处理双引号,对 HTML 一无所知;TextEncoder 输出的是字节数组,< 是 60、& 是 38。所以本页自带一张 31 条命名实体映射表,表外字符统一走数字实体。

完整示例(数值仅作演示)

与页面默认值完全一致(HTML实体编码的默认示例、正文与对照表三处咬合):输入 <a href="/x?a=1&b=2" title='语文 & 数学'>5 > 3 & 2 < 4</a>(54 个码点),上下文选「全部五字符」,写法选「命名实体」。实测:

再单独看顺序陷阱:输入 <b>,正确顺序给出 &lt;b&gt; 共 9 码点,错误顺序给出 &amp;lt;b&amp;gt; 共 17 码点。另一个常被引用的例子是 a&b<c>(即 a&b<c>):正确结果 16 码点,错误结果 24 码点,恰好 1.50 倍。

输入范围与对照表

输入上限是 100000 个码点,输出上限 400000 个码点。上限不是性能问题——实测 20 万码点单次编码约 9 毫秒——而是结果框的渲染代价:整段文本每个保留字符最多膨胀 6 倍,塞进 textarea 会让滚动明显发涩。

上下文转义哪些字符默认示例输出码点适用位置
文本节点& < >84元素之间的纯文本、<title>、<textarea> 内
双引号属性值& < > "94href="..."、title="..." 这类属性
单引号属性值& < > '94HTML 里少用,但拼接模板时常出现
全部五字符& < > " '104不确定上下文时;同一份输出在两者都安全
解码—(三种写法一起还原)54把实体还原成可读字符,非法码位替换成 U+FFFD

下表是常用命名实体对照,共 30 行,默认显示前 12 行,点「加载更多」看全部。最后两行的字符没有命名实体,只能写数字实体,这正是本页把表外字符回落成数字实体的原因。

字符命名实体十进制十六进制码位说明
&&amp;&#38;&#x26;U+0026起始字符引用,必须最先转义
<&lt;&#60;&#x3C;U+003C标签开始;文本与属性值里都危险
>&gt;&#62;&#x3E;U+003E单独出现不改解析,OWASP 仍要求编码
"&quot;&#34;&#x22;U+0022结束双引号属性值
'&apos;&#39;&#x27;U+0027结束单引号属性值;HTML4 无 &apos;,写 &#39;
 &nbsp;&#160;&#xA0;U+00A0不换行空格;innerHTML 会把它序列化回 &nbsp;
©&copy;&#169;&#xA9;U+00A9版权符号
®&reg;&#174;&#xAE;U+00AE注册商标
™&trade;&#8482;&#x2122;U+2122商标符号
—&mdash;&#8212;&#x2014;U+2014em 破折号
–&ndash;&#8211;&#x2013;U+2013en 连接号
…&hellip;&#8230;&#x2026;U+2026省略号
“&ldquo;&#8220;&#x201C;U+201C左双引号
”&rdquo;&#8221;&#x201D;U+201D右双引号
×&times;&#215;&#xD7;U+00D7乘号
÷&divide;&#247;&#xF7;U+00F7除号
±&plusmn;&#177;&#xB1;U+00B1正负号
°&deg;&#176;&#xB0;U+00B0度
€&euro;&#8364;&#x20AC;U+20AC欧元
£&pound;&#163;&#xA3;U+00A3英镑
¥&yen;&#165;&#xA5;U+00A5日元 / 人民币
→&rarr;&#8594;&#x2192;U+2192右箭头
←&larr;&#8592;&#x2190;U+2190左箭头
∞&infin;&#8734;&#x221E;U+221E无穷
√&radic;&#8730;&#x221A;U+221A根号
π&pi;&#960;&#x3C0;U+03C0圆周率 π
α&alpha;&#945;&#x3B1;U+03B1希腊小写 alpha
Α&Alpha;&#913;&#x391;U+0391希腊大写 Alpha
​(无)&#8203;&#x200B;U+200B零宽空格,无命名实体,只能写数字实体
(无)&#65279;&#xFEFF;U+FEFF字节序标记,无命名实体,只能写数字实体

对照表 12 / 30 行

常见误用对照。

常见说法为什么不成立
「UTF-8 页面不用转义了,直接输出就行」字符编码和语法解析是两件事。UTF-8 只让汉字能原样存盘,< 与 & 仍然是语法字符,不转义就会被当标签或引用
「已经转过了,再转一次更安全」二次转义不可逆:AT&T 会变成 AT&amp;T,页面上直接显示多余的 &。安全的前提是只转一次
「用 encodeURIComponent 转一下就能防 XSS」它按 URL 规则转义,尖括号引号都留在原地,放进 HTML 依然能形成标签
「数字实体和命名实体效果一样,随便挑」显示效果一样,但命名实体只有 2125 条带分号的名字,表外字符必须用数字实体;另外 &#39; 比 &apos; 兼容性更好

输入范围、边界与精度说明

常见问题

为什么 & 必须第一个转?我把顺序写反过会怎样?

因为转义的产物本身就含 &。若先把 < 写成 &lt;,随后那次替换 & 的规则会把新产生的 & 也一起吃进去,得到 &amp;lt;。实测 <b> 会从 9 个码点涨到 17 个。更麻烦的是不可逆:一旦落在页面上,浏览器只会显示字面的 &lt;,你再解一次也回不到 < 这个字符。

CSS 和 JavaScript 里也要做 HTML 实体编码吗?

不需要,而且转错了反而会坏。&#39; 在 <style> 与 <script> 里不会被解码成引号,会原样留着,于是 CSS 属性值、JS 字符串全部语法错误。字符实体只在 HTML 的文本节点和属性值里生效;JS 字符串要转义反斜杠与引号,URL 要用 encodeURIComponent,CSS 要用反斜杠转义,三套规则互不通用。

已经编码过的文本再编码一次,怎么判断出来?

看有没有出现 &amp; 或 &lt; 这类「实体里还含实体」的形态。最简单的自查:把一段文本用本页编码一次,再用「解码」跑回来,如果能还原就说明只编码过一次;如果解一次后还剩 &amp;,说明已经双重编码,需要先解到干净原文再重新编码。

为什么 &apos; 有的地方不认?

因为它不是 HTML 4.01 的实体。&apos; 来自 XML/SGML,HTML5 才把它补进实体表,所以遇到早期的 HTML4 解析器、部分邮件客户端或旧的 RSS 阅读器时可能原样显示。要最大兼容就用十进制 &#39;,语义完全一样,且从 HTML 2 起就有效。

innerHTML 把属性序列化回来,为什么引号没被转义?

因为浏览器序列化时只为「必要」转义:文本和属性值里的 &、文本里的 < 与 >、以及属性值里的引号(且只在属性用双引号时),再加上不换行空格 U+00A0。它永远不会输出 &apos; 或 &nbsp; 这类别的命名实体,而是直接给原始字符。所以「从 DOM 读回来的 HTML」和「你当初写进去的源码」本来就不保证逐字节一致。

我把汉字也一起实体化了,会影响 SEO 吗?

会有影响,而且通常偏负面。把正文每个汉字写成数字实体会让 HTML 体积膨胀数倍,拖慢传输、也让源码难以维护;主流搜索引擎能正确解码,但页面的可读性和体积成本是实打实的。UTF-8 声明正确的前提下,正文里的汉字不需要实体化,真正必须转的始终只有那五个语法字符。

转义后的文本还能不能用浏览器搜索(Ctrl + F)?

能。浏览器是先解码再渲染的,页面上看到的是原始字符,Ctrl + F 匹配的也是解码之后的文本,与源码里写的是实体还是原字符无关。唯一的例外是你要找的内容本身含不可见字符(如 U+200B),这时先在输入框里把它实体化再比对更可靠。

输入 10 万个码点会卡吗?

不会。实测 20 万码点单次编码约 9 毫秒,线性复杂度,输入上限设 100000 是为了结果框的体验而不是算力:输出最多可能到 40 万个码点,一次性塞进 textarea 会让滚动明显发涩。要处理更大的文本,建议先在服务端或本地脚本里完成转换。

相关工具