Emoji 到底算几个字符:实现 Unicode 感知的文本统计器
统计之前必须先定义规则
文本统计最容易犯的错误,是直接展示一组数字,却不说明这些数字代表什么。
例如 JavaScript 中:
"😀".length === 2;
这是因为 length 统计 UTF-16 码元,不等于用户看到的字符。家庭 Emoji 👨👩👧👦 由多个码点和连接符组成,即使用 Array.from 也不能保证把它当作一个可见字符。
因此,ZGLab Tools 在写算法前先确定规则:
- 总字符优先按 Unicode 字素簇统计;
- 中文字符按 Unicode
Script=Han统计; - 英文字母实际按拉丁文字脚本统计;
- 英文单词按拉丁字母及合理连接符识别;
- 段落按一个或多个空行分隔;
- 空文本为 0 行;
- CRLF 和 LF 先统一;
- 字节数按 UTF-8 编码计算;
- 阅读时间是可配置速度下的估算,不是假装精确的结论。
只有先固定口径,UI、测试和下载报告才可能一致。
模块结构
src/
├── components/tools/TextCounter.tsx
├── pages/text-counter.astro
├── tools/text-counter/
│ ├── logic.ts
│ ├── logic.test.ts
│ └── types.ts
└── utils/text.ts
纯函数 countText 接收文本和阅读速度,返回完整统计对象。组件只负责防抖、设置项和展示。
用 Intl.Segmenter 统计可见字符
现代浏览器提供 Intl.Segmenter,可以按字素、单词或句子切分文本。当前实现选择 grapheme:
const segmenter = new Intl.Segmenter("zh-CN", {
granularity: "grapheme",
});
const characters = [...segmenter.segment(input)].map(
(segment) => segment.segment,
);
这样普通 Emoji、代理对以及常见的零宽连接 Emoji 序列可以按一个用户可见字符统计。
旧浏览器若没有 Intl.Segmenter,会回退到:
Array.from(input);
该降级至少能按 Unicode 码点拆分,而不是按 UTF-16 码元拆分,但复杂组合 Emoji 可能仍被统计为多个字符。教程和页面都不应把降级结果描述成绝对准确。
不含空白字符数是在字素数组上过滤:
characters.filter((character) => !/^\s+$/u.test(character)).length;
这让总字符和非空白字符使用同一基本单位。
使用 Unicode 属性转义分类字符
与其维护一份汉字区间,不如使用 Unicode 属性转义:
const chinese = input.match(/\p{Script=Han}/gu)?.length ?? 0;
const latin = input.match(/\p{Script=Latin}/gu)?.length ?? 0;
const digits = input.match(/\p{Number}/gu)?.length ?? 0;
const punctuation = input.match(/\p{Punctuation}/gu)?.length ?? 0;
需要注意名称和规则之间的差异:
- 页面上的“中文字符”实际统计 Han 脚本字符,因此日文汉字等也可能计入;
- 页面上的“英文字母”实际统计 Latin 脚本字符,不仅限于 ASCII
A-Z; \p{Number}比[0-9]范围更广;\p{Punctuation}根据 Unicode 标点类别判断。
如果产品未来需要语言学意义上的“中文”和“英文”,就不能只依靠脚本类别,需要重新定义口径。
空格数量当前统计:
/[ \t\u3000]/gu;
即普通空格、制表符和全角空格,不把换行算作“空格”。
英文单词为什么不直接按空格切
下面的写法会错误处理标点和多个空格:
input.split(" ");
当前规则使用:
/[A-Za-z]+(?:['’-][A-Za-z]+)*/g;
它能把以下内容作为合理单词:
ZGLab;don't;user-facing;- 使用弯引号的英文缩写。
这个规则有意只统计 ASCII 英文字母组成的单词。带重音字符或其他语言的拉丁文字不会完整进入“英文单词”统计,这是当前版本的已知口径,而不是算法遗漏后仍宣称支持所有语言。
行和段落的处理
Windows 常用 \r\n,Unix 常用 \n。统计前统一为:
const normalized = input.replace(/\r\n?/g, "\n");
空文本单独返回空行数组:
const lines = input === "" ? [] : normalized.split("\n");
如果直接执行 ''.split('\n'),结果会是包含一个空字符串的数组,从而错误显示为 1 行。
非空行使用 line.trim() !== '' 判断。段落先对全文 trim,再按一个或多个空行切分:
normalized
.split(/\n[ \t\u3000]*\n+/)
.filter((paragraph) => paragraph.trim() !== "");
因此只有空格或全角空格的分隔行也能形成段落边界。
UTF-8 字节数
JavaScript 字符串内部表示不能直接告诉我们 UTF-8 文件大小。当前实现使用:
new TextEncoder().encode(input).byteLength;
测试中的典型结果:
| 文本 | UTF-8 字节 |
|---|---|
a |
1 |
中 |
3 |
😀 |
4 |
这项指标适合估算网络负载、文件大小或接口字段限制,但不能与可见字符数混为一谈。
阅读时间如何估算
默认速度是:
const speeds = {
chineseCharactersPerMinute: 400,
englishWordsPerMinute: 225,
};
分别计算:
chineseMinutes = chineseCharacters / chineseSpeed;
englishMinutes = englishWords / englishSpeed;
combinedMinutes = chineseMinutes + englishMinutes;
用户可以在 1 到 2000 的范围内调整速度。组件通过 clampNumber 限制输入,逻辑层也对非正速度做防御,避免除以零。
综合时间采用两部分相加,是一个清晰、可复算的工程估算。它不考虑:
- 内容难度;
- 代码块;
- 图表;
- 重读;
- 中英文混合时的上下文切换。
因此 UI 使用“估算”措辞,不把结果包装成精确阅读时长。
为什么实时统计仍然需要防抖
每次键盘输入都会产生新字符串。字素切分、多个正则匹配和 UTF-8 编码都需要遍历文本。大文本下,每个按键立即重复完整计算没有必要。
组件把原始输入和待统计输入分开:
const [input, setInput] = useState("");
const [debouncedInput, setDebouncedInput] = useState("");
每次输入后等待 140 毫秒:
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedInput(input), 140);
return () => window.clearTimeout(timer);
}, [input]);
如果用户继续输入,上一轮定时器会被清理。统计结果再通过 useMemo 依赖防抖文本和阅读速度:
useMemo(() => countText(debouncedInput, speeds), [debouncedInput, speeds]);
这不是把任务移出主线程,而是减少无意义的调用频率。若未来支持数十 MB 文本,仍应考虑 Worker 或增量统计。
统计摘要和本地下载
展示层使用仪表板式 dl 列表,下载层则把同一份统计对象转换为纯文本报告:
ZGLab Tools · 文本统计报告
总字符数:...
中文字符:...
英文单词:...
UTF-8 字节:...
复制和下载使用同一个 report 字符串,避免页面看到的口径与文件口径不一致。TXT 由浏览器本地创建 Blob,不会把正文发给服务器。
测试设计
当前单元测试覆盖:
- 空文本的字符、行和段落都为 0;
- 纯中文和中文标点;
- 英文字母和带撇号单词;
- 中英文、数字、标点混合;
- 单个 Emoji;
- 零宽连接的家庭 Emoji;
- LF 与 CRLF 一致;
- 多个连续空行;
- UTF-8 字节;
- 自定义阅读速度;
- 可下载报告内容。
单独执行:
npx vitest run src/tools/text-counter/logic.test.ts
可以继续改进什么
后续可以根据产品目标增加:
- 使用
Intl.Segmenter的单词模式扩展多语言词数; - 代码行、句子数和最长行;
- Markdown 去除语法后的正文统计;
- Worker 处理超大文本;
- 允许用户导入本地文件但不上传;
- 将统计口径导出为机器可读 JSON。
无论增加什么指标,都应先定义规则、再实现、最后用反例测试,不能只追求页面上的数字数量。