陌上人如玉
公子世无双

11.15 手写 Tooltip / Popover

11.15 手写 Tooltip / Popover

一句话:浮层组件的难点不在渲染,而在定位——要算的是一套完整的中间件链:基础位置 → 翻转(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]);

六、面试延伸

  1. 「手写 Tooltip,核心难点是什么?」
    定位与跟随。定位要算「12 种 placement(4 个方向 × 3 种对齐)」,并且必须处理三个真实场景:① 翻转(flip)——主方向空间不够时换到对侧(⭐ 判断要用「加上间距之后的可用空间」);② 位移(shift)——翻转后仍溢出,就沿交叉轴平移拉回视口;③ 箭头(arrow)——指向 trigger 中心但要钳制在浮层内。跟随要处理「窗口 resize、任意滚动容器的滚动(用 capture: true)、trigger/popup 尺寸变化(ResizeObserver)」,并用 rAF 节流避免抖动。此外还必须 Portal 到 body(否则会被祖先的 overflow: hidden 或 transform 裁剪/破坏 fixed)。

  2. 「为什么浮层要用 position: fixed + Portal?」
    因为两个独立的原因叠加:① 裁剪——如果浮层是「相对最近 position: relative 的祖先」用 absolute,一旦某个祖先有 overflow: hidden/auto,浮层就会被裁掉(表格行、卡片里最常见的坑);② 坐标系——getBoundingClientRect() 返回的是视口坐标,与 position: fixed 天然匹配(不用管祖先的定位);⚠️ 但 fixed 有一个陷阱:只要祖先有 transform/filter/will-change,它就会创建「包含块」,fixed 退化成「相对那个祖先」。所以Portal 到 body 是同时解决这两个问题的标准做法。

  3. 「中间件的顺序为什么不能乱?」
    因为每一步的判断都依赖上一步的结果:① offset 必须在 flip 之前——flip 判断的是「留出间距后还有没有空间」,否则会出现「翻过去刚好差几像素溢出」;② shift 必须在 flip 之后——shift 是「在已确定的主方向上做交叉轴修正」,先 shift 会基于「错误方向的位置」去平移;③ arrow 必须在最后——箭头位置依赖浮层的最终坐标(triggerCenter - state.x),前面任何位移都会改变它。⭐ 这与 Floating UI 的推荐顺序一致:offset → flip → shift → arrow → hide。

  4. 「flip 的判断逻辑该怎么做?」
    核心是「比较两侧的可用空间」:① 算出「当前主方向从 trigger 到视口边缘的可用空间」(availableIn(side))与「需要的空间」(浮层尺寸 + 两侧 padding);② 如果当前方向够,就不翻;③ 如果不够,算出对侧的可用空间,⭐ 只有当对侧确实更宽松时才翻(否则翻了更糟);④ 翻转时要保留 align(top-start → bottom-start),否则对齐会突然跑到另一边。进阶细节:还应该考虑「多个候选项的优先级列表」(如 ['top', 'bottom', 'right', 'left'],逐个尝试),以及「翻转后仍溢出就交给 shift」。

  5. 「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 通常不需要)。

  6. 「怎么处理『跟随』?会有什么性能问题?」
    三类事件都要监听:① window 的 resize;② scroll(用 capture: true)——因为滚动不一定发生在 window 上,内部滚动容器的滚动事件只会在捕获阶段被 window 看到;③ ResizeObserver(监听 trigger 与 popup 的尺寸变化——resize 事件拿不到「元素尺寸变化」)。性能问题是「滚动/resize 会高频触发,每次都算位置 + setState 会抖动和掉帧」。解法:① rAF 节流(同一帧最多更新一次);② ⭐ 更彻底的是「把位置写到 DOM 的 style 上(用 ref)而不经过 React 状态」——这样就完全不触发 React 重渲染(代价是「手动改 DOM 与 React 声明式混用」,要用 transform 而非 left/top 更安全)。

  7. 「CSS Anchor Positioning 能取代 JS 定位库吗?」
    能取代很大一部分,但还不到「完全取代」。能取代的:基础定位(position-anchor + position-area)、翻转(position-try-fallbacks)、箭头(anchor() 可以「相对锚点」定位,甚至允许超出浮层)、以及跟随(浏览器原生处理,零 JS)。还替代不了的:① 兼容性(支持面还在铺开,需要 JS 降级);② 更细的控制——如 shift 的「沿轴平移」、「限制浮层尺寸」、「多候选项的复杂优先级」;③ 交互逻辑(延迟、焦点管理、关闭、浮层栈)本来就要 JS。⭐ 实践策略:用 CSS.supports('position-anchor: --x') 检测,「支持就用纯 CSS(零 JS 开销),不支持降级到 JS 定位」。

  8. 「浮层的『关闭逻辑』有哪些容易漏的点?」
    六个:① 点击外部——用 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 节流(更彻底的是直接改 DOM style,绕开 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 能替代大部分定位,但需要特性检测与降级。

赞(0) 打赏
未经允许不得转载:陌上寒 » 11.15 手写 Tooltip / Popover

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫