从 computePosition 到 autoUpdate:深入 Floating UI 悬浮组件实现
Tooltip、Select 下拉、Mention 联想、Popover……这些「贴着某个元素浮出来」的组件,看起来只是 position: absolute 加几个偏移。真正写过才知道:父级 overflow 会裁切、滚动要跟着跑、视口边缘要翻面、Mention 还得贴着光标而不是贴着输入框。
Floating UI 把这件事拆成两层:
- Positioning:弹层贴在哪、怎么躲边。
- Interactions:什么时候开、什么时候关;打开期间焦点、键盘、读屏怎么办。
本文只盯 Positioning。会顺着 computePosition 把坐标怎么算、overflow 怎么检测讲清楚,再用它实现一个 Select 和一个 Mention,最后拆开 autoUpdate 源码:滚动、resize、layout shift、逐帧比对分别是怎么触发重算的。
一、先搞清楚三个测量 API
定位的本质是「量出两个盒子的相对位置」。浏览器里常用三个 API,职责完全不同。
| 特性 | getBoundingClientRect() | IntersectionObserver | MutationObserver |
|---|---|---|---|
| 执行机制 | 同步,调用即返回 | 异步,回调通知 | 异步,DOM 变更后回调 |
| 性能 | 强制 layout,频繁调用会卡 | 对主线程友好 | 只关心结构变化 |
| 典型用途 | 某一刻的精确坐标、拖拽计算 | 懒加载、曝光、视口相交 | 监听节点及其子树的增删改 |
Floating UI 的定位算法本身用 getBoundingClientRect:需要的是某一帧里 reference 和 floating 的精确矩形。autoUpdate 则反过来——它要尽量避免轮询 getBoundingClientRect,所以 layout shift 那条路径用了 IntersectionObserver 做「箍框」检测。后文会专门讲这个技巧。
二、computePosition:一次定位的完整流水线
最小用法:
computePosition(button, tooltip, {
placement: 'top',
middleware: [
offset(6),
flip(),
shift({ padding: 5 }),
arrow({ element: arrowElement }),
],
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(tooltip.style, {
left: `${x}px`,
top: `${y}px`,
});
const { x: arrowX, y: arrowY } = middlewareData.arrow;
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[placement.split('-')[0]];
Object.assign(arrowElement.style, {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
right: '',
bottom: '',
[staticSide]: '-4px',
});
});
返回值里的 x / y 是 floating 元素左上角 相对定位原点的坐标。DOM 平台用 getBoundingClientRect 拿矩形;别的平台(canvas、RN)换一套测量实现,算法本身不变。@floating-ui/dom 的 computePosition 本质上就是给 core 注入 DOM 的 platform。
四个主方向对应四套左上角公式。以 top 为例:floating 的底边贴 reference 的顶边,水平方向按 start / end 再做对齐偏移。
中间件按数组顺序跑,每一步都可以改 x / y、改 placement、往 middlewareData 里塞数据:
offset(6):沿主轴推开 6px,箭头才有空间。flip():当前方向溢出就换对面。shift({ padding: 5 }):换完还溢出,就沿交叉轴平移,保证至少留 5px 内边距。arrow({ element }):算出箭头相对 tooltip 自身 的x / y,不是相对 reference。上面那段staticSide就是把箭头钉在 tooltip 朝向 reference 的那条边上。
detectOverflow:clip 与四个方向的溢出
flip / shift 的判断依据都来自 detectOverflow:拿 floating 当前矩形去和 clip 比,四个方向各给出一个溢出量。正数表示已经越界。
所谓 clip,不是单纯的视口,而是「所有裁切边界的交集」:从 floating 往上走,路上所有 overflow: hidden / auto / scroll / clip 的祖先,再加上视口,取它们可见区域的最小交集。弹层只要有一边超出这块交集,就算 overflow。
flip 看到主轴溢出就翻面;shift 看到交叉轴溢出就平移。两者经常一起用:先翻,翻完还贴边再推。
三、用 computePosition 实现 Select
定位只解决「贴在哪」。一个能用的 Select 还要处理受控值、开关、以及点外面关闭。
核心 props:value、open、options、onChange、className。
设计要点:
- options 是否渲染由
open控制。关掉就卸载,避免隐藏节点继续参与布局和焦点。 - 下拉面板通过
computePosition贴到 trigger 的 ref 上。placement一般用'bottom-start',再挂offset/flip/shift。 value走受控 / 非受控两套。外部传了value就听外部;没传就用内部useState,和<input>的 defaultValue 同一套思路。- 点击 options 外关闭。监听
document的mousedown,点到根节点外面就setOpen(false)。
useEffect(() => {
if (!open) {
return;
}
const onDocMouseDown = (event: MouseEvent) => {
const target = event.target as Node;
if (rootRef.current?.contains(target)) {
return;
}
setOpen(false);
};
document.addEventListener('mousedown', onDocMouseDown);
return () => document.removeEventListener('mousedown', onDocMouseDown);
}, [open]);
用 mousedown 而不是 click,是为了赶在 blur / 焦点切换之前把面板关掉。open 为 false 时立刻解绑,避免全局监听常驻。
定位代码本身很薄:open 之后对 trigger 和 panel 跑一次 computePosition,把返回的 x / y 写到 panel 的 left / top。滚动、窗口缩放时要不要持续重算,是下一节 autoUpdate 的事。
四、Mention:reference 不再是 DOM 节点
Select 的 reference 是按钮,矩形现成。Mention 要贴的是 输入框里光标的位置,DOM 里没有这个节点。
触发逻辑先用字符串 API 搞定:
slice(0, caret)截出光标前的文本。lastIndexOf('@')找光标前最后一个@。@到光标之间如果没有空格,就认为正在输入 mention,弹出候选。
缺的是「这个 @ / 光标在屏幕上的坐标」。做法是造一个和 input 同字体的隐藏 span,把光标前的字符灌进去,用 span.offsetWidth 当水平偏移:
function getCaretRect(input: HTMLInputElement) {
const style = getComputedStyle(input);
const span = document.createElement('span');
span.textContent = input.value.slice(0, input.selectionStart ?? 0);
span.style.font = style.font;
span.style.letterSpacing = style.letterSpacing;
span.style.whiteSpace = 'pre';
span.style.position = 'fixed';
span.style.top = '-9999px';
document.body.append(span);
const box = input.getBoundingClientRect();
const x =
box.left +
parseFloat(style.paddingLeft) +
parseFloat(style.borderLeftWidth) +
span.offsetWidth -
input.scrollLeft;
const y =
box.top + parseFloat(style.paddingTop) + parseFloat(style.borderTopWidth);
const height = input.clientHeight;
span.remove();
return {
x,
y,
width: 0,
height,
top: y,
left: x,
right: x,
bottom: y + height,
};
}
几点值得单独说:
whiteSpace: 'pre'保留空格,否则测量宽度会比真实光标靠左。- 减掉
scrollLeft,输入内容超出宽度滚动后,光标坐标仍然正确。 - 返回值和
DOMRect同构:x / y / width / height / top / left / right / bottom。Floating UI 的 reference 不一定是 Element,传一个 virtual element 即可:
const virtualEl = {
getBoundingClientRect: () => getCaretRect(input),
};
computePosition(virtualEl, mentionList, {
placement: 'bottom-start',
middleware: [offset(4), flip(), shift({ padding: 8 })],
});
computePosition 只认「能给出矩形的东西」。按钮、光标、选区、鼠标指针,都可以当 reference。Mention 难的不是中间件,是把 caret 量成一个合法矩形。
单行 <input> 用 span 镜像够用。多行 <textarea> 还要处理换行高度,常见做法是再加一个 mirror 容器,或对 contenteditable 用 getClientRects() 拿原生 caret 矩形。思路一样:先有矩形,再交给 computePosition。
五、为什么 Select 要挂到 body,以及 autoUpdate 存在的理由
如果 floating 是 absolute,并且定位包含块就是 trigger 的父节点,滚动时两者一起动,算一次坐标就够了。
业务里的 Select 几乎不会这么做。下拉通常通过 getPopupContainer 挂到 body(或最近的滚动容器外),原因很具体:
- 选择框经常待在
overflow: hidden / auto的卡片、表格、Modal 里。 - 父级还有
transform、混乱的z-index。
菜单若仍是选择框的子节点,一超出父级盒子就被剪掉,或者压在别的层下面,只能看到半截。挂到 body 后,它和页面最外层一伙,完整浮在最上面。
代价是:reference 在局部滚动容器里走,floating 在 body 上不动。容器一滚,两者相对位置立刻过期,必须重算。这就是 autoUpdate 的场景。
autoUpdate 和定位算法无关。它只在「该重算了」时调用你传入的 update(通常就是再跑一遍 computePosition):
const cleanup = autoUpdate(button, tooltip, () => {
computePosition(button, tooltip, { /* ... */ }).then(({ x, y }) => {
Object.assign(tooltip.style, { left: `${x}px`, top: `${y}px` });
});
});
// 卸载时
cleanup();
六、autoUpdate 源码:四种「该重算了」
它监听四类变化:
- scroll
- resize
- layoutShift(页面发生位移,但既没滚也没 resize)
- 每帧比对(transform 动画位移)
前两件事直接挂监听:祖先滚动容器 + window 的 scroll,以及 ResizeObserver / window.resize。回调里调 update() 即可。真正值得看的是后两件。
6.1 layoutShift:用 IntersectionObserver 箍一个刚好包住按钮的框
原生 Performance 里的 Layout Shift 只是性能指标,并不能告诉你「这个按钮挪到哪了」。如果去轮询 getBoundingClientRect,又回到了强制 layout 的老路上。
Floating UI 的做法:用 IntersectionObserver 箍一个 刚好等于按钮当前矩形 的观察框。按钮还在框里 → 交比为 1;被旁边的元素挤出去 → 交比掉下去 → 当成「挪了」,触发 update。
观察框靠 rootMargin 四边取负来实现:
const rootMargin = [
rect.top, // top
root.offsetWidth - (rect.left + rect.width), // right
root.offsetHeight - (rect.top + rect.height), // bottom
rect.left, // left
].map(invertToPx).join(' ');
// 全部取负值,结果类似 "-100px -500px -200px -50px"
IntersectionObserver 默认相对视口。rootMargin 是在 root 四边向外扩(正值)或向内收(负值)。上面这组负 margin 刚好把观察区域收到和按钮当前 getBoundingClientRect 重合。
之后只要按钮相对视口的位置变了(flex 重排、上方插入节点、旁边展开面板……),交比就会离开 1。全程没有 rAF 轮询,也没有持续的 getBoundingClientRect。
观察到变化并完成一次 update 之后,按钮已经在新位置,旧的箍框失效,需要按新矩形重建 observer。所以这条路径是:相交变化 → 重算定位 → 按新 rect 换一副箍。
6.2 每帧比对:transform 动画这条漏网之鱼
scroll / resize / IntersectionObserver 都覆盖不到 CSS transform 动画:元素视觉上在动,布局矩形可能不变,observer 的交比也不变。这时只能打开 animationFrame 选项,用 rAF 每帧比一次矩形:
if (animationFrame) {
frameLoop();
}
function frameLoop() {
const nextRefRect = getBoundingClientRect(reference);
if (prevRefRect && !rectsAreEqual(prevRefRect, nextRefRect)) {
update();
}
prevRefRect = nextRefRect;
frameId = requestAnimationFrame(frameLoop);
}
这是四条路径里最贵的一条,默认不开。reference 真的在做 transform 位移(比如跟着拖拽把手、跟着运动的锚点)再打开。
七、把三条线拧在一起
回头看整条链路:
- 测量:某一刻的精确坐标用
getBoundingClientRect;持续跟踪位置变化尽量用IntersectionObserver,避免轮询。 - 计算:
computePosition算出 floating 左上角,中间件按序处理间距、翻面、平移、箭头。detectOverflow拿 clip(所有裁切祖先的交集)判断四边溢出。 - 更新:scroll / resize 直接听;layout shift 用「负 rootMargin 箍框」;transform 动画才走 rAF 逐帧比对。
自己写悬浮组件时,可以按这个顺序自检:矩形从哪来 → 溢出怎么躲 → 挂在哪一层 → 什么事件会让坐标过期。