pnpm 对象键排序工具 @pnpm/object.key-sorting 深度解析:确定性锁文件与可读配置输出的基石

原创2026-09-20 18:08:09488 阅读
文章标签:包管理器开发工具CLI

@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-sortingpackage.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.jslib/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)
}

比较规则可归纳为一张决策表:

条件 结果
leftright 都出现在 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) 做字典序比较,源码注释明确强调了两条关键约束:

  1. 不依赖 localeString.prototype.localeCompare 的排序结果随当前系统/进程 locale 变化,绝不能用在对跨机器一致性有要求的文件(锁文件等)上
  2. 全机器一致:基于码元的比较在任何环境下结果相同,因此同一份数据在任何机器上写出的键序完全一致。

这正是锁文件"确定性输出"的第一性原理: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 的完整流程为:

  1. importerspackagessnapshotscatalogstimepatchedDependencies 各自的映射键先用 sortDirectKeys 排序(例如 importers 按项目目录名、packages 按包 ID 字典序);
  2. importerspackagessnapshots 中的每个条目,用 sortKeysByPriority({ priority: ROOT_KEYS_ORDER | ORDERED_KEYS, deep: true }) 递归排序——于是每个包的键呈现"resolutiondependenciesoptionalDependencies → ..."这种对人对机器都友好的稳定顺序;
  3. catalogs 每个条目用 sortDeepKeys 全面递归排序;
  4. 最后整份锁文件再套一层 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 中,nameversionprivatedescriptionmainscriptskeywordsauthorlicensedevEnginespackageManager 依次排列,其它额外字段(例如 --init-type module 时写入的 type)按字典序排在后面。这样脚手架产出的文件既符合社区惯常的字段阅读顺序,又在不同机器/不同 pnpm 版本间保持稳定。测试套件则通过 index.test.ts 精确断言这一行为。

六、实战场景三:config list 输出与审计修复

  • 配置输出configToRecord.tspnpm 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 无关"的对象序列化场景,这个函数族都是一份精炼且可复用的参考实现。

登录后查看全文
pnpm