看标题,跨组件,如果是组件内部很容易。总体借鉴的思路是开源的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给我出一篇完整的思路,以便我后续总结和回归。

**核心挑战
uni-app 小程序环境不支持 Teleport,也没有 DOM API,导致经典的「将浮层挂载到 body」方案完全失效。本方案通过 全局响应式 Store + Portal 组件 的架构解决这个问题。**

┌─────────────────────────────────────────────────────┐
│ Page │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ s-tooltip │ │ s-tooltip-portal │ │
│ │ (触发器) │ │ (渲染层,放页面底部) │ │
│ │ │ Store │ │ │
│ │ 点击 ──────────────► │ 读取 Store 数据 │ │
│ │ 写入位置/内容│ │ 渲染浮层 + 蒙层 │ │
│ └──────────────┘ └──────────────────────┘ │
│ │
│ tooltipStore (reactive 全局单例) │
└─────────────────────────────────────────────────────┘

一句话概括: 触发器负责"写数据",Portal 负责"读数据渲染",Store 是两者之间的唯一桥梁。

三层核心设计

  1. 全局 Store —— 解耦触发与渲染
// tooltipStore.ts
export const tooltipStore = reactive<TooltipState>({
  visible: false,
  triggerRect: null,   // trigger 的位置信息
  hasPosition: false,  // 防闪烁标志
  activeId: null,      // 当前激活的实例 ID(多实例互斥)
  useCustomSlot: false,// 是否使用自定义内容
  toolTipHover: [],    // 字符串内容模式
  // ...布局参数
})

Store 是整个方案的核心,它让两个在 DOM 树上完全独立的组件能够通信,避免了 props 层层透传或 EventBus 的混乱。

  1. 两步渲染 —— 解决防闪烁问题
    浮层的位置依赖自身的尺寸(需要先渲染才能测量),这产生了「先有鸡还是先有蛋」的问题。方案用两个标志位优雅解决:

点击触发


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 渲染后再测量
  }
})

这样用户看到的是「浮层直接出现在正确位置」,完全无闪烁。

  1. 智能位置计算 —— auto placement
    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'
}

同时还处理了边界溢出修正——当浮层水平方向会超出屏幕时,动态调整 left 和 transform:

// 防止浮层超出左右边界
if (left < minLeft) {
  left = horizontalMargin
  translateX = '0'          // 左对齐
} else if (left > maxLeft) {
  left = windowWidth - horizontalMargin
  translateX = '-100%'      // 右对齐
}
  1. 多实例互斥 —— UID 机制
    页面可能有多个 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
  1. 自定义内容 Slot —— 本次修复的关键
    原始设计只支持字符串数组内容。由于 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 → 字符串列表

两处都放 <tooltipHover /> 是关键:

s-tooltip 内的用于检测(!!slots.default)
s-tooltip-portal 内的用于实际渲染(portal slot 内容)

生命周期清理
多处注册了关闭逻辑,确保状态不泄漏:

// 组件卸载时关闭
onUnmounted(() => { if (isActive.value) closeTooltip() })

// 页面滚动时关闭(触发器 + Portal 双重保险)
onPageScroll(() => { if (isActive.value) closeTooltip() })

// 点击蒙层关闭
const onMaskClick = () => closeTooltip()

image.png

标签: none

添加新评论