每年年初,我都会把自己维护的工程翻出来做一次“依赖体检”,打开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.RelativeTimeFormat |
moment(value).isValid() | 手写日期合法性校验(必须检查NaN) |
moment.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 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” 这句话是要打折扣的。
有两条路可以走:
- 上传进度单独保留 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. 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 包写法 | 原生替代 |
|---|---|---|
| 前端生成随机 ID | uuid.v4() | crypto.randomUUID() |
| Node 服务生成 ID | uuid.v4() | require('crypto').randomUUID() |
| 去掉连字符 | uuid.v4().replaceAll('-', '') | crypto.randomUUID().replaceAll('-', '') |
| 生成 v1 时间戳 UUID | uuid.v1() | 没有直接替代,需按业务决定 |
| 生成 v3/v5 命名空间 UUID | uuid.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从项目里删掉,我总结为三步:
- 检查项目启动脚本,把
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 ,然后把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,但把一个包从项目里拿掉的成本,始终需要靠对业务和运行环境基线的判断力来支撑。删除不是否定这些包的价值,而是承认它们完成了历史使命,然后把舞台让给更轻、更稳的方案。