首页
/ airi 项目的 pnpm Hooks(.pnpmfile.mjs)实战指南:定制依赖解析、配置与拉取行为

airi 项目的 pnpm Hooks(.pnpmfile.mjs)实战指南:定制依赖解析、配置与拉取行为

2026-09-05 21:22:56作者:董斯意

本篇围绕 pnpm 的 .pnpmfile.mjs 钩子机制展开:它声明在哪里、有哪些钩子(readPackageupdateConfigbeforePacking 等)、如何编写 Finders 与自定义 resolver/fetcher,以及它与 pnpm-workspace.yamloverrides 的分工。读完后你能在大仓 monorepo 中用 JavaScript 条件逻辑修复第三方依赖的 manifest 问题、程序化调整 pnpm 设置、定制发布产物清单,并理解 airi 这类以 pnpm 11 驱动的工作区(根目录 package.json"packageManager": "pnpm@11.24.0")为什么大量使用声明式配置而把 hooks 留作"条件逻辑兜底"。

pnpmfile 的位置与格式

pnpm hooks 用于定制安装过程。钩子声明在 .pnpmfile.mjs(ESM,推荐)或 .pnpmfile.cjs(CommonJS)中,文件必须放在 lockfile 旁边——对 monorepo 来说就是工作区根目录。

  • 现代写法使用 ESM 的 export const hooks = { ... }
  • 旧的 CommonJS module.exports = { hooks } 仍可在 .pnpmfile.cjs 中使用。

最简骨架如下:

export const hooks = {
  readPackage,
  afterAllResolved,
  updateConfig,
  beforePacking,
}

提示:--ignore-scripts 不会禁用 pnpmfile;要让 pnpm 完全忽略它,需要用下面的 ignorePnpmfile 设置。

钩子总览:6 个钩子分别在什么时机触发

钩子 触发时机 典型用途
readPackage(pkg, ctx) 依赖 manifest 被解析之后 修改依赖的 package.json(影响解析结果)
afterAllResolved(lockfile, ctx) 依赖解析完成之后 在 lockfile 落盘前修改 lockfile
updateConfig(config) 安装开始之前 修改 pnpm 自身设置(与 config dependency 搭配威力最大)
beforePacking(pkg) pnpm pack/publish 生成 tarball 之前 只定制即将发布的 manifest
preResolution(opts) 读取 lockfile 之后、开始解析之前 查看/修改 lockfile 对象
importPackage(dir, opts) 向 node_modules 写入时 改变包的链接方式

下面逐一看最常用的几个钩子的完整写法。

readPackage:在解析前修改依赖清单

readPackage 在解析前对每个包调用,是修复第三方包 manifest 缺陷的主力。常见用法包括:补齐缺失的 peer 依赖、钉住某个传递依赖版本、删掉有问题的 optional 依赖、替换已废弃的包:

function readPackage(pkg, context) {
  // Add a missing peer dependency
  if (pkg.name === 'some-broken-package') {
    pkg.peerDependencies = { ...pkg.peerDependencies, react: '*' }
  }
  // Pin a transitive version
  if (pkg.dependencies?.lodash) pkg.dependencies.lodash = '^4.17.21'
  // Drop a problematic optional dep
  delete pkg.optionalDependencies?.fsevents
  // Replace a deprecated dep
  if (pkg.dependencies?.['old-pkg']) {
    pkg.dependencies['new-pkg'] = pkg.dependencies['old-pkg']
    delete pkg.dependencies['old-pkg']
  }
  return pkg
}

export const hooks = { readPackage }

这里有几个必须清楚的行为边界:

  • 修改不会写回磁盘,只影响本次解析。对已经被锁定的依赖重新解析,需要删除 pnpm-lock.yaml
  • 在这里删掉依赖的 scripts 并不能阻止其构建脚本执行——控制安装期构建应使用 allowBuilds 设置;
  • 如果想把对某个依赖文件修改持久化下来,应使用 pnpm patch

这三点在 airi 仓库里都能找到对应的落地形态。airi 的 pnpm-workspace.yaml 使用 allowBuilds 白名单精确控制哪些包允许执行安装期脚本(如 electronesbuildsharpuiohook-napi 等设为 true@prisma/clientbetter-sqlite3 设为 false),而不是靠删 scripts;它通过 patchedDependencies 持久化了对 5 个第三方包的补丁(见 pnpm-workspace.yaml),对应的补丁文件就在 patches/ 目录下(mineflayer-pathfinderpixi-live2d-displaysponsorkit@17.1.0tab-election@4.6.2uiohook-napi@1.5.5)。这正是文档中"删 scripts 不生效,用 allowBuilds;持久化修改用 pnpm patch"两条告诫的真实用例。

另外注意:readPackage 能做的"改 manifest"操作,很多有更轻的声明式替代方案。airi 的 pnpm-workspace.yamloverrides 完成了 axios: npm:feaxios@^0.0.23isarray: npm:@nolyfill/isarray@^1.0.44 这类"包替换",并在 packageExtensions 段 里声明式地为 @formkit/auto-animatevitepress@tresjs/core 等补充了缺失的 peerDependencies——后者正是 readPackage 中"补齐 peer 依赖"示例的无 JS 版本。相关的声明式能力可参阅 Overrides 参考Patches 参考

updateConfig:程序化修改 pnpm 配置

updateConfig 在安装前修改 pnpm 自身的设置,最强大的场景是把它打进一个 config dependency 里,让多个仓库共享同一份设置:

export const hooks = {
  updateConfig(config) {
    return Object.assign(config, {
      enablePrePostScripts: false,
      optimisticRepeatInstall: true,
      resolutionMode: 'lowest-direct',
      verifyDepsBeforeRun: 'install',
    })
  }
}

也可以由插件向工作区注入 catalog 条目——消费者此后就能用 pnpm add is-odd@catalog: 安装到注入的版本:

// Add a catalog entry from a plugin
export const hooks = {
  updateConfig(config) {
    config.catalogs.default ??= {}
    config.catalogs.default['is-odd'] = '1.0.0'
    return config
  }
}

airi 工作区本身就是 catalog 的重度用户:pnpm-workspace.yamlcatalog:(默认目录,400+ 条目)与命名目录 catalogs.vitestcatalogs.xsai 统一收敛版本,catalogMode: prefer 则让子包声明版本时优先对齐目录。而 airi 在仓库内自带了 config dependency 的完整参考文档 features-config-dependencies:它说明了 configDependencies 声明于 pnpm-workspace.yaml、在常规依赖之前安装、不允许常规 dependencies 与 lifecycle scripts,且 pnpm-plugin-* / @pnpm/plugin-* 命名的包其 pnpmfile.mjs 会被自动加载——这正是与 updateConfig 配合共享设置与 catalog 的推荐路径。

beforePacking 与 afterAllResolved

beforePacking 用于定制最终进入发布 tarball 的 manifest,而不必改动本地 package.json

export const hooks = {
  beforePacking(pkg) {
    delete pkg.devDependencies
    pkg.main = './dist/index.js'
    return pkg
  }
}

afterAllResolved 在依赖解析全部完成后触发,适合在 lockfile 写入前做审计、日志或修正:

export const hooks = {
  afterAllResolved(lockfile, context) {
    context.log(`Resolved ${Object.keys(lockfile.packages || {}).length} packages`)
    return lockfile
  }
}

Finders:给 pnpm list / why 注册自定义判定

Finders 是自定义谓词函数,通过命令行 --find-by 使用。例如定义一个"找出声明了 react@^17.0.0 peer 依赖的包"的 finder:

export const finders = {
  react17: (ctx) => ctx.readManifest().peerDependencies?.react === '^17.0.0'
}

然后:

pnpm why --find-by=react17

这对排查大型 monorepo 中"到底是谁声明了某个版本的 peer 依赖"非常实用——比 airi 这样包含 apps/**packages/**plugins/**integrations/**services/**engines/**server/** 多组 glob 的工作区(见 pnpm-workspace.yamlpackages 段)更容易失控。

高级用法:自定义 resolvers 与 fetchers

注册顶层 resolvers/fetchers 可以支持新的包方案(如 my-protocol:pkg)。每个都是带廉价守卫 canResolve/canFetch 加上 resolve/fetch 的对象。自定义 resolver 先于内置 resolver 执行;自定义解析结果的 type 字段必须使用 custom: 前缀:

const resolver = {
  canResolve: (dep) => dep.alias.startsWith('@company/'),
  resolve: async (dep) => ({
    id: `${dep.alias}@${dep.bareSpecifier}`,
    resolution: { type: 'custom:cdn', cdnUrl: '...' },
  }),
}
const fetcher = {
  canFetch: (id, res) => res.type === 'custom:cdn',
  fetch: (cafs, res, opts, fetchers) =>
    fetchers.remoteTarball(cafs, { tarball: res.cdnUrl, integrity: res.integrity }, opts),
}
module.exports = { resolvers: [resolver], fetchers: [fetcher] }

版本注意:hooks.fetchers 在 pnpm v11 已被移除——请改用顶层 fetchers 导出。airi 根目录 package.json 声明的 packageManagerpnpm@11.24.0,属于 v11 行为,采用本节的顶层导出写法。

pnpmfile 相关设置

ignorePnpmfile: false                  # ignore the pnpmfile entirely
pnpmfile: ['.pnpmfile.mjs']            # local pnpmfile location(s)
globalPnpmfile: ~/.pnpm/global_pnpmfile.mjs
  • ignorePnpmfile:完全忽略 pnpmfile(注意与 --ignore-scripts 的区别);
  • pnpmfile:指定本地 pnpmfile 的位置,可列多个;
  • globalPnpmfile:全局生效的 pnpmfile 路径。

Hooks vs Overrides:如何选择

Hooks (.pnpmfile) Overrides (pnpm-workspace.yaml)
逻辑形式 JavaScript 声明式
作用范围 任意 manifest 字段、配置、lockfile、打包 版本号
适用场景 条件/复杂修复 简单的版本钉住

原则是:简单情况优先用 overrides/packageExtensions,条件逻辑、跨仓库配置共享、打包定制才上 hooks

从源码结构看,airi 当前仓库并没有提交任何 .pnpmfile.* 文件(全仓检索无结果),其依赖治理完全落在 pnpm-workspace.yaml 的声明式设施上:overrides 做包替换与版本钉住、packageExtensions 补 peer 依赖、patchedDependencies 持久化补丁、allowBuilds 控制安装期脚本、catalog/catalogs 收敛版本。这与文档的选型建议完全吻合:声明式能覆盖的就没有引入 JS 钩子的必要;而当出现"按包名条件分支修改 manifest""把配置以 config dependency 形式分发给其他仓库"这类需求时,才是 .pnpmfile.mjs 的用武之地。

要点回顾

  • 优先使用 .pnpmfile.mjsexport const hooks/finders/resolvers/fetchers
  • 新钩子:updateConfig(改设置)、beforePacking(发布 manifest)、preResolutionimportPackage
  • updateConfig 与 config dependencies 配合,可跨仓库共享设置与 catalog;
  • --ignore-scripts 不能禁用 pnpmfile,请用 ignorePnpmfile
  • readPackage 的修改不落盘、不阻止构建脚本;持久化修改依赖文件请用 pnpm patch(airi 的 patches/ 目录即是其产物)。

延伸阅读:Hooks 参考原文Overrides 参考Config Dependencies 参考Patches 参考、pnpm skill 总入口 SKILL.md

登录后查看全文
热门项目推荐
相关项目推荐