深入悬浮组件实现

从 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:一次定位的完整流水线

最小用法:

less 复制代码
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 / yfloating 元素左上角 相对定位原点的坐标。DOM 平台用 getBoundingClientRect 拿矩形;别的平台(canvas、RN)换一套测量实现,算法本身不变。@floating-ui/domcomputePosition 本质上就是给 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:valueopenoptionsonChangeclassName

设计要点:

  1. options 是否渲染由 open 控制。关掉就卸载,避免隐藏节点继续参与布局和焦点。
  2. 下拉面板通过 computePosition 贴到 trigger 的 ref 上placement 一般用 'bottom-start',再挂 offset / flip / shift
  3. value 走受控 / 非受控两套 。外部传了 value 就听外部;没传就用内部 useState,和 <input> 的 defaultValue 同一套思路。
  4. 点击 options 外关闭 。监听 documentmousedown,点到根节点外面就 setOpen(false)
javascript 复制代码
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 当水平偏移:

ini 复制代码
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 即可:
css 复制代码
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 容器,或对 contenteditablegetClientRects() 拿原生 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):

javascript 复制代码
const cleanup = autoUpdate(button, tooltip, () => {
  computePosition(button, tooltip, { /* ... */ }).then(({ x, y }) => {
    Object.assign(tooltip.style, { left: `${x}px`, top: `${y}px` });
  });
});

// 卸载时
cleanup();

六、autoUpdate 源码:四种「该重算了」

它监听四类变化:

  1. scroll
  2. resize
  3. layoutShift(页面发生位移,但既没滚也没 resize)
  4. 每帧比对(transform 动画位移)

前两件事直接挂监听:祖先滚动容器 + windowscroll,以及 ResizeObserver / window.resize。回调里调 update() 即可。真正值得看的是后两件。

6.1 layoutShift:用 IntersectionObserver 箍一个刚好包住按钮的框

原生 Performance 里的 Layout Shift 只是性能指标,并不能告诉你「这个按钮挪到哪了」。如果去轮询 getBoundingClientRect,又回到了强制 layout 的老路上。

Floating UI 的做法:用 IntersectionObserver 箍一个 刚好等于按钮当前矩形 的观察框。按钮还在框里 → 交比为 1;被旁边的元素挤出去 → 交比掉下去 → 当成「挪了」,触发 update

观察框靠 rootMargin 四边取负来实现:

arduino 复制代码
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 每帧比一次矩形:

scss 复制代码
if (animationFrame) {
  frameLoop();
}

function frameLoop() {
  const nextRefRect = getBoundingClientRect(reference);

  if (prevRefRect && !rectsAreEqual(prevRefRect, nextRefRect)) {
    update();
  }

  prevRefRect = nextRefRect;
  frameId = requestAnimationFrame(frameLoop);
}

这是四条路径里最贵的一条,默认不开。reference 真的在做 transform 位移(比如跟着拖拽把手、跟着运动的锚点)再打开。


七、把三条线拧在一起

回头看整条链路:

  1. 测量 :某一刻的精确坐标用 getBoundingClientRect;持续跟踪位置变化尽量用 IntersectionObserver,避免轮询。
  2. 计算computePosition 算出 floating 左上角,中间件按序处理间距、翻面、平移、箭头。detectOverflow 拿 clip(所有裁切祖先的交集)判断四边溢出。
  3. 更新:scroll / resize 直接听;layout shift 用「负 rootMargin 箍框」;transform 动画才走 rAF 逐帧比对。

自己写悬浮组件时,可以按这个顺序自检:矩形从哪来 → 溢出怎么躲 → 挂在哪一层 → 什么事件会让坐标过期。

相关推荐
yangzheui34 分钟前
nvue页面事件穿透到下层元素解决办法
前端
杉氧35 分钟前
从 Modifier 到 Flexbox:React Native 布局与样式设计哲学
android·前端·react native
比老马还六37 分钟前
Bipes-Blockly项目二次开发/硬件功能-LED灯条(十一)
前端·嵌入式硬件·硬件工程
circuitsosk1 小时前
智能体任务拆解与执行:基于ReAct+Plan-and-Execute框架的行业Skill构建实录
前端·javascript·python·react.js·react·llm agent·智能体编排
daols881 小时前
vue 表格vxe-table 实现紧凑型表格的方式
前端·javascript·vue.js
恋猫de小郭1 小时前
Flutter 多窗口支持类型和 API 介绍
android·前端·flutter
必须会一定会1 小时前
AI 前端项目验收:Playwright 截图、响应式视口、控制台错误与交互回归
前端·人工智能·gpt·交互·ai编程
Java搬码工1 小时前
VUE3使用教程
前端·javascript·vue.js