Huozhi

为什么在 react 中做一个跟随的 tooltip 这么复杂?

定位的难题

你可能听说国很多关于定位型组件种类、名词:popup, tooltip, popover, overlay...(后面将统称为 overlay)。这些都是定位型组件,当你需要触发显示它们的时候你需要把它们和你的 trigger (触发他们的组件)关联起来,根据两者大小一起调整位置。

让我们来把问题抽象化,想得更深入一点,我们可以将整体的 UI 组件封装为两个部分,一个是 trigger:当你点击或者 hover 的时候用来触发关联的 overlay;一个是你要弹出的 overlay,它可以是一个 tooltip,或者一个弹窗。

因为为使用的是 react, 所以我准备把这个定位的逻辑抽象为一个通用组件在 UI 之间公用,提供给各种类似 overlay 的组件。纯逻辑,没有任何样式或者 css 的东西。

接着我大概想出来比较基本的 API。首先它接受一个 single child (被 React.Children.only 处理过,单个独立、非数组的 react element)作为 children 穿给 OverlayTrigger,同时还有一个 placement string 作为位置标记 overlay 从哪里弹出来。events 代表触发事件,可以是 hover,可以是 focus 或者 click,也可以组合。️

<OverlayTrigger
  placement='top'
  events={['hover', 'focus']}
  overlay={<span>tooltip</span>}
>
  <button>鼠标 hover 这里来触发显示 tooltip</button>
</OverlayTrigger>

大致的效果可能如下,偷懒没给我的 overlay tooltip 做小箭头。

image

它是怎么工作的呢?

  1. 首先我们有一个 trigger 被 mount 到 DOM tree 上;
  2. 其次我们有一个 overlay 组件,当你 hover 或者 focus 和 trigger 交互的时候,它也会被 mount 到 DOM tree 上去;
  3. 我们通过 getBoundingClientRect 这个 DOM API 来获取两者的大小,以及位置,然后使用一个 position 函数,加上 placement 一起去定位 overlay 具体的位置,实在 trigger 上下左右的哪一方;

具体细节就不说了,只列一下伪代码

function position(overlay, trigger) {
  // 当大家都 mount 了后, 读取大小和位置
  overlaySize, overlayPos = getSizeAndPosition(overlay)
  triggerSize, triggerPos = getSizeAndPosition(trigger)

  // 把 overlay 重定位移动到 trigger 附近
  rePositionOverlay(...)
}

有时候可能还有一种情况是你需要和某个 root 节点再次相对定位,比如你 app 的画布可能不是从 document.body 开始的,可能是 body 内某一个可以滚动的 div,不过一般默认是 body,所以我们再提供一个可选的 root 节点。

然后你可以选择你喜欢的定位方式 relative,fixed,或者 transform 来调整距离。

听起来好像挺简单的,写了几十行 code 就能搞定的样子,接下来再和我的 app 集成一下...

发现 Hover 好像不能完全用 onMouseEnter 来实现 🤦

我们的 app 内只有一个交互很简单的 tooltip,在某些 button 上 hover 或者 focus 就显示,我在桌面浏览器上试了一下感觉还不错。惊喜来了,当我换到 surface 平板上,点击的瞬间就发现问题了...tooltip 这时候也会出来且不消失。

  • 我们可以把 touch 设备上的 tooltip 全部 disable 吗?
  • 不完全行,如果你使用 navigator.maxTouchPoints 来检测触摸屏的话,edge 有 bug 会认为普通桌面的浏览器也是 touch 屏。
  • 好的,Edge,很可以...

让我们试试如何用浏览器事件来解决它,回到了我之前博客的话题 Universal Scrubbing Experience on Web,简而言之就是不能只用 mouseentermouseleave 来检测 hover,在跨浏览器的情况下这是一个坑。

使用 PointerEvent 在支持它的浏览器上(如 chrome,edge),然后使用 MouseEvent 在不支持 PointerEvent 的浏览器上.

检测的逻辑大致是这样的

// if `hover` is specified in trigger `events`
onMouseEnter() {
  // 匹配桌面 safari 的行为
  // mobile safari 在你 touch 的时候不会触发任何 mouse event
  if (!window.PointerEvent && !window.TouchEvent) {
    this.showOverlay()
  }
  // ...
}

onPointerEnter(event) {
  // 匹配支持 PointerEvent 的 desktop/mobile 浏览器
  if (event.pointerType === 'mouse') {
    this.showOverlay()
  }
}

看起来我们似乎已经解决了? 但很快我又发现了问题...

Trigger 和 Overlay 的大小都会变化

如果你只是 hover 一下显示一个静态的 tooltip 似乎是没有问题的,但 trigger 的大小可能会变,比如你点击了之后变成了别的宽高。然后 overlay 也可能有类似问题。

所以不止 mount 的时候,DOM update 的时候也要重定位。因为 react ref 可能并没有发生改变,所以我们只能用别的办法监控一下大小变化。问题来了,怎么知道到底 trigger 里面什么地方发生变化了呢?当然我们也有一种方法,是把重定位的函数传下去,但是监测变化真的太难了,每次 componentDidUpdate 的时候再做又会非常麻烦。而且还要传其他 props 的时候过滤掉它。

react-bootstrap <OverlayTrigger />

让我们看看社区里比较火的组件库是怎么解决这个问题的,如 react-bootstrap, ant-design。 我发现 react-bootstrap 是把一个叫 scheduleUpdate 的 prop 传给了 trigger,让 trigger 自己决定什么时候强行重定位一次。这固然方便,但还是有点麻烦,我们很难知道是哪些 prop 变化引起了 DOM 大小改变,甚至可能是 children,传给 trigger DOM 上过滤掉,不得不说过滤真的很繁琐。

ant-design <align />

Ant design 内的 align 组件用了 ResizeObserver 去检测 trigger size 变化。但遗憾的是 ResizeObserver 的再 Safari 上的支持十分感人,当我在写这篇文章的时候 https://caniuse.com/#feat=resizeobserver 显示 ResizeObserver 只在最新的 Safari 13 和 tech preview 的版本里支持,mobile safari 不支持。Ant design 引入了一个 polyfill 来兼容 ResizeObserver,算是牺牲了 bundle 大小但获得了便利。

但是我很关心打包后的文件大小 📦如果 app 里每个包都足够小,加载性能也会有所提升。

最后我想到了一个办法,在支持 ResizeObserver 的浏览器使用它,如果不支持就 fallback 到 MutationObserver 上,当 mutation 触发的时候,我们比较上次缓存的宽高再决定是不是要重新定位。兼容一下后我们得到了如下东西。

function createObserver(node, onMeasure) {
  if (window.ResizeObserver) {
    const ro = new ResizeObserver(() => onMeasure())
    ro.observe(node)
    return ro
  } else {
    const cachedSize = {width: 0, height: 0}
    function handleMutate() {
      const {width, height} = node.getBoundingClientRect()
      if (cachedSize.width !== width || cachedSize.height !== height) {
        cachedSize.width = width
        cachedSize.height = height
        onMeasure()
      }
    }
    const mob = new MutationObserver(handleMutate)
    mob.observe(node, mutationObserverOption)
    return mob
  }
}

现在我们终于使得 API 依旧维持了简洁,实现也维持得足够小巧,我想最麻烦的事情应该算解决了 :) s

Repo & Demo

源码在 github 上 https://github.com/huozhi/react-overlay-trigger 或者你可以直接通过 npm 来使用它 npm install --save react-overlay-trigger.

我也提供了一个 playground,在这你可以尝试它的各种用法 https://huozhi.github.io/react-overlay-trigger/

bundlephobia 我们可以看到经过压缩和 gzip 后只有 2kb,它可以适用于很多的场景,各种不同样式的 overlay。

希望你会喜欢,欢迎提 PR 和 issue~

© 2020, Huozhi