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 三个判断标准,决定一个依赖能不能删

我不会告诉你“这些包必须马上删完”,那样太不负责任。我的实践里用的是下面三个条件,同时满足才动手:

  1. 原生 API 已经覆盖你项目里实际用到的那部分功能。你用moment只是格式化时间、算相对时间,那原生Intl完全能接住;如果你们用moment-timezone做全球多时区调度引擎,那先别急。关键看使用范围,不是看包总共有多少功能。
  2. 你的运行环境基线已经达标。这一点要查清楚。团队维护的 PC 端浏览器、服务器 Node 版本、是否有必须兼容的老系统,都要列为硬指标。环境没到,再先进的原生 API 都白搭。
  3. 删除后的收益明显大于改动成本。如果是几十处引用的工具函数,替换就半天,删掉后构建体积小了几百 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.RelativeTimeFormat
moment(value).isValid()手写日期合法性校验(必须检查NaN)
moment.utc().format()new Date().toISOString()

这里要特别提醒:Date的parse行为在不同运行时之间仍然有差异,所以“解析用户输入的日期字符串”这个场景,原生 API 并没有完美兜底。我自己的做法是:非受控格式的日期解析继续用dayjs或者更精确的解析库;如果只是标准 ISO 字符串、时间戳或者YYYY-MM-DD格式,原生完全没问题。

2.3 什么时候动不了它

如果项目里有很多函数传moment对象作为参数,甚至有人直接往对象上挂自定义属性,那替换成本会很高。这时候我不会建议你一两周内强删,而是先做一个时间工具层,把业务代码和 moment 隔离:

  1. 先封装自己的dateUtils.js,所有外部调用都走这里;
  2. 内部实现从 moment 换成原生;
  3. 跑完回归再删包。

另外,如果你的业务是复杂时区调度、国际会议排期、跨时区报表,不要只看到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 API,Node 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 的历史原因非常清楚:老浏览器没有 fetch,axios 底层用 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” 这句话是要打折扣的。

有两条路可以走:

  1. 上传进度单独保留 XHR。也就是说,常规 CRUD 请求全部走 fetch 封装,只有大文件上传的模块单独写一个基于 XHR 的uploadFile函数。这样既删掉了 axios 的依赖,也没有牺牲必须的功能。
  2. 用ReadableStream手写进度。这是纯粹 fetch 的方案,但代码复杂度高,还要处理流状态、背压、取消等问题,性价比一般。

基于我的经验,绝大多数后台管理系统根本没有文件上传进度需求,直接把 axios 换成 fetch 封装是完全安全的。如果确实遇到大文件上传,单独为它保留 XHR 也比整个项目里挂一个 axios 更合理。

4.4 什么时候应该继续用 axios

我并不是一个“凡是 axios 都要删”的原教旨主义者。下面这些情况,保留 axios 反而理性:

  • 项目里已经有几十处基于 axios 拦截器实现的复杂逻辑,包括自动刷新 token 队列、统一错误上报、多层拦截器嵌套,迁移成本高;
  • 团队大多数成员不熟悉 fetch 的流式处理,容易出现误用;
  • 你需要在浏览器、Node 两端同时使用一套请求代码,axios 的适配器已经帮你处理好了环境差异。

但如果你是 2026 年的新项目,我强烈建议直接用fetch封装或者选择ky、ofetch这类轻量库,没必要再引入 axios 这种“全家桶”。

5. uuid:crypto.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()和uuid@4输出的字符串格式完全一致,都是标准的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. dotenv:Node 已经把环境变量加载做成内置能力

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从项目里删掉,我总结为三步:

  1. 检查项目启动脚本,把node src/index.js改成node --env-file=.env src/index.js;
  2. 删掉代码开头的import 'dotenv/config'或require('dotenv').config();
  3. 跑一遍本地开发、测试、构建,确认环境变量照常加载。

这里有一个细节非常容易踩坑: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 先看清楚这个包到底被谁用了

删包前不要拍脑袋,先做一次细致的盘点。我用的是下面三个动作:

  1. npm ls moment,先确认它到底是直接依赖还是传递依赖。有些包是某个老库的依赖,直接删顶层依赖可能出现版本冲突。
  2. 用项目的全局搜索工具搜from 'moment'/require('moment'),把引用文件列出来,按使用场景分组。
  3. 看浏览器端打包分析。如果你用 Vite,可以跑vite-bundle-visualizer;如果还在用 webpack,用webpack-bundle-analyzer。重点观察删掉前后对应包体积的差异。

这一步做完,你心里就有一张表:哪些功能必须替代、哪些只是误引、哪些根本是死代码。

7.2 替换顺序:从最没有争议的包开始

我强烈建议不要五个包一起删。一次只处理一个,合并一个分支,跑一轮测试,确认完再继续。这里给一个我推荐的顺序:

  1. uuid:替换完全机械,crypto.randomUUID()一行搞定,风险最低。
  2. dotenv:改启动命令,逻辑变化小,但要注意环境变量加载时机。
  3. axios:做项目内请求封装,业务代码大概率不用改,但需要多写一层封装。
  4. lodash:替换函数多,建议拆成多个提交,分别处理数组工具、对象工具、函数工具。
  5. moment:时间逻辑最容易出边界问题,放在最后,并且预留充足回归时间。

每替换完一个包,就在分支里更新package.json,执行npm uninstall ,然后把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.6+,crypto.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,但把一个包从项目里拿掉的成本,始终需要靠对业务和运行环境基线的判断力来支撑。删除不是否定这些包的价值,而是承认它们完成了历史使命,然后把舞台让给更轻、更稳的方案。