为什么理解字符串编码是国际化开发的基础
在前端开发中,字符编码问题往往在用户输入 emoji、复制粘贴特殊字符、处理中文全角符号时才暴露出来。很多"乱码"问题本质上都是编码认知偏差导致的。
JavaScript 字符串使用 UTF-16 编码,这个选择影响了字符串方法的方方面面。理解这个设计决策,才能在国际化开发中避免陷阱。
一、UTF-16 编码与 JavaScript
1.1 JavaScript 选择 UTF-16 的历史原因
JavaScript 诞生于 1995 年,那时 Unicode 标准还在发展中(Unicode 1.0 发布于 1991 年)。当时的工程决策是使用 16 位字符编码,足够覆盖当时已知的字符( BMP,即基本多文种平面,包含大多数常用字符)。
// JavaScript 字符串在内存中的表示
const str = "Hello";
// 每个字符占用 2 字节(UTF-16)
// 实际上,JavaScript 字符串是 UTF-16 代码单元序列
1.2 代码单元 vs 码点
UTF-16 使用**代码单元(Code Unit)**作为基本单位:
- BMP 字符(U+0000 到 U+FFFF):1 个代码单元
- 增补字符(U+10000 及以上):2 个代码单元(代理对)
// ASCII 字符 — 1 个代码单元
"ABC".length; // 3
// 中文字符 — 通常 1 个代码单元(中文在 BMP)
"中".length; // 1
"文".length; // 1
// emoji — 通常 2 个代码单元(代理对)
"👍".length; // 2 ← 不是 1!
"你好".length; // 2
1.3 代理对(Surrogate Pair)
U+10000 到 U+10FFFF 的字符需要两个代码单元表示:
// emoji 👍 的码点:U+1F44D
// UTF-16 编码:高代理 0xD83D + 低代理 0xDC4D
const emoji = "👍";
console.log(emoji.charCodeAt(0).toString(16)); // "d83d" — 高代理
console.log(emoji.charCodeAt(1).toString(16)); // "dc4d" — 低代理
// 正确获取码点
console.log(emoji.codePointAt(0)); // 128077 — 正确的码点值
二、charCodeAt vs codePointAt
2.1 charCodeAt — 只处理代码单元
const str = "A👍";
// indices: 0 1 2
// charCodeAt 返回指定位置的代码单元
console.log(str.charCodeAt(0)); // 65 — 'A'
console.log(str.charCodeAt(1)); // 55357 (0xD83D) — 高代理
console.log(str.charCodeAt(2)); // 56461 (0xDC4D) — 低代理
2.2 codePointAt — 处理完整码点
const str = "A👍";
// indices: 0 1-2
// codePointAt 自动组合代理对
console.log(str.codePointAt(0)); // 65 — 'A'
console.log(str.codePointAt(1)); // 128077 — 👍 的完整码点
2.3 遍历字符串的正确方式
// 错误方式:for 循环 + charCodeAt
const emoji = "👍";
for (let i = 0; i < emoji.length; i++) {
console.log(emoji.charCodeAt(i).toString(16));
// d83d
// dc4d
}
// 正确方式:使用扩展运算符或 for...of
[...emoji].forEach(char => {
console.log(char.codePointAt(0).toString(16));
// 1f44d
});
// ES2021: at() 方法支持负索引
console.log(emoji.at(-1)); // 👍 — 不受代理对影响
三、字符串的不可变性
3.1 字符串是只读的
JavaScript 字符串是**不可变(Immutable)**的。所有"修改"字符串的方法实际上返回新字符串:
let str = "hello";
str[0] = "H"; // 不报错,但无效
console.log(str); // "hello" — 未改变
// 正确方式:创建新字符串
str = "H" + str.slice(1);
console.log(str); // "Hello"
// 或者使用 replace
str = str.replace("h", "H");
3.2 不可变性的原因
- 线程安全:字符串在 JavaScript 中是值类型,不可变意味着可以在多线程环境下安全共享
- 性能优化:字符串池(String Pool)允许重复利用相同内容的字符串
- 安全:防止通过引用修改字符串影响其他使用该字符串的代码
- 简化实现:引擎可以对字符串进行各种优化(常量折叠、内存折叠)
3.3 字符串池
const a = "hello";
const b = "hello";
console.log(a === b); // true — 指向同一引用
const c = new String("hello");
console.log(a === c); // false — new String 每次创建新对象
四、字符串方法详解
4.1 slice / substring / substr 的区别
const str = "hello world";
// slice(start, end) — 支持负索引
console.log(str.slice(-5)); // "world"
console.log(str.slice(0, -6)); // "hello"
// substring(start, end) — 负索引视为 0,参数可交换
console.log(str.substring(-5)); // "hello world" — 负数变 0
console.log(str.substring(6, 0)); // "hello" — 自动交换
// substr(start, length) — 已废弃,不推荐使用
console.log(str.substr(0, 5)); // "hello"
4.2 模板字符串(Template Literals)
// 基本用法
const name = "Alice";
const greeting = `Hello, ${name}!`;
console.log(greeting); // "Hello, Alice!"
// 表达式
const a = 5, b = 10;
console.log(`${a} + ${b} = ${a + b}`); // "5 + 10 = 15"
// 多行字符串
const multi = `
This is
a multi-line
string
`;
// 标签模板
function tag(strings, ...values) {
console.log(strings); // ["", " is ", " years old"]
console.log(values); // [25]
return "result";
}
const result = tag`${name} is ${25} years old`;
4.3 Unicode 处理
// 判断是否是增补字符
function isSurrogatePair(char) {
const code = char.codePointAt(0);
return code >= 0x10000;
}
console.log(isSurrogatePair("A")); // false
console.log(isSurrogatePair("👍")); // true
// 正确反转包含增补字符的字符串
function reverseString(str) {
return [...str].reverse().join("");
}
console.log(reverseString("hello👍")); // "👍olleh"
console.log(reverseString("hello")); // "olleh"
五、常见编码问题与解决方案
5.1 emoji 乱码问题
// 场景:用户输入 emoji,存储后乱码
// 原因:后端使用 UTF-8 存储,但处理时未正确处理代理对
// 解决方案一:使用 codePointAt 正确处理
function countEmoji(str) {
return [...str].filter(char => {
const code = char.codePointAt(0);
return code >= 0x1F300 && code <= 0x1F9FF;
}).length;
}
countEmoji("Hello 👋🎉"); // 2
// 解决方案二:使用 Intl.Segmenter(ES2022)
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const segments = [...segmenter.segment("Hello 👋🎉")];
console.log(segments.length); // 8 — 正确计数
5.2 URL 编码
// encodeURI vs encodeURIComponent
const url = "https://example.com/path?query=你好";
// encodeURI:保留 URL 合法字符
console.log(encodeURI(url));
// "https://example.com/path?query=%E4%BD%A0%E5%A5%BD"
// encodeURIComponent:编码所有需要转义的字符
console.log(encodeURIComponent(url));
// "https%3A%2F%2Fexample.com%2Fpath%3Fquery%3D%E4%BD%A0%E5%A5%BD"
// 解码
console.log(decodeURI(encoded)); // "你好"
console.log(decodeURIComponent(encoded)); // "你好"
5.3 Base64 编码
// 字符串与 Base64 互转
function toBase64(str) {
return btoa(unescape(encodeURIComponent(str)));
}
function fromBase64(base64) {
return decodeURIComponent(escape(atob(base64)));
}
const chinese = "你好";
console.log(toBase64(chinese)); // "5L2g5aW9"
console.log(fromBase64("5L2g5aW9")); // "你好"
六、延展阅读
- MDN: String
- Understanding JavaScript's Unicode support
- [JavaScript has a Unicode problem](https://mathiasbynens.be/notes/et cetera)
- ES6 Strings and Unicode