一个个人简介输入框写着“最多 20 字”。用户填了 20 个笑脸,页面却提示超限;换成 20 个普通汉字又可以提交。把 text.length 改成 [...text].length 后,笑脸的问题解决了,家庭 emoji 仍然被算成好几个字。

这是一个便于讨论的假设场景。它说明的工程问题很具体:产品里的“字”,必须落实为一种可计算、可解释的单位。 码元、码点、字素簇和字节分别回答不同的问题,混在一个 length 变量里,就容易让界面、接口和存储各说各话。

本文先解释这些单位,再完成一个小型校验器。所有表格和 JavaScript 核心示例已于 2026-09-28 在 Node.js v22.23.1、ICU 78.2、Unicode 17.0 环境重新运行。浏览器输入法行为另作集成讨论,没有把 Node 实验当成浏览器兼容性测试。

1. 四种长度分别在数什么

UTF-16 码元:JavaScript 字符串的计量单位

JavaScript 的字符串以 16 位码元组成,length 读取码元数量;字符串索引也在这一层工作。这是 ECMAScript String 类型的定义。

在本文的样本中,谷 占一个码元,😀 占两个。后者由一对代理码元表示,所以 "😀".length 是 2。这件事并非 emoji 专属:扩展汉字 𠮷 同样得到 2。因此,“中文用 length 没问题,只有表情要特殊处理”也会漏掉输入。

Unicode 码点:展开字符串后数到的元素

码点可以理解为 Unicode 编码空间中的编号。例如 😀 对应 U+1F600,𠮷 对应 U+20BB7。对合法的 Unicode 文本,JavaScript 字符串迭代器会把代理对一起取出,所以 [...text].length 能让这两个样本各计为一个。字符串迭代器的规范规定了这一行为。

但一个读者眼中的字符,仍可能包含多个码点。e\u0301 是字母 e 加组合重音;👨‍👩‍👧‍👦 则包含四个人物码点和三个零宽连接符。按码点计数解决了代理对问题,没有解决这些组合的边界问题。

字素簇:接近阅读和编辑时的字符单位

字素簇把一组码点划为一个片段。这里使用 Intl.Segmenter 的 grapheme 模式,取得用于用户感知字符计数的分段。字素簇与字体画出的字形并不是同一层概念,也不代表一个片段一定占一格屏幕。Unicode UAX #29解释了这个近似及其边界。

实际设计时,我会用字素簇表达“简介最多多少个字符”,而把显示宽度留给排版系统。如果需求是“列表标题只显示两行”,即使准确数出了字素簇,也还需要考虑字体、字号、容器宽度和换行。拿一个文字计数结果预测所有排版,问题仍然没有被定义完整。

UTF-8 字节:某种编码下的数据体积

TextEncoder 把字符串编码为 UTF-8,返回的字节数组长度就是该文本的 UTF-8 字节数,见 Encoding Standard。这里的编码名称不能省略:谈“多少字节”时,必须同时说明怎样编码。

它适合用来表达一个文本字段的编码体积上限。它不等于 JavaScript 对象的内存占用,也不等于整个 HTTP 请求的大小;JSON 字段名、引号以及转义后的内容都可能另外占空间。这些指标应分别测量,不能用字符串的一项统计代替。

2. 让同一组输入接受四种计数

测量函数把单位写进字段名,并在开始时检查文本是否包含未配对的代理码元:

const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const encoder = new TextEncoder();

function requireText(text) {
  if (typeof text !== 'string') throw new TypeError('Expected a string');
  if (!text.isWellFormed()) throw new TypeError('Unpaired UTF-16 surrogate');
}

function measureText(text) {
  requireText(text);
  let codePoints = 0;
  let graphemes = 0;
  for (const _ of text) codePoints++;
  for (const _ of segmenter.segment(text)) graphemes++;
  return {
    utf16Units: text.length,
    codePoints,
    graphemes,
    utf8Bytes: encoder.encode(text).length,
  };
}

以下是本次运行结果。表中的 e\u0301 表示转义构造的两个码点,而不是反斜杠等字符组成的字面文本。

输入UTF-16 码元码点字素簇UTF-8 字节
ABC3333
谷米2226
𠮷2114
😀2114
e\u03012213
🇯🇵4218
👨‍👩‍👧‍👦117125

家庭 emoji 很适合作为反例:在这个环境里,它只是一组字素簇,却有 25 个 UTF-8 字节。这直接说明,“字符上限”不能独自承担“体积上限”。一个输入即使让字数计数器显示很小,也可能触及另一个容量限制。

完整的可下载实验脚本包含这张表、后面的实现和边界断言。保存后执行 node text-length.mjs 即可复现;脚本会检查 Intl.Segmenter 和 String.prototype.isWellFormed 是否存在。它没有在缺少分段 API 时自动退回码点计数,因为那会悄悄改变本文定义的规则。

3. 完成一个“20 个字符,同时最多 80 字节”的校验器

为使实现可以审查,先明确这个教学用简介字段的规则:字符数采用字素簇;空格和标点参与计数;UTF-8 编码后最多 80 字节;允许空字符串;不自动去空格、规范化或截断。20 和 80 是本例选择的参数,并不是适用于所有网站的推荐值。是否必填属于另外一条产品规则。

const BIO_LIMITS = Object.freeze({ maxGraphemes: 20, maxUtf8Bytes: 80 });

function validateBio(text) {
  if (typeof text !== 'string') {
    return { ok: false, errors: ['not-string'], counts: null };
  }
  if (!text.isWellFormed()) {
    return { ok: false, errors: ['ill-formed-unicode'], counts: null };
  }
  const counts = measureText(text);
  const errors = [];
  if (counts.graphemes > BIO_LIMITS.maxGraphemes) errors.push('too-many-graphemes');
  if (counts.utf8Bytes > BIO_LIMITS.maxUtf8Bytes) errors.push('too-many-utf8-bytes');
  return { ok: errors.length === 0, errors, counts };
}

校验结果带有两个独立的错误原因,调用方可以给出对应提示。以下边界已实际断言通过:

输入字素簇UTF-8 字节结果
20 个 日2060通过
21 个 日2163字符数超限
20 个 😀2080通过,恰好达到字节上限
4 个 👨‍👩‍👧‍👦4100字节超限
空字符串00通过

这个返回结构也使接口约定更清楚:字数超限可以提示减少字符,字节超限则需要解释组合字符也会增加编码体积。若产品选择这种双限制,就应在帮助文本里公开两项规则,避免用户只看到“4/20”却不知道为何提交被拒。

未配对代理码元则单独处理。例如 '😀'.slice(0, 1) 留下半个代理对。TextEncoder 接受的 USVString 会把这种值转换成替代字符,见 Web IDL 的 USVString 转换。本例选择拒绝它,防止编码过程中已经替换了内容,调用方还以为保存的是原文本。需要修复输入的应用可以另外制定替换政策,但应明确记录内容发生了什么变化。

这是面向短简介的完整扫描示例,没有测量吞吐量。若接口可能接收大块文本,应先限制请求体,再安排字段校验;读完任意大的输入以后才计算字节数,不能保护前面的接收过程。性能优化也应以目标数据与实际测量为依据。

4. 截断也要使用同一个单位

正确计数之后,截断若仍然采用 slice(0, max),就会把问题重新引入。以下两种做法在本次实验中得到不同的错误:

'😀'.slice(0, 1).isWellFormed(); // false:留下未配对代理码元
[...'👨‍👩‍👧‍👦'].slice(0, 1).join(''); // '👨':组合被拆开

按字素簇保留前若干片段,可以保持本文样本的边界:

function takeGraphemes(text, max) {
  requireText(text);
  if (!Number.isInteger(max) || max < 0) throw new RangeError('Invalid limit');
  let result = '';
  let count = 0;
  for (const { segment } of segmenter.segment(text)) {
    if (count === max) break;
    result += segment;
    count++;
  }
  return result;
}

// takeGraphemes('👨‍👩‍👧‍👦谷米', 1) === '👨‍👩‍👧‍👦'
// takeGraphemes('e\u0301x', 1) === 'e\u0301'

这个辅助函数只控制字素簇数量,调用后仍需校验字节限制。如果预览还要追加省略号,省略号是否计入上限也要另作决定。显示摘要与保存用户原文是两个操作:我会把这个函数用于预览,表单提交则返回错误,让用户决定怎样修改,避免在输入过程中静默丢掉内容。

5. 接到网页时,先核对原生限制与输入法

直接写 <textarea maxlength="20"> 并不能实现前面的字素簇规则。HTML 的 maxlength 使用规范定义的字符串长度,而 Infra 的字符串长度以码元计,见 HTML 输入长度限制与 Infra 字符串定义。这与“20 个家庭 emoji”是不同的条件。

如果需求确实是按字素簇限制,就应由自定义计数器与提交校验表达这项政策。原生 maxlength 若作为额外限制保留,要明确它的单位和数值,不能让用户提前遭遇另一套未解释的上限。

中日文输入法还存在文字组合过程。compositionend 表示组合会话完成或取消,见 MDN 事件说明。我的集成建议是在组合期间保留用户正在编辑的值,等组合结束后刷新提示,提交时再校验最终内容。不要在每个按键事件里强制把 value 改成截断结果。

这部分仍需在目标浏览器、真实输入法、粘贴和光标编辑场景中测试。Node 的分段断言验证不了候选词窗口,也验证不了修改 value 后光标是否跳动。把两类验证写成不同任务,才知道还有什么尚未得到证据。

6. “多少词”是另一条规则

把文字按空白切开,得到的是空白分隔片段,不会自动变成中日文的词数。在相同实验环境里,对 私は猫が好きです。 执行以下代码:

const text = '私は猫が好きです。';
const words = [...new Intl.Segmenter('ja', { granularity: 'word' }).segment(text)]
  .filter(part => part.isWordLike)
  .map(part => part.segment);
// ['私', 'は', '猫', 'が', '好き', 'です']

本次得到六个片段。trim().split(/\s+/) 对这句只得到一段,不能表达相同的统计。词边界和 isWordLike 的判定存在实现与语言环境因素,见 ECMA-402 分段规则。因此,把它用于稿费、考试或其他严格指标之前,必须确定业务认可的分词规则,不能把某次运行结果宣布为语言学上的唯一答案。

7. 给这条规则留下可维护的契约

真正接入系统时,我会把计数单位、容量上限和转换顺序写入接口说明,再让前后端共享一组边界样本。样本至少包含普通汉字、扩展汉字、组合重音、旗帜和连接符 emoji;还要覆盖刚好达到上限、超过上限以及非法代理码元。以后修改运行环境,先重新跑这些样本,再决定结果变化是否需要迁移政策。

前端负责及时提示,接收端负责最终校验。两端只写着同一个“20”仍然不够;如果一端按码点、另一端按字素簇计算,分歧还在。Intl.Segmenter 的实际边界允许实现差异,相关约定见 ECMA-402 分段边界。应记录运行环境和测试结果,而不是假设所有版本永远一样。

如果保存前还会做 Unicode 规范化,应规定先转换还是先计数,并对最终保存值执行容量校验。这个话题在规范化与搜索键一文继续展开。本文的校验器保持原字符串,避免悄悄把两项政策揉进一个函数。

选对计数单位,只是输入限制的起点。可维护的实现还需要清楚的错误提示、完整的字符边界、前后端一致的约定,以及可以重新运行的反例。有了这些,“最多 20 字”才能从一行界面文案变成可审查的系统行为。