上周帮一个老项目做技术债清理,打开 package.json 那一刻,我愣住了——dotenv、nodemon、rimraf、uuid、moment.js整整齐齐躺在 dependencies 里。这些包在五六年前确实是标配,但在 2026 年再看,它们基本都属于“该退休”的范畴了。Node 运行时和浏览器原生能力这些年补课补得相当凶,以前必须求助于第三方包的功能,现在标准库直接就能干,而且干得还不差。
这篇文章不会劝你把所有依赖都删光,那属于极端洁癖。我只想把这 5 个有明确原生替代方案的包拆开揉碎,告诉你它们为什么可以退场、替换成什么、替换过程有哪些坑。适合正在维护老项目、或者新建项目时想控制依赖数量的人参考,看完基本可以照着操作。
1. 为什么 2026 年这些 npm 包可以名正言顺地删掉
1.1 运行时在追赶,原生能力补齐了
过去我们装这些包,本质原因是 Node 和浏览器的标准能力不够。以环境变量为例,Node 在 20.6.0 之前确实没法在启动时直接加载.env文件,所以dotenv几乎成了每个 Node 服务的必装品。再比如文件递归删除,早期fs.rmdirSync对嵌套目录完全无能为力,rimraf就成了跨平台删目录的唯一答案。
但 2026 年的现状是:Node.js 18 已经成为老古董,20 和 22 是绝对的主流,24 LTS 也已经在生产环境跑了相当长时间。三个大版本走下来,很多能力被原生补齐了。浏览器这边同样如此,fetch、randomUUID、Intl这些 API 早已全面普及。当平台本身提供了等价的、甚至更安全的方案时,继续往node_modules里塞第三方包,其实是在给自己增加风险面。
我之前遇到过一个事故,一个老服务因为某个间接依赖被报出高危漏洞,但那只是个生成随机 ID 的库,而代码里根本没用它。这就是典型的“依赖膨胀带病运行”——你为用不上的历史包袱承担安全责任。删掉能够被原生能力替代的包,是整个依赖树瘦身里性价比最高的一步。
1.2 依赖少一个,隐患少一截
有人可能会说:这些包很小,留着也不碍事。表面看确实如此,但你得考虑三个隐性成本。
首先是安装成本和体积。任何一个包进入node_modules,都会带来自己的传递依赖。moment.js光是自己的包体就超过 300KB,加时区数据更大,安装时还会拉取更多模块。其次是供应链安全。每多一个包,就多一层被投毒、被劫持、被植入恶意代码的风险面。npm 生态每年都会爆出各种违规包事件,而这类依赖清单里躺了几年的老包,恰恰是最容易被忽略的审计盲区。最后是心智负担。新接手项目的同事会疑惑:这个包到底在哪用了?写代码的时候倾向于继续用它,于是它就被“用”到了永远。
原生替代方案还有一个隐藏优势:它们不产生传递依赖,API 遵循平台标准,几乎没有被废弃的可能。把第三方包删掉、换成平台自带能力,你的代码五年后翻开来看依然能跑,这是第三方库做不到的。
2. 第一批退役:环境变量、热重载、目录清理
2.1 dotenv 可以删了,用 node --env-file
dotenv在很长一段时间里是 Node 项目加载.env文件的唯一选择,老教程里几乎都是“先npm i dotenv,然后在代码入口require('dotenv').config()”的固定写法。但现在真的不需要了。
Node.js 从 20.6.0 开始支持--env-file启动参数,直接从.env文件加载环境变量。22.9.0 之后又多了一个--env-file-if-exists,文件不存在时也不会报错。以我现在的习惯,项目启动命令是:
{ "scripts": { "start": "node --env-file=.env src/index.js", "start:prod": "node --env-file=.env.production src/index.js" } }代码里不需要写任何dotenv.config(),删掉相关 import 即可。这里有个容易忽略的点:Node 的--env-file加载规则是“不覆盖已存在的环境变量”。也就是说,如果 shell 里已经导出了同名变量,.env里的值不会覆盖它。这跟dotenv默认会覆盖的行为有差别,但大多数人不会碰到这个边界情况。
另一个差异是变量展开的写法。dotenv生态里习惯用$VAR或dotenv-expand处理变量之间的引用,而 Node 原生用的是${VAR}语法,跟 shell 一致。例如:
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:5432/app注意:如果想把
.env里的变量同时暴露给前端构建工具(比如 Vite、Webpack 环境变量),原生--env-file是管不到的,那种场景还是得靠工具自身的 env 机制。这类情况不属于“删掉 dotenv”的适用范围。
2.2 nodemon 可以删了,用 node --watch
nodemon是前端老兵的肌肉记忆了:改完代码自动重启服务,省去手动 Ctrl+C 再重新运行的繁琐操作。但从 Node 18.11.0 开始,Node 本身就带了--watch模式,而且 20 以后用起来已经相当稳。我在主力项目里跑开发命令现在是:
{ "scripts": { "dev": "node --watch src/index.js" } }保存文件后服务自动重启,体验和nodemon没有本质区别。如果只想监听特定目录,Node 22 提供的--watch-path参数可以精确指定监视路径:
node --watch-path=./src --watch-path=./config src/index.js但这里必须提醒一个最大的区别:nodemon监听的是目录文件变化,而 Node 原生--watch监听的是入口文件及其依赖模块组成的模块图。这意味着,如果你在运行过程中新增了一个尚未被任何模块引用的文件,node --watch不会因此重启。对于多数开发场景问题不大,但如果你习惯边写新文件边让服务自动重启,就会觉得原生模式“不够灵敏”。
还有一个实际体验上的差异:nodemon支持通过nodemon.json配置忽略目录和延迟重启,原生--watch目前还比较朴素。如果项目里大量使用动态import(),模块图计算可能不够稳定,极端情况下会导致修改了某个文件但服务不重启。遇到这种情况,再单独保留nodemon也不丢人,技术选型本来就是按需取用。
2.3 rimraf 可以删了,用 fs.rmSync
rimraf当年的核心价值是跨平台删除目录树。早期fs.rmdirSync只能删空目录,面对dist、build这种嵌套一堆文件的目录完全没脾气,而命令行rm -rf在 Windows 下又不可用,于是rimraf成了 npm scripts 里清理目录的标配。
从 Node 14.14.0 开始,fs.rmSync带着recursive: true就具备了一键递归删除的能力,而且跨平台表现良好。package.json 里的清理脚本可以这么写:
{ "scripts": { "clean": "node ./scripts/clean.mjs" } }clean.mjs内容非常简洁:
import { rmSync } from 'node:fs'; const dirs = ['dist', '.cache', 'coverage']; for (const dir of dirs) { rmSync(dir, { recursive: true, force: true }); }force: true的作用是目录不存在时不抛异常,效果等同于 shell 的rm -f。这样写的好处是跨平台一致——Windows、macOS、Linux 上行为完全相同,不用关心 shell 的差异。
有人说可以直接在 npm script 里这么写:
node -e "require('fs').rmSync('dist', {recursive: true, force: true})"单独一条命令确实没问题,但只要涉及多个目录,引号转义在 Windows 环境下就会变成噩梦。所以我更推荐独立脚本文件的方式。如果你的项目正在用rimraf且同时用了Vite或Webpack,会发现不少构建工具本身就带清空输出目录的逻辑,clean脚本甚至可能是多余的,删包之前可以顺手检查一遍。
3. 第二批退役:唯一 ID、日期处理
3.1 uuid 可以删了,用 crypto.randomUUID()
uuid这个包的地位曾经和lodash差不多,属于“新项目先装上再说”的默认依赖。当年生成一个 UUID v4 确实得靠它,因为浏览器和 Node 都没有原生 API。但现在是 2026 年了,crypto.randomUUID()已经是 Node 内置能力(自 14.17.0 起)和现代浏览器的标配 API(Chrome 92+、Firefox 95+、Safari 15.4+)。
服务端代码替换方式:
import { randomUUID } from 'node:crypto'; const id = randomUUID(); // 输出类似: 8f421f71-5c1d-4b5f-8f5c-4f7d4a9f6f2e浏览器代码更直接:
const id = crypto.randomUUID();randomUUID()生成的就是标准格式的 v4 UUID,带连字符,和uuid.v4()的输出完全兼容,数据库字段不用动。如果原来的代码有.toUpperCase()之类的处理,直接串上去即可。
一个常见的坑是浏览器环境下的安全上下文限制。crypto.randomUUID()只在 HTTPS 或 localhost 环境下可用,如果你的页面部署在纯 HTTP 的内网地址,又不做降级处理,控制台会直接报错。注意这里是“不可用”,不是“生成重复”,需要提前评估内网环境的兼容性。另外有个很冷的知识点:randomUUID()底层走的是 CSPRNG(密码学安全伪随机数生成器),不是Math.random(),安全性完全不需要担心。
3.2 moment.js 可以删了,用 Intl 和标准日期能力
moment.js是这批退役清单里最重的一个。它的问题不是功能不够强,而是太老了、太大了、官方都不建议在新项目里用了。moment 官方文档头图上就写着“请新项目不要使用”,Date 处理生态的接力棒早就交给了Day.js、date-fns、Luxon。但在 2026 年,绝大多数场景连这些都不一定需要——现代运行时原生IntlAPI 已经把格式化、相对时间、时区这些高频能力做得非常完善。
以前用 moment 格式化日期是这样写的:
moment().format('YYYY-MM-DD HH:mm:ss');原生替换:
new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });时区处理是 moment 最常被依赖的能力之一,原生Intl.DateTimeFormat一样能搞定:
const formatter = new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); formatter.format(new Date()); // 输出上海时间的字符串相对时间(比如“3 小时前”)用Intl.RelativeTimeFormat:
const rtf = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'always' }); rtf.format(-3, 'hour'); // "3小时前" rtf.format(1, 'day'); // "1天后"Intl的兼容覆盖面已经很广,而 Temporal 提案一旦在主流运行时稳定下来(2026 年已经进入相当后期的阶段),标准库对日期时间的基础支持还会再上一个台阶。如果你在主流的 Node LTS 版本上开发,Intl已经是开箱即用的状态。
注意:
moment.js的迁移在所有包里是最容易出问题的,尤其是重度使用moment-timezone的老项目。我建议分两阶段走:先把格式化、相对时间这类纯函数替换成Intl;再处理时区换算逻辑。如果项目里到处是moment(...).tz(...)这种调用,先别急着删包,考虑用date-fns-tz或Luxon做过渡,把业务逻辑理顺后再往原生方案靠,强行一次性改完大概率要出事故。
4. 实操:从项目里安全删除这 5 个包
4.1 先盘清使用面,别闭眼删
删包最忌讳的是“我觉得它没用就删了”。有些包藏得很深,可能在配置文件里、在构建脚本里、甚至被某个间接依赖引用着。动手之前,一定要把使用面盘清楚。
第一步,用 IDE 的全项目搜索,明确每个包在代码里出现的次数和位置。以dotenv为例,搜索关键词就是dotenv,凡是import 'dotenv/config'或require('dotenv')的地方,都是需要改的落点。这五个包分别对应的搜索关键词如下:
| 包名 | 搜索关键词 | 注意点 |
|---|---|---|
| dotenv | dotenv | 同时搜索.config()调用 |
| nodemon | nodemon | 排查 package.json 和配置文件 |
| rimraf | rimraf | 主要出现在 npm scripts |
| uuid | uuid | 区分uuid.v4()与uuid.v1() |
| moment | moment | 搜索范围会很大,切分模块处理 |
第二步,用工具检测未使用依赖。npm-check会逐个依赖询问使用情况,功能有点老旧但能用;更推荐knip,它不仅能找出未使用的依赖,也能帮你发现“只在配置文件里用过”的边缘情况。这些工具输出的报告,结合你的业务知识综合判断,比单纯靠搜索更可靠。
第三步,查看package-lock.json或yarn.lock,确认这些包是直接依赖还是间接依赖。间接依赖的话,根治方式要回到引用它的那个包上,单纯删node_modules里的目录是没用的,下次npm ci又会装回来。
4.2 逐步替换的思路和顺序
我给一个实操性很强的顺序建议:先替换简单的、影响范围小的,再把复杂的放后面。推荐顺序是:rimraf→uuid→dotenv→nodemon→moment.js。
为什么rimraf放第一?因为它的使用点高度集中在 npm scripts 和构建脚本里,不涉及业务代码逻辑,替换风险最低。接着做uuid,全局搜索uuid.v4然后换成randomUUID(),改动量通常也不大。dotenv的替换要稍微注意--env-file的启动参数配置和 CI/CD 部署脚本同步更新。nodemon替换后要通过实际开发验证监听和重启行为是否符合预期。moment.js放在最后,因为它往往是代码里渗透最深的一个,需要单独花时间。
有一个实操原则:每个包替换完成后,立刻跑一遍测试和构建,确认无回归再进入下一个包。不要攒着五个包一起改完再统一验证,出问题都不知道是哪个改动引入的。
4.3 删包、更新脚本和回归验证
所有代码替换完成后,删掉依赖本身并同步更新脚本配置:
npm uninstall dotenv nodemon rimraf uuid moment注意,npm uninstall会同时更新 lock 文件,这一步不要跳过。删完之后必须看一眼package.json的 scripts,确保没有遗漏的引用。比如启动命令是node --env-file=.env src/index.js还是旧的node -r dotenv/config src/index.js,构建清理命令里有没有残留的rimraf dist,这些都要逐项核对。
回归验证的底线操作清单:
# 全新安装,验证 lock 文件干净可复现 npm ci # 跑构建,确认构建脚本中没有旧依赖的引用 npm run build # 跑测试,重点覆盖涉及日期格式化、ID 生成、环境变量读取的模块 npm test # 启动开发服务,确认热重载和启动日志正常 npm run dev再补一个我个人的额外检查项:npm ls dotenv nodemon rimraf uuid moment,确认输出结果里没有UNMET DEPENDENCY或invalid标记。如果你发现明明删了包但npm ls还是能看到它们,说明某个包在别处把你删掉的包当成了依赖,要继续顺着依赖树排查。
完整流程走完,依赖树通常会少掉十几到几十个传递依赖,安装速度提升立竿见影,npm audit报的漏洞数也会明显下降。
5. 常见问题与避坑速查
5.1 老项目最容易踩的 5 个坑
我把实际操作里遇到过的坑整理成了一张速查表,换包之前先扫一眼,能省不少时间。
| 问题 | 现象 | 原因与解法 |
|---|---|---|
--env-file变量没生效 | 代码读到的环境变量是 undefined | 检查启动命令是否确实带了--env-file=.env;--env-file不会覆盖 shell 已存在的同名变量 |
node --watch不重启 | 改了新增文件后服务没反应 | 原生--watch监视的是模块图,不是目录;新增未被引用的文件不会触发重启,这是正常行为 |
fs.rmSync报错路径不存在 | 明明加了force: true还是抛异常 | Node 的force对文件也生效,但如果路径是空字符串会报错;检查路径变量是否为空 |
crypto.randomUUID在浏览器报错 | 提示crypto.randomUUID is not a function | 页面不在 HTTPS/localhost 环境下;或浏览器版本太旧,需要判断是否必须支持 |
| 日期输出格式变了 | 用户看到“2026-1-5”而不是“2026-01-05” | Intl格式化时把month和day都设为2-digit;这是moment迁移里超级常见的边界差异 |
每个坑背后都有一个具体场景。比如最后一个格式差异,不是代码逻辑错误,而是Intl.DateTimeFormat在某些语言环境下会省略前导零,这看起来是小事,但在报表、日志、文件名生成这些对格式敏感的地方,很容易被忽略。
5.2 顺带聊两个活跃的 npm 环境问题
这篇文章主题是“删包”,但实际动手过程中,不少读者会卡在环境准备阶段。这里顺手提两个从微博和评论区高频出现的问题。
第一个是npm install速度慢或直接失败。建议优先检查 registry 配置,如果还是默认的官方源(registry.npmjs.org),换成国内镜像后速度会有明显提升。执行:
npm config get registry如果输出是官方地址,可以设置镜像源:
npm config set registry https://registry.npmmirror.com注意,镜像源可能滞后于官方源几小时,发布新包或遇到“404”时先别慌,确认同步状态即可。
第二个是 Windows 下执行 npm 脚本报“无法加载文件 ...npm.ps1,因为在此系统上禁止运行脚本”。这是 PowerShell 执行策略导致的,跟 npm 本身无关。解决办法有几种,最省事的是一行命令放开当前用户的执行策略:
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned这个命令只需要执行一次,之后npm run dev、npm run build这类脚本都能正常跑。执行完如果还是不行,检查一下 Node 是否真的进了系统 PATH,控制台输入node -v能弹出版本号才说明环境变量配置正确。
我的个人习惯是,每次接到老项目,先花半小时看依赖清单。看到某个包还躺在 dependencies 里,会习惯性问三个问题:代码里真的在用吗?原生能力能做到吗?如果有一天它被曝安全漏洞,我们能不能快速替换?这三个问题问下来,该删谁、该留谁,答案基本就清楚了。这次列出的五个包只是起步,顺着这个思路往下排查,你会发现可精简的地方远比想象中多。