AI 写的字数统计器为什么连 Emoji 都数不对?从 UTF-16 到 Intl.Segmenter 的 Unicode
前言
现在使用 AI 编程助手开发网页小工具非常方便。
例如,我们只需要提出一个需求:
“帮我开发一个在线字数统计器,支持中文、英文和 Emoji。”
AI 很快就能生成一个输入框,再使用 JavaScript 统计字符数量。
最常见的写法就是:
const text = "你好世界";
console.log(text.length);
结果为 4,看起来完全正确。
但如果换成 Emoji 呢?
console.log("😀".length);
结果竟然是:
2
明明只有一个 Emoji,为什么 JavaScript 认为它的长度是 2?
如果输入家庭 Emoji:
console.log("👨👩👧👦".length);
结果甚至是 11。
这并不是 JavaScript 计算错误,而是我们对“字符”的理解与 JavaScript 字符串的内部表示方式不同。
本文就从这个常见问题出发,介绍 UTF-16、Unicode 码点以及 Intl.Segmenter,并实现一个能够正确处理大多数常见 Emoji 组合的网页字符统计工具。
一、为什么 JavaScript 的 length 不等于字符数量?
JavaScript 字符串采用 UTF-16 编码单元序列表示。
简单理解,length 返回的是 UTF-16 编码单元的数量,而不是用户肉眼看到的字符数量。
对于常见汉字、英文字母:
console.log("ABC".length); // 3
console.log("你好".length); // 2
这些结果通常符合我们的直觉。
但是,部分 Unicode 字符需要使用两个 UTF-16 编码单元表示。
例如:
console.log("😀".length); // 2
虽然屏幕上只有一个笑脸,但它占用了两个 UTF-16 编码单元。
更复杂的 Emoji 还可能由多个 Unicode 码点组合而成。
例如家庭 Emoji:
👨👩👧👦
它并不是一个单独的 Unicode 码点,而是由多个 Emoji 和零宽连接符组合形成的序列。
因此,如果直接用 length 统计用户感知的字符数量,就可能得到不符合直觉的结果。
二、解决方案一:使用 Array.from()
JavaScript 提供了一种更接近 Unicode 码点统计的方法。
例如:
const text = "😀";
console.log(Array.from(text).length);
结果是:
1
原因在于,字符串迭代器会按 Unicode 码点处理有效的代理项对,而不是简单地逐个读取 UTF-16 编码单元。
所以:
console.log(Array.from("你好").length); // 2
console.log(Array.from("😀").length); // 1
但是,这个方法还有一个局限。
继续测试:
const family = "👨👩👧👦";
console.log(family.length);
console.log(Array.from(family).length);
结果分别是:
11
7
问题来了:
家庭 Emoji 明明显示成一个图案,为什么按照 Unicode 码点统计仍然有 7 个?
因为 Unicode 码点并不等于用户感知字符。
Array.from() 解决了部分问题,但并不能完整识别组合 Emoji。
三、什么是 Grapheme Cluster?
在 Unicode 文本处理中,还有一个重要概念:
Grapheme Cluster,通常称为字素簇。
可以把它理解为按照 Unicode 文本分割规则划分出的用户感知字符单元。
例如:
| 输入内容 | UTF-16 编码单元 | Unicode 码点 | 字素簇 |
|---|---|---|---|
| A | 1 | 1 | 1 |
| 你 | 1 | 1 | 1 |
| 😀 | 2 | 1 | 1 |
| 👍🏽 | 4 | 2 | 1 |
| 👨👩👧👦 | 11 | 7 | 1 |
可以看到:
当我们希望统计用户视觉上认为的一个完整 Emoji 时,字素簇通常更符合需求。
不过,字素簇是基于 Unicode 分割规则的文本单位,并不保证与所有字体、浏览器中的实际字形显示始终一一对应。
四、解决方案二:使用 Intl.Segmenter
JavaScript 提供了 Intl.Segmenter API,可以按指定粒度分割文本。
其中一个非常实用的配置就是:
granularity: "grapheme"
例如:
const segmenter = new Intl.Segmenter("zh-CN", {
granularity: "grapheme"
});
const text = "👨👩👧👦";
const characters = [
...segmenter.segment(text)
];
console.log(characters.length);
结果为:
1
这次,家庭 Emoji 被识别为一个字素簇。
如果我们想要统计整个字符串,可以封装为一个函数:
function countCharacters(text) {
const segmenter = new Intl.Segmenter("zh-CN", {
granularity: "grapheme"
});
return [...segmenter.segment(text)].length;
}
测试:
console.log(countCharacters("你好世界")); // 4
console.log(countCharacters("😀")); // 1
console.log(countCharacters("👍🏽")); // 1
console.log(countCharacters("👨👩👧👦")); // 1
这样就能处理许多普通字符串统计方法无法正确识别的组合字符。
五、实战:开发一个 Unicode 字符统计网页
接下来实现一个简单的网页工具。
功能包括:
- 实时统计 UTF-16 编码单元数量。
- 统计 Unicode 码点数量。
- 统计字素簇数量。
- 显示 UTF-8 字节数。
整个项目使用浏览器原生 API,不需要后端服务器,也不需要第三方依赖。
创建 index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1">
<title>Unicode 字符统计器</title>
<style>
body {
font-family: system-ui, sans-serif;
max-width: 650px;
margin: 40px auto;
padding: 20px;
line-height: 1.6;
}
textarea {
box-sizing: border-box;
width: 100%;
min-height: 160px;
padding: 12px;
font: inherit;
}
.result {
background: #f1f5f9;
padding: 16px;
margin-top: 16px;
border-radius: 10px;
}
</style>
</head>
<body>
<h1>Unicode 字符统计器</h1>
<label for="input">
输入需要统计的文本
</label>
<textarea id="input"
placeholder="输入中文、英文或 Emoji"></textarea>
<div class="result">
<p>UTF-16 编码单元:<strong id="units">0</strong></p>
<p>Unicode 码点:<strong id="points">0</strong></p>
<p>字素簇:<strong id="graphemes">0</strong></p>
<p>UTF-8 字节数:<strong id="bytes">0</strong></p>
</div>
<p id="notice" role="status"></p>
<script>
const input = document.getElementById("input");
const segmenter =
typeof Intl.Segmenter === "function"
? new Intl.Segmenter("zh-CN", {
granularity: "grapheme"
})
: null;
function updateCount() {
const text = input.value;
const units = text.length;
const points = Array.from(text).length;
const bytes = new TextEncoder()
.encode(text).length;
const graphemes = segmenter
? Array.from(segmenter.segment(text)).length
: null;
document.getElementById("units")
.textContent = units;
document.getElementById("points")
.textContent = points;
document.getElementById("bytes")
.textContent = bytes;
document.getElementById("graphemes")
.textContent = graphemes ?? "不支持";
document.getElementById("notice")
.textContent = segmenter
? ""
: "当前浏览器不支持 Intl.Segmenter";
}
input.addEventListener("input", updateCount);
updateCount();
</script>
</body>
</html>
直接使用支持 Intl.Segmenter 的现代浏览器打开,就可以体验不同字符统计方式的区别。
例如输入:
你好😀👨👩👧👦
在支持上述 Unicode 分割行为的环境中,预期统计结果是:
| 指标 | 结果 |
|---|---|
| UTF-16 编码单元 | 15 |
| Unicode 码点 | 10 |
| 字素簇 | 4 |
| UTF-8 字节数 | 35 |
其中,字素簇统计最接近用户在这个例子中看到的四个完整字符单位。
六、为什么字数限制也可能出现 Bug?
这个问题在真实业务中很常见。
例如,一个网站要求用户昵称最多输入 10 个字符。
开发者可能写出:
if (nickname.length > 10) {
alert("昵称不能超过10个字符");
}
但如果用户输入多个 Emoji,这种限制就可能与用户预期不一致。
例如,五个普通笑脸 Emoji:
😀😀😀😀😀
它们的 length 为 10。
用户明明只输入了五个 Emoji,却已经用完了十个 UTF-16 编码单元。
如果产品需求真正要求限制用户感知字符数量,就应该考虑字素簇统计。
但需要注意:
并不是所有场景都应该改用字素簇。
例如:
- 数据库字段可能以字节长度限制。
- 某些 API 规定按 Unicode 码点统计。
- 文件协议可能规定具体编码长度。
- 短信计费可能使用其他编码和分段规则。
因此,开发者首先要弄清楚业务要求中的“字符”究竟指什么。
不能认为某一种统计方式适合所有场景。
七、还有一个容易忽略的问题:Unicode 规范化
在 Unicode 中,一些看起来相同的文字可能存在不同的编码表示。
例如,带重音的字母 é 可以使用一个预组合字符表示,也可以通过字母 e 加组合重音符号表示。
const a = "\u00E9";
const b = "e\u0301";
console.log(a === b); // false
虽然它们可能显示得很相似,但实际字符串并不相同。
JavaScript 提供了:
normalize()
用于 Unicode 规范化。
例如:
console.log(
a.normalize("NFC") === b.normalize("NFC")
);
结果是:
true
这对于文本比较、搜索和去重工具非常有用。
不过,规范化也会改变字符串表示,因此不能在所有场景中无条件使用。
尤其是密码、数字签名、文件哈希等依赖原始字节内容的场景,不能随意规范化后再处理。
八、AI 编程助手为什么容易忽略 Unicode?
因为大多数简单测试只包含:
Hello
你好
12345
这些输入通常不会暴露 UTF-16 编码单元与用户感知字符之间的区别。
如果测试数据没有 Emoji、组合字符或特殊 Unicode 序列,使用 length 可能一直看起来正确。
因此,在开发以下功能时,应该主动增加 Unicode 测试:
- 字数统计器。
- 昵称长度限制。
- 文本截取。
- 字符逐个显示动画。
- 搜索和文本去重。
- 表单输入验证。
例如,可以在给 AI 编程助手的提示词中增加:
文本处理功能必须考虑 Unicode。涉及用户感知字符数量时,优先使用 Intl.Segmenter,并测试中文、Emoji、组合字符和空字符串。不要简单地用 JavaScript 的 length 作为所有字符数量的判断依据。
这会比单纯要求“支持中文和 Emoji”更加明确。
九、总结
通过本文的实践,我们可以看到:
JavaScript 的字符串长度并不是一个看起来那么简单的问题。
length 统计的是 UTF-16 编码单元。
Array.from() 可以按 Unicode 码点遍历。
Intl.Segmenter 则能够按照 Unicode 字素簇规则识别完整的字符单元。
对于普通英文和常见汉字,这几种统计方式可能得到相同结果。
但当文本包含 Emoji、组合符号和复杂 Unicode 序列时,结果就可能明显不同。
我认为,AI 自动开发网页时,除了关注按钮是否正常、布局是否美观,也应该关注这些隐藏在常见 API 背后的边界情况。
- 点赞
- 收藏
- 关注作者
评论(0)