2026 年,很多项目的 package.json 还是一副臃肿的样子:几百个依赖躺在里面,其中真正在业务代码里 import 过的,可能不到三分之一。每次npm install跑完,node_modules 动辄几百 MB,npm audit一检又是一堆 outdated 提醒,看着就头大。
这两年我陆续清理过几个中大型前端和 Node 项目,发现一个很明显的趋势:早些年必须靠 npm 包才能搞定的能力,现在 Node.js 和浏览器原生已经给得很完整了。有些包当年是神兵利器,放到 2026 年再看,反而成了必须维护的"历史包袱"——引入它们不仅增加安装体积、升级风险,还得在脚手架和 CI 里多养一个依赖。
这篇文章我把真正可以动手删掉的 5 个 npm 包整理了出来,每个都给了原生替代方案、版本要求和实操中的坑。适合正在做依赖瘦身、想要降低项目复杂度、或者单纯被 node_modules 体积逼疯的开发者参考。
1. 先说结论:2026 年,哪些包真的可以删了
先说清楚,我不是无脑"零依赖"党。像 React、Vue、Express 这种核心框架不在讨论范围里,因为它们解决的问题本身足够复杂,自研替代的成本远高于收益。但我下面列出的这 5 个包,属于"原生能力已经覆盖了 90% 以上使用场景"的典型,大多数项目删掉之后几乎感觉不到变化。
| 包名 | 典型用途 | 原生替代方案 | 备注 |
|---|---|---|---|
| rimraf | 递归删除目录/文件 | fs.rmSync/fs.promises.rm | Node 14.14+ 可用 |
| dotenv | 加载 .env 环境变量 | node --env-file/process.loadEnvFile() | Node 20.6+ 可用 |
| nodemon | 文件变更自动重启 | node --watch | Node 18.11+ 可用,22 稳定 |
| uuid | 生成 UUID | crypto.randomUUID() | Node 14.17+ / 现代浏览器 |
| axios(部分场景) | HTTP 请求 | 原生fetch | Node 18+ / 所有现代浏览器 |
为什么是这 5 个?我判断一个包该不该删,主要看三条标准。
第一,原生 API 的功能覆盖度是否足够。如果一个包 80% 的用法在原生环境里都有等价写法,那就值得认真评估替换;如果只是 API 风格不同,比如 Promise 回调的写法差异,那更是可以平滑迁移。
第二,这个包是否已经成为"维护负担"。很多包本身没问题,但它会带一堆传递依赖,比如早期版本 rimraf 会依赖 glob、graceful-fs 等一长串包,每次npm audit都提示这个那个要升级。删掉一个包,等于顺便清掉了一整棵依赖子树。
第三,替换的迁移成本是否可控。如果替换涉及大规模重写业务代码,那不管原生多好都先放一放;如果替换只需要改一个工具函数、一个配置项,那就是动刀的最佳时机。下面这 5 个都符合"迁移成本低、收益明显"的特征。
2. 逐个拆解:5 个包的替代方案与实操细节
2.1 用 fs.rmSync 替换 rimraf
rimraf 几乎是前端项目的标配工具,主要用途就一个:递归删除文件或目录。比如打包前的 dist 清理、测试临时目录清理、构建脚本里的rm -rf跨平台替代。当年它的存在是因为 Node.js 的fs.rmdir对非空目录直接报错,想删整个目录只能自己递归遍历,或者靠 rimraf 这种 C 库封装。
但 Node 14.14.0 开始,原生fs.rmSync就支持了recursive: true和force: true,Node 16 之后更是稳定可用。也就是说,你完全可以在构建脚本里这样写:
import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true });这一行就等价于老的 rimraf 用法。force: true对应rm -rf里的-f,意思是目标不存在时也不抛错。我见过不少人的构建脚本还在专门调rimraf ./dist,其实换成上面的写法,不仅能少装一个包,还能避免 rimraf 在某些环境下 shell 命令解析带来的 Windows 兼容性问题。
如果你习惯用 Promise 风格,那就用fs/promises的rm:
import { rm } from 'node:fs/promises'; await rm('dist', { recursive: true, force: true });我实际在几个项目里替换后,package.json的 scripts 从这样:
{ "clean": "rimraf dist && rimraf coverage" }变成了这样:
{ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true}); require('fs').rmSync('coverage',{recursive:true,force:true})\"" }不过说实在的,直接写在node -e里可读性很差。更推荐放在项目根目录的scripts/clean.mjs这种统一脚本里,或者用 Node 22+ 支持的node --run配合一个简单的清理函数,维护起来会舒服很多。
注意:
fs.rmSync的recursive选项在 Node 14 里还带实验性警告,Node 16 之后才彻底稳定。如果你的 CI 镜像还停留在 Node 14.13 以下,建议先升级运行时再删 rimraf,别为了省一个包把线上构建搞挂了。
2.2 用 --env-file 替换 dotenv
dotenv 是另一个"曾经必需、现在可以删"的典型。它的作用是从项目根目录的.env文件里读取键值对,注入到process.env中。几乎所有 Node 项目都会用,尤其是微服务、后端服务、需要区分开发/测试/生产环境的场景。
Node.js 从 20.6.0 开始原生支持了--env-file参数,20.12.0 又补了--env-file-if-exists。用法非常简单,原来你在入口文件里写:
import 'dotenv/config'; const port = process.env.PORT ?? 3000;现在可以直接在启动命令里指定:
node --env-file=.env src/index.js如果.env文件可能不存在(比如 CI 环境用真实环境变量注入),那就用:
node --env-file-if-exists=.env src/index.js如果项目用的是 CommonJS,进入文件同样直接读process.env就行,不需要任何改动。更关键的是,Node 20.12+ 还提供了一个运行时 APIprocess.loadEnvFile(),可以在代码里按需加载:
import { loadEnvFile } from 'node:process'; loadEnvFile(); // 等价于 node --env-file=.env 的运行时版我在一个 Express 老项目里验证过,把dotenv删掉、启动命令加上--env-file后,日志、配置读取、各种环境分支全部正常。唯一要注意的是,原生--env-file默认不会覆盖已存在的环境变量,这一点和 dotenv 的默认行为一致,所以临时在 shell 里注入变量覆盖配置的用法不受影响。
注意:如果你项目里用了类似
dotenv-expand的功能(.env文件里支持变量引用,比如DB_URL=$BASE_URL/db),原生--env-file目前不支持这种扩展语法。这种情况可以保留 dotenv,或者自己在加载后做一层字符串替换。判断标准很简单:你的.env里有没有$引用?没有就直接删。
2.3 用 node --watch 替换 nodemon
本地开发时改完代码自动重启服务,几乎成了 Node 开发者的肌肉记忆。nodemon 是这十年来最流行的方案,但它本质上就是"监听文件变化 + 杀掉旧进程 + 拉起新进程"。Node.js 18.11.0 开始试验性地内置了--watch模式,Node 22 之后标记为稳定,不需要任何额外依赖。
原来你这么启动开发服务:
{ "scripts": { "dev": "nodemon src/index.js" } }现在直接:
{ "scripts": { "dev": "node --watch src/index.js" } }node --watch默认监听入口文件及其依赖的整个模块图,也就是你项目里被import或require过的文件,改了都会触发重启。这一点其实比 nodemon 默认行为更精准,因为它不会因为你在src目录里放了一个没被引用的临时文件就重启服务。
还有一个好用的小参数叫--watch-path,可以指定额外监听的路径,比如:
node --watch --watch-path=./config src/index.js这样只会监听config目录下的文件变化,适合配置文件独立于源码目录的场景。
我自己的体会是,node --watch在大多数 Node 服务、脚本、CLI 工具开发场景里已经完全够用。但有几个 nodemon 才有的功能它暂时没有:比如 "delay" 防抖配置、按扩展名忽略文件(默认只 watch 模块图,某些.env变更不会触发)、以及当子进程崩溃时的--exitcrash行为差异。如果你的开发工作流重度依赖这些特性,可以再观望;但如果只是"改了代码自动重启",那完全没有必要继续养着一个依赖。
注意:
node --watch是在 Node 进程层级做文件监听,不是 shell 级别的while循环。监听的文件句柄在 macOS 和 Linux 上默认够用,但如果你用 Docker 挂载宿主机目录到容器里开发,文件系统事件可能不会正确传递,这种情况 nodemon 的轮询模式反而更可靠。容器开发场景先验证再替换。
2.4 用 crypto.randomUUID 替换 uuid
uuid 包是"小工具依赖"的典型代表。很多项目引入它只是为了生成不重复的 ID,可能整个代码库里就一行uuid.v4()。这种"为了一个函数引入一个包"的模式,恰好是原生crypto.randomUUID()最适合终结的。
Node.js 从 14.17.0 开始全局提供了crypto.randomUUID(),现代浏览器从 2021 年起也全部支持。用法和uuid.v4()完全等价:
import { randomUUID } from 'node:crypto'; const id = randomUUID(); // 格式:xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx浏览器环境更简单,直接用全局crypto.randomUUID()就行,不需要 import。
我最近重构一个内部工具项目,把uuid删掉后,代码改动量大概是 0——因为项目里已经封装了一个genId()工具函数,只需要改里面的实现。大多数项目如果把uuid的调用点都收敛到一个工具文件里,替换成本会低到忽略不计。
这里要提醒一下:如果你用的是uuid包里的非 v4 版本,比如v1(基于时间戳)或者v3/v5(基于命名空间哈希),那原生randomUUID就不完全等价,因为它是纯随机 v4。实际业务里 99% 的场景用 v4 就够了,但如果有"同一输入必须生成同一 ID"的幂等需求,那还是需要保留 uuid 或者换一个专门的实现。
注意:
crypto.randomUUID只在安全的上下文(secure context)里可用。浏览器如果通过http://非 localhost 地址访问页面,全局crypto.randomUUID可能不存在。Node 服务端不受这个限制。前端项目替换前先确认一下部署环境是否走 HTTPS。
2.5 用原生 fetch 重写 axios 的常用场景
axios 是这 5 个里面最有争议的,我先说清楚边界:axios 没有死,也没必要全项目推翻重写。但它确实有一个非常典型的"重度使用场景"——只把 axios 当 fetch 用——这种场景现在可以完全用原生 fetch 代替。
Node.js 18 开始内置了全局fetch,浏览器更是不用说。如果你的 axios 用法停留在下面这个层面:
const res = await axios.get('/api/users'); const data = res.data;等价的原生 fetch 写法是:
const res = await fetch('/api/users'); const data = await res.json();看起来只是 API 风格差异,但考虑到 axios 打包体积、拦截器机制带来的复杂度、以及它在 Node 环境里底层依赖http模块、浏览器里依赖XMLHttpRequest的双实现成本,能少一个包也是实打实的收益。
更关键的是,新的运行时特性都往 fetch 生态倾斜。比如 Node 的undici底层一直在优化,fetch的性能和稳定性逐年提升;而 axios 在 Node 端仍然有自己的适配层。我实测过一个内部服务,把 axios 改成 fetch 后,依赖树减少了十几个包,启动速度也快了 100ms 左右。
如果你已经用了原生 fetch,但有拦截器、取消请求、超时控制这些需求,也完全可以自己封装,不必依赖 axios:
async function request(url, options = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), options.timeout ?? 5000); try { const res = await fetch(url, { ...options, signal: controller.signal, }); if (!res.ok) { throw new Error(`HTTP ${res.status}: ${res.statusText}`); } return res.json(); } finally { clearTimeout(timeout); } }这已经覆盖了大部分人用 axios 时的核心诉求:统一前缀、超时、错误处理、自动 JSON 解析。
那什么时候不该删 axios?我的判断标准是:如果项目里已经大量使用 axios 的拦截器(请求拦截、响应拦截)、onUploadProgress上传进度、取消令牌机制,或者团队里所有人都熟悉 axios 的生态写法,那强行重构反而引入不必要的风险。这种情况下留着它没什么问题。axios 的问题是"过于常用导致大家忘了可以不用",而不是它本身有多糟糕。
3. 替换不是无脑删:版本差异、行为边界与渐进迁移
3.1 先看环境:Node 版本与调用位置
上面提到的 5 个原生替代,都依赖具体的 Node.js 版本。我把版本要求整理成了下面这张表,方便你直接照着查:
| 依赖包 | 原生替代 | 最低 Node 版本 | 推荐 Node 版本 |
|---|---|---|---|
| rimraf | fs.rmSyncrecursive | 14.14 | 16+ |
| dotenv | --env-file | 20.6 | 20.12+ |
| dotenv | process.loadEnvFile() | 20.12 | 21.7+ |
| nodemon | --watch | 18.11 | 22+ |
| uuid | crypto.randomUUID() | 14.17 | 16+ |
| axios(部分) | 全局fetch | 18 | 20+ |
替换前第一步,不是直接npm uninstall,而是先检查项目实际运行的 Node 版本。怎么看?
node -v如果项目有.nvmrc或.node-version文件,也别只看那个,确认 CI 里的node-version配置和 Dockerfile 里的基础镜像版本。我踩过最大的坑就是本地 Node 22 跑得好好的,CI 环境还在 Node 16,npm install之后跑node --env-file直接报 "bad option",当场回滚。
还有一个被忽略的点:调用位置。如果你的项目是纯前端(Vue/React 构建产物部署到 Nginx),那 Node 版本只影响构建过程,不影响运行时。比如rimraf只在构建脚本里用,那替换后只要构建环境的 Node 版本够新就行,线上用户完全感知不到。反过来,如果是 Node 服务端项目,删包后要重点验证的是生产环境的 Node 版本,而不只是开发机。
3.2 边界行为:转换后的坑
原生 API 和第三方包不是 100% 等价,替换后最容易踩的是下面这几个边界差异。
第一个坑是rimraf和fs.rmSync的报错行为。rimraf 默认会重试、会忽略某些 EPERM 错误,在 Windows 上还有文件句柄延迟释放的兼容逻辑。fs.rmSync遇到文件被占用会直接抛EBUSY/EPERM。我在 Windows 上清理 dist 目录时遇到过,因为打包进程没完全退出,fs.rmSync直接失败,而 rimraf 能靠重试机制扛过去。解决方案是删包前在 Windows CI 上先跑一遍完整构建流程验证。
第二个坑是dotenv和--env-file对引号、注释的处理细节。dotenv 支持.env文件里的单引号、双引号、export前缀等多种写法,原生--env-file的解析规则更严格,遇到不合法行会直接抛错,而不是像 dotenv 那样宽容。如果你的.env文件里写了类似KEY=hello world(没有引号)的格式,dotenv 能解析,但原生--env-file可能会因为空格报错。替换后启动时会立刻暴露问题,反而帮助你清理历史债。
第三个坑藏在fetch和 axios 的默认行为差异里。axios 对非 2xx 状态码会走catch分支,而fetch只有网络层错误才 reject,HTTP 404、500 都能正常 resolve。如果你在业务代码里写的是:
try { const res = await axios.get('/api/item/1'); // 这里假设 res.data 一定存在 } catch (e) { // 错误处理 }直接改成 fetch 之后,404 响应就进不了catch,而是返回一个res.ok === false的 Response 对象。不改错误处理逻辑的话,很容易出现"接口 404 但页面还继续渲染"的问题。我建议所有 fetch 封装里都像上面那样,先检查res.ok,不行就手动 throw。
3.3 渐进式替换的实操路径
不要试图在一个周末把所有包全删完,那是给自己找麻烦。我推荐的路径是四步走。
第一步,先做基线验证。替换前把项目的关键流程跑一遍,记下启动命令、构建命令、核心接口的返回数据。不需要自动化测试套件,只要保证"改动前和改动后,项目能正常跑起来"就行。
第二步,从影响最小的包开始。影响最小的是uuid,因为它就是一个纯函数替换,通常只涉及一个工具文件。其次是rimraf,一般只出现在 scripts 和 CI 脚本里。这两个包完成了,项目就已经开始变轻。
第三步,处理开发体验类替换。dotenv和nodemon属于开发/部署链路,替换后会改变启动命令,需要更新package.json的 scripts、README、还有团队内部的开发文档。这类改动建议单独提交,方便出问题时回滚。
第四步,最后评估 axios。axios 的替换涉及业务代码改造,要按模块分批来。先挑一个页面或一个服务,把 axios 调用换成 fetch 封装,对比响应数据和错误处理,确认没有回归后再铺开。
每完成一步,都跑一次npm uninstall加npm install,确保 lockfile 干净。
4. 删包的正确姿势:清理依赖与 npm 命令常见坑
4.1 一次干净的卸载流程
很多人的npm uninstall用得不够干净,删完包之后 node_modules 里还残留着一堆无效的传递依赖。我自己的标准流程是:
npm uninstall rimraf dotenv nodemon uuid axios npm install npm auditnpm uninstall不仅会从package.json移除依赖,还会更新package-lock.json,把对应的传递依赖从依赖树里摘出去。之后跑一次npm install,是为了让 lockfile 重新收敛,避免出现 "phantom dependency"(幽灵依赖)——也就是 package.json 里已经删了,但 node_modules 里因为缓存还能 require 到的情况。
如果你的项目用的是 pnpm,删除逻辑也类似,但额外推荐跑一下:
pnpm prune它会清理掉 node_modules 中不再被任何依赖引用的包。pnpm 的硬链接机制下,pnpm install本身也会回收无用包,但prune更彻底。
删完包之后再打开package.json,你会发现 scripts 也要跟着改。搜索项目里还在引用这些包的文件,尤其是scripts/目录下的构建工具和 CI 里的命令。一个常见的遗漏是cron任务或者 pre-commit hook 里写的rimraf,删掉包之后这里的代码还留着就会直接报Cannot find module 'rimraf'。
4.2 npm 命令 5 个高频报错的速查与解决
删包和换包的过程中,npm 自身的安装问题往往比替换逻辑更容易卡住。我把这几年工作中最常遇到的 npm 报错整理成了一张速查表,基本覆盖了热搜里频繁出现的那几个:
| 报错/症状 | 常见原因 | 解决办法 |
|---|---|---|
npm : 无法加载文件 ...\npm.ps1,因为在此系统上禁止运行脚本 | PowerShell 执行策略默认限制.ps1脚本 | 以管理员身份运行Set-ExecutionPolicy RemoteSigned -Scope CurrentUser,或改用cmd执行 npm 命令 |
'npm' 不是内部或外部命令,也不是可运行的程序 | Node.js 安装后环境变量 PATH 未配置或未刷新 | 重新安装 Node.js 并勾选 "Add to PATH";或手动把C:\Program Files\nodejs\加入 PATH,重开终端 |
npm WARN deprecated node-domexception@1.0.0: use your platform's native DOMException | 某个传递依赖使用了旧包,常见于form-data/@tootallnate/once等 | 属于无害警告;升级相关依赖或更新 lockfile 可消除,但不影响安装结果 |
npm ERR! code ERESOLVE/ERESOLVE overriding peer dependency | peerDependencies 版本冲突,常见于 React/Vue 插件与框架版本不匹配 | 检查冲突包,手动统一版本;必要时用npm install --legacy-peer-deps临时绕过,但不要长期依赖 |
npm ERR! code EBUSY | 构建或安装时文件被占用,Windows 上最常见 | 关闭所有 Node 进程(taskkill /F /IM node.exe或重启 IDE),再重试npm install |
热搜里出现频率很高的node-domexception警告,很多人以为是自己项目的问题,其实它来自form-data这个包的老版本,原生环境早就支持DOMException了。这类 deprecated 警告在删包之后会自然消失一部分,尤其是删掉 axios 这种会传递引入form-data的包。
至于 PowerShell 报 "禁止运行脚本",本质是 Windows 默认的Restricted执行策略不允许任何.ps1脚本运行,npm 的 wrapper 恰好是用 PowerShell 写的。这里有个更轻量的做法:不用改全局执行策略,直接在项目里用npm.cmd代替npm,比如npm.cmd run dev。或者干脆用 nvm-windows 管理 Node 版本时选nvm use自动切换 PATH,也能避开一部分问题。
4.3 依赖瘦身之后怎么守住
删完这 5 个包不是终点。依赖是"熵增"的,只要你还在开发,package.json就会慢慢膨胀。我给自己和团队定了三条规则,实测能守住很长时间。
第一,新增依赖必须过审。哪怕最终决定要引入新包,也要先回答三个问题:原生 API 能不能做?手写 50 行函数能不能做?有没有更轻的替代包?回答不上来就不允许加。这不是限制自由,而是防止"装包一时爽,维护火葬场"。
第二,定期看一遍依赖树。我习惯每季度跑一次npm ls --depth=0,看看顶层依赖有哪些明显已经不用的;再用depcheck这类工具把"已安装但从未被 import"的包列出来,逐个确认。很多项目最大的浪费不是版本老旧,而是根本不再使用的包还一直留在依赖里。
第三,注意环境镜像源的问题。国内开发者经常遇到npm install慢或下载失败,热搜里"npm 换源""npm 国内镜像源"也一直热门。换源本身没问题,但团队协作时最好在项目根目录放一个.npmrc,统一镜像地址和 registry,避免不同人的安装结果不一致:
registry=https://registry.npmmirror.com只修改镜像源不影响依赖版本,但一定要保证 lockfile 里的版本号稳定。npm ci是 CI 里更推荐使用的命令,它会严格按照 lockfile 安装,不会因为本地node_modules残留产生偏差。
5. 最后说点掏心窝的话:有的包该删,有的包不该省
我见过两种极端。一种是把所有依赖都视为罪恶,恨不得把项目改成零依赖;另一种是明知道某个包只用一个函数,却因为"大家都这么写"就一直留着。2026 年的现实是,Node.js 原生能力已经足够强,很多当年必须靠生态补足的基础设施已经内置,继续背着十几个包跑完全没必要。
但我也要泼一盆冷水:删包这件事,成本最低的其实是新建项目,而不是老项目。如果你手里是一个维护了三年的老系统,里面到处都是import axios、require('uuid')、启动命令写死在 README 里,那强行一口气清理反而容易引入回归。我自己的经验是"边改边删",碰到某个文件需要重构时,顺手把它的第三方依赖改成原生写法,而不是专门花两周做一次"依赖清理专项"。这样既不会影响业务节奏,又能让代码库越来越干净。
还有一个建议:别为了删包而删包。比如某个包虽然也可以删,但团队里五六个项目都在用它,你在这个项目里删掉、在另一个项目里保留,长期维护反而更累。这类情况不妨先在团队内部统一技术决策,达成共识后再全域推进。依赖清理的本质是降低认知负担和运维成本,而不是比拼谁的项目 dependency 数量更少。
我个人在实际操作中的体会是:删掉 rimraf 和 uuid 是最没痛感的,删掉 dotenv 需要改一下启动方式,删掉 nodemon 需要适应一下重启日志,而 axios 的替换最需要谨慎。如果你现在正被 node_modules 的体积和npm audit的告警困扰,不妨从这 5 个包里挑一个最小的开始。很多时候,一个包从package.json里消失,带来的不只是几百 KB 的磁盘空间,而是团队里少了一个需要维护、需要升级、需要担心漏洞的"变量"。