JavaScript 字符串与编码

深入解析 JavaScript 字符串的 UTF-16 编码机制、codePointAt vs charCodeAt 的区别、字符串不可变性的原因,以及 emoji 乱码问题的根源。

为什么理解字符串编码是国际化开发的基础

在前端开发中,字符编码问题往往在用户输入 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 不可变性的原因

  1. 线程安全:字符串在 JavaScript 中是值类型,不可变意味着可以在多线程环境下安全共享
  2. 性能优化:字符串池(String Pool)允许重复利用相同内容的字符串
  3. 安全:防止通过引用修改字符串影响其他使用该字符串的代码
  4. 简化实现:引擎可以对字符串进行各种优化(常量折叠、内存折叠)

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")); // "你好"

六、延展阅读