每年年初我都习惯干一件事:把老项目package.json里的 npm 包清单从头到尾读一遍,问一句“这个包现在还在用吗”。前几年问完这句基本就结束了,因为很多包虽然忘了是干嘛的,但拆起来心里没底。2026 年不一样,Node 运行时和浏览器原生能力已经补齐了大部分历史欠账,现在发一个 npm 包的门槛很低,但比发布更重要的,是知道什么时候该把包从项目里删掉。这篇不聊大道理,就聊我最近实际从几个项目里删掉的 5 个包,以及每一步怎么验证、怎么替换。
1. 2026 年做依赖减负,底气从哪来
1.1 每多一个 npm 包,就多一份看不到的成本
很多团队对依赖的态度是“能用就不动”,这种心态我完全理解,毕竟依赖升坏了比依赖老着更麻烦。但你不能忽略另一个事实:npm 包从你 install 那一刻起,就一直在产生开销。首先是安装时间,一个中型 Web 项目,node_modules里往往躺着小几千个包,随便一个依赖的版本变动,CI 上就要多等几十秒。其次是安全审计,npm audit每次报出一堆漏洞,一大半来自传递依赖,而你根本不知道它们是被哪些包带进来的。最麻烦的是供应链风险,这两年业内出了不少针对流行包的可疑事件,少一个依赖,攻击面就小一圈。
我在清理项目时重新翻了一遍package.json,发现很多依赖都是四五年前为了某个小功能加的。当时加的时候觉得挺合理,后来说不上有用,也一直没敢删。2026 年回头看,这已经不是“要不要顺手清理”的问题,而是实打实的维护债。
1.2 运行时和浏览器把历史欠账补上了
以前装包不是因为我们懒,是因为平台真的没有。Node 早期连 fetch 都没有,读.env要装 dotenv,删目录要装 rimraf,生成 UUID 要装 uuid 包,深拷贝要依靠 lodash。这些在今天完全是另一回事:Node 18 开始内置全局 fetch,Node 20.6 开始支持--env-file,fs.rm和fs.mkdir早就支持递归操作,node:crypto模块里直接有randomUUID,structuredClone已经是标准全局函数。前端浏览器侧也一样,Intl的日期格式化能力已经很强,URLSearchParams、AbortController、可选链、空值合并这些标准 API 把 lodash 的很多常用函数降级成了“为了兼容老浏览器而存在”的东西。
所以这篇文章的底气不是来自某一次升级,而是来自“平台能力已经追上来”这个事实。你只需要在本地跑一遍验证,然后把依赖清单瘦下去。
2. dotenv:Node 自己会读 .env 了
2.1 一条命令切换原生方案
第一个建议删的是 dotenv。如果你只是拿它来加载项目根目录的.env文件,Node 20.6 开始自带的--env-file完全可以替代。启动命令从:
node -r dotenv/config dist/index.js改成:
node --env-file=.env dist/index.js写进package.json的 scripts 时也能直接拼:
{ "dev": "node --env-file=.env --watch src/index.js", "start": "node --env-file=.env dist/index.js" }如果代码里需要主动加载,Node 21.7 之后提供了process.loadEnvFile,不需要再调dotenv.config()。Node 22.9 还多了个--env-file-if-exists参数,适合那种“有本地配置文件就加载,没有也别报错”的团队环境。
我迁移时最舒服的一点是本地开发行为和 CI 对齐了。CI 里本来就用环境变量注入,本地用--env-file,两个环境之间的差异变得可预测。另外 Node 和 dotenv 默认都不会覆盖环境里已经存在的变量,这个默认行为两边是对齐的,所以迁移时不用担心“原来能覆盖,现在不能”之类的坑。
2.2 哪些场景仍然值得保留 dotenv
但我也要替 dotenv 说句公道话:原生方案覆盖的是 80% 场景,剩下 20% 别硬切。比如你要根据NODE_ENV动态决定加载.env.development还是.env.production,原生的--env-file只能写在启动命令里,做不了代码里的动态判断。虽然你可以用process.loadEnvFile在代码里自己判断,但行为还是不如dotenv.config({ path })来得顺手。
另一个场景是.env文件里用了比较复杂的变量展开,比如DB_URL=$BASE_URL/db这种互相引用的写法。虽然 dotenv 本体也不做展开,要靠额外的 dotenv-expand 包,但整个生态链已经成熟,原生能力在这方面还比较素。我的判断标准很简单:如果代码里只出现一次dotenv.config(),没有任何动态路径,删掉是稳的;一旦出现两次以上或者路径带参数,说明你的用法已经超出原生支持范围,不用为了删而删,留着它反而更清晰。
3. uuid:crypto.randomUUID() 已经够你用了
3.1 从装包到原生,只改一行
uuid 包在 npm 上的周下载量常年是天文数字,但它可能是最没有存在意义的一个。Node 从 14.17 开始就有crypto.randomUUID(),浏览器侧 Chrome 92、Safari 15.4、Firefox 95 也都支持了。2026 年你面对的不管是 Node 22/24 还是现代浏览器,原生 UUID 生成能力都是默认存在的。
后端代码里把:
import { v4 as uuidv4 } from 'uuid'; return uuidv4();换成:
import { randomUUID } from 'node:crypto'; return randomUUID();然后全局替换,项目里就再也不用维护这个包了。前端也一样,直接用crypto.randomUUID(),还顺手少了一个需要被打包工具处理的依赖。
有人担心随机 UUID 的碰撞率,这个担心在业务系统里基本是多余的。v4 版本的随机 UUID 有 122 位随机性,你在生产环境里硬编码也生成不出能碰撞的数量级。与其纠结碰撞概率,不如先把包删了,把精力留到真需要 v7 那种时间排序 ID 的场景再说。
3.2 别把 v4 的用途和整个 uuid 包画等号
不过有一个前提要说清楚:能删的是你用到了 v4 的情况。uuid 包除了 v4 还有 v1、v3、v5 和后来的 v7,这些各有各的用途。v1 依赖时间戳和节点信息,v5 需要命名空间和自定义哈希,如果你的业务真用到了这些变体,那原生randomUUID顶不上,继续留着包。
但话说回来,我翻过的老项目里,99% 的 uuid 使用场景就是“给数据库主键、订单号生成一个不会重复的字符串”,这种需求randomUUID完全覆盖。还有团队会用 uuid 包提供的validate和version方法做校验,这种也可以自己写个正则,或者只在测试代码里做一次逻辑判断,没必要让整条依赖链为了两个小函数服务。
4. axios:换成 fetch 不是激进,是回归
4.1 fetch 从实验到默认,已经够成熟
axios 的核心价值在过去很扎实:浏览器里封装了麻烦的 XMLHttpRequest,Node 里补齐了当年缺失的 HTTP 客户端。但这两个动机现在都不成立了。Node 18 开始全局 fetch,Node 21 以后标记为稳定,2026 年大家的主力版本基本都跑在 Node 22 或 24 上。浏览器更不用说,fetch 本身就是 Web 标准。
也就是说,过去你需要 axios 才能做到的一件事是“用统一的 API 在前后端写网络请求”,今天原生 fetch 就是这个统一 API。这项迁移并不激进,我见过不少项目从 axios 切到 fetch 之后,依赖少了,代码里对响应数据的处理反而更直接。
4.2 迁移清单和差异逐个对照
真正劝退团队的通常是拦截器、超时和错误处理。我整理了一张对照表,实际操作时思路很清晰:
| axios 能力 | 原生 fetch 对应方案 |
|---|---|
| 统一 baseURL | 封装一个 request 函数,内部用new URL(path, baseURL) |
| 请求超时 | AbortSignal.timeout(5000),老环境再包一层 AbortController |
| 请求/响应拦截器 | 在统一的 request 函数里做预处理和后处理 |
| 400/500 自动抛错 | 原生不抛错,需要在封装层检查response.ok,手动 throw |
| 自动序列化 params | 用URLSearchParams自己拼 |
| 上传进度 | 原生 fetch 没有现成事件,这个场景可以继续用 axios |
我最常用的写法是做一个request包装函数,把超时、错误检查、JSON 解析全部放在里面,业务代码只负责调用和 catch。这样团队迁移后的使用方式和以前几乎一样,认知负担很小。
这里要重点说明一个容易踩的坑:fetch 只有在网络层失败时才会 reject,比如 DNS 解析失败、连接被重置;HTTP 状态码 404、500 都会正常返回 Response,不会走到 catch 分支。axios 不是这样,它会把非 2xx 状态码一并当作错误抛到 catch 里。迁移之后如果业务代码里到处是“在 catch 里处理 401/500”的逻辑,你会发现这些分支全失效了,必须通过response.ok或response.status重新判断。
4.3 迁移时真正容易翻车的地方
实际操作里,最容易翻车的还不是上面这些,而是三种习惯性用法。第一是 axios 默认会自动解析 JSON 响应,fetch 需要自己res.json(),写漏了就变成 Promise 套 Promise。第二是超时语义,axios 不设 timeout 就是无限等,fetch 默认也是无限等,但很多人以为 fetch 自带超时,结果某个接口爬到一半挂死了,页面一直转圈。要给 fetch 配上AbortSignal.timeout(5000)才等价于 axios 的 timeout。第三是请求拦截器里经常会做 token 注入,切到 fetch 之后,这层逻辑要记得搬到统一封装里,否则会出现登录态失效但没人发现的诡异问题。
5. lodash:不是整个删,而是先删掉 90%
5.1 常用函数其实都有原生等价物
lodash 是最微妙的一个,因为它不是“不能删”,而是“不能无脑全删”。如果你项目里只用了一小部分数组、对象方法,那现在删除的成功率很高。原生语法和标准库这些年补齐了大部分:
_.map、_.filter、_.forEach直接用数组方法;_.includes用Array.prototype.includes;_.uniq用[...new Set(arr)];_.keyBy用Object.fromEntries(arr.map(item => [item.id, item]));_.cloneDeep用structuredClone;- 深层取值用可选链和空值合并;
_.assign用展开运算符或Object.assign;- 判断属性用
Object.hasOwn。
这套替换做完,你会发现 lodash 在代码里真正的使用量可能不到原来的 20%。剩下的_.get、_.set、_.debounce、_.throttle才需要逐个单独判断。我见过最典型的误判是:团队因为_.get的一个用法,把整个 lodash 保留了下来,实际上那个用法完全可以用可选链替代。
5.2 真正阻碍删除的是 debounce 这类工具
剩余的那些 lodash 特有工具,才是留下来的理由,其中出现频率最高的是debounce和throttle。这两个函数在原生 API 里没有等价物,自己手写虽然不难,但边界情况多,输入框防抖和滚动节流我见过太多写崩的版本。
我的做法是:项目里如果有debounce/throttle的明确使用,就保留lodash-es这个按模块拆分的版本,用import { debounce } from 'lodash-es',让打包工具只打进这两个函数。如果项目里完全没有debounce/throttle,只有 map/filter/uniq 这类,就直接把整个 lodash 删干净。判断方法很简单:全局搜.debounce(和.throttle(,没有就放心删,有就保留按需引入。
5.3 lodash 的强类型问题
还有一个容易忽略的点:TypeScript 项目里删 lodash 往往比 JS 项目麻烦。因为 lodash 的某些方法承担了“类型体操”的角色,比如_.get在深层嵌套对象上取值时,类型推断比手写一堆括号要直观。原生可选链也能做到类型收窄,但写法习惯变化大。
我的建议是别期望一步到位。先在代码里把类型上依赖 lodash 的地方改成自定义类型守卫或可选链,跑完类型检查确认干净,再删包。依赖减少是结果,不是目标;把 TypeScript 编译期错误清干净之后,删除就是顺手的事。
6. rimraf 和 mkdirp:文件操作早已原生
6.1 一个 API 干翻两个老牌包
rimraf 和 mkdirp 属于“老祖宗级”的 npm 包。但 Node 很早就有原生能力了:fs.rmSync(path, { recursive: true, force: true })从 14.14 就有,fs.mkdirSync(path, { recursive: true })从 10.12 就有。所以这两个包删起来基本没有缝隙。构建脚本里的"clean": "rimraf dist"改成:
{ "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"" }或者干脆写个scripts/clean.mjs文件,用fs.rm加fs.cp处理更复杂的清理任务。比如既要删dist,又要把public里的静态资源拷过去,一个脚本全搞定,不用在package.json里堆一堆带引号的 shell 命令。
6.2 顺带把文件操作家族一起收拾
这类可以一起收拾的还有 ncp(递归拷贝目录),Node 16.7 开始有fs.cp原生方法,支持递归拷贝。过去在 npm scripts 里做“拷贝 dist 到 release 目录”这种事,现在一行node -e就解决。
另一个容易忽略的收益是跨平台。rimraf 之所以流行,很大一部分原因是它在 Windows 上也能安全地递归删目录,而rm -rf在 Windows cmd 里根本跑不起来。现在fs.rm本身就是跨平台的,clean 脚本一旦只需要node -e就能跑,就不再依赖 shell 语法,Windows 和 Linux 上的行为完全一致。这也是为什么我现在建议把所有构建脚本里的 rimraf、mkdirp、ncp 全部换成原生node:模块调用。
7. 删包之前,先过一遍这几道检查
7.1 先看谁在用,再看有没有替代
动手之前最忌讳的是打开package.json看到哪个删哪个。正确顺序是先跑npm ls 包名(pnpm 是pnpm why)看依赖从哪来,再全局搜项目代码里 import/require 这个包的地方。很多包是被框架或另一个包间接依赖的,直接删dependencies里的顶层声明,反而会触发 peerDependencies 警告。
我建议先删最干净的三个:dotenv、uuid、rimraf/mkdirp,它们通常是直连依赖,搜一下出现次数就清楚。axios 和 lodash 要谨慎一些,因为业务代码里出现频率高,迁移时要配合类型检查和单测结果来验证。
7.2 一次删一个包,每删一次跑一遍全量验证
我见过很多团队一口气把五个包全删了,然后测试挂了三四片,再也不知道是哪个包引起的。正确做法是一次只删一个包,跑一遍npm run typecheck && npm run test && npm run build,确认绿了再删下一个。每次只改一个包,package-lock.json的 diff 也更容易 review。
另外记得看一眼前后node_modules的体积和安装耗时,这个数据哪怕不发周报,自己也心里有数。我这段时间清理一个中型项目的实际数据是:包数量从 842 降到 764,安装耗时下降约 30%,npm audit的高危项从 7 个降到 2 个。这个对比非常有说服力,之后申请做类似清理也会更容易过。
7.3 几个平时没人提的细节点
最后分享几个小细节。一是别忘了检查类型包,比如@types/uuid要跟着 uuid 一起删,否则 TypeScript 里还是会多一个隐式依赖。二是如果代码里原本用import { v4 } from 'uuid',替换成randomUUID()之后会遇到测试代码要 mock 的问题,网上很多 mock 方案都是针对 uuid 包的,换成原生后要改为 mocknode:crypto模块,建议提前把测试环境里的 mock 适配好。
三是 monorepo 场景要格外小心。如果你在 pnpm workspace 里用--env-file,注意每个子包的启动脚本都要带上各自的.env路径。Node 的--env-file按当前工作目录解析文件路径,如果你在仓库根目录跑子包的 dev 脚本,很容易读到根目录的.env。以前用 dotenv 时大家习惯在各子包代码里调dotenv.config(),行为比较隐式,删包之后建议显式传路径:
node --env-file=packages/foo/.env --watch packages/foo/src/index.js这类差异在 monorepo 里最容易翻车,我建议团队里先在共享的工具函数或根目录scripts里统一封装启动命令,再动手删。
还有一个顺手的建议:如果你之前用 nodemon 做开发热重启,Node 18 之后的--watch参数已经够日常用了,官方还标着 experimental,但我们在项目里跑了小半年没出过问题;moment 这种老牌日期库在大部分项目里也早该被Intl和原生 Date 替换。这算是我这个清单之外的 Bonus。2026 年做依赖清理的窗口期确实已经到了,趁项目功能稳定的时候做,比业务高峰期磨刀稳妥得多。