DEVTOOLBOX / GUIDES 本篇

字符集、UTF-8、Base64 与 URL 转义:编码选择树

编码问题之所以难,是因为字符集、字符编码和传输编码被混成了一层。这篇按三层拆开,给出可验证的判据:什么情况下必须用 UTF-8,Base64 付出多少体积代价,URL 里加号与 %20 何时互换,乱码出现时先看哪一层。

内容复核 10 分钟阅读

先分清三层:字符集、字符编码、传输编码

一个字符串要到达磁盘或网络,必须经过三次变换,每一次都可能出错,而报错信息只提到「编码」这一个词。字符集回答有哪些字符可用,Unicode 把字符映射到 U+0000 到 U+10FFFF 之间的码位;字符编码回答码位如何变成字节,UTF-8、UTF-16、GBK 都在这一层;传输编码回答字节如何变成只含安全字符的文本,Base64、百分号编码、HTML 实体属于这一层。三层是串联关系,不是三选一,任何一层选错都会在末端表现为乱码;日志、配置与接口文档里那个笼统的词「编码」究竟指哪一层,得先对齐术语才能谈修法。

判断该修哪一层,靠固定其余两层做对照实验:同一段字节分别用 UTF-8 与 windows-1252 解码,若得到两个不同却都可读的结果,错在字符编码层;若任何编码都产出 U+FFFD,错在写入端,字节本身已经损坏;若文本在 URL 或 JSON 里被截断、参数丢失,问题在传输编码层。方向是单向的,从字符集到码位、到字节、再到安全文本,越靠下游的修复越不可逆。

不可逆点在于字符集之外的字符:把当前字符集无法表示的字符写成问号,是替换而不是近似,回读时拿到的就是问号,原字符不会以任何形式残留。代价则体现在每一层都增加开销:UTF-8 下中文 3 字节、emoji 4 字节,Base64 再加三分之一;而 JavaScript 字符串以 UTF-16 存储,非 BMP 字符占两个码元,所以「长度」这个词在三层里各有不同含义。

层回答的问题对应标准弄错时的表现
字符集有哪些字符可用Unicode 码位空间字符根本不存在
字符编码码位如何变成字节UTF-8(RFC 3629)、UTF-16同一段字节出现两种可读结果
传输编码字节如何变成安全文本Base64(RFC 4648)、百分号编码(RFC 3986)参数被截断,加号变空格
转义文本如何嵌进宿主语法HTML 实体、JSON 字符串语法页面直接显示实体字面量

三层加转义:先确定错误落在哪一行,再选工具

文本到字节:UTF-8 的真实边界

UTF-8 是变长编码,一个码位用 1 到 4 字节表示:ASCII 占 1 字节,中文常用字占 3 字节,emoji 占 4 字节,U+1F600 编码为 F0 9F 98 80。这三个数字决定了三种长度不能混用:字节数、码位数、以及 JavaScript 的 string.length,后者数的是 UTF-16 码元。判据很直接,要字节数用 Buffer.byteLength 或 TextEncoder 的 encode 结果长度;要码位数把字符串展开后取 length;要用户感知的字符数,用 Intl.Segmenter 按字素切分。

真实的边界出现在截断处。把两个 emoji 组成的字符串切前三个码元,得到的只是第一个完整的 emoji 再加一个孤立的高位代理,把这段字符串编码成 UTF-8 会得到 EF BF BD,也就是 U+FFFD,原始信息当场丢失。因此按长度截断日志字段、按字节分片上传、按码元取子串这三件事,都不能直接沿用 slice 的默认语义,必须先编码成字节再在合法边界回退,或者按字素切分。更稳妥的写法是先编码成字节、按字节切分、再解码回文本,代价是多一次内存分配,换来的保证是切点永远不会落在序列中间。

解码端有同类边界。TextDecoder 默认宽容:遇到非法序列就替换成 U+FFFD 并继续,只有 fatal 为 true 时才抛 TypeError,让失败停在读入的那一刻。U+FFFD 是真实字符,写进文件后与原始字符无法区分,这是这一层最硬的不可逆点。BOM 属于同一类问题:UTF-8 的 EF BB BF 序列合法,但 JSON.parse 拒绝它,直接读成字符串会得到 position 为 0 的非法记号;而 TextDecoder 默认剥掉 BOM,把 ignoreBOM 设为 true 才会保留。

字符码位UTF-8 字节数UTF-16 码元数
AU+004111
éU+00E921
中U+4E2D31
€U+20AC31
😀U+1F60042

按字节截断前先查这张表:切点落到代理对中间就必然产生 U+FFFD

字节到安全文本:Base64、Base32、Hex 与 Base64URL

Base64 把每 3 字节切成 4 个 6 位组,用 64 个字符表达,长度是 ⌈n/3⌉×4,即膨胀到 4/3 倍;n 不是 3 的倍数时补 1 或 2 个等号。判据:只有通道只接受 ASCII 文本时才需要它,例如邮件头、JSON 字符串、data URL、JWT 的段。代价是体积固定增加三分之一,且对已经压缩的 PNG、JPEG 没有任何收益,把图片内联进页面反而比引用文件更慢。如果两端都在同一进程或同一套序列化协议内,直接传字节比任何文本编码都省事,也不存在字母表冲突。

变体的取舍各有理由。RFC 4648 第 5 节的 base64url 把加号与斜杠换成减号与下划线并省略等号,JWT 的三个段就是这么编码的。Base32 每字符 5 位,长度是 ⌈n/5⌉×8,字母表只有 A 到 Z 与 2 到 7,大小写不敏感,适合人工抄写和一次性密码的共享密钥。Hex 每字节两位,膨胀两倍,但字节边界肉眼可见,做哈希对照和抓包比对时最省事。

不可逆点不在算法,在字母表。标准 Base64 的加号一旦经过会做 URL 规范化的环节,就被解释成空格,之后无论怎么解码都还原不出原始字节;反过来把 base64url 的减号与下划线当作普通字符传递,解码器会直接报错。这类失败最容易被误判成内容本身坏了,实际上只是字母表选错。工程上只有一条硬规则:编码结果要出现在 URL、文件名、Cookie 值或 XML 名称里,就用 base64url。

编码字母表大小n 字节后的长度主要用途
Base6464⌈n/3⌉×4邮件头、data URL、JWT
Base64URL64⌈n/3⌉×4,省略等号URL、文件名、Cookie
Base3232⌈n/5⌉×8TOTP 密钥、人工抄写
Hex162n哈希对照、抓包比对

同样是可逆编码,膨胀率与字母表决定了它能否安全穿过某一层

URL 场景:保留字符、加号与 %20、路径与查询串

RFC 3986 把 URL 里的字符分成 unreserved 与 reserved 两类,后者包括冒号、斜杠、问号、井号、方括号、at 符号、感叹号、美元、与号、单引号、圆括号、星号、加号、逗号、分号与等号,它们在路径与查询串里承担分隔作用。把 reserved 字符当数据时必须百分号编码;非 ASCII 字符先按 UTF-8 编成字节再逐字节编码,所以一个汉字占 9 个字符,形如 %E4%B8%AD。

空格有两套约定,这是分叉的根源:查询串里加号表示空格,来自表单提交 application/x-www-form-urlencoded 的解析规则;路径里加号就是加号本身。WHATWG URL 的实测行为是,URLSearchParams 序列化时把空格写成加号,路径中的空格写成 %20;encodeURIComponent 把空格写成 %20,并保留感叹号、单引号、圆括号、星号、减号、点、下划线与波浪号。判据:拼单个参数值用 encodeURIComponent,拼整条 URL 用 encodeURI,构造查询串交给 URLSearchParams 序列化,任何情况下都不要用字符串加法拼参数。

双重编码是第二类高频错误。从 searchParams.get 拿到的已经是解码后的值,再编码一次会把百分号变成 %25,服务端收到的就是字面量 %E4。不可逆点在于两种约定无法从字符串本身区分:发出加号、服务端按表单规则解析得到空格;发出 %2B、服务端按 RFC 3986 解析才得到加号。域名部分不走百分号编码,走 IDNA 的 punycode,例如「例子.测试」变成 xn--fsqu00a.xn--0zwm56d。另外,fragment 不会发送到服务端,把状态塞进井号后面的写法不能替代查询串。

场景空格写成加号写成该用的 API
单个查询参数值%20 或 +%2BencodeURIComponent
路径段%20保持 +encodeURIComponent 后逐段拼接
整条 URL%20保持 +encodeURI
主机名不出现不出现IDNA 的 domainToASCII

加号只在查询串里有特殊含义,路径与主机名中它是普通字符

HTML、JSON 与日志里的转义

HTML 只需要转义会改变解析状态的字符:文本节点里要处理与符号和小于号,属性值里再加双引号与单引号。大于号在文本节点里不转义也不改变解析结果,转它是为了与小于号对称。判据是让转义只发生一次,用 DOM 的 textContent 或框架的插值,而不是手动拼接字符串;手动转义两次,页面上会连实体本身一起显示出来,名字里带 amp 的那种实体会原样露在正文里。

JSON 字符串只允许八种转义:双引号、反斜杠、斜杠、退格、换页、换行、回车、制表符,以及反斜杠加 u 与四位十六进制,依据是 RFC 8259 第 7 节与 ECMA-404。与号、小于号、大于号在 JSON 里没有转义的必要,JSON 不是 HTML;唯一例外是把 JSON 嵌进脚本标签,此时必须把小于号写成 \u003c 的形式,否则字符串里的结束标签会提前关闭脚本。JSON.stringify 不转义斜杠,也不把非 ASCII 转成转义序列,中文原样输出,而孤立代理写成 \ud800。

这一层还有几处静默损失,全都发生在序列化时:值为 undefined 的键被丢弃,数组里的 undefined 变成 null;NaN 与 Infinity 一律写成 null;负零写成 0;超过 2 的 53 次方减一的整数在赋值时就已经按双精度浮点取整,JSON.stringify(9007199254740993) 输出 9007199254740992,之后再解析也救不回来。日志另有一条:控制字符必须转义,否则一个换行就能伪造出两条记录;要逐行解析就用 NDJSON,每行一次独立的 JSON.parse,坏行不牵连其余行,代价是字符串里的换行只能写成 \n,不能出现真换行。

宿主必须转义不必转义常见事故
HTML 文本节点与号、小于号大于号、中文重复转义露出实体
HTML 属性值与号、小于号、引号中文引号提前闭合属性
JSON 字符串引号、反斜杠、控制字符与号、尖括号、中文内嵌脚本时结束标签提前闭合
日志行换行、回车、制表符可打印 ASCII换行注入伪造记录

转义的目标是宿主语法,不是「所有特殊字符」

三步定位乱码

第一步是判定层次,而不是猜编码。取出原始字节看十六进制:UTF-8 的多字节序列中首字节的位模式与续字节数量必须匹配,E4 B8 AD 是合法的三字节序列,说明字节完好、错在解码;若出现孤立的续字节,例如 B8 AD 前面不是合法首字节,说明字节已被截断,问题在读取或分片,与字符集无关。十六进制视图在这里比在线解码器可靠,因为它不替你做任何猜测。

第二步做候选解码,统计替换字符 U+FFFD 的数量,取值为零或最少的那一次解码。典型指纹可以直接背下来:UTF-8 字节被当作 windows-1252 解码,café 显示成 café;被当作 GBK 解码,中文显示成涓枃一类生僻字;一串问号则说明上游编码时字符集里没有这些字符,已经被替换。第三步才做规范化,确认解码正确之后再统一到 NFC,顺序反了会把错误固化进新的字节里。

判据是每一步都打印中间结果,一次只改一层,字节、码位、字符各看一遍再动手。工具上,看字节用十六进制视图,看码位用 unicode-escape,把一段 Base64 还原成字节再按指定编码解码,是验证字节是否完好的最快路径。不可逆点只有一个:U+FFFD 或问号一旦落入持久层,原字符的任何信息都不再存在,此时唯一能做的是回到源头重新导出,而不是继续换编码去试。

症状实际字节被当成处理方式
é、’UTF-8 的 C3 A9windows-1252按 windows-1252 解码后重新编码
涓枃一类生僻字UTF-8 的 E4 B8 ADGBK用 UTF-8 重新解码
一串问号已被替换为 3F任意编码不可恢复,从源头重新导出
方块或 U+FFFD非法序列UTF-8 宽容模式用 fatal 模式定位偏移

先看字节是否合法,再决定是换解码还是回源头

工具选型一览

选型按四个问题依次排除:输入是人可读的文本还是二进制字节;输出要进入哪种宿主语法,URL、HTML、JSON 还是纯 ASCII 通道;体积是否敏感;是否要求无损可逆。四问走完,二十多个候选工具最后只剩一两个,剩下的选择不需要直觉,只需要按答案查表;先定方向、再定编码、最后挑工具,顺序颠倒就会选出能跑却不该用的组合。

映射关系是固定的。字节转 ASCII 文本用 base64,结果要放进 URL、文件名或 JWT 时改用它的 base64url 变体;介质不区分大小写或需要人工念出来,用 base32;要逐字节对照、与哈希比对,用 hex。文本转 URL 参数用 url-encode;文本转 HTML 用 html-entity;要看清不可见字符的码位用 unicode-escape;文本转 JSON 字符串字面量用 json-escape;反过来把压缩的 JSON 展开成可读结构用 json-format,逐行日志用 json-lines,按路径取字段用 json-pointer。

代价与顺序同样要算。不要串联三层编码,文本先 Base64 再百分号编码,说明接口把字节当成文本处理了,解码方要按反序剥三层,任何一层错位都表现为乱码。跨格式转换用专门的工具,例如 json-to-xml 与 xml-to-json,而不是先转义再拼字符串。确定工具后,把三个中间态各留一份:原文、字节、编码后的文本,下次复现时就能直接定位到出错的那一层,而不是从现象重新猜。

需求工具为什么是它
把二进制塞进 ASCII 通道base64体积 4/3,通用且可逆
结果要进 URL、文件名、JWTbase64取 base64url 变体,避开加号与斜杠
人工抄写、介质不区分大小写base32字母表无歧义,膨胀 8/5
逐字节对照、核对哈希hex每字节两位,边界可见
中文要进查询串url-encode按 UTF-8 逐字节百分号编码
排查不可见字符unicode-escape直接读到 U+ 码位

四问走完后按需求查表,冲突时优先保证可逆与宿主兼容

常见问题

Base64 算加密吗?
不算。它是可逆的字节到文本映射,没有密钥,谁都能解码;要保密应先加密再编码,顺序不能反。
JSON 里的中文要转成 u 吗?
不用。RFC 8259 允许字符串直接用非 ASCII,JSON.stringify 原样输出中文;只有嵌进脚本标签或要求纯 ASCII 时才要转。
拼 URL 该用哪个编码函数?
单个参数值用 encodeURIComponent,空格写成 %20;整条 URL 用 encodeURI;查询串交给 URLSearchParams。
乱码还能救回来吗?
若是编码误判就能救,按原编码重新解码即可;一旦字节被换成问号或 U+FFFD,信息已丢失,只能从源头重导。
大整数过 JSON 为什么变了?
Number 是双精度浮点,超过 2 的 53 次方减一就丢精度,9007199254740993 会变成 9007199254740992,要用字符串传。

动手试试