2026年前端工程化:5个可以安全删除的npm包及迁移指南

1. 为什么现在敢删包了:2026 年前端基建的底层变化

过去几年,前端项目里塞满了一堆“历史遗留”依赖,很多人不是不想删,而是不敢删。删了怕构建挂、怕兼容炸、怕同事半夜打电话。但到了 2026 年,情况已经发生了根本性变化,有几个底层条件成熟了,才让“删包”这件事从冒险变成了常规操作。

第一个变化是运行时和浏览器能力的大幅补齐。以前我们装core-js、regenerator-runtime是因为老浏览器不支持Promise、async/await、Array.prototype.flat这些。现在主流浏览器的基线已经非常统一,ES2020甚至ES2022的特性在绝大多数用户环境里都是原生支持的。你打开caniuse

看一眼就知道,很多 polyfill 的覆盖率已经高到没必要再打包进去。

第二个变化是构建工具的成熟。Vite、Rspack、Turbopack 这些工具在 2026 年已经非常稳定,它们对package.json

的依赖解析、tree-shaking、副作用标记都做得比五年前好太多。以前删一个包可能导致整个 chunk 报错,现在构建工具会明确告诉你哪个模块缺失、哪条 import 路径断了,排查成本大幅降低。

第三个变化是Node.js 自身的标准库增强。node:test、node:util、node:fs/promises、fetch这些内置能力已经足够覆盖很多以前必须靠第三方包才能做的事。比如以前你要装node-fetch、rimraf、mkdirp

,现在 Node 原生就能干。

第四个变化是社区共识的转移。以前大家习惯“能装包就装包”,现在越来越多团队开始推行“零依赖优先”策略。尤其是工具链层面的包,维护者自己都在推迁移方案,比如request早就废弃了,node-sass被sass取代,tslint被eslint

取代。你不删,反而是在背技术债。

所以这篇文章不是教你“为了删而删”,而是帮你识别哪些包在 2026 年已经完成了历史使命,继续留着只会增加安装体积、安全审计噪音和升级负担。下面我会逐个拆解 5 个典型包,讲清楚它们为什么可以删、删了之后用什么替代、以及删的过程中容易踩哪些坑。

提示:删包之前一定要先跑一遍完整的构建和测试流程,确认当前项目没有隐式依赖这些包的副作用。很多包被删后报错,不是因为功能缺失,而是因为某处代码偷偷用了它的全局注入。

2. 第一个该删的包:core-js 的按需引入策略已经过时

2.1 core-js 到底在做什么

core-js是前端最经典的 polyfill 库,它给老浏览器补上了Promise、Symbol、Map、Set、Array.from、Object.assign这些现代 API。在 Babel 主导的年代,@babel/preset-env配合core-js@3几乎是标配,useBuiltIns: 'usage'

一开,Babel 会自动帮你按需引入需要的 polyfill 模块。

但问题在于,这套机制在 2026 年已经显得非常笨重。core-js

本身有几百个内部模块,即使按需引入,构建产物里仍然会带上一堆你根本用不到的检测代码。而且它的版本更新非常频繁,每次升级都要重新验证兼容性,维护成本不低。

2.2 为什么现在可以删

核心原因是浏览器基线已经足够高。根据 2026 年的主流浏览器支持情况,ES2020的所有特性在 Chrome、Edge、Firefox、Safari 的最新几个大版本里都是原生支持的。你如果做的是面向现代浏览器的项目,根本不需要为Promise、async/await

、可选链、空值合并这些语法准备 polyfill。

另一个原因是构建工具已经内置了更聪明的降级方案。Vite 的build.target和 Rspack 的target配置可以直接指定目标环境,构建时会自动处理语法降级,不需要你再手动引入core-js

。对于确实需要兼容老环境的项目,也可以只针对特定 API 做局部 polyfill,而不是全量引入。

2.3 删掉之后怎么替代

如果你只是需要语法降级,保留@babel/preset-env或esbuild的 target 配置就够了。如果确实需要某个特定 API 的 polyfill,比如Array.prototype.at,可以直接在入口文件里手写一个几行的实现,或者只引入core-js/actual/array/at这种单模块路径,而不是整个core-js

。

// 以前的做法:全量引入 import 'core-js/stable'; import 'regenerator-runtime/runtime'; // 现在的做法:按需单模块引入,或者干脆不引入 if (!Array.prototype.at) { Array.prototype.at = function (index) { const len = this.length; const relativeIndex = index >= 0 ? index : len + index; return this[relativeIndex]; }; }

2.4 删包时的注意事项

删core-js之前,先检查babel.config.js或.browserslistrc里有没有配置useBuiltIns。如果有,要同步改掉,否则 Babel 会继续尝试注入 polyfill 但找不到包,构建直接报错。另外,检查package.json里有没有regenerator-runtime,它通常和core-js

一起出现,删的时候要一起处理。

还有一个容易忽略的点:有些第三方库内部依赖了core-js,你删掉顶层依赖后,npm 会自动把它提升到node_modules的嵌套层级里,构建时仍然可能被打进去。这时候可以用npm ls core-js

看一下依赖树,确认没有其他包在引用它。

3. 第二个该删的包:node-sass 已经被 sass 完全取代

3.1 node-sass 的历史包袱

node-sass是早期 Sass 编译的主流方案,它基于 LibSass 的 C++ 实现,性能不错,但有一个致命问题:它和 Node.js 版本强绑定。每次 Node 大版本升级,node-sass都要重新编译二进制文件,Windows 用户经常遇到node-gyp

编译失败、Python 版本不匹配、Visual Studio 构建工具缺失等问题。

更麻烦的是,node-sass

在 2020 年之后基本停止维护,LibSass 也被官方标记为 deprecated。继续用它,等于把一个已经停止更新的 C++ 依赖留在项目里,安全审计和升级都会很痛苦。

3.2 sass 的纯 JS 实现已经足够快

sass包(以前叫dart-sass)是 Dart 写的纯 JS 实现,不依赖本地编译,安装即用。它的编译速度在 2026 年已经优化得非常好,对于绝大多数项目来说,和node-sass的性能差距可以忽略不计。而且它支持最新的 Sass 语法,包括@use、@forward、模块系统,这些是node-sass

根本不支持的。

3.3 迁移步骤和配置调整

迁移其实很简单,把package.json里的node-sass换成sass,然后检查构建配置。如果你用的是 Webpack,sass-loader需要确认版本,老版本的sass-loader默认找node-sass,新版本会优先找sass

。

# 卸载旧包 npm uninstall node-sass # 安装新包 npm install -D sass # 检查依赖树,确认没有残留 npm ls node-sass
// webpack.config.js 里的 sass-loader 配置 module.exports = { module: { rules: [ { test: /\.s[ac]ss$/i, use: [ 'style-loader', 'css-loader', { loader: 'sass-loader', options: { // 新版本 sass-loader 会自动使用 sass 包 implementation: require('sass'), }, }, ], }, ], }, };

3.4 迁移后的验证要点

迁移完成后,重点检查几个地方:一是@import语句是否还能正常工作,虽然sass仍然支持@import,但官方推荐迁移到@use;二是除法运算,node-sass和sass对/的处理有差异,新版本要求用math.div()

;三是自定义函数和 mixin 的兼容性,大部分情况下没问题,但涉及底层 API 的地方需要测试。

注意:如果你的项目里用了node-sass的fibers选项来加速同步编译,迁移到sass后这个选项不再支持,需要改成异步编译或者用sass-embedded

包。

4. 第三个该删的包:rimraf 和 mkdirp 已被 Node 原生能力覆盖

4.1 这两个包以前解决什么问题

rimraf是一个跨平台的递归删除工具,因为 Windows 的rm -rf命令和 Unix 不一样,Node 早期的fs.rmdir也不支持递归删除非空目录。mkdirp则是递归创建目录,因为fs.mkdir早期也不支持recursive: true

。

这两个包在构建脚本里非常常见,比如"clean": "rimraf dist"、"prebuild": "mkdirp dist/assets"

。它们本身很小,但属于典型的“历史补丁”,现在 Node 已经原生支持了。

4.2 Node 原生 API 的对应关系

Node.js 从 14.14 开始,fs.rm支持recursive: true和force: true,可以完全替代rimraf。fs.mkdir从 10.12 开始支持recursive: true,可以替代mkdirp

。到了 2026 年,这些 API 已经非常稳定,没有任何理由继续用第三方包。

// 以前用 rimraf const rimraf = require('rimraf'); rimraf.sync('dist'); // 现在用 Node 原生 const fs = require('fs'); fs.rmSync('dist', { recursive: true, force: true }); // 以前用 mkdirp const mkdirp = require('mkdirp'); mkdirp.sync('dist/assets'); // 现在用 Node 原生 fs.mkdirSync('dist/assets', { recursive: true });

4.3 在 package.json 脚本里直接替换

如果你只是在 npm scripts 里用这两个包,可以直接改成 Node 的-e

执行方式,连脚本文件都不用建。

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

4.4 删包时的边界情况

有一个细节需要注意:rimraf在处理符号链接和权限问题时有一些特殊逻辑,比如它默认会重试删除失败的文件。Node 原生的fs.rmSync

在遇到被占用的文件时会直接抛错。如果你的构建环境里有文件锁或者杀毒软件干扰,可能需要加一个重试逻辑。

function safeRm(dir, retries = 3) { for (let i = 0; i < retries; i++) { try { fs.rmSync(dir, { recursive: true, force: true }); return; } catch (err) { if (i === retries - 1) throw err; // 等待一小段时间后重试 const start = Date.now(); while (Date.now() - start < 100) {} } } }

另外,mkdirp有一个mode选项可以设置目录权限,Node 原生的fs.mkdirSync也支持mode

参数,迁移时如果原来有权限配置,记得同步过去。

5. 第四个该删的包:node-fetch 在 Node 18+ 里已经多余

5.1 node-fetch 的定位

node-fetch是一个把浏览器fetchAPI 带到 Node.js 的包。在 Node 18 之前,Node 没有内置fetch,所以服务端发 HTTP 请求要么用http模块手写,要么用axios、request、node-fetch这些第三方库。node-fetch

因为 API 和浏览器一致,在前端转全栈的团队里特别受欢迎。

5.2 Node 原生 fetch 的成熟

Node 18 开始内置了fetch,基于 undici 实现,性能比node-fetch更好,而且 API 完全兼容。到了 2026 年,Node 的 LTS 版本早就过了 18,原生fetch已经是默认能力。你直接在 Node 脚本里写fetch('https://api.example.com')

就能跑,不需要任何 polyfill。

// 以前用 node-fetch const fetch = require('node-fetch'); async function getData() { const res = await fetch('https://api.example.com/data'); return res.json(); } // 现在直接用原生 fetch async function getData() { const res = await fetch('https://api.example.com/data'); return res.json(); }

5.3 迁移时的差异点

虽然 API 基本一致,但有几个细节需要注意。node-fetch的Response.body是一个 Node Stream,而原生fetch的body是一个 Web Stream,如果你用了.pipe()或者stream.pipeline(),需要改成 Web Stream 的写法。另外,原生fetch默认不跟随重定向的某些行为可能和node-fetch

有细微差别,涉及重定向的逻辑要测试一下。

// node-fetch 的流式处理 const res = await fetch(url); res.body.pipe(fs.createWriteStream('file.txt')); // 原生 fetch 的流式处理 const res = await fetch(url); const fileStream = fs.createWriteStream('file.txt'); await stream.Readable.fromWeb(res.body).pipe(fileStream);

5.4 什么情况下还需要保留

如果你的项目需要兼容 Node 16 或更早版本,那node-fetch还有存在价值。但 2026 年还在用 Node 16 的项目,本身就是一个更大的问题。另外,如果你需要node-fetch的一些高级特性,比如自定义 agent、代理配置,原生fetch通过dispatcher

选项也能实现,只是写法不同。

提示:删掉node-fetch后,检查一下package.json里有没有form-data、whatwg-url

这些它的依赖包,它们通常也会一起被清理掉。

6. 第五个该删的包:tslint 早已被 eslint 生态吸收

6.1 tslint 的终结

tslint是 TypeScript 早期的官方 lint 工具,但在 2019 年就被官方标记为 deprecated,推荐迁移到eslint。到了 2026 年,tslint已经彻底停止维护,它的规则集也基本被@typescript-eslint

覆盖。继续留在项目里,只会让编辑器插件冲突、规则重复、升级困难。

6.2 迁移到 eslint 的核心步骤

迁移tslint到eslint不是简单换个包,需要重新配置规则。@typescript-eslint提供了tslint-to-eslint-config

工具,可以自动转换大部分规则,但转换后仍然需要手动调整。

# 安装迁移工具 npx tslint-to-eslint-config # 安装 eslint 相关依赖 npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
// eslint.config.js(扁平配置,2026 年主流写法) import tseslint from '@typescript-eslint/eslint-plugin'; import tsparser from '@typescript-eslint/parser'; export default [ { files: ['**/*.ts', '**/*.tsx'], languageOptions: { parser: tsparser, parserOptions: { project: './tsconfig.json', }, }, plugins: { '@typescript-eslint': tseslint, }, rules: { '@typescript-eslint/no-unused-vars': 'warn', '@typescript-eslint/no-explicit-any': 'warn', // 其他规则按需配置 }, }, ];

6.3 规则映射中的常见坑

tslint和eslint的规则命名不一样,比如tslint的no-unused-variable对应eslint的@typescript-eslint/no-unused-vars。有些规则在eslint里被拆成了多个,有些则合并了。自动转换工具能处理大部分,但像member-ordering、typedef

这种复杂规则,转换后往往需要手动重写。

另外,tslint支持--fix自动修复,eslint也支持,但两者的修复范围不同。迁移后建议先跑一遍eslint --fix

,然后人工 review 改动,避免自动修复引入意外变更。

6.4 删掉 tslint 后的清理工作

删掉tslint包后,记得删除tslint.json配置文件,以及package.json里的tslint相关脚本。如果项目里用了tslint-react、tslint-config-prettier

这些扩展,也要一并清理。编辑器方面,VS Code 的 TSLint 插件需要禁用,改用 ESLint 插件。

旧包替代方案迁移难度主要风险
core-js浏览器原生 + 按需 polyfill中Babel 配置残留导致构建报错
node-sasssass低语法差异和 fibers 选项失效
rimraf/mkdirpNode 原生 fs API低文件锁和权限边界情况
node-fetchNode 原生 fetch低Web Stream 和 Node Stream 差异
tslinteslint + @typescript-eslint高规则映射和自动修复范围差异

7. 删包之后怎么验证没有把项目搞崩

删包不是删完就完事,验证环节才是真正体现经验的地方。我见过太多人删完包直接提交,结果 CI 挂了才发现问题。下面是我自己常用的验证流程,按顺序走一遍,基本能覆盖 95% 的坑。

7.1 依赖树完整性检查

第一步永远是看依赖树。删掉顶层包之后,用npm ls <包名>确认没有其他包在引用它。如果输出里有UNMET DEPENDENCY或者invalid

,说明有包在依赖一个已经不存在的模块,需要处理。

# 检查单个包 npm ls core-js npm ls node-sass npm ls node-fetch # 检查所有依赖的完整性 npm ls --all 2>&1 | grep -i "unmet\|invalid"

7.2 构建产物对比

删包前后各跑一次构建,对比产物大小和 chunk 结构。如果删了包之后产物反而变大,说明构建工具把某些依赖提升到了主 chunk 里,需要检查optimizeDeps或splitChunks

配置。

# 构建并输出分析 npm run build # 如果用了 vite,可以用 rollup-plugin-visualizer npx vite build --mode production

7.3 运行时冒烟测试

构建通过不代表运行时没问题。重点测试几个场景:页面首次加载、路由切换、异步数据请求、表单提交、错误边界。如果项目有 E2E 测试,跑一遍是最稳妥的。没有的话,手动点一遍核心流程。

7.4 CI 环境的特殊处理

本地能跑不代表 CI 能跑。CI 环境通常是干净安装,npm ci会严格按照package-lock.json

安装。删包后一定要更新 lock 文件,否则 CI 可能装到旧版本或者报错。

# 删除 node_modules 和 lock 文件后重新安装 rm -rf node_modules package-lock.json npm install # 或者用 npm ci 验证 lock 文件一致性 npm ci

注意:如果项目用了 monorepo 或者 workspace,删包的影响范围会更大。npm ls在 workspace 里默认只查当前包,需要用--workspaces

参数查全部。

8. 我踩过的几个真实坑和最后的建议

说几个我自己在删包过程中真实踩过的坑,都是文档里不会写的。

第一个坑是隐式全局注入。有些包在被引入时会偷偷往global或window上挂东西,比如老版本的core-js会注入_babelPolyfill标记。你删掉包之后,某段代码里判断window._babelPolyfill

的逻辑就失效了,导致按需加载的 chunk 重复执行。这种问题构建不会报错,只有运行时才能发现。

第二个坑是类型声明的残留。删掉node-fetch后,@types/node-fetch可能还在devDependencies里,TypeScript 编译时仍然能找到类型定义,但运行时模块不存在。这种“类型通过、运行报错”的问题特别隐蔽,建议删包时把对应的@types/*

一起清理。

第三个坑是锁文件版本漂移。删包后如果只跑了npm install而没有重新生成 lock 文件,某些间接依赖的版本可能会被提升到不兼容的版本。我的习惯是删包后直接删掉node_modules和package-lock.json

,从头装一遍,确保依赖树是干净的。

最后一个建议:不要一次性删完所有包

。哪怕你确定这 5 个包都能删,也建议分批次来,每次删一个,跑完验证流程再删下一个。这样出问题时能快速定位是哪个包引起的,而不是在一堆变更里大海捞针。删包这件事,稳比快重要。