Skip to main content
  1. Blogs/
  2. 前端开发/
  3. 工程化/
  4. npm/

告别幽灵依赖与磁盘焦虑:pnpm 硬链接

·896 words·5 mins
Table of Contents

1. 硬链接 vs 符号链接
#

1.1 先认识下 inode
#

在常见文件系统(如 ext4、APFS、NTFS 上的常规语义)里, inode 是文件的「身份证」:元数据(权限、大小、时间戳等)和 数据块位置 挂在 inode 上;目录里的一条记录,本质是 名字 → inode 编号 的映射。

下面用 Node 的 fs.statSync 读出一个临时文件的 inode 号(ino

const fs = require('fs')
const path = require('path')
const os = require('os')

// 在系统临时目录建一个普通文件,用于 stat
const file = path.join(os.tmpdir(), `inode-demo-${Date.now()}.txt`)
fs.writeFileSync(file, 'hello', 'utf8')
const s = fs.statSync(file)

/*
 * s.ino:这个文件的 inode 编号,一个整数(就是下面要打印的数)。
 * 运行后终端大致会长这样(数字仅举例,你电脑上会是别的数):
 *   ino (inode 编号): 14023456789012345
 */
console.log('ino (inode 编号):', s.ino)

fs.unlinkSync(file)

也就是说:终端会先打印 ino (inode 编号): 这段说明,紧跟着的一个整数就是 s.ino ,例如 ino (inode 编号): 87291234(具体数值随系统、随每次新建文件而变)。

1.2 硬链接(hard link)#

硬链接 是为 已有 inode 再增加一个目录项(又一个路径名), 不复制数据块fs.link(existing, newpath) 对应 POSIX link(2):成功后 stat(existing).ino === stat(newpath).ino ,且 nlink 会增加

const fs = require('fs')
const path = require('path')
const os = require('os')

const dir = path.join(os.tmpdir(), `hardlink-demo-${Date.now()}`)
fs.mkdirSync(dir, { recursive: true })
const store = path.join(dir, 'store.txt') // 类比 pnpm store 里的唯一文件
const app = path.join(dir, 'app.txt') // 类比项目 node_modules 里出现的「第二名字」
fs.writeFileSync(store, 'A', 'utf8')
fs.linkSync(store, app) // 硬链:app 与 store 指向同一 inode

const ss = fs.statSync(store)
const sa = fs.statSync(app)
console.log('硬链后 ino 相同:', ss.ino === sa.ino) // true:两个路径是同一份物理文件
console.log('硬链后 nlink:', ss.nlink) // 2:两个名字都计入该 inode

fs.writeFileSync(store, 'B', 'utf8') // 改其中任意一个名字,另一处读到同一块数据
console.log('改 store 后读 app:', fs.readFileSync(app, 'utf8')) // B

fs.rmSync(dir, { recursive: true, force: true })

1.3 符号链接 / 软链接(symlink)#

符号链接 单独占一个 inode,内容存的是 目标路径字符串fs.stat(链接路径)跟随 链接去读目标文件,而 fs.lstat(链接路径) 不跟随 ,可看到 isSymbolicLink()。因此: 软链路径与目标路径的 ino 通常不同 ;删掉目标后,软链可能「悬空」,这与硬链「多名字平等、共享数据」不同。

const fs = require('fs')
const path = require('path')
const os = require('os')

const dir = path.join(os.tmpdir(), `symlink-demo-${Date.now()}`)
fs.mkdirSync(dir, { recursive: true })
const target = path.join(dir, 'target.txt')
const alias = path.join(dir, 'alias.txt')
fs.writeFileSync(target, 'real', 'utf8')
fs.symlinkSync(target, alias) // 软链:alias 指向 target 的路径(Windows 下若失败,可尝试管理员/开发者模式)

const stFollow = fs.statSync(alias) // 跟随:得到「目标文件」的 inode
const stLink = fs.lstatSync(alias) // 不跟随:得到「链接本身」的 inode
console.log('lstat isSymbolicLink:', stLink.isSymbolicLink()) // true:这是软链,不是普通文件
console.log('readlink 存的路径:', fs.readlinkSync(alias)) // 与硬链不同:链上存的是字符串路径
console.log('软链自身 ino !== 跟随后的 ino:', stLink.ino !== stFollow.ino) // 通常为 true:两个 inode

fs.rmSync(dir, { recursive: true, force: true })

小结(和硬链对比) :硬链是「多名字、同一 ino、同一数据」;软链是「一个名字指向另一路径,lstatstat 语义不同」。

与 pnpm 的关系(心智模型) :pnpm 把不可变包体放在 内容寻址的全局 store ;安装到项目时,对 单个文件 常用 硬链接 指向 store 中同一份内容,从而多项目复用同一 inode、节省磁盘。对 目录 ,Windows 上常见 junction ,类 Unix 上常见 符号链接 ,与「文件硬链」组合使用——因此真实 node_modules 是链 + 硬链的拼图,而不是「整个目录硬链」。


2. 可运行示意:fs.link 与 inode#

将下面脚本保存为 hardlink-demo.cjs 。Node 的 link 对应 POSIX link(2) :为已有文件增加硬链接。

const fs = require('fs')
const { join, dirname } = require('path')

// 输出目录:与脚本同级的 .demo-out/
const root = join(__dirname, '.demo-out')

/**
 * 将 store 中的已有文件硬链到「安装路径」。
 * 类比 pnpm:store 里唯一内容,项目 node_modules 里多出的名字指向同一 inode。
 */
function hardLinkFile(from, to) {
  fs.mkdirSync(dirname(to), { recursive: true })
  fs.linkSync(from, to) // 为已有文件增加硬链接(须在同一文件系统内)
}

function main() {
  fs.rmSync(root, { recursive: true, force: true })
  const storePkg = join(root, 'store', 'lodash@4.0.0', 'index.js')
  fs.mkdirSync(dirname(storePkg), { recursive: true })
  fs.writeFileSync(storePkg, "module.exports = { tag: 'from-store' }\n", 'utf8')

  const app1 = join(root, 'app-a', 'node_modules', 'lodash', 'index.js')
  const app2 = join(root, 'app-b', 'node_modules', 'lodash', 'index.js')
  hardLinkFile(storePkg, app1)
  hardLinkFile(storePkg, app2)

  const sStore = fs.statSync(storePkg)
  const s1 = fs.statSync(app1)
  const s2 = fs.statSync(app2)

  // ino 相同 → 三处路径共享同一 inode,即同一份物理文件
  console.log('同一物理文件(硬链):', sStore.ino === s1.ino && s1.ino === s2.ino)
  // nlink:指向该 inode 的硬链接条数(含 store 路径本身)
  console.log('nlink(硬链计数):', sStore.nlink)

  fs.writeFileSync(storePkg, "module.exports = { tag: 'mutated-in-store' }\n", 'utf8')
  // 改 store 即改 inode 上的数据,所有硬链名立即可见
  console.log('改 store 后 app-a 读到:', fs.readFileSync(app1, 'utf8').trim())
}

try {
  main()
} catch (e) {
  console.error(e)
  process.exit(1)
}
node hardlink-demo.cjs

你会看到 ino 一致nlink ≥ 3 ,以及修改 store 后 app-a / app-b 读到同一份更新——这就是 「省盘」 在文件系统层的含义: 不复制数据块,只增加名字


3. npm 的解析习惯与「幽灵依赖」
#

3.1 扁平 node_modules 下模块怎么被找到
#

Node 解析 require('bar') 时,会从当前文件所在目录开始,向上逐级查找 node_modules/bar经典 npm v2/v3+ 扁平化 后,依赖会被尽量摊到 上层 node_modules,于是 同一物理目录 里可能出现「你未声明、但兄弟包依赖过」的包。

下面的「目录树示意」用注释标出风险(逻辑结构,非某一工具的确切输出):

my-app/
  package.json          # dependencies 里只写了 "foo"
  node_modules/
    foo/
    bar/                # ← foo 依赖 bar,被提升/摊平到顶层
    # 你的代码在 my-app 里写 require('bar'):
    # 解析器在 my-app/node_modules 就找到了 bar —— 「碰巧能跑」

3.2 什么是幽灵依赖(phantom dependency)
#

定义 :在代码里 require('某包'),但当前包的 package.json 没有 把该包写在 dependencies(或 peer 等合法字段)里;只因 扁平布局 把传递依赖摆到了可解析路径上,模块才能被加载。

问题

  • 依赖边界不清:升级 foo 或锁文件后,bar 可能不再出现在顶层, 线上突然 MODULE_NOT_FOUND
  • 可读性与审计差:别人看不出你的包「真的」依赖谁。

4. 最小 workspace:幽灵依赖与修复
#

空目录 下按下列结构新建文件(依赖链为 consumerfoobarconsumer 只声明 foo,却 require('bar'))。

pnpm-workspace.yaml

packages:
  - 'packages/*'

package.json (根目录,提供两条演示命令)

{
  "name": "phantom-workspace-demo",
  "private": true,
  "scripts": {
    "demo:phantom": "node packages/consumer/entry.js",
    "demo:fixed": "node packages/consumer-fixed/entry.js"
  }
}

packages/bar/package.json

{
  "name": "bar",
  "version": "1.0.0",
  "main": "index.js"
}

packages/bar/index.js

exports.value = 'bar'

packages/foo/package.json

{
  "name": "foo",
  "version": "1.0.0",
  "main": "index.js",
  "dependencies": {
    "bar": "workspace:*"
  }
}

packages/foo/index.js

const { value } = require('bar')
exports.fooUses = value

packages/consumer/package.json (注意: 没有 声明 bar

{
  "name": "consumer",
  "version": "1.0.0",
  "dependencies": {
    "foo": "workspace:*"
  }
}

packages/consumer/entry.js (幽灵依赖:直接 require 传递依赖)

/**
 * 仅声明依赖 foo,却直接 require 传递依赖 bar。
 * 在扁平 node_modules 下可能「碰巧能跑」;
 * 在 pnpm 严格布局下应解析失败(无幽灵依赖)。
 */
const { fooUses } = require('foo')
const { value } = require('bar')

console.log('foo', fooUses, 'bar', value)

packages/consumer-fixed/package.json (显式补上 bar

{
  "name": "consumer-fixed",
  "version": "1.0.0",
  "dependencies": {
    "foo": "workspace:*",
    "bar": "workspace:*"
  }
}

packages/consumer-fixed/entry.js

const { fooUses } = require('foo')
const { value } = require('bar')

console.log('ok:', fooUses, value)

在该目录执行:

pnpm install
pnpm demo:phantom   # 预期:Cannot find module 'bar'
pnpm demo:fixed     # 预期:终端输出 ok: bar bar

在 pnpm 默认隔离布局下,demo:phantom 应失败consumer 的包边界内没有合法暴露的 bar,这正是 「消灭幽灵依赖」 的体现—— 逼你在 package.json 里说实话 ;补上声明后,demo:fixed 与之一致写法即可运行。


5. pnpm 的两大特性(与上文衔接)
#

5.1 硬链接节省空间(配合全局 store)
#

  • 包内容以 内容寻址 形式放在全局 store;项目里对 文件 大量使用 硬链接 指向 store 中同一份 inode。
  • 多项目、多版本 复用同一份物理文件时,磁盘不会按「每个项目全量拷贝」线性爆炸。
  • 与「拷贝」心智不同: 硬链指向同一数据 ;真实 pnpm 还会结合 symlink/junction 拼出目录结构,详见官方文档与上文 inode 示意。

5.2 幽灵依赖问题的解决
#

  • 通过 .pnpm 虚拟存储 + 严格的可见性边界 ,每个包只能看到自己声明的依赖(以及被声明规则允许的部分), 不会 把未声明的传递依赖「摊」到你随便 require 就能到的位置。
  • 第 4 节 consumer / consumer-fixed 一对照,对应 「错误写法必挂」「显式声明即可运行」 ,便于团队对齐规范。

6. 小结
#

维度要点
文件系统硬链共享 inode,省空间;软链存路径,适合目录跳转与拼接布局。
npm 扁平化提升传递依赖 → 易催生幽灵依赖,升级即踩雷。
pnpmstore + 硬链(文件)→ 磁盘焦虑缓解 ;严格 node_modules告别幽灵依赖 (改为显式依赖)。

第 2 节的脚本适合作为「几十秒理解 inode / 省盘」的动手材料;第 4 节的 workspace 适合解释「CI 里为何突然找不到某个包」——依赖未声明却被扁平布局惯坏了。


延伸阅读
#