前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
首页程序员面试题库JavaScript 字符串 代理对 emoji长度为2 遍历
JavaScript数据处理

为什么一个 emoji 的 length 是 2?怎么正确遍历字符串?

JavaScript 字符串的 length 数的是 UTF-16 代码单元,不是字符。😀 的编号是 U+1F600,超出了 16 位能表示的范围,只能拆成两个代码单元存,所以 length 是 2。按码点遍历用 for...of 或 [...str],不要用下标循环。

前端进阶之旅 · 一题精讲更新于 2026.09.29
JavaScript#数据处理
先看核心答案读代码示例
理解线索

一个字符串有三种「长度」

  1. 代码单元16 位一个,length、下标、charCodeAt 都按它算
  2. 码点Unicode 给字符的编号,for...of、codePointAt 按它算
  3. 字素簇用户看到的一个字,国旗、家庭 emoji 由多个码点组成

中文常用汉字都在 U+FFFF 以内,一个字 length 就是 1,所以只测中文发现不了这个问题,一定要拿 emoji 或生僻字来测。

核心回答

先记住这个答案

因为 JavaScript 字符串是 UTF-16 编码的,length 数的是 16 位的代码单元,不是字符个数。16 位最多表示 65536 个字符,编号超过 U+FFFF 的字符,比如大多数 emoji 和一部分生僻汉字,要拆成两个代码单元存储,叫一对代理项,所以 length 是 2。正确遍历要用 for...of、[...str] 或 Array.from(str),它们走字符串的迭代器,按码点前进,会把一对代理项当成一个字符。

  • length 数的是 16 位代码单元,不是字符个数
  • 超过 U+FFFF 的字符要用一对代理项表示
  • for...of 按码点遍历,下标循环会拆开 emoji
  • codePointAt 读完整码点,charCodeAt 只读半个

先看 😀 在内存里长什么样

用户反馈「昵称限制 10 个字,我只输了 6 个 emoji 就提示超长」,根源就在这里。先把一个 emoji 拆开看。

读取代码单元和码点JavaScript
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
de00

charCodeAt 每次只读一个 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 的区别

知道了原理,遍历的问题就清楚了:按下标走,走的是代码单元;按迭代器走,走的是码点。

同一个字符串的两种遍历JavaScript
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 3

for...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。

从一道题,走向一组知识

把知识连起来

函数与作用域

严格模式下为什么不能用 arguments.callee?递归该怎么写?

两题都在讲 JavaScript 早期设计遗留下来的行为,代理对和 arguments.callee 都要靠新 API 绕开。

函数与作用域

异步生成器和普通生成器有什么区别?什么时候会用到它?

for...of 能按码点遍历,靠的是字符串实现了迭代器协议,生成器正是自己写迭代器的方式。

参考资料

  • JavaScript:数据处理参考资料
  • String 中的 UTF-16 字符、码点和字素簇

示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。

本题目录
  1. 先记住这个答案
  2. 先看 😀 在内存里长什么样
  3. 代理对是怎么算出来的
  4. 下标循环和 for...of 的区别
  5. 容易答错的地方
  6. 面试官还会怎么问
  7. 把知识连起来
读懂,再试着讲出来

先看核心答案,再读代码。最后展开追问,检查自己有没有遗漏边界。

试着回答追问
浏览全部面试题理解原理,也关注真实的使用场景。回到顶部 ↑