项目里明明没有声明一个包,代码却能 import 到它。换到干净环境,或者换一种安装方式,才报找不到模块。这种藏在依赖关系里的问题,通常被叫作幽灵依赖。
它不是 Node 凭空找出了一个包,而是安装布局让代码碰巧能访问到传递依赖。
用本地包构造同一份依赖关系
应用依赖 A,A 依赖 B,应用却直接 require B:
应用 -> A -> B
应用代码 -> require(B) // 应用没有声明这一条依赖
新建一个空目录,将下面的脚本存为 install.mjs。脚本会在当前目录下创建 fixtures,打包两个本地包,然后分别安装;不会发布包,也不修改全局包管理器。
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
import assert from "node:assert/strict";
const root = process.cwd();
const fixtures = path.join(root, "fixtures");
function run(args, cwd, expect = 0) {
const result = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", args, {
cwd,
encoding: "utf8",
shell: process.platform === "win32",
});
if (result.status !== expect)
throw new Error(result.stderr || result.stdout || String(result.error));
return result.stdout;
}
async function json(file, value) {
await writeFile(file, JSON.stringify(value, null, 2));
}
const b = path.join(fixtures, "b"),
a = path.join(fixtures, "a");
await mkdir(b, { recursive: true });
await mkdir(a, { recursive: true });
await json(path.join(b, "package.json"), {
name: "leyla-fixture-b",
version: "1.0.0",
main: "index.cjs",
});
await writeFile(path.join(b, "index.cjs"), "module.exports='B';\n");
run(["pack", "--ignore-scripts", "--json"], b);
const bTar = path.join(b, "leyla-fixture-b-1.0.0.tgz").replaceAll("\\", "/");
await json(path.join(a, "package.json"), {
name: "leyla-fixture-a",
version: "1.0.0",
main: "index.cjs",
dependencies: { "leyla-fixture-b": "file:" + bTar },
});
await writeFile(
path.join(a, "index.cjs"),
"module.exports=require('leyla-fixture-b');\n",
);
run(["pack", "--ignore-scripts", "--json"], a);
const aTar = path.join(a, "leyla-fixture-a-1.0.0.tgz").replaceAll("\\", "/");
const results = [];
for (const manager of ["npm", "pnpm"]) {
const app = path.join(fixtures, manager + "-app");
await mkdir(app, { recursive: true });
await json(path.join(app, "package.json"), {
name: manager + "-app",
version: "1.0.0",
dependencies: { "leyla-fixture-a": "file:" + aTar },
});
if (manager === "npm")
run(["install", "--ignore-scripts", "--no-audit", "--no-fund"], app);
else
run(
[
"exec",
"--yes",
"--package=pnpm@8.15.9",
"--",
"pnpm",
"install",
"--ignore-scripts",
"--store-dir",
".store",
],
app,
);
await writeFile(
path.join(app, "probe.cjs"),
"try { console.log(require('leyla-fixture-b')); } catch { process.exitCode=3; }\n",
);
const result = spawnSync(process.execPath, ["probe.cjs"], {
cwd: app,
encoding: "utf8",
});
results.push({ manager, exit: result.status, output: result.stdout.trim() });
assert.equal(result.status, manager === "npm" ? 0 : 3);
}
console.log(results);
运行 node install.mjs。需要本机 npm,pnpm 通过 npm exec 使用固定版本,第一次可能需要下载 pnpm。
这个依赖图在 npm 11.14.1 默认安装下,应用直接访问 B 成功,退出码是 0;pnpm 8.15.9 默认布局下,访问失败,退出码是 3。脚本把这两个预期写进断言;换配置或版本后如果断言失败,需要检查实际布局,不能忽略输出。
Node 查找规则和安装布局一起起作用
Node 从调用文件附近的 node_modules 开始查找,并逐级往上找。npm 在这个例子里把 B 放到了应用能找到的位置,缺失的声明就被隐藏了。
pnpm 的默认结构把包的依赖关系放进相应的链接布局,应用直接访问没有声明的 B 时,不会自动拿到 A 的内部依赖。
这不是“npm 永远能访问所有传递依赖”或“pnpm 绝对禁止”的结论。提升、node linker 等配置会改变可见范围。讨论幽灵依赖时,安装器版本和配置都需要说明。
pnpm 也不是让所有项目共用同一份 node_modules。各项目仍然有自己的依赖结构,底层内容可以通过 store 复用。布局参考 pnpm 文档。
package.json 显示声明
如果应用确实使用 B,就应该把 B 加进应用依赖。不需要硬编码 .pnpm 的内部路径,也不要只是扩大提升范围来掩盖问题。
peer dependency 是另一种契约,需要由使用方按包的要求提供,不能把所有传递依赖都一股脑补进 package.json。构建插件、测试工具也可能有自己的加载要求。
长期没清理的 node_modules 还可能残留以前装过的包。确认依赖声明之后,用干净安装检查,比只在自己的旧目录里跑通更有说服力。