Skip to content

虚拟滚动列表

基于 IntersectionObserver 实现。


查看代码 index.vue
vue
<script setup lang="ts">
import { dataList } from './data';
import { usevScrollY, useVirtualList } from './use';
console.log(dataList);

const { scroll, vScrollY } = usevScrollY();
const { vObserver, vList } = useVirtualList(dataList, scroll);
</script>

<template>
	<div class="v-list">
		<title>VList</title>
		<div class="scroll-wrap" v-scroll-y>
			<template v-for="item in vList" :key="item.id">
				<div v-observer class="module" :style="{ height: item.height + 'px' }" :data-index="item.id">
					index:{{ item.id }}--height: {{ item.height }}
				</div>
			</template>
		</div>
	</div>
</template>

<style scoped lang="less">
.v-list {
	display: block;
	width: 100%;
	.scroll-wrap {
		position: relative;
		border: 1px dashed blue;
		height: 500px;
		overflow: hidden auto;
		padding: 1em;
		scroll-behavior: auto; // 滚动框无过度立即滚动。
		scroll-behavior: smooth; // 滚动框通过一个用户代理预定义的时长、使用预定义的时间函数,来实现平稳的滚动
		// -webkit-overflow-scrolling: touch; /* 当手指从触摸屏上移开,会保持一段时间的滚动 */
		-webkit-overflow-scrolling: auto; /* 当手指从触摸屏上移开,滚动会立即停止 */
	}
	.module {
		border: 1px dashed red;
		border-width: 1px 1px 0 1px;
		&:last-child {
			border-width: 1px;
		}
	}
}
</style>
<script setup lang="ts">
import { dataList } from './data';
import { usevScrollY, useVirtualList } from './use';
console.log(dataList);

const { scroll, vScrollY } = usevScrollY();
const { vObserver, vList } = useVirtualList(dataList, scroll);
</script>

<template>
	<div class="v-list">
		<title>VList</title>
		<div class="scroll-wrap" v-scroll-y>
			<template v-for="item in vList" :key="item.id">
				<div v-observer class="module" :style="{ height: item.height + 'px' }" :data-index="item.id">
					index:{{ item.id }}--height: {{ item.height }}
				</div>
			</template>
		</div>
	</div>
</template>

<style scoped lang="less">
.v-list {
	display: block;
	width: 100%;
	.scroll-wrap {
		position: relative;
		border: 1px dashed blue;
		height: 500px;
		overflow: hidden auto;
		padding: 1em;
		scroll-behavior: auto; // 滚动框无过度立即滚动。
		scroll-behavior: smooth; // 滚动框通过一个用户代理预定义的时长、使用预定义的时间函数,来实现平稳的滚动
		// -webkit-overflow-scrolling: touch; /* 当手指从触摸屏上移开,会保持一段时间的滚动 */
		-webkit-overflow-scrolling: auto; /* 当手指从触摸屏上移开,滚动会立即停止 */
	}
	.module {
		border: 1px dashed red;
		border-width: 1px 1px 0 1px;
		&:last-child {
			border-width: 1px;
		}
	}
}
</style>
查看代码 data.ts
ts
export const dataList = new Array(1000).fill(1).map((_, i) => {
	return {
		id: i,
		height: Math.ceil(Math.random() * 300) + 30
	};
});
export const dataList = new Array(1000).fill(1).map((_, i) => {
	return {
		id: i,
		height: Math.ceil(Math.random() * 300) + 30
	};
});
查看代码 use.ts
ts
import { type ObjectDirective, ref, reactive } from 'vue';

export interface Scroll {
	isToTop: boolean;
	y: number;
}

export interface observerElement extends HTMLElement {
	__expose__: boolean;
	__observerFn__: any;
}

export function usevScrollY() {
	const scroll: Scroll = reactive({
		isToTop: true,
		y: 0
	});

	function onScroll(e: Event) {
		const el = e.target as HTMLElement;
		scroll.isToTop = el.scrollTop - scroll.y >= 0;
		scroll.y = el.scrollTop;
	}

	const vScrollY: ObjectDirective = {
		beforeMount(el: HTMLElement) {
			el.addEventListener('scroll', onScroll);
		},
		beforeUnmount(el: HTMLElement) {
			el.removeEventListener('scroll', onScroll);
		}
	};

	return { scroll, vScrollY };
}

export function useVirtualList(dataList = [] as any[], scroll: Scroll) {
	// 配置
	const config = {
		startIndex: 0, // 虚拟列表区间开始索引
		endIndex: 10, // 虚拟列表区间结束索引
		cacheAfter: 20, // buffer 前缓冲区
		cacheBefore: 20, // buffer 后缓冲区
		isExposeRuning: false, // 开始曝光回调函数任务执行状态
		isEndExposeRuning: false // 结束曝光回调函数任务执行状态
	};
	// 虚拟列表
	const vList = ref(dataList.slice(config.startIndex, config.endIndex));
	/**
	 * 开始曝光
	 * 处理:添加上区间备用数据
	 * 处理:添加下区间备用数据
	 */
	function expose(el: HTMLElement) {
		// 工作状态加锁
		if (config.isExposeRuning) return;
		config.isExposeRuning = true;
		// 必须含有当前元素的索引下标
		if (el.dataset.index === undefined) return;
		const { cacheAfter, cacheBefore } = config;
		const index = +el.dataset.index;
		// 根据滚动方向处理
		if (scroll.isToTop) {
			while (config.endIndex - index < cacheAfter && config.endIndex < dataList.length) {
				// console.count('添加下区间备用数据');
				vList.value.push(dataList[config.endIndex]);
				config.endIndex += 1;
			}
		} else {
			while (index - config.startIndex < cacheBefore && config.startIndex > 0) {
				// console.count('添加上区间备用数据');
				vList.value.unshift(dataList[config.startIndex - 1]);
				config.startIndex -= 1;
			}
		}
		config.isExposeRuning = false;
	}
	/**
	 * 结束曝光
	 * 处理:删除上区间多余数据
	 * 处理:删除下区间多余数据
	 */
	function endExpose(el: HTMLElement) {
		// 工作状态加锁
		if (config.isEndExposeRuning) return;
		config.isEndExposeRuning = true;
		// 必须含有当前元素的索引下标
		if (el.dataset.index === undefined) return;
		const index = +el.dataset.index;
		const { cacheAfter, cacheBefore } = config;
		// 根据滚动方向处理
		if (scroll.isToTop) {
			while (index - config.startIndex > cacheBefore) {
				// console.count('处理:删除上区间多余数据--向上结束曝光');
				vList.value.shift();
				config.startIndex += 1;
			}
		} else {
			// 亲近结束位置,向下结束曝光
			while (config.endIndex - index > cacheAfter) {
				// console.count('处理:删除下区间多余数据--向下结束曝光');
				vList.value.pop();
				config.endIndex -= 1;
			}
		}
		config.isEndExposeRuning = false;
	}

	const vObserver: ObjectDirective = {
		beforeMount: (el) => {
			const io = new IntersectionObserver((entries) => {
				entries.forEach((entry) => {
					const el = entry.target as observerElement;
					if (entry.intersectionRatio > 0) {
						el.__expose__ = true;
						expose(el); // 开始曝光
					} else if (el.__expose__) {
						endExpose(el); // 结束曝光
					}
				});
			});
			io.observe(el);
			// el.__observerFn__ = io;
		}
	};
	return { vObserver, vList };
}
import { type ObjectDirective, ref, reactive } from 'vue';

export interface Scroll {
	isToTop: boolean;
	y: number;
}

export interface observerElement extends HTMLElement {
	__expose__: boolean;
	__observerFn__: any;
}

export function usevScrollY() {
	const scroll: Scroll = reactive({
		isToTop: true,
		y: 0
	});

	function onScroll(e: Event) {
		const el = e.target as HTMLElement;
		scroll.isToTop = el.scrollTop - scroll.y >= 0;
		scroll.y = el.scrollTop;
	}

	const vScrollY: ObjectDirective = {
		beforeMount(el: HTMLElement) {
			el.addEventListener('scroll', onScroll);
		},
		beforeUnmount(el: HTMLElement) {
			el.removeEventListener('scroll', onScroll);
		}
	};

	return { scroll, vScrollY };
}

export function useVirtualList(dataList = [] as any[], scroll: Scroll) {
	// 配置
	const config = {
		startIndex: 0, // 虚拟列表区间开始索引
		endIndex: 10, // 虚拟列表区间结束索引
		cacheAfter: 20, // buffer 前缓冲区
		cacheBefore: 20, // buffer 后缓冲区
		isExposeRuning: false, // 开始曝光回调函数任务执行状态
		isEndExposeRuning: false // 结束曝光回调函数任务执行状态
	};
	// 虚拟列表
	const vList = ref(dataList.slice(config.startIndex, config.endIndex));
	/**
	 * 开始曝光
	 * 处理:添加上区间备用数据
	 * 处理:添加下区间备用数据
	 */
	function expose(el: HTMLElement) {
		// 工作状态加锁
		if (config.isExposeRuning) return;
		config.isExposeRuning = true;
		// 必须含有当前元素的索引下标
		if (el.dataset.index === undefined) return;
		const { cacheAfter, cacheBefore } = config;
		const index = +el.dataset.index;
		// 根据滚动方向处理
		if (scroll.isToTop) {
			while (config.endIndex - index < cacheAfter && config.endIndex < dataList.length) {
				// console.count('添加下区间备用数据');
				vList.value.push(dataList[config.endIndex]);
				config.endIndex += 1;
			}
		} else {
			while (index - config.startIndex < cacheBefore && config.startIndex > 0) {
				// console.count('添加上区间备用数据');
				vList.value.unshift(dataList[config.startIndex - 1]);
				config.startIndex -= 1;
			}
		}
		config.isExposeRuning = false;
	}
	/**
	 * 结束曝光
	 * 处理:删除上区间多余数据
	 * 处理:删除下区间多余数据
	 */
	function endExpose(el: HTMLElement) {
		// 工作状态加锁
		if (config.isEndExposeRuning) return;
		config.isEndExposeRuning = true;
		// 必须含有当前元素的索引下标
		if (el.dataset.index === undefined) return;
		const index = +el.dataset.index;
		const { cacheAfter, cacheBefore } = config;
		// 根据滚动方向处理
		if (scroll.isToTop) {
			while (index - config.startIndex > cacheBefore) {
				// console.count('处理:删除上区间多余数据--向上结束曝光');
				vList.value.shift();
				config.startIndex += 1;
			}
		} else {
			// 亲近结束位置,向下结束曝光
			while (config.endIndex - index > cacheAfter) {
				// console.count('处理:删除下区间多余数据--向下结束曝光');
				vList.value.pop();
				config.endIndex -= 1;
			}
		}
		config.isEndExposeRuning = false;
	}

	const vObserver: ObjectDirective = {
		beforeMount: (el) => {
			const io = new IntersectionObserver((entries) => {
				entries.forEach((entry) => {
					const el = entry.target as observerElement;
					if (entry.intersectionRatio > 0) {
						el.__expose__ = true;
						expose(el); // 开始曝光
					} else if (el.__expose__) {
						endExpose(el); // 结束曝光
					}
				});
			});
			io.observe(el);
			// el.__observerFn__ = io;
		}
	};
	return { vObserver, vList };
}

相关属性

overflow-anchor

mdn: overflow-anchor

overflow-anchor CSS 属性提供一种退出浏览器滚动锚定行为的方法,该行为会调整滚动位置以最大程度地减少内容偏移。 默认情况下,在任何支持滚动锚定行为的浏览器中都将其启用。 因此,仅当你在文档或文档的一部分中遇到滚动锚定问题并且需要关闭行为时,才通常需要更改此属性的值。

css
/* 浏览器兼容性:ios 不支持 */
overflow-anchor: auto; /* 滚动锚定行为 */
overflow-anchor: none; /* 退出滚动锚定行为 */
/* 浏览器兼容性:ios 不支持 */
overflow-anchor: auto; /* 滚动锚定行为 */
overflow-anchor: none; /* 退出滚动锚定行为 */

scroll-behavior

mdn: scroll-behavior

当用户手动导航或者 CSSOM scrolling API 触发滚动操作时, CSS 属性 scroll-behavior 为一个滚动框指定滚动行为,其他任何的滚动, 例如那些由于用户行为而产生的滚动,不受这个属性的影响。在根元素中指定这个属性时,它反而适用于视窗。

css
scroll-behavior: auto; /* 滚动框无过度立即滚动。 */
scroll-behavior: smooth; /* 滚动框通过一个用户代理预定义的时长、使用预定义的时间函数,来实现平稳的滚动 */
scroll-behavior: auto; /* 滚动框无过度立即滚动。 */
scroll-behavior: smooth; /* 滚动框通过一个用户代理预定义的时长、使用预定义的时间函数,来实现平稳的滚动 */

-webkit-overflow-scrolling

mdn: -webkit-overflow-scrolling

属性控制元素在移动设备上是否使用滚动回弹效果。

css
-webkit-overflow-scrolling: touch; /* 当手指从触摸屏上移开,会保持一段时间的滚动 */
-webkit-overflow-scrolling: auto; /* 当手指从触摸屏上移开,滚动会立即停止 */
-webkit-overflow-scrolling: touch; /* 当手指从触摸屏上移开,会保持一段时间的滚动 */
-webkit-overflow-scrolling: auto; /* 当手指从触摸屏上移开,滚动会立即停止 */

༼ つ/̵͇̿̿/’̿’̿ ̿ ̿̿ ̿̿◕ _◕ ༽つ/̵͇̿̿/’̿’̿ ̿ ̿̿ ̿̿ ̿̿