先把文本经过的每一步画出来
从浏览器输入到数据库,再从接口回到页面,文本会经过多个边界。某些边界处理字符串,某些边界处理字节。编码是两者之间的约定,不是给已经损坏的内容补一个标签。实际文件使用 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、一个汉字和一个拉丁字母,即使各算一个显示单元,占用的像素仍由字体与布局决定。如果需求是让按钮文字适合宽度,应使用排版测量或可访问的视觉截断;如果需求是限制用户输入,则按照公布的计数规则处理。不要让字符统计承担字体排版的工作。
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 字节 |
|---|---|---|---|---|
| A | 1 | 1 | 1 | 1 |
| e\u0301 | 2 | 2 | 1 | 3 |
| 😀 | 2 | 1 | 1 | 4 |
| 👩\u200D💻 | 5 | 3 | 1 | 11 |
数据库的长度与唯一性需要单独核对
不要把数据库字段声明里的数字一律当成字节数。例如 PostgreSQL 的有长度字符类型按字符限制,而其他数据库、索引或传输层可能有不同规则。即使字段接收成功,索引大小、接口请求体和导出格式仍可能构成另一个限制。应查具体产品及版本的文档,并对实际读写路径测试。
唯一性也不只是比较两个字节数组。排序规则可能影响大小写、重音以及某些等价形式的比较,配置不同,重复判断就可能不同。若应用认为两个账户名相同,数据库却允许分别插入,或者情况反过来,用户会遇到难以解释的冲突。应用匹配键与数据库约束必须配合设计,并用相同样本验证。
做迁移时,应先在副本中检查新规则会合并哪些现有值。不能仅证明新建表能写入几个多语言词,就认为历史数据已经安全迁移。还要验证备份恢复、批量导入和回滚路径,尤其注意旧客户端是否仍会发送旧格式。字符串往返一致,才说明整条存储链路没有悄悄改变文本。
数据库往返测试最好记录输入序列和读出序列,而不仅比较截图。两种文本可能看起来一样,却在精确导出时不一致;反过来,字体回退改变外观,也不代表保存出错。把序列验证与视觉验证分开,能够让存储层和前端各自承担清晰责任。需要保真时,还应验证导出的文件重新导入后是否维持同一结果。
搜索、排序与精确相等是三种需求
精确比较适合需要保留差异的任务,搜索可能希望忽略部分差异,排序则通常需要语言习惯。它们不能由同一个“清理字符串”函数草率包办。例如读者搜索一个带重音的姓名时,产品可以决定是否接受不带重音的查询;这项方便不意味着应该把用户保存的姓名永久改成无重音形式。
保留原文并生成派生搜索键,是一种容易解释的结构,但键的具体规则仍要定义。规范化、大小写处理、分词与排序规则分别解决不同问题,处理次序也可能影响结果。排序不应凭英文字母示例推断全球行为,更不能假设按码位排序就是所有语言用户习惯的词典顺序。
分页也受排序规则影响。如果两个值在选定规则下并列,需要稳定的次级排序条件,避免翻页时漏项或重复。升级排序数据以后,还应核查数据库是否要求重建相关索引。把数据版本和排序设置记在维护文档中,后续出现结果变化时,团队才能判断是内容变化、规则变化,还是实现缺陷。
输入与复制是内容契约的一部分
浏览器输入法可能在用户确认前持续更新组合文本。若在每次输入事件里立即重写或截断,可能打断正常输入。应在交互设计中考虑组合阶段,并将即时提示与最终验证分开。对于粘贴内容,同样要让用户知道哪些变换会发生,而不是把意料之外的字符一律静默删除。
复制操作应符合按钮文案。复制原文就保留原文,复制规范化版本就明确标注,复制码位则输出用于检查的表示。显示预览与实际剪贴板结果不一致,会让排错变得困难。一个可靠测试是把剪贴板输出与当前工具结果逐码位比较,而不仅检查按钮是否短暂显示成功。
错误信息也要有定位价值。与其说“含有非法字符”,不如在适当场景指出位置、码位和当前字段的规则,同时保留用户输入供修改。不要把整段私密内容写入分析事件或共享日志。工程调试需要足够证据,但可以用固定错误类型、长度计数和可控样本完成许多验证,无须收集真实正文。
用一组小样本覆盖真实边界
一组实用测试可包括纯 ASCII、带组合重音的字母、中文、基本多文种平面之外的字符、完整 emoji 序列及混合方向短句。对每个样本执行输入、编码、发送、保存、读取和复制,再验证哪些步骤必须精确保留,哪些步骤允许约定变换。允许变换的部分也应有明确预期值。
失败样本同样重要:不完整 UTF-8 字节、未配对代理项、恰好落在序列中间的截取位置、超过字节限制但没有超过显示计数的文本。流式解码应改变切块位置,数据库验证应使用真实约束。测试不需要遍历所有 Unicode 字符才能有价值,关键是覆盖系统采用的边界和决策。
最后,把测试与升级过程连接起来。浏览器、运行时和国际化库的数据版本变化,可能改变分段或排序结果;字体变化则可能改变显示而不改变存储。记录版本并重跑相关样本,让变化有证据可追踪。真正可靠的 Unicode 支持,是每个组件都清楚自己在处理字节、码元、码位、字素还是语言规则,并在交接时保持约定。
准备这些样本时应写明为什么选它们。例如附加符号用于检查分段,补充平面字符用于检查代理对,多字节输入用于检查编码边界。这样的测试说明能指导后来加入的开发者补充真实缺陷样本。若只有一串来历不明的特殊字符,测试虽然会运行,却很难判断覆盖了什么,也容易在重构时被误删。
参考资料
- WHATWG Encoding Standard ↗
- ECMAScript String Methods ↗
- ECMAScript Internationalization API: Segmenter Objects ↗
- W3C: Declaring Character Encodings in HTML ↗
- PostgreSQL 18: Character Types ↗
- PostgreSQL 18: Collation Support ↗
- Unicode Standard Annex #15: Unicode Normalization Forms ↗
- Unicode Standard Annex #29: Unicode Text Segmentation ↗
- Unicode Technical Standard #55: Unicode Source Code Handling ↗
- W3C UI Events: Composition Events (Working Draft) ↗
- W3C: Clipboard API and Events (Working Draft) ↗