看不见的字符:URL 编码、HTML 实体与全角半角
「我这边看着没问题」是字符类 bug 的典型症状 —— 全角数字、实体被解两次、URL 里的加号、替换串里的美元符,它们都不改变外观,只改变行为。
- 全角数字与字母肉眼几乎无法分辨,但表单校验会直接失败;只有码位落在 U+FF01 到 U+FF5E 的才是全角 ASCII 变体
- HTML 实体解码必须单次扫描,分步替换会把
<二次解成尖括号,那等于凭空篡改内容 - URL 编码用
%20表示空格,加号只有表单编码才代表空格,解码时不会把+还原成空格 - 替换内容里的
$永远是特殊字符,与是否正则模式无关,要输出字面的美元符号必须写两个 - 一个汉字在 URL 编码里是 3 组百分号、一个 Emoji 是 4 组,按组数就能反推原字符的类型
#症状:看着一样,行为不一样
字符类问题的共同特征是「显示正常但逻辑出错」,因为它们改变的是字节,不是字形。 靠眼睛对比永远发现不了,只能靠工具把不可见的部分展开。
四类典型症状,对应四种不同的成因:
| 症状 | 很可能是什么 | 怎么确认 |
|---|---|---|
| 表单说格式错误,肉眼看着没问题 | 全角数字或字母 | 全角半角转换,看改动数 |
页面显示了 < 而不是字符 | HTML 实体被解了两次 | 查原始字符串里有没有 & |
后端收不到参数,日志里带 + | URL 里的加号没按表单规则处理 | 先换空格再解码 |
| 替换结果多了或少了内容 | 替换串里的 $ 被当成捕获组引用 | 查替换内容 |
#全角与半角:肉眼几乎无法分辨
全角数字与半角数字在屏幕上只差一点宽度,在代码里是两个完全不同的字符。 从 PDF、Excel、老系统或某些输入法复制出来的内容,经常带着全角字符。
判定规则很干脆:码位落在 U+FF01 到 U+FF5E 这一段的,就是全角 ASCII 变体,减去 0xFEE0 得到半角;U+3000(全角空格)换成普通空格。用 全角半角转换 一步就能统一,统计栏会给出实际改动的字符数,为 0 说明输入里没有该方向的字符。
转换不会连带调整中文标点。顿号「、」(U+3001)、句号「。」(U+3002)、书名号、中文引号、破折号都不在上面那个区间里,转换后保持不变。而 ,!?;:() 属于全角 ASCII,会被一起转成半角 —— 处理完记得扫一眼标点。
设计上这一点尤其要注意:中英混排的界面里,全角逗号占一个字宽、半角逗号占半个字宽,混用会让同一段文字的视觉节奏忽紧忽松。想做统一,得先决定用哪一套,再用工具批量处理,不要手工改。
#HTML 实体:只该解一层
HTML 实体解码必须是单次扫描,分步替换的写法会二次解码,那不是「多解了一层」而是篡改了内容。 &lt; 的正确结果是 <,而分步替换会先得到 < 再变成 <。
编码方向只处理四个字符:&、<、>、"。顺序也固定 —— 必须先转 &,否则后面转出来的实体会被自己再转一遍。
用 HTML 实体转义 时,两个边界要知道:
- 单引号在编码时不转,需要时手工补
'(属性用单引号包裹、或 JSON 串嵌在onclick里时会用到) - 命名实体远不止这四个,
、©、—解码时原样保留,工具不查实体表
转义能防的是「文本被当成标记解析」,它不等于完整的 XSS 防护。把用户输入拼进属性值、内联脚本或 javascript: 链接时,各有各的规则。真正安全的做法是交给 DOM API(textContent)或模板引擎自动转义,而不是手工拼字符串。
#URL 编码:加号不是空格
URL 编码里空格是 %20,加号只有在表单编码(application/x-www-form-urlencoded)里才代表空格 —— 这两套规则的历史遗留,是「参数收不到」最常见的来源。
用 URL 编码解码 时,按 encodeURIComponent 的规则走:字母、数字与 - _ . ! ~ * ' ( ) 原样保留,其余字符先按 UTF-8 拆成字节,再逐字节写成 %XX。
于是组数本身就有信息量:
- 一个汉字是 3 组(
中→%E4%B8%AD) - 一个 Emoji 是 4 组(
%F0%9F%98%80)
看到 3 组一循环,基本可以判断原文是汉字。
两个必须知道的边界:
- 它不能直接编码整条 URL。
https://a.com/p里的:和/会被转义成%3A%2F%2F,链接直接废掉。正确做法是协议、域名、路径原样保留,只把查询参数的键和值分别编码 - 解码不会把
+还原成空格。从查询字符串复制的a+b,解出来仍然是a+b。要按表单规则处理,先自己把+换成空格再解码
遇到「中文参数变成乱码」时先确认原始编码。从 GBK 页面复制来的 %XX 序列在这里按 UTF-8 解,结果是乱码而不是报错 —— 工具不会替你猜编码,因为猜错比报错更难排查。
#替换串里的美元符号
替换内容里的 $ 永远是特殊字符,这与是否开启正则模式无关 —— 因为最终走的是 String.replace。 这是「替换完结果少了几个字」最常见的原因。
用 查找替换 时记住三条:
- 普通模式下填
$1不会插入字面的$1,而是引用第一个捕获组(普通模式没有捕获组,结果就是空) - 想输出字面的美元符号,写
$$ - 想插入整段匹配的内容,写
$&
普通模式会把查找内容里的正则元字符自动转义,所以输入「价格(元)」是按字面匹配的;正则模式不转义,写错了会红字提示并把引擎给的原因带出来,不会静默返回原文。
模式内容只写表达式本身,不要带 /.../gi 的斜杠和标志位 —— 忽略大小写、是否全部替换由复选框控制,写进表达式里会被当成字面字符去匹配。
#什么时候该用哪个工具
| 你想做的事 | 用哪个 | 关键差异 |
|---|---|---|
| 把全角数字字母统一成半角 | 全角半角转换 | 只转 U+FF01–U+FF5E,中文标点不动 |
| 检查文本里的转义残留 | HTML 实体转义 | 解码单次扫描,支持数字实体 |
| 解析或生成百分号编码 | URL 编码解码 | + 不当空格,非法序列会报错 |
批量替换并处理 $ | 查找替换 | 普通模式自动转义元字符 |
| 看字符的码位与字节 | 进制转换 | 二进制、十六进制、Unicode 码点互转 |
| 统计到底有多少个字符 | 文本统计 | 按码点计数,区分字符与字节 |
排查顺序建议:先看字符本身(全角?),再看转义层级(解过几次?),最后看编码规则(加号?百分号?)。 这三步能定位绝大多数「看着一样却不对」的问题。
#常见问题
#复制来的数字明明是对的,为什么校验不通过?
大概率是全角数字。123 和 123 在屏幕上差异极小,但字节完全不同,正则 \d、parseInt、数据库字段校验都会拒绝。用 全角半角转换 跑一遍,统计栏显示改动了几个字符,就知道是不是这个问题。
#页面把 < 直接显示出来了,怎么修?
说明字符串里的 & 被转义成了 &,浏览器解一次后剩下 <,于是显示成字面的 <。要么在输出前少转一次,要么在存储时只转必须转的四个字符。用 HTML 实体转义 解一次就能看清原始内容是什么。
#URL 里的空格到底该用 %20 还是 +?
取决于语境。路径部分只能用 %20;查询字符串在表单编码下可以用 +,但在 RFC 3986 的严格语义里 + 只是普通字符。跨系统传参时用 %20 永远不会错,用 + 则要确保接收端按表单规则解析。
#替换时为什么 $1 变成了空?
因为普通模式没有捕获组,$1 引用不到任何东西。要么切换到正则模式并写出捕获组(如 (\d+) 配 $1),要么把字面的 $1 写成 $$1。这个行为来自 String.replace,不是工具自己定的。
#一个汉字在 URL 里为什么这么长?
UTF-8 里一个常用汉字占 3 个字节,每个字节编码成 %XX,所以是 3 组。这是 UTF-8 的代价,换来的是所有语言共用一套编码。GBK 编码的汉字是 2 字节,但跨语言兼容性差得多。