使用方法
- 页面打开时输入框里已经有一段示例:
<a href="/x?a=1&b=2" title='语文 & 数学'>5 > 3 & 2 < 4</a>,共 54 个码点、62 个 UTF-8 字节,五个保留字符一个不少。 - 选「上下文」。文本节点只转
& < >三个;双引号包住的属性值要多转",单引号包住的属性值要多转';不确定就选「全部五字符」,代价只是输出略长。 - 选「实体写法」。命名实体(
<)源码可读性最好;十进制(<)兼容性最好;十六进制(<)与码位表能直接对上,排查时最省事。 - 点「开始编码 / 解码」,或把光标放在输入框里按 Ctrl + Enter(Enter 本身留给换行)。结果区给出输出文本与四个统计值;选「解码」时方向相反,会同步把命名实体、十进制、十六进制三种写法一起还原。
- 下方对照表默认显示 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 条是无分号的历史写法(如 &、<),解析器识别它们时会同时记一个 parse error。HTML 4.01 的 DTD 里只有 252 条命名实体,而且没有 '——它来自 XML/SGML,是 HTML5 才补进 HTML 实体表的;要兼容老代码写 ' 更稳。所以「2231」不等于「2231 个字符可以取名字」:一个字符常有多个名字,绝大部分条目是带变音符的拉丁字母、数学符号与箭头,日常真正会用到的不过几十个。
五个字符必须转,理由各不相同。& 在任何位置都会开启字符引用;< 在文本里开启标签,在属性值里同样危险;> 单独出现其实不改变解析,但 OWASP 仍要求一并编码,因为属性与标签的边界都靠它判定;" 只在与属性分隔符同为双引号时才致命;' 同理。注意 > 的「不危险」是有条件的:一旦前面出现未闭合的 <,它就会参与构成标签,所以稳妥做法仍然是编码。
★ 转义顺序是这一页最重要的规则:& 必须最先替换。如果先把 < 写成 <、回头再替换 &,刚写出来的那个 & 会被再编码一次。实测:输入 <b>,按正确顺序得到 <b> 共 9 个码点;把 & 挪到最后,输出变成 &lt;b&gt; 共 17 个码点,膨胀 1.89 倍,浏览器会把后者渲染成字面字符串 <b> 而不是 <b>。
双重编码是不可逆的。原文 AT&T 编码一次得 AT&T,把结果再编码一次得 AT&amp;T;这时解码一次只能回到 AT&T,与原串并不相等。它是「多了一层 &」的累积,不是可以靠再解一次抹掉的临时状态,所以正确做法只有一条:编码前先确认输入是原文,而不是上一轮的输出。
解码侧的顺序正好相反。正确的解码器用一次正则回调把所有引用一次性替换——本页用的是 /&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g 配一个回调。这样 &lt;(字面 6 个字符)会被还原成字面文本 <,而不会继续被还原成 < 字符。如果分两步、先把 & 解掉再解其它,&lt; 就会先变成 < 再变成 <,把一段本该显示为文本的内容还原成真标签——这正是二次解码型 XSS 的成因。
数字引用有明确的码位边界。Unicode 合法码位是 U+0000 到 U+10FFFF 共 1114112 个;代理区 U+D800 到 U+DFFF(十进制 55296 到 57343,共 2048 个码位)不许作为字符单独出现。HTML5 规定数值引用解析到代理区、或数值超过 0x10FFFF 时,一律替换成 U+FFFD 替换符而不是丢弃。实测: 正常解出 U+10FFFF,� 与 � 都变成 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 个码点),上下文选「全部五字符」,写法选「命名实体」。实测:
- 输出
<a href="/x?a=1&b=2" title='语文 & 数学'>5 > 3 & 2 < 4</a>,共 104 个码点,膨胀 1.93 倍; - 上下文从「全部五字符」降到「文本节点」,输出减到 84 个码点:示例里两个
"与两个'不再需要保护,省掉的正是 20 个码点; - 只选「双引号属性值」或「单引号属性值」各是 94 个码点——各多转两个同类引号(每个
"或'比原字符多 5 个码点,两个就是 10); - 写法换成十进制是 106 个码点,换成十六进制是 119 个码点。数字实体比命名实体长,但它对任何码位都成立,是命名表的完整兜底;
- 四个汉字「语文」「数学」一个都没被实体化:UTF-8 页面里汉字不需要转义,这一段膨胀是 1.00 倍,输入 54 码点里有 15 个是汉字。
再单独看顺序陷阱:输入 <b>,正确顺序给出 <b> 共 9 码点,错误顺序给出 &lt;b&gt; 共 17 码点。另一个常被引用的例子是 a&b<c>(即 a&b<c>):正确结果 16 码点,错误结果 24 码点,恰好 1.50 倍。
输入范围与对照表
输入上限是 100000 个码点,输出上限 400000 个码点。上限不是性能问题——实测 20 万码点单次编码约 9 毫秒——而是结果框的渲染代价:整段文本每个保留字符最多膨胀 6 倍,塞进 textarea 会让滚动明显发涩。
| 上下文 | 转义哪些字符 | 默认示例输出码点 | 适用位置 |
|---|---|---|---|
| 文本节点 | & < > | 84 | 元素之间的纯文本、<title>、<textarea> 内 |
| 双引号属性值 | & < > " | 94 | href="..."、title="..." 这类属性 |
| 单引号属性值 | & < > ' | 94 | HTML 里少用,但拼接模板时常出现 |
| 全部五字符 | & < > " ' | 104 | 不确定上下文时;同一份输出在两者都安全 |
| 解码 | —(三种写法一起还原) | 54 | 把实体还原成可读字符,非法码位替换成 U+FFFD |
下表是常用命名实体对照,共 30 行,默认显示前 12 行,点「加载更多」看全部。最后两行的字符没有命名实体,只能写数字实体,这正是本页把表外字符回落成数字实体的原因。
| 字符 | 命名实体 | 十进制 | 十六进制 | 码位 | 说明 |
|---|---|---|---|---|---|
| & | & | & | & | U+0026 | 起始字符引用,必须最先转义 |
| < | < | < | < | U+003C | 标签开始;文本与属性值里都危险 |
| > | > | > | > | U+003E | 单独出现不改解析,OWASP 仍要求编码 |
| " | " | " | " | U+0022 | 结束双引号属性值 |
| ' | ' | ' | ' | U+0027 | 结束单引号属性值;HTML4 无 ',写 ' |
|   |   | U+00A0 | 不换行空格;innerHTML 会把它序列化回 | |
| © | © | © | © | U+00A9 | 版权符号 |
| ® | ® | ® | ® | U+00AE | 注册商标 |
| ™ | ™ | ™ | ™ | U+2122 | 商标符号 |
| — | — | — | — | U+2014 | em 破折号 |
| – | – | – | – | U+2013 | en 连接号 |
| … | … | … | … | U+2026 | 省略号 |
| “ | “ | “ | “ | U+201C | 左双引号 |
| ” | ” | ” | ” | U+201D | 右双引号 |
| × | × | × | × | U+00D7 | 乘号 |
| ÷ | ÷ | ÷ | ÷ | U+00F7 | 除号 |
| ± | ± | ± | ± | U+00B1 | 正负号 |
| ° | ° | ° | ° | U+00B0 | 度 |
| € | € | € | € | U+20AC | 欧元 |
| £ | £ | £ | £ | U+00A3 | 英镑 |
| ¥ | ¥ | ¥ | ¥ | U+00A5 | 日元 / 人民币 |
| → | → | → | → | U+2192 | 右箭头 |
| ← | ← | ← | ← | U+2190 | 左箭头 |
| ∞ | ∞ | ∞ | ∞ | U+221E | 无穷 |
| √ | √ | √ | √ | U+221A | 根号 |
| π | π | π | π | U+03C0 | 圆周率 π |
| α | α | α | α | U+03B1 | 希腊小写 alpha |
| Α | Α | Α | Α | U+0391 | 希腊大写 Alpha |
| | (无) | ​ | ​ | U+200B | 零宽空格,无命名实体,只能写数字实体 |
| | (无) |  |  | U+FEFF | 字节序标记,无命名实体,只能写数字实体 |
对照表 12 / 30 行
常见误用对照。
| 常见说法 | 为什么不成立 |
|---|---|
| 「UTF-8 页面不用转义了,直接输出就行」 | 字符编码和语法解析是两件事。UTF-8 只让汉字能原样存盘,< 与 & 仍然是语法字符,不转义就会被当标签或引用 |
| 「已经转过了,再转一次更安全」 | 二次转义不可逆:AT&T 会变成 AT&T,页面上直接显示多余的 &。安全的前提是只转一次 |
| 「用 encodeURIComponent 转一下就能防 XSS」 | 它按 URL 规则转义,尖括号引号都留在原地,放进 HTML 依然能形成标签 |
| 「数字实体和命名实体效果一样,随便挑」 | 显示效果一样,但命名实体只有 2125 条带分号的名字,表外字符必须用数字实体;另外 ' 比 ' 兼容性更好 |
输入范围、边界与精度说明
- 输入上限 100000 个码点。超过就直接拒绝并告诉你当前码点数,不会截断后继续处理——截断会悄悄改变内容,比报错更危险。按码点而不是 UTF-16 码元计数,是因为一个 emoji 占 2 个码元却只是 1 个字符。
- 输出上限 400000 个码点。每个保留字符最多膨胀到 6 个码点(
"用十六进制写成"是 6 个字符),所以 10 万码点全部命中时输出接近 40 万,正好卡在这个阈值上。 - 汉字、日文、韩文默认实体化。「全部五字符」模式只动那五个字符,汉字原样保留;这是刻意设计——UTF-8 下把每个汉字写成
中会把文本撑大 8 倍,且丢失可读性。真需要全量实体化时,用「文本节点」加「十进制」逐字处理即可。 - 解码要求分号结尾。
&(无分号)原样保留、不还原。&这类大小写不符的写法同样按字面保留,因为命名实体是大小写敏感的。这是刻意的严格:无分号写法只有 106 条历史遗留项在浏览器里被容忍,而且有「后面跟字母数字就不展开」的额外规则,自己实现时几乎必然踩坑,不如一律要求分号。 - 非法码位替换成 U+FFFD 而不是丢弃。代理区 2048 个码位、以及大于 U+10FFFF 的数值,都替换成替换符,这样「原文有几个字符」这个信息还能保住,排查时能看出是编码错而不是内容少了一段。
- 属性上下文只多转一个引号。如果用双引号包裹属性值,
'不必转;反之亦然。把两个引号都转(「全部五字符」)不会出错,只是输出略长,本页的 104 与 94 之差就是这 10 个码点。 - 不做 HTML 净化。转义只保证字符按字面显示,不判断内容是否安全。要过滤标签、属性白名单、javascript: 协议这些,属于净化器(sanitizer)的工作,本页输出的是「原样的文本」,不是「安全的 HTML」。
- 生成过程没有任何网络请求。编码、解码、统计都在当前页面完成,可以在开发者工具的 Network 面板自查。
常见问题
为什么 & 必须第一个转?我把顺序写反过会怎样?
因为转义的产物本身就含 &。若先把 < 写成 <,随后那次替换 & 的规则会把新产生的 & 也一起吃进去,得到 &lt;。实测 <b> 会从 9 个码点涨到 17 个。更麻烦的是不可逆:一旦落在页面上,浏览器只会显示字面的 <,你再解一次也回不到 < 这个字符。
CSS 和 JavaScript 里也要做 HTML 实体编码吗?
不需要,而且转错了反而会坏。' 在 <style> 与 <script> 里不会被解码成引号,会原样留着,于是 CSS 属性值、JS 字符串全部语法错误。字符实体只在 HTML 的文本节点和属性值里生效;JS 字符串要转义反斜杠与引号,URL 要用 encodeURIComponent,CSS 要用反斜杠转义,三套规则互不通用。
已经编码过的文本再编码一次,怎么判断出来?
看有没有出现 & 或 < 这类「实体里还含实体」的形态。最简单的自查:把一段文本用本页编码一次,再用「解码」跑回来,如果能还原就说明只编码过一次;如果解一次后还剩 &,说明已经双重编码,需要先解到干净原文再重新编码。
为什么 ' 有的地方不认?
因为它不是 HTML 4.01 的实体。' 来自 XML/SGML,HTML5 才把它补进实体表,所以遇到早期的 HTML4 解析器、部分邮件客户端或旧的 RSS 阅读器时可能原样显示。要最大兼容就用十进制 ',语义完全一样,且从 HTML 2 起就有效。
innerHTML 把属性序列化回来,为什么引号没被转义?
因为浏览器序列化时只为「必要」转义:文本和属性值里的 &、文本里的 < 与 >、以及属性值里的引号(且只在属性用双引号时),再加上不换行空格 U+00A0。它永远不会输出 ' 或 这类别的命名实体,而是直接给原始字符。所以「从 DOM 读回来的 HTML」和「你当初写进去的源码」本来就不保证逐字节一致。
我把汉字也一起实体化了,会影响 SEO 吗?
会有影响,而且通常偏负面。把正文每个汉字写成数字实体会让 HTML 体积膨胀数倍,拖慢传输、也让源码难以维护;主流搜索引擎能正确解码,但页面的可读性和体积成本是实打实的。UTF-8 声明正确的前提下,正文里的汉字不需要实体化,真正必须转的始终只有那五个语法字符。
转义后的文本还能不能用浏览器搜索(Ctrl + F)?
能。浏览器是先解码再渲染的,页面上看到的是原始字符,Ctrl + F 匹配的也是解码之后的文本,与源码里写的是实体还是原字符无关。唯一的例外是你要找的内容本身含不可见字符(如 U+200B),这时先在输入框里把它实体化再比对更可靠。
输入 10 万个码点会卡吗?
不会。实测 20 万码点单次编码约 9 毫秒,线性复杂度,输入上限设 100000 是为了结果框的体验而不是算力:输出最多可能到 40 万个码点,一次性塞进 textarea 会让滚动明显发涩。要处理更大的文本,建议先在服务端或本地脚本里完成转换。
相关工具
- HTML实体解码:本页的逆运算,把
<之类的实体还原成字符,含非法码位处理。 - URL编码:按 RFC 3986 转义查询串与路径,转的是
; , / ? : @ & = + $ #而不是引号尖括号。 - URL解码:把
%3C%3E这类百分号编码还原回字符。 - Base64编码:二进制到可打印文本的映射,与 HTML 实体是两套完全不同的规则。
- 文本转Unicode转义:把每个字符写成
\uXXXX,适合放进 JSON 或代码字符串。 - 去除HTML标签:先剥离标签再取纯文本,与本页「保留内容只改写法」的方向相反。
- URL解析:把一条 URL 拆成协议、主机、查询参数,排查跳转参数时比手工分段可靠。