
前阵子给一个维护了两年的老项目做例行依赖升级跑完npm install照例看了一眼node_modules的体积——几十万个小文件装机耗时从原来的十几秒涨到了快两分钟。一个业务接口不超过 20 个的中型后台项目package.json里躺着 40 多个直接依赖这本身就不太正常。于是我花了两个下午把这些 dependencies 一个个对着代码翻发现至少有 5 个包在 2026 年这个节点上真的可以卸掉了。不是可以尝试删掉而是删掉之后代码更短、构建更快、安全审计的噪音更小。这篇文章把它们的替代方案、迁移步骤和哪些情况下你还需要留着一次说清楚。顺带也会聊一个更重要的问题判断一个包还能不能删的标准到底应该是什么。1. 判断一个包能删的三条标准很多同学看到XX 包可以用原生替代这类文章后的第一反应是焦虑——是不是我的代码里所有第三方库都该删恰恰相反无脑删包和盲目装包一样危险。我自己的判断标准很朴素就三条。第一条原生能力是否覆盖了你的使用场景。这条看着简单实际最容易出岔子。一个包通常有很多功能但你项目里真正用到的可能只有四五个 API。比如 lodash 有 300 多个函数你常用的可能只有cloneDeep、debounce、get这几个。判断的时候要看你在用的那一小撮而不是这个包号称能做什么。方法也很简单全局搜一下from lodash或者_.xxx把所有出现的地方拉一个清单挨个对着原生 API 查即可。第二条运行环境是否真的到位。这决定了你的替代代码在不同环境里跑不跑得起来。做前端的要看目标用户的浏览器版本分布做后端的第一件事是确认 Node 主版本。举个例子node --env-file.env是 Node 20.6.0 才有的能力如果你的线上环境还跑在 Node 18 LTS 上dotenv 就还不是能随便删的状态。所以每次讨论删包之前先把运行时的最低版本列出来这是所有讨论的前提。第三条删掉之后这个包的功能在团队里的表述成本会不会失控。这一点经常被忽略。安装一个包本质上是在给团队提供一个约定俗成的 API。axios 有拦截器dayjs 有链式调用这些已经刻在很多人的肌肉记忆里。删掉它们之后你用一个别人没见过的封装函数替代新同事进来还得重新适应。我的经验是如果替代方案只有你自己看得懂那这个包还不能删如果替代方案是原生 API 少量注释那基本就到位了。接下来正文开始。以下这 5 个包是我在 2026 年的依赖清理中觉得删了最值的。2. dayjsTemporal 落地之后日期库终于可以退场了dayjs 曾经是我在每个项目里都会装的包理由也很简单——Moment.js 太肥了而 dayjs 和它 API 兼容体积却小得多。但注意dayjs 的好只是相对 Moment 而言的它本质上仍然是一个为了弥补语言缺陷而存在的补丁包。而在 2026 年这个缺陷已经被原生 API 基本补上了。2.1 为什么现在可以删JavaScript 这门语言长期以来被吐槽的点之一就是Date对象难用不能直接做日期运算格式化要自己拼时区转换要手动处理。dayjs 这类库把这些痛点全包了这也是它存在的最大价值。但这几年情况变了。Temporal API 在 2026 年已经从提案阶段进入主流落地期它把日期、时间、时区、持续时间这些概念彻底分开了// 之前dayjs dayjs().format(YYYY-MM-DD) dayjs().add(1, day).toISOString() // 现在原生 Temporal Temporal.Now.plainDateISO().toString() // 2026-03-15 Temporal.Now.zonedDateTimeISO().add({ days: 1 }).toInstant().toString()顺带提一嘴如果你只是需要格式化输出Intl.DateTimeFormat从十几年前就有了而且本地化做得比任何库都完整new Intl.DateTimeFormat(zh-CN, { year: numeric, month: 2-digit, day: 2-digit, }).format(new Date()) // 2026/03/15dayjs 一直吹2KB但真实项目里为了时区、相对时间、duration 这些能力需要引入一整套插件最终体积轻松到十几 KB。更重要的是这些插件你每升一次 Node 或浏览器版本就多一层兼容性负担。2.2 常见的迁移操作我把项目里最常见的场景整理了一下这一步直接对着改即可场景dayjs 写法原生替代当前时间dayjs()new Date()/Temporal.Now.zonedDateTimeISO()格式化dayjs(date).format(YYYY-MM-DD)Intl.DateTimeFormat或手写padStart拼接加减时间dayjs(date).add(7, day)Temporal.PlainDate.from(date).add({ days: 7 })两个日期差几天dayjs(a).diff(b, day)Temporal.PlainDate.from(a).until(b).days相对时间3天前dayjs(date).fromNow()Intl.RelativeTimeFormat时区转换dayjs(date).tz(America/New_York)Temporal.ZonedDateTimeIntl.DateTimeFormat({ timeZone })我最常用的是一个 8 行的formatDate函数放在utils/date.ts里项目里所有偶发性的格式化需求都用它function formatDate(date new Date(), template YYYY-MM-DD) { const pad (n) String(n).padStart(2, 0); const map { YYYY: date.getFullYear(), MM: pad(date.getMonth() 1), DD: pad(date.getDate()), HH: pad(date.getHours()), mm: pad(date.getMinutes()), }; return template.replace(/YYYY|MM|DD|HH|mm/g, (key) map[key]); }2.3 什么情况下你还得留着它先别急着删这三类情况我建议你继续用 dayjs。一是你的目标运行环境里 Temporal 还没落地。Temporal 是个大工程部分旧版本浏览器和低版本 Node 是不支持的。如果你要做 polyfill那个体积可能比你省的还多完全没必要。二是你重度依赖 dayjs 的插件生态比如dayjs/plugin/advancedFormat、weekOfYear这种非常规格式需求。Temporal 确实提供了底层机制但要自己拼出一个2026 年第 11 周的周二这种表达成本并不低。这种场景继续用 dayjs 更务实。三是某些老旧的编辑器环境、低代码平台、小程序容器这些地方对内置对象做了特殊处理Temporal 不一定可用。写库或者插件给别人用的时候为了兼容性保守一点没有错。3. axios原生 fetch 补全最后一块短板axios 大概是最舍不得删的包之一毕竟它太方便了自动转 JSON、拦截器、请求取消、上传进度、统一错误处理。但到了 2026 年这些能力在原生fetch里都有了对等的实现而且 Node.js 从 18 开始全局支持fetch浏览器端就更不用说了。删掉它完全可行。3.1 被反复拿来当借口的几个功能很多人留着 axios 是因为fetch 做不到这些这个说法放到 2026 年基本站不住脚了。我把最常见的四个理由挨个拆一下。拦截器这可能是 axios 最核心的护城河。但说白了拦截器就是请求发出前后统一做一些事。你完全可以用一个包装函数实现而且代码更加显式不看 interceptors 的配置文件也知道请求经历了什么async function request(path, options {}) { const token localStorage.getItem(token); const headers { ...(options.headers || {}) }; if (token) headers[Authorization] Bearer ${token}; const res await fetch(path, { ...options, headers }); if (res.status 401) { redirectToLogin(); throw new Error(Unauthorized); } if (!res.ok) { throw new Error(HTTP ${res.status}: ${await res.text()}); } return res.json(); } // 使用 const data await request(/api/user, { method: POST, body: JSON.stringify({ name: 张三 }) });取消请求AbortController从 2017 年就有了AbortSignal.timeout()在 Node 17.3 和所有现代浏览器里也能用// 5 秒超时 const res await fetch(/api/slow, { signal: AbortSignal.timeout(5000) }); // 手动取消 const controller new AbortController(); const res fetch(/api/slow, { signal: controller.signal }); // 某些时候 controller.abort();上传/下载进度这是当年 axios 最得意的功能。原生 fetch 的做法是用ReadableStream包装请求体逐步上报进度async function uploadWithProgress(file, onProgress) { const stream new ReadableStream({ async start(controller) { const reader file.stream().getReader(); let loaded 0; while (true) { const { done, value } await reader.read(); if (done) break; loaded value?.length || 0; onProgress({ loaded, total: file.size, percent: loaded / file.size }); controller.enqueue(value); } controller.close(); }, }); return fetch(/upload, { method: POST, body: stream, // 注意Node.js 环境下需要显式声明 duplex: half, }); }这段话看着有点复杂但实际业务里你还可以退一步——用XMLHttpRequest做上传进度这个 API 从远古时代就支持xhr.upload.onprogress。不少同学不知道原生 XHR 才是真正大而全的 HTTP 客户端fetch 只是一个更现代的补充。当进度需求来得比较急我又不想引第三方库的时候直接用 XHR 几行代码就搞定。自动 JSON 处理fetch 默认不会帮你解析 JSON但前端也就一个res.json()的事后端多一个Content-Type判断。这谈不上是成本。3.2 axios 换 fetch 时的类型与兼容问题真正麻烦的不是能力而是 TypeScript 类型和既有代码的改动量。axios 的res.data是直接带类型的换到 fetch 之后你需要自己保证res.json()的类型// 原来 const user (await axios.getUser(/api/user)).data; // 现在 const user await requestUser(/api/user); // request 函数的类型签名 async function requestT(path: string, options?: RequestInit): PromiseT { // ... 略 return res.json() as PromiseT; }如果你在项目里维护了一个api/目录所有请求都走那个封装好的 client那迁移成本就非常低只改一个文件即可。反过来如果项目里散落着上百个axios.get裸调用我建议你分模块逐个迁移而不是一把梭。3.3 什么情况下继续留着 axios如果你的项目已经深度使用 axios 的拦截器体系且团队中多数人都习惯了拦截器的写法那就继续留着没必要为了删而删。另外如果你做的是 Node 服务端请求库需要处理 HTTP/2、自动重试、轮询这类高级特性fetch的包装层会越写越厚最终还是落入自己造轮子的坑。这种时候用 axios 反而省心。但如果你是 2026 年新起的项目我强烈建议直接以 fetch 一个 50 行的 request 封装起步你会发现依赖真的可以很少。4. lodashES2023 之后工具链上最重的安全带lodash 绝对是老项目依赖清理的重灾区。我在一个后台项目里见过开发者为了用_.get和_.cloneDeep两个函数把整个 lodash 装了进来。而这两个函数在今天都有非常成熟的原生替代。4.1 先看清哪些函数有原生替身我把近几个 ES 版本和 lodash 高频函数的对照关系整理成了表你在迁移的时候可以直接对标lodash 函数原生替代说明_.cloneDeepstructuredClone()Node 17、Chrome 982026 年没有任何理由不用_.groupByObject.groupBy()/Map.groupBy()ES2024Chrome 117、Node 21_.uniq[...new Set(arr)]顺手还省了排序的坑_.sortByArray.prototype.toSorted()ES2023 返回新数组和 lodash 的不可变行为一致_.reverseArray.prototype.toReversed()同上_.get?...和??可选链早就不是新东西了_.pick/_.omit解构 restconst { omitMe, ...rest } obj_.hasObject.hasOwn()ES2022_.find/_.findIndexArray.prototype.find()/findIndex()只要 lodash 不是在传字符串路径原生就能扛_.flattenArray.prototype.flat()ES2019其中最典型的是_.get。老代码里常见的这种写法const name _.get(user, profile.name, 匿名);用可选链和空值合并写起来反而更直观const name user?.profile?.name ?? 匿名;注意这俩语义上有细微差别lodash 的_.get在profile为null时会返回默认值可选链对null和undefined的处理也差不多差别在??只对null/undefined生效不会把0、、false吞掉。这个差别在实际业务里反而是更准确的行为。4.2 那些没有原生替代的函数值得自己动手写真正麻烦的是debounce、throttle、deepMerge、isEqual这几个。以debounce为例2026 年依旧没有标准的原生实现。但这不意味着你要因此留下整个 lodash自己写一个基础版也就十来行function debounce(fn, wait 300) { let timer null; return function (...args) { clearTimeout(timer); timer setTimeout(() fn.apply(this, args), wait); }; } function throttle(fn, interval 300) { let last 0; return function (...args) { const now Date.now(); if (now - last interval) { last now; fn.apply(this, args); } }; }如果你需要的是带 trailing、cancel、promise 支持的完整版可以直接参考 underscore 的源码照着抄一个 30 行的工具文件。这样你的node_modules里就不需要凭空多出 1.4MB 的 lodash 整包依赖树更干净npm audit的告警也少一大截。4.3 迁移 lodash 的正确顺序删 lodash 和删其他包不一样它的调用点会非常分散。我的建议是分三步走。第一步全局搜出所有_.xxx的调用点按函数名分组统计找出你项目里真正依赖的函数清单。第二步把清单里能被原生替代的函数逐个替换这步比较简单纯粹是机械劳动。第三步处理剩下的冷门函数——新建一个utils/lodash-mini.ts从 underscore 或其他小型工具库里挑几个抄进来。全部搞定之后再把package.json里的lodash删掉跑测试。这里有一个很重要的技巧别急着从package.json里删依赖。因为你删了直接依赖之后node_modules里可能还有别的包间接依赖 lodash直接删除会导致那些包在你的 pnpm/npm 解析链里报错。先全局grep from lodash确认项目代码里已经没有引用再执行删除最后跑一遍npm ls lodash看看是谁还在依赖它。5. dotenvNode.js 自己把配置文件读了dotenv 大概是这 5 个包里最轻的一个装完可能也就几百 KB但它带来的问题是多一层不在官方标准里的魔法。以前我们写 Node 服务环境变量的标准姿势是# 安装 dotenv npm install dotenv然后在入口文件第一行require(dotenv).config()再写一个.env文件DB_URLmysql://localhost:3306 API_KEYabc123但从 Node 20.6 开始上面这整套流程可以全删了。5.1 Node 内置的加载方案到了 2026 年node --env-file已经是一个非常成熟的特性。用法很简单node --env-file.env server.js如果你希望 .env 文件不存在时不报错比如本地开发才需要 .env线上环境直接走真实环境变量可以用兼容写法node --env-file-if-missing.env server.js而如果你需要在运行时读取 .env 文件比如测试环境隔离Node 20.12 还提供了process.loadEnvFile()process.loadEnvFile(.env); // 等价于以前 require(dotenv).config()你可以直接把入口文件的require(dotenv).config({ path: .env })换成这一行。5.2 迁移 dotenv 时容易踩的三个坑第一个坑是 npm scripts 的问题。以前很多项目都是这样的scripts: { start: node server.js }dotenv 是代码里加载的所以npm start不用改。但换成--env-file之后你的启动命令必须改成scripts: { start: node --env-file.env server.js }如果忘了改线上环境可能直接报DB_URL is not defined因为环境变量根本没被加载进来。第二个坑是解析规则差异。dotenv 的老版本支持export KEYvalue这样的写法Node 内置的解析器不支持。如果你有团队里有人习惯了在 .env 里写export迁移后会导致变量失效。另外多行值和值里带引号的复杂场景两边解析也略有差异迁移前最好做一次环境变量全量对比。第三个坑是变量优先级的理解。dotenv 默认不会覆盖已有的环境变量Node 的--env-file同样如此都是已存在的优先。但你如果以前用了dotenv.config({ override: true })或者dotenv-expand做变量展开那要特别注意--env-file的展开行为在 Node 22 之后才比较完善。我建议高版本 Node 使用--env-file并发需求不复杂就少叠加dotenv-expand。6. cors框架更迭顺便带走的老伙计严格来说cors 不是一个重包它的代码量很小但它折射出一个趋势新一代框架已经把跨域这件事内置到骨子里了你根本不需要单独为它装一个包。6.1 Express 时代的老朋友2026 年还有多少存在感以前写 Express 项目几乎必然是这样const express require(express); const cors require(cors); const app express(); app.use(cors());如果你还在维护 Express 老项目cors 包同样可以用只是它自身也是在内部帮你拼 response header 和预检逻辑这本质上不是一个业务强相关的库而是标准行为的补丁。但到了 2026 年新项目选择 Hono、Fastify、NestJS 这些框架的概率越来越大。这些框架要么自带 CORS 中间件要么把 CORS 配置集成进框架配置里// Hono import { cors } from hono/cors; app.use(cors()); // Fastify await fastify.register(require(fastify/cors), { origin: https://app.example.com, }); // NestJS app.enableCors({ origin: https://app.example.com });而如果你是在 Next.js 的 Route Handlers 里开发 API你甚至不需要任何中间件直接在Response上设置 header 就行。这种情况下cors包自然就成了历史遗留物。6.2 手写 CORS 中间件也就十几行退一万步说就算你还在用 Express自己写一个足够稳的 CORS 中间件也不难。关键是处理预检请求和反射 Origin两个逻辑const ALLOWED_ORIGINS new Set([https://app.example.com]); function cors(req, res, next) { const origin req.headers.origin; if (origin ALLOWED_ORIGINS.has(origin)) { // 允许携带凭证时不能简单回 * res.setHeader(Access-Control-Allow-Origin, origin); res.setHeader(Vary, Origin); res.setHeader(Access-Control-Allow-Credentials, true); } res.setHeader( Access-Control-Allow-Methods, GET,HEAD,PUT,PATCH,POST,DELETE ); res.setHeader( Access-Control-Allow-Headers, req.headers[access-control-request-headers] ?? Content-Type,Authorization ); res.setHeader(Access-Control-Max-Age, 86400); // 预检请求直接短路 if (req.method OPTIONS) { return res.sendStatus(204); } next(); }这段代码已经覆盖了日常 95% 的场景。真正复杂的情况其实是多个来源 允许凭证 需要反射 Allow-Headers但真遇到这种需求大概率应该去配置网关或反向代理而不是在应用层中间件里纠结。7. 删包之前先做这几步避免删了又装回来很多人的删包流程是删掉 package.json 里的依赖跑一遍测试绿了就觉得完事了。但现实往往是上线第二天线上报错然后灰溜溜地把包装回来。删包之前这几步能帮你把风险降到最低。7.1 先用扫描工具查清楚依赖的真实调用情况不要靠记忆判断包有没有被用直接用工具扫。npx depcheck可以列出声明了但没有任何代码引用的依赖npx knip更严格一些还能找出未导出的文件。如果你只想快速 grep也可以用grep -rn from dayjs src --include*.{ts,tsx,js,jsx} grep -rn require(lodash) src -r --include*.{ts,tsx,js,jsx}一句话先搞清楚这个包在项目里的真实足迹再决定怎么删。7.2 按模块替换分批验证不要试图在一个大版本的改动里同时替换 5 个包。每个包的影响范围和风险都不一样。我建议按影响面从小到大排序先删 dotenv——改动小风险低入口集中再删 cors——一般只有服务端入口文件用到然后处理 dayjs——调用点可能较多但替换逻辑清晰接着是 axios——如果你的 api 封装层集中会很顺利最后才是 lodash——调用点最分散需要先整理函数清单每替换完一个模块跑一遍相关测试有条件的顺手跑一次构建产物对比。7.3 验证清单与回滚方案我给自己列了一张验证清单同样适用于你所有涉及替换的文件是否有单元测试覆盖浏览器兼容性矩阵Temporal、Object.groupBy、toSorted 这些是否在你的目标浏览器版本内Node 版本确认node -v至少 20.6 才能用--env-file22 体验更好TypeScript 类型Temporal 的类型定义是否已包含在 TS lib 中res.json()的返回类型是否明确构建体积对比替换前后dist体积变化依赖树复查npm ls lodash确认没有其他间接依赖在强制拉取回滚方案其实很简单因为你每一步替换都是独立的 commit出问题直接git revert那个 commit 就行。关键是在替换过程中不要把变量名、文件结构、请求格式混在一起改否则出问题都不知道回滚谁。我自己做这轮清理时最大的体会是删包最大的阻力从来不是技术而是不知道它还活着以及担心它还有隐晦的用法。很多团队写代码时根本不关心依赖树里有什么等到npm audit报了几十个漏洞才想起来看一眼。如果你打算做同样的清理我的建议是先挑一个影响面最小的包下手把整个流程跑顺了再去处理更核心的依赖。先从 cors 或 dotenv 开始千万别一上来就动 lodash。