
一句话:浮层组件的难点不在渲染,而在定位——要算的是一套完整的中间件链:基础位置 → 翻转(flip,空间不够就换到对侧)→ 位移(shift,仍溢出就沿轴平移)→ 箭头(arrow,跟着指向目标)→ 隐藏(hide,目标不可见就收起来),再补上「跟随(ResizeObserver/scroll)、Portal、无障碍与焦点管理」。
一、先拆需求(这是「为什么它会变复杂」的答案)
⭐⭐ 一个「看起来只是绝对定位」的组件,实际要解决 9 件事:
① 【定位】浮层相对 trigger 的位置(4 个方向 × 3 种对齐 = 12 种 placement)
② 【翻转(flip)】当主方向空间不够时,翻到对侧
(如「向上弹」但上面已经到顶 → 自动改成「向下弹」)
③ 【位移(shift)】翻转后仍溢出 → 沿「交叉轴」平移,把它拉回视口内
④ 【箭头(arrow)】箭头要指向 trigger 的中心,但「不能超出浮层的圆角区域」→ 需要钳制
⑤ 【跟随】窗口 resize / 滚动 / trigger 或浮层尺寸变化时,位置要更新
⚠️ 「位置更新」与「闪烁」是一对矛盾:更新太频繁会抖动,不更新会错位
⑥ 【Portal】浮层要挂到 body(避免被父元素的 overflow/transform/层叠上下文裁剪)
⑦ 【交互延迟】hover 触发要有 delay(避免鼠标划过就闪),关闭也要有 delay(让鼠标能移进浮层)
⑧ 【无障碍】`role="tooltip"` + `aria-describedby`;Popover 要 `aria-expanded` + `aria-controls` + 焦点管理
⑨ 【关闭逻辑】ESC、点击外部、滚动、失焦
⭐⭐ placement 的 12 种组合(命名规则):
top / top-start / top-end
bottom / bottom-start / bottom-end
left / left-start / left-end
right / right-start / right-end
· 主方向(side):top | bottom | left | right —— 浮层在 trigger 的哪一侧
· 对齐(align):center(默认)| start | end —— 浮层与 trigger 的哪一端对齐
⭐ 注意「start/end 是相对主轴方向的」:
· top-start = 「在 trigger 上方,且左边缘与 trigger 左边缘对齐」
· top-end = 「在 trigger 上方,且右边缘与 trigger 右边缘对齐」
· left-start = 「在 trigger 左侧,且上边缘与 trigger 上边缘对齐」
与 CSS 逻辑属性一致(在 RTL 下 start/end 会镜像)。
二、完整实现(1):定位算法(纯函数,可单测)
// floating/position.ts —— 定位计算(零 React 依赖,可直接单测)
export type Side = 'top' | 'bottom' | 'left' | 'right';
export type Align = 'start' | 'center' | 'end';
export type Placement = Side | `${Side}-${Align}`;
export interface Rect { x: number; y: number; width: number; height: number }
/** 把 DOMRect 转成「可运算的矩形」 */
export function toRect(r: DOMRect | DOMRectReadOnly): Rect {
return { x: r.left, y: r.top, width: r.width, height: r.height };
}
export interface PositionState {
trigger: Rect;
popup: { width: number; height: number };
viewport: { width: number; height: number };
/** 期望的 placement(可能被 flip 改掉) */
placement: Placement;
/** 最终坐标(相对视口,配合 position: fixed) */
x: number;
y: number;
/** 箭头相对浮层左上角的偏移 */
arrow: { x: number; y: number };
/** 是否应该隐藏 */
isHidden: boolean;
}
export interface MiddlewareContext {
trigger: Rect;
popup: { width: number; height: number };
viewport: { width: number; height: number };
/** 与视口边缘的最小距离 */
padding: number;
placement: Placement;
arrowSize: number;
}
export type Middleware = (state: PositionState, ctx: MiddlewareContext) => PositionState;
function parsePlacement(placement: Placement): { side: Side; align: Align } {
const [side, align = 'center'] = placement.split('-') as [Side, Align | undefined];
return { side, align: align ?? 'center' };
}
/** ⭐ 对侧方向(flip 用) */
function opposite(side: Side): Side {
return side === 'top' ? 'bottom' : side === 'bottom' ? 'top' : side === 'left' ? 'right' : 'left';
}
function isVertical(side: Side) {
return side === 'top' || side === 'bottom';
}
/** ============ ① 基础位置(不加任何中间件) ============ */
export function computeBasicPosition(ctx: MiddlewareContext): PositionState {
const { side, align } = parsePlacement(ctx.placement);
const { trigger, popup, viewport } = ctx;
const gap = ctx.padding; // 这里复用 padding 作为「初始间距」
let x = 0;
let y = 0;
if (isVertical(side)) {
// 主方向:垂直
y = side === 'top' ? trigger.y - popup.height - gap : trigger.y + trigger.height + gap;
// 对齐:水平
if (align === 'center') x = trigger.x + (trigger.width - popup.width) / 2;
else if (align === 'start') x = trigger.x;
else x = trigger.x + trigger.width - popup.width;
} else {
// 主方向:水平
x = side === 'left' ? trigger.x - popup.width - gap : trigger.x + trigger.width + gap;
// 对齐:垂直
if (align === 'center') y = trigger.y + (trigger.height - popup.height) / 2;
else if (align === 'start') y = trigger.y;
else y = trigger.y + trigger.height - popup.height;
}
return {
trigger, popup: ctx.popup, viewport,
placement: ctx.placement,
x, y,
arrow: { x: popup.width / 2, y: popup.height / 2 },
isHidden: false,
};
}
/** ============ ② offset 中间件:在主方向上留出间距 ============ */
export const offset =
(distance: number): Middleware =>
(state, ctx) => {
const { side } = parsePlacement(state.placement);
const gap = distance;
const dx = side === 'left' ? -gap : side === 'right' ? gap : 0;
const dy = side === 'top' ? -gap : side === 'bottom' ? gap : 0;
return { ...state, x: state.x + dx, y: state.y + dy };
};
/** ============ ③ flip 中间件:主方向空间不够就翻到对侧 ============ */
export const flip =
(): Middleware =>
(state, ctx) => {
const { side, align } = parsePlacement(state.placement);
const { trigger, popup, viewport, padding } = ctx;
/** 计算「某方向下」浮层的溢出量(>0 表示溢出) */
const overflowIn = (s: Side) => {
switch (s) {
case 'top': return padding - (trigger.y - padding - popup.height) + state.y * 0; // 见下方说明
default: return 0;
}
};
/** ⭐ 更清晰的做法:直接算「该方向上的可用空间」与「需要空间」 */
const need = isVertical(side) ? popup.height + padding * 2 : popup.width + padding * 2;
/** 主方向上「从 trigger 到视口边缘」的可用空间 */
const availableIn = (s: Side) => {
switch (s) {
case 'top': return trigger.y;
case 'bottom': return viewport.height - (trigger.y + trigger.height);
case 'left': return trigger.x;
case 'right': return viewport.width - (trigger.x + trigger.width);
}
};
const currentSpace = availableIn(side);
if (currentSpace >= need) return state; // ⭐ 空间够 → 不翻
const flipped = opposite(side);
const flippedSpace = availableIn(flipped);
// ⭐ 只有当「对侧确实更宽松」时才翻(否则翻了更糟)
if (flippedSpace <= currentSpace) return state;
// 用对侧重算
const newPlacement = (align === 'center' ? flipped : `${flipped}-${align}`) as Placement;
const next = computeBasicPosition({ ...ctx, placement: newPlacement });
return offset(0)({ ...next, arrow: state.arrow }, ctx);
};
/** ============ ④ shift 中间件:交叉轴上平移,把它拉回视口 ============ */
export const shift =
(): Middleware =>
(state, ctx) => {
const { side } = parsePlacement(state.placement);
const { popup, viewport, padding } = ctx;
let { x, y } = state;
if (isVertical(side)) {
// 交叉轴是水平 → 用 x 方向钳制
const minX = padding;
const maxX = viewport.width - popup.width - padding;
x = Math.min(Math.max(x, minX), Math.max(minX, maxX));
} else {
const minY = padding;
const maxY = viewport.height - popup.height - padding;
y = Math.min(Math.max(y, minY), Math.max(minY, maxY));
}
return { ...state, x, y };
};
/** ============ ⑤ arrow 中间件:让箭头指向 trigger 中心(并钳制在浮层内) ============ */
export const arrow =
({ size, inset = 6 }: { size: number; inset?: number }): Middleware =>
(state, ctx) => {
const { side } = parsePlacement(state.placement);
const { trigger, popup } = ctx;
if (isVertical(side)) {
// 箭头在浮层的「上边或下边」,沿水平方向定位
const triggerCenterX = trigger.x + trigger.width / 2;
const raw = triggerCenterX - state.x; // 相对浮层左边缘
const clamped = Math.min(Math.max(raw, inset + size / 2), popup.width - inset - size / 2);
return { ...state, arrow: { x: clamped, y: side === 'top' ? popup.height : 0 } };
}
const triggerCenterY = trigger.y + trigger.height / 2;
const raw = triggerCenterY - state.y;
const clamped = Math.min(Math.max(raw, inset + size / 2), popup.height - inset - size / 2);
return { ...state, arrow: { x: side === 'left' ? popup.width : 0, y: clamped } };
};
/** ============ ⑥ hide 中间件:trigger 被完全滚出视口 → 隐藏浮层 ============ */
export const hide =
(): Middleware =>
(state, ctx) => {
const { trigger, viewport } = ctx;
const fullyOutside =
trigger.y + trigger.height < 0 ||
trigger.y > viewport.height ||
trigger.x + trigger.width < 0 ||
trigger.x > viewport.width;
return { ...state, isHidden: fullyOutside };
};
/** ============ 组装:中间件链(顺序很重要!) ============ */
export interface ComputeOptions {
placement?: Placement;
/** 浮层与 trigger 的间距 */
offsetDistance?: number;
/** 浮层与视口边缘的最小距离 */
viewportPadding?: number;
/** 箭头尺寸(0 表示不需要箭头) */
arrowSize?: number;
}
export function computePosition(
triggerEl: Element,
popupEl: Element,
options: ComputeOptions = {}
): PositionState {
const {
placement = 'top',
offsetDistance = 8,
viewportPadding = 8,
arrowSize = 0,
} = options;
const trigger = toRect(triggerEl.getBoundingClientRect());
const popupRect = popupEl.getBoundingClientRect();
const popup = { width: popupRect.width, height: popupRect.height };
const viewport = {
width: document.documentElement.clientWidth, // ⭐ 用 clientWidth(不含滚动条)
height: document.documentElement.clientHeight,
};
const ctx: MiddlewareContext = {
trigger, popup, viewport,
padding: viewportPadding,
placement,
arrowSize,
};
// ⭐⭐ 中间件顺序(这是「正确性」的关键):
// 基础位置 → offset(先留间距)→ flip(在「有间距」的前提下判断空间)
// → shift(翻转后再修正)→ arrow(最后算箭头)
const chain: Middleware[] = [
offset(offsetDistance),
flip(),
shift(),
...(arrowSize > 0 ? [arrow({ size: arrowSize })] : []),
hide(),
];
let state = computeBasicPosition({
...ctx,
// ⭐ 基础位置里用「offsetDistance」当初始 gap,这样 flip 的判断才准
padding: offsetDistance,
});
return chain.reduce((acc, mw) => mw(acc, { ...ctx, padding: viewportPadding }), state);
}
⭐⭐⭐ 三个「顺序」上的坑(写错会得到「看起来很怪」的位置):
① 【`offset` 必须在 `flip` 之前】
因为「翻转」判断的是「**加上间距之后**还有没有空间」。
如果先 flip 再加间距,会出现「翻过去刚好差 8px 溢出」。
② 【`shift` 必须在 `flip` 之后】
`shift` 是「在**已经确定的方向上**做修正」——
如果先 shift,翻转后你会基于「错误方向的位置」去平移。
③ 【`arrow` 必须在最后】
因为箭头的位置依赖「浮层的最终坐标」(`triggerCenter - state.x`)——
前面任何一次位移都会改变它。
⭐ 这与 Floating UI 的中间件顺序建议一致:
`offset` → `flip` → `shift` → `arrow` → `hide`
三、完整实现(2):React Hook(跟随 + Portal + 交互)
// floating/useFloating.tsx
import { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { computePosition, type ComputeOptions, type PositionState } from './position';
export interface UseFloatingOptions extends ComputeOptions {
/** 是否挂到 body(默认 true) */
portal?: boolean;
/** hover 打开延迟 */
openDelay?: number;
/** hover 关闭延迟(让鼠标能移到浮层上) */
closeDelay?: number;
/** 点击外部是否关闭 */
closeOnOutsideClick?: boolean;
/** ESC 是否关闭 */
closeOnEscape?: boolean;
}
export function useFloating(options: UseFloatingOptions = {}) {
const {
portal = true,
openDelay = 150,
closeDelay = 100,
closeOnOutsideClick = true,
closeOnEscape = true,
} = options;
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<PositionState | null>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const popupRef = useRef<HTMLElement | null>(null);
const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = useCallback(() => {
if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = null; }
if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
}, []);
/** ⭐ 位置更新(用 rAF 节流,避免滚动时「每像素都算一次」) */
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
const popup = popupRef.current;
if (!trigger || !popup) return;
setPosition(computePosition(trigger, popup, options));
}, [options]);
const rafId = useRef(0);
const scheduleUpdate = useCallback(() => {
if (rafId.current) return;
rafId.current = requestAnimationFrame(() => {
rafId.current = 0;
updatePosition();
});
}, [updatePosition]);
/** ============ 打开/关闭(带延迟,处理「鼠标经过」与「移进浮层」) ============ */
const show = useCallback(() => {
clearTimers();
openTimer.current = setTimeout(() => setOpen(true), openDelay);
}, [openDelay, clearTimers]);
const hide = useCallback(() => {
clearTimers();
closeTimer.current = setTimeout(() => setOpen(false), closeDelay);
}, [closeDelay, clearTimers]);
/** ============ 打开后:计算位置 + 监听变化 ============ */
useLayoutEffect(() => {
if (!open) return;
// ⭐ 先算一次(在布局阶段,避免「先渲染在 0,0 再跳过去」的闪烁)
updatePosition();
// ⭐ 跟随:窗口 resize / 滚动(capture 捕获「任意滚动容器」的滚动)
const onScrollOrResize = () => scheduleUpdate();
window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true });
window.addEventListener('resize', onScrollOrResize, { passive: true });
// ⭐ 跟随:trigger / popup 的「尺寸变化」(内容变了、字体加载完等)
const ro = new ResizeObserver(() => scheduleUpdate());
if (triggerRef.current) ro.observe(triggerRef.current);
if (popupRef.current) ro.observe(popupRef.current);
// ⭐ 监听 trigger 的「位置变化」(如祖先元素动画、布局变化)
const io = new IntersectionObserver(() => scheduleUpdate());
if (triggerRef.current) io.observe(triggerRef.current);
return () => {
window.removeEventListener('scroll', onScrollOrResize, { capture: true } as EventListenerOptions);
window.removeEventListener('resize', onScrollOrResize);
ro.disconnect();
io.disconnect();
if (rafId.current) cancelAnimationFrame(rafId.current);
};
}, [open, updatePosition, scheduleUpdate]);
/** ============ 关闭:ESC / 点击外部 ============ */
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (closeOnEscape && e.key === 'Escape') setOpen(false);
};
const onPointerDown = (e: PointerEvent) => {
if (!closeOnOutsideClick) return;
const target = e.target as Node;
// ⭐ 点在「trigger 或 popup 内部」不关闭
if (triggerRef.current?.contains(target)) return;
if (popupRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener('keydown', onKeyDown);
// ⭐ 用 pointerdown 而不是 click(更早、且能覆盖「右键/触摸」)
document.addEventListener('pointerdown', onPointerDown, true);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('pointerdown', onPointerDown, true);
};
}, [open, closeOnEscape, closeOnOutsideClick]);
/** ============ 卸载清理 ============ */
useEffect(() => clearTimers, [clearTimers]);
/** ============ 返回「展开给 trigger / popup 的 props」 ============ */
const getTriggerProps = useCallback(
(extra: { describedBy?: string; controls?: string; isPopover?: boolean } = {}) => ({
ref: (el: HTMLElement | null) => { triggerRef.current = el; },
onPointerEnter: show,
onPointerLeave: hide,
onFocus: () => setOpen(true), // ⭐ 键盘可达:聚焦就打开
onBlur: () => setOpen(false),
'aria-describedby': extra.describedBy && open ? extra.describedBy : undefined,
...(extra.isPopover
? { 'aria-expanded': open, 'aria-haspopup': 'dialog' as const, 'aria-controls': extra.controls }
: {}),
}),
[show, hide, open]
);
const getPopupProps = useCallback(
(extra: { id?: string; role?: 'tooltip' | 'dialog' } = {}) => ({
ref: (el: HTMLElement | null) => { popupRef.current = el; },
id: extra.id,
role: extra.role ?? 'tooltip',
onPointerEnter: clearTimers, // ⭐ 鼠标移进浮层 → 取消关闭
style: position
? {
position: 'fixed' as const, // ⭐ fixed + 视口坐标(避免「祖先 relative」干扰)
left: position.x,
top: position.y,
visibility: position.isHidden ? ('hidden' as const) : ('visible' as const),
}
: { position: 'fixed' as const, left: 0, top: 0, visibility: 'hidden' as const },
}),
[position, clearTimers]
);
return {
open, setOpen,
position,
getTriggerProps,
getPopupProps,
triggerRef,
popupRef,
/** ⭐ 用 Portal 渲染浮层(默认挂到 body) */
renderPopup: (children: React.ReactNode, extra?: { id?: string; role?: 'tooltip' | 'dialog' }) => {
if (!open) return null;
const node = <div {...getPopupProps(extra)}>{children}</div>;
return portal && typeof document !== 'undefined'
? createPortal(node, document.body)
: node;
},
};
}
/** ============ 使用示例:Tooltip(hover)+ Popover(click) ============ */
export function Tooltip({ label, children }: { label: string; children: React.ReactElement }) {
const id = useId();
const floating = useFloating({ placement: 'top', offsetDistance: 8, arrowSize: 8, openDelay: 200, closeDelay: 80 });
return (
<>
{/* ⭐ 把 props 合并到子元素上(用 cloneElement 或让调用方展开) */}
{cloneElement(children as React.ReactElement<Record<string, unknown>>, {
...floating.getTriggerProps({ describedBy: id }),
})}
{floating.renderPopup(
<>
{label}
{/* ⭐ 箭头:位置来自 position.arrow,用 transform 摆过去(只改 transform,性能好,见 5.17) */}
{floating.position && (
<span
aria-hidden
style={{
position: 'absolute',
left: 0, top: 0,
width: 8, height: 8,
background: 'inherit',
transform: `translate(${floating.position.arrow.x - 4}px, ${floating.position.arrow.y - 4}px) rotate(45deg)`,
}}
/>
)}
</>,
{ id, role: 'tooltip' }
)}
</>
);
}
export function Popover({ trigger, content }: { trigger: React.ReactElement; content: React.ReactNode }) {
const id = useId();
const floating = useFloating({ placement: 'bottom-start', offsetDistance: 6 });
return (
<>
{cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {
// ⭐ Popover 是「点击」触发(不是 hover)
onClick: () => floating.setOpen((o) => !o),
...floating.getTriggerProps({ controls: id, isPopover: true }),
onPointerEnter: undefined, // 覆盖掉 hover 行为
onPointerLeave: undefined,
})}
{floating.renderPopup(content, { id, role: 'dialog' })}
</>
);
}
import { cloneElement, useId } from 'react';
/** ============ 一个「不依赖任何库」的最小样式(关键是 fixed + 高 z-index) ============ */
export const floatingStyles: Record<string, React.CSSProperties> = {
tooltip: {
zIndex: 9999,
maxWidth: 280,
padding: '6px 10px',
borderRadius: 6,
background: 'rgba(20,20,20,.92)',
color: '#fff',
fontSize: 12,
lineHeight: 1.5,
// ⭐ 关键:浮层本身不接收指针事件(否则会「挡住底下的东西」并造成 hover 抖动)
pointerEvents: 'none',
},
popover: {
zIndex: 9999,
minWidth: 200,
padding: 12,
borderRadius: 10,
background: '#fff',
boxShadow: '0 8px 32px rgba(0,0,0,.16)',
// ⭐ Popover 需要交互 → 必须接收指针事件
pointerEvents: 'auto',
},
};
⭐⭐⭐ 这段实现里有四个「必须做对」的细节:
① 【`position: fixed` + 视口坐标】
用 `fixed` 就不用关心「祖先有没有 `position: relative` / `transform`」——
因为 `getBoundingClientRect()` 返回的**已经是视口坐标**,两者天然匹配。
⚠️ 如果浮层挂在「有 `transform` 的祖先」下,`fixed` 会退化成「相对那个祖先」
(因为 transform 会创建包含块)→ ⭐ **这也是必须用 Portal 的原因之一**。
② 【`pointer-events: none`(Tooltip)】
Tooltip 本身不该接收指针事件:
· 否则它会「挡住鼠标」,导致 `pointerleave` 立刻触发 → 抖动着关闭
· 也避免「遮住底下的可点击元素」
⚠️ 但 **Popover 必须能接收**(里面有按钮/输入框)→ 这就是两者样式的关键差异。
③ 【`scroll` 用 `capture: true`】
滚动**不一定发生在 window 上**(可能是某个内部滚动容器)——
用捕获阶段监听可以「捕获到任意滚动容器的滚动」。
⭐ 不这么做的话,内部容器滚动时浮层会「脱离 trigger」。
④ 【用 `rAF` 节流 + `ResizeObserver`】
滚动/resize 会高频触发,直接每次算位置会「抖动 + 掉帧」;
而「trigger/popup 尺寸变化」(内容变了、字体加载完)用 `ResizeObserver` 能拿到
(`resize` 事件只在窗口变化时触发,拿不到元素尺寸变化)。
四、与 CSS Anchor Positioning 的关系
/* ⭐ 现代方案:CSS 锚点定位(Chrome 125+,逐步可用) */
.trigger {
anchor-name: --my-trigger;
}
.popup {
position: fixed;
position-anchor: --my-trigger;
/* ⭐ 一行声明「在锚点上方居中,间距 8px」 */
position-area: top;
margin-bottom: 8px;
/* ⭐ 内置的「溢出处理」:优先上方,不行就翻到下方 */
position-try-fallbacks: bottom;
}
.arrow {
position: absolute;
position-anchor: --my-trigger;
/* ⭐ 箭头可以「相对锚点」定位(甚至可以超出浮层) */
left: anchor(center);
}
⭐⭐ CSS 锚点定位能替代多少 JS?
✅ 能替代的:基础定位、翻转(`position-try-fallbacks`)、
箭头(`anchor()` 函数)、随滚动自动跟随(浏览器原生处理)
⚠️ 不能完全替代的:
· **兼容性**(2024 年前后的浏览器支持参差)→ 需要 JS 降级
· **复杂策略**(`shift` 的「沿轴平移」、多个候选的优先级、
「限制浮层尺寸」这类更细的控制)
· **交互逻辑**(延迟、焦点管理、关闭逻辑)本来就要 JS
⭐ 实践策略:「**优先用 CSS 锚点(支持的浏览器零 JS 开销),
不支持时降级到 JS 定位**」——
可以用 `CSS.supports('position-anchor: --x')` 检测。
// ✅ 特性检测 + 降级
const supportsAnchorPositioning =
typeof CSS !== 'undefined' && CSS.supports('position-anchor: --x');
if (supportsAnchorPositioning) {
// 用纯 CSS 方案(不跑 computePosition)
} else {
// 用 JS 定位(本篇的实现)
}
五、本篇特有的坑
// ① 用 absolute + 相对父元素定位(被祖先 overflow/transform 裁剪)
position: 'absolute' // ⚠️ 祖先是 overflow: hidden → 被裁掉
// ✅ position: fixed + Portal 到 body
// ② 浮层挂在「有 transform 的祖先」下,fixed 退化
// ⭐ transform 会创建「包含块」,fixed 变成「相对那个祖先」
// ✅ Portal 到 body(避开这个问题)
// ③ 忘记 pointer-events: none(Tooltip 挡住鼠标 → 抖动关闭)
// ✅ Tooltip 加 none;Popover 保留 auto
// ④ 只用 window 的 scroll 监听(内部滚动容器里错位)
window.addEventListener('scroll', update); // ⚠️ 拿不到内部容器滚动
// ✅ { capture: true }(捕获阶段)
// ⑤ 只在「打开时」算一次位置(滚动/resize/尺寸变化后错位)
useEffect(() => { computePosition(...); }, [open]); // ❌ 不跟随
// ✅ + ResizeObserver + scroll/resize 监听
// ⑥ 位置更新不做节流(滚动时抖动 + 掉帧)
scroll → 每次 setState(position) // ❌ 每像素一次
// ✅ rAF 节流(同一帧只更新一次)
// ⑦ 先渲染在 (0,0) 再跳到目标位置(闪烁)
useEffect(() => { setPosition(compute()); }, [open]); // ⚠️ 会先看到 (0,0)
// ✅ 初始 `visibility: hidden` + 用 `useLayoutEffect` 在绘制前算好
// ⑧ 翻转判断没考虑「间距」(翻过去刚好溢出)
// ✅ offset 必须在 flip 之前(见「顺序」说明)
// ⑨ 翻转时「不保留箭头/对齐信息」(翻完对齐跑到另一边)
// ✅ flip 重算时把 `align` 带过去(`${opposite}-${align}`)
// ⑩ 箭头位置没钳制(箭头跑到浮层外面)
arrow.x = triggerCenter - state.x // ❌ 可能超出 [0, width]
// ✅ `Math.min(Math.max(raw, inset + size/2), popup.width - inset - size/2)`
// ⑪ hover 打开没有延迟(鼠标划过就闪)
onPointerEnter={() => setOpen(true)} // ⚠️ 划过一排按钮会「闪」
// ✅ openDelay ~150~300ms(hover intent)
// ⑫ 关闭没有延迟(鼠标想移进浮层时就关了)
onPointerLeave={() => setOpen(false)} // ❌ 移不进去
// ✅ closeDelay ~100ms + 浮层自己的 onPointerEnter 取消关闭
// ⑬ Tooltip 不可键盘访问
// ⭐ 只用 hover 触发 → 键盘用户看不到
// ✅ focus/blur 也触发(并保证 `aria-describedby` 关联)
// ⑭ Popover 不管理焦点(打开后焦点还在 trigger 上,Tab 会跑到底下内容)
// ✅ 打开时把焦点移进浮层(或加 `inert` 到背景),关闭时还回 trigger
// ⑮ 点外部关闭「用了 click 而不是 pointerdown」
// ⚠️ click 更晚;且「在 trigger 上按下、拖到浮层上松开」会误判
// ✅ pointerdown + 检查 target 是否在 trigger/popup 内
// ⑯ ESC 关闭没有「把焦点还回 trigger」
// ✅ setOpen(false) 后 `triggerRef.current?.focus()`
// ⑰ 多个浮层同时打开(互相覆盖 / 点击外部把另一个关掉)
// ⚠️ 各自独立监听 `pointerdown` → 每次点击把所有浮层都关掉(有时是对的,有时不是)
// ✅ 用「浮层栈」管理(只关闭「最上层及其后代」)
// ⑱ 浮层在 iframe / 跨域嵌套里定位不对
// ⚠️ getBoundingClientRect 是「当前文档」的坐标系
// ✅ 按需要在 iframe 内渲染浮层(或用 postMessage 传坐标)
// ⑲ 忘记「浮层内容比自己预估的高」(导致被视口底部裁掉)
// ⚠️ 期望「两行」,实际「五行」→ 溢出
// ✅ 先测量(`getBoundingClientRect` 拿到实际尺寸)再定位(本实现就是这么做)
// 并给浮层 `max-height` + 溢出滚动
// ⑳ 用 `setState` 存「位置」导致每次滚动都重渲染整个浮层内容
// ⚠️ 如果浮层内容复杂,重渲染成本高
// ✅ 把「位置」写到 DOM 的 style 上(用 ref + 直接设 style),不经过 React 状态
// ⑳ 的完整处理(位置更新绕开 React 重渲染)
const updatePositionDirect = useCallback(() => {
const trigger = triggerRef.current;
const popup = popupRef.current;
if (!trigger || !popup) return;
const pos = computePosition(trigger, popup, options);
// ⭐ 直接改 style,不 setState → 不触发 React 重渲染
popup.style.transform = `translate3d(${pos.x}px, ${pos.y}px, 0)`;
popup.style.visibility = pos.isHidden ? 'hidden' : 'visible';
// ⭐ 箭头同理(用 ref 拿箭头元素,直接设 style)
if (arrowRef.current) {
arrowRef.current.style.transform =
`translate3d(${pos.arrow.x - 4}px, ${pos.arrow.y - 4}px, 0) rotate(45deg)`;
}
}, [options]);
// ⚠️ 代价:React 的「声明式渲染」与「手动改 DOM」混用,要小心「React 重渲染覆盖掉手改的 style」
// 解法:用 transform 而非 left/top(React 不管 transform 时更安全),
// 或把浮层的内容与「位置控制」拆成两个组件(内容走高阶,位置走 ref)
// ⑭ 的完整处理(Popover 的焦点管理)
useEffect(() => {
if (!open || role !== 'dialog') return;
const popup = popupRef.current;
if (!popup) return;
// ⭐ 记住「打开前的焦点」,关闭时还回去
previouslyFocused.current = document.activeElement as HTMLElement | null;
// ⭐ 把焦点移进浮层(找第一个可聚焦元素)
const focusable = popup.querySelector<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
);
(focusable ?? popup).focus();
return () => {
previouslyFocused.current?.focus();
};
}, [open, role]);
六、面试延伸
- 「手写 Tooltip,核心难点是什么?」
定位与跟随。定位要算「12 种 placement(4 个方向 × 3 种对齐)」,并且必须处理三个真实场景:① 翻转(flip)——主方向空间不够时换到对侧(⭐ 判断要用「加上间距之后的可用空间」);② 位移(shift)——翻转后仍溢出,就沿交叉轴平移拉回视口;③ 箭头(arrow)——指向 trigger 中心但要钳制在浮层内。跟随要处理「窗口 resize、任意滚动容器的滚动(用capture: true)、trigger/popup 尺寸变化(ResizeObserver)」,并用rAF节流避免抖动。此外还必须 Portal 到 body(否则会被祖先的overflow: hidden或transform裁剪/破坏fixed)。 -
「为什么浮层要用
position: fixed+ Portal?」
因为两个独立的原因叠加:① 裁剪——如果浮层是「相对最近position: relative的祖先」用absolute,一旦某个祖先有overflow: hidden/auto,浮层就会被裁掉(表格行、卡片里最常见的坑);② 坐标系——getBoundingClientRect()返回的是视口坐标,与position: fixed天然匹配(不用管祖先的定位);⚠️ 但fixed有一个陷阱:只要祖先有transform/filter/will-change,它就会创建「包含块」,fixed退化成「相对那个祖先」。所以Portal 到body是同时解决这两个问题的标准做法。 -
「中间件的顺序为什么不能乱?」
因为每一步的判断都依赖上一步的结果:①offset必须在flip之前——flip判断的是「留出间距后还有没有空间」,否则会出现「翻过去刚好差几像素溢出」;②shift必须在flip之后——shift是「在已确定的主方向上做交叉轴修正」,先 shift 会基于「错误方向的位置」去平移;③arrow必须在最后——箭头位置依赖浮层的最终坐标(triggerCenter - state.x),前面任何位移都会改变它。⭐ 这与 Floating UI 的推荐顺序一致:offset → flip → shift → arrow → hide。 -
「
flip的判断逻辑该怎么做?」
核心是「比较两侧的可用空间」:① 算出「当前主方向从 trigger 到视口边缘的可用空间」(availableIn(side))与「需要的空间」(浮层尺寸 + 两侧 padding);② 如果当前方向够,就不翻;③ 如果不够,算出对侧的可用空间,⭐ 只有当对侧确实更宽松时才翻(否则翻了更糟);④ 翻转时要保留align(top-start→bottom-start),否则对齐会突然跑到另一边。进阶细节:还应该考虑「多个候选项的优先级列表」(如['top', 'bottom', 'right', 'left'],逐个尝试),以及「翻转后仍溢出就交给shift」。 -
「Tooltip 和 Popover 在实现上有什么关键差别?」
四点:①pointer-events——Tooltip 要none(否则它挡住鼠标会导致pointerleave立刻触发 → 抖动关闭,还会挡住底下的可点击元素);Popover 必须auto(里面有交互元素);② 触发方式——Tooltip 是 hover/focus(带延迟:开约 150~300ms、关约 100ms);Popover 是 click(且要有开关状态);③ 无障碍——Tooltip 用role="tooltip"+aria-describedby(只描述、不聚焦);Popover 用aria-expanded+aria-controls+role="dialog",并管理焦点(打开移入、关闭还回 trigger);④ 关闭逻辑——Popover 必须支持 ESC + 点击外部(Tooltip 通常不需要)。 -
「怎么处理『跟随』?会有什么性能问题?」
三类事件都要监听:①window的resize;②scroll(用capture: true)——因为滚动不一定发生在 window 上,内部滚动容器的滚动事件只会在捕获阶段被 window 看到;③ResizeObserver(监听 trigger 与 popup 的尺寸变化——resize事件拿不到「元素尺寸变化」)。性能问题是「滚动/resize 会高频触发,每次都算位置 +setState会抖动和掉帧」。解法:①rAF节流(同一帧最多更新一次);② ⭐ 更彻底的是「把位置写到 DOM 的style上(用 ref)而不经过 React 状态」——这样就完全不触发 React 重渲染(代价是「手动改 DOM 与 React 声明式混用」,要用transform而非left/top更安全)。 -
「CSS Anchor Positioning 能取代 JS 定位库吗?」
能取代很大一部分,但还不到「完全取代」。能取代的:基础定位(position-anchor+position-area)、翻转(position-try-fallbacks)、箭头(anchor()可以「相对锚点」定位,甚至允许超出浮层)、以及跟随(浏览器原生处理,零 JS)。还替代不了的:① 兼容性(支持面还在铺开,需要 JS 降级);② 更细的控制——如shift的「沿轴平移」、「限制浮层尺寸」、「多候选项的复杂优先级」;③ 交互逻辑(延迟、焦点管理、关闭、浮层栈)本来就要 JS。⭐ 实践策略:用CSS.supports('position-anchor: --x')检测,「支持就用纯 CSS(零 JS 开销),不支持降级到 JS 定位」。 -
「浮层的『关闭逻辑』有哪些容易漏的点?」
六个:① 点击外部——用pointerdown(比click更早,且能覆盖「按下后拖走」的情况),并且要检查 target 是否在 trigger 或 popup 内;② ESC——关闭后要把焦点还回 trigger(否则键盘用户会「丢焦点」);③ 滚动时——是「跟随」还是「关闭」取决于场景(Tooltip 通常跟随,Popover 有些设计会关闭);④ 多个浮层——各自独立监听会「一次点击关掉所有浮层」,正确做法是维护一个浮层栈(只关闭「最上层及其后代」);⑤ 焦点管理——Popover 打开时应把焦点移入(或把背景设为inert),关闭时还回;⑥ 组件卸载——所有监听器/计时器/ResizeObserver都要清理(见 5.13)。
一句话速记
浮层的难点是「定位中间件链」:基础位置(12 种 placement = 4 方向 × 3 对齐)→
offset(先留间距)→flip(用「加上间距后的可用空间」判断,只有对侧更宽松才翻,且翻转要保留align)→shift(交叉轴平移拉回视口)→arrow(指向 trigger 中心但钳制在浮层内)→hide,⭐ 顺序不能乱(offset先于flip、shift后于flip、arrow最后);必须position: fixed+ Portal 到 body(躲开祖先的overflow裁剪,也躲开transform让fixed退化);跟随靠window resize+scroll(capture: true)(捕获内部滚动容器)+ResizeObserver(元素尺寸变化),并用rAF节流(更彻底的是直接改 DOMstyle,绕开 React 重渲染);Tooltip 要pointer-events: none+ hover 延迟(开 150~300ms / 关 100ms)+aria-describedby,Popover 要pointer-events: auto+ click 开关 +aria-expanded/aria-controls+ 焦点管理;关闭逻辑用pointerdown(不是 click)+ ESC 还焦点 + 浮层栈;CSS Anchor Positioning 能替代大部分定位,但需要特性检测与降级。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!