实现 React 组件系列 2 —— Tooltip

上篇文章(实现 React 组件系列 1 —— Modal)中,我们简单介绍了浮层组件,并基于 Portal 实现了 Modal 组件。在这篇文章中,我们会实现另一种浮层组件 Tooltip。 和 Modal 组件一样,Tooltip 也需要基于 Portal 组件来创建。不同的是,Tooltip 需要基于触发它的元素进行定位。 然而,通过 Portal 将 Tooltip 的弹出层传送到应用根节点之后,Tooltip 的「触发器」和「内容」在 DOM 中就不再是父子节点关系了(如下所示),因此无法通过 CSS 相对定位来定位 Tooltip。

image.png

那么,如何定位 Tooltip 就成了我们亟待解决的问题。

元素定位

对于 Tooltip 这类浮层组件来说,既然无法使用「相对定位」,那就只能采用「绝对定位」了。我们可以通过定义 position: absolute ,并设置 top、left、right 和 bottom 的值,来对一个元素进行绝对定位。

getBoundingClientRect

如何给元素设置正确的 top 和 left 值呢?这里需要用到一个关键的 API: Element.getBoundingClientRect()。它的返回值是一个 ClientRect 对象,其接口定义如下:

interface ClientRect {
  x: number;
  y: number;
  width: number;
  height: number;
  top: number;
  right: number;
  bottom: number;
  left: number;
}
复制代码

其中包含了元素自身的宽高,以及各边界相对于「视口左上角」的位置,如下图所示:

值得注意的是,ClientRect 中的 right 和 bottom 与 CSS 中的定义不同。因此,请不要直接将它们赋值给 CSS 的 right 和 bottom 属性。当然,由于 ClientRect 的 top、left、width 和 height 已经满足了所有计算需求,我们也无须再使用 right 和 bottom。

useClientRect

既然 Element.getBoundingClientRect() 可以获取一个元素的 ClientRect,那是不是可以直接使用它呢?其实不是,因为还有一些共用逻辑需要抽象,比如只有当组件 didMount 时,才能获取到元素的 ClientRect。

const useClientRect = (ele: RefObject<HTMLElement | null>) => {
  const [clientRect, setClientRect] = useState<ClientRect | null>(null);

  // 更新元素的 ClientRect,使用 useMemo 确保只创建一次 updateClientRect 方法
  const updateClientRect = useMemo(() => {
    return () => {
      setClientRect(ele.current!.getBoundingClientRect());
    };
  }, []);

  // 只有当 React 组件 didMount 时,才能取到元素的 ClientRect,所以这里要使用 useLayoutEffect
  useLayoutEffect(() => {
    if (ele.current) {
      updateClientRect();
    }
  }, []);

  return [clientRect, updateClientRect] as [typeof clientRect, typeof updateClientRect];
};
复制代码

计算相对位置

前面已经提到过,Tooltip 需要基于「触发它的元素」进行定位,这就意味着要计算 Tooltip 和其触发元素的相对位置。

举个例子,有两个元素 A 和 B,B 要基于 A 进行定位,那么有很多种方式,比如左上对齐,左下对齐,居中上对齐,居中下对齐等等。 如下图:

根据 A 的 ClientRect,我们很容易得到 A 的 top,left,width 和 height 的值。那么如何利用这些值,计算出 B 的 top 和 left 呢?

以下两点可以作为参考:

  1. 确定参照物,画出参考线

要和一个物体对齐,就应该以这个物体作为参照物。 确定参照物之后,根据参照物画出参考线。举个例子,在图一中,B 要和 A 的左下边界对齐,那么就应该以 A 的左边界和下边界作为参考线。

  1. 找出规律,得出公式

画出参考线之后,我们发现: **参考线距离视图边界的位置是恒定的。**根据这个规律,很容易计算出 B 的坐标。举个例子,在图二中,B 要和 A 水平居中对齐。在 A 元素 1/2 宽度的位置,画出参考线,由于视图区域左边界到参考线的距离是恒定的,可以得出:

A.left + A.width/2 =  B.left + B.width/2 
B.left  = A.left + A.width/2 - B.width/2
复制代码

我们可以创建一个 hook usePlacement,用来计算 Tooltip 的坐标。这里给大家留了一个 TODO,请根据上面讲的内容去实现它。

interface IUsePlacementProps {
    triggerRect: ClientRect | null;
    contentRect: ClientRect | null;
    placement: Placement;
}

interface IPosition {
  left: number;
  top: number;
}

// TODO: 请尝试实现 getPosition 方法

const usePlacement = ({ triggerRect, contentRect, placement }: IUsePlacementProps): IPosition => {
  return getPosition(triggerRect, contentRect, placement);
};
复制代码

获取 Tooltip 的坐标之后,接下来自然是给它定义样式了 —— 定义 position: absolute ,并设置正确的 top 和 left 值。

interface IPositionProps {
  triggerRef: RefObject<HTMLElement | null>;
  children: ReactNode;
  placement?: Placement;
}

const Position = ({ triggerRef, placement = Placement.bottomRight, children }: IPositionProps) => {
  const contentEl = useRef<HTMLDivElement>(null);
  const triggerRect = useClientRect(triggerRef);
  const contentRect = useClientRect(contentEl, [], false);
  // 根据触发元素和内容元素的 ClientRect,以及摆放位置,计算出内容元素的坐标
  const position = usePlacement({ triggerRect, contentRect, placement });

  return (
    <div style={{ position: "absolute", left: position.left, top: position.top }} ref={contentEl}>
      {children}
    </div>
  );
};
复制代码

实现 Tooltip

首先,分析 Tooltip 组件的需求,如下:

  1. 包含 trigger 和 content 两个部分。content 用于展示 Tooltip 的内容,trigger 用于触发它的显示和隐藏。

  2. 当鼠标移入 trigger 元素时,显示 content,移出 trigger 元素时,隐藏 content。

  3. 可以使用 placement 控制 content 相对于 trigger 的位置。比如上对齐,居中对齐等。

<Tooltip placement={Placement.bottomCenter} content={<TooltipContent placement={"center"} />}>
  <Button>On Bottom Center</Button>
</Tooltip>
复制代码

接下来,我们可以根据现有的组件和 hooks,来实现 Tooltip。需求 1和 需求 3 分别使用 Portal 和 usePlacement 来完成,那 Tooltip 只需要实现需求 2 即可,也就是绑定事件。

interface ITooltipsProps {
  children: ReactElement<any>;
  content?: ReactNode;
  placement?: Placement;
}

export function Tooltip(props: ITooltipsProps) {
  const { content, children, placement } = props;
  const [isOpen, show, hide] = useToggle();
  const triggerEl = useRef<HTMLElement>(null);
  return (
    <>
      {React.cloneElement(children, {
        ref: triggerEl,
        onMouseEnter: show,
        onMouseLeave: hide,
      })}
      {isOpen && (
        <BasicPortal>
          <Position triggerRef={triggerEl} placement={placement}>
            {content}
          </Position>
        </BasicPortal>
      )}
    </>
  );
}
复制代码

到这里,一个最基本的 Tooltip 就完成了。

进一步优化

我们已经知道,ClientRect 是相对于视图区域左上角(0,0)计算出来的,所以它不是一个固定不变的值。页面滚动或者窗口大小改变,都可能使它的值发生变化。但是 Tooltip 有点特殊,它会「自动消失」,因此即便不更新 ClientRect,也不会造成很大的影响。但为了让组件更健壮,满足更多的业务场景,我们还是可以进一步优化它。

Note:resize 不一定会导致 ClientRect 发生变化,只有重排导致元素相对于视口的位置发生变化时,ClientRect 才会发生变化。

因为 scroll 事件不冒泡,所以我们必须给 trigger 元素的每一个滚动父节点,绑定 scroll 事件。

const useScroll = (ele: RefObject<HTMLElement | null>, onScroll: (evt: Event) => void) => {
  useLayoutEffect(() => {
    const handleScroll = (evt: Event) => {
      onScroll(evt);
    };

    if (ele.current) {
      const parentElements = getScrollParents(ele.current);

      parentElements.forEach(parentElement => {
        parentElement.addEventListener("scroll", handleScroll);
      });

      return function cleanup() {
        parentElements.forEach(parentElement => {
          parentElement.removeEventListener("scroll", handleScroll);
        });
      };
    }
  }, []);
};
复制代码

要获取一个元素的所有滚动父节点,一个思路是,递归遍历它所有的父节点,找到其 css 属性中包含 auto , scroll 或者 overlay 的元素,直到根节点结束,返回所有符合条件的节点。由于代码较多,就不贴出来了,大家可以在源码库找到相应代码。

滚轮事件 wheel 支持冒泡,所以只需要在最外层 document.body 绑定即可。但是,这里并没有选择 wheel 事件,是因为它的兼容性没有 scroll 事件好。

更新 Position 组件:

const Position = ({ triggerRef, placement = Placement.bottomRight, children }: IPositionProps) => {
  // ...

  // 给 trigger 元素和它的滚动父节点绑定 scroll 事件,更新它的 ClientRect
  useScroll(triggerRef, updateTriggerRect);
  // 监听 resize 事件,并更新 trigger 元素的 ClientRect
  useResize(updateTriggerRect);

  // ...
};
复制代码

遇到的坑

  • Scroll 和 Resize 事件只需要给 trigger 元素绑定即可,不需要给 content 元素绑定。

  • 滚动优化,使用 requestAnimationFrame,willChange 开启 GPU 渲染。

  • 如果 Tooltip 的触发元素,所在的滚动容器不是 document.body,会出现抖动的问题。

  • React.cloneElement ref 问题

小结

Popover 和 Tooltip 非常类似。通过组合这些基础组件,相信实现起来也不是什么难事。

猜你喜欢

转载自juejin.im/post/7034870168295571486