2026年删包指南:用Node与浏览器原生能力替代dotenv、moment、lodash等5个npm包

前阵子接手一个维护了三四年的老项目,跑完npm install之后,我习惯性地看了眼依赖树,发现node_modules里躺着 800 多个包。再顺着package.json数了一遍直接依赖,dotenv、moment、lodash、axios、uuid 一个不少——都是前几年大家特别爱装的“必备件”,我也不例外,我自己发到 npm 上的好几个小工具包里也带过它们。

可站在 2026 年回头看,这 5 个包至少一半以上的使用场景,早已经被 Node 运行时、浏览器平台和 ECMAScript 标准库收编了。删掉它们,安装速度、构建体积、供应链攻击面都能实打实地变好,而且代码不会少任何一个能力。这篇文章就把每个包为什么能删、用什么替代、边界条件在哪、迁移时容易踩什么坑,一次讲清楚。

1. 为什么说 2026 年是一个“删包”的好时机

1.1 运行时的“内置化”速度,比大多数人感知的要快

很多团队对 npm 包的依赖习惯,其实还停在 2020 年前后的环境认知里。那时候 Node 12 还是主流,浏览器要兼容 IE11,前端代码动不动要配一大串 polyfill,很多工具函数只能靠第三方包兜底。

但从 2020 到 2025 这几年,运行时和标准库的补位速度非常快。fetch从 Node 18 开始全局可用,到 Node 21 以后基本稳定;crypto.randomUUID()在 Node 14.17 就有了,浏览器端也早已普及;Object.hasOwn、Array.prototype.at、String.prototype.replaceAll、structuredClone这些过去要 polyfill 的 API,现在都成了语言标准的一部分。更别说 Node 20.6 开始支持--env-file,把 dotenv 的核心功能直接做进了运行时。

到了 2026 年,Node 22 LTS 和 Node 24 LTS 是绝对的主流,现代浏览器的基线也早就到了 ES2022 以上。也就是说,当年装那些包的理由——兼容性、平台能力缺失——已经不存在了。如果你还在用 IE11 或者 Node 14 以下的版本,那确实另当别论,但正常维护中的项目早就不该有这种包袱了。

1.2 供应链安全让“最小依赖”从洁癖变成刚需

这几年 npm 生态里出现过不止一次规模不小的投毒事件,攻击者往往不挑那些大而全的框架,专门盯着被广泛引用的小包下手——维护者账号被盗、恶意版本悄悄发布、依赖混淆攻击,套路已经非常成熟。任何一个包只要还在你的依赖树里,它的发布权限、传递依赖、历史版本,本质上都是你代码库的一部分信任链。

所以很多团队现在做依赖治理,考量的已经不是“这个包好不好用”,而是“这个包值不值得我承担这份风险”。每多一个直接依赖,平均会带来几十个传递依赖,任何一个环节出问题,整条链都可能受影响。这种事在大厂的安全公告里看多了以后,我对“最小依赖原则”的态度就从可维护性偏好,变成了实打实的安全诉求。

1.3 删掉 5 个包,到底能换来什么

很多人觉得这几个包体积不大,删不删无所谓。但账不是这么算的。

第一个收益是依赖树的直接瘦身。lodash 全量打包后 min 大概 70KB,gzip 约 24KB;moment 全量 UMD 包 min 约 68KB,gzip 约 19KB;axios 打包后 gzip 也有 10KB 上下。这几个包加一起,再加上各自的传递依赖、类型声明、版本解析记录,会让package-lock.json或pnpm-lock.yaml长出十几甚至几十个间接条目。

第二个收益是安装和构建时间。每多一个包,npm install就要多做一次版本解析、下载、解压、构建。本地开发机器还好,CI 环境和 Docker 构建里这些开销是实打实的分钟级成本。第三个收益是心智负担。新同事接手项目时,看到 package.json 里躺着一堆“不知道干嘛用”的依赖,要么不敢动,要么瞎猜,最后往往变成历史包袱。

我自己的体验是,删完这几个包之后,npm install的输出明显变短,构建产物体积变小,ESLint 和类型检查的报错也少了一类容易混淆的来源。这个账,算得过来。

2. 配置与 ID 生成:dotenv、uuid 这两个包已经没存在的必要

2.1 dotenv:原生--env-file和loadEnvFile已经成熟

dotenv 这个包本身不大,核心逻辑就是解析.env文件、把键值对灌进process.env。但它的价值在过去是因为 Node 没有这个能力,大家只能用第三方包。Node 20.6 开始,官方提供了--env-file参数,直接在执行脚本时加载环境变量文件;Node 20.12 和 21.7 又补上了process.loadEnvFile()这个运行时方法。

最直接的迁移方式就是把启动命令改一下:

{ "scripts": { "start": "node --env-file=.env src/index.js", "dev": "node --env-file=.env --watch src/index.js" } }

TypeScript 项目也支持,只是多一个 loader 参数:

node --env-file=.env --import tsx src/index.ts

Docker 镜像里的启动命令同样可以这样写:

CMD ["node", "--env-file=.env", "dist/main.js"]

如果不想依赖启动参数,也可以在入口文件里主动调用:

if (process.env.NODE_ENV !== 'production') { process.loadEnvFile('.env'); }

这里注意一个细节:--env-file的行为和 dotenv 的默认行为是一致的——文件里的变量不会覆盖已存在的环境变量。也就是说 CI 平台上通过系统环境变量注入的配置,优先级始终高于.env文件,这个语义不用改。

2.2 uuid:crypto.randomUUID()一行搞定

uuid 这个包更没必要留。Node 14.17 以后,node:crypto模块自带randomUUID(),实现的就是标准 UUID v4,随机数来源是加密安全级别的。浏览器端crypto.randomUUID()也早就普及了。

替换起来几乎没有成本:

import { randomUUID } from 'node:crypto'; const id = randomUUID(); // 输出形如 6d9b2b1f-8a1a-4d7a-b7e1-3a1c1e2f3a4b

浏览器环境直接写:

const id = crypto.randomUUID();

很多人担心这个 API 的兼容性,其实到 2026 年还在用 Node LTS 版本的项目,完全不用担心。真正需要额外考虑的,是你生成的是不是必须是 v4 格式。crypto.randomUUID()默认就是 v4,这覆盖了绝大多数业务主键、会话 ID、文件名前缀的场景。如果某些老系统对 UUID 格式有历史要求,比如必须用 v1 的基于时间戳排序,那才需要继续用 uuid 包,否则一律用原生的就完事了。

2.3 换掉 dotenv 之前,先确认这几个边界

说归说,dotenv 有几种用法是原生能力没有直接覆盖的,迁移之前要先想清楚。

第一种是依赖dotenv-expand做变量展开的场景。.env文件里写PORT=${BASE_PORT} + 1或引用其他变量,这种复杂的展开逻辑,Node 原生的--env-file并不完全等价。新版本 Node 对变量展开的支持一直在完善,但不同版本表现有差异。如果你重度依赖展开功能,建议先写一个小函数,或者在启动脚本里用 shell 做一层拼接,别直接硬切。

第二种是多个环境文件。dotenv 生态里常见的.env.development、.env.production、.env.local这种按环境加载的写法,原生参数没法自动合并多个文件。我的做法是分场景处理:环境差异比较大的配置,交给 CI 平台注入环境变量;本地开发时就用一个.env文件,最多在入口处用process.loadEnvFile手动按顺序加载两个文件,自己控制优先级。

第三种是测试环境。Jest 和 Vitest 里加载.env的逻辑不太一样。Vitest 支持--env-file或环境配置;Jest 则要写一个 setup 文件,在里面调用process.loadEnvFile('.env')。改动不大,但确实需要额外处理,不能只改 package.json 里的 scripts 就完事。

提示:--env-file的具体行为因 Node 版本略有差异,动手迁移前先在你的目标运行环境里跑一次node --env-file=.env -e "console.log(process.env.YOUR_KEY)"验证一下,比看文档猜行为靠谱得多。

3. 工具函数库:如何体面地从 lodash 和 moment 迁移出来

3.1 lodash 高频方法替换清单:一张表说清楚

lodash 最尴尬的地方在于,它的很多方法当年填补的是 ES5/ES6 时代的空白,但 ECMAScript 标准后来把这些能力一个接一个地做了进去。拿业务代码里最常用的几个场景来说,原生替代已经完全够用:

场景lodash 旧写法原生替代注意点
安全读取属性_.get(obj, 'a.b.c')可选链obj?.a?.b?.c动态字符串路径仍需自己解析
深拷贝_.cloneDeep(obj)structuredClone(obj)不支持函数、DOM 节点、类实例
去重_.uniq(array)[...new Set(array)]uniqBy要用 Map 手动实现
拍平数组_.flattenDeep(array)array.flat(Infinity)注意flat()默认只拍一层
分组_.groupBy(arr, fn)Object.groupBy(arr, fn)ES2024,Node 21+ / 现代浏览器
取最后一项_.last(arr)arr.at(-1)ES2022 标准
防抖/节流_.debounce(fn, 300)手写 15 行或使用框架内置纯函数场景可保留独立小包

这里我想特别提醒两个容易踩坑的点。

第一个是structuredClone并不能完全替代_.cloneDeep。它支持循环引用、支持Date、Map、Set、ArrayBuffer这些类型,但遇到函数、DOM 元素、类实例会直接抛DataCloneError。如果业务代码里有人传了带方法对象进去,迁移后会在运行时突然报错。稳妥的做法是:先全局搜索cloneDeep的调用点,逐个确认参数类型,再分批替换为用户自己写的cloneWithFunctions之类的浅层处理函数。

第二个是_.get和可选链在动态路径上的差异。_.get(obj, 'a.b.c')接受一个字符串路径,运行时解析;可选链obj?.a?.b?.c是静态语法,写死了路径。如果项目里有大量从配置或接口返回里动态拼路径的需求,手写一个支持a.b.c格式的get函数并不难,大约 10 行代码,没必要为了这个留着一整个 lodash。

3.2 moment 的替代路线:Intl 家族 + 少量手写封装

moment 是我见过“早该退役但一直没退役”最典型的包。它的几个核心痛点想必各位都有体感:体积大且不可 tree-shaking;对象是可变对象,m.clone()没调用好会出各种诡异 bug;API 设计跟现代 JavaScript 风格格格不入。

2026 年做日期时间处理的正确姿势,是围绕Intl系列 API 写一层薄薄的自己用着顺手的封装。

格式化日期,用Intl.DateTimeFormat:

const formatter = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); formatter.format(new Date()); // "2026/05/18 14:30"

相对时间显示,用Intl.RelativeTimeFormat:

const rtf = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' }); rtf.format(-3, 'day'); // "3天前"

时区展示层转换,也可以在DateTimeFormat里直接指定timeZone:

new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'full', }).format(new Date());

我自己的迁移经验是:给团队封装一个lib/datetime.ts,只暴露业务真正需要的三四个函数——formatDate、formatDateTime、relativeTime、startOfDay。内部全部用原生 API 实现,调用方从 moment 直接换成这个模块,改动量比想象中小得多。

3.3 什么情况下不要硬换:先分清“库”和“业务模型”

虽然我建议删掉 moment 和 lodash,但有一个前提:你的用法得是在“函数库”这个层级。如果你的业务模型已经建立在 moment 对象或者 lodash 的深合并语义上,那就要谨慎一些。

比如_.merge。业务代码里如果依赖深合并,而且行为要求跟 lodash 完全一致,原生 JavaScript 并没有一个直接等价的方法。structuredClone是替换不是合并,Object.assign是浅拷贝不是深合并。如果项目里有大量配置对象合并逻辑,建议拆包替换,单独引一个lodash.merge或者deepmerge这样的极简包,而不是为了一个函数留一整个 lodash。

再比如 moment 的时区计算。moment-timezone在“把一个时间戳转换到指定 IANA 时区并继续做计算”这件事上确实做得细致,Intl的formatToParts虽然能做展示层转换,但要完整替代时区计算逻辑,需要维护的代码比你想象的多。如果老项目里到处是moment().tz('Asia/Shanghai').startOf('week')这种链式调用,我不会建议一口气全改完,更合理的方式是:新代码一律走Intl封装,老代码按模块渐进重构,把 moment 的使用范围逐步压缩到某几个文件里,最后再删包。

4. HTTP 请求:axios 可以退居二线,但前提是这些条件

4.1 原生 fetch 在 2026 年还缺什么

axios 的历史地位不用多说,它是 XHR 时代最成功的封装之一。但 2026 年的现实是:Node 18+ 内置 fetch,浏览器端 fetch 更是标准能力,大部分项目的“发请求”需求其实非常简单——GET、POST、带个 token、处理下错误码、超时就报错。这些用原生 fetch 完全能写,而且写出来的代码更直白,没有 axios 那层 adapter 魔法。

但要说清楚,axios 相对原生 fetch 确实还有一些当时卖得很好的能力缺口:

  • 拦截器:axios 的请求/响应拦截器是它最大的心智模型优势。
  • 超时控制:axios 有timeout配置,fetch 需要手动配合AbortController。
  • 自动 JSON 序列化:axios 会自动JSON.stringify请求体和JSON.parse响应,fetch 都要手动处理。
  • 上传进度:fetch 至今没有原生的上传进度事件,只能靠ReadableStream或退回 XHR。

前三个能力自己封装并不难,真正麻烦的是第四个。如果一个项目对上传进度有硬需求,又不想自己造轮子,那继续留在 axios 或 XHR 方案里是有合理性的。这一点下面会细说。

4.2 自己写一个够用的请求层:30 行代码示范

如果你只是需要“带超时、带 JSON、能取消”的基础请求能力,一个迷你请求层就够了:

type RequestOptions = { baseURL?: string; timeout?: number; headers?: Record; signal?: AbortSignal; }; class Http { constructor( private baseURL = '', private defaultTimeout = 10_000, ) {} request( method: string, url: string, body?: unknown, options: RequestOptions = {}, ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), options.timeout ?? this.defaultTimeout); // 合并外部取消信号 if (options.signal) { options.signal.addEventListener('abort', () => controller.abort()); } return fetch(this.baseURL + url, { method, headers: { 'Content-Type': 'application/json', ...this.authHeaders(), ...options.headers, }, body: body === undefined ? undefined : JSON.stringify(body), signal: controller.signal, }) .then((res) => { if (!res.ok) { throw new Error(`HTTP ${res.status}: ${res.statusText}`); } return res.status === 204 ? (undefined as T) : (res.json() as Promise); }) .finally(() => clearTimeout(timer)); } private authHeaders(): Record { const token = localStorage.getItem('token'); return token ? { Authorization: `Bearer ${token}` } : {}; } get(url: string, options?: RequestOptions) { return this.request('GET', url, undefined, options); } post(url: string, body?: unknown, options?: RequestOptions) { return this.request('POST', url, body, options); } } export const http = new Http('/api');

这段代码覆盖了基础 URL、超时、token 注入、JSON 处理和取消请求。真正的 axios 拦截器机制,本质上就是把所有请求和响应处理逻辑放进一个数组里统一遍历,你完全可以在request方法里加一个beforeRequest钩子数组,实现同样的扩展能力。

4.3 把这些项目继续留在 axios,别瞎折腾

诚实地说,有些项目我是建议继续用 axios 的,别为了删而删。

第一种是已经有大量 axios 封装的存量项目。代码里到处是instance.interceptors.request.use(...)、CancelToken、自定义transformResponse,这些东西全量替换成 fetch 其实工作量非常大,而且收不到什么明显收益。这种情况下更现实的方案是:新代码用原生 fetch 或新封装,老代码继续 axios,等代码重构到一定程度再整体切换。

第二种是强依赖上传进度的项目。fetch 在这个场景下没有现成的upload.onprogress,实现成本较高。如果团队没有专门的人愿意趟这个坑,保留 axios 是理性的选择。

第三种是团队新成员较多、需要统一认知的项目。axios 的文档和社区经验非常丰富,新人上手成本低,自己封装一套东西反而要写专门的内部文档。这个因素在商业项目里经常被低估。

所以我对 axios 的结论不是“今天就必须删”,而是“它已经从默认选择变成了可选选择”。如果你只是在fetch外面薄薄包了一层,那就果断把整套 axios 拆掉。

5. 删包实操:从依赖体检到平滑上线的完整步骤

5.1 删包前先做一次“依赖体检”

不要一上来就改代码。先花半小时把项目的依赖家底摸清楚。

第一步,看直接依赖列表:

npm ls --depth=0 # 或 pnpm list --depth=0

这会列出你声明在package.json里的所有直接依赖。先圈定哪些是真正在代码里import过、哪些是只用在 scripts 里、哪些是早就没人用但一直没删的。

第二步,用工具查未使用依赖。我自己常用的组合是knip加depcheck:

npx knip npx depcheck

knip能查出项目中声明了但没有任何文件引用的依赖,还能检测未使用的导出和文件;depcheck更适合快速扫描一次直接依赖的使用情况。两者交叉验证一下,基本能确定哪些包可以“闭眼删”。

第三步,查传递依赖。有些包虽然你的代码没直接用,但它是另一个包的必要依赖,删掉直接依赖会导致间接依赖也被清理,从而影响运行。所以删之前用npm why 或pnpm why 确认一下,这个包是被直接引用的,还是只是传递依赖链上的一环。

5.2 单包替换、分批提交、逐项回归

我强烈建议一个包一批提交,不要一口气把所有替换写进一个 PR。比如第一周只做uuid -> crypto.randomUUID(),第二周做dotenv -> --env-file,第三周再处理 lodash 或 moment。分开走的好处有三个:出问题时二分手很快,reviewer 能看清楚每处改动,回滚也只需要回滚一个小提交。

每个包的具体替换流程,我的套路是固定的:

  1. 全局搜索该包的所有引用点,统计使用范围。
  2. 逐个文件替换为原生 API 或自己的封装函数。
  3. 跑 TypeScript 类型检查,看有没有漏网之鱼。
  4. 跑单测、集成测试、关键路径的 E2E。
  5. 从package.json删除该依赖,重新安装并更新 lockfile。
  6. 构建一次,对比产物体积和依赖树是否明显变小。

这里要特别提醒一件事:删包后 lockfile 的更新。很多人会在代码里删掉 import,但忘记更新package-lock.json或pnpm-lock.yaml。结果就是 CI 里依然安装着旧依赖,本地构建跟 CI 行为不一致。正确做法是改完package.json之后重新执行一次完整的安装命令,确认node_modules里确实没有残留,再提交 lockfile 变更。

5.3 团队层面如何防止老依赖“复活”

代码层面删干净了,真正的挑战是防止后续 PR 里又有人把老包引回来。经验丰富的人不会靠自觉来维持依赖纪律,而是用工具和文档把规则固化下来。

第一个工具是 ESLint。用no-restricted-imports规则直接禁止新代码引入已经被淘汰的包:

{ "rules": { "no-restricted-imports": ["error", { "paths": ["lodash", "moment", "axios"] }] } }

如果希望 package.json 里也不能再出现这些依赖名,配合eslint-plugin-import的import/no-restricted-packages规则更彻底:

{ "rules": { "import/no-restricted-packages": ["error", ["lodash", "moment", "axios"]] } }

第二个是项目文档。在 README 或docs/coding-standards.md里明确列出“项目禁用库清单”,并写明替代 API 和用法示例。新人入职看一遍文档,比 review 时反复提醒效率高得多。

第三个是 CI 检查。可以在流水线里加一个简单的脚本,扫描package.json里是否出现禁用依赖名,一旦发现就 fail。工具不用多高级,一段grep加一行判断就能跑起来。

6. 还有几个包可以顺手清掉:cors、rimraf、mkdirp

6.1 cors:三行响应头的事,不值得多一个依赖

很多 Express 项目会装cors中间件来支持跨域。但你要真看它做了什么,核心无非是设几个响应头、处理一下 OPTIONS 预检请求。自己写一个中间件非常简单:

import type { NextFunction, Request, Response } from 'express'; export function corsMiddleware(req: Request, res: Response, next: NextFunction) { res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers'] ?? 'Content-Type, Authorization'); res.setHeader('Access-Control-Max-Age', '600'); if (req.method === 'OPTIONS') { res.sendStatus(204); return; } next(); }

用 Express 5 或者 Hono 这类新框架的话,CORS 支持往往已经内置了,更没必要装额外的包。唯一要提醒的是:生产环境不要直接把req.headers.origin原样回写,应该维护一个允许的域名白名单,把 origin 映射到固定的响应值,避免被任意站点跨域调用。

6.2 rimraf 和 mkdirp:Node 原生 fs 早已覆盖

rimraf在当年是 Windows 上递归删除目录的救星,因为fs.rmdirSync对非空目录会直接报错。但 Node 12.10 之后就有了fs.rm和fs.rmSync,支持recursive和force参数。清理构建目录的脚本可以这样写:

{ "scripts": { "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"" } }

mkdirp同理,fs.mkdirSync的{ recursive: true }参数从 Node 10.12 就有了,连续创建多层目录再也不用第三方包。这两个包删掉之后,依赖树能少好几个 Windows 兼容相关的传递依赖。

6.3 给自己的 package.json 立几条规矩

经过这几轮删包之后,我慢慢养成了一套依赖管理习惯,分享出来给各位参考。

新项目起步时,我默认从零依赖开始,需要什么功能先问一句:原生 API 能不能做?做不到,再找最小型的第三方包。每加一个依赖,都得能回答“它解决了什么问题,为什么原生做不到”。老项目维护时,我一季度做一次依赖体检,重点关注那些“没人维护但没被删”的包,能删就删。

我也很推荐给团队建立一个“依赖门槛”清单:比如体积超过某个阈值必须有 tree-shaking 证明;新引入的包必须有人回答维护状态和替代方案;超过一年没更新的包要打上“高危观察”标签。这些规矩不一定需要工具强制执行,但写下来之后,至少能在 review 时给“为什么要加这个包”留一个讨论空间。

最后分享一点个人经验

删包这件事,难的不是代码替换,而是心态。很多开发者觉得“多加一个包又不会怎么样”,但维护久了你会发现,依赖树是一点一点失控的,今天加一个工具函数,明天加一个语法糖包,半年后npm audit报告里全是红字,谁都不敢动了。我自己的底线是:新代码尽量不引入新依赖,旧代码按批次清理,宁可自己多写 10 行原生代码,也不为了省 10 分钟接入一个没人维护的“小工具”。2026 年的 JavaScript 生态,原生能力已经足够你写出干净、稳健、不心虚的代码了。