JavaScript 字符串截断:length 数的是码元,界面需要的可能是字素

10-01 3阅读

同一段文字为什么有三种长度

昵称框显示四个视觉单位,程序却报告长度十;把它截到第二个位置,表情还可能变成缺损符号。原因在于 JavaScript 字符串使用 UTF 十六码元序列,length 与普通索引按码元工作。一个用户看到的符号可能需要两个码元,也可能由多个码点组合出来。

因此“最多几个字符”并不是足够明确的技术约定。存储容量可能按字节,语言接口可能按码元,而界面截断通常希望保住完整字素簇。先选计量单位,再选处理方法,才能避免前端显示、服务端校验和数据库限制彼此打架。

用一个样本观察三个层级

下面保存为 JavaScript 文件并用支持 Intl.Segmenter 的 Node.js 执行,本文在二十四版验证。样本由英文字母、单个表情、带组合重音的字母和带连接符的职业表情组成。代码使用转义明确表示组合重音,方便复制后保持相同输入。

预期结果是十个码元、七个码点和四个字素簇。代码还打印各字素在原字符串中的起点,并验证截取前两个和前三个字素的内容。对普通 slice 的破坏性样本只检查码元值,不把孤立代理项直接打印成乱码,避免终端显示掩盖问题。

JavaScript 字符串截断:length 数的是码元,界面需要的可能是字素

AI概念配图,非真实界面

const assert = require('node:assert/strict');
assert.equal(typeof Intl.Segmenter, 'function');
const text = 'A😀e\u0301👩‍💻';
const segmenter = new Intl.Segmenter('zh', {granularity: 'grapheme'});
const parts = Array.from(segmenter.segment(text));
assert.equal(text.length, 10);
assert.equal([...text].length, 7);
assert.equal(parts.length, 4);
assert.deepEqual(parts.map(p => p.index), [0, 1, 3, 5]);
console.log('units:', text.length);
console.log('points:', [...text].length);
console.log('graphemes:', parts.length);
console.log('start offsets:', parts.map(p => p.index).join(','));

function takeGraphemes(value, limit) {
  if (typeof value !== 'string') throw new TypeError('string required');
  if (!Number.isSafeInteger(limit) || limit < 0) {
    throw new RangeError('nonnegative integer required');
  }
  let result = '';
  let count = 0;
  for (const part of segmenter.segment(value)) {
    if (count === limit) break;
    result += part.segment;
    count += 1;
  }
  return result;
}
assert.equal(takeGraphemes(text, 2), 'A😀');
assert.equal(takeGraphemes(text, 3), 'A😀e\u0301');
assert.equal(takeGraphemes(text, 0), '');
assert.equal(takeGraphemes('', 5), '');
assert.equal(takeGraphemes(text, 99), text);
assert.throws(() => takeGraphemes(text, -1), RangeError);
const broken = text.slice(0, 2);
assert.equal(broken.charCodeAt(1), 0xD83D);
console.log('first two:', takeGraphemes(text, 2));
console.log('grapheme truncation checks passed');

展开字符串只解决了其中一层

字符串迭代器会按码点推进,所以展开运算符能把一个需要代理对的表情作为一项,避免直接按码元切开它。但组合重音和带连接符的表情仍可能包含多个码点,展开后再 slice 并不能保证每个视觉单位完整。测试单个笑脸通过,不足以证明支持所有表情。

Intl.Segmenter 的 grapheme 粒度按字素边界分段。示例先建立分段器,再把每段的 segment 字段连接起来完成截断,既保住组合重音,也保住当前样本中的连接表情。返回对象里的 index 仍是原字符串的码元偏移,不能把它当成第几个码点。

这个索引规则反而便于和 slice 配合:分段器给出合法边界,字符串接口按同一套码元坐标截取。若需要在原文高亮一段内容,应保留起止偏移与原文的对应关系,不要在得到索引之后又任意替换或重排文本,否则坐标会失去意义。

界面规则还需要自己的边界

字素簇通常接近用户感知的字符,但不是词数,也不是排版宽度。中文、拉丁字母和表情即使各算一个字素,实际占用的像素也不同。需要让标题装进一行时,仍要依据字体与容器进行布局判断,不能用字素数量推断精确视觉宽度。

示例对负数和非整数的截断上限直接报错,零则返回空串;它不自动添加省略号。若产品需要省略号,还应决定省略号是否计入上限,只有真的删掉内容时才显示。这些细节看起来小,却会影响同一标题在列表、详情和复制操作中的一致性。

部署前检查目标环境是否提供分段器,并用真实支持范围测试。如果环境缺少它,静默退回码点切分会改变契约,尤其容易再次切开组合表情。可以明确限制功能或采用经过验证的兼容方案,同时让前后端使用相同的样本进行验收。

分段规则会受到运行时所带 Unicode 数据版本影响,因此对新增表情和复杂文字应在实际发布环境验证。保留本文的基础样本,再补充你的主要用户语言。讨论长度时把码元、码点、字素和字节写清楚,比给所有场景共用一个含糊的字符数更可靠。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。