先记住这个答案
因为 JavaScript 字符串是 UTF-16 编码的,length 数的是 16 位的代码单元,不是字符个数。16 位最多表示 65536 个字符,编号超过 U+FFFF 的字符,比如大多数 emoji 和一部分生僻汉字,要拆成两个代码单元存储,叫一对代理项,所以 length 是 2。正确遍历要用 for...of、[...str] 或 Array.from(str),它们走字符串的迭代器,按码点前进,会把一对代理项当成一个字符。
length数的是 16 位代码单元,不是字符个数- 超过
U+FFFF的字符要用一对代理项表示 for...of按码点遍历,下标循环会拆开 emojicodePointAt读完整码点,charCodeAt只读半个
先看 😀 在内存里长什么样
用户反馈「昵称限制 10 个字,我只输了 6 个 emoji 就提示超长」,根源就在这里。先把一个 emoji 拆开看。
const emoji = '😀' // 码点是 U+1F600
console.log(emoji.length) // 2
console.log(emoji.charCodeAt(0).toString(16)) // 高代理项
console.log(emoji.charCodeAt(1).toString(16)) // 低代理项
console.log(emoji.codePointAt(0).toString(16)) // 从高代理项读,得到完整码点
console.log(emoji.codePointAt(1).toString(16)) // 从低代理项读,只拿到一半查看输出与解释
2
d83d
de00
1f600
de00charCodeAt 每次只读一个 16 位单位,所以分别读出 d83d 和 de00。codePointAt 更聪明:如果当前位置是高代理项,它会把后面的低代理项一起读进来,拼出完整的 1f600。但如果你从下标 1 开始读,它只能看到低代理项自己。
代理对是怎么算出来的
Unicode 给每个字符分配一个编号,叫码点,范围从 U+0000 到 U+10FFFF。前 65536 个叫基本多文种平面,常用汉字、英文字母、标点都在这里,一个代码单元就放得下。后面的叫补充平面,emoji 大都在这里。
UTF-16 预留了两段没有分配给任何字符的编号,0xD800 到 0xDBFF 叫高代理项,0xDC00 到 0xDFFF 叫低代理项。补充平面的字符先减去 0x10000,剩下 20 位,高 10 位加到 0xD800 上,低 10 位加到 0xDC00 上。
拿 😀 算一遍:0x1F600 - 0x10000 得到 0xF600。高 10 位是 0x3D,加上 0xD800 得到 0xD83D;低 10 位是 0x200,加上 0xDC00 得到 0xDE00。和上面代码的输出对得上。
因为这两段编号专门留给代理项用,程序看到一个 0xD83D 就知道它不是完整字符,后面一定还跟着一个低代理项。这也是 codePointAt 和字符串迭代器能正确合并它们的依据。
下标循环和 for...of 的区别
知道了原理,遍历的问题就清楚了:按下标走,走的是代码单元;按迭代器走,走的是码点。
const s = 'a😀b'
const byIndex = []
for (let i = 0; i < s.length; i++) byIndex.push(s.charCodeAt(i).toString(16))
console.log(byIndex.join(' ')) // 4 项,emoji 被拆成两项
const byOf = []
for (const ch of s) byOf.push(ch.codePointAt(0).toString(16))
console.log(byOf.join(' ')) // 3 项,emoji 是一项
console.log(s.length, [...s].length) // 代码单元数和码点数查看输出与解释
61 d83d de00 62
61 1f600 62
4 3for...of、展开语法和 Array.from 都依赖字符串的 Symbol.iterator 方法,这个迭代器在规范里就是按码点产出的。所以要统计码点数,写 [...s].length 最简单。
反过来,所有按下标工作的 API 都可能切开代理对,包括 s[i]、charAt、slice、substring。比如 'a😀b'.slice(0, 2) 得到的是 a 加一个孤零零的高代理项,显示出来就是乱码。
- 码点数也不等于用户看到的字数
[...s]解决的是代理对,但有些字符是多个码点组合的。🇨🇳 由两个区域指示符组成,[...'🇨🇳'].length是 2,length是 4。家庭 emoji、带肤色的手势也是这样。做昵称字数限制这类面向用户的计数,要用Intl.Segmenter并把granularity设成'grapheme',数它切出来的段数。
容易答错的地方
- 以为 JavaScript 用的是 UTF-8
- JavaScript 引擎内部的字符串语义是 UTF-16,
length和下标都按 16 位单位算。UTF-8 是网络传输和文件存储常用的编码,比如用TextEncoder把字符串转成字节时得到的就是 UTF-8,两者不能混为一谈。😀 在 UTF-8 下是 4 个字节,在 UTF-16 下是 2 个代码单元。 - 用
String.fromCharCode创建 emoji String.fromCharCode(0x1F600)只会取低 16 位,得到的是U+F600,一个完全不同的字符。创建补充平面字符要用String.fromCodePoint(0x1F600),或者在字符串里写'\u{1F600}'。- 以为只有 emoji 才会这样
- 一部分生僻汉字也在补充平面,比如 𠮷(
U+20BB7),length同样是 2。姓名、地名字段如果按length截断,就可能把这些字截坏。
面试官还会怎么问?
怎么判断一个字符串里有没有被切坏的代理项?
用 isWellFormed()。它在字符串里存在落单的高代理项或低代理项时返回 false。配套的 toWellFormed() 会把这些落单的代理项替换成 U+FFFD 替换字符。它们是 ES2024 新增的,老环境可以用正则 /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/ 自己检查。
按码点安全地截取前 n 个字符怎么写?
先转成码点数组再截,[...str].slice(0, n).join('')。如果要按用户看到的字截,就把 Intl.Segmenter 切出来的段取前 n 个再拼接。
为什么 JavaScript 当初选了 UTF-16?
JavaScript 在 1995 年诞生时,Unicode 还只有 16 位,一个代码单元就能表示所有字符,当时的 Java 也是这么设计的。后来 Unicode 扩展到了 21 位,为了兼容已有代码,length 和下标的语义没法改,只能新增 codePointAt、String.fromCodePoint、字符串迭代器这些按码点工作的 API。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。