前端

Odoo 下拉层为什么总能“贴着目标走”:Popover、usePosition 与 AutoComplete 定位链路讲透

前端悬浮层看似只是“放对位置”,真正难的是滚动、缩放、RTL、iframe、点击外部关闭和动画期间重新测量这些细节。Odoo 把这些通通收进 `Popover` 与 `usePosition` 钩子里,连 AutoComplete 都走同一套定位语义。

前端
进阶 开发者 1 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

很多前端 bug 都发生在“浮层差一点点”的地方。

比如:

  • 下拉框明明打开了,但偏了几个像素;
  • 页面一滚动,浮层还停在原地;
  • 目标按钮消失了,popover 却悬空;
  • 动画进行中重新测量,箭头乱跳;
  • 点击 iframe 或外部区域,层不关或者误关。

这些问题单看都像小问题,但只要系统里弹层足够多,就会不断吞掉开发时间。

Odoo 的思路不是让每个组件自己摆浮层,而是把这件事抽成通用基础设施:

  • Popover
  • usePosition
  • AutoComplete 对定位钩子的复用

这套代码真正解决的不是“浮层放哪”,而是:

当目标元素、滚动状态、激活层级和动画节奏不断变化时,浮层怎样持续跟住它真正附着的对象。

一、Popover 首先把“目标是谁”定义清楚

Popoverprops.target 验证做得非常严谨,甚至考虑到目标可能来自 iframe 文档,需要从 ownerDocument.defaultView.Element 去判断实例类型。

这个细节说明官方早就承认:

  • 目标不一定在当前 window;
  • 甚至 Element 构造器都可能不是同一个全局对象。

很多自研浮层系统在这里就已经埋雷了:只按当前 window 的 instanceof Element 判,跨文档直接失真。

Odoo 则从一开始就把“跨文档目标”纳入设计。

二、usePosition("ref", () => this.props.target, ...) 才是核心

Popover 自己不直接计算坐标,而是把定位交给 usePosition

这意味着定位在 Odoo 里不是某个组件的私有逻辑,而是统一语义:

  • 谁是浮层本体;
  • 谁是目标元素;
  • 希望在 top/bottom/left/right 哪个方向;
  • 是否允许翻转;
  • 是否收缩宽度;
  • 重新定位后要不要回调。

这样做最直接的收益是:

  • Popover、Popover-like 组件、AutoComplete 下拉可以共享心智模型;
  • 二开时不用每个地方重复实现滚动与边界处理;
  • 行为更一致,用户更不容易感知到“这层和那层像两套系统”。

三、点击外部关闭并不只是 document.click

useClickAway() 很有代表性。它不只监听 click,而是处理:

  • pointerdown
  • blur
  • popstate
  • iframe 获得焦点时的特殊情况

为什么要这么细?

因为真正的“点击外部”并不总是一个简单 click 事件:

  • 用户可能点进 iframe;
  • 页面可能发生 history 导航;
  • 焦点切换可能先于 click 可见。

如果不把这些路径纳入考虑,常见问题就是:

  • 用户明明离开了,浮层还在;
  • 或者用户只是内部交互,浮层却被误判关闭。

Odoo 的 isInside() 还会额外把 overlay 容器考虑进去,这又解决了“浮层套浮层”时的误关问题。

四、动画期间为什么要 position.lock()

Popover 在首次打开动画时会:

  • position.lock()
  • 动画结束后再 unlock()

这一步特别工程化。

因为浮层在开启动画中通常会出现:

  • 元素尺寸逐步变化;
  • 透明度与 transform 同时变化;
  • ResizeObserver 可能不断触发。

如果这时还持续重算位置,结果往往是:

  • 浮层边动边跳;
  • 箭头位置抖动;
  • 浏览器反复触发布局测量。

锁位的含义不是“不要更新”,而是:

先把开场动画稳定跑完,再回到正常跟随模式。

这对体验和性能都很关键。

五、ResizeObserver 与目标 MutationObserver 是两条不同的生命线

源码里既观察 popover 自身尺寸,也观察目标元素父级的 DOM 变化。

这意味着 Odoo 同时在盯两类风险:

  1. 浮层内容变了 —— 例如内容更长、尺寸变化,需要重新定位;
  2. 目标节点结构变了 —— 例如原按钮被移除、列表重绘,浮层应该关闭或更新。

很多系统只处理第一类,忽略第二类,结果就是“目标都没了,浮层还在空中”。

Odoo 则把“目标是否还存在于活的 DOM 里”当成一等公民。

六、AutoComplete 说明 usePosition 不只是给弹窗用的

autocomplete.js 里,下拉菜单同样使用 usePosition("sourcesList", () => this.targetDropdown, this.dropdownOptions)

默认位置是:

  • bottom-start

但更重要的是,AutoComplete 把定位和下面这些行为绑定在了一起:

  • 异步加载 options;
  • state.open 开关;
  • 滚动与外部 pointerdown 关闭;
  • 热键导航高亮;
  • 根据活动项自动滚动列表。

这意味着在 Odoo 里,下拉菜单不是“输入框下方临时塞个 ul”,而是一个有完整生命周期的浮层组件。

七、定位问题其实和可访问性、键盘交互强相关

AutoComplete 里会维护:

  • activeSourceOption
  • activeSourceOptionId
  • 输入框 autofocus
  • 热键服务联动

这提醒我们一个常被忽略的事实:

浮层定位从来不是纯视觉问题,它和键盘焦点、活动项语义、滚动视口、关闭时机全部纠缠在一起。

如果只把它当 CSS 问题,系统很快就会出现“位置对了但体验很别扭”的怪现象。

八、二开时最值得学的不是某个 API,而是这套边界意识

很多团队写自定义 dropdown / popover 时,代码通常只有:

  • getBoundingClientRect()
  • 算个 top/left;
  • window scroll 时重算;
  • document click 时关闭。

这个版本能跑,但离稳定很远。真正容易漏掉的是:

  • 目标节点突然被替换怎么办;
  • 动画期间要不要锁定位;
  • iframe 点击是否算外部点击;
  • overlay 嵌套时内部点击会不会误关闭;
  • RTL、翻转、收缩宽度有没有统一策略。

Odoo 把这些边界都前置到了基础层,所以业务组件反而可以更专注内容本身。

九、总结:Odoo 浮层稳定的关键,不是算法炫,而是生命周期完整

看完 PopoverAutoComplete,很容易得出一个结论:

Odoo 在做的不是“高级定位算法秀”,而是一套完整浮层生命周期管理:

  • 建立目标锚点;
  • 在正确时机测量与翻转;
  • 动画期间锁位;
  • 监听尺寸和 DOM 变化;
  • 正确处理点击外部、Escape、导航与跨文档焦点。

所以它的 popover、autocomplete、popover-like 菜单看起来才会“总能贴着目标走”。

真正让人省心的,不是某一次坐标算对了,而是在复杂 UI 持续变化时,它依然知道自己该跟着谁、什么时候该关、什么时候该重新对齐。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。