ASCII / Unicode / UTF-8 编码基础
High Contrast
Dark Mode
Light Mode
Sepia
Forest
6 min read1,203 words

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 对比

graph LR subgraph Unicode字符集 CP["Code Point\nU+0000 ~ U+10FFFF"] end subgraph 编码方案 U8["UTF-8\n变长 1-4 字节"] U16["UTF-16\n变长 2 或 4 字节"] U32["UTF-32\n固定 4 字节"] end CP --> U8 CP --> U16 CP --> U32

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。

// 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

乱码的常见根因

flowchart TD A[文件/数据库用 GBK 存储中文] --> B[以 UTF-8 读取] B --> C[字节解释错误 → 乱码] D[MySQL 表字段设置 latin1] --> E[存入 UTF-8 中文] E --> F[截断或问号乱码] G[HTTP 响应未声明 charset] --> H[浏览器猜测编码错误] H --> I[页面乱码]

典型乱码案例与修复

案例 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-CNen-USar-SA)来标识 locale,以及 locale 对程序行为的具体影响。

BCP 47 语言标签与 locale 标准