同一个 é 在库里存了两种写法,搜索总漏结果,Unicode 规范化放哪一层合适?

用户敲进搜索框的字,来路很杂:不同的输入法、从网页或 PDF 里复制的片段、别的系统同步过来的文件名、App 端和 Web 端各自提交的表单。屏幕上都显示成 café,传到后端的码点却未必一样。英文和数字测得好好的,一碰到带重音的拉丁字母、全角字符、带圈数字,搜索就开始漏结果。这类问题往往要等用户反馈了才会发现。

一个字,两种存法

é 在 Unicode 里有两种合法写法:一种是预组合字符 U+00E9,另一种是普通的 e 后面跟一个组合重音符 U+0301。两者显示一样,按码点比较却不相等,连长度都不同:

const a = 'caf\u00e9';   // 预组合
const b = 'cafe\u0301';  // e + 组合符

console.log(a === b);              // false
console.log(a.length, b.length);   // 4 5
console.log(a.normalize() === b.normalize()); // true

normalize() 不传参数时默认用 NFC,MDN 上有写。参数一共可以填四个值:

  • NFC:先做规范分解,再规范组合,é 会合成一个码点。
  • NFD:只做规范分解,é 拆成 e 加组合符。
  • NFKC:先做兼容分解,再规范组合。
  • NFKD:只做兼容分解。

传别的字符串会抛 RangeError。

UAX #15 里还有一句对排查很有用:只含 ASCII 的文本不受任何一种规范化影响,只含 Latin-1 的文本本身就已经是 NFC。所以拿纯英文用例去测,这个坑是测不出来的。

带 K 的两种要小心用

NFC / NFD 处理的是规范等价,前后意思完全不变。NFKC / NFKD 多做了一步兼容映射,会把一批「长得不同但常被当成一回事」的字符抹平:全角的 ABC123 变成半角,① 变成 1,fi 连字拆成 f 和 i。做搜索要的正是这个效果。

代价是一些本来有含义的区别也被抹掉了。UAX #15 举了上标 i⁹ 变成 i9、¼ 变成 1/4 的例子。文档还明确说,NFKC / NFKD 不能随便套在所有文本上,最好把这两种形式看成和大小写转换差不多的操作。放到业务里,m² 规范化完会变成 m2,商品规格、化学式、数学公式里这种变化是不能接受的。

所以 NFKC 适合生成「比较用的键」,不适合覆盖原文。

各层怎么分工

比较稳的做法是原文照存、比较时统一规范化:

  • 存储层保留用户提交的原文,顶多统一成 NFC。W3C 的字符模型建议 Web 内容一律用 NFC,UAX #15 里也引用了这条。NFC 不改变字义,拿来存原文问题不大。
  • 单独加一列放搜索键,由后端在写入时生成,规则写死在一个函数里。前端顺手做一遍也行,只是 App、脚本、第三方导入都可能不经过前端,不能指望这一层。
  • 查询时用同一个函数处理关键词,再去匹配搜索键那一列。写入和查询只要有一边漏了规范化,前面全白做。
function searchKey(s) {
  return s.normalize('NFKC').toLowerCase().trim();
}

searchKey('ABC123'); // 'abc123'
searchKey('m²');           // 'm2'
searchKey(' Café ');       // 'café'

还有一点容易漏。UAX #15 提到,不管用哪种规范化形式,都不能保证拼接后还是规范的:两个各自规范化过的串,拼起来可能就不是了。所以搜索键要对拼好的整串算,别各字段算完再拼。

数据库这一层,有的自带规范化函数,有的排序规则能处理一部分等价字符。具体行为各数据库不一样,查自己的文档,再拿上面这几组例子实测。老数据可以写个一次性脚本把搜索键补齐,补完抽查一批带重音、全角的记录。

你们项目里的搜索键是在应用层算的,还是交给数据库的函数或排序规则去处理?