稀土掘金技术社区 2024年11月26日
Vue3 封装不定高虚拟列表 hooks,复用性更好!
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

文章探讨页面渲染卡顿问题,提出虚拟列表方案。介绍了定高虚拟列表的实现及相关概念,还解决了不定高场景下的几个问题,如获取列表项高度、计算总高度、确定开始索引等,并给出了代码示例和效果演示。

🎯虚拟列表解决页面渲染卡顿,保证每次实际渲染数量不递增

📜定高虚拟列表需考虑滚动模拟、渲染正确内容及数据在可视区

💡不定高场景下依次解决列表项高度、总高度、开始索引问题

📄给出虚拟列表的代码实现及不同组件的应用示例

熊的猫 2024-11-26 08:30 重庆

点击关注公众号,“技术干货” 及时达!

某日某时某刻某分某秒,收到 「小 A 同学」 的消息,原因是他司有人反馈某项目中页面渲染内容太慢、太卡,且后端开发也贴出接口响应很快的日志,于是乎这个 优化 的小任务就落到了他头上。

经过简单询问得知:

模拟效果(渲染 「3w」 数据)如下:

当然 「小 A 同学」 很快就想到了自己实现滚动加载:

于是马上进行提测,而测试同学也非常的敬业,一直滚动加载到了 「几千条」 数据,此时虽然在渲染表格项的时候没有出现卡顿,但是点击表格项时需要弹窗的这个交互,却又开始卡顿了,模拟效果如下(此处省略分批渲染):

table 慢元素

由于 table 元素在渲染时需要 「更多的计算资源」,这其中需要计算表格的布局、单元格的大小和位置等,这可能会导致在 「某些情况」table 元素的渲染速度较慢,因此 table 元素也叫 「慢元素」

现在的问题显然由于使用 「慢元素渲染大数据」 而造成渲染卡顿、交互不流畅的问题,而前面的 「分页加载」 虽然可以解决 「前期渲染卡顿」 的问题,却不能解决 「后期弹窗交互卡顿」 的问题,原因就是 最后实际需要渲染的慢元素根本没有减少

那有什么办法能 「保证每次实际渲染的数量不会递增」 呢?

有,就是 只渲染可视区及其周边的数据,而这也就是 虚拟列表 的核心。

接下来我们会封装一个和虚拟列表相关的 「hooks」,不封装成组件的目的就是为了让此方法更加的通用,「不局限外部使用的第三方组件或自己封装的组件」,让其既支持 table 形式,又让其支持普通的 list 形式,还能让其支持 select 形式

虚拟列表 — 定高

要实现虚拟列表需要考虑如下三个方面:

这里在引入三个名称和配图,方便进行理解,具体如下:

不到 100 行即可拥有虚拟滚动,具体实现如下:

// useVirtualList.ts
import { 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 组件」 的选择器可用如下的方式:

const { actualRenderData } = useVirtualList({  data: tableData, // 列表项数据  itemHeight: 100,  size: 10,  scrollContainer: ".el-scrollbar__wrap", // 滚动容器  actualHeightContainer: ".el-scrollbar__view", // 渲染实际高度的容器  tranlateContainer: ".el-table__body", // 需要偏移的目标元素});

最终演示效果如下,演示效果是 「3w」 条数据,实际上 「10w」 条数据也是很丝滑:

虚拟列表 — 不定高

假如列表项高度是固定的,那么 实际列表渲染总高度 = 列表项数量 * 单个列表项高度,然而列表项的内容并不总是一致的。

首先,「不定高」 相对于 「定高」 场景下存在几个不确定的内容:

下面我们就依次解决这几个问题即可。

nextTick — 解决列表项高度未知性

在实际渲染列表项之前,无法获取到对应列表项的高度,那么我们就等到这个列表渲染后,在获取它的高度就可以了。

而在 Vue 中能够帮我们实现这个目的的就是 「nextTick」,回顾官方文档对其的描述:

也就是说,当我们计算出需要 「实际渲染数据 actualRenderData」 时,基于响应式的存在,这个数据最终会渲染成页面上的 「Dom」,此时在 nextTick 中就能获取到已渲染到页面上的列表项的高度了。

nextTick(() => {  // 获取所有列表项元素  const Items: HTMLElement[] = Array.from(    document.querySelectorAll(config.itmeContainer)  );  ...};

cache 缓存 — 解决实际渲染总高度未知性

上面我们实现了不定高列表项高度的获取,但是单纯这样还是无法获取到 「实际渲染的总高度」,因为每次只是渲染 「部分数据」,所以我们需要把每次渲染好的列表项高度给存起来,建立 「缓存 cache」,缓存的对应关系就设置为:

更新好缓存后,所有列表项的总渲染高度就好计算了,只需要 「遍历数据源」,拿到对应的 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,// 单次渲染数量});

「el-table 组件」,如下:

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.ts
import { 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(); }); };
// 获取缓存高度,无缓存,取配置项的 itemHeight const 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 等组件,同时也能够支持自定义组件,只要其结构符合即可,这比封装成 「虚拟列表组件」 更合适。

点击关注公众号,“技术干货” 及时达!

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

虚拟列表 页面渲染 定高不定高 代码实现
相关文章