不写一行 JS:popover + @starting-style 重写筛选面板,顺手治好了开合闪烁

2026-10-29 0 446

上个月接了个后台管理系统的小活,先按惯例做体感测试。其他都还行,就是这个筛选面板,点开的时候会”闪”一下——不是那种慢慢淡入的打开,是一瞬间跳出来,然后才看到内容往下挪了半像素又回来。

一开始以为是浏览器重绘,打开 DevTools 的 Paint flashing 盯了半天,没毛用。后来去翻代码才知道,这套面板是这么写的:

// 简化后的原逻辑
btn.addEventListener('click', () => {
  panel.classList.toggle('is-open')
  setTimeout(() => {
    panel.setAttribute('aria-hidden', panel.classList.contains('is-open') ? 'false' : 'true')
  }, 200)
})

然后 CSS 那边靠 .is-open 来控制 display 和 opacity,还配了个 transition: all .2s。问题就出在 display 是不参与过渡的——从 none 切到 block 是瞬时生效的,所以元素先以最终状态渲染一帧,然后 opacity 的过渡才开始。那一帧就是眼睛看到的”闪”。

以前碰到这种问题的标准解法是:先加 class 触发 display: block,强制 requestAnimationFrame 读一次布局,再改 opacity。或者干脆放弃 display,全程用 visibility 配合。前者丑,后者又会让面板一直占着 DOM 位置、有机会吃到点击。

现在不用绕了。这条路上有三个比较新的东西凑齐了:popover 属性、@starting-style、transition-behavior: allow-discrete。我拿这个筛选项当试验田,改完之后 JS 那一坨基本删干净了,开合也顺了。

下面按我实际改的顺序写,包括中间写错的地方。

第一步:把”打开”这件事交给 popover

popover 是 HTML 上的一个全局属性,加上它就自动拥有了打开/关闭的状态,还顺带进了浏览器的最顶层(top layer),不用再操心 z-index 叠层的破事。触发方式有两种,我用了最省事的 popovertarget:

<button type="button" popovertarget="filter-panel">筛选</button>

<div id="filter-panel" popover>
  <label><input type="checkbox"> 只看待发货</label>
  <label><input type="checkbox"> 只看已退款</label>
</div>

就这几行,点击按钮面板能开了,点面板外面空白处会自动关,按 Esc 也会关。原来的 JS 里有一大半是在处理”点外面关掉”这个逻辑,现在全删了。

但这时候它是没有动画的,而且默认样式挺难看——它默认是 position: fixed 加上 inset: 0 加 margin: auto 居中显示,还带一圈黑边。所以下一步得先把位置改掉。

我在按钮外面套了个 position: relative 的容器,面板改成绝对定位贴着按钮:

.filter-wrap {
  position: relative;
}

#filter-panel {
  position: absolute;
  inset: auto;
  top: calc(100% + 8px);
  left: 0;
  width: 240px;
  margin: 0;
  padding: 12px;
  border: 1px solid #dcdfe6;
  border-radius: 6px;
  background: #fff;
  box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
}

注意 inset: auto 这句别漏,不然你会看到面板飞到屏幕中间去,还很自信地以为是自己 top/left 写错了。

第二步:让 display 参与过渡

这里是整篇文章最核心的一行。popover 在关闭状态下 UA 样式给的是 display: none,打开时切换到 display: block(准确说取决于具体值)。想让它平滑过渡,就必须显式地告诉浏览器:”display 这个属性的变化,请按离散的方式在过渡结束时再切换”:

#filter-panel {
  opacity: 0;
  transform: translateY(-6px);

  transition:
    opacity 180ms ease,
    transform 180ms ease,
    display 180ms allow-discrete,
    overlay 180ms allow-discrete;
}

#filter-panel:popover-open {
  opacity: 1;
  transform: translateY(0);
}

allow-discrete 是 transition-behavior 的取值,也可以单独写 transition-behavior: allow-discrete。它的含义是:本来离散的属性(display、overlay、content-visibility)也允许参与过渡,规则是在过渡的 0% 处用起始值、100% 处用结束值——打开时从头到尾都保持 block,关闭时反过来保持到最后一帧才变 none。

少了这句,关闭动画直接看不到:元素在第一帧就 display: none 了,opacity 的过渡没机会跑。

overlay 那句也是必须的,但原因不同,下一节单独说。

第三步:@starting-style 解决”从哪来”

上面写完,打开时能淡入了,但方向不对——它是从”当前位置的最终状态”开始的,也就是说 opacity: 0 这个起点根本没生效,元素直接以 opacity: 1 出现然后闪一下。

原因在于:元素从 display: none 变可见的那一帧,浏览器不认为它有”之前的样式”,也就没有起点可供过渡。@starting-style 就是专门用来给这种”首次渲染”提供起始值的:

@starting-style {
  #filter-panel:popover-open {
    opacity: 0;
    transform: translateY(-6px);
  }
}

或者用嵌套写法,新版 Chrome 里也认:

#filter-panel:popover-open {
  opacity: 1;
  transform: translateY(0);

  @starting-style {
    opacity: 0;
    transform: translateY(-6px);
  }
}

两种写法效果一样,我用了外面那层,因为 @starting-style 块里还能一起塞别的元素的选择器,集中放好找。

这里有个容易犯的错:起始值必须和最终值”类型一致”,比如 transform 的起始值不能是 none 然后指望过渡到 translateY(0)。none 到具体 transform 函数之间的插值是没法算的,浏览器会直接把整段过渡作废。我第一版就是这么写的,查了十几分钟。写成 translateY(-6px) 就没问题了。

第四步:为什么关闭动画要 overlay

元素打开时在 top layer 里,关闭时会被踢出 top layer。这个”踢出去”的动作默认发生在关闭的瞬间,而不是动画结束之后——所以你会看到面板在动画跑到一半时突然消失。

overlay 这个属性就是控制这件事的。它有两个值 auto 和 none,popover 打开时是 auto(在 top layer),关闭时变 none(不在)。把它一起列入 transition 并且允许离散,就能让它跟 display 一样,在动画最后一帧才切。

所以那句 overlay 180ms allow-discrete 不是可有可无的装饰。漏了它,效果是:打开正常,关闭时面板”啪”地消失,动画像是被吃了。

第五步:让 :has() 接管状态展示

面板里有几个复选框,原来的 JS 要监听 change 事件,在按钮上加个”已选 2 项”的角标,再给按钮加个高亮 class。这部分改完发现,纯 CSS 也能做到,因为 :has() 能从子元素反向影响父级。

<div class="filter-wrap">
  <button type="button" popovertarget="filter-panel">筛选</button>
  <div id="filter-panel" popover>
    <input type="checkbox" id="f1"><label for="f1">只看待发货</label>
    <input type="checkbox" id="f2"><label for="f2">只看已退款</label>
  </div>
</div>
/* 只要有任意一个勾选,外层按钮就高亮 */
.filter-wrap:has(input:checked) > button {
  border-color: #409eff;
  color: #409eff;
}

/* 面板打开时,按钮保持按下态 */
.filter-wrap:has(:popover-open) > button {
  background: #ecf5ff;
}

:popover-open 这个伪类专门匹配处于打开状态的 popover,和 :has() 一配合,就能在父级上做文章了。这两条规则把原来监听 change 事件和监听面板开合的那两段 JS 全替掉了。

踩到的几个坑

坑一:transition: all 会坑你。 我一开始图省事写了 transition: all 180ms allow-discrete,结果面板里输入框的边框、文字颜色全在这个过渡里跟着动,勾选的时候整个面板有种黏糊糊的感觉。显式列出要过渡的属性,永远是对的。

坑二:@starting-style 只对”首次渲染”生效。 我一开始想用它做面板内容切换时的过渡,失败——它不是在每次状态变化时都触发,只在元素第一次显示时。想做出列表重新排列的动画,那是另一个话题。

坑三:优先级问题。 UA 样式里 [popover]:popover-open 是有一定权重的,如果你的 #filter-panel 选择器权重不够(比如你用的是类名 .panel),某些属性会被 UA 样式盖住。我遇到的是 margin 被覆盖导致居中偏移,加了个 ID 就好了。不过这属于偶发,遇到时打开 DevTools 的 Computed 面板看来源最直接。

坑四:Safari 的版本问题。 项目里要照顾一批 macOS 上没更新的用户。我查了下 caniuse,allow-discrete 在 Safari 17.4 之后才好,@starting-style 是 17.5 起。所以在 Safari 17.4 这套环境下,会出现”能打开但没动画”的降级效果,不会白屏也不影响功能,我认了。但如果你们业务要求老版本也有动画,得留个 @supports 兜底。

@supports not (transition-behavior: allow-discrete) {
  /* 老浏览器:不做动画,保证功能可用 */
  #filter-panel {
    transition: none;
  }
}

这里有个小细节:@supports not 里我只写了关闭过渡,没有改 display 逻辑。因为在不支持 allow-discrete 的环境下,display 本来就不参与过渡,写了也没用,反倒可能让面板在某些时刻保持不可见。保守一点,直接不玩动画最稳。

坑五::has() 和 :popover-open 组合时的小意外。 我写了 .filter-wrap:has(:popover-open),本意是匹配面板打开,结果页面里另一个不相干的 popover 打开时,筛选按钮也高亮了。因为 :has() 的匹配范围是整个后代树,只要有任意一个后代满足就成立。改成 .filter-wrap:has(#filter-panel:popover-open) 就精确了。

改完之后的样子

整块筛选面板的 JS 从原来的一百多行(开合控制、外面点击关闭、checkbox 变化、角标更新)降到了 0 行。功能上还多了几样原来没有的:Esc 关闭、点空白关闭、打开时自动焦点管理、无障碍属性全部由浏览器负责,屏幕阅读器读出来的东西比以前对。

HTML 里那些 aria-expanded、aria-hidden 我也顺手删了。popover 的语义本身已经说明了它是弹出层,再手动加反而容易打架——比如原来那个 setTimeout 里更新 aria-hidden 的写法,在关闭动画期间它的值是错的,辅助技术会读到矛盾的状态。

顺手把这个模式推广到了系统里另外三个地方:表格的列设置、头像的下拉菜单、以及批量操作的确认浮层。基本就是复制粘贴改个 ID,每个都省了七八十行 JS。

几个使用上的建议

这套东西现在没必要再用 JS 去 classList.toggle 了。如果确实需要在代码里开关,用 element.showPopover() / hidePopover() / togglePopover(),别去操作 class,两条状态线混着用迟早出问题。

定位方面,如果面板和按钮的对应关系比较复杂(比如按钮靠近视口右边、面板需要自动往左翻),可以看看 anchor positioning。不过那套的语法在过去一年里改过几版命名,各浏览器支持进度也不一致,我这次故意没碰,用简单的相对定位就够用了。等 position-anchor 和 position-area 稳定了再上不迟。

最后说一句,这类”新特性替代 JS”的改造,收益最大的往往不是代码行数,而是那些你原来根本没想到要处理的情况——比如现在按住 Esc 能关、点外面能关、焦点跑出去了会自动收回来。这些细节靠手写 JS 补齐的成本,比多写几行 CSS 高太多了。

不写一行 JS:popover + @starting-style 重写筛选面板,顺手治好了开合闪烁
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 css 不写一行 JS:popover + @starting-style 重写筛选面板,顺手治好了开合闪烁 https://www.taomawang.com/web/css/2982.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务