先记住这个答案
JavaScript 字符串内部是 UTF-16 编码,下标和 length 数的是 16 位的码元,不是用户看到的字符。常用汉字和英文占一个码元,emoji 和一些生僻字占两个。charAt(i) 返回第 i 个码元组成的单字符字符串,charCodeAt(i) 返回这个码元的数值,两者遇到 emoji 都只能拿到一半。codePointAt(i) 如果发现 i 处是代理对的前半个,会把两个码元合起来返回完整的码点。处理 emoji 时用 codePointAt 取编号,用 for...of 或 [...str] 按字符遍历。
- 字符串索引按 UTF-16 码元计算
- emoji 等码点大于
0xFFFF的字符占两个码元 codePointAt可读完整 Unicode 码点- 按字符遍历用
for...of或[...str]
同一个 emoji,三个方法的结果
用一个中间夹着 emoji 的字符串,对比三个方法的返回值。
'a😀b' 上逐个调用JavaScriptconst s = 'a😀b'
console.log(s.length)
console.log(s.charAt(0), s.charCodeAt(0), s.codePointAt(0))
console.log(JSON.stringify(s.charAt(1))) // 只拿到前半个
console.log(s.charCodeAt(1), s.codePointAt(1)) // 半个 vs 完整
console.log(s.codePointAt(2)) // 指向后半个
console.log([...s].length, [...s][1] === '😀')
console.log(String.fromCodePoint(128512) === '😀')
console.log(s.charAt(10) === '', s.charCodeAt(10), s.codePointAt(10))查看输出与解释
4
a 97 97
"\ud83d"
55357 128512
56832
3 true
true
true NaN undefined'a😀b' 看起来是 3 个字符,length 却是 4。😀 的码点是 U+1F600(十进制 128512),超出了一个码元能表示的范围,所以被拆成两个码元 0xD83D 和 0xDE00,这一对叫代理对。charAt(1) 拿到的是孤零零的前半个,显示出来是乱码。charCodeAt(1) 返回 55357,也只是前半个的值。codePointAt(1) 识别出这是代理对的开头,合并后返回 128512。
注意 codePointAt(2) 返回的是 56832,也就是后半个码元。它的参数仍然是码元下标,不会自动跳到字符边界。
按字符遍历和截取
字符串的迭代器是按码点走的,所以 for...of、[...s]、Array.from(s) 都能把 emoji 当作一个整体。需要按「字符数」限制长度、截断文本时,先展开成数组再操作,比如 [...s].slice(0, 10).join(''),就不会把 emoji 从中间切断。
越界时三个方法表现也不同:charAt 返回空字符串,charCodeAt 返回 NaN,codePointAt 返回 undefined。方括号访问 s[10] 和 codePointAt 一样返回 undefined。
容易答错的地方
- 以为
str.length就是字符数 '😀'.length是 2。做输入框字数限制时直接用length,用户输入 emoji 会被多算。按码点计数用[...str].length。- 以为
codePointAt能解决所有 emoji 问题 - 很多 emoji 是几个码点组合出来的。👍🏽 是「竖大拇指」加「肤色修饰符」两个码点,👨👩👧 是三个人物码点加两个零宽连接符,共五个码点。
[...str]会把它们拆开。要按用户看到的字形切分,需要用Intl.Segmenter,把granularity设为'grapheme'。 - 用
String.fromCharCode还原 emoji fromCharCode只接收码元,传 128512 会被截断成一个错误的字符。对应codePointAt的是String.fromCodePoint。
面试官还会怎么问?
怎么判断字符串里有没有落单的代理项?
ES2024 新增了 str.isWellFormed(),返回 false 说明有落单的代理项,str.toWellFormed() 会把它们替换成替换字符 U+FFFD。截断字符串后发给后端之前可以检查一下,否则 encodeURIComponent 遇到落单代理项会抛 URIError。老环境不支持这两个方法时要先判断能力。
正则怎么正确匹配 emoji?
加 u 标志。不加时正则也按码元匹配,/^.$/.test('😀') 是 false;加上 u 后 . 能匹配一个完整码点,/^.$/u.test('😀') 是 true。\u{1F600} 这种写法也只有带 u 时才生效。
charAt(i) 和 str[i] 有什么区别?
正常下标时结果一样,都是按码元取。区别在越界:charAt 返回空字符串,方括号返回 undefined。另外 at(-1) 支持负数下标,但同样按码元,取不到结尾 emoji 的完整字符。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。