返回博客

Base UI Positioner 没有 Portal 直接崩:浮层组件的强制依赖陷阱

在给一个组件库封装 Tooltip / Popover 时,我想把弹出层"装进"一个固定尺寸的展示框里——文档画廊里每个 Cell 独立展示一个状态,不希望 Portal 默认挂到 body 后浮层飘到页面外面。于是我省略了 <Tooltip.Portal>,只写 Root > Trigger > Positioner > Popup。结果页面刚渲染就崩了。

现象

控制台抛出运行时错误,调用栈指向 Positioner 内部的 context hook:

Error: Base UI: <Tooltip.Portal> is missing.
  at useTooltipPortalContext (.../tooltip/portal/TooltipPortalContext.js)
  at TooltipPositioner (.../tooltip/positioner/TooltipPositioner.js)

Popover、Menu、Select、Combobox 同理——错误信息只是把"Tooltip"换成对应组件名。组件树根本没进入渲染阶段,连 Trigger 都没出来。

根因

Base UI 的每一类浮层(Tooltip / Popover / Menu / Select / Combobox / PreviewCard / NavigationMenu / Toast…)在 X.Positioner 内部都通过 useXPortalContext() 读取一个 React context,而这个 context 只有 X.Portal 才能提供。没有 Portal,context 值就是 undefined,Positioner 主动 throw 而不是降级渲染。

这不是偶然,是库的有意设计:Positioner 的定位算法(useAnchorPositioning)依赖 Portal 提供的挂载容器信息来计算 offsetParent。没有 Portal 就没有合法的挂载上下文,组件拒绝运行。

坑:Portal 不是可选的——没有任何 portal=false 模式

翻遍文档和类型定义,X.Positioner 没有 disablePortal prop,X.Portal 也没有 enabled={false} 逃生口。

一旦你想省掉 Portal——无论是为了"inline 渲染"还是"局部容器约束"——都会直接在运行时崩溃,不会有任何警告或降级。 不存在"先跑起来再说"的容错空间。

正解

1. Portal 是固定写法,不能省

封装任意浮层组件的 Content 部分,结构永远是:

// Tooltip 示例,Popover / Menu / Select 同理
export function TooltipContent({ side = "top", sideOffset = 4, children }) {
  return (
    <Tooltip.Portal>
      <Tooltip.Positioner side={side} sideOffset={sideOffset}>
        <Tooltip.Popup>
          {children}
          <Tooltip.Arrow />
        </Tooltip.Popup>
      </Tooltip.Positioner>
    </Tooltip.Portal>
  );
}

Portal > Positioner > Popup(+Arrow) 三层缺一不可,不要暴露 portal={false} 之类的 prop——没有对应的实现。

2. 想"装进"局部容器:用 container prop 而非省 Portal

如果你的场景是把弹出层限制在某个父元素内(展示框、设备 Frame、截图区域),不是省掉 Portal,而是让 Portal 挂到那个元素上:

const stageRef = useRef<HTMLDivElement>(null);
 
// 展示框:position: relative 是关键,absolute 子元素的包含块
<div ref={stageRef} className="relative w-[400px] h-[300px] overflow-hidden">
  {/* 触发器在这里 */}
</div>
 
// Portal 挂到 stageRef,而非默认的 body
<Tooltip.Portal container={stageRef}>
  <Tooltip.Positioner>
    <Tooltip.Popup>内容</Tooltip.Popup>
  </Tooltip.Positioner>
</Tooltip.Portal>

注意:positionMethod 默认是 'absolute',弹层会以 stageRef 元素为 offsetParent 定位,基本能"留在框内"。但 container 第一次渲染时是 null(ref 尚未赋值),Portal 会先短暂挂到 body,然后 re-render 再切换——有一帧闪烁,代价不小,通常只在截图 / 展示等非生产场景才值得这么干。

3. 文档展示页:用"可交互触发器"而非"强制打开状态"

文档画廊里想展示 Tooltip "打开状态"的效果,最省心的做法不是 open={true} 强制展开——那样弹层会飘到 body 层,盖住页面其他区域,多个 Cell 同时渲染时还会互相叠压。

正确做法:每个 Cell 渲染一个正常的触发器(Button / Icon),读者 hover(Tooltip)或 click(Popover / Menu)才打开。截图场景用 CDP/Playwright 先触发交互再截图:

// Playwright 示例:hover 触发 Tooltip 后截图
await page.hover('[data-testid="tooltip-trigger"]');
await page.screenshot({ path: "tooltip-open.png", fullPage: false });

这样 ShowcaseSpec 类型零改动,展示和单测都不需要 open 受控状态的特殊处理。

正解:Portal 必写,受限场景用 container,展示页用交互触发
  • 常规封装Portal > Positioner > Popup,Portal 不可省。
  • 局部容器Portal container={ref} 替代省掉 Portal,配合父元素 position: relative
  • 文档展示:渲染触发器而非 open={true},截图时先交互再捕获。

附:几个容易踩的细节

  • Tooltip delay 在 Tooltip.Provider 上,不在 Tooltip.Root(rc.0 起)。想在测试 / 截图中立即打开,用 <Tooltip.Provider delay={0} closeDelay={0}>
  • 受控 open 不会自动绑 aria-describedby。trigger↔popup 的 a11y 关联是通过真实 hover/focus 交互建立的(或显式传 triggerId)。jsdom 里断言 trigger.aria-describedby 会失败——这是正确行为,用 data-side / data-open 等数据属性做结构断言。
  • PopoverRoot.modal 默认 false(无滚动锁定、无遮罩)。需要模态 Popover 才加 Popover.Backdrop,不要无脑复制 Dialog 的写法。

一句话外卖

Base UI 浮层的 Positioner 强制依赖 Portal 提供的 context,漏掉 Portal 直接崩——想限制弹层位置,用 Portal container={ref} 而不是省掉 Portal。