跳到正文

别再用 length 统计用户文本:一次 Unicode 字符数和 ESLint 护栏复盘(1):用户眼里的一个字,与 UTF-16 的一个单元

别再用 length 统计用户文本:一次 Unicode 字符数和 ESLint 护栏复盘 · 第 1 篇(当前) · 2 · 3

这次问题最初很小:昵称编辑框右下角有一个字数计数,代码直接用了 value.length。普通中文、英文都没问题,遇到 emoji 和国旗时就不对了。

😀 会被算成 2,🇯🇵 会被算成 4。用户看到的是一个字符,前端计数却像在数底层编码单元。这个 bug 一旦出现在昵称、签名、备注、举报描述、搜索词、弹窗 prompt 这类用户可见输入里,表现会很别扭:计数提前超限、截断把 emoji 切坏、头像首字母取不到用户真正看到的第一个字符,或者原生 maxlength 在浏览器输入阶段就提前阻止继续输入。

要修正计数和截断,需要先确认产品的「一个字」指哪一层。这里把用户感知字符、Unicode 码点、UTF-16 单元与字节分开,并解释国旗、组合音标和 ZWJ 序列为何会让常见字符串 API 失效。

产品里的「一个字」按什么算

这类 bug 容易反复出现,是因为日常说的「一个字」其实混了好几层概念。中文里说「字」,可能指汉字;设计稿里写「20 个字符」,可能指输入框右下角的计数;Unicode 文档里说 character,又可能指更底层的抽象字符。产品里的昵称、签名、备注、举报描述和搜索词,最关心的是用户在屏幕上感知到的一个完整符号。

所以这篇文章里的「一个字」指的是更接近 Unicode grapheme cluster 的单位。它可以翻成「字素簇」,也可以在工程上下文里叫「用户感知字符」。

文本 用户感知字符数 Unicode code point 数 UTF-16 code unit 数 为什么容易踩坑
A 1 1 1 三层刚好一致
你 1 1 1 三层刚好一致
😀 1 1 2 一个 emoji 落到 UTF-16 是一对 surrogate
🇯🇵 1 2 4 一个国旗由两个 Regional Indicator 组合
é 1 2 2 一个显示符号可以由字母和 combining mark 组合

面向用户的字数额度、预览截断和取首字符,通常应该按用户感知字符处理。length、slice()、charAt() 操作的却是 UTF-16 code unit,这就是这次 bug 的根。外部接口如果另有长度协议,输入计数还要遵循协议;按码点计数时,截断也需要单独选择边界,具体输入处理见计数、截断与输入框必须共用边界。

同一个字符在用户感知、Unicode code point 和 UTF-16 code unit 三层里的差异

Unicode 组合边界:一个字可能由多个部分组成

真实输入中,除了国旗,还有音标、肤色、家庭 emoji 和复杂文字等多码点组合。它们各有组合边界,工具层测试也覆盖了这些情况。

区分这些组合,需要先明确 code point、code unit 和 emoji 的含义:

code point 是 Unicode 给每个抽象字符或符号分配的编号,例如 A 是 U+0041,😀 是 U+1F600。它和字节、用户感知字符属于不同层级。

code unit 是某种编码格式实际处理的最小编码单元。UTF-16 的 code unit 是 16 位值;JavaScript 的 length 数的就是 UTF-16 code unit 数。

emoji 是 Unicode 里按 emoji 规则显示和组合的一类字符或序列,包括笑脸、手势、符号、旗帜、家庭、职业等。emoji 不一定只由一个 code point 组成。

下面这些类型都可能让 length、slice() 和原生 maxlength 给出违背用户直觉的结果,因此每一类都应该进入测试。

补充平面字符:一个编号,两个 UTF-16 单元

Unicode 里超过 U+FFFF 的 code point 位于补充平面。它们的编号仍然只是一个 Unicode code point,但放进 UTF-16 时,需要拆成两个 16 位 code unit。

😀 是最容易看到的例子。它的编号是 U+1F600,UTF-16 表示是 \uD83D\uDE00;汉字里的 𠮷 也是补充平面字符,编号是 U+20BB7。用户看到一个符号,JavaScript length 却会得到 2。如果用 slice(0, 1) 截断,就会拿到半个 UTF-16 组合。

补充平面字符在 UTF-16 里会被拆成一对 surrogate

surrogate pair 是 UTF-16 用来表示补充平面 code point 的一对 16 位 code unit。前一个叫 high surrogate,后一个叫 low surrogate。单独拿其中一个出来没有完整字符意义,所以 slice(0, 1) 这类操作可能切出「半个 emoji」。

旗帜 emoji:两个区域符号拼成一面旗

国家或地区旗帜由两个 Regional Indicator 拼成一个 emoji sequence。🇯🇵 由 U+1F1EF 和 U+1F1F5 组成,🇺🇸 由 U+1F1FA 和 U+1F1F8 组成。两个 Regional Indicator 都位于补充平面,所以一面旗帜通常是 2 个 code point、4 个 UTF-16 code unit;用户看到的仍然是一面旗。

旗帜 emoji 由两个 Regional Indicator 组合成一个用户感知字符

Regional Indicator 是一组专门用于拼区域旗帜的 Unicode 符号,对应 A 到 Z。两个 Regional Indicator 放在一起时,可以按区域代码显示成一面旗帜。JP 对应日本,US 对应美国,CN 对应中国。

emoji sequence 是由多个 code point 按 Unicode Emoji 规则组合成的 emoji。它看起来可能是一个完整符号,底层却是一串编号。

肤色修饰:modifier 要跟着前一个 emoji 一起看

有些 emoji 可以跟一个 modifier 组合,用后者修改前者的显示效果。最常见的 modifier 是肤色。👍🏽 由基础 emoji 👍 和肤色 modifier 🏽 组成,👋🏻 由 👋 和肤色 modifier 🏻 组成。

modifier 通常依附前一个 emoji 表达完整外观。按 code unit 截断时,如果截断点落在它和前一个手势之间,可能会留下一个孤立的肤色块,或者让用户输入的「中等肤色点赞」变回普通点赞。

emoji modifier 要依附前一个 emoji,组合后才是用户输入的完整外观

modifier 是修饰符,作用是改变前一个字符或序列的属性。emoji modifier 是 Unicode Emoji 里的特定修饰符,例如肤色 modifier;它不应该被当作普通独立字符随意截断。

ZWJ 组合:看起来一个整体,底下是一串 emoji

ZWJ 可以把多个 emoji 或符号连接成一个更大的显示单元。家庭、职业、情侣、部分性别变体 emoji 都大量使用这种形式。

👨‍👩‍👧 由 👨、ZWJ、👩、ZWJ、👧 组成,一共 5 个 code point;三个头像 emoji 各占 2 个 UTF-16 code unit,两个 ZWJ 各占 1 个,所以 length 是 8。用户看到的却是一个家庭 emoji。👩‍💻 也是同类组合,由 👩、ZWJ、💻 拼成一个女性技术人员 emoji。

ZWJ 把多个 emoji 粘成家庭或职业 emoji,底层 code point 数和用户感知字符数不同

ZWJ 是 Zero Width Joiner,编号 U+200D。它没有可见宽度,作用是把前后符号连接成一个组合显示。如果截断点落在 ZWJ 前后,原本的组合 emoji 就会散开。

Combining mark:重音符号依附在基础字符上

很多文字可以用一个基础字符加一个或多个 combining mark 组成。é 可以由 e 加 U+0301 COMBINING ACUTE ACCENT 表示,ñ 可以由 n 加 U+0303 COMBINING TILDE 表示。显示时,重音或波浪符号会贴到前面的字母上,用户看到的是一个带附加符号的整体。

这类符号来自真实文字系统:拉丁文字里的重音和音调、阿拉伯文和希伯来文里的元音 / 发音标记、南亚和东南亚文字里的附加符号,都可能以「基础字符 + 组合符号」的形式出现。它也会因为输入法、复制粘贴或 Unicode 规范化出现在看起来很普通的文本里,同样的 é 既可能是一个预组合 code point,也可能是 e 加 combining mark。

多看几个例子会更直观。e + ◌́ 会显示成 é,n + ◌̃ 会显示成 ñ;阿拉伯文里 ب + ◌َ 会显示成 بَ,希伯来文里 ש + ◌ׁ 会显示成 שׁ。后两类不一定是日常英文输入里常见的字符,但它们说明 combining mark 本来就是文字系统的一部分。

普通文字里也有同样的风险。按 code point 或 code unit 截断,截断点可能落在重音符号和基础字母之间,得到残缺文字。

组合符与基础字符共同构成一个字素簇

combining mark 是附着在前后字符上的组合符号,例如重音、音调、元音符号。它通常不应该被单独切出来,否则用户会看到残缺的文字。

Variation selector:同一个字符也能选择显示样式

有些字符后面可以跟 variation selector,用来选择这个字符的某种显示变体。❤️ 由 U+2764 HEAVY BLACK HEART 和 U+FE0F VARIATION SELECTOR-16 组成,后者表示希望采用 emoji 样式展示。☺️ 也常通过 U+FE0F 指定 emoji 样式。

这里可以把 ❤ 理解成一个文本符号,把 U+FE0F 理解成「请用 emoji 样式显示」的选择提示。两者合在一起后,才更稳定地显示成彩色的 ❤️。如果截断时把 variation selector 切掉,同一个字符在不同平台上可能从彩色 emoji 变成文本心形,视觉和用户输入意图都变了。

Variation selector 为文本符号选择 emoji 显示样式,截断后会改变外观

variation selector 是变体选择符,用来告诉字体和渲染系统选择某个字符的特定显示变体。U+FE0F 常见于让符号采用 emoji presentation。

Keycap sequence:按键帽是拼出来的

按键帽 emoji 也是组合出来的。1️⃣ 由数字 1、U+FE0F 和 U+20E3 COMBINING ENCLOSING KEYCAP 组成;#️⃣ 和 *️⃣ 也是同类序列。它们看起来像一个按键,底层却至少有 2 到 3 个 code point。

所以按 code unit 截断时,可能把一个按键帽拆成普通数字、选择符或外框符号。这类例子很适合放进测试,因为它提醒维护者:emoji 组合规则也会覆盖数字按键帽这种看起来普通的符号。

按键帽由三个码点组成,选择符本身不可见

keycap sequence 是按键帽序列。它由基础字符、emoji 样式选择符和外框组合符拼成,所以 1️⃣ 这类符号看起来像一个按键,底层却是一串组合。

Indic conjunct:复杂书写系统也有组合边界

南亚文字里,辅音、virama、元音符号等经常组合成一个书写单元。这个单元不一定等于一个 Unicode code point。क्षि 是天城文组合,包含辅音、virama 和元音符号;类似的组合边界也会出现在孟加拉文、泰米尔文、马拉雅拉姆文、僧伽罗文等多种印度系文字里。不同文字的具体规则不完全一样,但共同点是:用户看到或编辑的书写单元,经常不是单个 code point。

比如天城文里 क + ् + ष + ि 可以组成 क्षि,孟加拉文里 ক + ্ + ষ 可以组成 ক্ষ,马拉雅拉姆文里 ക + ് + ഷ 可以组成 ക്ഷ。这些例子看起来差异很大,底层都在用多个 Unicode 字符共同表达一个书写整体。

如果按 code point 硬切,可能切在一个印度文字组合的中间,导致字形散开、残缺,或者光标行为不符合用户对这套文字的直觉。按完整组合处理文本,同样是这些自然语言文字的编辑需求。

连写组合要保留原始字符序列

Indic conjunct 是印度系文字里的连写组合。多个字符通过 virama 等符号形成一个更紧密的书写单元,用户编辑时通常期待它作为整体或按语言规则处理。

virama 是印度系文字里的一个符号,常用来去掉辅音自带的元音,并让前后辅音形成连写或组合。

Spacing mark:看得见宽度,也可能仍是一个整体

有些元音或音标符号会占据显示宽度,并和基础字符共同组成用户感知字符。它们不像很多重音符号那样完全叠在基础字符上,但仍然属于同一组文字组合。क + ा 会显示成 का,ক + া 会显示成 কা,ក + ា 会显示成 កា。后面的元音符号看得见、占位置,但编辑时通常仍要跟基础字符一起处理。

泰文 กำ、老挝文 ກຳ 也有类似风险:可见元音 / 音标相关符号参与文字组合,截断时不能只看「它有没有宽度」。不同文字在 Unicode 分类里的细节可能不同;对前端输入来说,符号即使看得见宽度,用户仍可能期待它和基础字符一起删除、移动和截断。

元音符号占宽度,也可能属于同一个字素簇

spacing mark 是会占显示空间的组合符号。它不像很多重音符号那样完全叠在基础字符上,但仍然可能和基础字符属于同一个 grapheme cluster。

UAX #29 负责定义默认文本分割规则,其中明确包含不要在 emoji modifier sequence、emoji ZWJ sequence、emoji flag sequence、combining mark、Indic conjunct 和 spacing mark 等场景中间断开;UTS #51 Unicode Emoji 则定义了 emoji flag sequence、keycap sequence、modifier sequence 和 ZWJ sequence 这些 emoji 组合形式。Intl.Segmenter 后面能解决问题,靠的就是这些标准规则;前端代码只需要表达「按 grapheme 切」这个意图。

业务中的计数单位,要按输入用途和接口契约选择:

  • 昵称、签名、备注、举报描述、搜索词这类用户可见自然语言文本,默认按 grapheme cluster 算。
  • 手机号、验证码、密码、URL、token、文件路径这类技术格式字符串,按业务协议自己的长度规则算,很多时候原生 length 反而合理。
  • 后端明确说限制 UTF-8 字节数时,前端就要按 UTF-8 byte length 算,而不是按用户感知字符算。
  • UI 上写的是「还能输入多少字」时,除非产品另有说明,读者和用户都会期待它按第一类处理。

Unicode 这层到底发生了什么

前面那些例子都在提醒一件事:用户看到的「一个字」不是 JavaScript 字符串 API 默认操作的那一层。要解释为什么 length 会数错,需要分清 Unicode 怎样为字符编号、编码格式怎样表示这些编号,以及 JavaScript 字符串使用哪种编码单元。

字怎么表示:从符号到编码单元

可以把文本表示拆成几层:

  1. 用户感知字符(grapheme cluster):用户看到、光标移动和退格删除时最接近直觉的单位。例如 🇯🇵 是一个,é 是一个。
  2. Unicode code point:Unicode 给字符和符号分配的编号。例如 A 是 U+0041,你 是 U+4F60,😀 是 U+1F600。
  3. code unit:某种编码格式处理文本时使用的最小编码单元。UTF-8 的 code unit 是 8 位字节,UTF-16 的 code unit 是 16 位值。
  4. 字节:文件、网络传输和很多底层存储最终处理的单位。UTF-8 文本最常见的就是一串字节。

Unicode 负责「编号」和一整套文本规则,UTF-8、UTF-16、UTF-32 负责把这些编号编码成不同宽度的 code unit。Unicode FAQ 也把这件事说得很直接:Unicode 数据可以用 UTF-8、UTF-16、UTF-32 等方式表示,它们都能表示 Unicode,只是 code unit 宽度不同。

字符怎样对应 Unicode 码点

这里最容易混的是 code point 和用户感知字符。😀 是一个 code point,也是一个用户感知字符;🇯🇵 是两个 code point,但仍然是一个用户感知字符;很多家庭 emoji、职业 emoji 还会用 ZWJ 把多个 code point 连成一个整体。用户看到的是一个,底层可能已经是多个编号组合出来的。

Unicode、UTF-8 和 UTF-16 分别解决什么

Unicode 规定字符和符号的名称、编号、类别、大小写关系、文本边界等规则,不直接规定某个文件必须怎么存成字节。

UTF 是 Unicode Transformation Format,负责把 Unicode code point 编成 code unit。

  • UTF-8 用 1 到 4 个字节表示一个 code point。ASCII 范围里的英文字符通常 1 个字节,中文通常 3 个字节,emoji 通常 4 个字节。网页、接口、JSON、文件传输里最常见的是 UTF-8。
  • UTF-16 用 1 个或 2 个 16 位 code unit 表示一个 code point。基本多文种平面(BMP)里的字符通常 1 个 code unit,补充平面里的字符会用一对 surrogate pair,也就是 2 个 code unit。
  • UTF-32 用 1 个 32 位 code unit 表示一个 code point。它按 code point 随机访问更直接,但空间开销更大,Web 前端日常很少直接接触。

同一段文本可以按字素簇、码点、UTF-8 字节和 UTF-16 单元分别观察:

同一段文本,在不同层的长度各是多少

每一层都有自己的用途。接口传输时关心 UTF-8 字节很正常,解析 Unicode escape 时关心 code point 很正常,JS 旧字符串 API 关心 UTF-16 code unit 也有历史原因。产品输入框右下角的计数,应该按用户感知字符算。

UCS-2 和 UTF-16 差在哪里

JavaScript 的历史包袱和 UCS-2 有关系。

早期 Unicode 曾经被设计成纯 16 位编码,大家以为 65536 个位置足够覆盖现代文字。UCS-2 基本就是这种固定 16 位模型:一个字符对应一个 16 位值。它能覆盖 BMP 里的字符,但没有 surrogate pair,也就无法按今天的 Unicode 语义表示 U+10000 以上的补充字符。

后来 Unicode 发现 16 位不够用。Unicode 2.0 引入 surrogate 机制,UTF-16 从这里出现:BMP 字符仍然和 UCS-2 一样用一个 16 位 code unit;补充字符用两个专门范围内的 16 位 code unit 组合出来。Unicode FAQ 对 UCS-2 和 UTF-16 的区别也强调了这点:UCS-2 这个说法现在应该避免使用,因为它不解释 surrogate code point,不能完整表示补充字符。

可以把差异压成这个表:

文本 UCS-2 视角 UTF-16 视角
A 一个 16 位值 一个 16 位 code unit
你 一个 16 位值 一个 16 位 code unit
😀 没有一个合法的单值表示 \uD83D\uDE00 两个 code unit 组成一个 code point

所以说「JS 是 UCS-2」和「JS 是 UTF-16」都容易说歪。更准确的说法是:JavaScript 的字符串抽象保留了早期 16 位序列模型;现代 ECMAScript 规范会把这些 16 位元素当作 UTF-16 code unit 来解释,但很多老 API 的行为仍然暴露的是 16 位 code unit,而不是 code point 或 grapheme cluster。

这不是某一天所有 JS 引擎把内部字符串从 UCS-2「迁移」成 UTF-16,然后旧 API 忘了改。更像是规范和生态后来承认这串 16 位元素可以按 UTF-16 解释,并逐步补上 code point 友好的 API;但 length、索引和一批早期字符串方法为了兼容,继续保持 16 位元素的语义。

JavaScript 为什么会数到 UTF-16 code unit

JavaScript 诞生在 1990 年代,字符串模型沿用了当时常见的 16 位字符序列思路。等 Unicode 扩展出补充平面、UTF-16 通过 surrogate pair 表示更多字符之后,JavaScript 没法把既有字符串索引和 length 语义推翻重来。否则大量老代码会直接改变行为。

当前 ECMAScript 规范里的 String type 仍然定义为一串 16-bit unsigned integer values;规范同时说明,当它用于表示文本时,每个元素会被当作 UTF-16 code unit。length 返回的也是这串 16 位元素的数量。

这就是为什么下面这些 API 都天然站在 code unit 层:

'😀'.length // 2
'😀'.charAt(0) // '\uD83D',半个 surrogate pair
'😀'.slice(0, 1) // '\uD83D',仍然是半个

ES2015 之后,JavaScript 补了一些 code point 友好的能力,例如 for...of、Array.from()、String.prototype.codePointAt()、String.fromCodePoint()。它们能让 😀 这种单 code point 的 emoji 表现更合理。

Array.from('😀') // ['😀']
Array.from('🇯🇵') // ['🇯', '🇵']

但 code point 仍然不是最终答案。Array.from('🇯🇵') 得到两个元素,因为国旗本来就是两个 code point 组合出来的。它比 length 往上走了一层,但还没走到用户感知字符这一层。

总结

用户看到的完整符号、Unicode 码点、编码单元和字节是不同层次。JavaScript 的 length 与索引按 UTF-16 单元工作,按码点迭代可以保住代理对,却仍可能分开国旗、音标和 ZWJ 组合。

用户输入的字数通常应按字素簇理解,接口若规定码点或字节上限,则继续遵循相应协议。计数单位明确以后,还要让截断与输入限制使用兼容的边界;单独换一个计数函数,不能保证完整文字不会被提前切掉。

下一篇:计数、截断与输入框必须共用边界。