一、背景
在开发中经常有长列表展示的需求,长列表在渲染时容易因为DOM节点过多而使页面明显的卡顿。比如在使用element-ui中的el-select组件时,options选项达到几百个时,组件使用上就极为不流畅等等。对于这种需求,简单的往往是懒加载思路:监听滚动分批次地加载数据。一开始我也采用了懒加载的思路去优化el-select,但这种做法并不能完全解决问题,它存在:
- 随着滚动的进行,页面内DOM节点还是会累积越来越多,还是会存在卡顿
- 组件值回显的时候,若数据还未加载到,会显示不正确的label值。
后面了解到element-plus新增了select V2选择器组件解决了该问题,
出于好奇,我们查看下源码实现,了解到一个新的思路:虚拟列表 ,一种局部渲染的方式。其实不止element-plus,市面上也有一些虚拟列表的库,比如vue-virtual-scroller,这种插件也能在Vue2中解决一些长列表的问题,下面我们讲讲selectV2及什么是虚拟列表。
二、虚拟列表
我们先看看select-v2在传入1000项数据后,渲染后的DOM,由下图中看出,在el-select-dropdown内,只有10项 li 数据节点是渲染的,并没有一次性渲染1000个DOM。(当然 这个大小可以控制,也不一定是10)而随着滚动,里面的10项 li 节点不断更新替换成应该显示的值,这就构成了一个虚拟列表,只展示可视区域的数据,只渲染可视区域的DOM节点。
可以注意到,虚拟列表中,每一项的定位都不相同,它们针对自己所在数组的位置,计算出相应的top偏差,结合绝对定位,当滚动到指定位置就能进行相应的展示与隐藏。
虚拟列表的大体思路可总结为:
- 监听渲染区域的滚动事件,通过计算与初始位置的偏移量
- 通过偏移量,计算当前可视区域的起始位置索引
- 通过偏移量,计算当前可视区域的结束位置索引
- 根据索引获取当前可视区域的数据,渲染到页面
- 根据偏移量,适当调整使渲染区域的数据完全展示
补充:select v2为了优化效果,将每次滚动的偏移量都设置为一页数据的高度,方便展示
三、select V2的实现
select v2在组件内维护长列表的数据,然后控制一次只渲染部分数据,完美地解决了长列表卡顿及数据回显的问题。我们知道select组件的长列表主要存在于下拉菜单中,所以我们直接定位到select-v2的下拉菜单内容,也就是下图中的el-select-menu组件
vue
<template #content>
<el-select-menu
ref="menuRef"
:data="filteredOptions"
:width="popperSize"
:hovering-index="states.hoveringIndex"
:scrollbar-always-on="scrollbarAlwaysOn"
>
<template #default="scope">
<slot v-bind="scope" />
</template>
<template #empty>
<slot name="empty">
<p :class="nsSelectV2.e('empty')">
{{ emptyText ? emptyText : '' }}
</p>
</slot>
</template>
</el-select-menu>
</template>
在select.dropdown.tsx中 我们了解到el-select-menu主要是基于List渲染的,这里的List也就是我们的虚拟列表,这边还根据是否传入Item项的大小来决定采用固定大小列表,还是动态大小列表。
tsx
const List = unref(isSized) ? FixedSizeList : DynamicSizeList
return (
<div class={[ns.b('dropdown'), ns.is('multiple', multiple)]}>
<List
ref={listRef}
{...unref(listProps)}
className={ns.be('dropdown', 'list')}
scrollbarAlwaysOn={scrollbarAlwaysOn}
data={data}
height={height}
width={width}
total={data.length}
// @ts-ignore - dts problem
onKeydown={onKeydown}
>
{{
default: (props: ItemProps<any>) => <Item {...props} />,
}}
</List>
</div>
)
接着我们定位到FixedSizeList,先看看其实现。组件的render函数的主要部分:由scrollbar和listContainer组成。listContainer就是虚拟列表的容器,还绑定了相应的onScroll、onWheel事件。
php
const scrollbar = h(Scrollbar, {
ref: 'scrollbarRef',
clientSize,
layout,
onScroll: onScrollbarScroll,
ratio: (clientSize * 100) / this.estimatedTotalSize,
scrollFrom:
states.scrollOffset / (this.estimatedTotalSize - clientSize),
total,
})
const listContainer = h(
Container as VNode,
{
class: [ns.e('window'), className],
style: windowStyle,
onScroll,
onWheel,
ref: 'windowRef',
key: 0,
},
!isString(Container) ? { default: () => [InnerNode] } : [InnerNode]
)
return h(
'div',
{
key: 0,
class: [ns.e('wrapper'), states.scrollbarAlwaysOn ? 'always-on' : ''],
},
[listContainer, scrollbar]
)
可以看到监听滚动事件,先根据横向与纵向的区别做了区分,然后根据滚动的偏移量做了state状态的更新,进而触发itemsToRender的更新。
javascript
const onScroll = (e: Event) => {
unref(_isHorizontal) ? scrollHorizontally(e) : scrollVertically(e)
emitEvents()
}
ini
const scrollHorizontally = (e: Event) => {
const { clientWidth, scrollLeft, scrollWidth } =
e.currentTarget as HTMLElement
const _states = unref(states)
if (_states.scrollOffset === scrollLeft) {
return
}
const { direction } = props
let scrollOffset = scrollLeft
if (direction === RTL) {
switch (getRTLOffsetType()) {
case RTL_OFFSET_NAG: {
scrollOffset = -scrollLeft
break
}
case RTL_OFFSET_POS_DESC: {
scrollOffset = scrollWidth - clientWidth - scrollLeft
break
}
}
}
scrollOffset = Math.max(
0,
Math.min(scrollOffset, scrollWidth - clientWidth)
)
states.value = {
..._states,
isScrolling: true,
scrollDir: getScrollDir(_states.scrollOffset, scrollOffset),
scrollOffset,
updateRequested: false,
}
nextTick(resetIsScrolling)
}
其中虚拟列表区域依次调用 listContainer => InnerNode =>itemsToRender。而itemsToRender则返回的是虚拟列表渲染的数据索引
javascript
// computed
const itemsToRender = computed(() => {
const { total, cache } = props
const { isScrolling, scrollDir, scrollOffset } = unref(states)
if (total === 0) {
return [0, 0, 0, 0]
}
const startIndex = getStartIndexForOffset(
props,
scrollOffset,
unref(dynamicSizeCache)
)
const stopIndex = getStopIndexForStartIndex(
props,
startIndex,
scrollOffset,
unref(dynamicSizeCache)
)
const cacheBackward =
!isScrolling || scrollDir === BACKWARD ? Math.max(1, cache) : 1
const cacheForward =
!isScrolling || scrollDir === FORWARD ? Math.max(1, cache) : 1
return [
Math.max(0, startIndex - cacheBackward),
Math.max(0, Math.min(total! - 1, stopIndex + cacheForward)),
startIndex,
stopIndex,
]
})
这边它分别使用getStartIndexForOffset、getStopIndexForStartIndex去计算开始索引和结束索引,它们的分别实现如下:
javascript
getStartIndexForOffset: ({ total, itemSize }, offset) =>
Math.max(0, Math.min(total - 1, Math.floor(offset / (itemSize as number)))),
getStopIndexForStartIndex: (
{ height, total, itemSize, layout, width }: Props,
startIndex: number,
scrollOffset: number
) => {
const offset = startIndex * (itemSize as number)
const size = isHorizontal(layout) ? width : height
const numVisibleItems = Math.ceil(
((size as number) + scrollOffset - offset) / (itemSize as number)
)
return Math.max(
0,
Math.min(
total - 1,
// because startIndex is inclusive, so in order to prevent array outbound indexing
// we need to - 1 to prevent outbound behavior
startIndex + numVisibleItems - 1
)
)
}
- 开始索引: Math.floor(offset / (itemSize as number)) // 偏移量除以数据项高度向下取整
- 结束索引: startIndex + numVisibleItems - 1 // 开始索引加上当前区域可渲染数量 * 数据项高度
javascript
const [start, end] = itemsToRender
const Container = resolveDynamicComponent(containerElement)
const Inner = resolveDynamicComponent(innerElement)
const children = [] as VNodeChild[]
if (total > 0) {
for (let i = start; i <= end; i++) {
children.push(
($slots.default as Slot)?.({
data,
key: i,
index: i,
isScrolling: useIsScrolling ? states.isScrolling : undefined,
style: getItemStyle(i),
})
)
}
}
这边值得注意的是它采取数据的区间并不是计算出的[startIndex, stopIndex],而是在这基础上添加了 [startIndex - cacheBackward, stopIndex + cacheForward],扩大了渲染区间,避免用户滚动过快时出现白屏的数据现象。这边它针对每一项数据,都进行了独立的style计算,这也是为什么每项都会有不同的top值:
javascript
const getItemStyle = (idx: number) => {
const { direction, itemSize, layout } = props
const itemStyleCache = getItemStyleCache.value(
clearCache && itemSize,
clearCache && layout,
clearCache && direction
)
let style: CSSProperties
if (hasOwn(itemStyleCache, String(idx))) {
style = itemStyleCache[idx]
} else {
const offset = getItemOffset(props, idx, unref(dynamicSizeCache))
const size = getItemSize(props, idx, unref(dynamicSizeCache))
const horizontal = unref(_isHorizontal)
const isRtl = direction === RTL
const offsetHorizontal = horizontal ? offset : 0
itemStyleCache[idx] = style = {
position: 'absolute',
left: isRtl ? undefined : `${offsetHorizontal}px`,
right: isRtl ? `${offsetHorizontal}px` : undefined,
top: !horizontal ? `${offset}px` : 0,
height: !horizontal ? `${size}px` : '100%',
width: horizontal ? `${size}px` : '100%',
}
}
return style
}
利用itemStyleCache的缓存机制,可以避免重复计算。
四、总结
虚拟列表原理是利用视觉差在页面内渲染出一份虚拟的列表,长列表撑起了一个看不见的高度,用户在数据区域内滚动察觉不到长列表的存在,只有渲染区域的数据不断切换虚拟出一种滚动的感觉。目前element-plus的select v2还在beta阶段,一些监听srcoll的事件处理还可以进一步调优。另外针对定高和不定高的数据项,都有对应的解决方案,这边主要根据定高的数据项展开,至于dynamic-size-list的虚拟列表后面再另开一篇。