别再用 length 统计用户文本:一次 Unicode 字符数和 ESLint 护栏复盘(2):计数、截断与输入框必须共用边界
别再用 length 统计用户文本:一次 Unicode 字符数和 ESLint 护栏复盘 · 1 · 第 2 篇(当前) · 3
昵称中的国旗看起来是一个字符,JavaScript 的 length 却得到 4;按码点迭代会得到 2。这里把输入额度、截断和组件内部限长接到同一份明确契约上:按用户感知字符计数时使用字素簇,接口另有码点预算时则分别设计计数与安全前缀。
输入组件还可能先于业务回调截断值,因此纯文本工具、原生 maxlength、输入法组合事件和组件库的扩展点需要一起检查。
工程上,按 grapheme cluster 计数之后,截断和输入限制也要使用同一套边界。项目需要统一这些操作,并约束调用方,避免新增代码又退回原生字符串 API。
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 数组,再基于数组计数、截断和取第几个字符。此时 segments.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 的定义。Intl.Segmenter 工作在更上层的文本分段:在一串 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)
}
这组纯运行时 helper 后来沉淀进了 sheng-tool@0.2.0。如果项目已经愿意引入这个包,优先从根入口导入;下面的源码工作区保留给需要私有化工具或排查 fallback 细节的场景。
pnpm add sheng-tool@^0.2.0
import { countUnicodeCharacters, truncateUnicodeCharacters } from 'sheng-tool'
上层再封装出更直接的 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 truncateAndEmit(target: 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 property escapes 的浏览器也有机会进入手工范围表分支。它只处理这一项语法风险;构建 target 仍需覆盖目标浏览器,例如把 optional chaining 等其他新语法按需要转译,不能只加一个 try/catch 就认定整份 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 限制用户输入,浏览器通常会阻止继续输入超过上限的内容;粘贴、输入法和组合字符的具体处理还受浏览器影响。它不能表达「最多 20 个 grapheme」,也不能保证按完整字素簇保留输入。遇到组合 emoji、国旗、音标和复杂文字时,用户可能还没输入到 20 个感知字符,就已经碰到限制。
按 grapheme 限制的输入,要在输入法提交完整文本后再截断。下面用组合事件维护状态,同时处理普通输入和粘贴:
<input
:value="modelValue"
@input="onInput"
@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd"
/>
const MAX_CHARACTERS = 200
let composing = false
function truncateAndEmit(target: HTMLInputElement) {
const nextValue = truncateUnicodeCharacters(target.value, MAX_CHARACTERS)
if (target.value !== nextValue) {
target.value = nextValue
}
emit('update:modelValue', nextValue)
}
function onInput(e: Event) {
if (composing || (e as InputEvent).isComposing) return
truncateAndEmit(e.target as HTMLInputElement)
}
function onCompositionStart() {
composing = true
}
function onCompositionEnd(e: CompositionEvent) {
composing = false
truncateAndEmit(e.target as HTMLInputElement)
}
这里把 target.value 写回去,是为了让 DOM 里的输入值和 emit 出去的受控值立即对齐。否则有些组件在父级还没更新 props 前,输入框短时间里会显示超长内容。
组合期间不要改写输入框的中间值,否则中文拼音候选、日文转换等可能被打断。composing 覆盖组合事件区间,InputEvent.isComposing 再补一层判断。还要测试组合结束后接着触发 input 的平台,确认没有重复保存等业务副作用;这里的 emit 只同步值,不提交请求。
计数规则也要和服务端对齐。前端显示 20 个 grapheme,不代表它能满足后端 20 个 UTF-16 单元或某个 UTF-8 字节上限;两种限制同时存在时,要分别校验和说明,不能只修展示计数。
密码、验证码、手机号、URL 这类输入还要按各自协议或业务格式处理。确定只允许 ASCII 的验证码、手机号等字段,可以直接使用原生 maxlength;密码和 URL 是否适用,仍要核对真实计数单位。
接口按码点限制时,计数和截断要分别选择
搜索词属于自然语言输入,但它也可能受外部接口的码点预算约束。此时不能只把计数换成 Array.from(value).length,截断却继续使用原生 value.slice(0, limit);前者数码点,后者切 UTF-16 单元,遇到 😀 就会错位。
JavaScript 的字符串迭代器按码点遍历。MDN 对字符串迭代器的说明也明确区分了两个边界:代理对会被保留,字素簇仍可能被拆开。因此,「取前 N 个码点」可以直接写成下面的片段:
const value = 'A🇯🇵B'
const limit = 2
const codePoints = Array.from(value)
codePoints.length // 4 个码点
codePoints.slice(0, limit).join('') // 'A🇯',恰好保留前 2 个码点
这里 slice() 操作的是码点数组,不是原始字符串。对于有效的 Unicode 文本,它不会切出半个代理对;但国旗里的两个区域指示符仍会分开,e\u0301 的音标、肤色修饰符和 ZWJ 序列也可能被截掉一部分。结果按码点计算准确,显示含义却可能改变。
如果目标是「最多 N 个码点,并保留完整字素」,就要换一种截断策略:先按字素分段,再累计每段的码点成本。遇到第一个放不下的字素就停止,保留原文前缀。字素边界采用前文工具的分段规则,依据是 Unicode UAX #29。
import { splitUnicodeCharacters } from 'sheng-tool'
const value = 'A🇯🇵B'
const limit = 2
const parts: string[] = []
let usedCodePoints = 0
for (const grapheme of splitUnicodeCharacters(value)) {
const cost = Array.from(grapheme).length
if (usedCodePoints + cost > limit) break
parts.push(grapheme)
usedCodePoints += cost
}
parts.join('') // 'A',占 1 个码点,剩下的 1 个额度放不下整面国旗
两种策略都按码点校验上限,输出要求不同:
| 输入 | 码点预算 | 取前 N 个码点 | 预算内保留完整字素 |
|---|---|---|---|
A😀B |
2 | A😀 |
A😀 |
A🇯🇵B |
2 | A🇯 |
A |
A🇯🇵B |
3 | A🇯🇵 |
A🇯🇵 |
e\u0301B |
1 | e |
空字符串 |
👨👩👧B |
4 | 👨👩(末尾保留 ZWJ) |
空字符串 |
e\u0301B 在表中用转义写明组合方式。完整字素策略允许额度没有用满,不能跳过国旗再拼上后面的 B,把 A🇯🇵B 变成 AB;那是在过滤内容,已经不是截取前缀。用户输入通常适合保留完整字素,明确要求码点前缀的协议处理则使用第一种策略。
sheng-tool 现有的 countUnicodeCharacters 和 truncateUnicodeCharacters 按字素簇工作,不能当作码点计数或码点预算截断来调用。需要复用时,应把码点计数和预算截断封装成命名明确的业务 helper,并让前后端共用;避免一个含混的 truncateCharacters 同时承担两种契约。
预算本身也要先校验为非负安全整数:0 返回空字符串,负数、小数、NaN 和无穷大按约定拒绝,不能直接交给 slice() 隐式转换。例如 slice(0, -1) 会去掉末尾一个元素,完全不是「额度为 0」。展示的计数和服务端校验都以最终提交文本为准,包含 trim、site: 等操作符拼接后的结果。也不要为挤进额度而静默改成 NFC 或 NFD,Unicode 规范化可能改变码点数,应先明确是否属于输入契约。
一个接口边界案例:2026-10-21 对 DeepSeek Search 的实测中,
'Nuxt' + ' '.repeat(123) + '😀'是 128 个码点、129 个 UTF-16 单元,请求成功;'Nuxt ' + 'e\u0301'.repeat(62)是 129 个码点、67 个字素簇,请求返回 400。接口文档将查询上限写为 128 个 Unicode 字符;这组测试支持其当时按码点执行上限的判断,不能据此把所有 API 的「Unicode 字符」都解释成码点。
组件库的差别在于谁负责截断
使用组件库时,除了看有没有字数统计,还要看业务需要接管多少逻辑:只传一个计数函数就能自动限制输入,还是必须再提供截断函数,或者整层输入行为都要自己处理。以下按 2026-10-21 核对的官方文档和当前源码整理,适用于这里列出的文本输入组件;接入时仍要核对项目锁定版本。
在这批组件中,Element Plus 的接入成本最低:从 2.13.7 起,已有 maxlength、show-word-limit 的输入框只需补一个 count-graphemes 计数函数,组件就会绕过原生长度限制,并负责超量输入的截断。支持 Intl.Segmenter 的环境下,它按字素边界处理插入内容;不需要业务再写一个截断回调。官方文档给出了配置和降级说明。
例如,搜索接口以码点为预算,而输入希望保留完整字素,可以这样配置:
<el-input
v-model="query"
:maxlength="128"
show-word-limit
:count-graphemes="countCodePoints"
/>
const countCodePoints = (value: string) => Array.from(value).length
如果额度按用户感知字符定义,则改为传入前文的 countUnicodeCharacters。count-graphemes 是配置名,实际计数单位由传入函数决定;官方示例也明确说明 Array.from() 只数码点。
| 组件库 | 默认长度处理 | 业务还需要提供什么 |
|---|---|---|
| Element Plus ≥ 2.13.7 | 未配置时仍按原生 UTF-16 规则 | 一个 count-graphemes 计数函数,组件接管限长和截断 |
| Ant Design React | 普通 maxLength 沿用原生规则 |
count.strategy 计数函数和 count.exceedFormatter 截断函数;只设 count.max 默认提示超限 |
| Arco Design Vue | 默认按 UTF-16 计数、截断 | word-length 计数函数和 word-slice 截断函数 |
| Naive UI | 默认计数按码点,限长仍走原生 UTF-16 | count-graphemes 可接管计数和限长,但超限时拒绝本次输入;自动保留可容纳的粘贴内容需另外处理 |
| Semi Design | 默认沿用原生长度限制 | 有 getValueLength 计数回调;截断实现仍需按项目版本验收 |
| Arco Design React | 默认使用 .length、.slice() |
当前 Input 没有 Vue 版的两项回调,需要外层适配 |
| Element UI(Vue 2) | .length 计数,原生 maxlength 限长 |
自行接管计数、截断和展示 |
| Vuetify | 默认计数和原生限长按 UTF-16 | counter-value 只替换计数,校验及截断另外处理 |
| MUI | TextField 可向原生 input 传递长度属性 | 通过受控输入及辅助文字补计数、截断和提示 |
| PrimeVue | InputText 包装原生 input,透传属性 | 通过外层适配补计数、截断和提示 |
Naive UI 的默认计数来自按字符串迭代的 len(),而原生 maxlength 只有在传入 countGraphemes 后才被绕过。这正好说明,显示的数字与真正阻止输入的限制要分别核对。
Element Plus 的这项配置也有适用边界。旧环境缺少 Intl.Segmenter 时,内部回退为 Array.from() 遍历,可能拆开多码点字素;目标浏览器需要补齐分段能力或使用已有兼容工具。它按字素边界接受插入内容,也不等同于前一节的「精确取前 N 个码点」。中间插入、选区替换和组合输入仍应按真实使用方式验收。
一个截断实现的反例:对 Semi 当前主干的内部截断函数单独传入基于
Intl.Segmenter的字素计数函数,额度设为 1,🇯🇵B得到🇯,👍🏽B得到👍。该函数在 UTF-16 索引上二分切片,候选前缀可能落在组合边界中间。这是 2026-10-21 对该内部函数的独立复现,未覆盖完整组件或所有发布版本;输入输出记录保留了具体样例。
项目已有 Element Plus 且版本和浏览器条件满足时,优先使用这项计数配置;已有 Ant Design 或 Arco Vue 时,把统一工具接入它们的计数和截断回调即可。缺少扩展点时,再封装受控输入,移除会提前截掉内容的原生限制,统一处理 IME、光标、选区和超限提示。纯文本算法继续由工具层维护,组件层负责输入行为,服务端负责最终请求校验。组件库已经丢弃的内容无法在后续 onChange 中恢复,因此适配位置要在它的内部截断之前。
总结
Intl.Segmenter 提供字素边界,统一 helper 表达计数、截断和取首字符。原生 maxlength 按 UTF-16 单元限制输入,组件库也可能有自己的内部截断;适配必须发生在内容丢失之前,并让组合输入、DOM 值和提交值保持一致。
接口按码点限制时,取前 N 个码点与预算内保留完整字素是两种不同策略。预算可以没有用满,但结果必须是原文前缀,不能跳过放不下的组合再拼后文。工具、组件行为和最终服务端校验分别验证,才能避免显示计数正确、真实输入仍被切坏。
上一篇:用户眼里的一个字,与 UTF-16 的一个单元。下一篇:用 ESLint 发现用户文本的危险操作。
Unicode 文本工具函数
如果要让 Agent 接入这组工具函数,可以把下面这段短 prompt 和源码工作区一起给它:
请把 Unicode User Text Guardrail 接入当前项目。
工具包根路径:https://shengsheng.fun/files/unicode-grapheme-text-count-eslint-guardrail/kits/unicode-user-text-guardrail/
如果项目可以新增依赖,优先安装 sheng-tool@^0.2.0,并从 sheng-tool 根入口导入 countUnicodeCharacters、truncateUnicodeCharacters 等 helper。
如果必须内置源码,再先读 README.md、MANIFEST.json、FILES.json、CHANGELOG.md、AGENT_PROMPT.md,按 FILES.json 迁移 copy/shared/utils/unicodeText/,并跑相关测试。
下面的只读源码工作区包含完整工具入口、fallback 和测试样例: