六种可替代常见JavaScript UI库的原生HTML功能
了解弹出框、专属详情、对话框、声明式阴影 DOM、fetchpriority 以及 datalist 如何替代自定义 JavaScript,以及它们各自仍存在的局限。
多年来,HTML负责提供结构,而JavaScript则处理所有交互功能:模态框需要React库,工具提示则需要Popper.js或Tippy,服务器端渲染的组件则依赖自定义脚本。与此同时,WHATWG HTML Living Standard以及各类浏览器引擎新增了原生构建模块,仅需少量属性即可解决许多此类问题,既减少了冗余代码和文件大小,又能默认实现无障碍访问。本指南将介绍其中的六种功能、每种功能所需的标记,以及何时仍需要使用CSS或JavaScript。在采用备用方案之前,请先确认目标浏览器对其的支持情况。
1. 使用popover属性的弹出框
传统上,工具提示和菜单需要处理z-index堆叠上下文的问题,并手动编写点击外部区域的监听代码。而借助Popover API,只要某个元素带有popover属性,它在打开时就会被提升到最上层,位于所有普通内容之上,且不受z-index的影响。如果为按钮设置popovertarget属性并指定该元素的id,即可控制其显示与隐藏:
<!-- The Trigger -->
<button popovertarget="my-menu">Toggle Menu</button>
<!-- The Popover Content -->
<div id="my-menu" popover>
<p>I am natively managed! Press ESC or click outside to close me.</p>
</div>
无需任何JavaScript即可实现:
- 声明式控制:按钮可用来打开和关闭popover。
- 简单关闭方式:点击页面外部或按Esc键即可关闭popover。这适用于默认的
popover="auto"模式;若设置为popover="manual",则由用户自行决定关闭方式。 - 无需处理堆叠问题:打开的popover会显示在最上层,位于其他所有内容之上。
两个限制:API仅能控制元素的可见性与堆叠方式,无法决定其位置,因此要将菜单固定在触发元素上仍需使用CSS(传统的定位方式或更新但支持度较低的锚点定位)。此外,弹出框属于非模态元素,不会锁定焦点,因此需使用dialog来实现确认功能。
2. 带详细内容与名称的折叠面板
details和summary早已能够无需JavaScript即可实现可折叠内容区域。新增功能是:具有相同name属性的details元素会组成一个组,该组中同一时间只能展开一个元素,功能类似传统的折叠面板。
<details name="faq">
<summary>What is modern HTML?</summary>
<p>A living standard evolving directly inside browser engines.</p>
</details>
<details name="faq">
<summary>Do I still need JavaScript?</summary>
<p>Much less of it than you used to!</p>
</details>
<details name="faq">
<summary>Is this supported natively?</summary>
<p>Yes, across all major modern browser engines.</p>
</details>
打开某个带有name="faq"属性的元素时,其他所有相同名称的开放项都会自动关闭,这一过程无需任何监听器、状态钩子或类切换操作。对于不支持name属性的浏览器,则会允许多个项目同时保持开放状态,作为优雅的降级方案。
3. 使用 dialog 和 ::backdrop 实现无障碍模态框
自定义的 div 叠加层常常无法通过无障碍审核,因为合格的模态框必须将焦点限制在自身内部、遮挡其背后的页面,并在操作结束后将焦点恢复到触发按钮。dialog 元素可解决这一问题。下方的表单使用了 method="dialog",无论点击哪个按钮都会关闭对话框,且被点击按钮的 value 值将成为对话框的 returnValue:
<dialog id="user-modal">
<h2>Confirm Action</h2>
<p>Are you sure you want to delete this repository?</p>
<!-- A form inside a dialog with method="dialog" closes it automatically -->
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="confirm">Delete</button>
</form>
</dialog>
<button onclick="document.getElementById('user-modal').showModal()">
Open Modal
</button>
那个变暗的叠加层就是通过纯 CSS 样式实现的 ::backdrop 伪元素:
/* Styling the native browser backdrop */
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
showModal()会将对话框置于最上层,使页面其余部分无法交互,保持焦点在对话框内,并允许使用 Escape 键关闭;关闭后,当前浏览器会将焦点恢复到之前聚焦的元素。show()则相反,以非模态方式打开对话框,且没有这些保护机制。在 React 中,应通过 ref 调用 showModal(),而非使用内联的 onclick。
4. 使用声明式 Shadow DOM 的服务器渲染 Web 组件
早期的 Shadow DOM 需要客户端 JavaScript(element.attachShadow({ mode: 'open' })),因此服务器渲染或静态生成的 Web 组件在脚本执行前没有封装的标记,从而导致页面出现无样式的闪烁和布局变动。声明式 Shadow DOM 解决了这一问题:在宿主元素中添加带有 shadowrootmode 的 template,即可在 HTML 中描述阴影树结构:
<user-card>
<template shadowrootmode="open">
<style>
/* Styles here stay encapsulated inside this component */
h3 { color: #3b82f6; font-family: sans-serif; }
</style>
<h3>Jane Doe</h3>
<p>Senior Frontend Engineer</p>
</template>
</user-card>
在渲染之前,解析器会将模板转换为user-card上的阴影根,这样作用域内的样式和标记就能在首次绘制时显示出来。需要注意的是:这一过程仅发生在HTML解析阶段。通过innerHTML在之后插入的标记不会生成阴影根;动态插入则需要使用如setHTMLUnsafe()之类的API,因此请确认当前是否支持。
5. 使用fetchpriority为关键资源设定优先级
loading="lazy"以及async/defer无法指示哪些早期加载的资源最为重要。当页面中有多张图片时,位于最大内容绘制点(LCP)之后的主图应比徽章和图标具有更高的优先级。fetchpriority可以为网络调度器提供这样的提示:
<!-- Critical LCP Image: Load this immediately! -->
<img src="hero-banner.webp" fetchpriority="high" alt="Product Hero">
<!-- Lower priority images -->
<img src="avatar.webp" fetchpriority="low" alt="User avatar">
<!-- Critical JavaScript bundle -->
<script src="app.js" fetchpriority="high"></script>
这无需修改应用程序逻辑即可提升核心网页指标。它只是建议而非强制要求,若将过多资源标记为高级别,则会抵消其优势。同时切勿对LCP图片使用懒加载。
6. 使用datalist的原生方案
许多项目会添加复杂的自动补全组件,仅用于在允许自由输入的同时提供选项建议。datalist可直接实现这一功能:通过输入框的list属性关联它,其选项就会成为建议内容。
<label for="framework">Choose or type a framework:</label>
<input list="frameworks" id="framework" name="framework" placeholder="e.g. Next.js">
<datalist id="frameworks">
<option value="Next.js"></option>
<option value="SvelteKit"></option>
<option value="Nuxt.js"></option>
<option value="Astro"></option>
<option value="Remix"></option>
</datalist>
浏览器负责渲染和过滤列表,处理键盘导航,并遵循平台的视觉风格。相应的代价是控制力的缺失:样式极为有限,不同浏览器及移动设备上的行为存在差异,且无法实现丰富的选项功能。它非常适合简单的“选择或输入”类型字段;而需要品牌标识的下拉框仍需借助专用组件。
原生功能与旧有方法的对比
- 工具提示与菜单:使用
popover而非定位库,同时处理点击页面外部和按ESC键的情况(定位功能仍由CSS提供)。 - 折叠面板:使用具有相同
name属性的details元素,无需在JavaScript中实现状态管理与切换逻辑。 - 模态框:使用
dialog结合showModal()及::backdrop,而非自定义覆盖层并手动管理焦点。 - 封装的服务器渲染组件:使用
template shadowrootmode,而非仅在客户端调用的attachShadow()方法。 - 加载优先级:使用
fetchpriority,而非手动预加载技巧或通过脚本控制加载顺序。
datalist 而非自定义自动补全组件。如需了解更多可替代依赖项的平台功能,可参阅我们关于原生浏览器 API 替代热门 npm 包的综述。
核心要点
- 现在的浏览器已内置了过去需要通过库才能实现的功能,通常还具备焦点处理和按 Escape 键关闭的功能。
- “原生”并不意味着功能完备:弹出框需要 CSS 来定位,
datalist的样式选项有限,而声明式阴影 DOM 仅在解析时有效。 - 在添加 UI 依赖项之前,先确认是否有某个元素或属性能满足你所支持的浏览器的实际需求。