1. 四种安装方式,一次说透
先说明一下:这篇补的就是npm依赖管理里最容易让人迷糊的那张表——全局安装和本地安装有什么区别,开发依赖和生产依赖又怎么分。很多新人装包的时候就是一路npm install走到底,装完了也不知道包到底装到哪儿了,代码托管上去之后别人跑不起来,或者部署到服务器上发现依赖全部丢失。这些问题十有八九就是全局安装和本地安装没分清、devDependencies和dependencies放错了位置。
我自己刚接触npm那会儿也吃得亏不少。今天就把这块掰开揉碎了讲清楚,包括每个命令背后的设计逻辑、package.json和package-lock.json各自扮演什么角色,以及实际团队协作中最容易踩的坑。适合刚接触前端工程化的新人,也适合那些用了很久npm但一直没系统梳理过依赖管理逻辑的开发者。
其实npm的安装规则并不复杂,核心就两条:要不要加-g决定装到哪儿,用--save/--save-dev决定怎么记录。搞懂这两条,后面的所有问题都顺理成章。
2. 全局安装与本地安装
2.1 两个安装位置的物理区别
先看它们分别装到了哪里。本地安装执行npm install <包名>时,包会下载到当前项目目录下的node_modules文件夹里,同时被记录到当前项目的package.json。全局安装执行npm install -g <包名>时,包会被放到系统级的Node模块目录下,不在任何一个项目内部。
全局安装目录在不同环境下有差异。Linux和macOS常见路径是/usr/local/lib/node_modules或~/.npm-global(取决于你是否配置过prefix),Windows则一般在C:\Users\你的用户名\AppData\Roaming\npm\node_modules。想知道确切位置,在终端输入npm root -g就能看到本机的全局安装路径。
这个物理位置差异直接决定了两个特性:项目之间能否共享依赖,以及依赖版本是否由项目自身控制。全局安装的包只有一个难copy版本,所有项目都共用它;本地安装的包则完全归当前项目所有,版本锁定、升级都不会波及别的项目。
2.2 什么样的包才值得全局安装
这不是判断题,是取舍题。我总结的全局安装适用条件是:这个工具是跨项目使用、并且以命令行CLI形式被调用的。典型例子包括:
npm自身的管理工具(如npm的扩展工具nrm、pnpm)- 代码格式化工具(如
prettier全局命令) - 脚手架工具(如
create-react-app、@vue/cli)
它们有一个共同特点:项目里并不会有代码require它们,而是直接在终端敲命令调用。这类工具装在全局,好处是任何目录下都能直接使用,不需要每个项目单独安装一份。
但这里有个容易踩的陷阱:即使工具是全局安装的,项目本身不应依赖全局环境来构建。比如你用全局的某个转译工具去构建项目,别人克隆你的项目之后,发现全局没装这个工具,构建直接失败。这就是为什么现在更推荐用npx,它允许你在不全局安装的情况下临时调用某个CLI工具,后面第三节展开讲。
2.3 全局安装的两个隐患
全局安装最大的问题之一是版本冲突。比如项目A需要某个CLI工具的1.x版本,项目B需要2.x版本,全局只有一个版本,必然有一方受影响。这类冲突在长期维护多个项目时非常普遍,光靠记版本号很难管理。
第二个问题是环境污染。全局包的堆积会让系统目录越来越臃肿,还可能与操作系统的包管理器产生混乱。我见过某台开发机的全局目录下累积了几十个工具,有些版本和项目构建要求对不上,排查问题时非常头疼。
实际操作中,我的推荐策略是:默认使用本地安装,只有工具在绝大部分项目里都需要、且其功能纯粹是命令行辅助时,才考虑全局安装。
3. 开发依赖与生产依赖
3.1 一条命令背后的分类逻辑
只要不走全局,剩下的就是本地安装。本地安装时,npm会把包记录到package.json的dependencies或devDependencies字段中,这两个字段分别对应生产依赖和开发依赖。
dependencies:项目运行所必需的依赖,生产环境也要装的包。devDependencies:只在开发构建阶段用到的依赖,比如测试框架、打包工具、本地开发服务器等。
命名的区分来自--save和--save-dev两个参数(也可写作-S和-D):
# 写入 dependencies(生产依赖) npm install express --save # 或 npm install express -S # 写入 devDependencies(开发依赖) npm install jest --save-dev # 或 npm install jest -Dnpm 5.0.0 之后,npm install <包名>默认就会写入dependencies,--save已经不是必须加的参数了,但很多老教程仍然习惯带上,无伤大雅。真正要养成习惯的是--save-dev,因为默认不会写入devDependencies,少写了会直接放错位置。
3.2 判断一个包属于哪一类
判断标准不是"它在我的开发中起不起作用",而是"去掉它之后,项目在线上还能不能正常运行"。
拿实际项目举例:一个用Vue构建的前端项目,核心的vue、vue-router、axios这些包,线上页面离了它们就无法渲染,属于生产依赖;而webpack、babel、eslint这些构建和校验工具,它们在代码打包、编译阶段完成使命,部署线上时只留下构建产物,运行时并不需要它们,因此属于开发依赖。
再细分一层:有些包既是中间工具又是外部服务的SDK。比如一个云存储的Node客户端库,你的后端在线上需要用它上传文件,那就必须装到dependencies;但是如果你只是在测试脚本里用它,仅测试环境使用,也可以放devDependencies。核心还是看生产环境运行时的真实需求。
3.3 放错位置的实际后果
把生产依赖误放进 devDependencies,后果是部署或镜像构建时执行npm install --production(或NODE_ENV=production时)后,包没被安装,线上代码运行时报Cannot find module。
把开发依赖误放进 dependencies,后果相对不那么严重:生产环境仍然能跑,但会安装大量没必要的包,导致部署体积变大、安装时间变长、甚至引入冗余的安全风险。对成本和效率敏感的项目,这是个不小的负担。
我自己的经验:拿不准的时候,看这份依赖是否参与"代码的运行期",而不是"代码的构建期"。构建期包括编译、转译、压缩、校验,运行期就是代码真正跑业务逻辑的时候。归类的那一刻问自己一句——去掉这个依赖,线上代码还能启动吗?能启动就是开发依赖,不能启动就是生产依赖。
4. package.json、lock文件与node_modules的三角关系
4.1 package.json:声明依赖的清单
package.json里的dependencies和devDependencies只是"声明",它们并不直接决定最终安装哪个精确版本。这里的关键是语义化版本号(Semver)。
{ "dependencies": { "axios": "^1.6.0", "lodash": "~4.17.21" } }^1.6.0表示允许安装 1.x.x 中不低于 1.6.0 的最新小版本,但不跨大版本。~4.17.21表示允许安装 4.17.x 中不低于 4.17.21 的版本,但不跨小版本。- 不带符号的
4.17.21则锁定精确版本。
这个设计原本是为了兼容性,但也埋下了一个隐患:同一份package.json,在不同时间点执行npm install,装到的具体小版本可能并不一样。某功能在某小版本次更新中被修复或破坏,团队内部就可能出现"我这里能跑、你那里报错"的诡异现象。package-lock.json就是为了解决这个问题而出现的。
4.2 package-lock.json:锁定安装现场
npm 5 之后,每次npm install都会自动生成或更新package-lock.json。它记录的是解析后的真实版本树,包括每个依赖的具体版本号、下载地址、依赖之间的嵌套关系。只要lock文件存在,npm install就会严格按照它去安装,而不是重新按照package.json的语义化范围去解析。
这意味着什么?它让团队成员和部署环境的依赖版本达到完全一致。项目开发前务必执行一次npm install生成lock文件,并且把lock文件提交到代码仓库。
有一个很容易踩的坑:很多人会把node_modules提交到Git忽略列表,同时却把package-lock.json也顺手提交了。前者没问题,后者要具体看项目和团队策略。对于应用型项目(可执行的网站、服务端程序),我强烈建议提交lock文件;对于库型项目(给其他人引用的npm包),有些团队选择不提交lock文件,让使用方按自己的环境解析。如果你不确定,对于应用型项目先提交。
4.3 node_modules:依赖的实际存放地
node_modules是依赖真正落地的地方。npm 在安装时会根据package.json和package-lock.json的声明,在项目根目录下生成这个目录。它的结构可能不是扁平化的——由于不同依赖之间又互相依赖,npm 会尽可能把公共依赖提升到顶层,遇到版本冲突时再嵌套存放在子目录里。
正常情况下,node_modules不应该被手动修改,也不应该被提交到版本库。它只是依赖解析后的产物,删除之后重新执行npm install即可还原。之前遇到过个别同学直接改node_modules里的文件来修复bug,改完当时能用,但一旦团队其他成员重新安装依赖,修改就全部丢了。正确的做法是把需求提升到源码层面,修改项目代码,或者通过patch-package这类工具管理补丁。
还有个实操细节:npm install执行时用的是模块缓存,网络正常的情况下安装速度通常不慢。但是想要干净环境时,很多人会直接删掉整个node_modules再装——如果项目依赖很少,这种操作开销不算大;如果依赖上千个,每分钟的冷安装时间会非常可观。这时可以先尝试npm ci,它专门为CI环境和需要干净安装的场景设计,会严格按lock文件执行并自动清理旧依赖,速度通常比手动删除再install更快。
5. 常用命令组合与典型场景
5.1 开发一个项目的完整安装步骤
假设我们新起一个前端项目,核心流程大致如下:
# 项目初始化 npm init -y # 安装生产依赖 npm install react react-dom # 安装开发依赖 npm install -D webpack webpack-cli # 安装某个特定版本的包 npm install lodash@4.17.21 # 卸载某个包(默认同时移除package.json里的记录) npm uninstall lodash # 只卸载开发依赖 npm uninstall -D webpack # 查看当前已安装依赖 npm list --depth=0这里补充一个细节:npm uninstall不加参数时,会直接移除node_modules中的包并更新package.json中的对应记录,不需要单独指定--save。但卸载开发依赖时,还是建议明确加-D参数,避免误删生产依赖的记录。
5.2 npx:一个替代全局安装的更优解
npx是npm自带的一个命令执行工具,它可以让你不用全局安装、也不用在脚本里写死路径,就能直接运行某个npm包的命令。
典型场景:大家都用某个脚手架工具,但不想全局安装它,因为不同项目的模板结构可能对应不同版本。
# 直接创建项目,用完即走,不留全局污染 npx create-react-app my-app执行原理是:npx会先在本项目的node_modules/.bin中查找这个命令,找不到就去npm仓库临时下载并执行。它有两个好处:一是避免全局版本冲突,二是你可以在不修改全局环境的前提下尝试工具的新版本。实际项目中,很多团队已经开始把原来需要全局安装的CLI工具,全部迁移到npx+ 本地安装的组合上,降低环境依赖。
5.3 生产环境如何安装依赖
部署到服务器时,通常会区分环境和跳过开发依赖:
# 只安装生产依赖(不装 devDependencies) npm install --production # 或先设定环境变量再安装 NODE_ENV=production npm install另一个常见的部署场景是使用npm ci:
# 严格按lock文件安装,适合CI npm cinpm ci与npm install的区别是:npm ci会先删除现有的node_modules,然后根据package-lock.json完全还原,不会修改lock文件,速度也更快。前提是项目里存在lock文件,如果lock文件和package.json不同步,它会直接报错并提示你执行install来同步。
5.4 从零迁移项目的技巧
旧机器换新、或接手一个老项目时,最稳妥的操作顺序是:
# 1. 检查node版本(有些依赖对Node版本有要求) node -v # 2. 确认npm版本(不同版本的npm处理依赖的策略有差异) npm -v # 3. 删除可能存在的过期node_modules rm -rf node_modules package-lock.json # 4. 重新安装 npm install这里有个经验教训:直接npm install有时候会因为旧lock文件中的下载源失效而失败。这时很多人的第一反应是换成镜像源,但我不建议长期依赖修改registry的方式,因为可能引入包不一致的风险。比较务实的做法是先删掉lock文件重新解析,还不行再考虑调整registry,并且通过.npmrc文件把镜像源的配置固化到项目里,避免每次手动改环境变量。
6. 常见问题与排查技巧实录
6.1 全局安装后命令找不到
表现:执行某个命令提示command not found,但明明已经全局安装过了。
排查思路:
- 确认包确实全局安装成功:
npm ls -g --depth=0 - 确认全局bin目录在PATH环境变量中,可以执行
npm bin -g查看路径,Windows下可能需要把对应目录加到系统PATH。 - 如果是macOS/Linux,注意某些版本的npm全局安装目录路径带空格或权限受限,常见解决方案是配置
npm config set prefix ~/.npm-global,再把这个目录加入PATH。
这个坑的根源是npm全局bin目录没有被shell识别。我在macOS上遇到过几次,最后都是通过手动配置PATH解决的。
6.2 本地安装了工具,终端却无法直接使用
本地安装的CLI工具,比如项目的ESLint,它的命令入口会出现在node_modules/.bin/目录下。你直接在终端敲eslint大概率找不到命令,因为终端并不会默认寻找当前项目的.bin目录。
解决办法有三种:
- npm脚本:在
package.json的scripts字段中定义"lint": "eslint src",再执行npm run lint,npm会自动把node_modules/.bin加入 PATH。 - npx调用:直接
npx eslint src。 - 手动指定完整路径:
./node_modules/.bin/eslint src。
推荐用第一种,因为npm run脚本会注入node_modules/.bin到PATH,既能精确控制版本,又兼容跨平台。
6.3 误把开发依赖装成了生产依赖
不小心把某个工具包安装成了生产依赖,改回来很简单:
# 先卸载 npm uninstall eslint # 再重装为开发依赖 npm install -D eslint如果是反过来的,把生产依赖误装成了开发依赖,同样的思路:卸载后重新安装,但不带-D,默认写入dependencies。这类问题通常可以通过审查package.json的字段及时发现。
6.4 版本不一致导致的"在我这里能跑"
出现这种情况,优先检查lock文件是否已提交。仓库里如果存在package-lock.json,但团队成员安装时没有按lock文件执行,或者有人手动加锁了某个低版本依赖,都会出现版本漂移。排查时先让出问题的机器执行npx npm-check-updates看依赖版本差异,或者直接对比lock文件的diff。
经验值:一个活跃开发的项目,每两周至少执行一次npm outdated检查依赖更新情况,并在升级依赖的同时单独提交lock文件,方便后续回溯。
7. 我个人在实际操作中的几个习惯
最后分享几个我自己用的比较顺手的习惯。装上依赖后,我会第一时间看一眼package.json里对应包被写到了哪个字段,尤其是当一条命令同时安装多个包时,npm install -D后面的包如果混用了,很容易出现漏写-D的情况。后来我习惯把生产依赖和开发依赖分成两次命令来装,降低失误概率。
第二点是node_modules一旦出现莫名其妙的问题,先别急着骂npm,多数时候是lock文件与package.json不一致导致的。执行npm ci会比手动删目录再install更快更稳。
第三点:多使用npx。全局安装应该说不是禁止使用,而是要克制的使用。我现在只有像npm本身的管理工具、或者极少数跨项目必须统一步调的工具,才会考虑全局安装,其余CLI工具一律本地安装配合npx调用。这样把每个项目的依赖环境完全隔离,换机器、换同事、换服务器,都不再是隐患。
npm的依赖管理实际就是一个"选位置"和"打标签"的过程。选对位置,包就能按预期使用;打对标签,部署和协作就不会翻车。希望这篇补充分享能帮你把那层模糊的窗户纸捅破,以后装包的时候心里更有数。