相关文章推荐
完美的砖头  ·  autohotkey GroupBox - ...·  2 周前    · 
爱吹牛的墨镜  ·  jquery - Find <tr> ...·  1 年前    · 
威武的烤面包  ·  Windows 10 store ...·  1 年前    · 
Ant Design 4.0 的一些杂事儿 - VirtualList 篇

Ant Design 4.0 的一些杂事儿 - VirtualList 篇

在 React 中,我们常说不太需要关注性能问题。只要在 prod 模式下没有卡顿就不需要使用 memo PureComponent shouldComponentUpdate useMemo 这些优化手段。

然而作为组件库,这些事你就不得不考虑一下:

于是,我们会推荐使用 onPopupScroll 方法来监听滚动。如果选项滚动到了底端再异步添加数据以防止初始化 dom 元素过多导致的卡顿。于是乎,又出现了 Y issue:

于是于是乎,我们在 v4 版本中为 Select、TreeSelect、Tree 添加默认的虚拟滚动支持~

虚拟滚动

我们在 react-component 中新增了 rc-virtual-list 组件用于处理底层的虚拟滚动相关逻辑,接下去我们就会介绍一下它到底做了些什么。当然,这是一个非常定制化的组件。如果你在寻找简单方便的虚拟滚动组件,业界已经有非常成熟的 react-window react-virtualized 来使用了。

先说说 Select

Select 组件的弹出列表是异步渲染,只有当其被展开时才会渲染,从而节省需要渲染的内容。其使用大致结构如下:

<Select>
  <Select.Option key="light" value="light">Light</Select.Option>
  <Select.Option key="bamboo" value="bamboo">Bamboo</Select.Option>
</Select>

Select 内部会将 children 转换成一套数据结构,供渲染使用:

[
  { key: 'light', value: 'light', label: 'Light' },
  { key: 'bamboo', value: 'bamboo', label: 'Bamboo' },
]

但是这也使得我们不得不在每次父层组件渲染后重新计算 options ,因为通过 React.createElement 创建的 children 无法简单的通过对比来确定是否有变化。你不得不重新转化一遍 options 数来比较是否有更新。而当数据量过大时,比较出不同到重新渲染反而会花费更多的时间。因此我们每次都作为新的数据来进行渲染。

在 v4 中,我们直接提供了 options 属性用于对比优化。如果传入的 options 和上一次渲染是同一个数组,那么我们直接复用之前计算的数据即可。当然,如果你的列表并不大使用 Select.Option 或者 options 都无所谓。React 对此已经做的足够好了:

const options = [
  { key: 'light', value: 'light', label: 'Light' },
  { key: 'bamboo', value: 'bamboo', label: 'Bamboo' },
<Select options={options} />

当完成这些,Select 组件在再次打开时,不再需要重新渲染整个下拉 dom 树。提升了一些性能。然而,还是没有解决首次打开的卡顿问题。接着,就是虚拟滚动上场的时候到了。

所见即所得

(如果你对虚拟滚动的概念已经相当熟悉,你可以直接跳过这一节~)

我们知道,屏幕的高度是有限的。在屏幕外暂时看不到的元素其实并不重要,只要保证滚动的时候把对应的元素展示出来即可。回到 Select 中,它的可见区域则更小。我们只需要展示 10 个 Option 就能填满弹出列表:

虚拟滚动的实现就是根据滚动区域来展示对用的内容:

只需要渲染 3 条

当然,仅仅“正正好好”渲染屏幕高度除以条目高度数量是不够的。我们还需要额外渲染一条以防止滚动到半个条目的情况:

此时,屏幕上渲染了 4 条

高度计算

虚拟滚动一般都会需要配置一下 itemHeight 作为基本高度,然后乘以 item 数量获得一个临时高度作为整个容器的高度。一旦元素被真实渲染后,则重新计算整体高度:

这就会导致如果用户拖拽滚动条向下的时候,随着真实高度的变化。初次渲染会遇到滚动条和和鼠标错开的情况:

react-window 动态高度首次拖拽的分离现象

白屏闪烁

此外,由于每次只渲染有限数量条目。当快速滚屏时,也会遇到滚动跟不上的情况:

react-window 在低 CPU 环境下的滚动白屏现象

对于滚轮、触摸板滚动滚屏,可以在可见区域外额外再渲染一定数量的条目作为缓冲:

缓冲区越大,滚动白屏越少

但是,对于快速拖拽滚动条这就不管用了。

动画支持

虚拟滚动的条目会通过 position: absolute 固定到容器中,下一个元素的 top 跟随上一个元素底部:

然而这种布局方式对于动画实现会比较困难,我们希望 Tree 组件在切换虚拟滚动时仍然能够保持原本的动画效果。一种实现方式是在开启动画后,实时变更动画 Item 的高度。但是对于通过 css 实现动画效果的情况下,你不得不为元素添加一个 ResizeObserver 做实时监听与更新。 为了防止过多的监听,我们还需要动态绑定、解绑监听事件到对应的 Item 上。想想就是个浩大的工程。那么,还有什么更简单的方式吗?

跳出边界

rc-virtual-list 针对这些问题,做了不同的尝试。 在高度占位容器中,额外添加了一个防止当前可见 Item 的展示容器,该容器使用 position: absolute 做位置固定。这样,我们只需要通过 offset 算出第一个可见 Item 的 top 位置,其余的 Item 通过游览器原生的布局能力进行布局:

rc-virtual-list-holder-inner 用于总体可见 Item 的偏移位置

这样我们就可以利用浏览器原生能力实现了虚拟滚动的动画效果:

无需计算每个 Item 的位置

此外,除了上述的额外的一条渲染 buffer 外。我们再预留一条额外的渲染用于关闭动画:

Math.ceil(height / itemHeight) + 2

“同步”滚动

对于滚动白屏的问题,最直接的想法就是通过劫持 onScroll 事件阻止滚动,等到我们完成了渲染后再通过设置 scrollTop 跳至滚动位置。然而遗憾的是, scroll 事件是在滚动发生后才会触发的 UIEvent,因而你无法在 onScroll 里调用 preventDefault

为了实现无白屏效果,我们需要劫持会触发 onScroll 事件的前置事件 onWheel

function onWheel({ deltaY }: WheelEvent) {
  event.preventDefault();
  listRef.current.scrollTop += deltaY;

此外,既然滚动高度已经被我们管控,那么我们其实也不需要每次触发滚动的时候都需要更新滚动高度。因而我们可以将一帧内的滚动事件合并:

function onWheel({ deltaY }: WheelEvent) {
  event.preventDefault();
  cancelAnimationFrame(nextFrameRef.current);
  offsetRef.current += deltaY;
  nextFrameRef.current = requestAnimationFrame(() => {
    listRef.current.scrollTop += offsetRef.current;
    offsetRef.current = 0;
}

在 Chrome 中,这段代码的表现非常的好。但是在 Firefox 中,我们发现通过鼠标滚轮滚动时滚动速度非常的慢。经过排查,Firefox 下鼠标滚轮触发的 onWheel 事件中的 deltaY 通过触摸板滚动是实际的滚动距离,而鼠标滚轮则只有 -1 / 1 两种值,这导致了滚轮滚动每次都只移动非常小的距离。查阅相关文档后,发现标准对 deltaY 并没要求为实际滚动距离,Firefox 的 -1 / 1 也是合理的值。

作为 workaround,我们使用 Firefox only 的 DOMMouseScroll 事件用于辅助监听滚动判断。如果发现 DOMMouseScroll detail wheel 事件中的 deltaY 不同,则说明这次滚动是通过滚轮滚动的。然后在更新滚动时,乘以额外的偏移量:

nextFrameRef.current = requestAnimationFrame(() => {
  const patchMultiple = isMouseScrollRef.current ? FIREFOX_FIXED_OFFSET : 1;
  listRef.current.scrollTop += offsetRef.current * patchMultiple;
  offsetRef.current = 0;
});

完成这些操作后,我们就可以做到滚动时候的无白屏效果而不需要配置 overscanCount

接着就是考虑直接拖拽滚动条的白屏场景。不同于触摸板与滚轮场景,直接拖拽滚动条我们是无法拦截的。为此,我们设置 overflow: hidden 直接隐藏滚动条,并额外实现了一个“假的”滚动条来代替原生的。利用这个滚动条,我们就可以做到每次拖拽会先经过我们的虚拟滚动逻辑,再进行滚动操作:

每次计算时,都通过滚动条所在位置的百分比还原回 scrollTop 的值。由于滚动条位置不依赖实际高度,这使得我们一并解决了上文提到的 Item 第一次渲染高度变化导致的滚动条与鼠标不同步的问题。

移动事件

当完成了这些,还需要注意移动设备的使用。通过 onTouchStart onTouchMove onTouchEnd 三件套可以很轻松的实现滚动监听:

function onTouchStart(e: TouchEvent) {
  touchYRef.current = Math.ceil(e.touches[0].pageY);
  // 由于虚拟滚动下超出屏幕的元素会被移除
  // 而当元素被移除时之后的 touch 事件在父层容器不再会被触发
  // 所以需要直接将 touch 事件绑定到触发元素上保持持续监听
  e.target.addEventListener('touchmove', onTouchMove);
  e.target.addEventListener('touchend', onTouchEnd);
}


function onTouchMove(e: TouchEvent) {
  e.preventDefault();
  const currentY = Math.ceil(e.touches[0].pageY);
  let offsetY = touchYRef.current - currentY;
  touchYRef.current = currentY;
  listRef.current.scrollTop += offsetY;
  // 通过计时器模拟滚动惯性效果
  clearInterval(intervalRef.current);
  intervalRef.current = setInterval(() => {
    offsetY *= SMOOTH_PTG;
    listRef.current.scrollTop += offsetY;