☰
这9个浏览器原生接口,正在干掉常用JS库
2026/10/14 22:28:51 网站建设 项目流程

HTML 还能干翻 JS 库?以前这话要是有人跟我说,我大概率觉得他在吹牛。毕竟印象里 HTML 就是些<p>、<div>、<span>,真正干活全靠 JS 库,少了 jQuery、弹窗插件根本活不下去。但这几年我越来越发现:浏览器原生提供的能力,已经不只是“标签”或者“CSS 属性”这么简单了,它们是一批真正的系统级接口——<dialog>、IntersectionObserver、Popover、View Transitions、Scroll Snap……每一个都能精准干掉一个常用 JS 库的核心功能。这篇文章不搞空谈,我直接把这 9 个最狠的系统级接口摊开来讲:能替代什么库、怎么写、有哪些坑,以及我实测下来的性能收益和取舍逻辑。

1. 内容整体设计与思路拆解

1.1 为什么“原生接口”突然能打了

两年前我做一次前端优化,把老项目里一堆插件全拔了,换成原生能力实现,bundle 体积直接少了一大半,页面功能一个没少。那次重构让我彻底改变了对 HTML 的认知:现在的浏览器接口,已经是“自带 UI 组件、自带观察器、自带网络请求控制”的系统级方案。

很多人觉得原生接口弱,其实是被老经验束缚了。以前确实不行,但现代浏览器在执行引擎、渲染引擎、标准制定上同步推进,很多能力直接做到内核里。比如<dialog>自带焦点管理、Esc 关闭、背景遮罩;IntersectionObserver由渲染进程异步计算交叉区域;Popover自带顶层层级和轻触关闭。这些能力,以前需要写一大段 JS 模拟,现在是浏览器底层帮你算好。

更关键的是,这些接口之间还能组合。用fetch+AbortController做请求,用IntersectionObserver做懒加载,用<dialog>做弹窗,用 Scroll Snap 做轮播,几乎覆盖了传统电商、后台管理、内容站的绝大部分交互场景。我说的“干翻常用 JS 库”,不是指所有库都卵用没有了,而是指 70% 的轻交互场景,根本不用引库。

1.2 选这 9 个接口的标准

市面上的 API 多了去了,我挑这 9 个,主要看三点。

第一,每个都对应一个高频引入的 JS 库或插件。弹窗对应各种 modal 插件,懒加载对应 lazysizes,轮播对应各种 carousel 库,请求库对应 axios,滚动监听对应 ScrollMagic 这类库。有取代对象,才有实际价值。

第二,兼容性已经上了主流舞台。这里说的兼容不是“所有浏览器版本都能用”,而是“渐进增强之后不会出事”。比如View Transitions目前 Chromium 系体验最好,其他浏览器可以优雅降级成无动画更新,这在我的日常项目里够用。

第三,组合收益明显。用原生方案减少依赖,不只是省几 KB 的事,还能减少构建复杂度、降低第三方库的安全审计成本和版本冲突风险。我自己的实践里,原生接口带来的不仅是体积收益,还有心智负担下降——代码里没有那么多黑盒插件,出问题时定位更快。

2. 界面组件三件套:dialog、Popover 与 Scroll Snap

2.1<dialog>直接替代弹窗插件

<dialog>是我最推荐优先替换的接口。以前写一个弹窗,你得手动管遮罩、关闭按钮、Esc 监听、焦点约束、背景滚动锁定,零零散散几百行。现在浏览器给了一整套方案。

最基础的用法:

<dialog id="tips"> <form method="dialog"> <p>这是原生弹窗</p> <button value="ok">确认</button> </form> </dialog> <button id="openBtn">打开弹窗</button>
const dialog = document.getElementById('tips'); document.getElementById('openBtn').addEventListener('click', () => { dialog.showModal(); }); dialog.addEventListener('close', () => { console.log('返回值:', dialog.returnValue); });

重点说几个细节。

showModal()打开的是模态弹窗,背后区域被遮罩盖住,而且浏览器会自动帮你管焦点约束:Tab 键不会跑到弹窗外面去。method="dialog"是专门给<dialog>配的表单提交方式,点击按钮会关闭弹窗,并按value设置returnValue。这个设计很巧妙,直接把“确认/取消”这套交互消化掉了。

我踩过一个坑:弹窗打开后,背景页面仍然可以滚动。原生<dialog>默认不会锁 body 滚动,你得自己加一行:

body:has(dialog[open]) { overflow: hidden; }

这里:has()刚好也是下文要讲的原生选择器,两个能力配合起来很好用。另外一个经验:如果你需要弹窗打开时有个渐变动画,别去引动画库,直接用@starting-style和transition就能做。当然这属于较新的特性,旧浏览器就算没有动画也不影响功能。

2.2 Popover API 干掉 Tooltip 和下拉面板

Popover API 是近几年最被低估的能力。以前做 tooltip、下拉菜单、气泡卡片,要么用定位库,要么用 bootstrap 的 popover,要么自己写一堆 show/hide 逻辑。现在一个popover属性搞定。

用法非常直接:

<button popovertarget="menu">更多操作</button> <div id="menu" popover> <p>菜单项一</p> <p>菜单项二</p> </div>

不用写一行 JS,点击按钮,面板自动弹出,再点一次关闭、点击外部关闭、Esc 也能关闭。popover属性声明这是一个浮层,popovertarget声明由哪个按钮触发。浏览器会自动把浮层放到顶层,不会出现 z-index 大战。

进阶一点可以配合popovertargetaction="show|hide|toggle"控制触发行为。还有:popover-open伪类,可以给打开状态写样式:

#menu { opacity: 0; transition: opacity 0.15s; } #menu:popover-open { opacity: 1; }

我知道有人会对 Popover 的兼容性有顾虑。它的确没有dialog那么老,但主流现代浏览器都支持了,而且它本质是“增强交互”,老浏览器上按钮还在,只是面板不显示或者需要 JS 兜底。我的处理方式是:如果目标是移动端管理后台和桌面浏览器,直接用没问题;如果要做公开老用户兼容,再考虑用 dialog 或简版 JS 兜底。

2.3 Scroll Snap 让“轮播”变成 CSS 的活

轮播图是所有前端都写过的组件。传统方案是引入 Slick 这类库,动辄几十 KB,只为了一个自动滑动和指示器。其实纯 CSS 就能实现轮播的滑动体验,核心就是 Scroll Snap。

基础结构:

<div class="carousel"> <section>1</section> <section>2</section> <section>3</section> </div>
.carousel { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; } .carousel section { flex: 0 0 100%; scroll-snap-align: start; }

scroll-snap-type: x mandatory让横向滚动在松手后自动吸附到最近的区块。触摸板、鼠标滚轮横向滑动、触屏滑动都是原生支持,不会像 JS 模拟那样有卡顿和惯性失真。

如果要做自动轮播,也不用引库,小段 JS 控制scrollTo({ left, behavior: 'smooth' })就行。指示器就监听scroll事件,用scrollLeft除以单页宽度算出当前索引。

踩过的坑有两个。第一是scroll-snap-align: center和start的区别,轮播尽量用start,不然第一张和最后一张会偏移。第二是容器高度必须显式设置,不然 Snap 计算参考高度会出问题。另外 iOS Safari 对scroll-snap-stop的支持有历史问题,写自动轮播时要注意触发频率,别把设置的滚动和用户手动滑动打架。

3. 观察与请求控制:IntersectionObserver、ResizeObserver 与 AbortController

3.1 IntersectionObserver 替代懒加载与曝光监听

懒加载图片以前是 lazysizes 的天下,要监听滚动、计算图片位置、判断是否进入视口。现在 IntersectionObserver 把这些全接管了。

const io = new IntersectionObserver((entries) => { for (const entry of entries) { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; io.unobserve(img); } } }, { rootMargin: '200px' // 提前 200px 开始加载 }); document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));

最关键的心理模型是:它是异步的、由浏览器底层计算交叉区域,而不是在主线程上反复监听 scroll 再做 getBoundingClientRect 计算。我实测过大列表懒加载,页面滚动时明显更顺滑,因为回调触发时机被浏览器优化了。

这个 API 还能做曝光统计。以前做埋点得自己在 scroll 回调里判断元素是否进入视口,现在直接观察就行。观察元素进入视口时上报,离开视口时可以选择继续观察或停止。我一般处理成重复曝光只上报一次:第一次进入视口后立即unobserve,然后业务层自己决定是否上报。

一个细节:rootMargin设置预加载距离,但注意它不能无限大,太大了等于把所有图片都加载了,失去懒加载的意义。200px 是我调过几个项目之后的习惯值,兼顾提前感知和节省流量。

3.2 ResizeObserver 干掉“窗口变了”的轮询

以前监听容器尺寸变化,只能window.addEventListener('resize', handler),然后手动算元素宽度。问题是窗口没变、容器变的时候,比如侧边栏折叠、面板拖拽、内容插入,resize 事件根本不会触发。

ResizeObserver 监听的是任意元素的内容盒尺寸变化,只要被监听元素宽高变了,回调就会触发。这正好契合做自适应布局组件。

const container = document.getElementById('grid'); const ro = new ResizeObserver((entries) => { for (const entry of entries) { const width = entry.contentRect.width; container.style.gridTemplateColumns = width < 480 ? '1fr' : '1fr 1fr'; } }); ro.observe(container);

这里有个简单的“响应式布局不查媒体查询”的玩法:组件内部的列数完全由自己的可用宽度决定,而不是全局视口宽度。这在做仪表盘、卡片流、富文本编辑器工具栏时非常有用。

注意两点:回调触发的次数可能比预期多,操作里别做高强度计算或频繁改 DOM 结构;contentRect里拿的是布局尺寸,如果监听对象里有图片加载导致高度变化,回调也会触发,这是正常现象,别当成 bug。

3.3 AbortController 甩掉 axios 的取消逻辑

fetch 出来之后很多人还留着 axios,理由是 axios 有“取消请求”的能力。其实 fetch 配合 AbortController 一样能取消,而且这是浏览器原生提供的标准机制。

const controller = new AbortController(); const { signal } = controller; fetch('/api/list', { signal }) .then((res) => res.json()) .catch((err) => { if (err.name === 'AbortError') { console.log('请求已取消'); } }); // 任意时刻取消 controller.abort();

实际项目里,我封装过一个简单的请求函数,把获取 signal 的逻辑拆出来:

function createRequestToken() { const controller = new AbortController(); return { signal: controller.signal, cancel: () => controller.abort(), }; } async function apiGet(url, token) { try { const res = await fetch(url, { signal: token.signal }); if (!res.ok) throw new Error('请求失败'); return await res.json(); } catch (e) { if (e.name === 'AbortError') return { cancelled: true }; throw e; } }

这套逻辑用来解决一个老问题:切换 Tab 页时,上一页的请求还在跑,回来之后响应覆盖了新页面的数据。现在每个 Tab 页挂在 mount 时创建 token,卸载时直接 cancel,干净利落。

一个被很多人忽略的坑:AbortController取消的是 fetch 的“等待阶段”,如果响应已经拿到、body 已经在流式传输,abort()会中断 body 的读取并抛AbortError。所以流式下载场景,比如大文件、SSE,要额外处理已消费数据的问题,不能简单认为 abort 是万能的。

4. 视图过渡与状态判断:View Transitions、Fetch 与 :has()

4.1 View Transitions API 替代过渡动画库

页面切换动画、列表增删动画,以前要么自己写 FLIP,要么引 GSAP、Framer Motion。现在浏览器原生的 View Transitions API 能接管大部分过渡场景,你只管更新 DOM,浏览器帮你完成前后状态之间的动画。

简单用法:

document.startViewTransition(() => { updateDom(); // 任意同步 DOM 更新 });

就这一行,浏览器会截取旧状态、快照、更新 DOM、截取新状态,然后自动做交叉淡入淡出。想要更炫的过渡效果,用 CSS 控制::view-transition-old(root)和::view-transition-new(root)的动画即可。比如列表里单个卡片增删时动态变化,可以给元素设置view-transition-name,让浏览器单独为这个元素做过渡。

.card { view-transition-name: card; }

这个 API 最大的价值是:之前做 SPA 路由切换动画,怎么写都费劲,现在只需要在路由切换时包一层startViewTransition,再补一点 CSS 就完成。可靠的主机不是它(在 wordpress 里吗)。我的经验是:不要试图对“所有页面”无差别做复杂动画,只挑品牌感强的场景用,比如列表到详情、登录态切换。降级问题上,老浏览器不支持时,startViewTransition不存在,代码里做个判断调用更新函数就行,页面功能不受影响。

4.2 Fetch 为什么不比 axios 差太多

axios 能做的核心事情:发请求、拿 JSON、设置 headers、统一错误处理。这些 Fetch 都有,只差一个“默认行为”的封装。很多人不习惯 fetch 是因为它把“HTTP 状态码非 2xx”也算正常响应,需要自己res.ok判断。一次封装就解决了:

async function http(url, options = {}) { const res = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...options.headers, }, }); if (!res.ok) { const err = new Error(`HTTP ${res.status}`); err.status = res.status; throw err; } return res.json(); }

这样封装完,日常项目的接口调用基本和 axios 一个体验。加上前面说的AbortController,请求取消能力也齐了。

那什么时候还需要 axios?统一拦截器、baseURL 自动拼装、多种适配器、老浏览器兼容这些场景。如果你的团队已经在用 TypeScript 和现代构建环境,我反而建议用原生 fetch 自己维护一个 20 行的请求封装,后续想做什么拦截都很自由,不用等库作者更新。

4.3 CSS :has() 干掉“状态切换”的 JS

:has()是近几年 CSS 选择器里最该练的一个。它允许你根据子元素状态改变父元素样式,以前这种逻辑必须写 JS。

以前实现“某个卡片处于激活态时,给父容器加边框”,你得监听点击事件,给父容器加类。现在:

.card:has(.active) { border-color: #2f54eb; box-shadow: 0 4px 12px rgba(47, 84, 235, 0.3); }

:has()还能配合状态伪类做表单校验提醒,不需要 JS 监听输入:

.field:has(input:invalid) .error-message { display: block; } .field:has(input:valid) .ok-icon { display: inline-block; }

这个选择器的厉害之处是能表达“基于子节点状态的父级样式”,这是 CSS 以前完全没有的能力。我在项目里用它做了很多以前非得写 JS 才能做的事,包括弹窗打开时锁背景滚动(前文提到)、侧边栏展开后主区域调整间距、选中态驱动布局变化等。

一个性能提醒::has()不是不要钱,浏览器需要额外判断“有没有更深的匹配节点”,在超大文档里如果你用它查很深的层级,代价比普通选择器高。我的习惯是让它做“就近匹配”,比如父元素和直接子元素、表单控件与提示文本,别拿来查很远的关系。

5. 实操过程与核心环节实现

5.1 重构框架:从四个 JS 库到零依赖

我在一个实际项目里做过一次完整替换。原页面用了四个东西:一个弹窗插件、一个懒加载插件、一个轮播插件、一个请求库。重构目标是全部换成原生能力,同时保证视觉和交互不变。

整体替换后的代码结构非常干净。

原结构:

  • 弹窗:调用插件方法,传title/content配置
  • 懒加载:lazyload自动扫描img.lazyload
  • 轮播:carousel 插件初始化
  • 请求:axios 实例 + 拦截器

新结构:

  • 弹窗:<dialog>元素 + 少量样式
  • 懒加载:IntersectionObserver统一扫描
  • 轮播:scroll-snap-type+scrollTo
  • 请求:fetch 封装 +AbortController

5.2 分步替换的关键步骤

第一步,弹窗。把模板里插件生成的结构改成<dialog>。需要注意showModal()打开时,浏览器自动加了::backdrop遮罩层,遮罩默认是黑色半透明,想换成品牌色就改样式:

dialog::backdrop { background: rgba(0, 0, 0, 0.45); }

第二步,懒加载。原页面有很多图片,统一在模板里改成>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询