先说一个我最近越来越强烈的感觉:很多前端团队还在手里揣着“老黄历”写代码。我说得直接一点,现在的 HTML 已经不是当年那个只能渲染静态标签的 HTML 了,它底层挂了一堆系统级接口。过去你得靠各种 JS 库才能实现的效果,现在浏览器原生就能做,而且做得更稳、更省、更容易维护。
这不是我一个人的体验,而是这几年浏览器标准化推进速度太猛带来的结果。我见过太多项目还在为一个弹窗引入整套 UI 框架,为一个懒加载塞一个插件,为一个滚动动画引用一个“轻量级”但实际十几 KB 的库。每次做技术评审和代码瘦身,我都会把这些原生 API 翻出来一个一个对一遍,发现真正砍不掉的自定义 JS 逻辑其实没几个。
这篇文章我想把这 9 个最有代表性、最“能打”的浏览器原生 API 逐一拆给你看:它们是什么、底层原理好在哪、能替你干掉哪一类 JS 库、以及在实际项目里用的时候要注意什么。适合正在做代码现代化改造的团队,也适合想给自己的项目减负的独立开发者。
1. 浏览器早已不是当年那个浏览器:原生 API 凭什么替代 JS 库
先说清楚为什么会出现“原生 API 干翻 JS 库”这种事。核心原因就一句:浏览器在标准化的路上一路狂奔,把过去只能靠 JS 手搓的能力沉淀成了系统级能力。
拿 2010 年前后的前端开发来对比,那时候你写一个弹窗,需要动态创建遮罩层、监听键盘事件处理 ESC 关闭、手动管理焦点、还要处理滚动穿透。每一步都是一堆代码,而且每个浏览器表现还不一样。这种情况下,第三方 JS 库的价值非常大——它把跨浏览器的差异都替你抹平了,你只需要调用库提供的 API 就行。
但现在情况完全不同了。HTML 标准里直接加入了<dialog>元素,浏览器帮你把遮罩、焦点管理、ESC 关闭这些系统级行为全部封装好。再看页面滚动加载,过去你要监听 scroll 事件、计算元素位置、再判断进入视口,现在IntersectionObserver把这一切变成一行observe调用。本质上是浏览器在替你做以前 JS 库做的脏活累活,而且做得更符合原生体验和性能要求。
还有一个容易被忽略的因素是打包体积。我做过很多次 bundle 分析,很多项目里 JS 库的体积占了全站脚本的三分之一甚至更多。你可以说现在带宽快、加载不是问题,但解析和执行 JS 仍然是 CPU 时间和主线程阻塞成本。原生 API 几乎是零额外成本的,你只是在调用浏览器已经内置的能力。特别在移动端,省掉的每一 KB JS 都可能直接影响首屏时间和交互响应速度。
浏览器兼容性也不再是当年的大难题。现在的现代浏览器迭代非常快,Chrome、Edge、Firefox、Safari 对标准化特性的支持率都很高,而且很多新 API 都支持@support或特性检测来优雅降级。这就意味着,技术选型上有了全新的可能:能用原生的就不引库,需要库的时候才让库出场。
我还观察到一点:原生 API 写出来的代码可读性和可维护性普遍比库代码更好。因为它紧贴 HTML 语义,任何一个后来接手的人看代码,都知道这是浏览器标准能力,不用去查第三方库的文档。这在大团队协作里尤其值钱。既然基础能力已经强到这个程度,下面就来逐个过这 9 个 API,看它们具体是怎么“干翻”对应 JS 库的。
2. 九个 API 逐个拆解:每一处都能砍掉一个库
我把这 9 个 API 分成三组来理解会更快:第一组是“行为交互类”,包括弹窗、折叠、弹出层和模板;第二组是“观察反馈类”,包括懒加载、元素尺寸变化和动画;第三组是“页面呈现类”,包括页面转场和滚动。分好组之后,你在脑子里就有一个地图了,遇到具体需求能立刻反应出该调哪把刀。
| 原生能力 | 替代的库类型 | 核心价值 |
|---|---|---|
<dialog> | 弹窗/模态框组件库 | 内置焦点陷阱、ESC 关闭、遮罩 |
<details>+<summary> | 折叠面板/手风琴库 | 零 JS 状态管理,原生可访问性 |
| Popover API | Tooltip/下拉菜单库 | 顶层层级管理、自动定位协作 |
<template> | 轻量模板拼接库 | 惰性实例化、DocumentFragment 批渲染 |
| View Transitions API | 页面转场/路由动画库 | 视图状态共享、系统级过度动画 |
| IntersectionObserver | 懒加载/无限滚动库 | 视口检测的异步回调机制 |
| ResizeObserver | 响应式布局监听库 | 精确尺寸变化通知,不触发性能抖动 |
| Web Animations API | 动画编排库 | 主线程外合成、时间线控制 |
| scroll-behavior + scrollend | 平滑滚动/锚点库 | 声明式滚动,事件准确收尾 |
2.1<dialog>:弹窗库的第一个业务杀手
弹窗是前端随处可见的硬需求。过去选择第三方弹窗库,主要是因为焦点管理太麻烦:打开弹窗之后,键盘 Tab 应该被限制在弹窗内部;关闭弹窗之后,焦点要回到打开前的触发元素。自己做很容易漏,而漏了就是可访问性事故。
<dialog>元素的最大价值就是把这些系统级行为内置了。你只要调用dialog.showModal(),浏览器自动帮你做这几件事:显示在顶层、生成一个不可穿透的模态上下文、把焦点锁进对话框内部、监听 ESC 键关闭。代码量直接从几百行变成几行:
<dialog id="confirmDialog"> <p>确定要删除这条记录吗?</p> <button class="primary" id="confirmBtn">确定</button> <button id="cancelBtn">取消</button> </dialog>const dialog = document.getElementById('confirmDialog'); document.getElementById('confirmBtn').addEventListener('click', () => { // 业务逻辑 dialog.close(); }); document.getElementById('cancelBtn').addEventListener('click', () => { dialog.close(); }); dialog.showModal();我在实际项目里最常用的处理是配合::backdrop伪元素设计遮罩样式,这比你自己写一个遮罩层 div 再处理 z-index 要干净得多。关键是这个 API 不是“能用”,而是“好用”:它连关闭事件close都能直接监听,不需要你在关闭按钮里手动维护状态。如果你的项目还在为几个弹窗引入一个重组件库,这部分就属于明显的过度设计。
2.2<details>+<summary>:折叠面板不再需要手写状态机
折叠面板(手风琴)也是个看起来简单、写起来啰嗦的东西。你需要维护展开状态、切换图标、处理只允许同时展开一个还是多个并存。以前很多团队为了这个逻辑也要写几十行 JS,甚至为了“更好的动画”再引一个库。
实际上整个折叠面板的骨架在 HTML 里躺了很久了,只是很多人没用起来。<details>元素自带展开和收起的状态管理,<summary>是它的可见触发器。点击、键盘操作、屏幕阅读器朗读,全部原生支持。
<details> <summary>服务条款第一条</summary> <p>这是一段默认收起的内容,点击 summary 展开或收起。</p> </details>我见过最惊艳的用法是不写一行 JS,纯靠CSS配合details做手风琴互斥效果。把多个details放在同一组里,当用户打开一个时,通过一小段逻辑关闭其他组件,这时可以选择引入少量 JS,也可以接受“多个同时展开”的交互形式。如果你的交互需求并不复杂,原生能力完全可以覆盖绝大多数业务场景。
值得提醒的是,给summary设置自定义指示箭头时,要记得保留默认的展开状态提示。我一般用::details-marker或直接在summary内放一个小图标,用details[open]属性控制旋转方向。这部分本来就是纯 CSS 的活了,跟 JS 库没关系。
2.3 Popover API:提示层从小作坊变成了规范产物
Tooltip、下拉提示、右键菜单、浮层卡片,这类“点击或悬停后弹出的悬浮内容”在过去是最容易出事的地方。因为你得自己处理点击外部关闭、多个弹出层互相遮挡、层级混乱、滚动时位置偏移等各种细节。每一件事单看都不难,堆在一起就是一个消耗时间的黑洞。
Popover API 把这一整类需求变成了一个标准方案。你只需要给目标元素加一个popover属性,再用 JS 或另一个元素的popovertarget属性把它调出来。弹出层会自动进入顶层(Top Layer),不需要你管理 z-index;点击页面其他位置会自动关闭;支持light-dismiss(轻量关闭)行为。
<button popovertarget="tip">查看更多</button> <div id="tip" popover> <p>这是原生弹出层,自动在顶层显示,点击其他区域自动关闭。</p> </div>const tip = document.getElementById('tip'); // 也可以手动控制 tip.showPopover(); tip.hidePopover();实际使用中我最大感受是,它把原先“弹窗管理”的复杂度直接抹掉了。以前你可能为了两个下拉菜单互相干扰的问题,专门写一套“同一时间只允许一个 popover 打开”的逻辑;现在只要你愿意,浏览器可以帮你管理顶层顺序,且天然互不干扰。这个 API 也提醒我:很多 UI 难点其实不是业务难点,是没等标准出来就自己造了轮子。
2.4<template>:轻量模板引擎的替代者
渲染列表时,很多人习惯在 JS 里拼字符串再一次性塞进 innerHTML。字符串拼接短了还好,长了以后转义、嵌套引号和 HTML 结构混乱的问题就全来了。<template>是一个更优雅的答案:你提前把需要重复的结构写在 HTML 里,浏览器解析但不渲染,需要时克隆一份再填充数据。
<template id="listItem"> <li class="item"> <span class="title"></span> <span class="date"></span> </li> </template>const template = document.getElementById('listItem'); const list = document.getElementById('list'); data.forEach((item) => { const node = template.content.cloneNode(true); node.querySelector('.title').textContent = item.title; node.querySelector('.date').textContent = item.date; list.appendChild(node); });这里面的性能关键点是template.content是一个DocumentFragment对象,你可以先创建一批节点再一次性挂接到 DOM 上,避免反复触发布局抖动。模板内容本身也是惰性的,只有在cloneNode(true)之后才真正成为可用的元素。如果你只需要做简单的数据填充,<template>+DocumentFragment完全可以替换掉一个轻量模板库,而且语义更清晰,逻辑都在 HTML 结构里,不用在 JS 里维护一大片模板字符串。
2.5 View Transitions API:路由转场不再是一等公民的奢侈品
页面切换动画这块,过去基本是专属动画库的天下。你想做一个淡入淡出或者滑动切换,不仅要监听路由变化,还要管理入场、出场两个状态,对一个普通业务团队来说成本很高。View Transitions API 把这件事变成了一个半自动化的过程——你只需要告诉浏览器“我要切换了”,浏览器会帮你截取当前视图、执行过渡、再切换到新视图。
这个 API 最爽的用法在文档流切换上。比如你要更新一个列表数据,旧列表整体淡出,新列表整体淡入:
document.startViewTransition(() => { updateListContent(); // 同步更新 DOM });浏览器会自动捕捉过渡前后的状态,默认用交叉淡化效果。你也通过 CSS::view-transition-old和::view-transition-new控制动画的具体形态。本质上这是把视图转场从“两个动画的拼凑”变成“一次浏览器级别的状态迁移”,代码量少了很多,流畅度反而更好,因为是系统级合成。
要注意它最擅长的是状态切换场景,不适合做“两个独立元素完全不同的运动轨迹”这种复杂编排。遇到那种需求,仍然建议上专门的动画库。但如果你只是要业务页面切换、列表刷新过渡,View Transitions API 能让你在零依赖的情况下获得接近 App 的转场体验。
2.6 IntersectionObserver:懒加载从监听滚动到观察视口
懒加载、无限滚动、曝光埋点,这三类需求以前是监听滚动事件的重灾区。常规写法是在scroll回调里写一堆getBoundingClientRect()判断,可能在每一帧都被触发多次,性能好一点的会加requestAnimationFrame节流,但还是绕不开“手工计算位置”这件事。
IntersectionObserver的思路完全不同:你不需要关心滚动发生在哪里,只需要告诉浏览器“帮我盯着这个元素,出现在视口或离开视口的时候告诉我”。浏览器内部会用高效的机制监听布局变化,并且以异步回调的方式通知你。
const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { // 进入视口,触发懒加载 loadImage(entry.target); observer.unobserve(entry.target); } }); }); document.querySelectorAll('img[data-src]').forEach((img) => { observer.observe(img); });这种模式用来做图片懒加载、列表无限加载、甚至是广告曝光埋点都很顺手。我实际项目里最常配合的是给观察目标设置一个rootMargin,让图片提前半个屏幕加载,体感上完全无缝。相比老式的滚动监听库,代码少、性能好,而且开发者完全不用考虑节流策略,因为浏览器自己已经做了最优化的时机判断。
2.7 ResizeObserver:布局反馈从“猜大小”到“看事实”
响应式布局里经常遇到一个问题:元素尺寸变化之后,要调整内部布局或重新计算位置。过去没有标准方案时,大家普遍用window.resize加getBoundingClientRect()去“猜”某个元素是不是变了。这种方式不仅延迟高,还会漏掉复杂的尺寸变化来源,比如字体加载后文本变高、兄弟元素变化挤占空间、CSS 动画过程中尺寸变动。
ResizeObserver解决了这个痛点:你观察一个元素,只要它的content box尺寸真的发生变化,回调就会触发,并告诉你新的尺寸值。
const observer = new ResizeObserver((entries) => { for (const entry of entries) { const width = entry.contentRect.width; // 根据宽度切换布局模式 adaptLayout(width); } }); observer.observe(document.querySelector('.container'));一个我经常提到的对比是老式做法里,监听 window resize 后满 DOM 找目标元素,重新测尺寸,再手动比对“是否真的变了”。那是一种通过猜测来处理问题的方式;而 ResizeObserver 是直接观察事实。大型应用中,如果你有侧边栏拖拽、图表容器自适应、自定义滚动区域尺寸同步这些需求,这个 API 能省掉大量“协调布局”的胶水代码。还有一个被低估的点是:它在元素从display: none变成可见的时候,也能正确触发回调,这一点以前很难做对。
2.8 Web Animations API:动画编排的自来水管
动画库的价值在于提供易于使用的 API、时间线和缓动管理。Web Animations API(WAAPI)是浏览器原生提供的对应能力,它让你可以直接用 JS 创建动画,而不是依赖 CSS 类切换和 transition 的事件计时。
一个常见的动画需求是:点击按钮后,弹窗从底部滑入。用 WAAPI 写起来很清楚:
const sheet = document.getElementById('bottomSheet'); sheet.animate( [ { transform: 'translateY(100%)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }, ], { duration: 300, easing: 'cubic-bezier(0.2, 0.9, 0.3, 1)', fill: 'backwards', } );WAAPI 里animate()返回一个 Animation 对象,你可以叫暂停、掉头、跳时间线、监听 finish 事件,甚至可以多条动画放在同一个效果组里。对于常规的过渡效果、入场动画、数字滚动效果,它完全可以胜任,且由于运行在合成器线程,性能上往往比“给每个属性加 transition + JS 里改样式”更稳。
但我也想把话说明白:复杂交错、逐帧控制、场景级的动画编排,WAAPI 还不至于完全取代专业动画库。它更像是一个覆盖面很广的标准基础层,能把 60% 的动画需求消化掉。真正剩下那部分“必须按物理引擎规则连动”的需求,再考虑重量级的方案。
2.9 scroll-behavior 与 scrollend:平滑滚动的最后一公里
“点击导航滚动到锚点”这种需求,以前最常见实现是引入一个平滑滚动小工具。其实 CSS 一行就能解决:
html { scroll-behavior: smooth; }然后依然用element.scrollIntoView(),浏览器会自然执行平滑滚动。这个看起来太简单了,反而是很多人忽略它的原因。但更进阶的是scrollend事件,它解决了滚动结束回调的问题。
过去你要判断滚动是否结束,通常得在 scroll 事件里写定时器或者做“位置没变就视为结束”的判断。scrollend事件把这件事变成了一个可靠的回调:滚动彻底停止后,浏览器会触发一次scrollend。这对“到达底部时自动加载下一页”或者“滚动结束后固定某个元素位置”这类交互特别有用。
const scroller = document.getElementById('scroller'); scroller.addEventListener('scrollend', () => { // 滚动真正结束了,做收尾工作 if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 10) { loadMore(); } });这个组合解决的是“最后一点体验细节”。以前可能为了一个“下滑一定距离后按位置吸顶”的逻辑,要同时处理 scroll 事件、方向判断、节流和结束识别,现在交给标准事件,代码简洁,行为统一。
3. 替换库之前的冷静期:这些 API 的边界在哪里
上面把 9 个 API 都夸了一通,但我必须说,原生 API 不是银弹。我在推动团队做技术瘦身时,一直坚持一个原则:原生 API 能替代的是“通用能力”,但替代不了“特定业务封装”。提前想清楚边界,才不会从一个极端走到另一个极端。
第一个明显的边界是复杂交互状态机。拿<dialog>来说,它能帮你管理模态焦点和 ESC 关闭,但如果你的弹窗里有多个步骤、分支条件、异步校验,这些业务逻辑仍然需要你来写。完全不需要库,指的是你需要做一个“打开-展示-关闭”的自然弹窗;如果你要做向导式弹窗、内嵌复杂表单校验、多个弹窗嵌套联动,核心逻辑依然是你的 JS 代码量,原生 API 只帮你省掉了壳子部分。
动画库也一样。WAAPI 做常规动画非常顺手,但场景级编排,比如一个页面里多个元素依次入场、交错播放、相互等待,那么你的代码里还是会冒出一堆 Promise 或者回调。专业动画库已经把时间线和场景管理的模式抽象好了,这时候硬用原生实现只会让代码变复杂,反而不利于维护。
再一个重要边界是老旧浏览器的兼容。虽然现代浏览器发展很快,但有些企业应用里还跑着老版本的环境,或者你的用户里有相当比例使用旧内核。popover、View Transitions这类新 API 在旧环境里没有天然降级方案,一旦做了特性检测就得渲染另一套方案,这部分成本也得算进技术决策里。后面我会给一整套降级策略参考。
所以我的建议是:每个项目开始前,先列一张“依赖清单”,把每引入一个库的理由写在上面。如果理由只是“大家都这么用”,那就该考虑原生方案了;如果理由是“这个库给了我一套完整的组件状态管理模式”,那保留它是有道理的。砍掉库不是目的,让技术栈回归业务的真实复杂度才是目的。
4. 兼容性降级策略:原生越强,越要准备一张安全网
现在你在生产环境用这些特性,完全不用慌,但必须有意识地给老环境留后路。具体做法无非四种:特性检测、功能回退、样式兼容、polyfill 替代。
特性检测是第一步。任何原生 API 在使用前,都应该先确认当前浏览器有没有这个能力。比如这样:
if (typeof HTMLElement.prototype.showModal === 'function') { dialog.showModal(); } else { // 回退到自定义弹窗逻辑 }没有showModal的老浏览器下,我只能用传统的 class 切换 + 遮罩层来处理。这种回退逻辑不值得每次都写得很完善,只需要让核心功能可用即可。我在项目里常用的做法是封装一个小工具函数,输出当前环境的能力表,这样全局都能复用。
样式兼容这块容易被忽略。例如<details>在新浏览器里点击有默认的展开收起动画,老浏览器可能直接跳变,功能不受影响但体验稍差。不影响功能的情况下,我一般不处理;但如果跳变影响到了视觉一致性,就通过@supports区分样式。View Transitions本身是一种渐进增强,如果浏览器不支持,startViewTransition不存在,页面更新逻辑直接走同步版本,用户看到的只是没有过渡动画的普通刷新,完全可以接受。
| 原生能力 | 降级方案 | 建议 |
|---|---|---|
<dialog> | 自写遮罩 + class 显隐 | 只保证核心交互可用 |
| Popover API | 动态创建占位层,绝对定位 | 或直接隐藏提示功能 |
| View Transitions | 直接更新 DOM,不做过渡 | 体验衰减但不影响功能 |
| IntersectionObserver | 滚动事件 + 节流 | 旧环境可接受性能差 |
| ResizeObserver | window.resize + 手动测量 | 常见场景,简单实现即可 |
| WAAPI | 用 CSS transition 兼容 | 大部分基础动效可覆盖 |
polyfill 这块我的建议是克制。对于像IntersectionObserver这种行为定义明确、polyfill 体积又小的,可以考虑引入官方 polyfill。但对于像View Transitions这种浏览器深层能力,polyfill 很难还原完整效果,与其引入一个半吊子的模拟实现,不如优雅降级。安全网的意义是“业务不崩”,不是“能力对等”。清楚这个取舍,你才能在引入新 API 时保持从容。
5. 从一个真实重构案例看替换效果:代码量、性能与可维护性
前面讲了这么多单个 API,最后我想用一个相对完整的视角,看看一次真实的瘦身流程到底能改写多少代码。
这个案例是一个后台管理系统,典型的中后台项目。技术方案是维护团队自己积累的一套老组件,其中弹窗、折叠菜单、滚动加载、图片懒加载、表格动画都各自引了独立的小工具包。一次代码清理过程中,我们把目标锁定在这些场景上,逐步做替换。
弹窗部分,全站共有三类弹窗:确认框、详情预览、小表单。确认框用<dialog>重写之后,原先每个弹窗里手写的焦点管理和 ESC 监听代码全部删除。详情预览本来用的是第三方轻量弹窗库,替换成<dialog>之后,那个库的依赖几乎可以从打包产物里剔除。折叠菜单栏原本是手写状态,每个菜单维护一个 open 标志,还要同步箭头旋转,换成<details>+<summary>后,相关 JS 只剩“根据权限过滤菜单项”的纯业务代码。图片懒加载用IntersectionObserver重写后,我顺手把每个图片的监听目标设成rootMargin: '200px',首屏加载速度和滚动时图片解析时机都调到了比较理想的状态。滚动加载列表从 scroll 节流监听改成scrollend后,误触发次数明显减少,而且不用再写防抖逻辑。
最终统计下来,跟这次清理相关的 JS 减少了大概 38%,首屏主线程长任务次数下降了约两成。我写这段不是要给你一个精确的数字标准,而是想说明一个趋势:当浏览器原生能力已经把通用交互覆盖掉之后,项目代码里剩下的大都是真正的业务逻辑,而不是“给浏览器打补丁”的处理代码。
维护性上的提升同样直观。原生 API 没有版本升级和 API 废弃压力,你用五年也不会过时。第三方库则不同,升级主版本意味着引入 breaking changes,不升级又面临安全修复停止。我在做技术决策时越来越倾向于“让标准承担通用能力,让库承担复杂封装”,这样才能既不累着自己,也不透支未来。
页面上真正难的从来不是弹窗怎么做、折叠怎么开、列表怎么懒加载,而是你的业务本身。把通用能力还给浏览器,就是把这些消耗从日常开发里拿掉,让团队的精力都放在产品问题和技术深度上。我个人的体会是:每一次技术选型,都应该先问浏览器有没有现成的答案,再问真的需要多重的工具。这套 9 个 API 的组合拳,至少能帮你把清单里那一长串“经典必装库”删掉一小半,剩下的空间,留给那些真正为业务复杂度和体验深度而存在的核心依赖。