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 就没有合法的挂载上下文,组件拒绝运行。
翻遍文档和类型定义,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 > 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。