scrolltop一直为0_Ant Design 4.0 的一些杂事儿 - VirtualList 篇

881267b4d7318458a84de316028c4aa5.png
  • Ant Design 4.0 的一些杂事儿 - Space 篇
  • Ant Design 4.0 的一些杂事儿 - CI 篇
  • Ant Design 4.0 的一些杂事儿 - Table 篇
  • Ant Design 4.0 的一些杂事儿 - Form 篇
  • Ant Design 4.0 的一些杂事儿 - Select 篇

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

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

d989a985c2f77a67257742ca4d27d732.png

5c964e469ed3443db2feea34e875333d.png

89d1cedd75e5103b124dc6d408447e21.png

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

f6331b0b01ab9e32c5fe874a1628e49a.png

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

虚拟滚动

我们在 react-component 中新增了 rc-virtual-list 组件用于处理底层的虚拟滚动相关逻辑,接下去我们就会介绍一下它到底做了些什么。当然,这是一个非常定制化的组件。如果你在寻找简单方便的虚拟滚动组件,业界已经有非常成熟的 react-windowreact-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 就能填满弹出列表:

231516c821800ac3a8a9322534ff3cf7.png

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

4d550efdf942bcc1902540e8ee57df74.png
只需要渲染 3 条

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

15f09be80854c850fda649ecdca79d9f.png
此时,屏幕上渲染了 4 条

高度计算

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

ab6186918e82971fcc8eefd583d50acd.png

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

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

白屏闪烁

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

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

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

1e9657e988716f6b464deb0fba40faa9.png
缓冲区越大,滚动白屏越少

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

动画支持

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

1c48823f8a84404d513dd966442c571e.png

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

跳出边界

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

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

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

4c55ed0737402dfc4397381b27b4065b.gif
无需计算每个 Item 的位置

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

de59b3981f81abc9b338266a293df0df.gif
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 也是合理的值。

6ad0f3588ebfc3f31bf6f8d1caf00ee8.gif

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

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

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

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

d612387d4e680720ec7390eb7e033263.png

每次计算时,都通过滚动条所在位置的百分比还原回 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;

    if (Math.abs(offsetY) <= 0.1) {
      clearInterval(intervalRef.current);
    }
  }, 16);
}

差不多了~

以上就是 Ant Design 4.0 中如何实现无白屏 & 动画支持的虚拟滚动列表(虽然具体实现还有很多细节,在这里就不多说了。感兴趣的同学可以直接到 github 查看相关代码)。而在这个虚拟滚动方案之前(大约是 v4 alpha 版本时期),我们还尝试过另一种方案来实现鼠标与滚动条同步问题,其缺点是映射逻辑过于复杂而被废弃,此处就不展开了。

如果你也需要一些动画相关的虚拟滚动实现,欢迎参考 rc-virtual-list 了~

祝好

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值