关于uniapp中 跨组件实现toolTip
看标题,跨组件,如果是组件内部很容易。总体借鉴的思路是开源的antd 或者饿了么架构,他们在做hover的时候 最终的hover体在root根路径的外层,比如你有一个文案需要被hover,他不是在文案内部嵌套一个hover体,而是直接append到root外层了。这种方案很好的解决了z-index层级问题。 我为何要做跨组件方案,我的bug原有是在unipp项目中,我写了一个toolTip组件,相比大家都知道一般都是hover体 append到被hover的目标上,但就出现了一个致命的bug。我有一个table,他是左侧固定的,用的是position sticky方案,不管我设置hover体的层级多高,我的td列都会遮盖hover体。我整整研究了快1个多小时才慢慢去研究开源方案的思路。 行了,ai帮我写代码,我提供思路,写好代码我校验没问题后,我又让ai给我出一篇完整的思路,以便我后续总结和回归。 **核心挑战 ┌─────────────────────────────────────────────────────┐ 一句话概括: 触发器负责"写数据",Portal 负责"读数据渲染",Store 是两者之间的唯一桥梁。 三层核心设计 Store 是整个方案的核心,它让两个在 DOM 树上完全独立的组件能够通信,避免了 props 层层透传或 EventBus 的混乱。 点击触发 这样用户看到的是「浮层直接出现在正确位置」,完全无闪烁。 同时还处理了边界溢出修正——当浮层水平方向会超出屏幕时,动态调整 left 和 transform: 两处都放 <tooltipHover /> 是关键: s-tooltip 内的用于检测(!!slots.default) 生命周期清理
uni-app 小程序环境不支持 Teleport,也没有 DOM API,导致经典的「将浮层挂载到 body」方案完全失效。本方案通过 全局响应式 Store + Portal 组件 的架构解决这个问题。**
│ Page │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ s-tooltip │ │ s-tooltip-portal │ │
│ │ (触发器) │ │ (渲染层,放页面底部) │ │
│ │ │ Store │ │ │
│ │ 点击 ──────────────► │ 读取 Store 数据 │ │
│ │ 写入位置/内容│ │ 渲染浮层 + 蒙层 │ │
│ └──────────────┘ └──────────────────────┘ │
│ │
│ tooltipStore (reactive 全局单例) │
└─────────────────────────────────────────────────────┘// tooltipStore.ts
export const tooltipStore = reactive<TooltipState>({
visible: false,
triggerRect: null, // trigger 的位置信息
hasPosition: false, // 防闪烁标志
activeId: null, // 当前激活的实例 ID(多实例互斥)
useCustomSlot: false,// 是否使用自定义内容
toolTipHover: [], // 字符串内容模式
// ...布局参数
})
浮层的位置依赖自身的尺寸(需要先渲染才能测量),这产生了「先有鸡还是先有蛋」的问题。方案用两个标志位优雅解决:
│
▼
visible = true ← 第一步:渲染浮层,但 opacity: 0(不可见)
hasPosition = false
│
▼
Portal 监听到 visible ← nextTick 后测量浮层尺寸
│
▼
计算最优位置
│
▼
hasPosition = true ← 第二步:位置写入完成,opacity: 1(显现)// s-tooltip-portal.vue
const boxVisibleStyle = computed(() => ({
opacity: tooltipStore.hasPosition ? 1 : 0, // 关键:位置就绪前透明
}))
watch(() => tooltipStore.visible, val => {
if (val) {
nextTick(() => updatePosition()) // 等 DOM 渲染后再测量
}
})
Portal 获取到 trigger 坐标和自身尺寸后,计算四个方向的可用空间,自动选择最优方位:const pickPlacement = (): Placement => {
if (want !== 'auto') {
// 优先尊重用户指定方向,空间不足时降级
if (want === 'top' && canShowTop) return 'top'
// ...
}
// auto 模式:按 bottom > top > right > left 优先级
if (canShowBottom) return 'bottom'
if (canShowTop) return 'top'
if (canShowRight) return 'right'
return 'left'
}// 防止浮层超出左右边界
if (left < minLeft) {
left = horizontalMargin
translateX = '0' // 左对齐
} else if (left > maxLeft) {
left = windowWidth - horizontalMargin
translateX = '-100%' // 右对齐
}
页面可能有多个 s-tooltip,需要保证同一时刻只有一个处于激活状态:// s-tooltip.vue
const uid = instance?.uid ?? Math.random() // Vue 实例唯一 ID
const handleTriggerClick = () => {
if (isActive.value) {
closeTooltip() // 再次点击自己 → 关闭
return
}
openTooltip({ id: String(uid), ... }) // 写入 activeId
}
// Store 中新的 openTooltip 会覆盖 activeId
// 其他实例的 isActive 自动变为 false
原始设计只支持字符串数组内容。由于 Portal 和 Trigger 是两个独立组件,VNode 无法跨组件传递,通过以下方式实现自定义内容:s-tooltip (default slot 传入 <tooltipHover />)
│ 检测到 slots.default 存在
│ openTooltip({ useCustomSlot: true })
▼
tooltipStore.useCustomSlot = true
│
▼
s-tooltip-portal 渲染时判断:
├── useCustomSlot = true → <slot /> (渲染 <tooltipHover />)
└── useCustomSlot = false → 字符串列表
s-tooltip-portal 内的用于实际渲染(portal slot 内容)
多处注册了关闭逻辑,确保状态不泄漏:// 组件卸载时关闭
onUnmounted(() => { if (isActive.value) closeTooltip() })
// 页面滚动时关闭(触发器 + Portal 双重保险)
onPageScroll(() => { if (isActive.value) closeTooltip() })
// 点击蒙层关闭
const onMaskClick = () => closeTooltip()