【Popover 弹出框】:特殊的滚动逻辑
Popover 一般来说是一个组件库中比较基础的组件,因为其他组件有可能需要使用 Popover 提供的能力,例如 Select 下拉选择、Cascader 级联选择器,类似于这种需要弹出隐藏部分内容的组件。
所以,实现一个 Popover 是非常有必要的。本文将会详细介绍,如何使用 React 实现一个 Popover 组件,包括:如何将 Popover 挂载到页面上、如何让 Popover 不影响被其包裹的子组件以及衍生出的其他问题的解决。
需求分析
功能
Popover 弹出框组件常用于展示额外的、隐藏的内容,与对话框不同的是,弹出框往往需要与指定的元素绑定才能触发,例如上图中的 Button 按钮;同时,Popover 还需要能接受用户指定的任意内容,例如当悬浮在一个 Button 上时,激活弹出框,显示一段自定义的文字;最后,Popover 的激活位置和激活方式也需要能定制。
Popover 的使用方式如下:
const content = 'this is a popover';
<Popover content={content} position="top" trigger="hover">
<Button>这是一个弹出框</Button>Ï
</Popover>
这样一看 Popover 并不是很复杂嘛🧐
样式
Popover 本质上是一个 div
,其中包裹了自定义内容,绝大多数 UI 库中的 Popover 都长得像聊天气泡,例如 Element UI 和 Ant Design
唯一的不同的就是比单纯一个 div
矩形多出了一个表示指向小三角,不过这个实现也非常简单,后面会说到。
还需要考虑的就是 Popover 的宽高了,如果不定义宽高,遇到超长的内容时可能会溢出整个屏幕,所以这里设置一个max-width
就行了。
问题归纳
如何不影响子组件的样式
上面说过,Popover 需要目标组件包裹起来,然后弹出在目标组件的附近位置,最开始想到的方法是通过 Popover 接受目标组件作为childNode
,然后直接将其包裹在div
中:
function Popover(props){
const {children} = props;
return (
<div class="wdu-popover">
{children}
</div>
)
}
不过我很快发现这样行不通,因为外层的div
不可避免的会影响到目标元素的样式,假如目标元素是position: absolute; left: 20px; top; 300px;
,加上了这个外层 div
后,就不能保证目标元素的定位还能生效。
当然了,也可以获取目标元素的 DOM ,然后读取它的样式,直接移植到外层容器上,但是这种方法太笨了,而且在 React 中,通过ReactChildren
并不能直接获取到子节点的 DOM ,所以,这个方法被放弃了。
那么为了不影响目标元素,就只能将 Popover 作为一个 HOC 组件,封装好内部逻辑之后,原样返回目标元素子节点,不对其包裹任何元素;将 Popover 元素本身渲染到body
下即可。
function Popover(props){
const {children} = props;
// 封装内部逻辑...
return children;
}
如何获取到子组件
将弹出框和目标元素分开渲染之后会遇到一个问题:在 Popover 组件中怎样拿到目标元素节点?因为,之后在激活弹出框时需要先获取目标元素的位置,然后计算弹出框的位置。
前面说过在 Popover 中是无法直接拿到children
节点的 DOM 对象的,所以,需要一个方法,在 Popover 组件挂载完成后获取到目标元素;我们可以在useEffect
中执行这个方法,首先需要传递一个 css class 类名给目标元素,然后通过这个类名来查询 DOM 节点即可。
如何跟随子组件的滚动而滚动
因为弹出框和目标元素分开渲染,目标元素仍然处于它原来在页面中的位置,但是 Popover 则挂在body
节点下,跟目标元素并不是同一个父容器,所以当遇到目标元素发生移动时,就会出现下面的现象:
如何让已激活的 Popover 元素跟随着目标元素的移动而移动呢?这个问题稍有点复杂,首先是如何确定目标元素发生滚动时所在的容器,其次是如何计算滚动距离保证 Popover 和目标元素的相对位置不发生变化。这个后面会详细说到。
弹出框如何定位
弹出框的定位一般只有 4 个位置,即上下左右,然后为了美观,需要相对于目标元素保持居中;这个实现上不难,基本思路是:首先获取到目标元素的绝对位置(相对于 view-port)的位置,然后把 Popover 设置为相同的位置;然后,再根据目标元素的宽高,以及 Popover 容器的宽高来计算,得出与目标元素的相对位置,从而保持居中。
开发实现
好了,经过了前面的问题分析与总结,我们已经有了清晰的思路了,下面,就来一步步的实现出 Popover 组件😎。(使用 typescript + jsx 来编写)
Popover 容器
首先,我们需要明确 Popover 可以接受的 props
值,如下:
position
指定弹出位置active
指定是否激活trigger
指定激活方式content
指定 Popover 内容
用 typescript 声明接口propsPopover
type position = 'left' | 'bottom' | 'right' | 'top';
interface propsPopover {
position?: position;
className?: string; // 自定义类名
children: ReactElement; // 目标元素
trigger?: 'hover' | 'click';
active?: boolean;
content: ReactNode;
}
然后,创建一个函数组件Popover
const T = 'wdu-popover';
function Popover(props: propsPopover) {
const {
position = 'left',
className,
children,
trigger = 'hover',
active = false,
content,
} = props;
const [visible, setVisible] = useState(active);
useEffect(() => {
if (visible) {
// 显示
} else {
// 隐藏
}
}, [visible]);
return children;
}
上面就是最基础的组件结构,状态visible
用来控制 Popover 的可见性。
接下来我们需要完成的功能是接受props.content
然后将其作为 Popover 组件容器的内容,之后再将容器挂在到 body
节点上,同时还需要传递一个标识给目标元素,这样才能获取到目标元素的 DOM 对象。
创建一个方法createPopover
,通过调用React.createPortal()
方法将容器挂载到body
上
// 容器 DOM 节点
const refPopover = useRef<HTMLDivElement>(null);
const createPopoverContent = () => {
const popover = (
<div ref={refPopover}>
{content}
</div>
);
if (content) {
return createPortal(popover, document.body);
} else {
console.warn('Popover content is empty');
}
};
包裹目标元素
首先,我们规定 Popover 只能接受一个目标元素,这是因为如果接受多个元素作为触发目标,那么在计算弹出位置的时候就会发生歧义——到底以哪个目标元素为准,这也会增加更多复杂性,所以,Popover 只能由一个目标元素触发;同时,还需要给出一个唯一标识,方便后面查找。
// popover 只能接受一个目标元素
if (Children.count(children) > 1) {
throw new Error('Popover can only accept one child');
}
// 目标元素唯一标识
const randomId = uuid(8);
const popoverId = `wdu-popover-${randomId}`;
return (
<>
{cloneElement(children, { className: popoverId })}
{createPopoverContent()}
</>
);
因为并不需要任何额外元素,所以我们使用 React Fragment
来包裹children
子节点,它会原样返回其包裹的子节点,而不需要在最外层提供一个用于包裹的html
标签。经过这一步,得到的结果如下:
目标元素仍然处于它本来的位置,不会因为被Popover
包裹而改变,只是多了一个独一无二的类名标识。
Popover 容器则直接在body
节点下
接下来,编写方法findPopoverTarget
来查找目标元素,获取其 DOM 节点
// 目标元素 DOM 节点
const [refPopoverTarget, setRefPopoverTarget] = useState<Element>();
// 获取目标元素的 DOM 节点
const findPopoverTarget = () => {
let isFind = false;
const findTarget = () => {
const target = document.querySelector('.' + popoverId);
if (target) {
isFind = true;
setRefPopoverTarget(target);
return;
} else {
window.requestAnimationFrame(findTarget);
}
};
window.requestAnimationFrame(findTarget);
};
useEffect(() => {
findPopoverTarget();
}, []);
可以看到,findPopoverTarget
方法通过requestAnimationFrame
启动了一个轮询,然后通过固定的频率不断的查询 DOM 节点;这个轮询是以递归调用的方式执行的,有一个标志isFind
,如果获取到了目标元素 DOM 节点,将其赋值为true
,这也是递归调用的终止条件。
为什么要这么做呢?因为 Popover 组件初始化时,作为子组件的目标元素组件,还没有初始化成功,所以如果直接在 Popover 组件挂载后去获取目标元素是拿不到的,因为目标元素不一定渲染到了真实 DOM 中,所以,我们就需要一个方法,在父组件初始化之后不断的查询目标元素,直到成功。
通常,组件渲染到真实 DOM 的用时也并不长,所以这个轮询并不会执行很多次,其性能损耗可以忽略不计。
弹出框激活
前面说过,弹出框组件可以接受参数position
作为激活方位,同时,也接受了一个参数trigger
表示触发方式,有鼠标点击触发,鼠标悬浮触发,下面就来实现这一块逻辑:
// 切换显示
const togglePopover = useCallback(() => setVisible((prev) => !prev));
// 显示
const openPopover = useCallback(() => setVisible(true));
// 隐藏
const closePopover = useCallback(() => setVisible(false));
// 在目标元素上添加相应类型的事件监听
const handlePopoverActive = () => {
if (refPopoverTarget) {
if (trigger === 'click') {
refPopoverTarget.addEventListener('click', togglePopover);
} else if (trigger === 'hover') {
refPopoverTarget.addEventListener('mouseenter', openPopover);
refPopoverTarget.addEventListener('mouseleave', closePopover);
}
}
};
useEffect(() => {
handlePopoverActive();
}, [refPopoverTarget]);
useEffect(() => {
createPopoverContent();
// 组件销毁时,记的清除事件绑定
return () => {
const events = [ ['click', togglePopover],Ï
['mouseenter', openPopover],
['mouseleave', closePopover],
];
events.forEach(([type, listener]) => {
if (refPopoverTarget) {
refPopoverTarget.removeEventListener(type, listener);
}
});
};
}, []);
这里需要提一下的是useEffect
可以返回一个函数,在这个函数中可以封装一些解除事件绑定之类的操作,在组件卸载时这个函数就会被执行。
弹出框定位
弹出框的定位需要结合当前目标元素的位置、尺寸以及弹出框元素的尺寸来计算,最终要实现的效果就是 Popover 弹出框定位在目标元素的附近,同时还要相对于目标元素保持居中。由于这一块逻辑相对来说比较独立,所以我们直接封装一个自定义 Hooks usePopoverPosition
来管理这块逻辑:
function usePopoverPosition(
popoverTarget: Element | undefined,
popover: MutableRefObject<HTMLDivElement | null>,
visible: boolean,
position: position,
) {
const popoverPosition = useRef([0, 0]);
const calcStaticPos = () => {
// 计算位置
};
useLayoutEffect(() => {
calcStaticPos();
}, [popoverTarget, visible]);
}
函数usePopoverposition
接受四个参数:
popoverTarget
:目标元素的 DOM 节点对象popover
:Popover 容器的 DOM 节点对象,是一个ref
对象visible
:Popover 组件的可见性position
:Popover 组件的入参,表示激活的位置
每次setVisible(true)
时,就执行函数calcStaticPos
来计算位置,得到位置参数之后,才能切换 CSS 类名将原本隐藏的 Popover 显示出来。
好了,下面是calcStaticPos
的具体实现,看上去一大坨,很长很长,但其实很简单,就是根据position
的值做条件判断,然后计算相对位置:
const calcStaticPos = () => {
if (popoverTarget && visible) {
// 目标元素的位置、尺寸
const targetPos = popoverTarget.getBoundingClientRect();
const {
x: targetX,
y: targetY,
width: targetW,
height: targetH,
} = targetPos;
// Popover 容器的尺寸
let sourceW = 0,
sourceH = 0;
if (popover.current) {
const { width, height } =
popover.current.getBoundingClientRect();
[sourceW, sourceH] = [width, height];
}
// 计算 Popover 容器的位置
let left = 0,top = 0;
if (position === 'left') {
left = targetX - sourceW - 12;
top = targetY + (targetH - sourceH) / 2;
} else if (position === 'right') {
left = targetX + targetW + 12;
top = targetY + (targetH - sourceH) / 2;
} else if (position === 'top') {
left = targetX + (targetW - sourceW) / 2;
top = targetY - sourceH - 12;
} else if (position === 'bottom') {
left = targetX + (targetW - sourceW) / 2;
top = targetY + targetH + 12;
}
// 设置 Popover 容器的位置
(popover.current as HTMLElement).style.left = `${left}px`;
(popover.current as HTMLElement).style.top = `${top}px`;
}
};
获取元素的尺寸、相对于浏览器视口位置可以用Element.getBoundingClientRect()
这个方法,详情参见MDN
滚动跟随
接下来就到了本文最重点的环节——让毫不相干的两个元素实现同步滚动,我花了三个多小时才想出来这个方法😢
首先,我们来回顾一下要解决什么问题:
想必你一眼就明白了吧,目标元素和 Popover 容器元素这两个元素在 DOM 结构上毫无关联,所以当你滚动页面时,目标元素会随着其父容器的滚动而移动,但是 Popover 容器怎么办呢?
我们先来分解这个问题,要实现让 Popover 容器元素跟随目标元素滚动,需要以下步骤:
- 获取到正在触发滚动事件的元素(滚动容器),而且这个元素必须是目标元素的父级元素
- 通过滚动容器,获取到目标元素的滚动距离
scrollTop
和scrollLeft
- 将滚动距离同步给 Popover 容器元素,实现最终的跟随滚动
获取滚动容器
首先,我们来获取滚动容器,要知道,目标元素所在的滚动容器并不只有一个,也有可能是body -> div -> targetElement
这种一层套一层的结构;同时目标元素的滚动容器也不一定就是它的直接父元素,这个应该很好理解,像上面动图中的情况,button
按钮的外层还有一个div
,而发生滚动的却是最外层的main
元素。
所以,我的解决办法就是,用递归的方式逐级向上查找目标元素的父节点,直到顶层,这个过程中进行判断,如果当前层级的父节点发生了内容溢出,那么就说明它可以是目标元素的滚动容器,就这样收集到所有父级容器,然后给它们加上scroll
事件监听,获取到我们最终需要的滚动距离
首先,编写函数findScrollContainerParents
查找父级滚动容器
// 滚动容器集合
const [parentList, setParentList] = useState<Array<Element>>([]);
// 状态标识:是否找完成了滚动容器的查找,递归的终止条件
const [hasParents, setHasParents] = useState(false);
// 查找父级滚动容器
const findScrollContainerParents = (node: Element) => {
if (!node) return;
const parent = node.parentElement;
if (parent) {
if (parent.scrollHeight > parent.clientHeight) {
setParentList((prev) => [...prev, parent]);
} else {
findScrollContainerParents(parent);
}
}
};
如何判断元素是否可以滚动内容?通过比较scrollHeight
和clientHeight
的值即可,scrollHeight
表示元素的实际高度,而clientHeight
表示元素的可见高度
如果你对这两个属性不太熟悉,那么具体细节可以参考 MDN ,示意图可以参考我之前的学习笔记《视窗与坐标》
同理,元素发生滚动时不一定只在垂直方向发生滚动,也可能会在水平方向滚动,所以只需要加上对应的scrollWidth
和 clientWidth
进行判断就好了
监听滚动事件
元素的scrollTop
和scrollLeft
这两个属性表示元素滚动条的位置到起始点的距离,这两个属性需要在scroll
事件对象中获得,我们需要给收集到的滚动容器加上事件监听,代码如下:
// 滚动事件回调
const scroll = useCallback((e: Event) => {}, []);
// 绑定滚动事件监听
const watchScroll = () => {
if (parentList.length) {
parentList.forEach((element) => {
element.addEventListener('scroll', scroll);
});
}
};
// 解除绑定滚动事件监听
const unWatchScroll = () => {
if (parentList.length) {
parentList.forEach((element) => {
element.removeEventListener('scroll', scroll);
});
}
};
// popover 被激活时监听滚动事件,隐藏时解除监听
useEffect(() => {
if (visible) {
watchScroll();
} else {
unWatchScroll();
}
return () => unWatchScroll();
}, [parentList, visible]);
上面的代码很好理解,需要提一下的就是使用了useCallback
来缓存滚动事件回调函数,因为每次 visible
发生变化,Popover 组件都会重渲染,相应的hooks
中的变量也都会被刷新,如果不使用事件缓存,那么组件重渲染后会丢失掉原来的函数引用地址,导致每次都会绑定新创建的事件回调,最终造成内存泄漏。
获取滚动距离
上面代码中我们已经给滚动容器加上了事件监听,只要触发滚动事件,那么回调函数就会执行,将scroll
事件对象传入回调函数就能获取到滚动距离了
// 滚动事件回调
const scroll = useCallback((e: Event) => {
const {scrollTop, scrollLeft} = e.target;
}, []);
最后,考虑到这一块逻辑比较独立,所以还是拆分出来一个自定义 hooks useScrollContainer
,贴一下完整代码:
function useScrollContainer(visible: boolean, onScroll: Function) {
// 状态标识:是否找完成了滚动容器的查找
const [hasParents, setHasParents] = useState(false);
// 滚动容器集合
const [parentList, setParentList] = useState<Array<Element>>([]);
// 滚动事件回调
const scroll = useCallback((e: Event) => onScroll(e), []);
// 绑定滚动事件监听
const watchScroll = () => {
if (parentList.length) {
parentList.forEach((element) => {
element.addEventListener('scroll', scroll);
});
}
};
// 解除绑定滚动事件监听
const unWatchScroll = () => {
if (parentList.length) {
parentList.forEach((element) => {
element.removeEventListener('scroll', scroll);
});
}
};
// 查找父级滚动容器
const findScrollContainerParents = (node: Element) => {
if (!node) return;
const parent = node.parentElement;
if (parent) {
if (parent.scrollHeight > parent.clientHeight) {
setParentList((prev) => [...prev, parent]);
} else {
findScrollContainerParents(parent);
}
}
};
// popover 被激活时监听滚动事件,隐藏时解除监听
useEffect(() => {
if (visible) {
watchScroll();
} else {
unWatchScroll();
}
return () => unWatchScroll();
}, [parentList, visible]);
return {
hasParents,
parentList,
setHasParents,
findScrollContainerParents,
};
}
useScrollContainer
导出了几个变量和方法以供外部调用,其中hasParents
的作用需要单独说一下:考虑到页面渲染后一般不会发生太大的变动,所以基本可以判定滚动容器就那么几个,不会再变了;而每次激活 Popover 都会执行以此递归查找,会有性能损耗,所以这个地方我们做个优化,第一次找到全部滚动容器后直接缓存起来,之后再次激活了 Popover 时直接取出缓存即可,不必每次都重新查找一遍,hasParents
为true
时就表示已经收集到了滚动容器,后面就会直接跳过查找逻辑了。
实现同步滚动
需要的参数我们都拿到了,接下来就是将目标元素的滚动距离同步给 Popover 容器元素了,这一块我放到了usePopoverPosition
中:
function usePopoverPosition(
popoverTarget: Element | undefined,
popover: MutableRefObject<HTMLDivElement | null>,
visible: boolean,
position: position,
trigger: 'click' | 'hover',
setVisible: React.Dispatch<React.SetStateAction<boolean>>,
) {
// 滚动条位置
const scrollBarPosition = useRef([0, 0]);
// 状态标志:是否是首次触发滚动事件
const firstScrollFlag = useRef(false);
const popoverPosition = useRef([0, 0]);
// 滚动事件回调,传递给 useScrollContainer
const onScroll = (e: MouseEvent) => {
// 当首次滚动事件执行时,记录下滚动开始的起点位置
const { scrollLeft, scrollTop } = e.target as HTMLElement;
if (!firstScrollFlag.current) {
scrollBarPosition.current = [scrollLeft, scrollTop];
firstScrollFlag.current = true;
}
// 同步滚动
const [x, y] = popoverPosition.current;
const [exitLeft, exitTop] = scrollBarPosition.current;
window.requestAnimationFrame(() => {
(popover.current as HTMLElement).style.left = `${
x - scrollLeft + exitLeft
}px`;
(popover.current as HTMLElement).style.top = `${
y - scrollTop + exitTop
}px`;
});
};
// 获取滚动容器
const { hasParents, setHasParents, findScrollContainerParents } =
useScrollContainer(visible, onScroll);
useLayoutEffect(() => {
calcStaticPos();
// 如果已经查找过滚动容器,则不必重复查找
if (popoverTarget && visible && !hasParents) {
findScrollContainerParents(popoverTarget);
setHasParents(true);
}
}, [popoverTarget, visible]);
useEffect(() => {
// 每次激活时,重新获取一次初始滚动位置
if (visible) {
firstScrollFlag.current = false;
}
}, [visible]);
}
export { usePopoverPosition };
上面的代码省去了一些无关逻辑,所做的事情就一件:在合适的时机调用findScrollContainerParents
查找到滚动容器,定义好滚动事件回调然后传递给useScrollContainer
,这样当发生滚动时就能将目标元素与容器的位置同步。
还是重点来看一下滚动事件回调吧,这是同步位置的核心逻辑:
// Popover 容器元素的位置
const [x, y] = popoverPosition.current;
// 滚动的起始位置
const [exitLeft, exitTop] = scrollBarPosition.current;
// 同步滚动位置到 Popover 容器元素
window.requestAnimationFrame(() => {
(popover.current as HTMLElement).style.left = `${
x - scrollLeft + exitLeft
}px`;
(popover.current as HTMLElement).style.top = `${
y - scrollTop + exitTop
}px`;
});
看到这里,你可能有一个问题:为啥要记录滚动的初始位置呢?我给你看个图你就明白了
这里滚动后,弹出框的位置就漂移了,为啥?因为滚动条并不是从 0 开始的,假如激活弹出框的时候,滚动条已经存在了一段距离,那么我们在计算滚动位置的时候就需要加上这一段滚动距离,不然就会出现上图的“漂移”现象
气泡样式实现
到了这一步,“武的”都已经整完了,现在来点“文的”,我们给弹出框加上一个指示性的小三角,这样才能保证颜值嘛!
很简单,用::before
或::after
伪元素即可,下面上代码:
.popoverCorner {
.corner();
background-color: white;
box-sizing: border-box;
border-width: 1px;
border-style: solid;
}
&__top {
transform-origin: bottom;
&::before {
.popoverCorner();
border-color: transparent @borderColor @borderColor transparent;
bottom: -6px;
left: calc(50% - 4px);
}
}
&__bottom {
transform-origin: top;
&::after {
.popoverCorner();
border-color: @borderColor transparent transparent @borderColor ;
top: -6px;
left: calc(50% - 4px);
}
}
&__left {
transform-origin: right;
&::before {
.popoverCorner();
border-color: @borderColor @borderColor transparent transparent;
right: -6px;
top: calc(50% - 5px);
}
}
&__right {
transform-origin: left;
&::before {
.popoverCorner();
border-color: transparent transparent @borderColor @borderColor ;
left: -6px;
top: calc(50% - 5px);
}
}
根据不同的弹出位置,编写好不同的类名,激活弹出框时应用到元素上即可,这一步完成之后结果如下
动画效果
终于到了最后一步了,没有动画效果的 UI 界面就像没涂过油的机械设备,用起来总有几分生硬,下面就给它整个动画上去,实现弹出框显示和隐藏时的动画过渡。
首先,我们需要编写一段 CSS 动画
@keyframes fadeIn {
from {
transform: scale(0.3);
opacity: 0;
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
transform: scale(0.3);
opacity: 0;
}
}
然后,需要通过js
来动态控制何时应用这两个动画,在组件中编写如下代码
// 定义组件需要的不同样式,通过切换类名来实现
const classMap = {
base: `${T} ${T}__${position} ${className ?? ''}`,
active: `${T}__active`,
hidden: `${T}__hidden`,
};
// useCssClassManager 是一个自定义 hooks ,上一篇文章中有
const { classList, removeClassName, addClassName, hasClassName } =
useCssClassManager(classMap);
// 省略部分代码...
// 切换显示/隐藏对应的类名
useEffect(() => {
if (visible) {
addClassName('active');
} else {
if (hasClassName('active')) {
addClassName('hidden');
}
}
}, [visible]);
这一步完成,我们就的到一个稍显丝滑的动画效果了
最终的完整代码我在这就不贴了,可以戳这里查看源代码,再戳这里预览
总结
写代码,做功能,爽!😎
转载自:https://juejin.cn/post/7228831114667098172