Intl.Collator与Postgres排序规则多平台差异及统一诉求
问题背景
对比Chrome、Firefox、WebKit三款浏览器,Node.js及PostgreSQL中Intl.Collator与PostgreSQL Collation的排序规则后,发现多数实现的排序差异显著——仅Chrome与Firefox在en locale下结果一致。例如Chrome和Firefox会把MATHEMATICAL SANS-SERIF ITALIC SMALL A to Z类字符,和普通a-z在en locale的Intl排序中归为一组,但PostgreSQL不会。原本以为所有实现都基于相同CLDR数据,实际Playwright测试得到的差异数据如下:
Chrome - Node 1474 Chrome - Webkit 34727 Chrome - Firefox 0 Chrome - Postgres 25781 Webkit - Node 34727 Webkit - Postgres 34727 Node - Postgres 34892
要实现前后端无差异排序,必须让所有环境使用完全一致的排序规则逻辑,不能依赖各平台默认的CLDR实现差异。以下是具体可行的方案:
可行解决方案
方案1:对齐CLDR版本与排序参数
强制所有环境使用相同的CLDR版本和明确的排序参数,消除默认行为差异:
浏览器/Node.js端:
明确指定Intl.Collator的所有关键参数,避免依赖默认值。比如统一设置:const collator = new Intl.Collator('en', { sensitivity: 'base', // 仅区分字符基本形态,忽略大小写、重音 caseFirst: 'false', usage: 'sort', numeric: false });Node.js需确保使用的ICU版本与Chrome/Firefox一致,可通过
--icu-data-dir指定完整ICU数据,或直接使用全ICU版本的Node.js。PostgreSQL端:
放弃系统默认排序规则,改用基于CLDR的自定义规则(PostgreSQL 10+支持):-- 先安装ICU扩展(部分发行版需单独安装) CREATE EXTENSION IF NOT EXISTS icu_ext; -- 创建匹配前端参数的自定义排序规则 CREATE COLLATION en_cldr ( PROVIDER = icu, LOCALE = 'en@colStrength=primary;caseFirst=lower', DETERMINISTIC = true );排序时明确使用该规则:
ORDER BY character COLLATE en_cldr;
方案2:统一使用自定义排序键(推荐)
如果各平台CLDR版本难以对齐,可在前端/Node.js生成标准化排序键,存储到数据库后直接按键排序:
- 生成排序键:
利用Intl.Collator的getSortKey方法生成可存储的排序键(部分环境可能需要Polyfill):const collator = new Intl.Collator('en', { sensitivity: 'base' }); function getSortKey(str) { // 将二进制排序键转为十六进制字符串,方便存储 return Array.from(collator.getSortKey(str)) .map(byte => byte.toString(16).padStart(2, '0')) .join(''); } - 存储与排序:
在PostgreSQL表中新增排序键字段,插入/更新数据时同步存入排序键:
排序时直接按排序键比较:ALTER TABLE unicode.character ADD COLUMN sort_key text; -- 插入数据时同步写入sort_key INSERT INTO unicode.character (character, sort_key) VALUES ('a', '...');SELECT character FROM unicode.character ORDER BY sort_key;
方案3:限制字符集范围
如果业务不需要处理特殊数学字符等边缘情况,可过滤掉非标准ASCII或常用Unicode范围外的字符,仅保留需要统一排序的字符集,减少差异场景。
相关测试代码
解析Unicode数据代码
export async function parseUnicodeData(unicodePath: string): Promise<UnicodeData []> { return (await fs.promises.readFile(path.join(unicodePath, 'UCD/UnicodeData.txt'), 'utf-8')) .split('\n') .filter((line) => line !== '') .map((line) => line.split(';')) .map(([codeValue]) => ({codeValue})); }
PostgreSQL表结构与数据插入
CREATE TABLE unicode.character ( character text PRIMARY KEY ); -- 过滤无法插入PostgreSQL的码点: '0000', 'D800', 'DB7F', 'DB80', 'DBFF', 'DC00', 'DFFF' INSERT INTO unicode.character VALUES (chr(1)), (chr(2)), ... (chr(1114109));
Node.js对比测试代码
import {Pool} from 'pg'; import {parseUnicodeData} from 'util-unicode-parser'; import {chromium, webkit, firefox} from 'playwright'; const pool = new Pool(); export async function compareCollations( unicodeDirectory: string, postgresCollationName: string, intlLocale: string, intlSettings = '{}', ) { // 初始化Playwright const pages = await Promise.all([chromium, webkit, firefox] .map((browserType) => browserType.launch({headless: true})) .map(async (browser) => (await browser).newContext()) .map(async (context) => (await context).newPage())); // 解析Unicode数据 const unicodeData = (await parseUnicodeData(unicodeDirectory)) // 过滤无法插入PostgreSQL的码点 .filter(({codeValue}) => !['0000', 'D800', 'DB7F', 'DB80', 'DBFF', 'DC00', 'DFFF'].includes(codeValue)); const browserCollationString = `[${unicodeData .map(({codeValue}) => `String.fromCodePoint(parseInt('${codeValue}', 16))`).join(',') }].sort(new Intl.Collator('${intlLocale}', ${intlSettings}).compare)`; // 生成各环境的排序数组 const nodeCollation = unicodeData.map(({codeValue}) => String.fromCodePoint(parseInt(codeValue, 16))).sort(new Intl.Collator(intlLocale, JSON.parse(intlSettings)).compare); const chromeCollation = await pages[0].evaluate(browserCollationString); const webkitCollation = await pages[1].evaluate(browserCollationString); const firefoxCollation = await pages[2].evaluate(browserCollationString); const postgresCollation = (await pool.query(`SELECT character from unicode.character ORDER BY character COLLATE "${postgresCollationName}";`)) .rows.map(({character}) => character); // 对比排序差异(仅展示Chrome与Node的差异数量) console.log(chromeCollation.map((c, i) => [c, nodeCollation[i], c.codePointAt(0), nodeCollation[i].codePointAt(0)]).filter(([c, r, pc, rc]) => c !== r ).sort((a, b) => a[2] < b[2] ? -1 : 1).length) // ... }
内容的提问来源于stack exchange,提问作者Florat

