web 使用 时间分片或虚拟列表 提高渲染大量数据的渲染速度

270 阅读2分钟

场景

当需求是需要一次性渲染几千上万甚至十万或更多条数据的时候,如果我们一次性在页面上渲染会导致页面渲染很慢甚至卡死

优化方法

  1. 时间分片
  2. 虚拟列表

时间分片

使用 requestAnimationFrame + document.createDocumentFragment()

requestAnimationFrame 更符合浏览器的刷新节奏,配合 document.createDocumentFragment 批量更新 DOM,更节省渲染性能

<body>
	<ul id="container"></ul>
	<script>
		const total = 100000;
		const once = 20;
		const page = total / once;
		let index = 0;

		const ul = document.getElementById('container');

		function loop(curTotal, curIndex) {
			const pageCount = Math.min(once, curTotal);
			requestAnimationFrame(() => {
				// 创建一个文档碎片,是一个虚拟的DOM结构
				const fragment = document.createDocumentFragment();
				for (let i = 0; i < pageCount; i++) {
					const li = document.createElement('li');
					li.innerText = `${curIndex + i}: ${Math.random()}`;
					fragment.appendChild(li);
				}
				// 固定每20个li只回流一次
				ul.appendChild(fragment);
				if (curTotal > pageCount) {
					loop(curTotal - pageCount, curIndex + pageCount);
				}
			});
		}

		loop(total, index);
	</script>
</body>

虚拟列表

只渲染可见区域的数据,而非一次性渲染所有数据

  1. 初始化:设置一个固定高度的容器和数据源。
  2. 计算可见区域:通过容器高度和单个数据项高度,计算当前可见的数据条数。
  3. 监听滚动事件:实时更新需要渲染的起始和结束数据索引。
  4. 动态渲染:只更新当前视口内的 DOM 元素,减少渲染开销。

将虚拟列表封装成一个组件,在多个需要调用的地方直接调用即可

<template>
	<div ref="listRef" class="infinite-list-container" @scroll="scrollEvent()">
		<div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>

		<div class="infinite-list" :style="{ transform: getTransform }">
			<div
				class="infinite-list-item"
				v-for="item in visibleData"
				:key="item.id"
				:style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
			>
				{{ item.value }}
			</div>
		</div>
	</div>
</template>
<script setup>
	import { onMounted, reactive, ref, computed } from 'vue';
	const props = defineProps({
		listData: [],
		// 每个item的高度
		itemSize: {
			type: Number,
			default: 50,
		},
	});

	const state = reactive({
		screenHeight: 0, // 可视区域的高度
		startOffset: 0, // 偏移量
		start: 0, // 起始数据下标
		end: 0, // 结束数据下标
	});

	// 可视区域显示的数据条数
	const visibleCount = computed(() => {
		return state.screenHeight / props.itemSize;
	});

	// 可视区域显示的真实数据
	const visibleData = computed(() => {
		return props.listData.slice(state.start, Math.min(props.listData.length, state.end));
	});

	// 当前列表总高度
	const listHeight = computed(() => {
		return props.listData.length * props.itemSize;
	});

	// list跟着父容器移动了,现在列表要移回来
	const getTransform = computed(() => {
		return `translateY(${state.startOffset}px)`;
	});

	// 获取可视区域的高度
	const listRef = ref(null);
	onMounted(() => {
		state.screenHeight = listRef.value.clientHeight;
		state.end = state.start + visibleCount.value;
	});

	const scrollEvent = () => {
		let scrollTop = listRef.value.scrollTop;
		state.start = Math.floor(scrollTop / props.itemSize);
		state.end = state.start + visibleCount.value;
		state.startOffset = scrollTop - (scrollTop % props.itemSize);
	};
</script>

<style lang="css" scoped>
	.infinite-list-container {
		height: 100%;
		overflow: auto;
		position: relative;
		-webkit-overflow-scrolling: touch; /* 启用触摸滚动 */
	}
	.infinite-list-phantom {
		position: absolute;
		left: 0;
		top: 0;
		right: 0;
		z-index: -1; /* 置于背景层 **/
	}
	.infinite-list {
		position: absolute;
		left: 0;
		top: 0;
		right: 0;
		text-align: center;
	}
	.infinite-list-item {
		border-bottom: 1px, solid, #000;
		box-sizing: border-box;
	}
</style>

页面使用

<template>
	<div class="app">
		<virtualList :listData="data" />
	</div>
</template>

<script setup>
	import { ref } from 'vue';
	import virtualList from './components/virtualList.vue';

	// 数据源
	const data = ref([]);

	for (let i = 0; i < 1000; i++) {
		data.value.push({ id: i, value: i });
	}
</script>

<style lang="css" scoped>
	.app {
		width: 300px;
		height: 400px;
		border: 1px solid #000;
	}
</style>