很多前端 bug 都发生在“浮层差一点点”的地方。
比如:
- 下拉框明明打开了,但偏了几个像素;
- 页面一滚动,浮层还停在原地;
- 目标按钮消失了,popover 却悬空;
- 动画进行中重新测量,箭头乱跳;
- 点击 iframe 或外部区域,层不关或者误关。
这些问题单看都像小问题,但只要系统里弹层足够多,就会不断吞掉开发时间。
Odoo 的思路不是让每个组件自己摆浮层,而是把这件事抽成通用基础设施:
PopoverusePositionAutoComplete对定位钩子的复用
这套代码真正解决的不是“浮层放哪”,而是:
当目标元素、滚动状态、激活层级和动画节奏不断变化时,浮层怎样持续跟住它真正附着的对象。
一、Popover 首先把“目标是谁”定义清楚
Popover 的 props.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,而是处理:
pointerdownblurpopstate- iframe 获得焦点时的特殊情况
为什么要这么细?
因为真正的“点击外部”并不总是一个简单 click 事件:
- 用户可能点进 iframe;
- 页面可能发生 history 导航;
- 焦点切换可能先于 click 可见。
如果不把这些路径纳入考虑,常见问题就是:
- 用户明明离开了,浮层还在;
- 或者用户只是内部交互,浮层却被误判关闭。
Odoo 的 isInside() 还会额外把 overlay 容器考虑进去,这又解决了“浮层套浮层”时的误关问题。
四、动画期间为什么要 position.lock()
Popover 在首次打开动画时会:
- 先
position.lock(); - 动画结束后再
unlock()。
这一步特别工程化。
因为浮层在开启动画中通常会出现:
- 元素尺寸逐步变化;
- 透明度与 transform 同时变化;
ResizeObserver可能不断触发。
如果这时还持续重算位置,结果往往是:
- 浮层边动边跳;
- 箭头位置抖动;
- 浏览器反复触发布局测量。
锁位的含义不是“不要更新”,而是:
先把开场动画稳定跑完,再回到正常跟随模式。
这对体验和性能都很关键。
五、ResizeObserver 与目标 MutationObserver 是两条不同的生命线
源码里既观察 popover 自身尺寸,也观察目标元素父级的 DOM 变化。
这意味着 Odoo 同时在盯两类风险:
- 浮层内容变了 —— 例如内容更长、尺寸变化,需要重新定位;
- 目标节点结构变了 —— 例如原按钮被移除、列表重绘,浮层应该关闭或更新。
很多系统只处理第一类,忽略第二类,结果就是“目标都没了,浮层还在空中”。
Odoo 则把“目标是否还存在于活的 DOM 里”当成一等公民。
六、AutoComplete 说明 usePosition 不只是给弹窗用的
autocomplete.js 里,下拉菜单同样使用 usePosition("sourcesList", () => this.targetDropdown, this.dropdownOptions)。
默认位置是:
bottom-start
但更重要的是,AutoComplete 把定位和下面这些行为绑定在了一起:
- 异步加载 options;
state.open开关;- 滚动与外部 pointerdown 关闭;
- 热键导航高亮;
- 根据活动项自动滚动列表。
这意味着在 Odoo 里,下拉菜单不是“输入框下方临时塞个 ul”,而是一个有完整生命周期的浮层组件。
七、定位问题其实和可访问性、键盘交互强相关
AutoComplete 里会维护:
activeSourceOptionactiveSourceOptionId- 输入框 autofocus
- 热键服务联动
这提醒我们一个常被忽略的事实:
浮层定位从来不是纯视觉问题,它和键盘焦点、活动项语义、滚动视口、关闭时机全部纠缠在一起。
如果只把它当 CSS 问题,系统很快就会出现“位置对了但体验很别扭”的怪现象。
八、二开时最值得学的不是某个 API,而是这套边界意识
很多团队写自定义 dropdown / popover 时,代码通常只有:
- 取
getBoundingClientRect(); - 算个 top/left;
- window scroll 时重算;
- document click 时关闭。
这个版本能跑,但离稳定很远。真正容易漏掉的是:
- 目标节点突然被替换怎么办;
- 动画期间要不要锁定位;
- iframe 点击是否算外部点击;
- overlay 嵌套时内部点击会不会误关闭;
- RTL、翻转、收缩宽度有没有统一策略。
Odoo 把这些边界都前置到了基础层,所以业务组件反而可以更专注内容本身。
九、总结:Odoo 浮层稳定的关键,不是算法炫,而是生命周期完整
看完 Popover 和 AutoComplete,很容易得出一个结论:
Odoo 在做的不是“高级定位算法秀”,而是一套完整浮层生命周期管理:
- 建立目标锚点;
- 在正确时机测量与翻转;
- 动画期间锁位;
- 监听尺寸和 DOM 变化;
- 正确处理点击外部、Escape、导航与跨文档焦点。
所以它的 popover、autocomplete、popover-like 菜单看起来才会“总能贴着目标走”。
真正让人省心的,不是某一次坐标算对了,而是在复杂 UI 持续变化时,它依然知道自己该跟着谁、什么时候该关、什么时候该重新对齐。
DISCUSSION
评论区