•
阅读时长 3 分钟

谨慎直接 require 没有写进 package.json 的包

目录

项目里明明没有声明一个包,代码却能 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 还可能残留以前装过的包。确认依赖声明之后,用干净安装检查,比只在自己的旧目录里跑通更有说服力。