文本截断深度指南:字符数、字节数与 Unicode 编码的陷阱

文本截断的三种维度

文本截断看似简单——“截取前 N 个字符”——但在 Unicode 时代,“字符”和”长度”的定义并不唯一。选择不同的截断维度,会产生截然不同的结果。

配套工具:文本截断工具

三种截断方式对比

方式计数单位适用场景示例
按字符数Unicode 码点UI 显示限制、摘要生成”你好 JS 😊” = 6 字符
按字节数UTF-8 字节数据库字段、网络传输”你好 JS” = 9 字节
按行数文本行日志预览、多行截断3 段文字 = 5 行

字符数 vs 字节数:为什么不同?

Unicode 码点

Unicode 为世界上所有字符分配了唯一的编号(码点)。A 的码点是 U+0041, 的码点是 U+4F60,😊 的码点是 U+1F60A。

按字符数截断就是按码点数截断,每个码点算 1 个单位。

UTF-8 编码

UTF-8 是 Unicode 的变长编码方案,用 1-4 字节表示一个码点:

码点范围字节数示例
U+0000 ~ U+007F1 字节A = 0x41
U+0080 ~ U+07FF2 字节ñ = 0xC3 0xB1
U+0800 ~ U+FFFF3 字节 = 0xE4 0xBD 0xA0
U+10000 ~ U+10FFFF4 字节😊 = 0xF0 0x9F 0x98 0x8A

因此”你好 JS”的 UTF-8 字节数 = 3 + 3 + 1 + 1 + 1 = 9 字节,但字符数只有 5。

什么时候用字节截断?

  • 数据库字段VARCHAR(255) 在 MySQL 中默认是 255 个字符,但 VARCHAR(255) BYTE 是 255 字节
  • 网络传输:HTTP 请求体、Cookie 大小限制按字节计算
  • 存储预算:文件大小、内存占用按字节衡量

Array.from vs split(”):代理对陷阱

JavaScript 字符串的内部表示

JavaScript 字符串使用 UTF-16 编码,每个”码元”(code unit)占 2 字节。对于 BMP(基本多文种平面)内的字符(U+0000 ~ U+FFFF),一个码元就是一个字符。但对于辅助平面字符(U+10000+,如 Emoji),需要两个码元(代理对)表示。

😊 = U+1F60A
UTF-16 表示:\uD83D\uDE0A(两个码元)

split(”) 的危险

String.split('') 按 UTF-16 码元分割,会把代理对拆散:

"😊你好".split('').slice(0, 2).join('')
// 结果:乱码!😊 被拆成了两个码元,slice(0,2) 取了这两个码元
// 但它们单独无法正确显示

Array.from 的正确性

Array.from() 按 Unicode 码点遍历字符串,将代理对作为一个整体处理:

Array.from("😊你好").slice(0, 2).join('')
// 结果:"😊你" — 正确!

这是因为 Array.from 内部使用字符串的迭代器(Symbol.iterator),而迭代器按码点遍历。按字符数截断必须使用 Array.from 而非 split('')

UTF-8 字节截断的字符边界问题

问题:截断位置落在多字节字符中间

按字节截断时,截断位置可能恰好落在多字节字符的中间:

文本:"你好"(UTF-8: E4 BD A0 E5 A5 BD,共 6 字节)
截断到 4 字节:E4 BD A0 E5
                     ↑ 截断位置在"好"的第 1 字节后
结果:无效的 UTF-8 序列,无法解码

解决方案:逐字节回退

使用 TextDecoder 逐字节回退,直到截断位置是完整的 UTF-8 字符边界:

const bytes = new TextEncoder().encode("你好");
const decoder = new TextDecoder();
let limit = 4;
while (limit > 0) {
  const result = decoder.decode(bytes.slice(0, limit));
  if (result.length > 0) break;  // 成功解码
  limit--;
}
// limit = 3,结果 = "你"

TextDecoder 在遇到不完整的 UTF-8 序列时会用替换字符(U+FFFD)替代,通过检查结果长度可以判断是否成功解码。

单词边界保留策略

为什么需要保留单词边界?

直接按字符截断可能在英文单词中间切断,影响阅读体验:

截断到 13 字符:"JavaScript is awesome" → "JavaScript is"  ✅ 恰好在空格
截断到 10 字符:"JavaScript is awesome" → "JavaScript"      ✅ 恰好在空格
截断到 15 字符:"JavaScript is awesome" → "JavaScript is a" ❌ "a" 不完整

回退算法

开启”保留单词边界”后,截断位置向前回退到最近的空格:

  1. 检查截断位置的字符是否是空格 → 如果是,直接截断
  2. 向前查找最近的空格 → 找到后截断到该位置
  3. 找不到空格(纯中文无空格)→ 按原 limit 截断

中文文本的特殊性

中文没有空格分词,“保留单词边界”选项对纯中文文本不产生效果。如果要实现中文”词”级别的截断,需要分词库(如 jieba),但那引入了过重的依赖。对于大多数场景,按字符截断中文是可接受的。

省略号与长度计算

省略号占位

省略号本身的字符数(或字节数)应从截断限制中扣除:

截断到 50 字符,省略号 "..."(3 字符)
→ 实际文本截断到 47 字符 + "..." = 50 字符

省略号选择

省略号字符数UTF-8 字节说明
...33最常见,三个 ASCII 句点
13Unicode 省略号(U+2026),视觉等价但占 1 字符
13中文省略号常用单字符
……26中文双省略号
[更多]48链接式省略号

按字节截断时,... 的字节数相同(都是 3 字节),但按字符截断时 只占 1 字符,能多保留 2 个文本字符。

典型应用场景

1. SEO meta description

搜索引擎通常显示 150-160 字符的 description。按字符数截断到 155 字符,添加 ”…” 省略号:

截断方式:按字符数
限制:155
省略号:"..."
保留单词边界:是(避免截断单词影响可读性)

2. 数据库 VARCHAR 字段

MySQL VARCHAR(255) BYTE 限制 255 字节。中文每字 3 字节,最多约 85 个中文字符:

截断方式:按字节数
限制:255
省略号:无(数据库存储不需要省略号)

3. 消息预览

短信、推送通知预览通常限制 50-100 字符:

截断方式:按字符数
限制:50
省略号:"…"
保留单词边界:是

4. 日志行截断

过长日志行影响阅读,截断到固定行数:

截断方式:按行数
限制:100
省略号:"\n... (更多行已省略)"

5. UI 文本溢出

列表项、卡片标题等 UI 元素的文本溢出处理(虽然 CSS text-overflow: ellipsis 也能处理,但服务端截断更高效):

截断方式:按字符数
限制:30
省略号:"…"

总结

文本截断的”正确性”取决于场景需求:

  • 按字符数Array.from 处理码点,避免代理对陷阱
  • 按字节数TextDecoder 回退到字符边界,避免产生无效 UTF-8
  • 按行数split(/\r?\n/) 统一处理换行符
  • 省略号的长度需从限制中扣除,选择 ... 更节省空间
  • 单词边界保留提升英文截断可读性,但对中文无效

本文配套的文本截断工具实现了以上所有能力,完全在浏览器本地运行,正确处理中英文与 Emoji,适用于摘要生成、数据库字段截断、SEO 长度控制等场景。