码元、码点、字素:同一个字符串的三种长度
问"这个字符串有多长?",JavaScript、Unicode 和用户会给出三个不同的数字。String.prototype.length 数的是 UTF-16 码元 — JavaScript 内部编码的 16 位单元。所有基本多文种平面(BMP)之外的字符都以代理对(surrogate pair)存储,所以肉眼看是一个符号,'👍'.length 却是 2。
对字符串做展开([...s])则按码点迭代,解决了代理对问题,但解决不了序列问题。家庭 emoji 👨👩👧👦 是 4 个人物码点用 3 个零宽连接符(U+200D)缝在一起:length 为 11,码点数为 7,可见的字形却只有 1 个。国旗是 2 个 regional indicator 符号 — 🇰🇷 的 length 是 4。肤色修饰符又在基础 emoji 上再叠一个码点。
用户感知的单位是字素簇(grapheme cluster),而且从 2021 年起平台可以原生统计:granularity 设为 'grapheme' 的 Intl.Segmenter 按文本光标移动的方式精确切分字符串。字符计数器展示"用户看到的字符数"时,应该用的就是这个数字。
后端还会加上第四把尺子。MySQL utf8mb4 的 VARCHAR(10) 按码点计数,Postgres 的 varchar(10) 也是;但按字节限制的 API 把每个 CJK 字符看作 3 个 UTF-8 字节、每个 emoji 看作 4 个。同一段日文可以在 JS 的 length 检查里没超限,在按字节计数的后端却超限了。永远用执行限制的那个系统的尺子来校验。
const fam = '👨👩👧👦';
fam.length // 11 UTF-16 code units
[...fam].length // 7 code points (4 people + 3 ZWJ)
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment(fam)].length // 1 — what the user sees
'👍'.length // 2 — surrogate pair
'🇰🇷'.length // 4 — two regional indicators平台限制解码:Twitter 的 280、短信的 160、Google 的 600 像素
现实世界的限制很少是纯字符数。Twitter/X 允许 280 个加权单位:多数拉丁字母、数字和标点权重为 1,而 CJK 字符、emoji 等宽字符范围权重为 2 — 所以一条日文推文最多 140 个字。URL 无论实际多长,一律按 23 个单位计费,因为 t.co 包装把长度归一化了。
短信更古怪。整条文本若都落在 GSM-7 字母表内,一条短信容纳 160 个字符。只要有一个字符越界 — 一个 emoji、一个从文字处理器粘来的弯引号、某些带重音的字母 — 整条消息就静默切换为 UCS-2,额度骤降到 70。长消息被拆成级联(concatenated)分段,每段要牺牲头部字节,GSM-7 剩每段 153 字、UCS-2 剩 67 字。一个智能引号能把一条计费短信变成三条 — 批量短信账单经常暴露这个问题。
其他平台:Instagram 文案上限 2,200 字符、30 个话题标签。Google 按渲染后的像素宽度截断标题 — 约 600px,依字母宽窄大致相当于 50–60 个字符(WWW 比 iii 宽得多)— meta description 大约在 155–160 字符。实务结论:先弄清目标系统数的是码元、加权单位、GSM-7 septet 还是像素,再用有代表性的真实文本测试,而不是 lorem ipsum。
数单词依赖语言 — CJK 打破了空格规则
按空格切分,在英语、德语、西班牙语里是可靠的单词计数器。但对任何一句中文或日文,它都精确地返回 1 — 这些文字书写时没有空格。CJK 真正的分词需要词典或统计模型:日语的标准形态素分析器是 MeCab,中文用 jieba,浏览器里则有 granularity 设为 'word' 的 Intl.Segmenter,零依赖地暴露 ICU 的词典分词。
韩语介于两者之间:它确实用空格,但间隔的单位是"어절"(词+助词的组合),所以按空格计数得到的是어절数而非词典意义上的单词。实践中韩文反正按字数计量 — 这是从 200 字稿纸(원고지)传统继承的惯例,学校和出版界至今沿用。
这个区别直接关联到钱。专业翻译对英语类语言按源语单词计费,对日文中文按字符计费;跨语言报价时,翻译公司会套用公开的换算系数 — 量级大约是每个英文单词折合两个 CJK 字符。学术和法律的篇幅限制同样分裂:日文期刊规定字数,英文期刊规定单词数。
如果你的产品要向多语言用户展示单词数,请统计 Intl.Segmenter 里 isWordLike 的分段;同一条代码路径就能对英语和 CJK 都给出语言学上合理的数字。
function countWords(text, locale = 'en') {
const seg = new Intl.Segmenter(locale, { granularity: 'word' });
return [...seg.segment(text)].filter(s => s.isWordLike).length;
}
countWords('The quick brown fox'); // 4
countWords('日本語は分かち書きをしない', 'ja'); // dictionary-segmented
'日本語は分かち書きをしない'.split(/\s+/).length; // 1 — naive split failsNFC 与 NFD:像素不变、计数却变的规范化
Unicode 里同一段可见文本常常有不止一种编码。韩文音节 한 既可以是一个预组合码点(U+D55C),也可以是三个组合字母(初声 ᄒ、中声 ᅡ、终声 ᆫ);é 则是单码点 U+00E9,或者是 e 后面跟组合尖音符 U+0301。NFC 是组合形式,NFD 是分解形式。二者渲染出的像素完全相同,比较结果却不相等。
在 Mac 上这不是纸上谈兵。HFS+ 以分解形式的变体存储文件名,macOS 的一些界面至今仍会递给你 NFD 文本 — 从 Finder 复制一个韩文文件名贴进计数器,한글.txt 会报 10 个字符而不是 6 个。从 macOS 上传的文件名同理:后端的 maxlength 校验可能放行手动输入的文本,却拒绝看起来一模一样的粘贴文本。
第二个症状是相等性:当一侧是 NFC、另一侧是 NFD 时,'é' === 'é' 可能为 false,这会悄悄破坏去重、搜索匹配和唯一性约束。修复只要一行 — 在计数、比较或哈希之前调用 str.normalize('NFC');规范化是幂等且快速的。通过 Intl.Segmenter 做字素簇计数则基本免疫,因为 한 的两种编码都构成同一个簇 — 这也是"凡是给人看的数字就数字素"的又一个理由。
const nfc = '한'.normalize('NFC'); // U+D55C
const nfd = '한'.normalize('NFD'); // U+1112 U+1161 U+11AB
nfc.length // 1
nfd.length // 3 — same pixels, three code units
nfc === nfd // false
'한글.txt'.normalize('NFD').length // 10
'한글.txt'.normalize('NFC').length // 6阅读时间的数学:每分钟 238 词及其他有依据的数字
阅读时间估算看似拍脑袋,其实有实测的锚点。Brysbaert 2019 年的元分析(近 190 项研究)把英语非虚构类的默读速度定在每分钟约 238 词,虚构类稍快,约 260。Medium 的阅读时长算法用 265 wpm,并为第一张图片加 12 秒,之后每张递减 1 秒,最低 3 秒。日文和中文的规划数字则按字符报 — 舒适默读大约每分钟 400 到 600 字。
平均词长让换算成为可能。英语平均每词约 4.7 个字母,算上后面的空格约 5.7 个字符,所以一篇 1,000 词的文章约 5,700 字符,按 238 wpm 四分钟出头读完。同样的算术可以翻译各种限制:155 字符的 meta description 约容纳 25 个英文单词;160 字符的 GSM-7 短信约 28 个。
口播远慢于默读 — 旁白和会议演讲在每分钟 130 到 150 词左右 — 这就是"五分钟阅读"的文章会变成九分钟配音稿的原因。密度也是有用的编辑信号:如果平均词长明显超过 6 个字母,说明文本术语密集,实际阅读会比任何按词计算的公式预测的更慢。