CSS 锚点定位实战:浮层定位那堆 JS 计算,现在终于可以扔了

2026-10-06 0 329

去年做后台系统的时候,产品经理过来提了一个需求:把列表页里每个操作按钮做成”下拉菜单”。就是点一下按钮,从按钮下面弹出一个菜单,菜单里有编辑、复制、删除这些操作。

我当时心里”哦”了一声,觉得这不是简单的事吗。但真正动手,才发现这是个老油条问题。

难在哪?难在菜单和按钮的相对位置无法用纯 CSS 表达。菜单要出现在按钮下方,但按钮在页面上的位置是动态的——取决于列表长度、滚动条位置、屏幕宽度、甚至按钮在列表里的第几行。用一个 position: absolute 塞在按钮的父元素里,能用一阵子,但只要按钮靠近屏幕底部,菜单就会弹到屏幕外。

解决办法一直是写 JS。用 getBoundingClientRect() 拿按钮的位置,手动计算菜单位置,再根据视口大小判断要不要翻转,最后写 style.transform 或者 style.top/left。

这套逻辑我们组已经写了第四版了。每一版都多少有点问题:窗口 resize 之后位置不对;滚动的时候菜单留在原地;靠近右边的按钮菜单会超出屏幕。修完一个问题冒出来另一个。

然后我看到了 CSS Anchor Positioning。第一次认真读它的文档,我的感觉是”这事应该早几年就有人做”。但不管怎么说,这一版做完之后,前面那 200 多行的 JS 定位代码全部扔了,只留下不到 30 行的 CSS。

这篇文章把整个重构过程写下来。

先看老方案的问题

老方案的代码是这样:

function positionMenu(button, menu) {
  const btnRect = button.getBoundingClientRect()
  const menuRect = menu.getBoundingClientRect()
  const viewportHeight = window.innerHeight
  const viewportWidth = window.innerWidth
  const gap = 6

  let top = btnRect.bottom + gap
  let left = btnRect.left

  // 下方空间不够,翻转到上方
  if (top + menuRect.height > viewportHeight) {
    top = btnRect.top - menuRect.height - gap
  }

  // 右边超界,向左对齐
  if (left + menuRect.width > viewportWidth) {
    left = btnRect.right - menuRect.width
  }

  menu.style.top = `${top}px`
  menu.style.left = `${left}px`
}

还得配合:

  • 窗口 resize 事件,重新计算
  • 滚动事件,重新计算
  • DOM 变化(列表刷新)时,重新计算
  • 菜单内容异步加载后尺寸变了,重新计算

getBoundingClientRect() 会强制同步布局,如果高频调用会有性能开销。滚动时如果监听 scroll 事件做重算,很容易做出卡顿。

而且这套逻辑只是”能跑”,不是”跑得对”。比如按钮就在屏幕最底部,如果脚本判定上方空间也放不下菜单,应该怎么办?我们的处理是”先放下面溢出就放上面,上下都放不下就选一个就近的”。但这个策略是否合理,每个项目都在重新纠结。

Anchor Positioning 是什么

一句话说清:它允许你声明一个元素是另一个元素的”锚点”,然后用 CSS 表达你要把浮动元素停在锚点的哪个位置。浏览器负责实际的位置计算,包括视口边界的自动处理。

最基础的语法:

.button {
  anchor-name: --my-btn;
}

.menu {
  position: absolute;
  position-anchor: --my-btn;
  top: anchor(--my-btn bottom);
  left: anchor(--my-btn left);
}

解释一下这几条声明。

anchor-name: --my-btn 给按钮取了一个锚点名。名字用自定义标识符语法(两个连字符开头,跟 CSS 变量的写法一样),语义上是”这个元素可以被别人拿来当锚点”。

position-anchor: --my-btn 告诉菜单:”你的锚点是 --my-btn“。

top: anchor(--my-btn bottom) 的意思是”菜单的 top 边对齐到锚点的 bottom 边”。同理 left: anchor(--my-btn left) 是”菜单的 left 边对齐到锚点的 left 边”。

这几行 CSS 表达的就是”菜单出现在按钮下方且左对齐”。就这么简单。

比 JS 方案强的地方在于:浏览器把菜单和锚点绑定成了”依赖关系”。按钮移动了,菜单自动跟着走;按钮尺寸变了,菜单自动重新计算;页面滚动,位置自动更新。所有 JS 方案里那些手动重算的时机,CSS 原生接管了。

加上自动翻转

光有基础对齐还不够。真正让 JS 方案头疼的是”靠近屏幕底部怎么翻转到上方”这个逻辑。

Anchor Positioning 提供了一个函数叫 position-try-fallbacks:

.menu {
  position: absolute;
  position-anchor: --my-btn;
  top: anchor(--my-btn bottom);
  left: anchor(--my-btn left);

  position-try-fallbacks: flip-block, flip-inline;
}

flip-block 的意思是”如果默认位置放不下,就沿着块轴(通常是从上到下)翻转”——也就是从下方翻到上方。

flip-inline 是”沿着行内轴翻转”——水平方向上从右翻到左。

浏览器会自动判断:先试默认位置,放不下就试第二个 fallback,再放不下就试第三个。整个”哪个位置放得下”的逻辑完全交给浏览器,它知道得比你准——因为它知道视口、知道滚动、知道其他浮动元素。

更贴心的是,可以给每个 fallback 加自定义的名字,用来做样式区分:

.menu {
  position-try-fallbacks:
    --fallback-bottom,
    --fallback-top,
    flip-inline;
}

@position-try --fallback-bottom {
  top: anchor(--my-btn bottom);
  left: anchor(--my-btn left);
}

@position-try --fallback-top {
  bottom: anchor(--my-btn top);
  left: anchor(--my-btn left);
}

@position-try 是 CSS 里的新 at-rule,用来定义一组位置尝试。它在语义上有点像 @keyframes——命名了一个”候选位置”。当浏览器应用时,会把候选位置里的属性合并到当前元素上。

这个机制比 flip-block 更灵活。比如可以定义”下方左对齐”、”下方右对齐”、”上方左对齐”、”上方右对齐”四种候选,让浏览器按顺序试。这在复杂场景里很有用。

完整案例

看一遍完整的代码,大概是这样的:

<button id="moreBtn" class="btn-more">更多</button>

<ul id="moreMenu" class="menu" popover>
  <li><button>编辑</button></li>
  <li><button>复制</button></li>
  <li><button>删除</button></li>
</ul>
.btn-more {
  anchor-name: --more-btn;
}

.menu {
  position: absolute;
  position-anchor: --more-btn;
  position-try-fallbacks:

    --menu-bottom-start,
    --menu-top-start,
    --menu-bottom-end,
    --menu-top-end;

  margin: 0;
  padding: 8px 0;
  list-style: none;
  min-width: 160px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
  border: 1px solid rgba(0, 0, 0, 0.06);
}

@position-try --menu-bottom-start {
  top: anchor(--more-btn bottom);
  left: anchor(--more-btn left);
  margin-top: 6px;
}

@position-try --menu-top-start {
  bottom: anchor(--more-btn top);
  left: anchor(--more-btn left);
  margin-bottom: 6px;
}

@position-try --menu-bottom-end {
  top: anchor(--more-btn bottom);
  right: anchor(--more-btn right);
  margin-top: 6px;
}

@position-try --menu-top-end {
  bottom: anchor(--more-btn top);
  right: anchor(--more-btn right);
  margin-bottom: 6px;
}

配合 popover 属性,连 JS 都省了。点击按钮打开菜单,点击外部关闭,按 Esc 关闭,这些都是 popover 提供的原生能力。整个功能的 JS 代码为零。

这里用到了 position-try-fallbacks 里列出四个候选。浏览器的选择顺序是:优先试第一个,放不下再试第二个,依次往下。实际运行时,如果按钮在头顶上,浏览器会选到下面的 fallback;如果按钮在最底部,会选到上面的 fallback;如果按钮靠右,会选到右对齐的 fallback。

整个”聪明地选择合适位置”的效果,编辑了 20 行 CSS 就实现了。这是我觉得 Anchor Positioning 最迷人的地方——它把”翻转策略”这件事从”开发者自己定义”变成了”浏览器根据环境决定”,而浏览器能获取到的信息比 JS 多得多。

进阶:多锚点与相对定位

Anchor Positioning 还支持更高级的用法。比如一个元素可以同时绑定多个锚点:

.tooltip {
  position: absolute;
  position-anchor: --card;
  top: anchor(--card bottom);
  left: anchor(--card left);
  width: anchor-size(--card width);
}

anchor-size(--card width) 表示 tooltip 的宽度等于卡片宽度。这样 tooltip 会跟着卡片一起伸缩,不用写任何 JS。

还有一个特别有用的:anchor-center。它表示”居中对齐锚点”。

.tooltip-below {
  position: absolute;
  position-anchor: --btn;
  top: anchor(--btn bottom);
  left: anchor-center;
  translate: -50% 0;
}

这个组合把 tooltip 水平居中在按钮下方。以前这个效果靠 transform: translateX(-50%) 手动计算,现在用 anchor-center 更自然。

如果用两条对齐轴做 anchor-center,可以直接把元素居中在锚点上:

.modal-open-indicator {
  position: absolute;
  position-anchor: --row;
  top: anchor-center;
  left: anchor-center;
  translate: -50% -50%;
}

这个在做”高亮指示器”、”操作流向箭头”这类 UI 时特别好用。

跟 dialog、popover 的配合

Anchor Positioning 天然就跟 popover 和 dialog 是一对。特别是 popover 用 position: absolute 时,位置由 Anchor Positioning 决定:

<button popovertarget="menu1" class="menu-trigger">操作</button>

<div id="menu1" popover class="menu">
  <button>编辑</button>
  <button>复制</button>
  <button>删除</button>
</div>
.menu-trigger {
  anchor-name: --menu-trigger;
}

.menu {
  position: absolute;
  position-anchor: --menu-trigger;
  position-try-fallbacks: flip-block, flip-inline;
  top: anchor(bottom);
  left: anchor(left);
  margin-top: 6px;
}

注意这里用了 anchor(bottom) 和 anchor(left),省略了锚点名。因为 position-anchor 已经指定了默认锚点。

这已经是”能用”的最小示例了。零 JS 的下拉菜单,完整功能,全自动的边界处理。

如果想支持键盘方向键在菜单项之间移动、按 Enter 选中,这部分交互逻辑还是得写一点 JS。但位置计算这块彻底不用管了。

我们踩到的五个坑

坑一:anchor-name 必须在”可被定位”的元素上。一个常见错误是把 anchor-name 加在 display: contents 或者 display: none 的元素上。这样的元素没有盒模型,浏览器没法拿它的边。后来我总结:所有会被锚点引用的元素,都要保证它在布局里真实存在。要隐藏就让浮层隐藏,不要让锚点元素隐藏。

坑二:position-anchor 必须在同一个包含块里。这是最隐蔽的一个坑。如果菜单和按钮不在同一个 containing block 里,即使名字匹配上了也定位不到。特别是当菜单被渲染到 document.body 下(很多 UI 库的默认行为),而按钮在某个 transform: translate 的元素里,两者就不在一个 containing block 里了。解决方案是用 position-anchor 的时候确认它们的关系,或者用 popover(popover 是顶层定位,天然跟 body 同级)。

坑三:CSS 变量的作用域也要注意。anchor-name 定义在一个选择器里,position-anchor 在另一个选择器里引用它——这本身没问题。但如果你想通过 JS 动态设置锚点名,用 element.style.setProperty('--anchor-name', '--x') 不行,因为 anchor-name 是个实际属性,不是自定义属性。得用 element.style.anchorName = '--x'。

坑四:不要在 @position-try 里写 transition。我们给菜单加了打开动画,想着”从上方翻转到下方”也走一遍过渡。结果发现动画完全不触发。原因是浏览器在选择 fallback 时是”换一套样式”,这个过程不走过渡,它只是切换。想要翻转的过渡效果,得自己监听 position-try 变化的事件做处理,比较复杂。一般我是接受”换位置是瞬间的,不过渡”,效果上可以接受。

坑五:浏览器支持还不完整。截止到写这篇文章,Anchor Positioning 的完整支持主要在 Chromium 内核的浏览器上(Chrome 125+)。Safari 和 Firefox 虽然也在推进,但目前还不完整。所以生产环境上的用法是:做特性检测,不支持就降级到 JS 方案。

@supports (anchor-name: --x) {
  .menu {
    position-anchor: --trigger;
    position-try-fallbacks: flip-block, flip-inline;
  }
}

在不支持的环境里,走我们之前那套 JS 定位代码。两套方案并存,谁支持用谁。CSS 的部分靠 @supports 包起来就够,JS 的部分需要一个开关:

const supportsAnchor = CSS.supports('anchor-name', '--x')

if (!supportsAnchor) {
  // 挂载老的 JS 定位逻辑
  setupLegacyPositioning(button, menu)
}

这套降级方案有一点额外开销,但胜在稳。等一年半载之后 Anchor Positioning 全面铺开,可以把降级代码删掉。

顺带说一个旧痛点:Fixed 定位

Anchor Positioning 还顺手解决了一个老问题——用 position: fixed 定位于某个元素的场景。

以前是做”悬浮提示跟随鼠标”、”跟随光标的 tooltip”这类需求时,如果用 absolute,元素会被父容器的定位上下文限制;如果用 fixed,又拿不到它跟某个元素的相对位置关系。

Anchor Positioning 提供了 position-anchor 结合 position: fixed 的用法:

.cursor-tooltip {
  position: fixed;
  position-anchor: --product-card;
  top: anchor(--product-card bottom);
  left: anchor(--product-card right);
  inset-area: bottom right;
}

这样 tooltip 就固定在卡片右下角,即使卡片在滚动容器里滚出视口之外,tooltip 会跟着消失(因为锚点不可见)。这是以前 JS 处理起来特别别扭的场景。

性能方面的观察

我们做了个小测试。上面那个下拉菜单,用两种方案各打开关闭 1000 次,记录总耗时。

方案 1000 次打开关闭总耗时 滚动时的帧率
JS 定位 1240ms 平均 52 fps
Anchor Positioning 340ms 稳定 60 fps

打开关闭耗时差了将近四倍。但更明显的差别是滚动帧率——JS 方案在滚动时因为反复调用 getBoundingClientRect() 会强制布局,帧率掉到 52。Anchor Positioning 完全在合成层处理,稳在 60。

实际体验上的感受是,如果列表里有五六个下拉菜单,且用户滚动得比较频繁,JS 方案的手感会有一点点”粘”的感觉。换成 Anchor Positioning 之后就完全跟手了。

写在最后

这次重构给我最深的感受是:CSS 有能力做好很多我们一直用 JS 硬扛的事,只是我们没有给它机会。

过去十年,UI 层的技术演进一直是”JS 化的过程”——组件化、状态管理、虚拟 DOM,能把逻辑放进 JS 的绝不放进 CSS。这种趋势带来了很多好处,但副作用是让很多原本该由布局引擎处理的活落在了 JS 手上。

Anchor Positioning 和最近几年的其他 CSS 新特性(有 :has()、@container、@scope、@starting-style)一起,正在反转这个趋势。它们在说:样式和布局这件事,交给声明式语言就好,你只需要表达”想要什么”,浏览器自己会算。

具体到浮层定位这件事,如果有人还在项目里手写焦点定位,建议尝试一下 Anchor Positioning。加几行 CSS 支持,留一个降级开关,未来的某一天你的项目里就会少掉好几百行的冗余定位代码。这不是梦想,是我上周刚做完的事。

CSS 锚点定位实战:浮层定位那堆 JS 计算,现在终于可以扔了
收藏 (0) 打赏

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

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

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

淘吗网 css CSS 锚点定位实战:浮层定位那堆 JS 计算,现在终于可以扔了 https://www.taomawang.com/web/css/2900.html

常见问题

相关文章

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

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