利用Baseline特性减少前端JavaScript依赖
我们大多安装一次依赖包后就不再过问。它完成任务、测试通过,我们便继续开发。但Web平台也在持续演进,如今你的package.json里,有数量惊人的库其实已经被浏览器原生支持了。
在一个典型的中型JavaScript应用中,通常能找到60KB至90KB(已压缩并gzip)的依赖功能,如今都可由平台原生实现。日期与数字格式化、HTTP请求、模态框、工具提示、深度克隆、数组分组——这些在几年前确实是平台的功能空白,但如今很多都已被填补。
这些库之所以一直留在项目中,并非因为开发团队懒惰。主要原因是,多数团队不会按Baseline节奏重新审核依赖,或是根本没意识到浏览器的更新速度有多快。大家会用npm audit检查安全问题,但很少有人问:*这个库的功能是不是浏览器已经原生支持了?*于是这些库就一直留了下来。
本文中,我们将一起完成这场依赖审核。我们不会逐个排查依赖,而是按功能类别分组处理,因为收益往往来自批量替换。我们会计算包体积变化,构建一套可复用的决策框架,同时坦诚说明平台仍存在的不足。读完本文后,你将掌握一套可自行在package.json上执行的标准化流程。

什么是Baseline
在开始删除依赖之前,我们先快速回顾一下Baseline的定义。如果你已经熟悉,可以跳过本节。
Baseline是WebDX社区小组发起的项目,用直白的语言告诉你,某项Web功能在主流浏览器(Chrome、Edge、Firefox和Safari)中使用的安全程度。功能分为三种状态:
- 有限可用 该功能尚未在所有主流浏览器引擎中上线。若无降级方案,不宜依赖。
- Baseline新可用 该功能刚在所有主流引擎中落地。在最新版浏览器中可正常工作,但市面上的旧设备可能暂不支持。
- Baseline广泛可用 该功能已在所有主流引擎中存在30个月。此时无需过多顾虑,可直接使用。
"新可用"与"广泛可用"之间的30个月间隔,对本次审核至关重要。广泛可用的功能,如今通常就能替换掉对应的库;而仅新可用的功能,需先确认受众设备情况,或做好特性检测,再考虑替换。本文中会对这两种情况区别处理。
你可以通过webstatus.dev、MDN(每个参考页面顶部都有Baseline标识),或通过web-features npm包以编程方式查询任意功能的状态。后面我们审核真实项目时,会用到这三种方式。
删除前的决策框架
看到"浏览器已原生支持"就急于删除库,是很诱人的做法,但我们不能这么草率。看似零成本的替换,可能悄无声息地导致部分用户无法正常使用,或是让你失去某个未曾留意的依赖功能。
因此,删除任何库之前,请先问自己三个问题。后续每个功能组的分析都会用到这三个问题。
1. 替换方案对我的受众来说是否符合Baseline安全标准?
不是抽象地问"它是否属于Baseline",而是具体问"它对我的实际用户来说是否安全"。如果原生功能是"广泛可用"的,答案通常是肯定的。如果只是"新可用",请查看分析数据或browserslist配置,了解有多少用户会受影响。面向内部员工的B2B后台,所有用户都使用最新浏览器;而面向公众的网站,可能有大量老旧安卓设备用户——这两种场景完全不同。
2. 替换的实际成本是什么?
删除库并非总是零成本。有时原生功能的支持度还不够,你需要引入polyfill(兼容补丁)。如果这个polyfill比你要删除的库体积更大,除非做条件加载,否则你的包体积反而会增加。后面讲到Temporal时,我们就会遇到这种情况。
3. 平台原生功能能否覆盖我的真实使用场景?
库的功能往往比相似的平台原生功能更丰富。比如axios不只是自带JSON自动解析的fetch,它还具备拦截器、请求取消和重试功能。如果你用到了这些特性,直接替换成fetch就需要自己重新实现。不要想当然认为是无缝替换,先确认自己的实际使用需求。
请牢记这三个问题。下面每个功能组的分析,本质上都是针对不同依赖场景套用这三个问题。
第一组:国际化(当前收益最大的替换)
这一组通常包含最多可被广泛可用的原生功能替代的代码,能节省大量体积。浏览器在Intl命名空间下提供了一整套格式化工具,许多小巧流行的库因此变得可有可无。
以下是常见的可替换库及其对应的原生方案:
timeago.js(1 KB gz)→Intl.RelativeTimeFormatpluralize(2.3 KB gz)→Intl.PluralRulesnumeral(3.9 KB gz)→Intl.NumberFormathumanize-duration(6.6 KB gz)→Intl.DurationFormat- 列表拼接工具 →
Intl.ListFormat
我们来具体看几个例子。
相对时间格式化
timeago.js的作用是将时间戳转换为"3小时前"这样的表述。Intl.RelativeTimeFormat也能实现同样功能,且属于Baseline广泛可用。
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
rtf.format(-1, "day"); // "yesterday"(昨天)
rtf.format(3, "hour"); // "in 3 hours"(3小时后)
rtf.format(-2, "week"); // "2 weeks ago"(2周前)
这里的numeric: "auto"选项很实用:当语言中有对应词汇时,会返回"yesterday"而非"1 day ago"。你只需传入数字和时间单位,就能得到本地化的字符串。
你可能会注意到,timeago.js有一个上述代码片段未实现的功能:自动选择时间单位。给定一个日期,timeago.js会判断应该用"秒"还是"天"。而Intl.RelativeTimeFormat需要你自己完成这一步。这只需要几行算术代码(计算时间差,找到最合适的时间单位),写完这个工具函数后,你就不再需要这个库了。
数字、货币与列表格式化
Intl.NumberFormat涵盖了数字格式化库的大部分功能:千位分隔符、货币格式、百分比和紧凑表示法。
new Intl.NumberFormat("en-US").format(1234567.89);
// "1,234,567.89"
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(
1234.5,
);
// "$1,234.50"
new Intl.NumberFormat("en", { notation: "compact" }).format(1200000);
// "1.2M"
而Intl.ListFormat属于广泛可用,能解决"将数组拼接成句子"的问题,包括牛津逗号——这类功能通常需要写复杂的工具函数:
const lf = new Intl.ListFormat("en", { style: "long", type: "conjunction" });
lf.format(["Alice", "Bob", "Carol"]);
// "Alice, Bob, and Carol"
注意事项:时长格式化
humanize-duration能将毫秒数转换为"1小时30分钟"这样的表述。对应的平台原生功能是Intl.DurationFormat:
const df = new Intl.DurationFormat("en", { style: "long" });
df.format({ hours: 1, minutes: 30 });
// "1 hour, 30 minutes"
需要注意的是,撰写本文时Intl.DurationFormat属于Baseline新可用,而非广泛可用。它于2025年3月在所有主流引擎中上线,预计2027年达到广泛可用状态。因此,面向大众的应用若直接替换,会不符合第一个问题的要求——除非你先分析流量数据,或添加降级方案。对于仅使用现代浏览器的内部工具,现在替换没问题;但面向有大量旧设备用户的公共网站,建议再等一年,或先做特性检测再决定是否使用。
本组体积计算
如果你的应用同时使用了humanize-duration、timeago.js、pluralize和numeral,这些依赖加起来约14 KB gzipped,其中大部分如今都可用广泛支持的API替代。国际化组通常是整个审核中最容易实现的优化。
第二组:HTTP客户端
这一组的情况更复杂,需要我们仔细分析。
常用的浏览器HTTP库有axios(17 KB gz)和superagent(19 KB gz)。对于大多数请求场景,fetch加AbortController就能满足需求,且两者都属于广泛可用。
基础的GET请求示例:
// axios
const { data } = await axios.get("/api/users");
// fetch
const res = await fetch("/api/users");
const data = await res.json();
多出来的那一行res.json(),体现了fetch的显式设计,而axios则做了隐式处理。这是本组的普遍规律:fetch默认提供的功能更少,由你决定是否需要补充它未实现的部分。
超时处理
axios有timeout选项,fetch则可以用AbortSignal.timeout():
const res = await fetch("/api/users", {
signal: AbortSignal.timeout(5000), // 5秒后终止请求
});
fetch无法替代axios的场景
这正是第三个问题发挥作用的地方,我们来具体看看差距:
fetch不会因HTTP错误而拒绝Promise。 遇到404或500状态码时,Promise依然会resolve,而非reject。你需要自己检查res.ok;而axios会对所有非2xx状态码抛出错误。- 无拦截器
如果你依赖
axios拦截器统一添加认证令牌或处理401状态,fetch没有原生等效功能。你需要自己封装fetch函数或类来实现相同行为。 - 无自动重试
axios(配合插件)可以自动重试失败请求;而fetch需要你自己编写重试逻辑。 - 无上传进度反馈
fetch目前仍无法原生上报上传进度。如果你有带进度条的文件上传功能,这就是保留库的合理理由。
我本人在交互式在线课程(比如Learn JavaScript)中严重依赖拦截器,多年来我通过在fetch之上封装自定义class解决了这个问题。这个方案已服务数百万用户,效果很好。
这些功能都不难重新实现,而且大多数应用只用到其中一两个。但这正是不能盲目替换的场景:先查看你实际如何使用HTTP客户端。如果只是简单的GET和POST请求,用轻量的fetch封装替换axios,能节省约17 KB gzipped的体积。
第三组:UI基础组件
这一组的替换最有成就感,因为平台原生功能不仅能匹配库的功能,通常还比团队自行实现的方案更具可访问性。
本组涉及的库包括模态框(如a11y-dialog,1.8 KB gz)、工具提示和弹出层库(tippy.js,14 KB gz,内置Popper用于定位)、focus-trap(6.6 KB gz)和body-scroll-lock(1.3 KB gz)。它们可被三个平台原生功能替代:<dialog>元素、Popover API和CSS锚点定位。
<dialog>元素
大量模态框相关代码都是为了解决可访问性问题:在模态框内捕获焦点、按Esc键关闭、关闭时恢复之前的焦点元素、确保模态框显示在最上层。而<dialog>元素属于广泛可用,能原生实现所有这些功能。
<dialog id="confirm">
<form method="dialog">
<p>删除此文件?</p>
<button value="cancel">取消</button>
<button value="delete">删除</button>
</form>
</dialog>
const dialog = document.querySelector("#confirm");
dialog.showModal(); // 焦点移入模态框,背景设为不可交互,按Esc可关闭
dialog.addEventListener("close", () => {
console.log(dialog.returnValue); // "cancel"或"delete"
});
调用showModal()就能完成focus-trap原本负责的工作:焦点移入模态框,页面其余部分变为不可交互(无法Tab移出),按Esc键可关闭,关闭后焦点返回至触发元素。对话框会渲染在浏览器的顶层,无需再为z-index发愁。你还可以通过::backdrop伪元素设置遮罩样式。
这一个元素就能替代你的模态框库和focus-trap。它唯一不原生支持的是锁定背景滚动——这正是body-scroll-lock的作用。现在只需一行CSS就能实现:
body:has(dialog:modal) {
overflow: hidden;
}
你可能会问,为什么用dialog:modal而不是dialog[open]?因为调用show()后open属性就会被设置,但此时对话框并非真正的模态框,所以不需要锁定滚动。:modal伪类仅在对话框真正处于模态状态时生效,也就是调用showModal()之后。
就这样,三个库的功能被浓缩成一个元素和一行CSS规则。
Popover API与锚点定位
对于非全屏模态的组件(下拉菜单、工具提示、tippy.js处理的小型浮动面板),Popover API无需任何JavaScript就能提供点击外部关闭、顶层渲染和按Esc关闭的功能:
<button popovertarget="menu" id="options">选项</button>
<div id="menu" popover>
<!-- 菜单内容 -->
</div>
点击按钮可切换弹出层显示状态,点击外部则关闭。它属于Baseline新可用(自2025年1月起)。
工具提示库的另一核心功能是定位:让浮动元素始终固定在触发元素旁,并在即将溢出视口时自动切换位置。这正是tippy.js内置的Popper所处理的,而现在CSS的锚点定位功能就能实现。下面的代码将#menu弹出层固定在触发按钮正下方:
#options {
anchor-name: --trigger;
}
.tooltip {
position-anchor: --trigger;
position-area: top;
margin: 0;
}
锚点定位是本文中最新的功能,于2026年1月Firefox 147发布后成为Baseline新可用(Chrome自125版本起支持,Safari自26版本起支持)。由于它还很新,完全符合第一个问题的适用场景:适合现代浏览器受众,但需先检查你的用户设备情况,且注意一些高级功能(如备选定位方案)在不同版本浏览器中的支持度不一致。请为旧浏览器保留合理的降级方案。
通过<dialog>、Popover API和锚点定位,UI基础组件组(工具提示库、模态框库、focus-trap、body-scroll-lock)的总大小约为24 KB gzipped,替换后你将获得比大多数自行实现方案更好的默认可访问性。
第四组:Lodash工具函数
如今很少有人会完整导入Lodash了,但它的单个函数依然随处可见——要么是完整的lodash包(25 KB gz),要么是独立安装的lodash.clonedeep、lodash.groupby等。其中几个最常用的函数,现在已有直接对应的平台原生实现。
数组分组
lodash.groupby能将数组按某个属性重组为对象。Object.groupBy的功能完全一致:
const products = [
{ name: "Apple", category: "fruit" },
{ name: "Carrot", category: "vegetable" },
{ name: "Banana", category: "fruit" },
];
const grouped = Object.groupBy(products, (product) => product.category);
// {
// fruit: [{ name: "Apple", ... }, { name: "Banana", ... }],
// vegetable: [{ name: "Carrot", ... }],
// }
还有Map.groupBy,适合需要返回Map而非普通对象的场景(当键不是字符串时很有用)。两者都属于Baseline新可用(自2024年3月起),预计2026年末达到广泛可用状态。
深度克隆
lodash.clonedeep用于创建对象的深拷贝。对应的平台原生功能是structuredClone,属于广泛可用:
const original = { user: { name: "Sam", roles: ["admin"] } };
const copy = structuredClone(original);
copy.user.roles.push("editor");
original.user.roles; // ["admin"](未被修改)
structuredClone能处理JSON.parse(JSON.stringify(...))无法解决的复杂场景:正确克隆Date、Map、Set、ArrayBuffer和循环引用。需要注意的限制(再次对应第三个问题)是,它无法克隆函数、DOM节点或类实例:遇到函数会抛出错误,类实例则会丢失原型。对于大多数人深克隆的普通数据来说,它是完美的替代方案。
集合操作
如果你曾用Lodash的工具函数处理union(并集)、intersection(交集)或difference(差集),现在Set对象已原生支持这些操作。它们属于Baseline新可用(自2024年6月起):
const admins = new Set(["sam", "alex", "jo"]);
const editors = new Set(["alex", "kim"]);
admins.intersection(editors); // Set { "alex" }
admins.union(editors); // Set { "sam", "alex", "jo", "kim" }
admins.difference(editors); // Set { "sam", "jo" }
完整的方法包括union、intersection、difference、symmetricDifference、isSubsetOf、isSupersetOf和isDisjointFrom。
值得保留的功能
并非所有Lodash功能都已被平台原生实现。debounce(防抖)和throttle(节流)仍无原生等效功能,且确实实用,因此单独引入lodash.debounce是合理的。本组的核心不是"删除Lodash",而是"停止打包浏览器已原生支持的部分"。仅替换lodash.clonedeep和lodash.groupby就能节省约8 KB gzipped;如果你为了少数几个函数而导入了完整的lodash,替换掉平台已支持的部分后,甚至可以完全移除这个包。
第五组:Temporal——暂不替换的案例研究
前面几组的结论都是"可以替换",但这一组恰恰相反,这也正是它值得被纳入的原因:它展示了决策框架如何告诉你应该等待。
Temporal是万众期待的JavaScript Date对象替代方案,API设计更合理:对象不可变、时区处理更清晰、月份索引不再从0开始。它于2026年3月达到TC39 Stage 4,成为ES2026规范的一部分。Firefox在2025年发布的139版本中支持了它,Chrome在2026年1月发布的144版本中也已支持。Safari尚未在稳定版中发布,目前仅在Safari Technology Preview中可用,预计2026年晚些时候会推出稳定版支持。
如果你不了解Temporal,可以查看Temporal速查表快速了解API,并对比它与Date的区别。
然而,Temporal尚未纳入Baseline。它仍处于有限可用状态,因为Safari用户还无法使用。如今要在所有浏览器中使用它,需要引入polyfill,而此时体积计算就变得不划算了。
官方的@js-temporal/polyfill约44 KB gzipped。还有一个不依赖BigInt的轻量版polyfill,体积约19 KB gzipped。而像dayjs这样的轻量日期库,体积仅约3 KB gzipped。因此,如果你现在把dayjs换成Temporal加polyfill,不仅不会节省3 KB,反而会让包体积增加约41 KB——除非你能实现polyfill的条件加载。
我们用决策框架分析一下:
- **问题1(受众):**Temporal不符合Baseline标准。面向大众受众的话,受影响用户数量会很多。
- **问题2(成本):**polyfill的体积是你要删除的库的十多倍。替换会导致包体积增大。
- **问题3(功能差距):**这一点Temporal占优,它的功能比
dayjs更丰富。但在问题1和2不满足的情况下,这一点无关紧要。
结论很明确:现在继续使用dayjs(或date-fns)。当Safari稳定版支持Temporal并使其达到Baseline标准后,再重新考虑替换。届时你可以原生使用Temporal,并为旧浏览器用户条件加载polyfill。这是一个需要记录下来、几个月后再复查的功能,而非现在就行动。
如何在自己的package.json上执行这场审核
上面的功能组是一个参考框架,但你的依赖是独特的。以下是一套可在本季度执行的标准化流程。
步骤1:列出生产环境依赖
首先列出真正会打包给用户的依赖:
npm ls --omit=dev --depth=0
步骤2:测算每个依赖的体积
要快速查看单个包的体积,Bundlephobia能提供任意npm包的压缩后及gzip体积。要了解真实情况(即经过摇树优化和去重后,每个依赖在你的实际包中所占体积),可以用包分析工具分析构建产物。npx source-map-explorer适用于大多数打包产物,npx vite-bundle-visualizer则适用于Vite项目。

步骤3:检查替代方案的Baseline状态
对于每个候选库,找到对应的平台原生功能并检查其Baseline状态。最快的方式是通过webstatus.dev或该功能MDN页面上的Baseline标识。
步骤4:执行三个问题的评估
对于每个有平台替代方案的库,回到决策框架:它对我的受众来说是否符合Baseline安全标准?替换的成本是什么?原生功能能否覆盖我的实际使用场景?大多数决策都会由问题1(对比功能状态与你的browserslist)和问题3(检查你的实际使用情况)得出结论。
步骤5:必要时基于渐进式增强进行替换
对于广泛可用的功能,直接替换即可。对于新可用的功能,要么确认你的受众都使用现代浏览器,要么为新代码添加特性检测并保留降级方案:
if (typeof Intl.DurationFormat === "function") {
// 使用平台原生功能
} else {
// 降级使用库,或采用更简单的格式化方式
}
这样既能为支持新功能的用户减少代码体积,又不会影响旧设备用户的使用。
总结
把各组的体积加起来,就能看到清晰的优化空间:国际化组约14 KB gzipped,HTTP组约17 KB,UI基础组件组约24 KB,Lodash工具函数组约8 KB(具体取决于你使用的库的多少)。对于典型的中型应用,这意味着你可以将60 KB至90 KB gzipped的依赖功能交还给平台原生实现;如果你之前使用了完整的lodash或多个上述库,节省的体积会更多。(未压缩的体积是这个数字的两到三倍,这也是你在包分析工具中gzip前看到的大小。)
本文中我选择的都是相对轻量的包,但某些单个包的体积可能更大。比如你使用的对话框库,体积可能高达50KB gzipped,具体取决于你用的是哪一个。
未来一年有几个值得关注的功能,它们将带来更多替换机会:
- Temporal原生支持 一旦Safari稳定版支持Temporal并使其达到Baseline标准,你就能同时删除日期库和polyfill,将如今的体积增加变为真正的优化收益。
- CSS锚点定位成熟 它于2026年1月成为Baseline新可用。随着它逐渐走向广泛可用,替换工具提示和弹出层定位库对大众受众来说会越来越安全。
Object.groupBy及相关功能进入广泛可用状态 2024年推出的这批功能(数组分组、Set方法)预计2026年末达到广泛可用,届时它们将从"需检查受众"变为"可直接使用"。
这不是一次性的清理工作。平台一直在发布新功能,"需要用库实现"和"浏览器原生支持"之间的差距不断缩小。值得养成的小习惯是:每季度执行一次这样的审核,列出你的依赖,检查哪些功能已纳入Baseline,尽可能将功能交还给平台。
从本文中选一组功能,打开你的package.json,看看浏览器已经为你实现了多少吧。
(yk)
(yk)