针对大数据列表一般不会一次性加载,会采用上拉加载或者分页的方式展示。如果 10W 条数据,列表对应着 10W 个 DOM 节点,性能方面体验可能会不太好,因此引入虚拟滚动来优化。
虚拟滚动:要渲染完整列表对应的高度是通过 [虚拟计算] 的,并不是文档中存在对应的 DON 节点数。只要 [虚拟列表高度] > [列表可视区高度] 时,就会产生滚动条即可发生滚动操作。
在滚动操作时,保证 [实际渲染的列表] 一直存在 [列表可视区] 中,并且动态切换需要渲染的列表数据。
组件对外暴漏的接口如下:
type NumberOrNumberString = PropType<string | number | undefined>
const props = {
// 容器高度/宽度
height: [Number, String] as NumberOrNumberString,
width: [Number, String] as NumberOrNumberString,
maxHeight: [Number, String] as NumberOrNumberString,
maxWidth: [Number, String] as NumberOrNumberString,
minHeight: [Number, String] as NumberOrNumberString,
minWidth: [Number, String] as NumberOrNumberString,
// 列表项高度
itemHeight: {
type: [Number, String] as NumberOrNumberString,
required: true,
},
// 数据
items: {
type: Array as PropType<any[]>,
default: () => [],
},
// 预加载数据数量
bench: {
type: [Number, String] as NumberOrNumberString,
default: 0,
}
}
const prefixCls = 'virtual-scroll';
export default defineComponent({
name: 'VirtualScroll',
setup(props, { slots }) {
const wrapElRef = ref<HTMLDivElement | null>(null);
// 列表项高度
const getItemHeightRef = computed(() => {
return parseInt(props.itemHeight as string, 10);
});
// 虚拟列表高度
const getContainerStyleRef = computed((): CSSProperties => {
return {
height: convertToUnit((props.items || []).length * unref(getItemHeightRef)),
};
});
// 滚动区域样式
const getWrapStyleRef = computed((): CSSProperties => {
const styles: Recordable<string> = {};
const height = convertToUnit(props.height);
const minHeight = convertToUnit(props.minHeight);
const minWidth = convertToUnit(props.minWidth);
const maxHeight = convertToUnit(props.maxHeight);
const maxWidth = convertToUnit(props.maxWidth);
const width = convertToUnit(props.width);
if (height) styles.height = height;
if (minHeight) styles.minHeight = minHeight;
if (minWidth) styles.minWidth = minWidth;
if (maxHeight) styles.maxHeight = maxHeight;
if (maxWidth) styles.maxWidth = maxWidth;
if (width) styles.width = width;
return styles;
});
return () => {
<div class={prefixCls} ref={wrapElRef} style={unref(getWrapStyleRef)}>
<div class={`${prefixCls}__container`} style={unref(getContainerStyleRef)}>
{renderChildren()}
</div>
</div>
}
}
})
接下来设置真正渲染数据的索引 first 和 last,用于从列表数据 items 中获取对应的数据内容。
export default defineComponent({
name: 'VirtualScroll',
setup(props, { slots }) {
const wrapElRef = ref<HTMLDivElement | null>(null);
const state = reactive({
first: 0,
last: 0,
scrollTop: 0,
});
const getBenchRef = computed(() => {
return parseInt(props.bench as string, 10);
});
// 数组渲染 first 和 last 索引值
const getFirstToRenderRef = computed(() => {
return Math.max(0, state.first - unref(getBenchRef));
});
const getLastToRenderRef = computed(() => {
return Math.min((props.items || []).length, state.last + unref(getBenchRef));
});
}
})
对于虚拟组件支持外部通过插槽插入相关组件内容,我们使用 renderChildren 函数进行处理。通过 getFirstToRenderRef 和 getLastToRenderRef 分别计算到数组渲染起始和终止位置。
export default defineComponent({
name: 'VirtualScroll',
setup(props, { slots }) {
function renderChildren() {
const { items = [] } = props
return items.slice(unref(getFirstToRenderRef), unref(getLastToRenderRef).map(genChild))
}
function genChild(item: any, index: number) {
index += unref(getFirstToRenderRef)
const top = convertToUnit(index * unref(getItemHeightRef))
return (
<div class={`${prefixCls}__item`} style={{ top }} key={index}>
{getSlot(slots, 'default', { index, item })}
</div>
)
}
}
})
genChild 函数负责处理列表项渲染,通过计算 index * getItemHeightRef 的值 top ,即 [实际渲染列表元素] 相对位置的数值,保证 [实例渲染列表元素] 一直存在可视区域中。
我们需要注册并监听滚动事件 onScroll,代码实现如下:
export default defineComponent({
name: 'VirtualScroll',
setup(props, { slots }) {
const wrapElRef = ref<HTMLDivElement | null>(null);
// 滚动事件
function onScroll() {
const wrapEl = unref(warpElRef)
if (!wrapEl) {
return
}
state.scrollTop = wrapEl.scrollTop
// first 和 last 为数组下标
state.first = getFirst()
state.last = getLast(state.first)
}
onMounted(() => {
state.last = getLast(0)
nextTick(() => {
const wrapEl = unref(wrapElRef);
if (!wrapEl) {
return;
}
// 监听滚动事件
useEventListener({
el: wrapEl,
name: 'scroll',
listener: onScroll,
wait: 0,
});
})
})
}
})
export default defineComponent({
name: 'VirtualScroll',
setup(props, { slots }) {
const state = reactive({
first: 0,
last: 0,
scrollTop: 0,
});
function getFirst(): number {
return Math.floor(state.scrollTop / unref(getItemHeightRef))
}
function getLast(first: number): number {
const wrapEl = unref(warpElRef)
if (!wrapEl) {
return 0
}
const height = parseInt(props.height || 0, 10) || wrapEl.clientHeight
return first + Math.ceil(height / unref(getItemHeightRef))
}
}
})
关于更多虚拟滚动下拉刷新、上拉加载更多等功能细节,请参考: taro-ui-vue3 之虚拟组件
vueuse 库中的 useEventListener hook 函数,在挂载时使用 addEventListener 注册,在卸载时使用 removeEventListener 自动移出监听事件。接下来我们实现一个 hook,在 mounted 时添加事件监听,页面销毁时移出。
首先 hook 钩子函数入参设定,具体代码如下:
interface UseEventParams {
el?: Element;
name: string;
listener: EventListener;
options?: boolean;
}
export function useEventListener({
el = window,
name,
listener,
options,
autoRemove = true,
isDebounce = true,
wait = 80
}: UseEventParams) {
......
}
// src/hooks/event/useEventListener.ts
import type { Ref } from 'vue';
import { ref, watch, unref } from 'vue';
import { useThrottleFn, useDebounceFn } from '@vueuse/core'
export type RemoveEventFn = () => void;
export function useEventListener({
el = window,
name,
listener,
options,
autoRemove = true,
isDebounce = true,
wait = 80
}: UseEventParams) {
let remove: removeEventFn = () => {}
// 是否移出标识
const isAddRef = ref(false)
if (el) {
const element = ref(el as Element) as Ref<Element>
const handler = isDebounce ? useDebounceFn(listener, wait) : useThrottleFn(listener, wait)
const realHandler = wait ? handler : listener
// 移除监听
const removeEventListener = (e: Element) => {
isAddRef.value = true
e.removeEventListener(name, realHandler, options)
}
// 增加监听
const addEventListener = (e: Element) => {
return e.addEventListener(name, realHandler, options)
}
// 监听: 使用 watch 方法返回的一个 unwatch 方法
const removeWatch = watch(
element,
(v, _ov, cleanUp) => {
if (v) {
// 添加事件监听并执行 cleanUp 函数
!unref(isAddRef) && addEventListener(v);
cleanUp(() => {
autoRemove && removeEventListener(v);
});
}
},
// 立即触发回调
{ immediate: true },
);
// 更新 remove 函数
remove = () => {
removeEventListener()
removeWatch()
}
}
return { removeEvent: remove }
}
更多细节,请参考 vueuse 官网下的 useEventListener hook 源码实现。
// src/utils/helper/tsxHelper.tsx
import { Slots } from 'vue'
function getSlot(slots: Slots, slot = 'default', data?: any) {
if (!slots || !Reflect.has(slots, slot)) {
return null
}
if (!isFunction(slots[slot])) {
console.error(`${slot} is not a function!`)
return null
}
const slotFn = slots[slot]
if (!slotFn) return null
return slotFn(data)
}