1. 从“为啥要加 -D”说起
在 npm 的命令行参数里,-D是被问得最多的一个,也是最容易被“想当然”的一个。我记得刚带项目那阵,经常有同事跑过来问:安装命令里为什么要加-D,不加行不行?加了之后东西还能正常运行吗?这类问题背后其实是一个核心知识点没打通:npm 的依赖分为生产依赖和开发依赖两类,而-D就是让 npm 把这个包标记为开发依赖的快捷指令。这篇文章会把这件事讲透,内容包括-D与--save-dev的关系、devDependencies与dependencies的底层区别、如何验证安装结果,以及在 Windows 环境中常见的 npm 报错处理。
先说一句最直接的结论:-D是--save-dev的简写,安装时加上它,npm 会把包写入package.json的devDependencies字段。不带-D的普通安装,则会写入dependencies。这两个字段一个表示“生产环境也要用的依赖”,一个表示“只在开发构建阶段才需要的依赖”。很多新手在两种依赖之间分不清,于是安装时要么全都不加参数,要么全部加上-D,结果部署时要么构建工具一堆,要么运行依赖缺失,真正到了线上才发现问题。
这篇文章主要写给三类人:刚开始学 npm,想把npm install的参数彻底搞清楚;已经写了一阵子项目,但搞不清为什么有些包要加-D、有些不用,部署时总觉得依赖安装很臃肿;以及被 Windows 上各种 npm 报错折腾得头疼,想一次性把常见问题都解决掉的人。接下来我会先讲原理,再带你在命令行里做几次真实对比,最后把 Windows 下常见的 npm 报错也一起梳理掉。内容不复杂,但每一步我都会把背后的原因说清楚,这样你以后遇到类似问题,也能自己推断出答案。
2. -D 做了什么事:开发依赖与生产依赖的分工
2.1 一个 package.json 里为什么要有两个依赖清单
先看一个最基础的package.json:
{ "name": "demo", "version": "1.0.0", "dependencies": { "axios": "^1.6.0" }, "devDependencies": { "webpack": "^5.89.0", "typescript": "^5.2.2" } }dependencies清单里的包,是应用在运行的时候真正需要的。比如 axios 用于发请求,vue/react 用于渲染页面,如果生产环境没有这些包,程序跑起来就会直接报错。而devDependencies里的包,是你在写代码、构建、测试阶段用的工具,比如 webpack、typescript、eslint、jest 这些,最终线上运行的是它们打包、编译之后的产物,工具本身并不出现在运行现场。这就像你要做一顿饭:面粉、蔬菜、肉是最终要进锅的食材,而菜刀、砧板、燃气灶是做饭过程需要的工具。菜刀不会跑到菜里去,但你做饭时确实离不开它。
npm 之所以要区分这两类,核心目的是为了在不同场景下“按需安装”。如果你是应用开发者,部署环境不需要安装 webpack、typescript,只需要把最终产物和服务端代码拷过去,再装上运行时依赖就能跑。如果你是 npm 包作者,区分两个字段就更加重要:使用者安装你的包时,npm 会自动安装dependencies里的依赖,但不会安装devDependencies。简单说,dependencies是“别人欠你的债”,devDependencies是“你自己的工具台”。
很多人会觉得,反正npm install会把两个字段的依赖都装齐,那分不分开有什么区别?问题就在于,并非所有场景都会执行完整的npm install。CI、生产镜像、自动化部署往往只安装生产依赖,这时候依赖分类就决定了线上环境是清爽还是臃肿。项目越大、团队越多人协作,这个区别就越明显。
2.2 --save-dev、--save 和参数的历史演变
老版本 npm 有个容易让人头大的行为:npm install不会自动把包写进package.json,你得手动加--save才会记录到 dependencies。所以当时的开发者经常记混--save和--save-dev,还要额外操心版本范围符号的写法。从 npm 5 开始,npm install <包名>默认就会写入dependencies,于是日常命令行简化成了现在这样:
npm install axios:安装并且写入dependencies,等价于npm install axios --save,也可以显式写成npm install axios -S。npm install webpack -D:安装并且写入devDependencies,等价于完整写法npm install webpack --save-dev。npm install -g xxx:全局安装,不写入项目 package.json。npm install xxx -O:写入optionalDependencies,表示可选依赖。
用表格看更直观:
| 参数 | 等价写法 | 写入 package.json 的字段 | 适用场景 |
|---|---|---|---|
| 不带参数 | --save | dependencies | 运行时依赖,比如 axios、react |
-D | --save-dev | devDependencies | 开发工具,比如 webpack、typescript |
-O | --save-optional | optionalDependencies | 可选依赖,较少见 |
-E | --save-exact | 写入精确版本号 | 希望锁定精确版本时使用 |
这里有个容易踩的坑:很多人以为-D只是“开发的时候装一下”的意思,于是把所有依赖都加-D,结果部署后应用直接白屏,因为运行时依赖根本没进生产环境。反过来,把所有东西都丢进 dependencies,生产环境安装时就会装一堆用不到的编译工具,既慢又占空间,还可能扩大安全扫描的范围。正确区分这两个字段,是 Node.js 项目工程质量的一部分,不只是命令行技巧。
2.3 判断该用 -D 还是该普通安装
我自己的判断标准很简单,装一个包之前先问自己:这个包在最终运行的进程里还会被用到吗?
- 如果会,例如 axios、lodash、react,用普通安装(
npm install),写入 dependencies。 - 如果不会,例如 webpack、babel、jest、eslint、typescript,用
npm install -D,写入 devDependencies。 - 如果是 CLI 工具,像
vue-cli、create-react-app这类,通常全局安装或临时用npx调用,不要写进项目依赖。
还有一个更严谨的场景:如果你在开发一个会发布给其他人使用的 npm 包,那就不能只看“运行时”这一个标准。对于使用你包的人来说,凡是他们import你的包时连带使用的依赖,都要放进 dependencies;而只是你开发时用来测试、编译的依赖,放进 devDependencies。如果你把一个构建插件误放进了 dependencies,使用者安装你的包时就会被迫多装一堆他们根本不需要的模块,拉低安装速度,还可能引发依赖冲突。
如果项目里已经装错了,把某包装到了 dependencies,想挪到 devDependencies,可以这样做:
npm uninstall <包名> npm install <包名> -D先卸载再安装,保证 package-lock.json 里的记录是干净的,不要只手动改 package.json,否则容易留下版本不一致的问题。我见过有人直接改文件,结果 lock 文件和 package.json 对不上,CI 上跑出来的依赖树和本地完全不一样,排查了大半天,最后只能全部重装。所以这种基础操作,还是走命令行最稳。
3. 实操:用命令验证 -D 到底改变了什么
3.1 从零开始,观察一条带 -D 的安装命令
空口说原理不如亲手验证一遍。我建议你随便建一个临时目录,比如mkdir npm-d-demo && cd npm-d-demo,然后执行npm init -y生成一个最基础的 package.json。接着执行:
npm install lodash -D命令执行完之后,打开 package.json,你会看到:
{ "name": "npm-d-demo", "version": "1.0.0", "devDependencies": { "lodash": "^4.17.21" } }能看到 lodash 被写到了devDependencies,而不是dependencies。node_modules 里当然也能找到 lodash,也就是说在开发阶段它确实能被正常引用。差别不在于“能不能用”,而在于它被 npm 标记成“开发期依赖”了。如果你再执行一次npm install lodash(不带 -D),package.json 的 dependencies 里也会多出一条 lodash,同时 node_modules 里可能因为版本相同而不会重复下载,但 package.json 的两个字段会发生明显变化。
从这里也能引出一个常见的疑问:既然开发阶段两种安装方式都能用,那是不是无所谓?当然不是。真正的区别要等到别人从你的仓库拉代码、你在 CI 里构建、或者部署到生产环境时才会显现出来。开发阶段是“全量依赖”环境,你装了什么都影响不大;但生产环境讲究“最小依赖”,缺一个运行库会挂,多一个构建工具则是浪费。
3.2 模拟生产环境:看 devDependencies 会不会被安装
依赖分类最核心的验证方式是使用生产模式安装。在项目里执行:
npm install --production或者新版 npm 更推荐的写法:
npm install --omit=dev这条命令的含义是“只安装生产环境需要的依赖,跳过 devDependencies”。你在刚才那个临时项目里执行它,然后去看 node_modules,会发现 lodash 根本不在。因为这个项目唯一一个依赖被标记成了 devDependencies,所以在生产模式下直接就被过滤掉了。
如果项目里既有 dependencies 又有 devDependencies,你可以在生产模式下看到 node_modules 里只保留前者,后者全部不装。这个机制对部署特别有价值。很多项目的 node_modules 动辄几百 MB,其中一大半是编译工具、测试框架,而这些在生产环境中完全不需要。用对了-D,部署时就能省下大量的下载时间和磁盘空间,也能降低安全扫描范围。
顺带一提,npm ci和npm install的区别也在这里体现。npm ci会严格按照 package-lock.json 安装,速度更快,也更稳定。在 CI 环境里,构建阶段一般用npm ci安装全量依赖,运行阶段用npm ci --omit=dev安装生产依赖。这两个指令配合-D的分类,形成了非常清晰的流水线。
3.3 依赖混淆之后的处理:从 dependencies 挪到 devDependencies
实际操作中,依赖未分类的情况经常发生。我之前接手过一个项目,dependencies 里躺着 babel、webpack、eslint 这一大堆开发工具,package.json 看起来像一座小山。这类依赖在生产环境安装时会被全部拉下来,部署时间长得离谱,而且每次运行npm audit都会扫出一堆开发工具的安全告警,让人分不清哪些问题真正影响生产环境。
解决办法分两步。第一步,先把这些依赖移除:
npm uninstall webpack babel-loader eslint第二步,再把它们以开发依赖的身份装回来:
npm install -D webpack babel-loader eslint这里有一点要注意:如果版本有特殊要求,最好在安装时直接指定版本号,比如npm install -D webpack@^5.89.0,避免 npm 解析到最新版导致构建行为变化。如果你已经手动改过 package.json,又懒得重新安装,也可以直接编辑文件后执行npm install来更新锁文件,但我个人不推荐这种做法,因为容易漏掉某个包在 lock 文件里的传递依赖信息,手动改完经常会出现版本对不上的问题。
处理完之后,可以用npm ls --depth=0看一眼顶层依赖列表,确认它们都属于正确的字段。也可以打开 package.json,检查dependencies里是否还残留着仅开发期使用的工具。这个检查在项目交接、code review 时很有用,我一般会在新成员提的第一个 PR 里帮他们检查一遍依赖分类情况。
3.4 -D 在 CI/CD 里的真实作用
部署流程通常有两个阶段:构建阶段需要完整的依赖,包括 devDependencies,因为要跑 webpack、tsc 这些工具;运行阶段只需要 dependencies,因为跑的是构建后的产物。如果没有-D的正确分类,CI 流水线就很难做到“构建依赖”和“运行依赖”的分层。
在实际的 Dockerfile 里经常能看到这样的操作:
FROM node:18 AS build WORKDIR /app COPY package*.json ./ RUN npm ci RUN npm run build FROM node:18 AS production WORKDIR /app COPY --from=build /app/dist ./dist COPY package*.json ./ RUN npm ci --omit=dev CMD ["node", "server.js"]先用npm ci安装全部依赖完成构建,再用npm ci --omit=dev只装上运行时依赖作为最终运行镜像。这个多阶段构建之所以能成立,前提就是项目里的依赖被正确分到了 dependencies 和 devDependencies。如果开发工具全被塞进了 dependencies,那第二阶段的--omit=dev就形同虚设,生产镜像会白白增大几百 MB。
我之前遇到过一个小项目,原本 docker 镜像体积 1.2GB,后来仔细一看,node_modules 里光是 typescript、webpack、各种 loader 就占了差不多 800MB。把依赖分类理顺之后,运行镜像直接从 1.2GB 降到 400MB 左右。对于频繁发布的应用,这节省的不只是存储空间,还有每次推送镜像、拉取镜像的时间成本。这也是为什么我会说,-D这个选项虽然小,但用对了能让整个交付链路都受益。
4. 常见问题与排查技巧实录
4.1 PowerShell 下报错:无法加载文件 npm.ps1
Windows 上踩到这个坑的人特别多。你在 PowerShell 里运行npm,结果弹出一行红色错误:
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1, 因为在此系统上禁止运行脚本。这不是 npm 坏了,也不是 Node.js 没装好,而是 PowerShell 的执行策略限制了脚本文件运行。npm.ps1 本质上是一个 PowerShell 脚本,默认策略可能不允许当前用户执行它。解决办法是查看并修改执行策略:
Get-ExecutionPolicy Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUserRemoteSigned的意思是:本地创建的脚本可以运行,从网上下载的脚本必须带有可信签名,这是比较平衡的策略,比Unrestricted安全得多。执行设置后按Y确认,然后重新打开一个终端就能正常使用 npm 了。
如果公司电脑被组策略锁死了,无法修改执行策略,还有一个应急办法:不在 PowerShell 里用 npm,改用 CMD,或者在 Windows Terminal 下拉选项里打开 Command Prompt,直接运行npm.cmd。也可以用npm.cmd -v先验证一下 npm 命令本身是否可用。这个问题的本质是执行策略和脚本权限,和-D参数没有冲突,但它的出现频率实在太高,我觉得有必要放在常见问题里一起讲掉。
4.2 安装时报 npm ERR! cannot read properties of null (reading 'edgesout')
这个报错我遇到过不止一次,尤其是 npm 7 之后,项目里的 node_modules 或者 package-lock.json 状态异常时,很容易触发。报错信息看起来像是 npm 内部在构建依赖树时读到了 null,实际原因多是缓存损坏、依赖树不一致或者旧版本 npm 的 bug。
常规修复步骤是:
rm -rf node_modules package-lock.json npm cache clean --force npm install如果你的项目很大,重装 node_modules 耗时较长,也可以先只删 package-lock.json 再npm install,很多时候就能恢复。如果还不行,就考虑升级 npm 本身:
npm install -g npm@latest升级后清除缓存再重装一次。这是我实测下来最稳定的处理顺序。需要注意,删除 node_modules 不会影响 package.json 里的依赖声明,所以重装之后只要 package.json 是正确的,所有依赖都会恢复。如果你是在执行npm install lodash -D时遇到了这个报错,重点先检查是不是包名写错了、registry 是否正常,然后再考虑缓存和锁文件问题。
4.3 安装依赖时出现 npm WARN deprecated,要不要处理?
执行npm install时如果看到类似这样的日志:
npm warn deprecated node-domexception@1.0.0: use your platform's native DOMException instead这表示被安装的某个包所依赖的node-domexception已经被作者标记为废弃,推荐使用环境自带的 DOMException。这类警告在小项目里很常见,一般不影响运行,但不能完全无视。因为文件被标记 deprecated,往往意味着存在已知问题或不再维护,长期留在依赖树里存在潜在风险。
处理思路是先找到是谁在依赖它:
npm ls node-domexception查看依赖树,然后升级对应的上层包,比如把form-data、axios等包更新到新版本,通常就能消除警告。如果升级后警告还在,说明某个包还没同步更新,这时可以根据项目情况决定是继续等待上游修复,还是换成其他替代库。
还有一种情况是警告来自你直接安装的-D包,比如某个测试框架的旧版本被标记 deprecated。这时代替方案是直接升级该工具到最新版本,或换一个维护更活跃的同类工具。总之,看到 deprecated 不要慌,但要记下这个信号,尽快排查,而不是看着一排黄色警告熟视无睹。
4.4 国内网络环境下的 npm 安装问题:换源与 PATH 配置
还有一个经常和-D一起出现的问题,就是安装速度极慢,或者安装时不断超时。这通常不是-D参数的问题,而是默认下载源在国外。我一般会先把 npm 镜像源切换成国内镜像,例如:
npm config set registry https://registry.npmmirror.com执行完后可以用npm config get registry确认。如果你只想临时用一次,也可以不修改全局配置,而是在安装命令里加:
npm install lodash -D --registry=https://registry.npmmirror.com这样更干净,适合不想改动全局配置的场景。顺便提一下“npm 不是内部或外部命令”的问题:如果终端提示找不到 npm,说明 Node.js 安装目录没有加入系统 PATH。重新安装 Node.js 时勾选“Add to PATH”,或者手动把C:\Program Files\nodejs加进环境变量,然后重开终端。这个现象和-D本身无关,但很多初学者在配置环境时就会卡住,连npm install都执行不了,更别说加参数了。
4.5 常用排查命令速查
| 问题场景 | 推荐排查命令 |
|---|---|
| 看某个包被装到哪里 | npm ls <包名> |
| 查看全局配置 | npm config list |
| 查看 registry 地址 | npm config get registry |
| 清理 npm 缓存 | npm cache clean --force |
| 按 package-lock 精确安装 | npm ci |
| 只装生产依赖 | npm install --omit=dev |
| 检查顶层依赖 | npm ls --depth=0 |
5. 一点个人习惯和补充
写了这么多,最后还是想再分享一点我在真实项目里的习惯。现在每装一个包,我都会下意识地在脑子里过一遍“这个包在最终运行的进程里还会被用到吗”。像 webpack、typescript、eslint、prettier、jest 这些,全部用-D安装,一个都不往 dependencies 里放。对于 axios、vue、react、lodash 这类运行时要用的库,再普通安装到 dependencies。
有时我也会用npm install -D搭配-E,也就是--save-exact,把开发工具固定到精确版本,避免团队里其他人执行npm install时自动装上一个小版本更新,导致行为不一致。业务依赖可以适当用波浪号范围,但构建工具我倾向于锁死版本,省得哪天 CI 上的构建结果和本地不一样,排查起来非常痛苦。
另外,我建议在每个项目里做一次依赖体检。打开 package.json,逐个问自己:这个包是给用户用的,还是给开发者用的?如果一个包模棱两可,就去看文档里它的使用场景;如果一个依赖只在scripts里出现,那是很典型的 devDependency,应该加-D。这个体检过程花不了多少时间,但能避免绝大多数部署事故。
依赖分类这件事,看起来只是安装命令末尾一个字母的区别,但它会实时影响部署体积、CI 构建速度和团队协作的稳定性。养成随手用对-D的习惯,比事后做一堆依赖清理要省心得多。希望这篇对你有用,也欢迎去看一看你自己项目里的 package.json,看看那些 devDependencies 是否真的都加对了地方。