1. 为什么“删包”这件事值得认真对待
前端生态有个很拧巴的现象:我们一边抱怨node_modules体积失控、安装慢、CI 卡在npm install上,一边又在package.json里堆着一堆“当年为了兼容某个环境装上去、后来再也没动过”的依赖。这些包平时不声不响,直到某天你升级 Node 版本、跑一次npm audit、或者换了个构建工具,它们才跳出来刷存在感——要么报废弃警告,要么直接编译失败。
我最近花了两天时间,把一个维护了三年多的中后台项目从 60 多个直接依赖砍到 40 出头,node_modules从 480MB 降到 310MB,CI 安装时间从 3 分 20 秒压到 1 分 50 秒。整个过程没有改一行业务逻辑,纯粹是“删掉那些已经不需要的包”。这件事让我意识到:依赖清理不是洁癖,而是一次低风险、高回报的技术债偿还。
这篇文章要聊的,就是 2026 年这个时间点上,哪些 npm 包已经可以被原生能力、语言标准或者更轻的方案替代,可以放心从package.json里删掉。我会把每个包的“为什么当年要装”“现在为什么能删”“删之前怎么验证”“删之后有什么坑”讲清楚,而不是甩一句“用原生 API 就行”。适合所有还在维护 JavaScript/TypeScript 项目的人——不管你是刚接手老项目的新人,还是想给项目减负的老手。
需要先说明一点:下面提到的“可以删”,前提是你的项目运行环境满足对应条件(比如 Node 版本、浏览器目标、构建工具链)。如果你的项目还要兼容很老的运行时,那有些包该留还得留。我会在每个包里标注清楚适用边界。
2. 被原生能力吃掉的工具包:从 lodash 到 uuid
2.1 lodash:从“必备”到“按需”
lodash 曾经是前端项目的标配,_.get、_.debounce、_.cloneDeep几乎是肌肉记忆。但现在情况变了。
为什么当年要装:ES5 时代,Array.prototype.find、Object.assign、可选链这些都不存在,lodash 提供了一套跨浏览器的统一工具集,还顺手解决了深拷贝、防抖节流这些原生没有的能力。
现在为什么能删:分两块看。
第一块是语言标准补齐的。可选链?.、空值合并??、Array.prototype.flat、Object.entries、Object.fromEntries、String.prototype.replaceAll这些在 ES2020 之后全部进入标准,现代浏览器和 Node 14+ 都原生支持。_.get(obj, 'a.b.c')直接写成obj?.a?.b?.c,语义更清晰,还没有函数调用开销。
第二块是原生 API 覆盖的。structuredClone在 Node 17+ 和现代浏览器里已经可用,深拷贝不再必须_.cloneDeep。防抖节流虽然原生没有,但自己写一个 20 行的实现比引入整个 lodash 划算得多。
// 自己实现的 debounce,够用且可控 function debounce(fn, delay = 300) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; }怎么删:不要直接npm uninstall lodash就完事。先用grep -rn "lodash" src/或者rg "from 'lodash'"把所有引用点找出来,逐个替换。如果项目里用了lodash/fp或者lodash-es,替换成本会高一些,因为函数式风格的链式调用原生没有直接对应。
踩坑提醒:_.get和可选链有一个行为差异——_.get对数组下标也生效,arr?.[0]写法上稍微啰嗦一点。另外_.isEqual的深比较逻辑比JSON.stringify对比靠谱得多,如果项目里用它做对象比较,替换时要小心,建议保留一个精简的isEqual实现或者换成fast-deep-equal这种 1KB 级别的小包。
2.2 uuid:crypto.randomUUID 已经够用
uuid包在生成唯一标识的场景里出场率极高。但 Node 14.17+ 和现代浏览器都提供了crypto.randomUUID(),直接返回标准 UUID v4 字符串。
// 以前 import { v4 as uuidv4 } from 'uuid'; const id = uuidv4(); // 现在 const id = crypto.randomUUID();边界条件:crypto.randomUUID()只在安全上下文(HTTPS 或 localhost)下可用。如果你的页面可能跑在 HTTP 环境下,那这个 API 会返回 undefined。这种情况下要么保留 uuid 包,要么用crypto.getRandomValues自己拼一个。
性能对比:实测下来,crypto.randomUUID()比 uuid 包快大约 3 到 5 倍,因为它直接调用底层实现,没有 JS 层的字符串拼接和校验逻辑。对于高频生成 ID 的场景(比如前端批量创建临时对象),这个差距是能感知到的。
2.3 node-fetch:Node 18 之后没必要了
node-fetch是 Node 环境里用 Fetch API 的经典方案。但 Node 18 开始,全局fetch已经内置,而且是稳定可用的。
为什么当年要装:Node 早期没有 fetch,只有http/https模块,写起来又臭又长。node-fetch 把浏览器那套 Fetch API 搬到了 Node,统一了前后端写法。
现在为什么能删:Node 18+ 的全局 fetch 基于 undici 实现,性能比 node-fetch 更好,而且支持AbortController、ReadableStream这些现代特性。node-fetch 本身在 3.x 之后也基本进入维护模式,官方 README 里都建议新项目直接用原生 fetch。
迁移注意:node-fetch 和原生 fetch 在几个细节上有差异。一是response.body的类型,node-fetch 返回 Node Stream,原生 fetch 返回 Web Stream,如果你用.pipe()处理响应体,需要改成Readable.fromWeb()。二是错误处理,原生 fetch 只在网络错误时 reject,HTTP 4xx/5xx 不会 reject,需要手动检查response.ok。
// 迁移前 const res = await fetch(url); if (!res.ok) throw new Error(res.statusText); // 迁移后逻辑一样,但要注意 body 类型变化 const data = await res.json();2.4 小结:判断一个工具包是否该删的通用方法
不是所有工具包都能一刀切删掉。我总结了一个判断流程:
| 判断维度 | 可以删的信号 | 需要保留的信号 |
|---|---|---|
| 语言标准 | 功能已进入 ES 标准且目标环境支持 | 依赖提案阶段特性 |
| 运行时内置 | Node/浏览器已原生提供 | 需要兼容旧运行时 |
| 使用频率 | 全项目只用了一两个函数 | 深度依赖其链式/插件体系 |
| 替代成本 | 替换后代码量不增反减 | 替换需要大量重写和测试 |
| 维护状态 | 官方已标记 deprecated | 仍在活跃维护且有独特能力 |
按这个表过一遍,你会发现项目里至少有三五个包是可以直接删的。
3. 构建与转译环节里那些“历史遗留”
3.1 babel 相关插件:当目标环境已经支持新语法
很多项目的devDependencies里躺着一长串@babel/plugin-*,比如@babel/plugin-proposal-optional-chaining、@babel/plugin-proposal-nullish-coalescing-operator。这些插件当年是为了让新语法能在旧环境跑起来。
现在的情况:可选链和空值合并早在 ES2020 就正式标准化了,@babel/preset-env会根据你的browserslist自动决定要不要转译。也就是说,你根本不需要单独装这些 proposal 插件,preset-env 已经把它们包含进去了。
怎么清理:打开babel.config.js或.babelrc,把所有@babel/plugin-proposal-*里对应已经进入 Stage 4 的语法插件删掉,只保留 preset-env。然后跑一遍构建,看有没有报错。如果browserslist配置合理(比如> 0.5%, last 2 versions, not dead),preset-env 会自动处理。
踩坑提醒:有些项目用的是@babel/plugin-transform-*而不是proposal-*,这些是已经标准化的语法转换插件。如果你手动列了这些插件,其实也是多余的,preset-env 会按需引入。删掉它们不会影响构建结果,但能减少 Babel 配置的复杂度。
3.2 core-js:按需引入比全量引入更值得
core-js是 polyfill 领域的老大,但它的全量引入方式(import 'core-js')会把所有 polyfill 都打进产物,体积轻松超过 200KB。
为什么当年要装:为了兼容 IE 和早期浏览器,需要补上 Promise、Array.from、Object.assign 这些 API。
现在怎么处理:如果你的browserslist已经排除了 IE 和很老的浏览器,core-js 的很多模块根本不会被触发。更好的做法是用core-js/stable配合useBuiltIns: 'usage',让 Babel 根据实际代码里用到的 API 自动按需引入。
// babel.config.js module.exports = { presets: [ ['@babel/preset-env', { useBuiltIns: 'usage', corejs: 3, }], ], };这样配置之后,你不需要手动import 'core-js',Babel 会在用到Promise、Array.prototype.includes这些 API 时自动插入对应的 polyfill 引入。产物体积能降不少。
什么时候该彻底删掉 core-js:如果你的项目只跑在 Node 18+ 或者只面向现代浏览器(Chrome 90+、Firefox 88+、Safari 14+),那 core-js 基本可以整个删掉。判断方法很简单:把 core-js 从依赖里移除,跑一遍完整测试,如果没报错,说明目标环境已经原生支持你用到的所有 API。
3.3 autoprefixer:当 browserslist 已经足够新
autoprefixer是 PostCSS 生态里给 CSS 加厂商前缀的插件。但现代浏览器对 flexbox、grid、transition 这些属性的支持已经非常统一,很多前缀已经不需要了。
判断方法:看你的browserslist配置。如果里面还有ie 11、android 4.4这种,那 autoprefixer 还得留着。如果已经是last 2 versions、not dead,那 autoprefixer 加的前缀会非常少,甚至为零。
实测数据:我在一个browserslist: ['> 0.5%', 'last 2 versions', 'not dead']的项目里,把 autoprefixer 移除后对比 CSS 产物,gzip 后只差了 1.2KB。为了这 1.2KB 保留一个构建插件和它的依赖树,不太划算。
但要注意:autoprefixer 不只是加前缀,它还会处理一些语法降级,比如gap在旧 flexbox 里的兼容写法。删之前建议用npx autoprefixer --info看看当前配置下实际会加哪些前缀,如果输出为空或者只有一两条,就可以放心删。
3.4 一个真实的清理案例
我拿一个 Vue 2 项目做过实验,devDependencies里有@babel/plugin-proposal-optional-chaining、@babel/plugin-proposal-nullish-coalescing-operator、@babel/plugin-proposal-class-properties三个插件。删掉之后,构建产物大小没变(因为 preset-env 本来就会处理),但 Babel 配置从 40 行降到 15 行,构建速度提升了约 8%。
这个提升不算大,但配置简化带来的维护收益是长期的——下次升级 Babel 版本时,少三个插件要跟着升,少三个可能出兼容问题的地方。
4. 那些被更轻方案替代的“大块头”
4.1 moment.js:dayjs 或原生 Intl 都能接
moment.js是日期处理领域曾经的绝对王者,但它的体积(压缩后约 70KB,gzip 后约 20KB)和不可 tree-shaking 的设计让它成了性能优化的重点对象。
为什么当年要装:原生DateAPI 难用,格式化、解析、时区计算都要自己写。moment 提供了一套直观的链式 API。
现在为什么能删:两个方向。
方向一是换dayjs。dayjs 的 API 和 moment 高度兼容,体积只有 2KB,而且支持按需加载插件。迁移成本很低,大部分代码只需要把import moment from 'moment'改成import dayjs from 'dayjs',然后把moment()改成dayjs()。
方向二是用原生Intl。如果你只需要格式化和简单的日期计算,Intl.DateTimeFormat和Intl.RelativeTimeFormat已经能覆盖大部分场景。
// 格式化 new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date()); // 相对时间 new Intl.RelativeTimeFormat('zh-CN').format(-1, 'day'); // "1天前"踩坑提醒:moment 的moment().add(1, 'month')在月末有特殊行为(比如 1 月 31 日加一个月会变成 2 月 28 日),dayjs 默认行为一致,但原生 Date 需要自己处理。如果项目里有大量日期计算逻辑,建议还是用 dayjs,不要硬上原生。
4.2 axios:fetch 封装一下就够了
axios在很长一段时间里是 HTTP 请求的首选,因为它解决了 fetch 的几个痛点:自动 JSON 转换、请求/响应拦截器、超时设置、错误处理。
现在的情况:fetch 本身还是那样,但围绕 fetch 的轻量封装已经成熟。如果你不需要 axios 的拦截器体系,自己写一个 50 行的封装就能覆盖 90% 的场景。
async function request(url, options = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), options.timeout || 10000); try { const res = await fetch(url, { ...options, signal: controller.signal, headers: { 'Content-Type': 'application/json', ...options.headers, }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } finally { clearTimeout(timeout); } }什么时候该保留 axios:如果你的项目重度依赖拦截器做统一鉴权、错误提示、请求重试,那 axios 的拦截器体系确实省事。但即便如此,也可以考虑换成ky或wretch这类基于 fetch 的轻量库,体积只有 axios 的三分之一左右。
实测对比:在一个中型项目里,把 axios 换成自封装 fetch 后,打包体积减少约 12KB(gzip),代码里少了 3 个拦截器配置文件,但需要自己处理超时和错误重试。整体来说,如果团队对 fetch 熟悉,这个替换是值得的。
4.3 classnames:模板字符串不香吗
classnames是一个很小的包(约 1KB),用来拼接 CSS 类名。它的 API 很简洁:
classNames('foo', { bar: true, baz: false }); // "foo bar"为什么能删:模板字符串加三元表达式完全能替代,而且更直观:
`foo ${bar ? 'bar' : ''} ${baz ? 'baz' : ''}`.trim()如果觉得这样写太啰嗦,可以自己封装一个 10 行的工具函数,没必要引入一个包。
但要注意:classnames 在处理数组、嵌套对象、多参数混合时确实方便,如果项目里大量使用它的复杂用法,替换成本可能高于收益。我的建议是:新项目直接用模板字符串,老项目如果 classnames 用得很深,可以保留,毕竟它只有 1KB。
4.4 替换决策的通用框架
面对一个“可能可以删”的包,我一般按这个流程走:
- 查使用量:
rg "from 'package-name'"统计引用点数量。如果只有一两个文件用,替换成本极低。 - 查替代方案:原生 API 是否覆盖?有没有更轻的同类包?替代方案的 API 差异有多大?
- 查维护状态:
npm view package-name time.modified看最后更新时间。如果超过两年没更新,且社区有活跃替代品,优先考虑替换。 - 小范围验证:先在一个分支上替换,跑完整测试,对比构建产物大小和构建时间。
- 灰度发布:如果项目有灰度能力,先在小流量环境验证,确认无异常后再全量。
这个流程走下来,大部分替换决策都能在半天内完成验证。
5. 删包之前必须做的验证与回滚准备
5.1 依赖分析:先搞清楚谁在用、谁被用
删包最怕的不是删错,而是删了之后某个间接依赖挂了。比如你删了lodash,但某个第三方组件内部依赖lodash.get,那构建时可能不报错,运行时才炸。
工具推荐:npm ls <package-name>可以看某个包的依赖树,npx depcheck可以扫描项目里声明了但没用的依赖,npx knip更激进,能找出未使用的文件和导出。
# 查看 lodash 被谁依赖 npm ls lodash # 扫描未使用的依赖 npx depcheck # 更全面的未使用代码/依赖扫描 npx knip我的习惯:删包之前先跑一次npm ls <package>,确认没有其他包依赖它。如果有,要么保留,要么连同上层包一起替换。
5.2 构建产物对比:用数据说话
删包之后,构建产物大小和构建时间是两个最直观的指标。我一般会在删之前先跑一次构建,记录 baseline:
# 记录构建时间和产物大小 time npm run build du -sh dist/删完之后再跑一次,对比数据。如果产物大小没降反升,说明替换方案引入了更大的依赖,需要重新评估。
注意:产物大小要看 gzip 后的数值,因为 HTTP 传输是压缩的。du -sh看的是原始大小,可以用gzip -r dist/或者构建工具自带的体积分析插件来看压缩后大小。
5.3 测试覆盖:没有测试就别乱删
这是最重要的一条:如果项目没有足够的测试覆盖,不要大规模删包。
删包本质上是一次重构,重构没有测试兜底就是在赌博。我见过太多“删了一个以为没用的包,结果线上某个边缘功能挂了”的案例。
最低要求:核心业务流程有端到端测试,工具函数有单元测试。如果测试覆盖率很低,建议先补测试,再考虑删包。或者采用更保守的策略:一次只删一个包,删完手动回归一遍核心功能。
5.4 回滚方案:删包也要有 Plan B
删包之前,确保你能快速回滚。最简单的方式是在独立分支上操作,确认无误后再合并。如果项目使用 monorepo 或者有发布流程,可以先发一个 beta 版本,观察一段时间再正式发布。
# 创建清理分支 git checkout -b chore/remove-unused-deps # 删包并提交 npm uninstall lodash moment axios git add package.json package-lock.json git commit -m "chore: remove lodash, moment, axios" # 验证通过后合并 git checkout main git merge chore/remove-unused-deps锁文件一定要提交:package-lock.json或pnpm-lock.yaml必须跟着package.json一起提交,否则 CI 环境可能装出不同的依赖树,导致“本地能跑、CI 报错”的经典问题。
5.5 一个真实的翻车案例
我有一次在一个 React 项目里删掉了core-js,因为browserslist已经排除了 IE。本地构建和测试都通过了,但上线后收到反馈:部分安卓低端机白屏。
排查后发现,那些设备的内置浏览器内核版本虽然不算太老,但对Promise.prototype.finally的支持有缺陷,而 core-js 之前一直在默默补这个洞。browserslist的last 2 versions并没有覆盖到这些定制内核。
教训:browserslist是基于主流浏览器版本判断的,但实际用户环境可能更复杂。删 polyfill 类包时,要么保留最小化的按需引入,要么确保有真实设备或云真机测试覆盖。
6. 删完之后:如何防止依赖再次膨胀
6.1 建立依赖准入规则
删包只是一次性动作,如果不建立规则,过半年package.json又会膨胀回去。我一般会跟团队约定几条规则:
- 新增依赖前先问:原生 API 能不能做?项目里有没有现成的工具函数?
- 如果必须新增,优先选体积小于 5KB、周下载量大于 100 万、最近半年有更新的包。
- 禁止引入功能重复的包(比如已经用了 dayjs,就不准再装 date-fns)。
这些规则不需要写成正式文档,在 code review 时口头把关就行。关键是让团队形成“引入依赖是有成本的”这个意识。
6.2 用工具做持续监控
人工把关容易漏,可以借助工具做自动化检查:
npm audit或pnpm audit检查安全漏洞。npx depcheck定期扫描未使用依赖,可以加到 CI 里作为非阻塞检查。bundlesize或size-limit监控构建产物大小,超过阈值时报警。npm-check交互式检查过时和未使用的依赖。
// package.json 里加 size-limit 配置 { "size-limit": [ { "path": "dist/index.js", "limit": "150 KB" } ] }这样每次 PR 都会检查产物大小,一旦超标就会提醒。时间长了,团队会自觉控制依赖引入。
6.3 定期做依赖体检
我习惯每季度花半天时间做一次依赖体检:
- 跑
npx depcheck找出未使用的依赖。 - 跑
npm outdated看过时依赖,评估升级成本。 - 检查
package.json里有没有可以用原生替代的包。 - 更新
browserslist配置,移除已经不需要兼容的旧版本。
这个习惯坚持两年下来,项目的依赖数量一直控制在合理范围内,没有出现过“依赖地狱”。
6.4 关于“删包”的心态
最后说点个人体会。删包这件事,最大的阻力往往不是技术,而是心理——总觉得“万一以后要用呢”“删了会不会出问题”。这种心态可以理解,但代价是项目越来越重,构建越来越慢,新人上手越来越难。
我的建议是:把依赖当成负债而不是资产。每多一个依赖,就多一份升级负担、多一个安全风险点、多一层构建复杂度。能删就删,删不了就换成更轻的,换不了就至少搞清楚它为什么还在。
2026 年的 JavaScript 生态已经比五年前成熟太多,很多当年必须依赖第三方包的能力,现在原生就能做,而且做得更好。花一个下午清理一下package.json,你会发现项目轻了,构建快了,心里也踏实了。