2026年npm依赖体检:用原生API替代moment、lodash等5个常用包
每年年初我都会把自己维护的工程翻出来做一次“依赖体检”打开package.json从上到下扫一遍光看名字已经能想起一堆跟它们纠缠过的夜晚。2026 年再做这件事的时候我发现有一批曾经在项目里出生入死的老熟客是真的可以请出去了。不是它们不好而是环境变了。浏览器和 Node 的内置能力一年比一年完整当年安装它们是因为“没有别的选择”现在再用就是纯粹的负重。今天就拿我一圈项目里最典型的 5 个 npm 包开刀moment、lodash、axios、uuid、dotenv。我会逐个说清楚当年为什么装、现在为什么能删、替换时有哪些坑还有一套我自己用的“删除依赖体检流程”照着做能少踩很多雷。如果你是维护中后台项目、Node 服务或者自己也发布 npm 包这篇文章应该对你有用。先说一个态度删依赖不是跟风也不是为了显得自己“原生党”很高端。判断一个 npm 包该不该删标准其实非常简单明确下面展开聊。1. 为什么 2026 年这些 npm 包终于可以被请走了1.1 “内置能力”已经不是当年那个水平了很多人对原生 API 的印象还停留在 2018 年左右其实这几年标准和运行时变化非常快。ES2019 之后可选链、空值合并、Object.fromEntries、structuredClone陆续成为基线能力浏览器端的fetch、crypto.randomUUID()、AbortController早就不是实验特性Node 这边从 20.6 开始支持--env-file从 14.17 开始就有crypto.randomUUID()。把时间点放到 2026 年主流项目的运行环境大概率是 Node 20/22/24 LTS浏览器基于 Chromium 内核或者新版 Safari/ Firefox这些 API 的可用性已经不需要打问号。换句话说当年为了填坑装的包现在坑已经被人踩平了。我特别想提一个被低估的理由供应链安全。2021 年以后npm 生态里因为依赖传递引发的安全事件多了很多你装了moment实际拉下来的是几十个传递依赖。每少一个包你的npm audit就少一片需要盯的报告面。从维护成本来看删包不是可有可无而是实打实的安全投资。1.2 三个判断标准决定一个依赖能不能删我不会告诉你“这些包必须马上删完”那样太不负责任。我的实践里用的是下面三个条件同时满足才动手原生 API 已经覆盖你项目里实际用到的那部分功能。你用moment只是格式化时间、算相对时间那原生Intl完全能接住如果你们用moment-timezone做全球多时区调度引擎那先别急。关键看使用范围不是看包总共有多少功能。你的运行环境基线已经达标。这一点要查清楚。团队维护的 PC 端浏览器、服务器 Node 版本、是否有必须兼容的老系统都要列为硬指标。环境没到再先进的原生 API 都白搭。删除后的收益明显大于改动成本。如果是几十处引用的工具函数替换就半天删掉后构建体积小了几百 KB肯定值得如果是几千行业务代码到处传递 moment 对象改起来伤筋动骨那不如先封装再逐步替换。用这三条过一遍你会发现大部分项目里那 5 个包都符合“可以删”的条件。下面我按包一个一个拆。2. moment.js原生 Intl 已经把日常时间处理接住了2.1 当年它解决的是“格式化难、解析难”的问题moment在我刚入行那几年是神兵利器。JavaScript 原生Date对象有多难用老前端都懂getMonth()从 0 开始、时间戳是毫秒、ISO 字符串解析在不同浏览器里行为不一致。moment把这些问题全部封装起来还顺手提供了大量链式 API、插件系统、时区支持所以那会儿几乎所有项目都会装它。但它的代价也很明显包体积大完整版加上语言包和时区数据动辄 300-400 KB对象是可变的moment()返回的对象内部状态能直接被改在多处共用同一个实例时特别容易出隐蔽 bug再加上这个库早就进入了维护模式除了安全修复基本不再加功能。你留着它既吃亏又没盼头。到了 2026 年浏览器和 Node 里已经有一套完整的国际化能力Intl.DateTimeFormat处理格式化Intl.RelativeTimeFormat处理“3 天前”“5 小时后”Date配合 UTC 方法处理基础日期计算。日常业务里九成的时间操作原生 API 都能干净地搞定。2.2 原生替换实操格式化、相对时间、日期计算替换的核心思路是不要找“某项功能最像的替代函数”而是重新抽象你真正需要的业务需求。比如moment().format(YYYY-MM-DD)很多项目只是要一个“本地时间的日期字符串”那用原生可以这样实现function formatDate(date new Date(), pattern YYYY-MM-DD) { const year date.getFullYear() const month String(date.getMonth() 1).padStart(2, 0) const day String(date.getDate()).padStart(2, 0) return pattern .replace(YYYY, year) .replace(MM, month) .replace(DD, day) }你可能会说“这不还得自己写吗”对但这就是删包的真实状态一部分逻辑直接用原生 API另一部分用一个十几行的工具函数顶替仍然比引入一个 300KB 的库划算得多。相对时间用Intl.RelativeTimeFormat更优雅并且天然支持多语言const rtf new Intl.RelativeTimeFormat(zh-CN, { numeric: auto }) console.log(rtf.format(-3, day)) // 3天前 console.log(rtf.format(1, hour)) // 1小时后日期加减用原生Date其实不难关键是先把“年/月/日”拆出来处理function addDays(date, days) { const result new Date(date) result.setDate(result.getDate() days) return result } function addMonths(date, months) { const result new Date(date) result.setMonth(result.getMonth() months) return result }这里有一个经验处理月份加减时要小心setMonth(3)遇到 3 月 31 日这种边界会溢出到 5 月 1 日。实际业务里我建议在替换代码时把这些边界写成单测一次性确认清楚。下面把我项目里最常见的替换对照列出来moment 用法原生替代方案moment().format(YYYY-MM-DD HH:mm:ss)手写formatDate工具函数moment().startOf(day).valueOf()new Date().setHours(0, 0, 0, 0)moment().subtract(7, days)new Date(Date.now() - 7 * 86400000)moment().fromNow()Intl.RelativeTimeFormatmoment(value).isValid()手写日期合法性校验必须检查NaNmoment.utc().format()new Date().toISOString()这里要特别提醒Date的parse行为在不同运行时之间仍然有差异所以“解析用户输入的日期字符串”这个场景原生 API 并没有完美兜底。我自己的做法是非受控格式的日期解析继续用dayjs或者更精确的解析库如果只是标准 ISO 字符串、时间戳或者YYYY-MM-DD格式原生完全没问题。2.3 什么时候动不了它如果项目里有很多函数传moment对象作为参数甚至有人直接往对象上挂自定义属性那替换成本会很高。这时候我不会建议你一两周内强删而是先做一个时间工具层把业务代码和 moment 隔离先封装自己的dateUtils.js所有外部调用都走这里内部实现从 moment 换成原生跑完回归再删包。另外如果你的业务是复杂时区调度、国际会议排期、跨时区报表不要只看到Intl就上头moment-timezone的 IANA 时区数据换算能力原生依然没法直接替代。这种情况我建议要么保留moment-timezone要么调研Temporalpolyfill 过渡。删包是手段不是目标这一定要想清楚。3. lodash原生 ES 已经补齐了 80% 的常用能力3.1 曾经是“数组与对象瑞士军刀”现在是“大部分场景有重复”lodash的辉煌时期对应着 JavaScript 内置方法还不够的日子里。想对数组去个重你要么写filter indexOf要么引_.uniq想深拷贝一个配置对象JSON.parse(JSON.stringify())有各种坑还是_.cloneDeep省心。它设计精良、性能优秀是很多老项目的基础设施。问题是ES6 到 ES2023 把大部分常用操作都原生化了map、filter、reduce、find、findIndex、includes、Array.from、Object.fromEntries、Object.assign、展开运算符再到Array.prototype.at、Array.prototype.findLast、structuredClone。你把 lodash 装在那里真正被业务代码用到的可能还不到十个函数但打包器仍然要把整个库或大半个库的代码塞进产物。我做迁移前会给项目做一次调用统计常见 lodash 函数的使用量分布大概是这样的lodash 函数真实场景原生替代_.map/_.filter/_.reduce数据转换、筛选、聚合原生数组方法_.uniq/_.uniqBy去重[...new Set(arr)]或Map实现_.flatten/_.flattenDeep数组扁平化arr.flat(depth)_.find/_.findIndex查找元素原生find/findIndex_.get安全取值可选链 空值合并_.cloneDeep深拷贝structuredClone_.groupBy分组手写reduce_.debounce/_.throttle防抖节流手写工具函数或引入非常小的专用库会发现前五行的替换几乎零成本后三行需要自己动一点手但完全可控。3.2 手写替代函数替换成本其实很低先看最典型的_.get。以前访问嵌套对象不安全const city _.get(user, address.city, 未知)现在原生可以这样写const city user?.address?.city ?? 未知差别在于_.get对空字符串、0、false这类“假值”不会触发默认值而??只有null和undefined才触发。这两个语义八成情况下是等价且符合预期的但如果有特殊边界记得在测试里单独覆盖。_.groupBy是 lodash 里使用率很高的一个原生没有直接等效。写一个也就十几行function groupBy(list, keyFn) { return list.reduce((acc, item, index) { const key typeof keyFn function ? keyFn(item) : item[keyFn] const groupKey String(key) ;(acc[groupKey] || []).push(item) return acc }, {}) }如果项目只是偶尔用一次groupBy把这段放到utils/array.js里完全够用。如果是大量使用我会要求写一个带测试的小工具模块而不是直接把 lodash 的整个 API 面复制出来。_.cloneDeep的替代方案变化最大。以前大家要么用JSON大法要么用 lodash。2022 年后structuredClone已经是标准 Web APINode 17 也原生支持。它支持大多数现代 JavaScript 数据类型包括Date、Map、Set、RegExp、ArrayBuffer并且是浏览器和 Node 共同提供的能力const copy structuredClone(originalValue)需要注意structuredClone不能复制函数、DOM 节点、类实例原型链等。如果你的业务对象里只放着普通字段和嵌套数组它就是无缝替代_.cloneDeep的解法。3.3 我踩过的一个坑从 lodash 整包改成按需引入并没有想象中香有一段时间我图省事把import _ from lodash改成import cloneDeep from lodash/cloneDeep想着能减体积。但后来发现 lodash 的单函数模块仍然自己带内部依赖有些还会把整个工具链一起打进去体积减少有限。而且各大打包工具对 CommonJS 的 tree-shaking 一直不太好折腾半天不如直接把手写的工具函数放进来。还有一个原则如果删掉 lodash 后发现某些函数真的需要保留就认真写好对应的原生实现而不是继续局部引入 lodash。真正值得留在项目里的永远是你们自己维护、可测试、可修剪的工具函数。这个东西还可以顺手发一个团队内部的 npm 包用到多个项目里反而比继续依赖 lodash 更贴合自己业务的形状。4. axios原生 fetch 的“补完计划”其实已经完成了4.1 axios 当年解决了 fetch 的几个硬伤前端用 axios 的历史原因非常清楚老浏览器没有 fetchaxios 底层用 XMLHttpRequest兼容性好fetch 在网络错误和 HTTP 错误码面前行为都偏“原始”axios 默认把非 2xx 当作错误抛出来axios 还有拦截器、取消请求、超时配置、上传下载进度回调。这些确实都是真实需求不是矫情。但到了 2026 年fetch的可用性早就是全平台基线了而且配套 API 也补齐了AbortController能做取消和超时AbortSignal.timeout()可以一行设置超时Response上有json()、text()、blob()等解析方法Headers对象也提供了标准的头部管理。如果不是对老浏览器有执念fetch已经不是“能用”而是“好用”。4.2 用 fetch 封装一个够用的请求客户端直接裸用fetch在业务代码里会有大量重复样板所以我从不建议“直接用原生 fetch 替换 axios”而是建议封装一次形成项目内的默认请求客户端。下面这个httpClient.js就是我实际用在项目里的模板实现了 axios 最常用的三个能力统一 baseURL、请求拦截器、响应拦截器、JSON 序列化、超时和错误抛出const DEFAULT_TIMEOUT 10000 const BASE_URL import.meta.env?.VITE_API_BASE_URL ?? /api async function request(config) { const { url, method GET, params, data, headers {}, timeout DEFAULT_TIMEOUT, signal } config // 处理 query 参数 let finalUrl /^https?:\/\//.test(url) ? url : BASE_URL url if (params) { const query new URLSearchParams( Object.entries(params).filter(([, v]) v ! undefined v ! null) ) finalUrl (finalUrl.includes(?) ? : ?) query.toString() } // 请求拦截器统一加 token const finalHeaders new Headers(headers) const token localStorage.getItem(token) if (token) finalHeaders.set(Authorization, Bearer ${token}) const controller new AbortController() const timer setTimeout(() controller.abort(), timeout) const finalSignal signal ? AbortSignal.any([signal, controller.signal]) : controller.signal try { const res await fetch(finalUrl, { method, headers: finalHeaders, body: data instanceof FormData ? data : data ? JSON.stringify(data) : undefined, signal: finalSignal }) // 响应拦截器统一处理非 2xx if (!res.ok) { const error new Error(HTTP ${res.status}: ${res.statusText}) error.status res.status throw error } const contentType res.headers.get(content-type) ?? if (contentType.includes(application/json)) { return await res.json() } return await res.text() } finally { clearTimeout(timer) } } export const http { get: (url, config) request({ ...config, url, method: GET }), post: (url, data, config) request({ ...config, url, data, method: POST }), put: (url, data, config) request({ ...config, url, data, method: PUT }), delete: (url, config) request({ ...config, url, method: DELETE }) }这里有两个点值得展开说。第一AbortSignal.any可以同时响应外部取消信号和内部超时信号这在 2026 年的主流浏览器和 Node 里都能用。如果你的运行环境偏保守可以只传内部超时信号放弃外部取消代码更简单但灵活性差一点。第二axios 的“拦截器”本质就是请求发出前和响应返回后的一对钩子。上面这个封装里统一加 token、统一抛错就是最常见的拦截器需求。如果团队里还有埋点、错误上报、自动刷新 token 这类逻辑都可以往这两个位置扩展。这份代码把 axios 替换掉之后业务层接口的调用方式基本不用改。4.3 一提到进度条事情就没那么简单了我必须诚实说fetch 的Request.body是流式的原生并不直接提供类似 XHR 的upload.onprogress事件。如果你的项目重度依赖上传进度反馈比如大文件上传后台、而且不想依赖额外库那么 “用 fetch 完全替代 axios” 这句话是要打折扣的。有两条路可以走上传进度单独保留 XHR。也就是说常规 CRUD 请求全部走 fetch 封装只有大文件上传的模块单独写一个基于 XHR 的uploadFile函数。这样既删掉了 axios 的依赖也没有牺牲必须的功能。用ReadableStream手写进度。这是纯粹 fetch 的方案但代码复杂度高还要处理流状态、背压、取消等问题性价比一般。基于我的经验绝大多数后台管理系统根本没有文件上传进度需求直接把 axios 换成 fetch 封装是完全安全的。如果确实遇到大文件上传单独为它保留 XHR 也比整个项目里挂一个 axios 更合理。4.4 什么时候应该继续用 axios我并不是一个“凡是 axios 都要删”的原教旨主义者。下面这些情况保留 axios 反而理性项目里已经有几十处基于 axios 拦截器实现的复杂逻辑包括自动刷新 token 队列、统一错误上报、多层拦截器嵌套迁移成本高团队大多数成员不熟悉 fetch 的流式处理容易出现误用你需要在浏览器、Node 两端同时使用一套请求代码axios 的适配器已经帮你处理好了环境差异。但如果你是 2026 年的新项目我强烈建议直接用fetch封装或者选择ky、ofetch这类轻量库没必要再引入 axios 这种“全家桶”。5. uuidcrypto.randomUUID() 已经是基线能力了5.1 从“uuid 包”到“一行原生方法”生成 UUID 曾经是一个必须装 npm 包的事情。用crypto自己拼随机数容易在格式上踩坑用Math.random()安全性不足还可能因为伪随机导致碰撞概率上升。于是uuid包成为标配大家最常用的是v4也就是基于随机数的版本。但 Web Crypto API 很早就加入了crypto.randomUUID()。Chrome 92、Firefox 95、Safari 15 都从 2021 年就开始支持Node 14.17 也支持了require(crypto).randomUUID()Node 19 以后globalThis.crypto.randomUUID()默认可用。2026 年再看这已经完全是一个“拿来就能用”的能力。替换很简单// 删掉 npm 包之后 const id crypto.randomUUID() console.log(id) // 例如 7288b1a5-7b5e-4d5a-a4d5-c0d2e4d3e1f1如果是在老一点的 Node 版本或者想要保险可以这样兼容const { randomUUID } require(crypto) const id randomUUID()业务代码里几乎不用改格式因为randomUUID()和uuid4输出的字符串格式完全一致都是标准的xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx长度 36包含连字符。5.2 几种“uuid 包依赖项”的替换对照我用表格整理一下实际开发中会遇到的情况原来用法的场景原 uuid 包写法原生替代前端生成随机 IDuuid.v4()crypto.randomUUID()Node 服务生成 IDuuid.v4()require(crypto).randomUUID()去掉连字符uuid.v4().replaceAll(-, )crypto.randomUUID().replaceAll(-, )生成 v1 时间戳 UUIDuuid.v1()没有直接替代需按业务决定生成 v3/v5 命名空间 UUIDuuid.v3(...)/uuid.v5(...)需要专门的实现我有一个很实际的判断标准如果你还在用 v4立刻就能替换如果你用了 v1、v3、v5那业务场景相对特殊换个包或者保留uuid也说得过去。但以我的经验绝大多数项目从装uuid到删掉只是用了v4一种生成方式。还有一个小技巧在浏览器端如果要生成一个“较短的主键”很多人会把 UUID 去掉连字符当成 32 位随机字符串。这种用法用crypto.randomUUID().replaceAll(-, )也能无缝替换。不过要提醒一句短 ID 一般不需要 UUID 格式直接用crypto.getRandomValues生成随机字节再转 base64url 更省空间但那是另一套方案不在这次替换范围内。5.3 为什么“不装 uuid 包”也是一种安全决策2026 年谈删掉uuid包除了少装一个依赖还有一个隐藏收益通用包在 npm 生态里被广泛依赖任何一个上游出现供应链问题受到波及的项目数量都是指数级的。uuid包本身很良心但它也是攻击面的一部分。你把自己的依赖树剪掉一层就是把潜在的攻击入口关掉一个。这种收益看不见、摸不着但到了真出事的时候会非常值钱。我每次向团队解释“为什么要用原生randomUUID”时都会顺带把这层意思讲清楚不是原生 API 一定比第三方实现更安全而是“依赖越少风险面越小”。6. dotenvNode 已经把环境变量加载做成内置能力6.1 原生加载 .env 的两种姿势后端项目里装dotenv几乎成了惯性require(dotenv).config()这行代码老到大家都不会多想。但是 Node 从 20.6 开始支持直接在启动命令里加载环境变量文件2026 年的 Node 运行环境已经完全内置这个能力。方式一命令行参数node --env-file.env src/server.js你还可以在 npm scripts 里这样写{ scripts: { dev: node --env-file.env --watch src/server.js, start: node --env-file.env src/server.js } }方式二用process.loadEnvFile()适合程序内部按条件动态加载if (process.env.NODE_ENV ! production) { process.loadEnvFile(.env.development) }process.loadEnvFile()从 Node 20.12 / 21.7 开始可用。如果你确定团队都在 Node 22.9还可以考虑--env-file-if-exists文件不存在时不会中断启动在简化 CI 配置时很好用。6.2 迁移过程比想象中还要简单把dotenv从项目里删掉我总结为三步检查项目启动脚本把node src/index.js改成node --env-file.env src/index.js删掉代码开头的import dotenv/config或require(dotenv).config()跑一遍本地开发、测试、构建确认环境变量照常加载。这里有一个细节非常容易踩坑dotenv 默认从当前工作目录找.env文件而--env-file同样遵循工作目录的相对路径。如果你的服务是通过pm2、systemd、Docker 启动启动目录和项目目录可能不一致记得改成绝对路径或者统一在启动脚本里指定。我在一个微服务项目里就遇到过类似问题本地跑得好好的上了容器环境变量突然全没了排查半天发现是工作目录变化导致.env没被找到。另外原生--env-file对文件内容的解析支持的是常见键值对语法不支持dotenv-expand里的变量展开比如API_URL$BASE_URL/api这种。如果项目里用了变量展开且无法避免那就先不要删dotenv-expand相关逻辑或者把这个变量展开的逻辑手写进去否则线上配置直接崩。6.3 不是所有环境变量都适合放进 .env删掉 dotenv 之后我反而建议团队重新梳理一下环境变量管理。原生--env-file让加载文件变得简单但不应该鼓励把密钥、数据库密码等同进 git 仓库。区分.env、.env.local、.env.example把带真实配置的.env放进.gitignore在部署环境里用平台自带的环境变量配置能力比如容器编排的 env 字段才是更健康的方案。还有一点--env-file默认不会覆盖已经存在的环境变量这一点和 dotenv 的核心行为一致适合防止容器/CI 环境里已有的变量被本地文件覆盖。但如果你的部署流程非常依赖“文件优先级最高”这个行为那需要自己在启动脚本里做控制。不是不能做只是要知道有这个差异。7. 删除依赖的实操流程与回归验证7.1 先看清楚这个包到底被谁用了删包前不要拍脑袋先做一次细致的盘点。我用的是下面三个动作npm ls moment先确认它到底是直接依赖还是传递依赖。有些包是某个老库的依赖直接删顶层依赖可能出现版本冲突。用项目的全局搜索工具搜from moment/require(moment)把引用文件列出来按使用场景分组。看浏览器端打包分析。如果你用 Vite可以跑vite-bundle-visualizer如果还在用 webpack用webpack-bundle-analyzer。重点观察删掉前后对应包体积的差异。这一步做完你心里就有一张表哪些功能必须替代、哪些只是误引、哪些根本是死代码。7.2 替换顺序从最没有争议的包开始我强烈建议不要五个包一起删。一次只处理一个合并一个分支跑一轮测试确认完再继续。这里给一个我推荐的顺序uuid替换完全机械crypto.randomUUID()一行搞定风险最低。dotenv改启动命令逻辑变化小但要注意环境变量加载时机。axios做项目内请求封装业务代码大概率不用改但需要多写一层封装。lodash替换函数多建议拆成多个提交分别处理数组工具、对象工具、函数工具。moment时间逻辑最容易出边界问题放在最后并且预留充足回归时间。每替换完一个包就在分支里更新package.json执行npm uninstall pkg然后把package-lock.json的变更一起提交确保 install 的结果可复现。7.3 回归验证清单照着打钩我自己在使用前会过一遍下面这个验证 checklist单元测试所有涉及被替换函数、接口封装、时间格式化、ID 生成、环境变量读取的测试全部通过。类型检查tsc --noEmit无新增类型错误。注意手写工具函数尽量补上完善的 TypeScript 类型。端到端测试重点验证登录、列表页渲染、日期展示、文件上传这几个核心业务路径。构建产物对比记录替换前后的产物体积和构建时间。如果一个 300KB 的 moment 被删掉后体积变化不明显说明打包配置里可能有其他问题值得顺带排查。运行时监控发到测试环境后观察一段时间报错日志尤其是时间格式化、接口请求、环境变量加载相关的内容。8. 常见问题与排查技巧实录8.1 行为不一致的典型场景替换过程中最容易遇到的是“看起来替换了但运行结果和原来不一样”。下面几个场景是我实际踩过的直接列成速查表问题现象可能原因解决思路时间差 8 小时moment 的本地时区与toISOString()的 UTC 语义混用统一使用本地时区对象或统一用 UTC别混着用默认值没生效_.get(a, b, default)与a?.b ?? default对假值处理不一致明确业务语义测试空字符串、0、false 的情况HTTP 请求 404 时没抛错fetch 只对网络错误 reject对 HTTP 错误码不抛异常在封装里显式检查res.ok生成的 ID 不带连字符业务里依赖uuid.v4().replace(/-/g, )原样替换后丢掉了处理逻辑不要直接替换要按原字符串格式做.replaceAll(-, )环境变量是 undefined--env-file路径和工作目录不匹配用绝对路径或在启动脚本里cd到项目目录这里我想特别强调第一行的时间问题。moment在格式化时用的就是本地时区但new Date().toISOString()永远返回 UTC 字符串。如果代码里有一段日志用toISOString()打印“当前时间”你会发现和原来moment().format()的结果差上 8 小时。这不是 bug是时区语义不同必须统一。8.2 老项目、低版本 Node 环境怎么办如果你的项目还锁在 Node 14/16或者需要支持很老的企业浏览器那某些替换就不成立。比如--env-file需要 Node 20.6crypto.randomUUID()在 Node 的全局形式上也需要较新版本。这种情况下我建议分段处理先在代码层面把可替换的都替换掉环境层面的再等升级。如果必须兼容老环境但你又想先删包可以为个别 API 补一层 polyfill。举个例子crypto.randomUUID在不支持时可以临时用crypto.getRandomValues手写function fallbackUUID() { const bytes crypto.getRandomValues(new Uint8Array(16)) bytes[6] (bytes[6] 0x0f) | 0x40 bytes[8] (bytes[8] 0x3f) | 0x80 const hex Array.from(bytes, (b) b.toString(16).padStart(2, 0)) return ${hex.slice(0, 4).join()}-${hex.slice(4, 6).join()}-${hex.slice(6, 8).join()}-${hex.slice(8, 10).join()}-${hex.slice(10).join()} }但请注意polyfill 也是一种依赖也要维护。能解决的问题值得做单纯为了“删包而删包”就别折腾了。上面的代码也仅供参考生产环境建议直接用经过审计的库或者等环境升级后再删。8.3 团队协作中的迁移节奏删 npm 包这个动作看起来是技术性的实际上也是团队管理问题。我个人经验是先把“依赖体检”这个动作变成常态化。我建议在每个季度末或者每逢大版本升级时抽半天时间做一次依赖清理而不是等出了安全公告再被动行动。每次删除前在代码评审里说明这个包原来解决什么问题、现在被什么替代、替代的边界在哪里、有哪些已知差异。这样即使有人后续踩坑也能追溯回当初的决策。还有一个小技巧在 README 或者docs/decisions里写一条简短的“依赖变更记录”。不用写很长就记“某年某月因为 Node 20.6 支持 --env-file删除 dotenv”。新成员加入团队时看到这份记录会比翻 git log 快得多。我把这个习惯坚持了三四年效果很明显项目的构建速度越来越快CI 报漏洞的次数越来越少新人上手时不需要理解一堆“历史遗留依赖”。现在 npm 生态里发布一个新包的成本低到只要一条npm publish但把一个包从项目里拿掉的成本始终需要靠对业务和运行环境基线的判断力来支撑。删除不是否定这些包的价值而是承认它们完成了历史使命然后把舞台让给更轻、更稳的方案。