严格区分 dependencies 和 devDependencies,这件事听起来像每个前端/Node 开发者入职第一天就该会的常识,但现实是我接手过的项目里,十个有八个把 qrcode、moment、axios 这种运行时库放在 devDependencies,又把 eslint、jest、typescript 塞进生产依赖。还有更常见的:项目跑得挺好,但没人敢动 package.json,因为一 npm install 就冒出一堆“幽灵包”,npm ls 列出来全都带 extraneous 标记。
这个标题的核心其实就是两件事:先想清楚包该放哪一类,再回头把不该存在的包清干净。前者影响部署环境、镜像大小、CI 缓存命中率;后者影响开发者日常安装速度、依赖树健康度、以及最容易被忽视的供应链攻击面。这篇文章就当是一次现场排障记录,我会把判断依据、实操命令、以及我踩过的坑全部拆开讲,适合所有用 npm、yarn、pnpm 维护项目的同学参考。
1. 先弄明白:为什么“归类”错了会非常麻烦
1.1 一个错误归类导致的生产事故复盘
两年前我维护过一个 Node 服务,package.json 里 dependencies 足足有 180 个包,其中一半以上都是构建期工具。有一次部署流水线优化,运维小哥把 Docker 镜像改成多阶段构建,生产阶段只执行 npm install --production,结果服务启动直接报 Cannot find module 'ts-node/register'。
原因很好笑:项目里有人为了本地调试方便,把 ts-node 写进了 dependencies,生产环境安装时确实装上了,但真正的业务代码用了 dist 下的编译产物,根本不需要它。而 dotenv 这种运行时必须的库,反而因为当时图省事被放进了 devDependencies,生产环境一装,连配置文件都读不进来。
这事背后是两种风险:
- 放错到 devDependencies 的运行时包,一旦部署环境使用
--production或NODE_ENV=production安装,应用启动就会原地崩掉。 - 放错到 dependencies 的工具包,会让生产镜像白白多出几十上百 MB,更重要的是这些包平时只在开发机上有漏洞通告,一旦被带上生产环境,等于主动扩大了攻击面。
所以“归类”不是 package.json 里的排版问题,它直接决定部署产物里到底有什么。
1.2 两类依赖的本质区别不是“常用”和“不常用”
很多人对 dependencies 和 devDependencies 的理解停留在“前者是项目运行需要的,后者是开发时候用的”。这句话基本对,但缺少一个最重要的判断标尺:这段代码是否会被打包进你的线上运行产物?
只要回答“是”,就该进 dependencies;只要回答“否”,那就进 devDependencies。
从 npm 的视角看,dependencies 里的包会被当成“应用运行时不可分割的一部分”,npm install --production 会照单全收;devDependencies 则只服务于开发工作流——编译、测试、代码检查、本地 mock,生产环境安装时会被完全忽略。
再讲得直白一点,Node 服务端项目里:
- 被
require()或import()的业务代码直接依赖的库,express、koa、mongoose、mysql2、dotenv、ioredis这些,放 dependencies。 - 负责把 TypeScript 编译成 JS 的
typescript、ts-node,做单元测试的jest、vitest,代码规范工具eslint、prettier,这些只在开发期干活,放 devDependencies。
前端项目因为最终产物是静态文件,很多人会想“反正都 webpack 打包进去了,dist 里又没有 node_modules”,于是随便放。但要注意两点:
- 如果你的前端项目需要服务端渲染(SSR)或者接入
next start/nuxt start这类生产启动命令,服务端运行时依赖依然是生产环境需要安装的。 - CI 阶段如果用
npm ci --omit=dev来装依赖再执行构建,而你的eslint、postcss被错放成了生产依赖且业务构建真的用到了,虽然不会报错,但每次构建都必须承担这部分安装开销。
1.3 语义化版本与版本锁定的隐性影响
另一个容易忽略的点是 package-lock.json 会把两类依赖分开记录,但这并不意味着分类不会影响锁文件。
如果你把 webpack 放进 dependencies,它连同它的几十个传递依赖都会进入生产依赖树;如果你把它放回 devDependencies,锁文件里生产区瞬间瘦身。对于部署时要执行 npm ci 的团队,生产依赖越少,下载时间越短,CI/CD 跑得越快,漏洞扫描的范围也越小。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 正确归类:一整套可以直接抄的判断流程
2.1 五分钟判断法:问四个问题
面对每一个包,你不需要背规则,按顺序问四个问题就行。
- 问题一:项目在线上运行(或用户使用构建产物)时,这个包是否会被加载?
- 是 → dependencies;
- 不确定 → 继续问;
- 问题二:去掉这个包,本地开发和构建还能正常跑吗?
- 不能 → devDependencies;
- 能 → 这包大概率连 devDependencies 都不该留;
- 问题三:这个包是“脚手架”还是“运行时工具”?
- 脚手架 / 编译 / 校验 / 测试 → devDependencies;
- 提供数据读写、网络、加解密、业务 SDK → dependencies;
- 问题四:项目是否存在服务端启动、定时任务、脚本消费场景?
- 如果这个包会被线上
node src/index.js或其他生产脚本require,必须 dependencies。
- 如果这个包会被线上
这个方法对 70% 的包都能立即做出判断。剩下 30% 属于灰色地带,比如 dotenv、cross-env,我们单独讲。
2.2 容易放错的典型包名单
我整理了一张高频踩坑表,都是我实际见过或者自己犯过的错:
| 包名 | 常见错误归类 | 正确归类 | 理由 |
|---|---|---|---|
typescript |
dependencies | devDependencies | 它是编译器,线上跑的是编译产物 |
ts-node |
dependencies | devDependencies | 生产环境极少需要直接用 TS 运行代码 |
eslint 全家桶 |
dependencies | devDependencies | 开发检查工具 |
jest / vitest |
dependencies | devDependencies | 只在跑测试时使用 |
nodemon |
dependencies | devDependencies | 热重启只服务本地开发 |
concurrently |
devDependencies | devDependencies | 通常用于启动多个开发脚本 |
webpack / vite 构建链 |
dependencies | devDependencies | 前端项目构建产物已静态化 |
dotenv |
devDependencies | dependencies | 生产启动同样需要读取 env |
axios / node-fetch |
devDependencies | dependencies | 线上接口调用需要 |
qrcode |
devDependencies | dependencies | 运行时生成二维码 |
md5 / crypto-js |
devDependencies | dependencies | 运行时数据加密 |
pm2 |
dependencies | 建议全局或独立部署依赖 | 不写进业务 package.json |
mongoose / pg |
devDependencies | dependencies | 运行时数据库连接 |
lodash / dayjs |
devDependencies | dependencies | 业务代码运行期调用 |
这里面最有迷惑性的是 dotenv。很多人觉得“env 文件不是只在本地有吗”,就把 dotenv 扔到 devDependencies。但生产服务器上同样需要 .env(或者通过注入环境变量),应用进程还得调用 dotenv.config() 来加载,所以它必然是运行时依赖。
还有 cross-env,它改了 npm scripts 里的环境变量写法,比如 NODE_ENV=production 在 Windows 上跑不起来,需要它来兼容。生产环境如果是 npm run start 触发,它会被调用,需要放 dependencies;如果生产环境直接用 node 启动且 scripts 里没有 cross-env,那么放 devDependencies 也能接受。我的建议是:只要是 scripts 生产命令里出现的包,一律 dependencies,少想一步省一个事故。
2.3 用初始化命令杜绝源头污染
比“事后分类”更好的办法,是从一开始安装的时候就做对。
bash复制# 安装运行时依赖
npm install axios dotenv mongoose
# 安装开发依赖
npm install -D eslint jest typescript ts-node
等价写法:
bash复制npm install --save-dev eslint prettier typescript
# yarn 2+ 的写法
yarn add axios dotenv
yarn add -D typescript eslint jest
# pnpm 的写法
pnpm add axios
pnpm add -D typescript vitest
这里顺便提一句 npm 7 之后的坑:如果在 npm install 时不加 -D,npm 默认会把它装进 dependencies,不存在以前 npm 5 自动识别命令行工具往 devDependencies 塞的“智能”行为。所以安装时务必想清楚,别一路 npm i 到最后再来纠错。
2.4 用 npm pkg 快速修正历史包袱
存量项目如果已经放错了,不必手动去编辑整个 package.json,可以用 npm 自带的命令精确迁移:
bash复制# 把 axios 从 devDependencies 移到 dependencies
npm pkg set dependencies.axios="^1.6.0"
npm pkg delete devDependencies.axios
# 然后重新安装使锁文件同步
npm install
如果项目用的是 pnpm,可以改用 pnpm add axios --save-prod 和 pnpm add typescript -D,pnpm 会自动帮你挪字段并更新 lockfile。
3. 找出无用的开发依赖包:工具与实战
分类做对之后,第二个大问题就是“哪些开发依赖包其实根本用不上了”。这比想象中隐蔽得多,因为项目一旦跑起来,很少会有人回头去审视每一个 devDependency 是否还有被引用的地方。
3.1 为什么无用的 devDependencies 特别值得清理
- 每次
npm install都要解析并下载,CI 会变慢。 node_modules体积虚胖,尤其是带二进制依赖的包(sharp、esbuild、canvas)动辄几十 MB。- 依赖越多,版本冲突和漏洞扫描范围越大。公司安全平台扫描依赖时不会区分你用没用,它只看数组里有没有。
- 团队新人接手时会被“幽灵依赖”误导,明明项目没用到某个库,但 package.json 里躺着,于是越不敢删,时间久了谁也说不清。
3.2 主流检测工具横向对比
清理无用的开发依赖包,我先后用过 depcheck、npm-check、knip,现在主力是 knip,下面说说区别。
| 工具 | 定位 | 优势 | 劣势 |
|---|---|---|---|
depcheck |
经典扫描工具 | 轻量、老牌、单条命令可用 | 对 monorepo 支持较弱,误报率较高 |
npm-check |
交互式更新/清理 | 能一键更新依赖,带 UI | 对“无用包”的判断逻辑较浅 |
knip |
现代无依赖扫描器 | 支持 monorepo、plugins,能检测未使用文件、未导出成员 | 配置项偏多,第一次上手有学习成本 |
如果你的项目是普通单包前端或 Node 服务,depcheck 完全够用;如果是 pnpm monorepo,建议直接 knip。下面拿 depcheck 示例,因为它最小化且认知成本低:
bash复制npx depcheck
输出里会分两类:
code复制Unused dependencies
* lodash
* qrcode
Unused devDependencies
* @types/express
* jest
看到这个结果不要马上删,depcheck 是靠静态分析 import/require 字符串来判断的,它不认识“间接使用”的情况。比如:
babel.config.js里动态引用的插件。- SQL 文件、模板字符串里用到的运行时依赖。
- scripts 命令中直接调用的二进制工具(
eslint、prettier),如果没在 JS 里 import,容易被算成 unused。
所以它的结论只能作为候选清单,必须人工复核。
3.3 更严格的方式:用 knip 扫出被引用的文件级依赖
knip 的核心理念是“从入口文件出发,全量追踪”。它会解析你的入口、配置文件、plugin,最后给出三类报告:
bash复制npx knip
典型输出:
code复制Unused files
* src/utils/legacy.ts
* scripts/deploy-old.sh
Unused dependencies
* axios
Unused devDependencies
* @types/node (实际上 @types/node 可能在 tsconfig types 里被引用,需谨慎)
我更推荐在 CI 里加一个 step:
bash复制npx knip --production
--production 只检查生产依赖;不加这个参数时它会把 devDependencies 也纳入分析。开发者可以把 knip 当作 pre-push 的 lint 之一,避免新依赖引入后长期不清理。
3.4 手动审视开发依赖的“三大类”
工具扫描之后,建议再人工过一遍三大类“高概率废弃包”:
- 同系列工具残留:项目从 webpack 迁移到 vite 后,
css-loader、file-loader、url-loader、terser-webpack-plugin一般都不需要了,但 package.json 里可能还留着。 - 被替代的库:比如 axios 换成了浏览器原生 fetch 或
ofetch,旧的axios就不该再出现了;moment 换成了 dayjs,老库还留在 dependencies 里。 - 移除功能后的专用包:做过问卷、图片压缩、导出 Excel 这些一次性功能,后来需求删了,但
xlsx、compressorjs、file-saver留在 devDependencies 里没人清理。
我遇到过一个项目,devDependencies 里躺着 gulp 和十几个 gulp-* 插件,实际上项目三年前就已经不用 gulp 了,build 脚本换成了 npm scripts + vite。这就是典型的“功能转移后没有顺带清依赖”。
4. 实操流程:干净利落地清掉无用开发依赖包
4.1 第一步:完整梳理现状
先别动手删,先给项目拍一张 X 光片:
bash复制# 查看顶层依赖数量
npm ls --depth=0
# 检查 extraneous(不在 package.json 里的幽灵包)
npm ls --depth=0 --parseable | grep -i extraneous
# 生产依赖与开发依赖分布
npm view 命令无法直接做统计,可以用下面这个 Node 脚本快速看
node -e "const p=require('./package.json'); console.log('deps:',Object.keys(p.dependencies||{}).length); console.log('devDeps:',Object.keys(p.devDependencies||{}).length)"
如果项目使用 pnpm,更直观:
bash复制pnpm list --depth=0 --prod
pnpm list --depth=0 --dev
这样你能锁定“生产依赖”和“开发依赖”各有多少,后续删减才有对照。
4.2 第二步:用集合来识别“明显无用”
我习惯先把 package.json 里所有 devDependencies 列出来,与代码实际引用对比。写一个一次性脚本是效率最高的方式:
bash复制node -e "
const fs=require('fs');
const pkg=require('./package.json');
const devs=Object.keys(pkg.devDependencies||{});
console.log(devs.join('\n'));
"
然后并行跑代码搜索:
bash复制# 搜索业务代码里是否有 import xxx 或 require('xxx')
grep -rE "(from|require\(|import\() ['\"]" src/ --include="*.ts" --include="*.tsx" --include="*.js" -o | \
sed -E "s/.*['\"]([^'\"]+).*/\1/" | sort -u
这个步骤不用特别精确,目的是把“明显没有被引用”的候选包圈出来,再交给 depcheck/knip 做精确扫。
4.3 第三步:执行删除的最佳姿势
当确认某个 devDependency 确实无用,删除命令很简单:
bash复制npm uninstall --save-dev jest # 删除并自动从 devDependencies 移除
# 一次删多个
npm uninstall -D gulp gulp-uglify gulp-sass gulp-concat
# pnpm
pnpm remove -D gulp gulp-uglify
# yarn
yarn remove -D gulp gulp-uglify
删完务必重新生成锁文件并验证差异:
bash复制npm install
git diff package-lock.json | head -80
这里要强调:一定不要在删完之后直接手动改动 package.json 再跑 npm install,容易造成 lockfile 与 package.json 不同步,进而让 CI 里 npm ci 直接报错。正确姿势永远是“通过包管理命令删,让锁文件一起更新”。
4.4 第四步:验证没有破坏构建和测试
删除后的验证是很多人跳过的环节,也是最容易翻车的地方。我的建议至少跑三件事:
- 构建验证:
npm run build,保证编译产物正常。 - 测试验证:
npm test,保证测试依赖没有缺失。 - 运行验证:本地启动一次完整应用,确认 devDependencies 里被删掉的包没有被运行时代码引用。
如果你身在多人协作项目,建议先提交 package.json 与 lockfile 的改动,跑通 CI 后再通知其他人拉取更新。因为如果团队里有人的本地 /node_modules 还是旧状态,他可能调到一个代码分支依赖刚刚被删掉的包,直接白屏报错。
4.5 把清理变成日常习惯:加入 CI 检查
最有效的方式不是“半年大扫除”,而是让工具帮你盯住。
可以在 pre-push 钩子或者 CI 中增加一个命令:
bash复制npx depcheck --json --ignore-dirs=dist,node_modules
如果分析结果里出现你不认识的 unused devDependency,先不要自动 fail,可以先输出日志让人工确认。团队约定跑通后再升级为 hard fail。比如:
bash复制npx depcheck --json | node scripts/check-unused.mjs
脚本里把你主动声明忽略的白名单(比如 @types/* 这类经常被误报的包)过滤掉,其他的一律阻塞合并。
5. 删除依赖时的典型问题与排障速查
5.1 删除后构建报 Module not found
场景:删掉某个看起来很没用的 devDependency 后,执行 npm run build 直接报错。
排查思路:
bash复制# 1. 看是哪个文件引用了它
grep -r "package-name" src/ config/ scripts/
# 2. 如果确实没有业务引用,那大概率是配置文件里在用
# 检查 .babelrc、webpack.config.js、tailwind.config.js、postcss.config.js
# 3. 如果确认项目已不再需要该构建工具,却仍被调用,检查 scripts 命令
cat package.json | grep -A 20 '"scripts"'
我遇到最多的是 @babel/plugin-transform-runtime,depcheck 扫描时认为业务代码没有 import 它,实际上 .babelrc 的 plugins 里写了,导致删除后构建失败。这类包要人工加回,或者以后删之前用 grep 先在配置文件里过一遍。
5.2 删除后别人的电脑上还是能用
这个“灵异现象”背后是幽灵依赖:A 包明明被删了,但是因为 node_modules 里还留着 B 包依赖的 A 副本,代码里直接 import 'A' 居然还能跑。
npm 的嵌套依赖结构里,这个问题很常见。当你在业务代码里直接访问一个“没有被 package.json 声明”的包时,npm 不会拦截,因为 Node 解析模块时默认向上找 node_modules,只要磁盘上存在它就能加载。这就是为什么删包后,必须用 npm ci 做一次干净安装再验证,而不是在自己本就混乱的 node_modules 上跑。
bash复制# 先清掉 node_modules,再纯净安装
rm -rf node_modules
npm ci
# pnpm 则可以直接
pnpm install --frozen-lockfile
如果你跑完 npm ci 后业务代码在 import 被删包的地方才报错,说明这是一个隐性的幽灵依赖问题。正确做法是:要么把该包正式加回 dependencies,要么把业务代码里直接引用的代码改成引用真正声明过的依赖。
5.3 depcheck/knip 误报怎么处理
工具误报场景非常常见,直接上表:
| 场景 | 原因 | 处理方式 |
|---|---|---|
@types/express 被标记 unused |
类型声明文件通常只在 tsconfig typeRoots 里引用 |
确认 tsconfig 里配置了 types,可列入白名单不要删 |
eslint-plugin-vue 被标记 unused |
它只在 .eslintrc 的 plugins 字段中声明 |
手动确认后保留 |
dotenv 被标记 unused |
项目入口已经提前加载了它但是静态扫描无法识别 | 确认启动脚本 |
ts-node 被标记 unused |
只在 scripts 执行 ts-node xxx.ts |
检查 scripts,保留 |
| monorepo 内部 workspace 包 | 跨包引用,扫描入口不全 | 使用 knip 或配置 depcheck ignore |
结论:工具输出是“线索”,不是“判决书”。删除前一定先看包里谁在引用、配置里谁在引用、scripts 里谁在引用。
5.4 npm audit 在新安装后暴涨
当你为了清理而执行 npm install 时,依赖树可能发生变化,导致版本重新解析。如果你没锁定版本或者 lockfile 未正确提交,npm audit 有时候报告的漏洞数会变多。
这是两个不同的问题,要分开看:
- 清理无用包本身会缩小
npm audit的扫描范围,漏洞数应该下降; - 如果反而涨了,多半是某一次
npm install悄悄升级了包的小版本。
所以我建议在团队里固定 lockfile,并且清理后看 git diff package-lock.json 的变更量。理想情况下,你只是删除了若干依赖项,不会出现大量 version 和 integrity 变更。如果变更巨大,说明基础依赖树已经被重新拉平了,建议恢复 lockfile 后重新用更精准的方式删。
6. 高级技巧:从“删包”升级为“依赖健康管理”
6.1 用 overrides 处理无法直接删的传递依赖
有时无用的包不是顶层依赖,而是传递依赖。比如你明明没装过 minimist,但它出现在依赖树里。这种情况不要直接去删,因为删掉一个无关紧要的包会破坏其他包的依赖声明。
正确方式是用 overrides 覆盖版本或者排除特定子依赖:
json复制{
"overrides": {
"minimist": "^1.2.7"
}
}
但它并不是“无用依赖清理”的核心手段。先用 npm ls minimist 看依赖来源,再判断能否在源头上升级或替换上游包。
6.2 定期执行依赖清理巡检
把依赖清理变成季度任务,建议每次巡检按固定流程推进:
- 运行
npm outdated看版本过期情况。 - 运行
npx knip检查未使用文件、未使用依赖。 - 对照生产日志判断是否有“运行时才 require”的动态引用。
- 用干净环境(删除 node_modules + lockfile 重建)验证整体可用性。
- 完成一次线上发版,让干净依赖接受真实流量验证。
很多团队只做第 1 步和第 2 步,忽略了第 4 步和第 5 步。事实上,不少“清理后一切正常、上线后偶发报错”的情况,都是因为本地遗留的 node_modules 帮代码兜了底。
6.3 monorepo 场景下的额外注意
在 pnpm/npm workspaces monorepo 里,删依赖比单包更危险。一个 devDependency 可能在 packages/a 的脚本里作为二进制被调用,但扫描入口如果只盯着 packages/b 的 src,自然判定它是无用的。
因此 monorepo 团队建议:
- 不要轻易在 sub-package 里手动删除 devDependencies;
- 优先用根目录的 workspace 工具统一管理公共构建工具;
- 删除包前用
pnpm why <package>查看依赖来源与引用方:
bash复制pnpm why typescript
这条命令会输出依赖关系链,比 grep package.json 可靠得多。
7. 最后分享几个我长期坚持的小习惯
关于正确归类和无用依赖清理,我自己平时一直沿用几个原则,这里一并分享出来。
第一,安装任何包之前先问一句:它会在“构建时”还是“运行时”被用到?一时分不清就翻官方文档,看安装命令示例。如果文档里写的是 npm install --save-dev,你就别自作主张改成普通安装。很多包的 README 对安装类型是有明确区分的,尤其测试框架、类型声明、打包工具这几类。
第二,删包时千万不要“凭感觉”。我见过有人对着 package.json 看了一会儿,觉得 uuid 好像没用到就删了,结果线上某个生成订单号的接口瞬间报错。先跑 depcheck、再用 grep 双保险,删除后立刻跑干净安装和完整测试,这比事后回滚成本低得多。
第三,我会在 CI 里保留一个 check:deps 脚本,专门跑 depcheck,并且让误报白名单显式维护在独立文件里。这样每次新增依赖后,如果开发者忘了调用,下一个人会在合并前看到提示。
第四,lockfile 是团队资产,删除依赖后的 lockfile 变更必须 review。如果一次清理导致 lockfile 里出现上百行版本变动,说明你可能触发了依赖树重解析,建议立刻回滚,改用手工分步删。
依赖管理不是“能跑就行”的细枝末节。生产镜像、CI 缓存、团队协作心智、漏洞面大小,全都被这两行字段影响着。在这个问题上多花几分钟,后续能省下来的时间远不止几十分钟。
