Intl.Segmenter 做标题计数:码点、字形簇与输入法组合边界
一个家庭表情显示成一组,text.length 却能得到十几个。把标题截到8个“字符”,结果切断表情或只留下组合重音的一半,原因往往是代码按UTF-16代码单元切片,却向使用者承诺了可见字符数量。
改成 [...text] 可以按码点遍历,但仍不等于用户眼中的一组字符。国旗、带连接符的表情、基础字母加组合重音,都可能包含多个码点。需要按字形簇近似用户的字符感知时,可以使用 Intl.Segmenter 的grapheme粒度。
先做一个能同时显示四种长度的编辑器:代码单元、码点、字形簇和UTF-8字节数。它不自动修改输入,只有点按钮才按字形簇截取预览,避免在用户还没完成输入法组合时抢走编辑过程。
可运行页面:计数与截断分别发生
保存为 grapheme-counter.html,使用支持Intl.Segmenter的现代浏览器打开。
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>字形簇计数实验</title>
<style>
body { max-width: 44rem; margin: 2rem auto; padding: 1rem; font: 16px sans-serif; }
textarea { width: 100%; box-sizing: border-box; }
#preview { overflow-wrap: anywhere; border: 1px solid #777; padding: 1rem; }
</style>
<label for="text">标题样本</label>
<textarea id="text" rows="4">A👨👩👧👦é🇨🇳你好</textarea>
<p id="counts" role="status"></p>
<button id="apply" type="button">预览前 4 个字形簇</button>
<p id="preview"></p>
<script>
const input = document.querySelector('#text');
const counts = document.querySelector('#counts');
const button = document.querySelector('#apply');
const preview = document.querySelector('#preview');
let composing = false;
const segmenter = typeof Intl.Segmenter === 'function'
? new Intl.Segmenter('zh-CN', { granularity: 'grapheme' }) : null;
function parts(text) {
return [...segmenter.segment(text)].map(item => item.segment);
}
function render() {
if (!segmenter) {
counts.textContent = '当前浏览器不支持 Intl.Segmenter';
button.disabled = true;
return;
}
const text = input.value;
if (text.length > 2000) {
counts.textContent = '本实验最多接受 2000 个 UTF-16 代码单元';
button.disabled = true;
return;
}
counts.textContent = [
`代码单元 ${text.length}`,
`码点 ${[...text].length}`,
`字形簇 ${parts(text).length}`,
`UTF-8 字节 ${new TextEncoder().encode(text).length}`,
composing ? '输入法正在组合' : '组合已结束'
].join(';');
button.disabled = composing;
}
input.addEventListener('compositionstart', () => {
composing = true;
render();
});
input.addEventListener('compositionend', () => {
composing = false;
render();
});
input.addEventListener('input', render);
button.addEventListener('click', () => {
preview.textContent = parts(input.value).slice(0, 4).join('');
});
render();
</script>
</html>
默认样本里,A、家庭表情、带重音的e、国旗、你、好各占一个字形簇,共6组。预览前4组以后,家庭表情、组合重音与国旗都应保持完整,原输入没有被改动。

可以删掉重音,再用分开的 e 与组合重音输入一次,观察代码单元和码点数量如何变化。截图上看起来相近,并不能证明底层字符串完全相同;计数与规范化是两个独立操作。
grapheme 是分段规则,不是文字宽度测量
字形簇是Unicode文本分段定义的单位,适合许多编辑计数需求,但不代表每一组占用相同屏幕宽度。一个汉字、一个拉丁字母和一组表情可能都算一组,实际绘制宽度完全不同。
如果需求是“标题必须放进这一行”,应该使用布局与实际测量,或明确的CSS省略策略;不能按字形簇数乘一个固定像素宽度。字体、字号、连字和平台表情渲染都会改变显示结果。
同样,码点也不是不正确,只是适用合同不同。协议按Unicode码点限制,就应使用码点口径;数据库列、网络载荷或文件格式按字节限制,则必须按对应编码检查。前端显示还剩几个字,后端按另一单位拒绝,需要在产品文案与接口约定里说清楚。
页面同时显示四种数,是为了暴露这些差异,不是要选一个永远正确、其余都错误的长度函数。对ASCII样本它们经常相同,容易让错误实现一直藏到国际化输入出现。
不在 composition 进行时抢先截断
输入法组合期间,当前文字可能还不是用户最终确定的结果。每次input就立刻slice并覆盖value,会打断候选、移动光标,甚至让用户无法完成一个合法字。
这个例子只计数,截断通过明确按钮触发,组合期间按钮不可用。真实的限长编辑器可以在compositionend后核对,也可以允许暂时超限并显示提示,但不应把所有input事件都当成已经提交的一次独立字符输入。
即使按字形簇截断,直接替换整个value仍可能影响选区与撤销历史。若产品要求实时裁剪,需要另外处理光标位置、粘贴、撤销与移动端行为;Segmenter只负责找边界,不是完整文本编辑引擎。
脚本也没有使用HTML maxlength=4。maxlength的原生长度规则不是这里的4个字形簇合同,直接写上去会在另一套单位下提前挡住输入。原生约束与自定义计数必须对齐,否则同一个输入框会同时执行两份不一致的限制。
换行和空格也需要业务约定。Segmenter不会因为输入来自标题框就自动忽略它们,页面展示的长度包含真实输入中的这些分段。若产品会在保存前trim或合并空白,应让计数针对同一份最终文本,否则输入时显示合法,提交转换后又可能得到另一份长度。
同样,截断后的预览只是一份派生文本,不应悄悄回写成用户原稿。需要保存缩短版本时,应给出明确操作与可恢复路径,尤其不要让一次展示省略损坏源数据。
规范化与跨端一致性要另外约定
é可以是一个预组合码点,也可以是基础字母加组合重音。Segmenter可以把相应序列分在一组,却不会自动把字符串改成同一串编码。用户名唯一性、签名或内容哈希若要求统一形式,应明确选择规范化规则,并评估兼容折叠是否符合业务。
Unicode分段规则与国际化数据版本也会演进。需要跨浏览器、后端和导出任务完全一致时,应固定规则或统一在可信执行端检查,不能让每一端凭自己的库版本给出互相矛盾的边界。
这份页面对旧浏览器明确显示缺少能力,没有退回Array.from以后仍宣称按字形簇计数。降级可以存在,但必须承认单位变化;有明确兼容需求时,再选用经过验证的分段实现。
最后把限制从4改成1,分别输入家庭表情、国旗和带重音字母。每次预览都应保留整组,代码单元数却未必相同。用这些有真实边界的样本验证,比拿一串普通英文证明截取“没有报错”更接近编辑器真正要完成的任务。