pnpm 对象键排序工具 @pnpm/object.key-sorting 深度解析:确定性锁文件与可读配置输出的基石
@pnpm/object.key-sorting 是 pnpm 11 代码库(pnpm11/object/key-sorting)中一个专注而关键的小型工具包:它只做一件事——对 JavaScript 对象的键进行确定性排序,却支撑着 pnpm 锁文件(pnpm-lock.yaml)的稳定输出、pnpm config list 的可读展示以及 pnpm init 生成的 package.json 字段顺序。本文以该包的 README 与源码为主体,结合仓库内真实调用场景,讲解其三种排序 API、底层比较算法,以及它们如何被锁文件写入等核心模块复用。
一、包概况:定位、依赖与工程形态
包位于 pnpm11/object/key-sorting,package.json 中描述为 "Sorting the keys of an object"。它不是一个独立应用,而是 pnpm 内部共享的底层工具库,遵循 pnpm 仓库一贯的 workspace 组织结构:
- 依赖:仅两个——
@pnpm/text.ordinal-comparator(workspace 内提供字典序比较器)与sort-keys(catalog 统一版本管理的对象键排序实现)。从源码可见,真正的排序工作委托给成熟的sort-keys包,本包则在其上注入 pnpm 自有的比较策略。 - 运行环境:
"type": "module"(ESM),engines.node >= 22.13,与 pnpm 11 的整体 Node 版本基线一致。 - 构建与测试:
compile使用tsgo --build,测试通过 Jest(@pnpm/jest-config预设)在--experimental-vm-modules模式下运行 ESM 测试;prepublishOnly亦执行tsgo --build确保发布产物与声明文件就绪。 - 对外接口:
main/types指向编译后的lib/index.js与lib/index.d.ts,仅导出"."一个入口。
整个实现只有 src/index.ts 一个文件、46 行代码,但其影响面覆盖锁文件、配置输出与脚手架命令——这正是"小工具、大用途"的典型。
二、核心 API:三种键排序策略
@pnpm/object.key-sorting 对外导出三个函数,分别对应"仅排直接键""递归排全部键""按优先级排键"三种策略。所有函数接受泛型 T extends { [key: string]: any } 的对象,返回同类型的新对象(sort-keys 返回新对象,不修改入参)。
2.1 sortDirectKeys:仅排序顶层键
export function sortDirectKeys<T extends { [key: string]: any }> (obj: T): T {
return _sortKeys<T>(obj, {
compare: lexCompare,
deep: false,
})
}
deep: false表示只对对象自身的直接键排序,嵌套对象的键保持原样;- 比较器为
lexCompare(见第三节),保证排序结果与运行环境无关。
典型用途:对"键本身即标识符"的映射做排序。例如在 configToRecord.ts 中,pnpm config list 最终输出前对配置记录调用 sortDirectKeys(result),让所有配置项按键名字典序稳定排列,避免每次运行输出顺序抖动、便于 diff 与排查。
2.2 sortDeepKeys:递归排序所有层级的键
export function sortDeepKeys<T extends { [key: string]: any }> (obj: T): T {
return _sortKeys(obj, {
compare: lexCompare,
deep: true,
})
}
deep: true表示递归遍历所有嵌套对象与数组,每一层对象的键都按字典序排列。
典型用途:对结构完全扁平的映射做整体规范化。在锁文件排序模块 sortLockfileKeys.ts 中,catalogs 段下的每个 catalog 对象直接调用 sortDeepKeys(catalog),确保 catalog 内所有嵌套配置(如 default 下的各依赖条目)键序完全确定。
2.3 sortKeysByPriority:按优先级 + 字典序排序
export function sortKeysByPriority<T extends { [key: string]: any }> (
opts: {
priority: Record<string, number>
deep?: boolean
},
obj: T
): T {
const compare = compareWithPriority.bind(null, opts.priority)
return _sortKeys(obj, {
compare,
deep: opts.deep,
})
}
这是三者中最灵活、也是锁文件场景的核心函数。opts.priority 是一张"键名 → 排序序号"的映射,序号越小越靠前;deep 决定是否递归。其排序语义由内部比较器决定(见 2.4)。
注意参数顺序:opts 在前、obj 在后,与常见的"对象在前、选项在后"相反,调用时需留意。
2.4 内部比较器 compareWithPriority:优先级优先,其余字典序兜底
function compareWithPriority (priority: Record<string, number>, left: string, right: string): number {
const leftPriority = priority[left]
const rightPriority = priority[right]
if (leftPriority != null && rightPriority != null) return leftPriority - rightPriority
if (leftPriority != null) return -1
if (rightPriority != null) return 1
return lexCompare(left, right)
}
比较规则可归纳为一张决策表:
| 条件 | 结果 |
|---|---|
left、right 都出现在 priority 中 |
按各自序号升序排列(序号差作为返回值) |
仅 left 出现在 priority 中 |
left 排前面(返回 -1) |
仅 right 出现在 priority 中 |
right 排前面(返回 1) |
两者都不在 priority 中 |
退化为 lexCompare 字典序 |
即:被"点名"的键优先整体前移(保持彼此相对顺序),未被点名的键再按字典序排列。这保证了"重要字段靠前、其余字段仍有确定性顺序"的双重目标。
三、底层比较器 lexCompare:跨机器确定性的根基
所有排序最终都落到 @pnpm/text.ordinal-comparator 提供的 lexCompare:
export function lexCompare (a: string, b: string): number {
return a > b ? 1 : a < b ? -1 : 0
}
它的实现极其简单——直接按 UTF-16 码元(code unit) 做字典序比较,源码注释明确强调了两条关键约束:
- 不依赖 locale:
String.prototype.localeCompare的排序结果随当前系统/进程 locale 变化,绝不能用在对跨机器一致性有要求的文件(锁文件等)上; - 全机器一致:基于码元的比较在任何环境下结果相同,因此同一份数据在任何机器上写出的键序完全一致。
这正是锁文件"确定性输出"的第一性原理:pnpm-lock.yaml 被提交进版本库、被 CI 与所有协作者共享,任何 locale 相关的排序都会导致"同一份依赖、不同机器生成不同锁文件"的灾难。lexCompare 用最朴素的比较消除了这一变量。
四、实战场景一:锁文件键排序(核心消费者)
锁文件写入模块 pnpm11/lockfile/fs/src/sortLockfileKeys.ts 是三个 API 最完整的组合应用范例。它定义了两张优先级表:
根级键顺序(ROOT_KEYS):
const ROOT_KEYS: readonly RootKey[] = [
'lockfileVersion',
'settings',
'catalogs',
'overrides',
'packageExtensionsChecksum',
'pnpmfileChecksum',
'patchedDependencies',
'importers',
'packages',
]
const ROOT_KEYS_ORDER = Object.fromEntries(ROOT_KEYS.map((key, index) => [key, index]))
packages/snapshots 条目键顺序(ORDERED_KEYS):
const ORDERED_KEYS = {
resolution: 1,
id: 2,
name: 3,
version: 4,
engines: 5,
cpu: 6,
os: 7,
libc: 8,
deprecated: 9,
hasBin: 10,
prepare: 11,
requiresBuild: 12,
bundleDependencies: 13,
peerDependencies: 14,
peerDependenciesMeta: 15,
dependencies: 16,
optionalDependencies: 17,
transitivePeerDependencies: 18,
dev: 19,
optional: 20,
}
sortLockfileKeys 的完整流程为:
importers、packages、snapshots、catalogs、time、patchedDependencies各自的映射键先用sortDirectKeys排序(例如importers按项目目录名、packages按包 ID 字典序);- 对
importers、packages、snapshots中的每个条目,用sortKeysByPriority({ priority: ROOT_KEYS_ORDER | ORDERED_KEYS, deep: true })递归排序——于是每个包的键呈现"resolution→dependencies→optionalDependencies→ ..."这种对人对机器都友好的稳定顺序; catalogs每个条目用sortDeepKeys全面递归排序;- 最后整份锁文件再套一层
sortKeysByPriority({ priority: ROOT_KEYS_ORDER }, lockfile)完成根级收尾。
可以推断:这套优先级表(resolution 在最前、optional 在最后)是为可读性设计的——最重要的解析信息与包身份字段排在最前,辅助性的标记字段靠后,同时未列出的键仍会被字典序稳定兜底。
五、实战场景二:pnpm init 的 package.json 字段排序
pnpm11/workspace/commands/src/init.ts 使用 sortKeysByPriority 为新项目生成的 package.json 排定字段顺序。它构造了这样一张优先级表:
const priority = Object.fromEntries([
'name',
'version',
'private',
'description',
'main',
'scripts',
'keywords',
'author',
'license',
'devEngines',
'packageManager',
].map((key, index) => [key, index]))
const sortedPackageJson = sortKeysByPriority({ priority }, packageJson)
效果是:pnpm init 生成的 package.json 中,name → version → private → description → main → scripts → keywords → author → license → devEngines → packageManager 依次排列,其它额外字段(例如 --init-type module 时写入的 type)按字典序排在后面。这样脚手架产出的文件既符合社区惯常的字段阅读顺序,又在不同机器/不同 pnpm 版本间保持稳定。测试套件则通过 index.test.ts 精确断言这一行为。
六、实战场景三:config list 输出与审计修复
- 配置输出:configToRecord.ts 在
pnpm config list展示前对完整配置记录执行sortDirectKeys(result),保证配置项的展示顺序稳定、便于脚本解析与人工比对(该输出还会经censorProtectedSettings过滤敏感项)。 - 依赖审计修复:
audit fix流程(audit/fix.ts)与 store 索引查看命令(catIndex.ts)同样复用本包做确定性排序,确保修复结果与索引输出的一致性。
七、Rust 侧的对应实现:设计同源的佐证
pnpm 11 的 Rust 原生实现提供了设计同源的旁证:在 pnpm/crates/lockfile/src/yaml_emit.rs 中,sort_lockfile_keys 以几乎一一对应的方式实现了同样的三段逻辑:
sort_direct_keys/sort_deep_keys:直接键排序与递归排序(lex_cmp同样基于码元比较,对应 TS 侧lexCompare);priority_cmp:先看两个键在优先级数组中的rank序号,两者都在则序号小者在前,仅一方命中则命中者在前,均未命中退回lex_cmp——与 TS 侧compareWithPriority的决策表完全一致;- 对
importers/packages/snapshots先排映射键、再对每个条目按键优先级递归排序,catalogs/time/patchedDependencies用纯字典序处理,最后根级整体按ROOT_KEYS排序。
此外 pnpm/crates/cli/src/cli_args/cat_index.rs 实现了带深度上限(MAX_JSON_SORT_DEPTH)的递归键排序,其测试 cat_index/tests.rs 同时验证"递归排序确定性"与"过深 JSON 拒绝"。可以看到,"优先级 + 字典序兜底"与"码元比较"这两条原则在 TypeScript 与 Rust 两套实现中被严格对齐,保证无论走哪条执行路径,产出的锁文件字节级一致。
八、小结:一个函数族如何保障 pnpm 的确定性
回看 src/index.ts 的全部 46 行,@pnpm/object.key-sorting 用三个 API 覆盖了三种典型需求:
- 纯字典序、仅顶层(
sortDirectKeys)——适合"键名即身份"的映射展示; - 纯字典序、全递归(
sortDeepKeys)——适合结构扁平的规范化输出; - 优先级 + 字典序兜底、可递归(
sortKeysByPriority)——适合"重要字段靠前 + 全局稳定"的文档型文件(锁文件、package.json)。
这些能力共同服务于 pnpm 的确定性承诺:锁文件可提交、可 diff、跨机器跨平台一致;脚手架与配置输出稳定可读;同时为后续的 Rust 原生实现提供了可直接对照移植的语义。对任何需要"稳定、可预期、与 locale 无关"的对象序列化场景,这个函数族都是一份精炼且可复用的参考实现。