第 9 讲 / 共 10 讲

Web 开发中的 Unicode:从输入、存储到搜索与复制

一个表单在英文测试数据上完全正常,遇到带重音的名字、emoji 或扩展汉字却出现截断、重复记录或搜索不到。这类问题往往来自不同组件对“字符”和“相同”的理解不一致。可靠的 Unicode 支持,需要让输入、编码、存储、比较与展示各自有明确规则,并用同一批真实文本贯穿验证。

Copy2Paste约 12 分钟阅读

先把文本经过的每一步画出来

从浏览器输入到数据库,再从接口回到页面,文本会经过多个边界。某些边界处理字符串,某些边界处理字节。编码是两者之间的约定,不是给已经损坏的内容补一个标签。实际文件使用 UTF-8,却被按其他编码解释,会产生错误文本;之后再声明 UTF-8,并不能自动找回原先想表达的字符。

新建网页应使用一致的 UTF-8 编码约定,并让服务器响应、文档声明和实际内容保持一致。排查时应检查真实响应,而不是只看编辑器状态栏。导入旧文件时,则先确认来源编码,记录转换方式和失败处理。不要连续尝试不同解码器,直到某种结果“看起来差不多”,然后覆盖唯一原件。

可以建立一张简短的数据流表:输入组件接收什么,接口以什么形式发送,服务端怎样解析,数据库用什么规则保存,导出文件怎样编码。每个边界都要有可检查的样本。这样,乱码不再是整套系统的模糊故障,而是可以定位到第一次从正确内容变成错误内容的具体步骤。

用编码器测字节,不凭语言猜长度

JavaScript 的 TextEncoder 将字符串编码成 UTF-8 字节,输出数组的长度可用于测量对应字节数。ASCII 字母通常只需要一个字节,中文和 emoji 的常见示例会更长,但业务规则不应写成“中文算三,英文算一”。文本还能包含组合符号、其他文字和不同范围的字符,直接编码比猜测可靠。

解码时也要决定如何处理坏输入。TextDecoder 默认可用替代字符处理某些错误;开启 fatal 选项则会在解码错误时抛出异常。适合预览的容错策略未必适合严谨的数据导入。两种方式没有脱离场景的绝对优劣,关键是调用方知道内容是否被替换,以及失败后原始字节是否仍可取回。

分块读取文件或网络数据时,不要把每块都当成完整文本独立解码。一个多字节字符可能刚好跨过块边界,应使用支持流式状态的解码方式,并在末尾正确结束。测试时把同一段字节切在不同位置,确认拼回的文本一致。这种测试比仅用很长的英文文件更容易暴露真实边界问题。

下面还可以延伸出一个上传限制实验:先计算完整文本的字节数,再比较服务器接受的实际请求大小。二者可能不同,因为请求还包含字段名、结构和其他数据。文本长度检查保护的是一个明确层次,不能拿它直接替代整个请求的限制。界面应分别解释内容太长与上传数据超限,避免用户删了几个字符以后仍不知道为什么失败。

JavaScript 字符串不保证天然完整

JavaScript 字符串以 UTF-16 码元组织,length 给出的不是用户看到的字符数量。字符串还可能包含未配对的代理项,这种值不能表示良构的 Unicode 标量值序列。现代运行时提供 isWellFormed 检查,以及把未配对代理项替换为替代字符的 toWellFormed;使用前仍需确认目标运行时支持。

“修复为良构字符串”不等于无损恢复。替换操作不知道缺失的另一半原本是什么。对于用户手工编辑的普通说明,产品可以选择明确的容错;对于要求逐字保真的导入任务,拒绝并提示位置往往更合适。应在数据边界作决定,避免编码器执行替换以后,业务层还把结果描述成原文的精确副本。

最容易制造未配对代理项的操作之一,是按固定码元位置切开字符串。比如先把含 emoji 的标题切成前若干位,再发送给后端,看似只是缩短文本,却可能切在代理对中间。检查边界与选择正确分段方式,比事后反复调用修复函数更能保留内容。处理规则应从需求出发,而不是从方便的字符串方法出发。

还应区分空字符串与无效字符串。一个值可以是空的但完全良构,也可以肉眼看起来没有问题却含有未配对代理项。必填验证、长度验证与良构性验证回答三个不同问题。把它们分开实现和测试,错误信息就能准确对应原因,也不会把某一次容错转换的成功误当成所有业务条件都已满足。

给用户看的限制,应说明计数单位

码位迭代能避免把代理对拆开,但仍可能把字母与附加符号、或复合 emoji 分开。面向用户的光标移动、截取和显示计数,通常需要字素簇这一更接近阅读感受的单位。Intl.Segmenter 可按字素粒度分段;其行为还依赖运行时提供的国际化数据,因此跨环境验证仍有意义。

如果界面说最多二十个字符,就应明确它指显示单位还是存储限制。可以同时提供面向人的字素计数,以及面向系统的 UTF-8 字节上限,但不要把两个数字混称为字符数。限制达到时应保留完整字素,而不是悄悄把附加符号或序列尾部丢掉。对于持续输入中的组合过程,也应避免过早截断。

可以把长度规则写成一个独立函数,输入原文,输出码元数、码位数、字素数和字节数。下面的示例使用同一字符串产生四种统计。它们可能全部正确,只是在回答不同问题。测试时应断言每种统计的用途,而不是试图让所有接口返回同一个数字,否则需求中的差异会被错误地掩盖。

字素簇也不是屏幕宽度单位。一个完整 emoji、一个汉字和一个拉丁字母,即使各算一个显示单元,占用的像素仍由字体与布局决定。如果需求是让按钮文字适合宽度,应使用排版测量或可访问的视觉截断;如果需求是限制用户输入,则按照公布的计数规则处理。不要让字符统计承担字体排版的工作。

javascript
const text = "Ae\u0301😀";
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
console.log({
  codeUnits: text.length,
  codePoints: [...text].length,
  graphemes: [...segmenter.segment(text)].length,
  utf8Bytes: new TextEncoder().encode(text).length,
});
// { codeUnits: 5, codePoints: 4, graphemes: 3, utf8Bytes: 8 }

const broken = "\uD800";
console.log(broken.isWellFormed()); // false
console.log(broken.toWellFormed() === "\uFFFD"); // true

try {
  new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.of(0xFF));
} catch (error) {
  console.log(error.name); // TypeError
}
给用户看的限制,应说明计数单位
文本(转义表示)UTF-16 码元码位字素簇UTF-8 字节
A1111
e\u03012213
😀2114
👩\u200D💻53111

数据库的长度与唯一性需要单独核对

不要把数据库字段声明里的数字一律当成字节数。例如 PostgreSQL 的有长度字符类型按字符限制,而其他数据库、索引或传输层可能有不同规则。即使字段接收成功,索引大小、接口请求体和导出格式仍可能构成另一个限制。应查具体产品及版本的文档,并对实际读写路径测试。

唯一性也不只是比较两个字节数组。排序规则可能影响大小写、重音以及某些等价形式的比较,配置不同,重复判断就可能不同。若应用认为两个账户名相同,数据库却允许分别插入,或者情况反过来,用户会遇到难以解释的冲突。应用匹配键与数据库约束必须配合设计,并用相同样本验证。

做迁移时,应先在副本中检查新规则会合并哪些现有值。不能仅证明新建表能写入几个多语言词,就认为历史数据已经安全迁移。还要验证备份恢复、批量导入和回滚路径,尤其注意旧客户端是否仍会发送旧格式。字符串往返一致,才说明整条存储链路没有悄悄改变文本。

数据库往返测试最好记录输入序列和读出序列,而不仅比较截图。两种文本可能看起来一样,却在精确导出时不一致;反过来,字体回退改变外观,也不代表保存出错。把序列验证与视觉验证分开,能够让存储层和前端各自承担清晰责任。需要保真时,还应验证导出的文件重新导入后是否维持同一结果。

输入与复制是内容契约的一部分

浏览器输入法可能在用户确认前持续更新组合文本。若在每次输入事件里立即重写或截断,可能打断正常输入。应在交互设计中考虑组合阶段,并将即时提示与最终验证分开。对于粘贴内容,同样要让用户知道哪些变换会发生,而不是把意料之外的字符一律静默删除。

复制操作应符合按钮文案。复制原文就保留原文,复制规范化版本就明确标注,复制码位则输出用于检查的表示。显示预览与实际剪贴板结果不一致,会让排错变得困难。一个可靠测试是把剪贴板输出与当前工具结果逐码位比较,而不仅检查按钮是否短暂显示成功。

错误信息也要有定位价值。与其说“含有非法字符”,不如在适当场景指出位置、码位和当前字段的规则,同时保留用户输入供修改。不要把整段私密内容写入分析事件或共享日志。工程调试需要足够证据,但可以用固定错误类型、长度计数和可控样本完成许多验证,无须收集真实正文。

用一组小样本覆盖真实边界

一组实用测试可包括纯 ASCII、带组合重音的字母、中文、基本多文种平面之外的字符、完整 emoji 序列及混合方向短句。对每个样本执行输入、编码、发送、保存、读取和复制,再验证哪些步骤必须精确保留,哪些步骤允许约定变换。允许变换的部分也应有明确预期值。

失败样本同样重要:不完整 UTF-8 字节、未配对代理项、恰好落在序列中间的截取位置、超过字节限制但没有超过显示计数的文本。流式解码应改变切块位置,数据库验证应使用真实约束。测试不需要遍历所有 Unicode 字符才能有价值,关键是覆盖系统采用的边界和决策。

最后,把测试与升级过程连接起来。浏览器、运行时和国际化库的数据版本变化,可能改变分段或排序结果;字体变化则可能改变显示而不改变存储。记录版本并重跑相关样本,让变化有证据可追踪。真正可靠的 Unicode 支持,是每个组件都清楚自己在处理字节、码元、码位、字素还是语言规则,并在交接时保持约定。

准备这些样本时应写明为什么选它们。例如附加符号用于检查分段,补充平面字符用于检查代理对,多字节输入用于检查编码边界。这样的测试说明能指导后来加入的开发者补充真实缺陷样本。若只有一串来历不明的特殊字符,测试虽然会运行,却很难判断覆盖了什么,也容易在重构时被误删。

参考资料

  1. WHATWG Encoding Standard ↗
  2. ECMAScript String Methods ↗
  3. ECMAScript Internationalization API: Segmenter Objects ↗
  4. W3C: Declaring Character Encodings in HTML ↗
  5. PostgreSQL 18: Character Types ↗
  6. PostgreSQL 18: Collation Support ↗
  7. Unicode Standard Annex #15: Unicode Normalization Forms ↗
  8. Unicode Standard Annex #29: Unicode Text Segmentation ↗
  9. Unicode Technical Standard #55: Unicode Source Code Handling ↗
  10. W3C UI Events: Composition Events (Working Draft) ↗
  11. W3C: Clipboard API and Events (Working Draft) ↗