[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$fdJzttgK_MulT6SNbk87lh0_-Zdl-Z0I4-a7LBj3l0Ds":3},{"item":4,"related":44},{"id":5,"type":6,"title":7,"slug":8,"summary":9,"body":10,"coverUrl":11,"productScreenshots":12,"productLinks":13,"authorName":14,"authorUrl":15,"authorSubject":16,"category":17,"tags":22,"sourceLabel":35,"sourceName":36,"sourceUrl":36,"status":37,"seoTitle":36,"seoDescription":36,"canonicalUrl":36,"isFeatured":38,"sno":39,"sortOrder":40,"publishedAt":41,"updatedAt":42,"createdAt":43},"81c63a90-6816-42f1-ba58-11ccc2b6bcc8","article","Unicode：一个 Emoji 为什么 length 不是 1","unicode-grapheme-clusters-emoji-explained","用户眼中的一个字符，可能由多个 Unicode 码点和多个 UTF-16 代码单元组成。本文用 Emoji、组合音标和 ZWJ 序列拆解字符串长度、截断、校验和 Intl.Segmenter 的正确边界。","在 JavaScript 里，下面这段代码经常让人第一次看到时感到困惑：\n\n```js\n\"👨‍👩‍👧‍👦\".length\n```\n\n它不会返回人眼理解的“一个”。因为字符串至少有四个层次：存储字节、编码单元、Unicode 码点，以及用户感知到的字符。Unicode 标准把最后一种称为 grapheme cluster，也就是用户通常认为的一个可见字符。UAX #29 定义了如何判断这些文本边界，并明确把一个 Emoji 序列视为一个 grapheme cluster。[Unicode UAX #29](https:\u002F\u002Funicode.org\u002Freports\u002Ftr29\u002F)\n\n## 四个“字符”概念不要混着用\n\n### 字节：编码后的存储单位\n\nUTF-8 会把一个 Unicode 码点编码成 1 到 4 个字节。中文、Emoji 和拉丁字母占用的字节数可能不同。网络传输和文件大小通常关心字节，而不是用户看见了几个字符。\n\n### UTF-16 编码单元：JavaScript 字符串的历史包袱\n\nJavaScript 字符串使用 UTF-16 编码单元表达文本。基本多文种平面里的字符通常占一个 16 位编码单元，超出范围的码点会使用一对 surrogate pair。于是：\n\n```js\n\"A\".length \u002F\u002F 1\n\"中\".length \u002F\u002F 1\n\"😀\".length \u002F\u002F 2\n```\n\n`length` 返回的是编码单元数量，不是 Unicode 码点数量，更不是用户感知的字符数量。\n\n### Unicode 码点：抽象字符编号\n\n使用展开运算符或 `for...of`，JavaScript 可以按码点遍历：\n\n```js\n[...\"😀\"].length \u002F\u002F 1\n```\n\n这比直接用 `length` 更接近 Unicode 层面的“一个字符”，但仍然没有到达用户感知层。带肤色修饰符、组合音标、旗帜和 ZWJ Emoji 都可能由多个码点组成。\n\n### Grapheme Cluster：用户按退格键想删掉的单位\n\n用户看到的一个 Emoji 可能由基础 Emoji、零宽连接符和另一个 Emoji 拼成。用户按一次退格，通常期待整个图形一起消失，而不是只删掉其中一个不可见组成部分。UI 光标移动、截断、计数和删除应该尽量以 grapheme cluster 为单位。\n\n## 为什么 Emoji 会变成一串码点\n\nUnicode 不只为“一个图形一个编号”设计。为了表达组合关系，它允许多个码点组成一个显示序列：\n\n- 基础字符加组合音标。\n- 基础 Emoji 加肤色修饰符。\n- 区域指示符组合成旗帜。\n- 多个 Emoji 通过零宽连接符组成职业、家庭等序列。\n\n这让文本可以持续扩展，也避免为所有视觉组合分配独立编码。但程序不能只看单个码点就判断用户看见了几个字符。\n\n```mermaid\nflowchart TD\n    A[原始输入文本] --> B[UTF-8 \u002F UTF-16 编码]\n    B --> C[编码单元]\n    C --> D[Unicode 码点]\n    D --> E[组合规则与边界判断]\n    E --> F[Grapheme Cluster]\n    F --> G[界面显示、光标与删除]\n```\n\n## `slice` 为什么可能把 Emoji 切坏\n\n```js\nconst value = \"你好😀世界\"\nvalue.slice(0, 4)\n```\n\n如果切分位置落在 surrogate pair 中间，结果可能包含孤立的代理单元；即便没有切在代理对中间，也可能把 ZWJ 序列或组合音标拆开，得到一个显示异常的字符串。\n\n用户输入长度限制也要先定义“长度”是什么：数据库列限制可能按字节，协议字段可能按字节数，UI 文案可能按 grapheme cluster，模型输入则可能按 token。把这些限制都叫“字符数”会让边界问题迟早出现。\n\n## 正规化：看起来一样，内部可能不同\n\n带重音符号的文字可能有两种表示：一个预组合码点，或一个普通字母加组合音标。它们视觉上相同，底层序列却可能不同。Unicode Normalization 提供 NFC、NFD 等形式，帮助系统在比较、搜索和存储前选择一致的表示。\n\n正规化也不是越早越好。文件名、密码、签名和协议字段对原始字节可能有特殊要求，不能不加判断地把所有字符串统一转换。应该根据字段语义决定：展示文本、搜索索引和用户标识通常需要明确的规范化策略；密码验证则必须遵循认证系统的约定。\n\n## JavaScript 里怎么做得更稳\n\n现代运行时可以使用 `Intl.Segmenter` 按 grapheme cluster 分词：\n\n```js\nconst segmenter = new Intl.Segmenter(\"zh\", {\n  granularity: \"grapheme\",\n})\n\nconst clusters = [...segmenter.segment(\"👨‍👩‍👧‍👦á\")]\nconsole.log(clusters.length)\n```\n\n它比手写“遇到 Emoji 就算一个”可靠得多，因为 Unicode 的分段规则会持续演进。服务端和客户端还要统一截断策略，否则一个端显示完整、另一个端保存半个组合序列，问题会在同步时暴露。\n\n## 一份实用清单\n\n- 展示和删除：按 grapheme cluster 处理。\n- 统计数据库大小：明确按字节、码点还是编码单元。\n- 截断昵称：不要直接对 UTF-16 下标做 `slice`。\n- 搜索和比较：考虑 Unicode 正规化与大小写折叠。\n- 接口校验：对长度、编码和非法代理单元做明确约定。\n- 测试：加入组合音标、肤色 Emoji、旗帜和 ZWJ 序列。\n\n## 一句话带走\n\n字符串不是一串“字母格子”，而是一层层编码和组合规则。`length` 只能回答“有多少个 UTF-16 编码单元”，不能回答用户看见了多少个字符；真正贴近用户体验的单位，是 Unicode 定义的 grapheme cluster。\n\n## 延伸阅读\n\n- [Unicode Standard Annex #29](https:\u002F\u002Funicode.org\u002Freports\u002Ftr29\u002F)\n- [Unicode Emoji 序列分类图](https:\u002F\u002Fcommons.wikimedia.org\u002Fwiki\u002FFile:Pistol_emoji_evolution.svg)","\u002Fuploads\u002F2026-08-09\u002Fd9450674-0a8a-48c3-8319-684c8a581547.jpg",[],[],"Foundit","https:\u002F\u002Ffoundit.cn","f39339b1-aaa6-4e86-b0c2-a6e6a21113b5",{"id":18,"name":19,"slug":20,"description":21},"6179d3b6-dc34-4483-9ded-3cd9f1b37a47","科普","abbreviation","介绍各领域新兴概念",[23,27,31],{"id":24,"name":25,"slug":26},"144abe77-0dc6-4f66-a176-20bddb1c0bfa","编程","coding",{"id":28,"name":29,"slug":30},"7c76bfc2-f80f-4ee0-a95d-27bd8708b434","技术","slug",{"id":32,"name":33,"slug":34},"4c2bbea6-eab7-40a8-8447-1de478ff7749","分析","analyse","资料来源",null,"published",false,66,0,"2026-08-09T00:00:00.000Z","2026-08-09T12:43:54.842Z","2026-08-09T12:13:28.966Z",[45,54,63],{"id":46,"type":6,"title":47,"slug":48,"summary":49,"coverUrl":50,"authorName":14,"sno":51,"publishedAt":52,"createdAt":53},"62724dbd-ce49-4be9-afa9-4ba127513a62","语音搜索一定先变成文字吗？AI 开始绕过转写这一步","speech-to-retrieval-without-transcription","传统语音搜索先把声音转成文字，再拿文字查资料，一次听错就可能让搜索方向完全跑偏。Speech-to-Retrieval 尝试直接把语音与相关文档映射到同一个语义空间。本文用蒙克名画的例子讲清新旧架构及其边界。","\u002Fuploads\u002F2026-09-08\u002Fe1173842-1ec4-48f3-8349-240fc2d78197.jpg",50,"2026-08-30T00:00:00.000Z","2026-08-14T03:06:11.525Z",{"id":55,"type":6,"title":56,"slug":57,"summary":58,"coverUrl":59,"authorName":14,"sno":60,"publishedAt":61,"createdAt":62},"0999bb14-a97c-4003-84e7-61ad2da998e0","文件删除以后去了哪里？为什么有时还能恢复？","where-deleted-files-go-data-recovery","普通删除往往只是移除文件系统里的索引并把空间标记为可重用，数据本身未必立即消失；SSD 的 TRIM、磨损均衡与加密又让情况更加复杂。本文区分回收站、删除、覆盖和安全清除，说明何时应停止写入设备。","\u002Fuploads\u002F2026-09-08\u002Fb7222764-de75-4863-8679-0a94a8965af3.jpg",51,"2026-08-20T00:00:00.000Z","2026-08-14T03:06:15.710Z",{"id":64,"type":6,"title":65,"slug":66,"summary":67,"coverUrl":68,"authorName":14,"sno":69,"publishedAt":70,"createdAt":71},"55f42805-a25a-4ce3-89d9-121b9a268a8f","0.1＋0.2 为什么不等于 0.3？计算机真的算错了吗？","why-point-one-plus-point-two-not-point-three","许多十进制小数无法用有限位二进制精确表示，计算机只能保存最接近的值。微小误差在显示时常被隐藏，却会在比较、累计和金额计算中冒出来。本文用三分之一的类比讲清浮点数，并给出可靠的处理原则。","\u002Fuploads\u002F2026-09-08\u002Ffd3a7daf-2a2c-4ef0-8f57-38f1d9de962c.jpg",54,"2026-09-06T00:00:00.000Z","2026-08-14T03:06:15.129Z"]