ASCII / Unicode / UTF-8 编码基础
核心问题:为什么有些中文在保存或传输后变成乱码?不同编码之间的本质区别是什么?
真实场景
你的 Node.js 后端从数据库读出商品名称 "苹果手机",发给前端后显示成 "è�¹æ�œæ‰‹æ�º"——这就是编码不一致导致的乱码。理解编码,是做好 i18n 的第一步。
字符编码的演进
ASCII:一切的起点
ASCII(American Standard Code for Information Interchange)诞生于 1963 年,用 7 位二进制表示 128 个字符(英文字母、数字、标点、控制字符)。
'A' = 65 = 0x41 = 0b01000001
'a' = 97 = 0x61 = 0b01100001
'0' = 48 = 0x30 = 0b00110000
问题:ASCII 覆盖不了中文、日文、阿拉伯文等非英语语言。
各国自己的编码:碎片化时代
| 编码 | 覆盖语言 | 年代 | 问题 |
|---|---|---|---|
| GB2312 | 简体中文 | 1981 | 只有 6763 个汉字 |
| GBK | 简体中文(扩展) | 1993 | 向下兼容 GB2312 |
| GB18030 | 简体+繁体+少数民族 | 2000 | 中国国家强制标准 |
| Big5 | 繁体中文 | 1984 | 台湾/香港地区 |
| Shift_JIS | 日文 | 1969 | 日本 Windows 默认 |
| ISO-8859-1 | 西欧语言 | 1987 | HTTP 历史默认编码 |
结果:同一个字节序列在不同编码下含义不同——跨系统传输必然乱码。
Unicode:统一字符集
Unicode 的目标是:为世界上所有字符分配唯一编号(Code Point)。
'中' → U+4E2D (code point,十六进制)
'A' → U+0041
'©' → U+00A9
'😀' → U+1F600
Unicode 目前收录超过 14 万个字符,覆盖 161 种语言文字。
关键区分:Unicode 是字符集(规定字符与编号的对应),不是编码方案。UTF-8、UTF-16、UTF-32 是 Unicode 的编码实现。
UTF-8、UTF-16、UTF-32 对比
UTF-8
最重要的编码方案,也是 Web 事实标准。
编码规则:
| Code Point 范围 | 字节数 | 二进制模式 |
|---|---|---|
| U+0000 ~ U+007F | 1 字节 | 0xxxxxxx |
| U+0080 ~ U+07FF | 2 字节 | 110xxxxx 10xxxxxx |
| U+0800 ~ U+FFFF | 3 字节 | 1110xxxx 10xxxxxx 10xxxxxx |
| U+10000 ~ U+10FFFF | 4 字节 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
示例:'中' = U+4E2D
U+4E2D = 0100 1110 0010 1101 (二进制)
在 U+0800~U+FFFF 范围 → 3 字节模式:1110xxxx 10xxxxxx 10xxxxxx
填入:1110-0100 10-111000 10-101101
= 0xE4 0xB8 0xAD
'中' 的 UTF-8 字节:E4 B8 AD
UTF-8 的优势: - ASCII 字符只占 1 字节,向后兼容 ASCII - 自同步:任何字节开头可以判断自己是否是字符的起始字节 - 无字节序问题(BOM)
UTF-16
Java、JavaScript 引擎内部(V8)、Windows 内核使用 UTF-16。
- BMP 字符(U+0000 ~ U+FFFF):2 字节
- 补充字符(Emoji 等,U+10000 以上):4 字节(代理对)
// JavaScript 字符串是 UTF-16,emoji 会让 length 出错
'😀'.length // 2(不是 1!)
// 正确统计 Unicode 字符数
[...'😀'].length // 1
Array.from('😀').length // 1
UTF-32
每个字符固定 4 字节,内存效率低,主要用于内部处理。
三种编码对比表
| 特性 | UTF-8 | UTF-16 | UTF-32 |
|---|---|---|---|
| ASCII 字符大小 | 1 字节 | 2 字节 | 4 字节 |
| 中文字符大小 | 3 字节 | 2 字节 | 4 字节 |
| Emoji 大小 | 4 字节 | 4 字节 | 4 字节 |
| 字节序问题 | 无 | 有(BOM) | 有(BOM) |
| Web 标准 | ✅ 是 | ❌ 否 | ❌ 否 |
| 内部处理效率 | 一般 | 好(BMP) | 最高 |
BOM(字节顺序标记)
UTF-16 和 UTF-32 有大端(Big Endian)和小端(Little Endian)之分,文件开头会加 BOM 来标识。
UTF-8 BOM: EF BB BF(可选,不推荐加)
UTF-16 LE BOM: FF FE
UTF-16 BE BOM: FE FF
实践建议:UTF-8 文件不要加 BOM。Windows 记事本历史上默认加 BOM,会导致某些工具(#!/usr/bin/env node shebang 解析失败等)出现问题。
# 检测文件编码
file -i index.js
# 输出:index.js: text/plain; charset=utf-8
# 去掉 UTF-8 BOM
sed -i '1s/^\xEF\xBB\xBF//' filename.txt
乱码的常见根因
典型乱码案例与修复
案例 1:数据库乱码
-- 错误:创建表时未指定字符集
CREATE TABLE products (name VARCHAR(255));
-- 正确:明确指定 UTF-8
CREATE TABLE products (
name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
);
-- MySQL 注意:utf8 只支持 3 字节(emoji 会截断),必须用 utf8mb4
ALTER TABLE products CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
案例 2:HTTP 响应未声明编码
# 错误:缺少 charset 声明
Content-Type: text/html
# 正确
Content-Type: text/html; charset=UTF-8
<!-- HTML 文件内也要声明 -->
<meta charset="UTF-8">
案例 3:Node.js 读文件乱码
const fs = require('fs');
// 错误:返回 Buffer,toString() 默认 utf8 但文件是 GBK
const content = fs.readFileSync('old-file.txt').toString();
// 正确方式 1:如果文件是 UTF-8
const content = fs.readFileSync('file.txt', 'utf8');
// 正确方式 2:如果文件是 GBK,使用 iconv-lite 转换
const iconv = require('iconv-lite');
const buf = fs.readFileSync('gbk-file.txt');
const content = iconv.decode(buf, 'gbk');
案例 4:Python 3 文件读取
# 正确方式:明确指定编码
with open('data.txt', 'r', encoding='utf-8') as f:
content = f.read()
# 处理混合编码文件(errors='replace' 替换无法解码的字节)
with open('data.txt', 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
Unicode 归一化(Normalization)
有些字符可以有多种 Unicode 表示方式:
// 'é' 的两种表示
const s1 = '\u00e9'; // 预组合形式:é(1个码点)
const s2 = 'e\u0301'; // 分解形式:e + 组合重音符(2个码点)
console.log(s1 === s2); // false!字节不同
console.log(s1.length); // 1
console.log(s2.length); // 2
// 归一化后相等
console.log(s1.normalize('NFC') === s2.normalize('NFC')); // true
四种归一化形式:
| 形式 | 说明 | 使用场景 |
|---|---|---|
| NFC | 预组合(Canonical Decomposition + Composition) | Web / 文本存储(推荐) |
| NFD | 规范分解 | macOS 文件系统 |
| NFKC | 兼容预组合 | 搜索、比较 |
| NFKD | 兼容分解 | 搜索、全文索引 |
// 实践:存储前归一化
function saveText(text) {
return text.normalize('NFC');
}
// 比较前归一化
function equals(a, b) {
return a.normalize('NFC') === b.normalize('NFC');
}
工程实践清单
| 层 | 操作 | 正确做法 |
|---|---|---|
| 数据库 | 字符集 | MySQL 用 utf8mb4,PostgreSQL 默认 UTF-8 |
| 数据库 | 排序规则 | utf8mb4_unicode_ci(不区分大小写) |
| HTTP | Content-Type | 始终声明 charset=UTF-8 |
| HTML | meta 标签 | <meta charset="UTF-8"> 放在 <head> 第一行 |
| 文件 | 编辑器配置 | 所有文件保存为 UTF-8,不加 BOM |
| JSON | 默认编码 | JSON 规范要求 UTF-8,无需声明 |
| 代码 | 字符串比较 | 比较前执行 normalize('NFC') |
| 代码 | 字符串长度 | 用 [...str].length 而非 .length(emoji 场景) |
| 日志 | 输出 | 确保终端/日志系统支持 UTF-8 |
常见问题
Q:为什么 MySQL 的 utf8 不能存 emoji?
A:MySQL 的 utf8 实现有 bug,最多只支持 3 字节(BMP 范围),而 emoji 需要 4 字节(U+10000 以上)。必须使用 utf8mb4。
Q:JSON 文件需要声明编码吗?
A:不需要。RFC 8259 规定 JSON 必须使用 UTF-8 编码,且不应带 BOM。
Q:charCodeAt() 和 codePointAt() 有什么区别?
'😀'.charCodeAt(0); // 55357 (代理对的高位,不完整)
'😀'.codePointAt(0); // 128512 = 0x1F600(正确的码点)
下一节:了解了字符编码,接下来学习如何用 BCP 47 标准语言标签(
zh-CN、en-US、ar-SA)来标识 locale,以及 locale 对程序行为的具体影响。