别再用 length 统计用户文本:一次 Unicode 字符数和 ESLint 护栏复盘
这次问题最初很小:昵称编辑框右下角有一个字数计数,代码直接用了 value.length。普通中文、英文都没问题,遇到 emoji 和国旗时就不对了。
😀 会被算成 2,🇯🇵 会被算成 4。用户看到的是一个字符,前端计数却像在数底层编码单元。这个 bug 一旦出现在昵称、签名、备注、举报描述、搜索词、弹窗 prompt 这类用户可见输入里,表现会很别扭:计数提前超限、截断把 emoji 切坏、头像首字母取不到用户真正看到的第一个字符,或者原生 maxlength 在浏览器输入阶段就把组合字符截断。
修这个问题时,单点改 length 不够。它很容易在下一次新增表单、弹窗、搜索框时重新出现。所以最后做成了三层:
- 先补一组 Unicode 文本工具,统一按用户感知字符计数、截断、取首字符。
- 再把现有昵称、签名、备注、举报描述、搜索输入等调用点迁过去。
- 最后补 ESLint 规则,让后续新增的
.length、slice()、substring()、charAt()和静态maxlength在 review 前就暴露出来。
先回答:产品里的「一个字」按什么算
这类 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() 站在最后一列看问题,这就是这次 bug 的根。
Unicode 组合边界:一个字可能由多个部分组成
这张表里只列了几个最直观的例子。真实输入会更花,因为 Unicode 里本来就有很多「多个 code point 组合成一个用户感知字符」的类型。工具层测试把这些类型都列进去,是为了守住这些真实存在的组合边界。
先把后面会反复出现的几个底层词放在手边。这里不单独做一个「名词解释」章节,因为这些词最好跟它们影响判断的地方连在一起看。
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 组合。
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;用户看到的仍然是一面旗。
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 截断时,如果把它和前面的手势拆开,可能会留下一个孤立的肤色块,或者让用户输入的「中等肤色点赞」变回普通点赞。
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 是 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 是变体选择符,用来告诉字体和渲染系统选择某个字符的特定显示变体。
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 硬切,可能切在一个印度文字组合的中间,导致字形散开、残缺,或者光标行为不符合用户对这套文字的直觉。这一类例子提醒我们,问题不只是 emoji,很多自然语言文字本身也有组合规则。
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 字符串模型放到同一张地图里看。
字怎么表示:从符号到编码单元
可以把文本表示拆成几层:
- 用户感知字符(grapheme cluster):用户看到、光标移动和退格删除时最接近直觉的单位。例如
🇯🇵是一个,é是一个。 - Unicode code point:Unicode 给字符和符号分配的编号。例如
A是U+0041,你是U+4F60,😀是U+1F600。 - code unit:某种编码格式处理文本时使用的最小编码单元。UTF-8 的 code unit 是 8 位字节,UTF-16 的 code unit 是 16 位值。
- 字节:文件、网络传输和很多底层存储最终处理的单位。UTF-8 文本最常见的就是一串字节。
Unicode 负责「编号」和一整套文本规则,UTF-8、UTF-16、UTF-32 负责把这些编号编码成不同宽度的 code unit。Unicode FAQ 也把这件事说得很直接:Unicode 数据可以用 UTF-8、UTF-16、UTF-32 等方式表示,它们都能表示 Unicode,只是 code unit 宽度不同。
A -> U+0041
你 -> U+4F60
😀 -> U+1F600
🇯🇵 -> U+1F1EF + U+1F1F5这里最容易混的是 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 前端日常很少直接接触。
同一个 😀,从不同层看会得到不同答案:
用户感知字符:1 个
Unicode code point:1 个,U+1F600
UTF-8:4 个字节
UTF-16:2 个 code unit国旗更能说明问题:
🇯🇵
用户感知字符:1 个
Unicode code point:2 个,U+1F1EF + U+1F1F5
UTF-8:8 个字节
UTF-16:4 个 code unit每一层都有自己的用途。接口传输时关心 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 往上走了一层,但还没走到用户感知字符这一层。
从 length 到 Intl.Segmenter:计数应该数哪一层
Unicode 机制解释的是「为什么会数错」。工程实现还要回答另一个问题:既然用户可见文本应该按 grapheme cluster 算,项目代码要怎样稳定地计数、截断和限制输入,并且不让旧写法再混回来。
length 为什么有问题
JavaScript 字符串的 length 数的是 UTF-16 code unit。它不是用户看到的字符数,也不是 Unicode code point 数。
'A'.length // 1
'你'.length // 1
'😀'.length // 2
'🇯🇵'.length // 4很多人第一反应会改成 Array.from(value).length。这能把 😀 算成 1,因为 Array.from() 按 code point 迭代;但它仍然会把 🇯🇵 算成 2,因为国旗是两个 Regional Indicator code point 组合出来的。
Array.from('😀').length // 1
Array.from('🇯🇵').length // 2真正贴近输入框、光标移动、退格删除和用户感知的是 grapheme cluster。Unicode 的 UAX #29 Text Segmentation 里定义了这类文本边界;Web 运行时现在也提供了 Intl.Segmenter 来做分词、断句和 grapheme segmentation。
用 Intl.Segmenter 把用户感知字符切出来
前面讲了那么多编码层级,真正落到项目里,解决方案不应该变成“业务代码手写一份 Unicode 规则”。业务代码要表达的是:这里需要按用户感知字符计数、截断和取首字符。至于边界怎么判断,应该交给运行时已经提供的国际化能力。
实际写法上,先把原字符串切成正确的 grapheme 数组,再基于这个数组做 length、截断和取第几个字符。计数读取 segments.length,让统计逻辑跟用户感知字符保持一致;这个变化才真正解决了「看起来一个字,length 却算成多个」的问题。
Intl 是 JavaScript 的国际化能力入口
这里的 Intl 是 ECMAScript Internationalization API 的入口,由 JavaScript 运行时内建提供。它覆盖一组国际化能力,常见功能包括日期格式化、数字格式化、排序比较、复数规则、相对时间格式化和文本分段。
Intl.Segmenter 负责文本切分。它可以按 locale 和 granularity 把文本切成 grapheme、word 或 sentence。输入框长度统计关心的是 grapheme,也就是用户感知字符这一层。
Segmenter 找的是边界,不会改变字符串本身
最小用法是这样:
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
Array.from(segmenter.segment('A😀🇯🇵e\u0301'), (part) => part.segment)
// ['A', '😀', '🇯🇵', 'é']granularity: 'grapheme' 表示按用户感知字符切。segment() 返回的每一项里除了 segment,还有 index 和 input。这里的 index 仍然是原字符串里的 UTF-16 code unit offset;Intl.Segmenter 的价值在于它帮我们找到了安全边界,让业务代码不要把 surrogate pair、combining mark、ZWJ emoji 或国旗切开。
它没有让 JavaScript 字符串变成另一种内部存储,也没有改变 length 的定义。它做的是更上层的文本分段:在一串 UTF-16 code unit 上,根据 Unicode 文本分割规则和 locale 数据,告诉你哪些 code unit 范围应该被当作一个 grapheme、一个词或一句话。
这也是它适合放在底层工具里的原因。emoji 每年都在变,很多语言还有自己的组合字符和书写规则;运行时里的国际化数据和 Unicode 分割规则更适合维护这层判断。项目代码只需要把“我要按用户感知字符切”表达清楚。
项目里只暴露业务意图
项目里的核心工具最后变成了这样:
// shared/utils/unicodeText/splitUnicodeCharacters.ts
const Segmenter = globalThis.Intl?.Segmenter
const segmenter = Segmenter ? new Segmenter(undefined, { granularity: 'grapheme' }) : null
export default function splitUnicodeCharacters(value: string): string[] {
if (!segmenter) return splitUnicodeGraphemeClustersFallback(value)
return Array.from(segmenter.segment(value), (part) => part.segment)
}上层再封装出更直接的 API:
countUnicodeCharacters(value)
countTrimmedUnicodeCharacters(value)
isUnicodeTextBlank(value)
isUnicodeTextOverLimit(value, max)
sliceUnicodeCharacters(value, start, end)
substringUnicodeCharacters(value, start, end)
truncateUnicodeCharacters(value, max)
getUnicodeCharacterAt(value, index)调用方不需要记 grapheme cluster 的细节,只要表达业务意图。
const valueCount = computed(() => countUnicodeCharacters(model.value))
function onInput(e: Event) {
const target = e.target as HTMLInputElement
const nextValue = truncateUnicodeCharacters(target.value, maxLength)
if (target.value !== nextValue) target.value = nextValue
emit('update:modelValue', nextValue)
}这层封装还有一个好处:后续如果运行时支持情况、fallback 策略或测试样例变化,业务组件不用跟着改。组件只知道自己要的是“用户看到的字符数”。
fallback 只承诺兜住高风险边界
如果只服务最新浏览器,Intl.Segmenter 已经是很好的答案。MDN 目前把它标成 Baseline 2024,Can I Use 也能查到主流浏览器支持情况;但业务项目经常要考虑旧 Safari、旧 WebView 或某些内嵌浏览器。这里有两个兼容问题。
第一个问题是 Intl.Segmenter 可能不存在。这个好处理,特性检测后走 fallback。
第二个问题更容易被漏掉:fallback 里如果直接写 Unicode property escapes 的正则字面量,例如 /\p{Mark}/u,旧 Safari 会在解析 bundle 时直接抛 SyntaxError。失败发生在模块加载阶段,代码还没机会进入 fallback 分支,页面可能直接白屏。
fallback 里不能这样写:
// 旧 Safari 可能在解析阶段就炸掉
const markPattern = /\p{Mark}/u可以改成运行时构造,再用 try/catch 做能力检测:
function createUnicodePropertyTester(property, fallback) {
try {
const unicodePropertyRegExp = new RegExp(`\\p{${property}}`, 'u')
return (character) => unicodePropertyRegExp.test(character)
} catch {
return fallback
}
}这样新浏览器继续用内建 Unicode 数据;旧浏览器至少能走手工范围表,不会在 bundle 解析阶段把页面带崩。
fallback 不需要完整复刻 Unicode 数据表,但要覆盖产品里最容易出问题的场景:
- CRLF 和控制字符边界。
- combining mark,例如
e\u0301。 - variation selector,例如
❤️。 - keycap,例如
1️⃣。 - emoji modifier,例如
👍🏽。 - ZWJ emoji,例如
👨👩👧。 - regional indicator 国旗,例如
🇯🇵。 - Indic conjunct、spacing mark、Thai / Lao spacing vowel 等多脚本文本。
这些样例都应该进单测。测试不只是证明 helper 对,也是在告诉后面的人:这里处理的“字符”到底是哪种字符。
expect(countUnicodeCharacters('😀')).toBe(1)
expect(countUnicodeCharacters('🇯🇵')).toBe(1)
expect(countUnicodeCharacters('👍🏽')).toBe(1)
expect(countUnicodeCharacters('👨👩👧')).toBe(1)
expect(countUnicodeCharacters('e\u0301')).toBe(1)
expect(countUnicodeCharacters('❤️')).toBe(1)
expect(countUnicodeCharacters('1️⃣')).toBe(1)
expect(countUnicodeCharacters('क्षि')).toBe(1)
expect(countUnicodeCharacters('กำ')).toBe(1)原生 maxlength 也要让位给受控输入
一开始容易只盯着 JS 里的 .length。但用户输入长度限制还有另一个入口:模板上的原生 maxlength。
<input maxlength="20" />
<textarea maxlength="200" />它的问题更隐蔽。maxlength 在浏览器输入阶段工作,按 UTF-16 code unit 截断。业务代码还没来得及校正,输入框里的值已经被浏览器截过一次。遇到组合 emoji、国旗、音标和复杂文字时,表现和 .length 一样不符合用户直觉。
用户可见文本输入不要再写静态 maxlength,而是用 input 事件接管:
<input
:value="modelValue"
@input="onInput"
/>const MAX_CHARACTERS = 200
function onInput(e: Event) {
const target = e.target as HTMLInputElement
const nextValue = truncateUnicodeCharacters(target.value, MAX_CHARACTERS)
if (target.value !== nextValue) {
target.value = nextValue
}
emit('update:modelValue', nextValue)
}这里把 target.value 写回去,是为了让 DOM 里的输入值和 emit 出去的受控值立即对齐。否则有些组件在父级还没更新 props 前,输入框短时间里会显示超长内容。
密码、验证码、手机号、URL 这类输入另算。它们通常就是按协议或业务格式计数,原生 maxlength 反而合理。关键是要区分“用户可见自然语言文本”和“技术格式字符串”。
用 ESLint 把旧写法挡在 review 前
解决 helper 只是第一步。如果只改现有代码,半年后大概率还会有人写回:
if (nickname.trim().length === 0) return
const preview = nickname.slice(0, 10)
const initial = nickname.charAt(0)这类问题适合用 ESLint 兜住。它不负责理解完整业务,只负责在 review 前提醒:你现在操作的字符串看起来像用户可见文本,原生字符串 API 可能会把 Unicode 组合字符切坏。
先抓真正危险的字符串操作
规则只保护用户可见文本,不把所有字符串 API 都当成风险。它要抓住这些写法:
nickname.length
remark.trim().length
description.slice(0, max)
signature.substring(0, 20)
avatarName.charAt(0)也要抓住模板里的静态限制:
<input maxlength="20" />
<textarea maxlength="200" />
<input inputmode="search" maxlength="200" />但它必须放过这些写法:
items.length
Object.keys(payload).length
new Uint8Array(bytes).length
uid.length
token.slice(0, 8)
password.length >= 6
phone.length === 11
path.slice(0, -1)
hex.length % 2 === 0这一步最容易走偏。规则写得太弱,扫不出真实问题;写得太强,项目里到处都是 warning,最后大家只会把规则关掉。
语义判断比粗暴禁用更重要
最后的判断模型大概是:
- 有 TypeScript 类型信息时,先确认目标确实是 string。
- 没有类型信息时,用字符串字面量、模板字符串、
String(value)、ref<string>()、computed<string>()和明确返回 string 的方法链辅助判断。 - 收集变量名、属性名、函数名、调用参数和 Vue template 属性里的语义词。
- 命中用户文案语义时报告,例如
nickname、remark、description、signature、title、content、search。 - 命中技术字符串语义时放过,例如
uid、token、cookie、path、url、hex、password、phone、i18n key。 - 如果同时命中用户文案和技术语义,用户文案优先。
codeTitle.length仍然应该报告,因为它是标题,不是验证码。
语义匹配前要先归一化名字。didFromCookie、did_from_cookie、did-from-cookie 都应该能变成类似 did from cookie 的词串;否则名单会越写越散。
Vue template 还要注意 class 噪声。text-ink-primary、content-center 这类 Tailwind class 不能被当成用户文案证据。class 可以帮助识别 password-input 这种技术输入,但不能因为里面有 text 就误报。
常量名单和规则实现分开维护
第一版规则很容易把所有东西都写进一个文件:AST 遍历、错误文案、方法名单、技术字段名单、用户文案字段名单、正则表达式,全塞在一起。短期能跑,维护起来很痛。
这类规则有两种变化频率:
- AST 遍历、类型判断、report 逻辑相对稳定。
- 方法名单、黑白名单、替代文案、项目特殊字段会频繁调整。
所以最后拆成了:
eslint/rules/no-native-string-user-text-ops/
constants.js
index.mjsconstants.js 只放经常变化的名单和文案,而且尽量用数组:
export const NATIVE_STRING_TEXT_METHOD_NAMES = [
'charAt',
'slice',
'substring',
'substr',
]
export const DEFAULT_TECHNICAL_NAME_WORDS = [
'uid',
'token',
'cookie',
'path',
'url',
'hex',
'password',
'phone',
'i18n',
'key',
'keys',
]
export const DEFAULT_USER_TEXT_NAME_WORDS = [
'nickname',
'remark',
'description',
'signature',
'title',
'content',
'search',
]入口文件再把数组转成 Set、Map 或 RegExp:
const NATIVE_STRING_TEXT_METHODS = new Set(NATIVE_STRING_TEXT_METHOD_NAMES)
function toWordPattern(words, patternFragments = []) {
const parts = [
...words.map(escapeRegExp),
...patternFragments,
]
return parts.length ? `\\b(?:${parts.join('|')})\\b` : null
}这里有一个很具体的取舍:常量文件里不要维护长正则,也不要直接维护 Set / Map。
长正则的问题是 review 成本高。追加一个词、删除一个词、调整排序、解释例外,都藏在一行很长的字符串里。Set / Map 对运行时好,但对维护者没有数组直观。规则加载时再转换成高效结构就够了,源文件应该优先服务 review。
黑白名单支持项目追加
默认名单只能覆盖跨项目稳定语义。每个业务都会有自己的技术字符串和用户文案字段,例如 sku、serial、checksum、caption、headline。如果每次都改规则源码,规则很快会变成某个项目的字段大全。
更好的方式是默认值加项目级追加配置:
// eslint.config.mjs
'sugo/no-native-string-user-text-ops': [
'warn',
{
// 默认名单在规则内部维护;这里的配置只做项目级追加。
// 新增技术字符串语义时加到 technicalNamePatterns,例如 sku / serial / checksum;
// 新增用户可见文本语义时加到 userTextNamePatterns,例如 caption / headline。
technicalNamePatterns: [],
userTextNamePatterns: [],
},
]这里用了追加,不用覆盖。默认名单是安全基线,项目配置只是补自己的语义。否则某个项目一旦传了自定义名单,就可能把 uid / token / password / phone 这类通用技术字符串全部丢掉。
规则单测也要同时覆盖默认名单和项目配置:
// 默认技术字符串
"const i18nKey = 'string_confirm'; i18nKey.trim().length > 0"
// 项目级技术字段
{
code: "const sku = 'ABC'; sku.length",
options: [{ technicalNamePatterns: ['\\bsku\\b'] }],
}
// 项目级用户文本字段
{
code: "const caption = 'SUGO'; caption.slice(0, 2)",
options: [{ userTextNamePatterns: ['\\bcaption\\b'] }],
errors: [{ messageId: 'noNativeStringMethod' }],
}Warning 起步更适合团队迁移
这类规则第一次进项目时,我更倾向于先开 warning。
原因很现实:它一定会扫出一批历史问题,也一定会扫出少数需要判断的边界。直接开 error,容易让大家为了过 CI 写一堆 eslint-disable;开 warning 可以先把问题暴露出来,再按模块逐步处理。
处理顺序也不要一刀切。可以按风险分层:
- 昵称、签名、备注、举报描述、弹窗 prompt 这类保存型文本,优先改。
- 搜索词、评论、输入框本地截断,第二批改。
- 只读展示预览里的
slice()、charAt(),按影响范围改。 - chatroom、message 这类别人正在大改的模块,先留 warning,避免抢所有权。
- 明确是技术字符串的误报,优先改变量名或补默认 / 项目名单;不要随手 disable。
只有两类地方适合局部 disable:
- 当前字符串确实是协议、编码、路径、token、手机号、验证码等技术格式。
- 规则短期无法理解上下文,但人能确认原生长度就是业务需要。
disable 也要写理由:
// eslint-disable-next-line sugo/no-native-string-user-text-ops -- 这里校验的是 6 位短信验证码,不是用户可见自然语言文本。
if (code.length !== 6) return false如果解释写不出来,通常说明这行不该 disable。
验证同时覆盖工具、规则和业务组件
这个改动不能只测一个 helper。工具层证明 Unicode 边界切对了,规则层证明危险写法会被抓出来,业务组件层证明真实输入不会闪回、截断半个字符或 emit 出超长值。
工具层测试要覆盖 Unicode 样例:
expect(truncateUnicodeCharacters('A😀🇯🇵𠮷B', 3)).toBe('A😀🇯🇵')
expect(truncateUnicodeCharacters('🇯🇵🇺🇸🇹🇷', 2)).toBe('🇯🇵🇺🇸')
expect(truncateUnicodeCharacters('👍🏽👍🏿B', 2)).toBe('👍🏽👍🏿')
expect(truncateUnicodeCharacters('👨👩👧B', 1)).toBe('👨👩👧')
expect(truncateUnicodeCharacters('e\u0301e\u0301B', 2)).toBe('e\u0301e\u0301')规则层测试要覆盖该报和该放过:
// 应该报告
nickname.trim().length === 0
remark.slice(0, max)
description.substring(0, 20)
avatarName.charAt(0)
<input inputmode="search" maxlength="200" />
// 应该放过
items.length
Object.keys(payload).length
new Uint8Array(bytes).length
uid.length
password.length >= 6
phone.length === 11
i18nKey.trim().length > 0业务组件测试要覆盖真实输入行为。比如搜索框输入 199 个 A 再加 🇯🇵B,最后应该保留 199 个 A 和一个完整国旗,而不是截成半个 flag,也不是把 B 放进去。
const value = `${'A'.repeat(199)}🇯🇵B`
await wrapper.get('input').setValue(value)
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
`${'A'.repeat(199)}🇯🇵`,
])
expect((wrapper.get('input').element as HTMLInputElement).value).toBe(
`${'A'.repeat(199)}🇯🇵`,
)最后再跑一次项目 lint。理想状态是 warning 列表里的每一项都能解释清楚:哪些是历史模块待处理,哪些是所有权冲突先不碰,哪些是规则误报已经修掉。
总结
用户可见文本里的“字符数”默认按 grapheme cluster 处理。
length、slice()、substring()、charAt()和静态maxlength都不是合适默认值。Intl.Segmenter是首选,但兼容层不能写会让旧浏览器解析失败的语法。Unicode property escapes 这类能力放进new RegExp()和try/catch,不要写成模块顶层正则字面量。fallback 要有明确承诺。它不一定完整复刻 Unicode 规范,但必须覆盖产品最容易踩坑的样例,并且每个新增样例都进测试。
ESLint 规则要以“降低维护成本”为目标。类型判断、语义名单、替代 API、误报处理和配置入口都要一起设计;只写一个粗暴禁用
.length的规则,很快会被项目反噬。规则源码里的常量要用最容易 review 的形态维护。经常变化的名单放
constants.js,用数组表达;运行时需要Set、Map、RegExp时,在入口文件转换。默认黑白名单和项目配置要分层。默认值承担跨项目安全基线,项目配置只追加自己的语义。这样规则可以复用,也不会因为某个业务字段把通用边界污染掉。
这类问题看起来只是一个字符计数 bug,最后其实是在补一条工程护栏:让用户看到的文本,按用户的方式处理;让容易忘的边界,变成工具会提醒的规则。
