文本截断的三种维度
文本截断看似简单——“截取前 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+007F | 1 字节 | A = 0x41 |
| U+0080 ~ U+07FF | 2 字节 | ñ = 0xC3 0xB1 |
| U+0800 ~ U+FFFF | 3 字节 | 你 = 0xE4 0xBD 0xA0 |
| U+10000 ~ U+10FFFF | 4 字节 | 😊 = 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" 不完整
回退算法
开启”保留单词边界”后,截断位置向前回退到最近的空格:
- 检查截断位置的字符是否是空格 → 如果是,直接截断
- 向前查找最近的空格 → 找到后截断到该位置
- 找不到空格(纯中文无空格)→ 按原 limit 截断
中文文本的特殊性
中文没有空格分词,“保留单词边界”选项对纯中文文本不产生效果。如果要实现中文”词”级别的截断,需要分词库(如 jieba),但那引入了过重的依赖。对于大多数场景,按字符截断中文是可接受的。
省略号与长度计算
省略号占位
省略号本身的字符数(或字节数)应从截断限制中扣除:
截断到 50 字符,省略号 "..."(3 字符)
→ 实际文本截断到 47 字符 + "..." = 50 字符
省略号选择
| 省略号 | 字符数 | UTF-8 字节 | 说明 |
|---|---|---|---|
... | 3 | 3 | 最常见,三个 ASCII 句点 |
… | 1 | 3 | Unicode 省略号(U+2026),视觉等价但占 1 字符 |
… | 1 | 3 | 中文省略号常用单字符 |
…… | 2 | 6 | 中文双省略号 |
[更多] | 4 | 8 | 链接式省略号 |
按字节截断时,... 和 … 的字节数相同(都是 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 长度控制等场景。