HTML 实体转义与还原(& < 中 与浏览器的宽容规则)

把文本转成 HTML 安全的写法(& < > 引号,以及可选的中文和 emoji 数字引用),或者反过来把实体引用还原成文字:命名表、缺分号的遗留名字、Windows-1252 数字段和代理区处理都按浏览器的口径来。全部在浏览器本地完成。

A · 转义(文本 → HTML 里能安全写出来的样子)

B · 还原(实体引用 → 文字)

转义与还原的规则

往网页里塞文本的两种翻车方式

往网页里塞文本时有两种翻车方式:一种是把 <b> 或用户输入的 <script> 当成标签渲染出来,一种是把已经写成 &amp; 的东西再处理一遍,结果页面上显示的是字面上的 &amp;amp;。这两种都需要一个「看得懂浏览器怎么解释」的转换,而不是随便 replace 一下。

所以这里做两个方向:A 转义——把要放进 HTML 的普通文本变成安全的写法;B 还原——把实体引用(&amp;、&#20013;、&#x1F600;、&nbsp)变回文字。B 段刻意照着浏览器解析器的口径写:命名表、能省分号的遗留名字、0x80-0x9F 的 Windows-1252 老映射、代理区和越界码点怎么处理,都和 HTML 规范一致(所有解码结果都跟浏览器自己的解析结果逐条比对过)。

A 段:哪几个字符必须动

按用途分两个口径:

  • 放在元素的内容里(<p>这里</p>):只需要动三个——& → &amp;、< → &lt;、> → &gt;。其中 > 严格说不是每处都必须,但 -->、]]> 这类序列里会出问题,所以一律转。
  • 放在属性值里(title="这里"):再加两个——" → &quot;、' → &apos;。HTML 里属性用双引号包时只有 " 必须转,但工具不知道你会用哪种引号,所以「属性口径」默认把两种引号一起转,粘进任何一种属性都安全。勾掉它就退化成内容口径," 和 ' 原样保留。

转义是一次性的,不是幂等的:&amp; 进去会变成 &amp;amp;。已经转过一遍的文本再转一次,页面显示的就是字面上的 &amp;。这段文本此前是不是 HTML,工具不知道,也不猜。

勾上「非 ASCII 转数字引用」后,所有码点大于 0x7F 的字符(中文、重音字母、emoji)都写成 &#十进制;,一个码点一个引用:emoji 😀 在 JavaScript 里是 2 个 UTF-16 单元,但输出是 &#128512; 一个引用,不会被拆成两半。这个选项适合要发进只认 ASCII 的老系统的内容;日常网页不需要,UTF-8 的页面直接写中文更好读。

B 段:还原的规则

  • &名字;:先找「后面紧跟分号的最长名字」。表里有 187 个常用名字(& < > " ' 两种大小写写法、拉丁文重音字母大小写两套、标点排版、货币单位、希腊字母、数学箭头)。名字认不出来又以分号结尾 → 原样保留,并计入「未识别的引用数」。
  • 没写分号的老名字:HTML 4 时代那批 ISO 8859-1 实体历史上允许省分号,浏览器到今天还认。勾上「按遗留规则还原」后,&copy → ©、&nbsp → 不换行空格,而且用的是最长匹配:&notitice 会还原成「¬itice」而不是报错,因为能当遗留名字用的最长前缀是 not。hellip、mdash、euro、apos、trade 这些 HTML 4/5 才有的名字不在省分号的名单里,必须写全。关掉这个开关,未写分号的名字就原样保留。
  • 数字引用:&# 后面跟十进制或 #x 十六进制,值按码点解释。分号同样可以省(&#65 还原成 A),只是会被记进提示里。
  • 孤立的 &:AT&T 里的 & 后面不接合法名字时不算引用,原样输出,也不计入未识别。反过来 AT&amp;T 是合法的,会还原。

数字引用有几类值不能按「码点就是字符」直接输出,工具照浏览器解析器的实际做法处理(下面每一行都和浏览器自己的解析结果比对过):

输入 结果 原因
&#0; 替换字符(U+FFFD) 规范里唯一被明确替换的控制码点就是 NUL
&#1; … &#31;、&#127; 原样输出那个不可见的控制字符 这些位置只是「解析错误」,规范并不替换它们;&#9; &#10; &#12; &#13; 就是制表、换行、换页、回车
&#128; … &#159; 按 Windows-1252 老表映射 &#128; → €、&#151; → —、&#148; → “;表里本来就空着的 5 个位置(&#129; &#141; &#143; &#144; &#157;)浏览器仍吐出对应的不可见控制字符
&#xD800; … &#xDFFF; 替换字符(U+FFFD) 代理区码点不能单独存在,规范禁止用引用写出来
&#1114112;(超过 U+10FFFF) 替换字符(U+FFFD) 超出 Unicode 范围
&#x; 替换字符,分号被消耗 一个十六进制位都没有:浏览器给替换字符
&#;、&#xGG; 原样保留 没有数字就根本不是引用,浏览器把开头的 & 当普通字符放回

因为控制字符真的会被还原出来,结果框里可能出现看不见的内容:复制出去就是那几个原始字节,而 <textarea> 自己会把 \r 显示成换行。这不是解码错了,是输入框的行为。

什么时候结果不能直接用

  • A 段不是 XSS 防护的替代品:它只保证「文本作为文本出现」。如果内容要放进 <script>、<style> 或 URL 里,光转义引号是不够的——那些位置的解析规则完全不同(比如 <script> 里只有 </script 能结束它,&lt; 在那里不是小于号)。请让模板引擎或框架的转义函数负责,它们知道自己的上下文。
  • 换行不会因为转义而保留:HTML 把源码里的换行当空白折叠。想让断行显示出来要用 <br>(那就得允许标签,和上面的转义思路冲突)或者 CSS 的 white-space: pre-line。
  • B 段还原出来的文本不能直接再塞进 HTML:&lt;b&gt; 还原成 <b> 之后,它就是真的标签了。还原和插入之间要做什么校验是调用方的事。
  • 命名表是精选的,不是全量:HTML 规范有 2231 个命名引用(&aopf;、&CounterClockwiseContourIntegral; 这类数学大字全在里面,&Alpha; 这种大写希腊字母也没收)。这里只收录日常会遇到的 187 个,其余的会进「未识别」并原样保留。要还原生僻数学实体就用数字引用。
  • 不要把 & 的宽容规则当依据:&nbsp 能用是 1990 年代的遗留,新写的代码应该一律带分号;&amp 在 XHTML 里直接是错误。工具支持只因为你粘到的东西往往就是这么写的。

常见问题

Q:&micro; 和 &mu; 有什么区别? 不是同一个字符。&micro; 是 U+00B5 MICRO SIGN(拉丁文单位的 µ,来自 ISO 8859-1),&mu; 是 U+03BC GREEK SMALL LETTER MU(希腊字母 μ)。两者外形几乎一样,码点不同,复制出去在搜索、比较里就是两个字符。工具把两者都按各自码点还原,不合并。

Q:为什么不直接用浏览器解析(innerHTML 一行搞定)? 因为那是把整段内容交给 HTML 解析器,任何 <img onerror> 都会立刻被执行。做转换的脚本只扫字符串,没有 eval、没有 innerHTML,也不会把文本交给任何服务器,你粘进去的文本不会离开这台设备;而且正因为不依赖解析器,同一份规则在任何浏览器里结果一模一样。

Q:&#146; 为什么不是 ’? 它就是 ’(U+2019)。&#146; 落在 Windows-1252 老表里,工具按那张表映射——这也正是浏览器过去二十年的行为。注意这层的存在意味着「把 &#146; 当作’第 146 号 Unicode 码点’」是错的:U+0092 是个不可见的控制字符,规范特意把这一段接回了老编码。

Q:一次能处理多少? 每段上限 4000 个字符,超出只处理前 4000 个并在提示里写明。按键 60 毫秒后合并计算一次,两次输入互不影响:A 段的输出可以直接按 Ctrl+C 拿走去用,也可以整块粘进 B 段验证还原结果对不对。

微信
微信
支付宝
支付宝