上篇文章(实现 React 组件系列 1 —— Modal)中,我们简单介绍了浮层组件,并基于 Portal 实现了 Modal 组件。在这篇文章中,我们会实现另一种浮层组件 Tooltip。 和 Modal 组件一样,Tooltip 也需要基于 Portal 组件来创建。不同的是,Tooltip 需要基于触发它的元素进行定位。 然而,通过 Portal 将 Tooltip 的弹出层传送到应用根节点之后,Tooltip 的「触发器」和「内容」在 DOM 中就不再是父子节点关系了(如下所示),因此无法通过 CSS 相对定位来定位 Tooltip。
那么,如何定位 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 呢?
以下两点可以作为参考:
- 确定参照物,画出参考线
要和一个物体对齐,就应该以这个物体作为参照物。 确定参照物之后,根据参照物画出参考线。举个例子,在图一中,B 要和 A 的左下边界对齐,那么就应该以 A 的左边界和下边界作为参考线。
- 找出规律,得出公式
画出参考线之后,我们发现: **参考线距离视图边界的位置是恒定的。**根据这个规律,很容易计算出 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 组件的需求,如下:
-
包含 trigger 和 content 两个部分。content 用于展示 Tooltip 的内容,trigger 用于触发它的显示和隐藏。
-
当鼠标移入 trigger 元素时,显示 content,移出 trigger 元素时,隐藏 content。
-
可以使用 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 非常类似。通过组合这些基础组件,相信实现起来也不是什么难事。