Unicode 为文本分配码点,编码则把标量值转换为字节。一个用户感知的字符可能包含多个码点,并占用多个存储单元。
字节长度、字符串长度、码点数量与字素数量回答的是不同问题。按错误单位切片或比较,可能拆开文本或漏掉等价输入。
在字节边界严格解码,保留原始文本,并为每项操作明确选择单位和比较策略。只有契约要求时才做规范化。
是什么,为什么存在
Unicode 是跨书写系统处理文本时共用的字符表与算法集合。它给抽象字符分配称为 码点(code point) 的编号位置,例如 A 对应 U+0041。它并未规定每个字符只占一个字节、一个字符串索引或一个可见光标位置。
程序会在多个层次上接触文本。文件或网络消息包含按 UTF-8 等编码形成的字节;运行时字符串通过 API 暴露存储单元或标量值;渲染器与用户通常关心字素簇,其中可能组合基础字母、附加符号、emoji 修饰符和连接符。
这些层次相互分离,是因为没有一个整数能回答所有文本问题。字节限制用于保护协议帧,码点循环用于检查 Unicode 值,字素限制用于约束用户实际看到的内容。把这些计数混为一谈,会在边界处破坏数据。
数据库键、用户名、文件导入、表单限制、日志、正则表达式、光标移动、搜索和排序都会遇到这种差异。只用 ASCII 测试会隐藏多数错误,因为一个 ASCII 字符恰好是一个 UTF-8 字节、一个 UTF-16 代码单元、一个码点,通常也是一个字素簇。
Unicode 还允许外观相同的文本采用不同码点序列。字母 é 既可存为 U+00E9,也可存为 e 后接组合锐音符 U+0301。规范化为这些表示提供了确定的转换方式,但它是一项策略工具,不是重写所有字符串的命令。
文本比较有多种合理含义。协议标识符可能要求精确相等,规范文本可能先经过 NFC 规范化再比较,供人阅读的名称排序则可能使用区域敏感的比较器。程序必须从这些含义中明确选择,不能假定存在一种通用的「Unicode 安全比较」。
这里使用 JavaScript,是因为它的字符串 API 清楚暴露了一个重要边界:索引与 length 使用 UTF-16 代码单元,迭代则使用码点。其他运行时即使内部字符串表示或标准库 API 不同,也面临相同的设计问题。
工作原理
必须区分的五种单位
字节是序列化数据中取值为 0 到 255 的整数。编码把 Unicode 标量值映射成字节序列,解码器则把有效字节序列还原为文本。缺少编码标签时,字节本身无法唯一确定文本。
码点是 Unicode 代码空间中从 U+0000 到 U+10FFFF 的位置。代理项范围 U+D800 到 U+DFFF 保留给 UTF-16 的实现机制,不属于 Unicode 标量值(Unicode scalar value) 。UTF-8 编码标量值,不编码孤立的代理项码点。
一个 UTF-16 代码单元(UTF-16 code unit) 包含 16 位。基本多文种平面中的码点通常使用一个单元,补充平面码点则使用一对高、低代理项。JavaScript 的 length、方括号索引和 slice() 都按这种单元操作。
字素簇(grapheme cluster) 是文本分段规则视为一个用户感知字符的序列。e 加组合重音符号是一个簇。许多 emoji 序列由多个码点连接成一个簇,因此按码点迭代对于面向用户的字符限制仍然太细。
| 单位 | 示例问题 | JavaScript 中合适的机制 |
|---|---|---|
| UTF-8 字节 | 编码后的字段是否符合 64 字节限制? | TextEncoder().encode(text).length |
| UTF-16 代码单元 | 这个旧 API 按哪种单元索引? | text.length 或 text.slice() |
| 码点 | 字符串中出现了哪些已分配值? | for...of、[...text]、codePointAt() |
| 字素簇 | 用户能看到多少个可编辑字符? | 使用 granularity: "grapheme" 的 Intl.Segmenter |
| 区域排序元素 | 面向该受众时,标签应怎样排序? | Intl.Collator |
同一个字符串在每一行都可能得到不同计数。变量与限制的名称必须带上单位:maximumUtf8Bytes 比 maximumLength 更安全。只说「字符」的 API 契约并不完整。
编码是一份边界契约
UTF-8 是变长编码,每个标量值使用一到四个字节。ASCII 字节保留熟悉的值,非 ASCII 文本则会展开。UTF-16 用一个或两个 16 位代码单元表示标量值,因此两种编码提供的有效偏移量不同。
在系统边界用明确编码解码一次字节。只有再次进入面向字节的协议或存储格式时才编码。把 UTF-8 字节误当作 Latin-1 字符再编码,会产生乱码,而不是可逆的文本转换。
畸形输入需要声明处理策略。替换式解码器会填入 U+FFFD,适合尽力展示,却会销毁原始字节的证据。严格解码器会拒绝输入,更适合标识符、签名内容、导入任务,以及任何不能静默修改数据的路径。
分块输入还带有状态。一个多字节 UTF-8 序列可能从某个网络块开始,在下一个块中结束;独立解码每个块,会错误拒绝或替换本来有效的数据。应使用能保留部分序列状态的流式解码器,并在输入结束时明确收尾。
JavaScript 字符串暴露 UTF-16 语义
JavaScript 字符串是 UTF-16 代码单元序列。"💡".length 等于 2,只取第一个单元会得到未配对代理项,而不是半个有效 Unicode 标量值。方括号索引具有相同的单元边界。
字符串迭代会识别代理项对,并产出码点字符串。因此 [..."💡"] 的长度为 1。迭代仍不会组合重音符号或连接的 emoji,所以不能取代字素分段。
JavaScript 允许任意 16 位单元序列,所以字符串可以包含孤立代理项。String.prototype.isWellFormed() 可以检测它们,toWellFormed() 则用 U+FFFD 替换。替换是有损的;需要保留精确输入时,应拒绝无效的内部文本。
偏移量必须携带单位。数据库字节偏移、JavaScript 代码单元索引、码点序号和字素位置不能未经转换就在 API 之间传递。如果文本可能规范化或编辑,应存储稳定的语义锚点,因为任何转换都可能移动数字偏移。
字素边界由算法确定
Unicode 文本分段依据字符属性和规则,定义默认的扩展字素簇边界。Intl.Segmenter 通过 JavaScript 国际化 API 提供区域敏感的分段。它通常是实现光标步进、截断和用户可见计数的起点。
字素簇不一定等于单词、字形或固定宽度的显示单元。字体可能把多个字素簇渲染成一个连字,一个字素簇在不同字体或终端中的可见宽度也可能不同。布局测量仍应交给渲染系统。
随着 Unicode 增加字符和改进规则,分段数据也会演进。如果只持久化字素索引,多年后用不同 Unicode 版本重新计算时,可能选中不同边界。结果必须可复现时,应保留源文本与应用级锚点。
规范化定义等价转换
规范化形式 C(NFC)先执行规范分解,再在有定义的位置重新组合。规范化形式 D(NFD)则保留规范分解。两者会让规范等价序列收敛,但不会把兼容字符当作普通替代项处理。
NFKC 与 NFKD 还会执行兼容分解。带圈数字、宽度变体和一些表现形式会因此与普通字符收敛。这种更宽的折叠可能适合明确规定的搜索键,却也可能消除存储、显示或标识符需要保留的差异。
规范化具有幂等性:再次应用同一种形式,结果不会变化。它不是音译、拼写修正、去除重音符号或区域敏感的大小写转换。这些操作各自具有不同的数据损失和语言影响。
应在明确的比较或摄取边界规范化,而不是散布在代码库各处。用户可能需要原始形式时,应保留显示文本,并在旁边生成规范化键。如果数据库中已有混合形式,只改变新写入会造成契约分裂,直到旧数据迁移完成或读取逻辑同时处理两者。
比较方式取决于目的
精确相等比较 JavaScript 中存储的代码单元序列。规范把标识符定义为精确字符串,或需要确认字节是否解码为预期文本时,这种方式很合适。它会有意把规范等价序列视为不同。
规范化相等先对两个操作数应用同一种选定形式,再做精确比较。只有领域明确声明这些规范化等价项可互换时,这种方式才合适。每条写入与查询路径都必须一致地存储或计算键。
区域敏感排序回答的是客户姓名应怎样展示排序等问题。Intl.Collator 需要明确的区域设置,以及 sensitivity、numeric 和 usage 等选项。比较结果为 0 只表示在该排序策略下等价,不代表两者完全相同或可以安全合并。
| 目的 | 常见策略 | 重要警告 |
|---|---|---|
| 协议标记 | 按协议规范执行精确相等 | 不要静默加入规范化或大小写折叠 |
| 规范文本键 | 两侧采用同一种规范化形式 | 迁移现有数据,并统一写入路径 |
| 用户搜索 | 采用产品定义的规范化、大小写与区域规则 | 保留原文,并测试特定语言情况 |
| 展示排序 | 使用一个明确区域与选项的 Intl.Collator | 用稳定次级键保证同值项顺序确定 |
| 安全标识符 | 采用规范指定的标准化方案 | 排序等价不能作为授权规则 |
不区分大小写的匹配并不等于在所有位置调用 toLowerCase()。大小写行为可能取决于语言,完整大小写折叠还可能改变字符串长度。应遵循对应协议或产品规范,测试它的精确映射,而不是自行发明一套通用的规范化加小写转换顺序。
可靠的处理顺序
- 在从可信契约确定编码之前,把传入数据保留为字节。
- 按选定的畸形输入策略解码;审计或恢复需要时,保留原始字节。
- 使用应用约束真正指定的单位做验证:字节、标量值、字素或领域专用单位。
- 保留原始字符串,只为明确用途派生规范化键或搜索键。
- 按标识符规范进行标识比较,按明确的区域策略处理面向用户的文本。
- 在传出边界编码,然后对编码结果执行面向字节的协议限制。
这种顺序能防止意外的重复解码,也让有损转换保持可见。某些系统会为性能合并步骤,但 API 契约仍应描述相同的逻辑边界。
示例
下面的示例先检查文本单位,再实施严格字节边界、建立规范化查询键,最后对面向用户的文本分段和排序。每个文件都用 Node 24.14.0 运行;紧随其后的文本块是完整真实输出。
测量真正需要的单位
同一组样本分别按 UTF-16 代码单元、码点、字素簇和 UTF-8 字节测量。这些名称能避免只报告一个含义不明的「长度」。
const samples = ["A", "é", "e\u0301", "💡", "👨👩👧👦"];
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const encoder = new TextEncoder();
for (const sample of samples) {
const codeUnits = sample.length;
const codePoints = [...sample].length;
const graphemes = [...segmenter.segment(sample)].length;
const utf8Bytes = encoder.encode(sample).length;
console.log(
`${JSON.stringify(sample)}: units=${codeUnits}, points=${codePoints}, graphemes=${graphemes}, bytes=${utf8Bytes}`,
);
}"A": units=1, points=1, graphemes=1, bytes=1
"é": units=1, points=1, graphemes=1, bytes=2
"é": units=2, points=2, graphemes=1, bytes=3
"💡": units=2, points=1, graphemes=1, bytes=4
"👨👩👧👦": units=11, points=7, graphemes=1, bytes=25预组和形式与分解形式的重音字母外观相同,也各自形成一个字素,但代码单元、码点和字节计数不同。家庭 emoji 是更明显的反例:一个用户感知的簇由七个码点组成,占用十一个 JavaScript 字符串单元。
这里仅有 Intl.Segmenter 用于计算可编辑字符。编码器给出的计数适合 UTF-8 协议限制。两者不能互相替代。
在摄取时拒绝畸形字节
这个导入边界先编码消息,以生成自包含的字节,然后输出存储表示,并在严格模式下解码。去掉最后一个字节后,四字节 emoji 序列便不完整。
const encoder = new TextEncoder();
const strictUtf8 = new TextDecoder("utf-8", { fatal: true });
const message = "订单 💡";
const stored = encoder.encode(message);
const hex = [...stored]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join(" ");
console.log(hex);
console.log(strictUtf8.decode(stored));
const truncated = stored.slice(0, -1);
try {
strictUtf8.decode(truncated);
} catch (error) {
console.log(`rejected: ${error.constructor.name}`);
}e8 ae a2 e5 8d 95 20 f0 9f 92 a1
订单 💡
rejected: TypeError严格失败后,调用方可以隔离原始字节、返回验证错误或请求重新传输。替换模式解码器会生成含 � 的字符串,可能让两个不同的畸形标识符变成同一个可见值。
本例解码的是完整缓冲区。处理网络分块时,应复用启用流模式的解码器,并在流结束时再调用一次,以检测未完成序列。
同时保留显示文本与规范化键
目录保留最初提供的拼写用于展示,并生成 NFC 键用于精确查询。示例还说明,在缺少领域决策时改用 NFKC 会扩大相等关系。
const composed = "Am\u00e9lie";
const decomposed = "Ame\u0301lie";
function exactComparisonKey(value) {
return value.normalize("NFC");
}
const directory = new Map();
directory.set(exactComparisonKey(composed), { id: "customer-17", displayName: composed });
console.log(composed === decomposed);
console.log(exactComparisonKey(composed) === exactComparisonKey(decomposed));
console.log(directory.get(exactComparisonKey(decomposed)).id);
console.log(`NFC circled one equals 1: ${"①".normalize("NFC") === "1"}`);
console.log(`NFKC circled one equals 1: ${"①".normalize("NFKC") === "1"}`);false
true
customer-17
NFC circled one equals 1: false
NFKC circled one equals 1: trueNFC 让两个规范等价的姓名序列收敛,又不改变存储的显示名称。带圈数字在 NFC 下保持不同,在 NFKC 下却与普通 1 收敛,说明规范化形式是数据契约的一部分。
生产目录还必须决定大小写、空白、区域设置、重复项和迁移策略。只有 NFC 并不能构成完整的用户名或搜索策略。
对显示文本分段并排列姓名
最后一个示例只在字素边界截断预览,再用法语显示排序器排列客户姓名。对所选敏感度而言比较相等的姓名,由稳定标识符决定顺序。
const message = "👍🏽 café e\u0301lan 👨👩👧👦";
const segmenter = new Intl.Segmenter("fr", { granularity: "grapheme" });
function takeGraphemes(text, maximum) {
return [...segmenter.segment(text)]
.slice(0, maximum)
.map(({ segment }) => segment)
.join("");
}
console.log(takeGraphemes(message, 8));
console.log([...segmenter.segment(message)].length);
const customers = [
{ id: "u3", name: "Élodie" },
{ id: "u1", name: "Elodie" },
{ id: "u2", name: "Zoë" },
];
const collator = new Intl.Collator("fr", { sensitivity: "base" });
const ordered = customers.toSorted(
(left, right) => collator.compare(left.name, right.name) || left.id.localeCompare(right.id),
);
console.log(collator.compare("Élodie", "elodie") === 0);
console.log(ordered.map(({ id }) => id).join(", "));👍🏽 café é
13
true
u1, u3, u2预览完整保留带肤色的 emoji 和分解形式的重音字母。分段只查找边界,并不会规范化文本,因此原始码点序列仍然不变。
在基础敏感度下,排序器认为 Élodie 与 elodie 对本次比较等价。标识符同值排序条件让输出可重复,却不会让两个姓名变成同一账户或授权身份。
陷阱
按代码单元实施用户限制
修复方法: 先说明限制用于保护编码字节,还是约束用户可见输入。用 TextEncoder 计算 UTF-8 字节,用 Intl.Segmenter 处理字素簇;两种限制同时存在时,还要再次检查编码结果。
使用隐式或宽容的解码策略
修复方法: 从协议获取编码,不要根据内容猜测。完整性敏感的数据应采用严格解码;允许时保留原始字节供诊断;跨分块边界时使用流式解码器。
把规范化当成净化
修复方法: 只在有文档说明的比较或存储策略中使用指定的规范化形式。安全验证、转义、书写系统限制和混淆字符处理应作为独立控制措施,并由具体威胁模型决定。
用区域比较判断身份
修复方法: 身份相等必须单独按规范定义。排序仅用于面向用户的搜索或顺序,需记录区域与选项;必须确定排序时,还应加入稳定次级键。
因派生键丢失原文
修复方法: 保留原始文本;索引需要派生键时,按带版本的策略另外存储。策略或 Unicode 数据版本变化时重建键,并确保写入与查询采用同一派生过程。
Unicode 边界与比较契约
字符串内部的畸形 UTF-16
Unicode 标量值排除代理项,但 JavaScript 字符串可以包含孤立的高代理项或低代理项。这种字符串可能来自代码单元切片、手动构造、旧数据,或暴露未经检查 UTF-16 的 API。它是有效的 JavaScript 值,却不是 Unicode 标量值序列。
TextEncoder 把这种字符串编码为 UTF-8 时,会用 U+FFFD 替换孤立代理项。这种转换无法保持字节往返一致。如果标识符、签名输入或审计记录不能静默改变,编码前应调用 isWellFormed() 检查并拒绝。
产品明确偏好可渲染替换文本时,可以使用 toWellFormed()。它不应被描述为修复原字符,因为缺失的另一半代理项无法推断。如果后续调查很重要,应另外保留源表示。
增量解码持有部分序列
UTF-8 解码器依据起始字节与续字节判断边界。传输分块没有义务在字符边界结束,所以解码器必须在调用之间保留未完成前缀。先拼接全部字节再统一解码是正确的,但对于数据流可能占用过多内存。
流式方案会把每个块传给同一个已启用流状态的解码器。输入结束时,再做一次不带新字节的最终调用,强制验证所有待处理前缀。每个块重新创建解码器,会丢掉判断跨块有效序列所需的状态,无法把它与畸形输入区分开。
协议限制必须说明应用在解码前还是解码后。最大线路大小是字节限制,最大显示长度可能是字素限制。两者要在各自边界执行,避免含大量多字节字符的输入绕过容量规划或被过早截断。
组合符号的规范顺序
规范化不只是把某个分解字符对替换成一个预组和码点。它还会依据规范组合类别排列组合符号,除非起始字符边界或阻塞规则要求保留顺序。只为少数带重音拉丁字母编写的手工映射无法重现该算法。
并非每个序列都有预组和字符。因此,NFC 结果仍可能用多个码点表示一个字素簇。假定「NFC 意味着每个可见字符只有一个码点」仍然是错误的。
派生数据应记录规范化形式。仅仅因为两个键都叫作规范化键,就不能安全比较分别由 NFC 与 NFKC 生成的结果。加入大小写折叠、空白规则或应用别名等其他映射时,应给键方案设置版本。
分段对于布局必要但不充分
扩展字素簇旨在为编辑与边界操作提供实用默认单位。它会把常见组合序列、emoji 修饰符、区域指示符旗帜和连接符序列保持在一起,但不保证每个用户群体都把每个簇视为一个有意义的「字符」。
显示宽度取决于字体、塑形、终端约定和周围文本。计算字素簇无法预测像素或终端列数。布局应使用渲染器测量 API,文本边界则使用分段 API。
正则表达式也有自己的操作单位与 Unicode 模式。点号、字符类或量词可能按代码单元或码点工作,仍会拆开字素簇。如果模式需要验证完整的用户感知字符,应组合明确的 Unicode 属性和分段,而不是假定一个 Unicode 标志就会把单位改成字素。
存储文本与比较键职责不同
原始文本回答「用户或来源提供了什么」,比较键回答「本次操作忽略哪些差异」,排序则根据区域策略产生顺序。把这些值合并到一个字段,会增加未来调整显示、审计与策略的难度。
稳健的记录可以同时保存 displayName、精确规范化查询键,以及按另一套带版本策略生成的搜索索引。这些字段可能有意碰撞:两个显示名称可以共享搜索键,却不代表同一条记录。只有等价关系符合业务规则的键才应设置唯一约束。
数据库排序规则与应用排序器可能采用不同 Unicode 版本、定制规则或敏感度。只有两层顺序契约一致时,才能在一层排序后到另一层应用二分查找边界。否则,应获取范围更宽的候选集,或让同一个所有者负责排序与查找。
Unicode 或区域数据变化时,即使源文本不变,分段和排序也可能变化。需要复现的索引因此必须声明实现或数据版本,并准备重建方案。展示排序通常可以接受升级后的行为,持久化分页游标则可能需要稳定次级标识符。
面向边界的测试矩阵
应从能让单位产生差异的情况开始,而不是罗列大量普通单词:
- 空文本与 ASCII 用于建立普通边界行为。
- 补充平面码点用于暴露 UTF-16 代理项对处理。
- 分解形式的重音用于暴露规范等价与多码点字素。
- emoji 修饰符、旗帜和连接序列用于检验字素分段。
- 在每个字节边界拆分有效 UTF-8,用于检验流式解码器状态。
- 过长形式、截断、代理项编码和孤立续字节用于检验拒绝逻辑。
- 兼容字符用于区分 NFC 与 NFKC 策略。
- 特定区域的大小写与排序同值项用于暴露隐藏的比较默认值。
断言应写明目标性质。应检查严格解码会拒绝畸形字节数组、截断结束于分段边界、比较键只在业务规则允许的位置发生碰撞。若没有声明区域与选项,只对某个运行时的姓名排序做快照,记录的是偶然结果而不是契约。
性质测试还能加入实用不变量。用匹配编码器产生的字节解码后,应还原原始的格式正确标量序列;规范化两次等于一次;拼接所有分段可还原原文;基于排序器的排序结果在同一排序器下不会递减。
如果源码编辑器可能规范化文本,测试夹具应使用明确的转义序列。包含分解形式 e\u0301 的测试应在代码中直接写明,否则格式化或复制过程把它静默变成 é 后,测试原本要覆盖的情况就消失了。
延伸阅读
5个问题 · 1 道输出预测题 · 1 道找错题