Ant Design 4.0 的一些杂事儿 - VirtualList 篇
- 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 模式下没有卡顿就不需要使用
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 就能填满弹出列表:
虚拟滚动的实现就是根据滚动区域来展示对用的内容:
当然,仅仅“正正好好”渲染屏幕高度除以条目高度数量是不够的。我们还需要额外渲染一条以防止滚动到半个条目的情况:
高度计算
虚拟滚动一般都会需要配置一下
itemHeight
作为基本高度,然后乘以
item
数量获得一个临时高度作为整个容器的高度。一旦元素被真实渲染后,则重新计算整体高度:
这就会导致如果用户拖拽滚动条向下的时候,随着真实高度的变化。初次渲染会遇到滚动条和和鼠标错开的情况:
白屏闪烁
此外,由于每次只渲染有限数量条目。当快速滚屏时,也会遇到滚动跟不上的情况:
对于滚轮、触摸板滚动滚屏,可以在可见区域外额外再渲染一定数量的条目作为缓冲:
但是,对于快速拖拽滚动条这就不管用了。
动画支持
虚拟滚动的条目会通过
position: absolute
固定到容器中,下一个元素的
top
跟随上一个元素底部:
然而这种布局方式对于动画实现会比较困难,我们希望 Tree 组件在切换虚拟滚动时仍然能够保持原本的动画效果。一种实现方式是在开启动画后,实时变更动画 Item 的高度。但是对于通过 css 实现动画效果的情况下,你不得不为元素添加一个
ResizeObserver
做实时监听与更新。 为了防止过多的监听,我们还需要动态绑定、解绑监听事件到对应的 Item 上。想想就是个浩大的工程。那么,还有什么更简单的方式吗?
跳出边界
rc-virtual-list
针对这些问题,做了不同的尝试。 在高度占位容器中,额外添加了一个防止当前可见 Item 的展示容器,该容器使用
position: absolute
做位置固定。这样,我们只需要通过 offset 算出第一个可见 Item 的
top
位置,其余的 Item 通过游览器原生的布局能力进行布局:
这样我们就可以利用浏览器原生能力实现了虚拟滚动的动画效果:
此外,除了上述的额外的一条渲染 buffer 外。我们再预留一条额外的渲染用于关闭动画:
“同步”滚动
对于滚动白屏的问题,最直接的想法就是通过劫持
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;