用 Popover API 和 CSS 锚点定位替代工具提示库
为何需要 Popper 和浮动 UI 来实现工具提示功能,而现在的原生特性已能解决堆叠、定位及隐藏问题,以及在何种情况下仍需使用 JavaScript 库。
在按钮旁显示一行文本看似微不足道,但正因为这事儿并不简单,才有了 Popper.js、Floating UI 以及众多相关封装包。实际上,工具提示涉及三个相互叠加的独立问题,而直到最近,各大平台都未能为其中任何一个问题提供有效的解决方案。本指南将逐一分析这些问题,展示每个问题在最终生成的 JavaScript 代码中占用了多少资源,并将其与目前能够解决这些常见问题的两项浏览器功能相对应:Popover API 和 CSS 锚点定位。读完之后,你就能知道工具提示库中的哪些部分可以删除,哪些部分仍然必不可少。
隐藏在单个工具提示中的三个问题
这些都是在新规范出现之前,浏览器行为所存在的长期且广为人知的局限性:
- 层叠顺序。工具提示会显示在所有内容之上,还是会被某个父元素的
overflow: hidden属性截断? - 定位。工具提示能否知道触发元素在屏幕上的位置,并在滚动或调整窗口大小时保持该位置不变?
- 关闭方式。当用户点击其他地方或按下Esc键时,工具提示是否会自动关闭?
在网页发展的大部分时间里,凡是需要工具提示、下拉菜单或自动补全列表的项目,都是通过JavaScript来解决这三个问题的。每种情况的解决方案各不相同,而将它们视为同一个问题,正是导致一个小小的UI细节演变成一个依赖项的原因。因此,逐一分析它们是很有必要的。
层叠顺序:为何z-index无法脱离其上下文
每个元素都属于一个堆叠上下文,该上下文决定了哪些内容会绘制在哪些内容的上面。z-index用于确定同一堆叠上下文内的元素顺序,但它无法将某个元素从其所属的上下文中移出。
如果将工具提示放在具有overflow: hidden属性的容器中,或放在会创建自身堆叠上下文的模态框内,那么无论设置多大的值,甚至999999,它都无法显示在该边界之上。工具提示受其父元素的渲染规则限制,z-index的作用范围也仅限于这些父元素之内。
传统的备用出口是一种门户:将工具提示的标记放在文档中的其他位置,通常附加在<body>之后,这样它就不会继承父元素的裁剪和堆叠规则。React的createPortal功能很大程度上就是为了解决这个问题而存在的。它与其说是一种React特性,不如说是一种针对CSS无法实现的场景的变通方法。
定位:绝对定位仅能参考祖先元素
position: absolute会相对于最近的已定位祖先元素来放置某个元素,也就是说,是指DOM树中position属性为relative、absolute、fixed或sticky的最近元素。关键在于“祖先元素”:参考点必须位于DOM树的同一分支中,且在工具提示的上方。
一旦工具提示与其触发元素成为兄弟节点,或者为了解决层叠问题而将工具提示移至 <body> 中,触发元素就不再是其祖先节点。CSS 无法实现“将此元素定位到那边那个无关元素的位置上”这样的操作。问题并不在于 CSS 定位机制本身,而在于缺乏能够忽略文档结构的定位关系。
各种库通过测量来填补这一空白。它们调用 getBoundingClientRect() 获取触发元素的视口相对坐标框,进而计算出工具提示的坐标,并且在每次滚动或窗口大小改变时都会重新进行计算,因为这些数值会不断变化。这种持续运行的测量与定位循环正是定位库在运行时所执行的主要功能。
隐藏行为:标记语言无法描述的功能
在 Popover API 出现之前,HTML 和 CSS 并不支持“用户点击外部或按 Esc 键时关闭”的功能。这类操作完全依赖脚本实现:在 document 上添加点击监听器来判断事件目标是否位于工具提示外部,再添加 keydown 监听器等待 Esc 键的输入,同时在组件卸载时清理这些监听器以避免内存泄漏。与前两个问题不同,这个问题与几何定位无关,纯粹是行为逻辑问题,但依然属于浏览器未提供的第三类运行时代码。
工具提示与模态框
在研究语法之前,先明确术语定义很有帮助。弹出框是指任何显示在页面其他内容上方的元素,其位置相对于触发器而定,并且会在外部点击或按 Escape 键时自动关闭。工具提示、下拉菜单、自动完成列表以及上下文菜单都属于弹出框,只是样式不同,但都存在同样的三个问题。
模态框也存在层叠问题,但它不属于弹出框,因为它会遮挡其他内容。当模态框打开时,其背后的内容无法被操作:用户既不能使用 Tab 键切换到该内容,也不能点击或滚动它,而且通常会有背景幕布,在模态框关闭之前焦点都会保持在其中。就像“确认删除”提示那样:在得到回应之前,页面上的其他内容都无法使用。而弹出框则不会遮挡任何内容,页面始终保持完全可交互状态,用户离开后弹出框就会自动关闭。
相关规范直接对这种区别进行了规定:
popover="auto"会自动关闭且不会阻塞操作:在外部点击或按 Escape 键时即关闭,同时不会占用焦点。工具提示和下拉菜单属于此类。popover="manual"会一直保持打开状态,直到脚本手动关闭它,没有自动关闭功能,适合用于持续显示的通知提示。- 通过
.showModal()打开的<dialog>属于阻塞型:它会显示在最上层,带有背景幕布,占据焦点,并使其背后的所有内容无法交互。 - 而通过
.show()打开的<dialog>则属于非阻塞型,行为与弹出提示非常相似。
JavaScript 方法的代价
以下为从 npm 获取的压缩后大小,仅包含库文件本身:
popper.js (v1, now deprecated) 7.1 KB
Tippy.js (bundles @popperjs/core) 14.1 KB
Floating UI, vanilla (@floating-ui/dom) 8.1 KB
Floating UI, React bindings 30.1 KB
react-tooltip (@floating-ui/dom + clsx) 14.1 KB
这些数字并未包含任何额外添加的内容:配置、封装组件、箭头图标及主题 CSS。这是在你自行编写逻辑之前的基础成本,而如果一个项目同时使用了工具提示插件和独立的下拉菜单插件,就需要支付双倍的成本。
这并非反映出工程水平低下。尤其是浮动 UI 的设计十分精巧,其核心功能就是在各种浏览器的不同特性下实现正确的溢出显示效果。之所以会产生额外开销,是因为平台没有提供其他解决方案,不得不在 JavaScript 中同时解决三个相互关联的问题。
Popover API 负责层级管理及关闭操作
有两个独立的规范取代了原有的库,但它们的工作划分并不如人们预期的那样。popover 属性几乎无需任何脚本即可同时处理层级管理和关闭操作。
<button popovertarget="my-tooltip">Hover me</button>
<div id="my-tooltip" popover="auto">
This is the tooltip content.
</div>
popovertarget属性将按钮与具有对应id的元素关联起来。当使用popover="auto"时,弹出元素会在打开时被置于浏览器的最上层,这正是摆脱之前提到的overflow: hidden限制及堆叠上下文的方式,而且还能无需额外代码即可通过点击外部区域或按Esc键来关闭弹出框。
关于标记语的表述需要一处更正:popovertarget是在被激活时才会启用,也就是在点击、触摸或按下键盘键时,而非悬停时。真正的悬停工具提示仍需要少量脚本代码,在指针移动和焦点变化事件中调用showPopover()和hidePopover()函数,或者等到目标浏览器支持更新的声明式机制后再使用该功能。即便如此,元素的堆叠与隐藏操作也不再需要依赖任何库。目前仅剩定位问题,而这属于另一项规范范畴。
锚点定位:通过名称连接元素
CSS锚点定位解决了Popover API未处理的一个问题。它允许文档中的任意两个元素通过名称相互引用,而无需依赖父子结构关系。
.trigger {
anchor-name: --my-anchor;
}
.tooltip {
position: absolute;
position-anchor: --my-anchor;
top: anchor(--my-anchor bottom);
left: anchor(--my-anchor left);
}
anchor-name会用一种虚线形式的标识符来注册触发器,这种语法与自定义属性所使用的相同。position-anchor用于指定该标识符,而anchor()函数则可以读取锚点的特定边缘位置(top、right、bottom、left或center),从而使工具提示能够与之对齐。
关键在于,这两个元素无需相互包含。浏览器现在可以原生完成原本需要通过getBoundingClientRect()在每次滚动时手动计算的工作。当将此功能与弹出菜单结合使用时,请注意用户代理样式表会为[popover]元素设置inset: 0和margin: auto以实现居中;如果工具提示忽略了您设定的锚点偏移量,通常重置这些属性即可解决问题。
未设置滚动监听时的溢出处理
定位库中包含大部分逻辑的部分就是溢出处理:检测到工具提示即将超出视口范围时,首先选择其他放置位置。position-try-fallbacks功能可解决这一问题。
.tooltip {
position: absolute;
position-anchor: --my-anchor;
position-area: top center;
position-try-fallbacks: flip-block, flip-inline;
}
此处position-area: top center设置了默认的放置位置,而position-try-fallbacks则列出了当该位置会使其所在容器或视口出现溢出时,浏览器会按顺序尝试的替代方案。flip-block功能会将工具提示在块轴方向上对称翻转,使顶部变为底部;flip-inline功能则会在行内轴方向上对称翻转,使左侧变为右侧。在没有滚动处理程序且主线程脚本无法检测到溢出的情况下,浏览器仍会在布局过程中重新评估这些选项。
当简单的镜像定位不够用时,@position-try at规则允许你定义带名称的备用定位方案,每个方案都是一组小的定位声明,浏览器可以依次尝试这些方案。
@position-try --below {
position-area: bottom center;
margin-top: 8px;
}
@position-try --above {
position-area: top center;
margin-bottom: 8px;
}
.tooltip {
position-anchor: --my-anchor;
position-try-fallbacks: --above, --below;
}
这正是Floating UI在每次滚动事件中通过JavaScript所做的决策,这些决策会提前作为数据被布局引擎处理。需要注意的是,此代码片段中的.tooltip规则假设元素已经处于绝对定位或固定定位状态,就像前面的示例一样;锚点定位对静态定位的元素没有任何影响。
为何仍需要定位库
对于工具提示、简单的下拉菜单或自动补全列表,使用原生定位方式现已是合理的默认选择。虽然定位库的作用有所减弱,但并未完全消失。
浏览器支持是首要的限制因素。Chromium系列浏览器从125版本起就支持锚点定位功能,而@position-try的基准实现时间则晚于anchor()本身。Safari和Firefox随后才加入对该功能的支持,网络上流传的兼容性数据各不相同,因此请参考最新的兼容性表格,而不要依赖某一份特定的版本列表。在某些浏览器不支持该功能时,无法仅通过CSS实现平滑的降级处理:无法理解anchor()的浏览器将直接无法定位该元素。如果您必须支持旧版本的Safari或基于较旧引擎的移动浏览器,就需要准备备用方案;我们的关于安全部署现代CSS的指南介绍了针对锚点功能的特性检测与渐进增强方法。
第二种情况是那些超越简单镜像处理的布局规则:包含虚拟化列表的浮动面板、需要同时检测与多个边界的碰撞情况,或是由应用数据而非布局决定元素位置。脚本可以响应应用的任何状态,而固定的CSS备用方案仅了解布局信息。
对于涵盖大多数项目的普通情况,为原本由浏览器自行完成的工作额外编写8到30 KB的JavaScript代码实在难以找到合理依据。
关键要点
- 工具提示涉及三个问题:堆叠、定位以及关闭机制。之所以需要相关库,是因为这三个问题都必须通过脚本来解决。
popover="manual"以及带有.showModal()的则可处理持续显示和阻塞操作的场景。position-try-fallbacks与@position-try则取代了以往库运行时所依赖的溢出时翻转逻辑。