2026年值得删除的5个前端依赖包:用原生API替代

前阵子给一个维护了两年的老项目做例行依赖升级,跑完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. dayjs:Temporal 落地之后,日期库终于可以退场了

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/15"

dayjs 一直吹"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.ZonedDateTime+Intl.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.get('/api/user')).data; // 现在 const user = await request('/api/user'); // request 函数的类型签名 async function request(path: string, options?: RequestInit): Promise { // ... 略 return res.json() as Promise; }

如果你在项目里维护了一个api/目录,所有请求都走那个封装好的 client,那迁移成本就非常低,只改一个文件即可。反过来,如果项目里散落着上百个axios.get裸调用,我建议你分模块逐个迁移,而不是一把梭。

3.3 什么情况下继续留着 axios

如果你的项目已经深度使用 axios 的拦截器体系,且团队中多数人都习惯了拦截器的写法,那就继续留着,没必要为了删而删。另外,如果你做的是 Node 服务端请求库,需要处理 HTTP/2、自动重试、轮询这类高级特性,fetch的包装层会越写越厚,最终还是落入"自己造轮子"的坑。这种时候,用 axios 反而省心。

但如果你是 2026 年新起的项目,我强烈建议直接以 fetch + 一个 50 行的 request 封装起步,你会发现依赖真的可以很少。

4. lodash:ES2023 之后,工具链上最重的"安全带"

lodash 绝对是老项目依赖清理的重灾区。我在一个后台项目里见过开发者为了用_.get和_.cloneDeep两个函数,把整个 lodash 装了进来。而这两个函数,在今天都有非常成熟的原生替代。

4.1 先看清哪些函数有原生替身

我把近几个 ES 版本和 lodash 高频函数的对照关系整理成了表,你在迁移的时候可以直接对标:

lodash 函数原生替代说明
_.cloneDeepstructuredClone()Node 17+、Chrome 98+,2026 年没有任何理由不用
_.groupByObject.groupBy()/Map.groupBy()ES2024,Chrome 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. dotenv:Node.js 自己把配置文件读了

dotenv 大概是这 5 个包里"最轻"的一个,装完可能也就几百 KB,但它带来的问题是"多一层不在官方标准里的魔法"。以前我们写 Node 服务,环境变量的标准姿势是:

# 安装 dotenv npm install dotenv

然后在入口文件第一行require('dotenv').config(),再写一个.env文件:

DB_URL=mysql://localhost:3306 API_KEY=abc123

但从 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 KEY=value这样的写法,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 个包。每个包的影响范围和风险都不一样。我建议按"影响面从小到大"排序:

  1. 先删 dotenv——改动小,风险低,入口集中
  2. 再删 cors——一般只有服务端入口文件用到
  3. 然后处理 dayjs——调用点可能较多,但替换逻辑清晰
  4. 接着是 axios——如果你的 api 封装层集中,会很顺利
  5. 最后才是 lodash——调用点最分散,需要先整理函数清单

每替换完一个模块,跑一遍相关测试,有条件的顺手跑一次构建产物对比。

7.3 验证清单与回滚方案

我给自己列了一张验证清单,同样适用于你:

  • 所有涉及替换的文件是否有单元测试覆盖
  • 浏览器兼容性矩阵:Temporal、Object.groupBy、toSorted 这些是否在你的目标浏览器版本内
  • Node 版本确认:node -v,至少 20.6 才能用--env-file,22+ 体验更好
  • TypeScript 类型:Temporal 的类型定义是否已包含在 TS lib 中,res.json()的返回类型是否明确
  • 构建体积对比:替换前后dist体积变化
  • 依赖树复查:npm ls lodash确认没有其他间接依赖在强制拉取

回滚方案其实很简单:因为你每一步替换都是独立的 commit,出问题直接git revert那个 commit 就行。关键是在替换过程中不要把变量名、文件结构、请求格式混在一起改,否则出问题都不知道回滚谁。

我自己做这轮清理时最大的体会是:删包最大的阻力从来不是技术,而是"不知道它还活着"以及"担心它还有隐晦的用法"。很多团队写代码时根本不关心依赖树里有什么,等到npm audit报了几十个漏洞才想起来看一眼。如果你打算做同样的清理,我的建议是先挑一个影响面最小的包下手,把整个流程跑顺了,再去处理更核心的依赖。先从 cors 或 dotenv 开始,千万别一上来就动 lodash。