likes
comments
collection
share

【Popover 弹出框】:特殊的滚动逻辑

作者站长头像
站长
· 阅读数 16

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

【Popover 弹出框】:特殊的滚动逻辑【Popover 弹出框】:特殊的滚动逻辑

唯一的不同的就是比单纯一个 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 元素跟随着目标元素的移动而移动呢?这个问题稍有点复杂,首先是如何确定目标元素发生滚动时所在的容器,其次是如何计算滚动距离保证 Popover 和目标元素的相对位置不发生变化。这个后面会详细说到。

弹出框如何定位

弹出框的定位一般只有 4 个位置,即上下左右,然后为了美观,需要相对于目标元素保持居中;这个实现上不难,基本思路是:首先获取到目标元素的绝对位置(相对于 view-port)的位置,然后把 Popover 设置为相同的位置;然后,再根据目标元素的宽高,以及 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包裹而改变,只是多了一个独一无二的类名标识。

【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 弹出框】:特殊的滚动逻辑

想必你一眼就明白了吧,目标元素和 Popover 容器元素这两个元素在 DOM 结构上毫无关联,所以当你滚动页面时,目标元素会随着其父容器的滚动而移动,但是 Popover 容器怎么办呢?

我们先来分解这个问题,要实现让 Popover 容器元素跟随目标元素滚动,需要以下步骤:

  • 获取到正在触发滚动事件的元素(滚动容器),而且这个元素必须是目标元素的父级元素
  • 通过滚动容器,获取到目标元素的滚动距离scrollTopscrollLeft
  • 将滚动距离同步给 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);
    }
  }
};

如何判断元素是否可以滚动内容?通过比较scrollHeightclientHeight的值即可,scrollHeight表示元素的实际高度,而clientHeight表示元素的可见高度

【Popover 弹出框】:特殊的滚动逻辑

【Popover 弹出框】:特殊的滚动逻辑

如果你对这两个属性不太熟悉,那么具体细节可以参考 MDN ,示意图可以参考我之前的学习笔记《视窗与坐标》

同理,元素发生滚动时不一定只在垂直方向发生滚动,也可能会在水平方向滚动,所以只需要加上对应的scrollWidthclientWidth进行判断就好了

监听滚动事件

元素的scrollTopscrollLeft这两个属性表示元素滚动条的位置到起始点的距离,这两个属性需要在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 时直接取出缓存即可,不必每次都重新查找一遍,hasParentstrue时就表示已经收集到了滚动容器,后面就会直接跳过查找逻辑了。

实现同步滚动

需要的参数我们都拿到了,接下来就是将目标元素的滚动距离同步给 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`;
});

看到这里,你可能有一个问题:为啥要记录滚动的初始位置呢?我给你看个图你就明白了

【Popover 弹出框】:特殊的滚动逻辑

这里滚动后,弹出框的位置就漂移了,为啥?因为滚动条并不是从 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);
  }
}

根据不同的弹出位置,编写好不同的类名,激活弹出框时应用到元素上即可,这一步完成之后结果如下

【Popover 弹出框】:特殊的滚动逻辑

动画效果

终于到了最后一步了,没有动画效果的 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]);

这一步完成,我们就的到一个稍显丝滑的动画效果了【Popover 弹出框】:特殊的滚动逻辑

最终的完整代码我在这就不贴了,可以戳这里查看源代码,再戳这里预览

总结

写代码,做功能,爽!😎