Vue3 封装不定高虚拟列表 hooks,复用性更好!
点击
关注
公众号,“
技术干货
” 及时达!
前言
某日某时某刻某分某秒,收到 「小 A 同学」 的消息,原因是他司有人反馈某项目中页面渲染内容太慢、太卡,且后端开发也贴出接口响应很快的日志,于是乎这个 「
优化
」
的小任务就落到了他头上。
经过简单询问得知:
- 页面上某个 「table 组件」 渲染的数据 「不是分页的」 ,接口将查到的所有符合的数据一股脑返回给了前端,约几万条数据
- 前端页面表现是 「渲染慢、交互卡」
一:治标不治本-滚动加载
当然 「小 A 同学」 很快就想到了自己实现滚动加载:- 「每次渲染20条数据」 ,当滚动条 「触底后继续渲染」
此处省略分批渲染
」
):
table 慢元素
由于 「
table
」
元素在渲染时需要
「更多的计算资源」
,这其中需要计算表格的布局、单元格的大小和位置等,这可能会导致在
「某些情况」
下
「
table
」
元素的渲染速度较慢,因此
「
table
」
元素也叫
「慢元素」
。
最后实际需要渲染的慢元素根本没有减少
」
。
那有什么办法能
「保证每次实际渲染的数量不会递增」
呢?
有,就是
「
只渲染可视区及其周边的数据
」
,而这也就是
「
虚拟列表
」
的核心。
二、虚拟列表
接下来我们会封装一个和虚拟列表相关的 「hooks」 ,不封装成组件的目的就是为了让此方法更加的通用, 「不局限外部使用的第三方组件或自己封装的组件」 ,让其既支持 「
table 形式
」
,又让其支持普通的
「
list 形式
」
,还能让其支持
「
select 形式
」
。
虚拟列表 — 定高
要实现虚拟列表需要考虑如下三个方面:
- 「滚动模拟」
-
「
普通列表渲染」 是 「可滚动」 的,滚动产生的条件就是 「每次渲染数量会递增」 ,那么 「虚拟列表」 就需要在保证 「每次渲染数量不递增」 的情况下 「支持滚动」 - 「渲染正确的内容」
-
保证用户在向上或向下滚动的过程中数据的
「
渲染内容是正确的」 ,只有这样看起来才和 「普通列表」 表现一致 - 「渲染的数据需要在可视区」
-
「
虚拟列表」 支持滚动之后,就需要保证渲染的数据一直存在于 「可视区」 ,而不是随着滚动到可视区之外
这里在引入三个名称和配图,方便进行理解,具体如下:
- 「滚动容器」
-
顾名思义,就是为了实现滚动,所以需要设置
「
height固定高度」 或 「最大高度max-height」 - 「渲染实际高度的容器」
- 为了实现模拟滚动,需要将实际高度的值,即 「每个列表项高度之和」 设置在某个元素上,这样就可以超过 「滚动容器的高度」 ,从而产生滚动效果
- 「偏移容器」
-
要实现渲染的数据始终处于可视区,那么可以针对
「包裹着所有列表项的元素」
进行处理,也就是将它的
「
transform: translateY(n)」 值设置为 「当前已滚动的高度scrollTop」 即可 -
同时要保证每个滚动位置要渲染正确的数据,那么最简单的方式就是,根据
「当前已滚动的高度
scrollTop」 除以 「单个列表项的高低 height」 ,计算出当前需要渲染的 「起始索引startIndex」 ,假设每次需要渲染 「20 条」 数据,很容易算出 「结束索引endIndex」 ,这样就可以知道当前滚动位置需要渲染的数据范围是什么
不到 100 行即可拥有虚拟滚动,具体实现如下:
针对 「自定义列表结构」 应符合如下结构:// useVirtualList.tsimport { ref, onMounted, onBeforeUnmount, watch, computed} from "vue";import type { Ref } from "vue";interface Config {data: Ref<any[]>; // 数据itemHeight: number;// 列表项高度size: number;// 每次渲染数据量scrollContainer: string;// 滚动容器的元素选择器actualHeightContainer: string;// 用于撑开高度的元素选择器tranlateContainer: string;// 用于偏移的元素选择器}type HtmlElType = HTMLElement | null;export default function useVirtualList(config: Config) {// 获取元素let actualHeightContainerEl: HtmlElType = null,tranlateContainerEl: HtmlElType = null,scrollContainerEl: HtmlElType = null;onMounted(() => {actualHeightContainerEl = document.querySelector(config.actualHeightContainer);scrollContainerEl = document.querySelector(config.scrollContainer);tranlateContainerEl = document.querySelector(config.tranlateContainer);});// 通过设置高度,模拟滚动watch(() => config.data.value, (newVal) => {actualHeightContainerEl!.style.height = newVal.length * config.itemHeight + "px";});// 实际渲染的数据const startIndex = ref(0);const endIndex = ref(config.size - 1);const actualRenderData = computed(() => {return config.data.value.slice(startIndex.value, endIndex.value + 1);});// 滚动事件const handleScroll = (e) => {const target = e.target;const { scrollTop, clientHeight, scrollHeight } = target;// 边界控制:实际触底,且页面正常渲染全部数据时,不再触发后续计算,防止触底抖动if (scrollHeight <= scrollTop + clientHeight &&endIndex.value >= config.data.value.length) {return;}// 保证数据渲染一直在可视区tranlateContainerEl.style.transform = `translateY(${scrollTop}px)`;// 渲染正确的数据startIndex.value = Math.floor(scrollTop / config.itemHeight);endIndex.value = startIndex.value + config.size;};// 注册滚动事件onMounted(() => {scrollContainerEl?.addEventListener("scroll", handleScroll);});// 移除滚动事件onBeforeUnmount(() => {scrollContainerEl?.removeEventListener("scroll", handleScroll);});return { actualRenderData };}
针对 「<ul class="scroll-container"> // 滚动容器<div class="actual-height-container">// 渲染实际高度的容器<div class="tranlate-container"> // 用于偏移的容器<li v-for="(item, i) in actualRenderData">...</li></div></div></ul>
el-table
组件」
的选择器可用如下的方式:
最终演示效果如下,演示效果是 「3w」 条数据,实际上 「10w」 条数据也是很丝滑:const { actualRenderData } = useVirtualList({data: tableData, // 列表项数据itemHeight: 100,size: 10,scrollContainer: ".el-scrollbar__wrap", // 滚动容器actualHeightContainer: ".el-scrollbar__view", // 渲染实际高度的容器tranlateContainer: ".el-table__body", // 需要偏移的目标元素});
虚拟列表 — 不定高
假如列表项高度是固定的,那么 「
实际列表渲染总高度 = 列表项数量 * 单个列表项高度
」
,然而列表项的内容并不总是一致的。
首先,
「不定高」
相对于
「定高」
场景下存在几个不确定的内容:
-
「
每个列表项实际渲染高度无法直接获取」 -
「
实际渲染总高度无法直接计算」 -
「滚动时对应需要渲染数据的开始索引
startIndex无法直接计算」
下面我们就依次解决这几个问题即可。
nextTick — 解决列表项高度未知性
在实际渲染列表项之前,无法获取到对应列表项的高度,那么我们就等到这个列表渲染后,在获取它的高度就可以了。
而在 Vue 中能够帮我们实现这个目的的就是 「nextTick」 ,回顾官方文档对其的描述:-
当 Vue 中
「
更改响应式状态」 时,最终的 「DOM 更新」 并 「不是同步生效」 的,而是由 Vue 将它们 「缓存在一个队列」 中,直到下一个 「tick」 才一起执行,这样是为了确保每个组件 「无论发生多少状态改变」 ,都 「仅执行一次更新」
nextTick
」
中就能获取到已渲染到页面上的列表项的高度了。
nextTick(() => {// 获取所有列表项元素const Items: HTMLElement[] = Array.from(document.querySelectorAll(config.itmeContainer));...};
cache 缓存 — 解决实际渲染总高度未知性
上面我们实现了不定高列表项高度的获取,但是单纯这样还是无法获取到 「实际渲染的总高度」 ,因为每次只是渲染 「部分数据」 ,所以我们需要把每次渲染好的列表项高度给存起来,建立 「缓存 cache」 ,缓存的对应关系就设置为:-
「cache」
的
「
key」 就是当前列表项在 「数据源中的 index」 -
「cache[key]」
的
「
value」 就是当前列表项的 「渲染高度」
index
」
再去
「
缓存 cache
」
中获取高度,然后累加即可。
值得注意的是,初始化时
「
缓存 cache
」
为空,此时无法从中获取的高度,因此我们需要给定一个接近列表的高度值,当缓存中取不到值时,就使用此高度参与计算即可。
// 更新已渲染列表项的缓存高度const updateRenderedItemCache = (index: number) => {nextTick(() => {// 获取所有列表项元素const Items: HTMLElement[] = Array.from(document.querySelectorAll(config.itmeContainer));// 进行缓存Items.forEach((el) => {if (!RenderedItemsCache[index]) {RenderedItemsCache[index] = el.offsetHeight;}index++;});...});};
scrollTop + cache 缓存 — 解决列表 startIndex 未知性
要计算当前需要渲染数据的 「开始索引
startIndex
」
,在不定高的场景下,我们可以
「从
cache 缓存
中依次计算列表项的高度之和
offsetHeight
」
,直到
「
offsetHeight >= scrollTop
」
,那么此时
「该列表项
index
」
就可以作为当前需要渲染数据的
「开始索引
startIndex
」
。
offsetHeight
」
后,其实它就是列表项需要偏移的值,只不过初始化
「
scrollTop = 0
」
时实际上是不需要偏移的,但此时计算出
「
offsetHeight
」
的值为
「开始索引
startIndex
」
列表项的高度,因此在实际偏移是我们需要减去这个值。
// 更新实际渲染数据const updateRenderData = (scrollTop: number) => {let startIndex = 0;let offsetHeight = 0;for (let i = 0; i < dataSource.length; i++) {offsetHeight += getItemHeightFromCache(i);if (offsetHeight >= scrollTop) {startIndex = i;break;}}// 计算得出的渲染数据actualRenderData.value = dataSource.slice(startIndex,startIndex + config.size);// 缓存最新的列表项高度updateRenderedItemCache(startIndex);// 更新偏移值updateOffset(offsetHeight - getItemHeightFromCache(startIndex));};
效果演示
「普通 List 列表」 ,如下:const { actualRenderData } = useVirtualList({data: tableData, // 列表项数据scrollContainer: ".scroll-container", // 滚动容器actualHeightContainer: ".actual-height-container", // 渲染实际高度的容器translateContainer: ".translate-container", // 需要偏移的目标元素,itmeContainer: '.item',// 列表项itemHeight: 50,// 列表项的大致高度size: 10,// 单次渲染数量});
const { actualRenderData } = useVirtualList({data: tableData, // 列表项数据scrollContainer: ".el-scrollbar__wrap", // 滚动容器actualHeightContainer: ".el-scrollbar__view", // 渲染实际高度的容器tranlateContainer: ".el-table__body", // 需要偏移的目标元素,itmeContainer: '.el-table__row',// 列表项itemHeight: 50,// 列表项的大致高度size: 10,// 单次渲染数量});
完整代码
// useVirtualList.tsimport { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";import type { Ref } from "vue";interface Config {data: Ref<any[]>; // 数据源scrollContainer: string; // 滚动容器的元素选择器actualHeightContainer: string; // 用于撑开高度的元素选择器translateContainer: string; // 用于偏移的元素选择器itmeContainer: string;// 列表项选择器itemHeight: number; // 列表项高度size: number; // 每次渲染数据量}type HtmlElType = HTMLElement | null;export default function useVirtualList(config: Config) {// 获取元素let actualHeightContainerEl: HtmlElType = null,translateContainerEl: HtmlElType = null,scrollContainerEl: HtmlElType = null;onMounted(() => {actualHeightContainerEl = document.querySelector(config.actualHeightContainer);scrollContainerEl = document.querySelector(config.scrollContainer);translateContainerEl = document.querySelector(config.translateContainer);});// 数据源,便于后续直接访问let dataSource: any[] = [];// 数据源发生变动watch(() => config.data.value,(newVla) => {// 更新数据源dataSource = newVla;// 计算需要渲染的数据updateRenderData(0);});// 更新实际高度const updateActualHeight = () => {let actualHeight = 0;dataSource.forEach((_, i) => {actualHeight += getItemHeightFromCache(i);});actualHeightContainerEl!.style.height = actualHeight + "px";};// 缓存已渲染元素的高度const RenderedItemsCache: any = {};// 更新已渲染列表项的缓存高度const updateRenderedItemCache = (index: number) => {// 当所有元素的实际高度更新完毕,就不需要重新计算高度const shouldUpdate =Object.keys(RenderedItemsCache).length < dataSource.length;if (!shouldUpdate) return;nextTick(() => {// 获取所有列表项元素const Items: HTMLElement[] = Array.from(document.querySelectorAll(config.itmeContainer));// 进行缓存Items.forEach((el) => {if (!RenderedItemsCache[index]) {RenderedItemsCache[index] = el.offsetHeight;}index++;});// 更新实际高度updateActualHeight();});};// 获取缓存高度,无缓存,取配置项的 itemHeightconst getItemHeightFromCache = (index: number | string) => {const val = RenderedItemsCache[index];return val === void 0 ? config.itemHeight : val;};// 实际渲染的数据const actualRenderData: Ref<any[]> = ref([]);// 更新实际渲染数据const updateRenderData = (scrollTop: number) => {let startIndex = 0;let offsetHeight = 0;for (let i = 0; i < dataSource.length; i++) {offsetHeight += getItemHeightFromCache(i);if (offsetHeight >= scrollTop) {startIndex = i;break;}}// 计算得出的渲染数据actualRenderData.value = dataSource.slice(startIndex,startIndex + config.size);// 缓存最新的列表项高度updateRenderedItemCache(startIndex);// 更新偏移值updateOffset(offsetHeight - getItemHeightFromCache(startIndex));};// 更新偏移值const updateOffset = (offset: number) => {translateContainerEl!.style.transform = `translateY(${offset}px)`;};// 滚动事件const handleScroll = (e: any) => {// 渲染正确的数据updateRenderData(e.target.scrollTop);};// 注册滚动事件onMounted(() => {scrollContainerEl?.addEventListener("scroll", handleScroll);});// 移除滚动事件onBeforeUnmount(() => {scrollContainerEl?.removeEventListener("scroll", handleScroll);});return { actualRenderData };}
最后
综上,我们通过 「封装 hooks」 将虚拟列表核心逻辑进行抽离,就不用局限于某个组件中,这样就可以支持第三方组件库中的 「
List、Select、Table
」
等组件,同时也能够支持自定义组件,只要其结构符合即可,这比封装成
「虚拟列表组件」
更合适。