跳到正文

别再用 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 分支,页面可能直接白屏。

Intl.Segmenter 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 文本工具函数

打开原文件 FILES.json

如果要让 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 和测试样例:

Unicode 文本工具函数正在加载代码工作区...