
最近在写一个命令行小工具中间有个绕不开的需求读取用户项目里的package.json拿到项目名、脚本命令、依赖信息再决定后面怎么做。一开始我想得很简单直接在process.cwd()下面读文件不就行了结果一跑起来就发现问题——用户在 monorepo 的子目录里执行、在工具目录里执行、甚至在完全没有 package.json 的临时目录里执行各种边缘情况根本没法用一个固定路径兜住。后来我把read-pkg-up翻了出来用它向上查找最近的 package.json几行代码就解决了这个看似简单、实际坑不少的问题。如果你也在写 CLI 工具、脚手架或者任何需要“自动发现”用户项目清单文件的前端/Node 工具这个包基本是绕不开的。它做的事很纯粹从某个目录开始一层一层往上级目录找直到找到最近的package.json并解析成 JS 对象返回。这篇文章我会从实际场景出发把它的用法、参数、源码行为以及我踩过的坑完整讲一遍涉及包管理生态里常见但文档不会细说的那些点。1. 为什么需要 read-pkg-up 这个包1.1 一个真实的“找文件”痛点先说个很典型的场景。假设你写了一个发版辅助工具rel-cli用户在你的项目目录里敲npx rel-cli工具需要读取当前项目的 package.json判断版本号、查看 scripts 里有没有 build 命令。看起来很简单fs.readFileSync(./package.json)不就完了问题在于你的工具根本不知道用户会在哪个目录下执行它。用户在仓库根目录执行没问题但如果他站在packages/web子目录里执行呢如果站在项目里的某个临时目录比如scripts/下执行呢如果站在一个根本没有 package.json 的目录里但向上两层的父目录才有呢手写一个向上查找的逻辑其实也不难核心就是循环里不断path.dirname直到找到或者到达文件系统根目录import fs from node:fs; import path from node:path; function findPackageJson(startDir) { let dir path.resolve(startDir); for (;;) { const file path.join(dir, package.json); if (fs.existsSync(file)) { return file; } const parent path.dirname(dir); if (parent dir) { return null; } dir parent; } }这段代码本身没毛病但它暴露了这类需求最常见的几个隐患边界条件要考虑到根目录了吗、文件是否存在要判断、找到后还要再写一遍 JSON.parse 与异常处理、如果想同步又想要异步还得写两套逻辑。更关键的是这只是“找文件”找到之后你还得“解析文件”这两个动作在真实工程里是高度绑定的。自己手写很容易漏掉链路里的一环比如有人只做了存在性判断忘了处理 JSON 解析失败有人向上查找时用错了require.resolve导致找到的是依赖包里的 package.json。read-pkg-up就是把“向上找 解析 JSON 标准化字段”这套完整链路封装好让你不用重复造轮子。它属于那种“用起来无感但没它就得自己写一坨”的库。1.2 read-pkg-up 到底解决了什么read-pkg-up这个名字其实已经点明了它的职责read-pkg读取 package.json加上up向上查找。它不是一个包管理器也不参与依赖解析它只做一件事——从指定目录开始向上级目录查找找到最近的 package.json 并解析成对象。在 npm/Yarn/pnpm 这套基于 package.json 的包管理生态里几乎所有工具链的起点都是“读取配置文件”。无论是eslint寻找.eslintrc、jest寻找 config、还是changeset寻找项目根信息都需要一种“从当前目录向上发现”的机制。Node 生态里这类需求很多所以find-up、read-pkg、read-pkg-up这一族由 Sindre Sorhus 维护的工具包几乎是事实标准。放到更大的包管理语境下看事情会更清楚。系统级的 debian 包管理通过 apt 处理.deb包的安装、依赖和卸载npm 通过 package.json 处理 JavaScript 依赖的声明与解析而 read-pkg-up 既不负责安装也不负责解析依赖树它只是包管理工具链最前端的一个“探针”帮其他工具快速定位到项目清单文件。没有它npm 生态里大量 CLI 工具就得各自维护一套向上查找配置文件的逻辑既容易写错也难保持行为一致。2. 快速上手安装与第一个示例2.1 环境要求与安装read-pkg-up 目前的版本对运行环境有明确要求纯 ESM需要 Node.js 14.16 以上较新的版本甚至要求更高的 Node 版本。如果你还在用 CommonJS 的老项目直接require(read-pkg-up)会报错ERR_REQUIRE_ESM这一点我会在后面的常见问题里详细展开。安装方式和普通 npm 包一样用你习惯的包管理器都可以npm install read-pkg-uppnpm add read-pkg-upyarn add read-pkg-up因为它是纯 ESM 包使用时的导入语法是importimport { readPackageUp, readPackageUpSync } from read-pkg-up;注意它不是默认导出而是命名导出。这和很多人的直觉不太一样我第一次用的时候顺手写了import readPackageUp from read-pkg-up结果直接 undefined 然后把 TypeError 甩在我脸上。从 v7 到 v8 的升级中导出方式也有变化如果参考旧资料需要注意。2.2 最小可用示例异步读取绝大多数情况下推荐使用异步版readPackageUpimport { readPackageUp } from read-pkg-up; const result await readPackageUp({ cwd: process.cwd(), }); if (result) { console.log(找到的 package.json 路径:, result.path); console.log(项目名称:, result.packageJson.name); console.log(版本号:, result.packageJson.version); } else { console.log(向上查找了很多层没找到任何 package.json); }就这么简单。result是个对象包含两个字段packageJson解析后的对象和path文件绝对路径。如果从cwd一直找到文件系统根目录都没有 package.json返回值是undefined所以判断是否存在直接用if (result)即可。这个异步版本在大多数 CLI 工具、Node 服务、脚本场景里都是更好的选择因为它在文件系统 IO 时不会阻塞事件循环。你可能觉得读一个 JSON 文件能有多慢但在网络文件系统、CI 缓存盘、或者用户特别深的目录结构下阻塞式读取的延迟会被放大而且一旦前面有其他异步任务在排队同步阻塞会让整个进程的响应明显变卡。2.3 同步版本readPackageUpSync有的场景确实需要同步拿到结果比如在启动阶段初始化配置或者在同步执行的模块顶层做判断。这时候用readPackageUpSyncimport { readPackageUpSync } from read-pkg-up; const result readPackageUpSync({ cwd: __dirname }); if (result) { console.log(当前环境基于 ${result.packageJson.name}${result.packageJson.version}); }同步版本和异步版本的入参、返回值结构完全一致唯一的区别是它底层用fs.readFileSync。在 CLI 工具里很多人会习惯性地在main()函数里先同步读取配置再进入业务逻辑这样写起来确实直观。但要注意如果你的工具是长时间运行的服务或者会在同一进程里多次调用读取尽量避免在热路径上频繁使用同步版本单次调用没问题高频调用就会在性能上付出代价。3. 核心 API 与参数行为详解3.1 返回值结构packageJson 和 path 缺一不可readPackageUp的返回值设计我认为是这个库最优秀的地方之一。它没有只返回解析后的对象而是同时把解析后的内容和文件路径一起返回。为什么这个设计重要因为在实际工程里“package.json 的内容”和“这个文件在哪”这两条信息几乎总是同时需要的。举个例子你的工具读取到项目名之后可能还想知道这个 package.json 是在仓库根目录还是子包目录里从而决定后续的路径计算。没有path字段你就得自己再对 read-pkg-up 的结果做一遍反向推导。path字段作为绝对路径提供直接可以作为其他文件操作的基础非常方便。具体返回值如下字段类型说明packageJsonobject解析后的 package.json 内容默认经过 normalize 处理pathstring找到的 package.json 文件的绝对路径如果没有找到任何 package.json返回值就是undefined。这一点很关键因为很多新手会以为会返回一个空对象然后直接访问result.packageJson结果收到一条Cannot read properties of undefined的报错。记住一定要先判断是非空再使用。3.2 cwd 参数从哪里开始向上查找cwd参数控制搜索的起点默认值是process.cwd()。这是 read-pkg-up 向上查找的源头你可以把它理解成“从哪个楼层开始往下找”。默认行为很好理解你在命令行里敲命令的那个目录就是默认起点。但这里有一个很容易被忽略的场景——你的工具可能通过child_process在别的目录执行命令或者用户用--prefix之类的参数指定了工作目录这时候你就应该显式传入cwdconst result await readPackageUp({ cwd: /path/to/your/project/packages/web, });传入了cwd之后查找逻辑会从指定目录开始逐级向上。比如目录结构是/root ├── package.json └── packages └── web ├── package.json └── src └── utils如果你设置的cwd是/root/packages/web/src/utils那么查找顺序是/root/packages/web/src/utils/package.json不存在继续/root/packages/web/src/package.json不存在继续/root/packages/web/package.json找到返回以此类推直到文件系统根目录在实际项目中很多工具的cwd并不是用户执行命令的目录而是用户输入的参数指定的目录。比如eslint --cwd src这种模式工具内部就得用cwd参数将查找起点移到src下。这时候如果你还在用默认值process.cwd()就会南辕北辙。3.3 normalize 参数默认帮你“整理”数据normalize参数的默认值是true它的作用是read-pkg-up 读取到 package.json 后会用normalize-package-data库对原始 JSON 进行一轮标准化处理。这轮处理会做哪些事简单举几个例子将name字段统一转成小写npm 包名规范要求小写如果version缺失或格式不对会尝试修正为一些常见字段设置合理的默认值比如readme之类的把bin、directories这类对象结构统一成规范形式根据规范校验bugs、repository等字段的 URL 结构换句话说normalize 相当于是“洗数据”让符合规范或不完全符合规范的 package.json 拥有一个统一的数据长相避免下游代码在字段判断时被各种非标写法搞崩。但是标准化也意味着数据可能被“改”过。如果你只是想要 package.json 的原始内容保留它本来的字段和值比如你要做文件指纹校验、对比原始 content、或者分析某些非标准字段就需要显式关闭const result await readPackageUp({ normalize: false, });我在实际项目中确实遇到过需要normalize: false的场景有个工具需要统计项目里所有依赖的声明方式如果 normalize 把某些包名格式或字段补全了统计结果就失真了所以必须拿原始数据。这一点如果不是仔细读过 read-pkg 的文档很容易被默认行为带偏。3.4 其他参数兜底数据与查找范围控制除了上面两个核心参数read-pkg-up 在较新版本中还支持一些继承自上游find-up/read-pkg的参数这里挑两个常见的说。一个是default参数。这个参数在解析失败或读取异常时可以用一个兜底对象顶替返回值。比如你想让工具在没有 package.json 的环境里也保持可用可以传一个空配置避免后续代码做大量空值分支。要注意这个兜底行为主要针对读取解析阶段如果向上查找完全找不到文件返回的packageJson就会是你传入的默认对象。实际使用时要区分“找到了但解析出错”和“根本没找到”两种情况。另一个是limit参数用来控制向上查找的最大层数。默认是 Infinity也就是一直找到文件系统根目录。如果你只关心当前目录或者最多往上一层的 package.json可以设置较小的值避免意外的搜索结果。比如limit: 1就表示只找当前目录。这个参数在很多 monorepo 场景里很有用后面实操部分我会再演示。如果你需要读的不是 package.json 而是别的配置文件比如.eslintrc、tsconfig.json、.npmrc那就没必要用 read-pkg-up 了。可以直接使用它依赖的上游库find-up用法几乎一样只是不包含 JSON 解析和标准化逻辑。这个问题其实不少读者会踩到因为在网上搜索“向上查找配置文件”时read-pkg-up 往往排在前面但它只适用于 package.json。4. 源码逻辑与包管理工具链中的位置4.1 向上查找的实现原理read-pkg-up 本身并不复杂它的核心逻辑是组合了两个更底层的库find-up负责向上查找文件路径read-pkg负责解析和标准化 package.json。从源码角度来看伪代码大致是这样的async function readPackageUp(options) { const filePath await findUp(package.json, options); if (!filePath) { return undefined; } return { packageJson: await readPackage({ ...options, cwd: dirname(filePath) }), path: filePath, }; }也就是说read-pkg-up 先把“找路径”和“读内容”两个阶段解耦先拿到文件绝对路径再把路径所在的目录作为新的起点去读取 package.json。这种分层组合的方式也让 read-pkg-up 在维护上非常轻量每个环节都可以单独替换。find-up的向上查找逻辑本质上就是一个循环path.dirname的过程但它处理了很多边界情况Windows 路径的盘符判断、根目录检测、符号链接、传入参数的类型处理等。我前面手写的那版查找逻辑在普通 Linux 目录下没问题但放到 Windows 上或者在遇到根目录时判断条件就容易写错。这就是成熟工具的价值——它把一堆你不一定想得到的边界条件提前处理好了。另外还需要注意read-pkg-up 查找的是“最近”的 package.json而不是“根目录”的。在 monorepo 里这意味着一件很有意思的事如果你站在某个子包里执行read-pkg-up 很可能会先找到子包的 package.json而不是仓库根目录的。如果你的目标是根目录的 package.json那就要小心路径设计了后面我会专门聊。4.2 和 npm、pnpm、yarn 以及 debian 包管理的对比把视角拉远一点read-pkg-up 在包管理生态里处于什么位置这里可以把它和几个常见概念对比一下。npm/yarn/pnpm 是 JavaScript 生态的包管理器负责依赖解析、安装、脚本执行。debian 系的 apt/apt-get 则是系统层的包管理器负责系统软件包的安装和依赖管理。两者解决的问题类似但作用域完全不同一个管用户态依赖一个管系统级软件包。而 read-pkg-up 两者都不是它是一个“配置发现工具”。在 npm 生态里包管理的第一步永远需要先知道项目清单在哪。npm 自己内部有一套逻辑去找 package.jsonpnpm 也有但它们不会把这些找文件的能力暴露给第三方开发者。read-pkg-up 等于是把这个“找清单文件”的公共能力独立出来让所有工具都能复用。这也是为什么 Sindre Sorhus 这一系列工具在生态里地位这么高的原因——它们不是最大的库但是被其他库依赖最多的库之一。如果你用过 apt 的包管理流程可以类比一下apt 需要先读取/var/lib/dpkg/status这类状态文件来判断系统里装了什么包工具链要判断项目里有什么包就得往 package.json 方向找。read-pkg-up 就是帮你找到并读取这份“项目状态文件”的那个工具。4.3 为什么不直接读 process.cwd()有人说我直接用process.cwd()拼上package.json去读不就行了吗为什么非要向上查找这个问题问得很多原因在于process.cwd()代表的是“用户当前在哪个目录”而不是“项目根目录在哪里”。真实使用场景里工具的执行目录和项目根目录经常不一致。例如用户从项目子目录执行npm run lint脚本里的 Node 工具拿到的process.cwd()就是子目录而不是项目根目录。如果不向上查找工具就会盲目认为子目录里应该有 package.json找不到就直接报错哪怕根目录里有的是。还有一种更隐蔽的情况process.cwd()并不总等于你脚本文件所在的目录。比如一个工具被安装到了全局node_modules用户在其他地方执行它process.cwd()是用户的工作目录而工具的代码可能在完全不同的位置。如果用__dirname去读配置文件会读到工具自带的 package.json而不是用户项目的。read-pkg-up 的cwd参数赋予了调用者明确的控制权默认值虽然也是process.cwd()但只要愿意随时可以指定从任意目录开始向上搜索。5. 实际项目中的典型用法5.1 CLI 工具读取用户项目配置先看一个完整的实战场景。假设你在写一个release-helperCLI 工具它要做的是读取用户项目里最近的 package.json判断项目当前版本号根据scripts里有没有build命令决定后续要不要执行构建流程#!/usr/bin/env node import { readPackageUp } from read-pkg-up; import { execa } from execa; const result await readPackageUp(); if (!result) { console.error(未找到 package.json请确认在正确的项目目录中运行); process.exit(1); } const { packageJson: pkg, path: pkgPath } result; console.log(项目: ${pkg.name}${pkg.version}); console.log(配置文件位置: ${pkgPath}); if (!pkg.scripts || !pkg.scripts.build) { console.warn(当前项目没有 build 脚本跳过构建步骤); } else { await execa(npm, [run, build], { stdio: inherit }); }这就是一个非常典型的 CLI 工具初始化流程。用户在项目任意子目录执行工具read-pkg-up 自动帮他找到项目根部的 package.json然后工具基于这个信息继续干活。这个流程里有几个细节值得注意第一用if (!packageJson)做空值判断保证在找不到配置时不至于直接抛异常第二把path也打出来方便用户在调试时确认工具找到的是哪个文件第三pkg.scripts可能不存在访问前先判断一层这种保护在真实项目中很重要因为你不知道用户会写多随意的 package.json。5.2 monorepo 场景下的注意点monorepo 是 read-pkg-up 最容易产生“意料之外、情理之中”行为的地方。假设仓库结构如下repo ├── package.json # 根 package.json包含 workspaces ├── packages │ ├── a │ │ └── package.json │ └── b │ └── package.json当用户在packages/a目录下执行工具时read-pkg-up 默认会先找到packages/a/package.json因为从当前目录向上看它是最近的一个。这通常符合预期——工具应该优先处理当前子包的信息。但有些场景下你需要的恰恰是根 package.json。举个例子你的工具需要判断仓库根部的packageManager字段以决定用 npm 还是 pnpm 执行命令这时候子包的 package.json 帮不上忙。怎么办可以从子包目录继续往上一层找import path from node:path; import { readPackageUp } from read-pkg-up; // 先找到子包路径再从它的父目录开始找 const subPkg await readPackageUp({ cwd: process.cwd() }); if (!subPkg) process.exit(1); const rootPkg await readPackageUp({ cwd: path.dirname(path.dirname(subPkg.path)), // 视情况调整向上层数或者用 limit 控制查找范围 });从subPkg.path的父目录出发read-pkg-up 会继续向上最终找到根 package.json。这是一种很实用的“从子包反查仓库根”的模式。如果你确定根 package.json 一定在 subPkg 的上一层目录可以直接用path.dirname(path.dirname(subPkg.path))作为起点一次到位。如果不想做二次查找也可以一开始就把cwd指向仓库根目录。但问题是你怎么知道仓库根目录在哪又回到了“向上找”的循环里。我的经验是先把子包信息读出来再基于它反查根目录逻辑最清晰也最不容易误判。5.3 在构建脚本、发布流程中配合包管理器使用除了 CLI 工具read-pkg-up 在构建脚本和发布流程里也经常充当“前置侦察兵”。比如你需要写一个 pre-publish 脚本检查当前包的版本号是否已经发布过import { readPackageUp } from read-pkg-up; const result await readPackageUp(); if (!result) throw new Error(找不到 package.json); const { version } result.packageJson; const npmView await execa(npm, [view, ${result.packageJson.name}${version}, version]); if (npmView.stdout) { console.warn(版本 ${version} 已存在请先更新版本号); // 这里可以选择退出或提示 }这个流程同样可以用在 CI 里检查用户当前分支的版本号是否和线上版本冲突提前拦截。read-pkg-up 在这里的价值是确保脚本不管在什么目录下被触发npm 的 lifecycle script 在子包目录触发很常见都能拿到正确的 package.json。再看一个和包管理器协作的例子。pnpm 的 workspace 和 npm 的 workspaces 都依赖根 package.json 和各个子包的 package.json。有个工具想统计整个仓库的依赖声明它需要先明确所有子包的位置这时 read-pkg-up 的价值就体现在“先帮每个子包定位它自己的 package.json”避免你按固定目录去拼接路径。包的路径在 monorepo 里经常变今天在packages/web明天挪到apps/web硬编码路径一定被坑用 read-pkg-up 从子包目录反查才是正道。另外我建议你在使用 read-pkg-up 时把它和 Node 内置的path模块结合起来用。因为 read-pkg-up 返回的是绝对路径你完全可以基于它继续做上层目录的定位而不用再自己去解析路径。配合fs.existsSync之类的基础 API能组合出很多灵活的目录探索逻辑比手写一整套文件查找高效得多。6. 常见问题与排查技巧6.1 常见问题速查表我在使用 read-pkg-up 的过程中遇到过的和询问频率较高的问题整理成一张表方便你快速定位问题现象原因解决方案require(read-pkg-up)报错ERR_REQUIRE_ESM新版本是纯 ESM 包不支持 CommonJSrequire改用import或使用动态import()包裹读取到的name字母全变小写了normalize 默认开启这是标准化行为不需要标准化时传入normalize: false明明有 package.json 却返回undefinedcwd指向了文件本身或者查找起点设成了包内部目录确认cwd是目录路径不是文件路径确认没有设置过小的limitmonorepo 中拿到的是子包 package.json 而不是根目录的read-pkg-up 返回“最近”的 package.json不是“根”的先读子包再从其path的父目录二次向上查找同步方法在启动时阻塞过久读取文件本身是同步阻塞的尽量改用异步版本或只在初始化阶段调用一次返回值一开始以为是个对象实际是 undefined访问属性报错找不到 package.json 时返回 undefined不是空对象用if (result)先做空值判断其中第一个问题是最多人踩的坑。如果你在维护一个老项目或者同时存在 CJS 和 ESM 两种模块建议在文档里明确标注 read-pkg-up 的版本要求。v7 及之前是 CommonJS 包v8 之后才转向纯 ESM。如果你的项目无法快速迁移到 ESM可以锁定旧版本或者用下面的方式做动态加载const { readPackageUp } await import(read-pkg-up);动态import()在 CommonJS 模块里可以被正常使用这是不少人在兼容期采用的过渡方案。不过这只适合在初始化阶段用一次频繁调用动态 import 会有性能开销老项目还是建议找时间整体规划迁移。6.2 常见问题排查思路从“找不到”到“读不对”遇到 read-pkg-up 行为怪异时我通常按下面的顺序排查这个方法分享出来希望对你有帮助。先复现问题确认执行时的工作目录是什么。很多“找不到 package.json”的问题本质上都是cwd和你预期的不一致。在代码里临时打一行console.log(process.cwd())或者在 CLI 里pwd一下立刻就能确认。用户从哪个目录执行你的命令决定了默认查找起点。再确认你找的是不是“最近”的那份 package.json。在 monorepo 里这几乎是最容易误判的一点。如果你觉得应该找到根目录的结果却拿到了子包的那就按前面说的方式拿到子包path后再往上级目录查找。最后检查 normalize 是否改变了数据结构。normalize: true默认时read-pkg-up 返回的对象已经经过标准化字段顺序、默认值都可能和原始 JSON 不一样。如果你要做字段级对比或者依赖某个非标准字段一定要先把 normalize 关掉。我遇到过有人从 package.json 里取自定义配置字段结果发现字段被 normalize 给修改了排查半天才发现是标准化逻辑在作怪。读不出来不等于没有读出来了不等于是对的。这两个排查方向基本能覆盖九成以上的问题场景。6.3 我的最终建议与几点实用心得用了 read-pkg-up 小半年之后我觉得它最值得称道的就是“小而专”。它没有试图解决 package.json 之外的问题也没有引入复杂的配置体系而是把一个高频、看似简单、实际很磨人的操作压缩成了两个函数。这种“一个库只解决一个问题”的思路在 JavaScript 生态里被反复验证是 Sindre Sorhus 系列工具的一贯风格也确实好用。在具体使用上我有几个建议希望你能认真考虑第一始终对返回值做空值判断。找不到 package.json 是完全正常的场景不要假设它一定存在空值判断既能让工具行为更明确也能在出错时给出更友好的提示。第二需要修改 package.json 内容时不要直接基于 read-pkg-up 的结果回写。read-pkg-up 返回的是标准化后的对象和磁盘上的原始文本并不完全一致。如果你要安全地更新 package.json 文件建议用 fs 读取原始 JSON、修改、再写回或者使用专门的写库。否则你可能把 normalize 处理过的字段也一起写回去造成一堆无意义的 diff。第三在 monorepo 或 workspace 环境里先弄清楚自己真正需要哪一层 package.json。是子包的还是根部的这个决定直接影响你调用的参数和后续逻辑。如果拿不准就把找到的path先打出来看一眼再决定要不要继续往上找。第四把 read-pkg-up 当成你“探索项目结构”的基础设施而不是一个只能读取 package.json 的一次性函数。从它返回的path出发做路径推导能解决很多看似和它没关系的目录问题。配合find-up这类上游工具几乎可以覆盖所有“向上找配置文件”的需求场景。最后再分享一个小技巧。在写 CLI 工具时我会把 read-pkg-up 的调用封装成一个独立模块比如getProjectInfo()专门负责读取项目配置并返回统一结构。这样一旦将来需要更换实现方案只需要改一个文件所有业务代码完全不用动。这个习惯我建议你从第一个工具就开始养成因为配置读取逻辑往往会在多个命令里被复用到。