面试官:不会“不定高”虚拟列表,你在简历上面提他干嘛?

news/2024/12/27 8:50:31/文章来源:https://www.cnblogs.com/heavenYJJ/p/18634226

前言

很多同学将虚拟列表当做亮点写在简历上面,但是却不知道如何手写,那么这个就不是加分项而是减分项了。在上一篇文章欧阳教会你 如何实现一个定高虚拟列表 ,但是实际项目中更多的是不定高虚拟列表,这篇文章欧阳来教你不定高如何实现。PS:建议先看看欧阳的上一篇 如何实现一个定高虚拟列表 后再来看这篇效果更佳。

欧阳也在找工作,坐标成都求内推!

什么是不定高虚拟列表

不定高的意思很简单,就是不知道每一项item的具体高度,如下图:
v1

现在我们有个问题,在不定高的情况下我们就不能根据当前滚动条的scrollTop去计算可视区域里面实际渲染的第一个item的index位置,也就是start的值。

没有start,那么就无法实现在滚动的时候只渲染可视区域的那几个item了。

预估高度

既然我们不知道每个item的高度,那么就采用预估高度的方式去实现。比如这样:

const { listData, itemSize } = defineProps({// 列表数据listData: {type: Array,default: () => [],},// 预估item高度,不是真实item高度itemSize: {type: Number,default: 300,},
});

还是和上一篇一样的套路,计算出当前可视区域的高度containerHeight,然后结合预估的itemSize就可以得到当前可视区域里面渲染的item数量。代码如下:

const renderCount = computed(() => Math.ceil(containerHeight.value / itemSize));

注意:由于我们是预估的高度,所以这个renderCount的数量是不准的。

如果预估的高度比实际高太多,那么实际渲染的item数量就会不够,导致页面下方出现白屏的情况。

如果预估的高度太小,那么这里的item数量就会渲染的太多了,性能又没之前那么好。

所以预估item高度需要根据实际业务去给一个适当的值,理论上是宁可预估小点,也不预估的大了(大了会出现白屏)。

start初始值为0,并且算出了renderCount,此时我们也就知道了可视区域渲染的最后一个end的值。如下:

const end = computed(() => start.value + renderCount.value);

和上一篇一样计算end时在下方多渲染了一个item,第一个item有一部分滚出可视区域的情况时,如果不多渲染可能就会出现白屏的情况。

有了startend,那么就知道了可视区域渲染的renderList,代码如下:

const renderList = computed(() => listData.slice(start.value, end.value + 1));

这样我们就知道了,初始化时可视区域应该渲染哪些item了,但是因为我们之前是给每个item预估高度,所以我们应该将这些高度的值纠正过来

更新高度

为了记录不定高的list里面的每个item的高度,所以我们需要一个数组来存每个item的高度。所以我们需要定义一个positions数组来存这些值。

既然都存了每个item的高度,那么同样可以使用topbottom这两个字段去记录每个item在列表中的开始位置结束位置。注意bottom - top的值肯定等于height的值。

还有一个index字段记录每个item的index的值。positions定义如下:

const positions = ref<{index: number;height: number;top: number;bottom: number;}[]
>([]);

positions的初始化值为空数组,那么什么时候给这个数组赋值呢?

答案很简单,虚拟列表渲染的是props传入进来的listData。所以我们watch监听listData,加上immediate: true。这样就可以实现初始化时给positions赋值,代码如下:

watch(() => listData, initPosition, {immediate: true,
});function initPosition() {positions.value = [];listData.forEach((_item, index) => {positions.value.push({index,height: itemSize,top: index * itemSize,bottom: (index + 1) * itemSize,});});
}

遍历listData结合预估的itemSize,我们就可以得出每一个item里面的heighttopbottom这几个字段的值。

还有一个问题,我们需要一个元素来撑开滚动条。在定高的虚拟列表中我们是通过itemSize * listData.length得到的。显然这里不能那样做了,由于positions数组中存的是所有item的位置,那么最后一个item的bottom的值就是列表的真实高度。前面也是不准的,会随着我们纠正positions中的值后他就是越来越准的了。

所以列表的真实高度为:

const listHeight = computed(() => positions.value[positions.value.length - 1].bottom
);

此时positions数组中就已经记录了每个item的具体位置,虽然这个位置是错的。接下来我们就需要将这些错误的值纠正过来,如何纠正呢?

答案很简单,使用Vue的onUpdated钩子函数,这个钩子函数会在响应式状态变更而更新其 DOM 树之后调用。也就是会在renderList渲染成DOM后触发!

此时这些item已经渲染成了DOM节点,那么我们就可以遍历这些item的DOM节点拿到每个item的真实高度。都知道每个item的真实高度了,那么也就能够更新里面所有item的topbottom了。代码如下:

<template><div ref="container" class="container" @scroll="handleScroll($event)"><div class="placeholder" :style="{ height: listHeight + 'px' }"></div><div class="list-wrapper" :style="{ transform: getTransform }"><divclass="card-item"v-for="item in renderList":key="item.index"ref="itemRefs":data-index="item.index"><span style="color: red">{{ item.index }}<img width="200" :src="item.imgUrl" alt="" /></span>{{ item.value }}</div></div></div>
</template><script setup>
onUpdated(() => {updatePosition();
});function updatePosition() {itemRefs.value.forEach((el) => {const index = +el.getAttribute("data-index");const realHeight = el.getBoundingClientRect().height;let diffVal = positions.value[index].height - realHeight;const curItem = positions.value[index];if (diffVal !== 0) {// 说明item的高度不等于预估值curItem.height = realHeight;curItem.bottom = curItem.bottom - diffVal;for (let i = index + 1; i < positions.value.length - 1; i++) {positions.value[i].top = positions.value[i].top - diffVal;positions.value[i].bottom = positions.value[i].bottom - diffVal;}}});
}
</script>

使用:data-index="item.index"index绑定到item上面,更新时就可以通过+el.getAttribute("data-index")拿到对应item的index

itemRefs中存的是所有item的DOM元素,遍历他就可以拿到每一个item,然后拿到每个item在长列表中的index和真实高度realHeight

diffVal的值是预估的高度比实际的高度大多少,如果diffVal的值不等于0,说明预估的高度不准。此时就需要将当前item的高度height更新了,由于高度只会影响bottom的值,所以只需要更新当前item的heightbottom

由于当前item的高度变了,假如diffVal的值为正值,说明我们预估的高度多了。此时我们需要从当前item的下一个元素开始遍历,直到遍历完整个长列表。我们预估多了,那么只需要将后面的所有item整体都向上移一移,移动的距离就是预估的差值diffVal

所以这里需要从index + 1开始遍历,将遍历到的所有元素的topbottom的值都减去diffVal

将可视区域渲染的所有item都遍历一遍,将每个item的高度和位置都纠正过来,同时会将后面没有渲染到的item的topbottom都纠正过来,这样就实现了高度的更新。理论上从头滚到尾,那么整个长列表里面的所有位置和高度都纠正完了。

开始滚动

通过前面我们已经实现了预估高度值的纠正,渲染过的item的高度和位置都是纠正过后的了。此时我们需要在滚动后如何计算出新的start的位置,以及offset偏移量的值。

还是和定高同样的套路,当滚动条在item中间滚动时复用浏览器的滚动条,从一个item滚到另外一个item时才需要更新start的值以及offset偏移量的值。如果你看不懂这句话,建议先看我上一篇如何实现一个定高虚拟列表 文章。

此时应该如何计算最新的start值呢?

很简单!在positions中存了两个字段分别是topbottom,分别表示当前item的开始位置结束位置。如果当前滚动条的scrollTop刚好在topbottom之间,也就是scrollTop >= top && scrollTop < bottom,那么是不是就说明当前刚好滚到这个item的位置呢。

并且由于在positions数组中bottom的值是递增的,那么问题不就变成了查找第一个item的scrollTop < bottom。所以我们得出:

function getStart(scrollTop) {return positions.value.findIndex((item) => scrollTop < item.bottom);
}

每次scroll滚动都会触发一次这个查找,那么我们可以优化上面的算法吗?

positions数组中的bottom字段是递增的,这很符合二分查找的规律。不了解二分查找的同学可以看看leetcode上面的这道题: https://leetcode.cn/problems/search-insert-position/description/。

所以上面的代码可以优化成这样:

function getStart(scrollTop) {let left = 0;let right = positions.value.length - 1;while (left <= right) {const mid = Math.floor((left + right) / 2);if (positions.value[mid].bottom === scrollTop) {return mid + 1;} else if (positions.value[mid].bottom < scrollTop) {left = mid + 1;} else {right = mid - 1;}}return left;
}

和定高的虚拟列表一样,当在start的item中滚动时直接复用浏览器的滚动,无需做任何事情。所以此时的offset偏移量就应该等于当前start的item的top值,也就是start的item前面的所有item加起来的高度。所以得出offset的值为:

offset.value = positions.value[start.value].top;

可能有的小伙伴会迷惑,在start的item中的滚动值为什么不算到offset偏移中去呢?

因为在start的item范围内滚动时都是直接使用的浏览器滚动,已经有了scrollTop,所以无需加到offset偏移中去。

所以我们得出当scroll事件触发时代码如下:

function handleScroll(e) {const scrollTop = e.target.scrollTop;start.value = getStart(scrollTop);offset.value = positions.value[start.value].top;
}

同样offset偏移值使用translate3d应用到可视区域的div上面,代码如下:

<template><div ref="container" class="container" @scroll="handleScroll($event)"><div class="placeholder" :style="{ height: listHeight + 'px' }"></div><div class="list-wrapper" :style="{ transform: getTransform }">...省略</div></div>
</template><script setup>
const props = defineProps({offset: {type: Number,default: 0,},
});
const getTransform = computed(() => `translate3d(0,${props.offset}px,0)`);
</script>

这个是最终的运行效果图:
demo

完整的父组件代码如下:

<template><div style="height: 100vh; width: 100vw"><VirtualList :listData="data" :itemSize="50" /></div>
</template><script setup>
import VirtualList from "./dynamic.vue";
import { faker } from "@faker-js/faker";
import { ref } from "vue";const data = ref([]);
for (let i = 0; i < 1000; i++) {data.value.push({index: i,value: faker.lorem.sentences(),});
}
</script><style>
html {height: 100%;
}
body {height: 100%;margin: 0;
}
#app {height: 100%;
}
</style>

完整的虚拟列表子组件代码如下:

<template><div ref="container" class="container" @scroll="handleScroll($event)"><div class="placeholder" :style="{ height: listHeight + 'px' }"></div><div class="list-wrapper" :style="{ transform: getTransform }"><divclass="card-item"v-for="item in renderList":key="item.index"ref="itemRefs":data-index="item.index"><span style="color: red">{{ item.index }}<img width="200" :src="item.imgUrl" alt="" /></span>{{ item.value }}</div></div></div>
</template><script setup lang="ts">
import { ref, computed, watch, onMounted, onUpdated } from "vue";
const { listData, itemSize } = defineProps({// 列表数据listData: {type: Array,default: () => [],},// 预估item高度,不是真实item高度itemSize: {type: Number,default: 300,},
});const container = ref(null);
const containerHeight = ref(0);
const start = ref(0);
const offset = ref(0);
const itemRefs = ref();
const positions = ref<{index: number;height: number;top: number;bottom: number;}[]
>([]);const end = computed(() => start.value + renderCount.value);
const renderList = computed(() => listData.slice(start.value, end.value + 1));
const renderCount = computed(() => Math.ceil(containerHeight.value / itemSize));
const listHeight = computed(() => positions.value[positions.value.length - 1].bottom
);
const getTransform = computed(() => `translate3d(0,${offset.value}px,0)`);watch(() => listData, initPosition, {immediate: true,
});function handleScroll(e) {const scrollTop = e.target.scrollTop;start.value = getStart(scrollTop);offset.value = positions.value[start.value].top;
}function getStart(scrollTop) {let left = 0;let right = positions.value.length - 1;while (left <= right) {const mid = Math.floor((left + right) / 2);if (positions.value[mid].bottom === scrollTop) {return mid + 1;} else if (positions.value[mid].bottom < scrollTop) {left = mid + 1;} else {right = mid - 1;}}return left;
}function initPosition() {positions.value = [];listData.forEach((_item, index) => {positions.value.push({index,height: itemSize,top: index * itemSize,bottom: (index + 1) * itemSize,});});
}function updatePosition() {itemRefs.value.forEach((el) => {const index = +el.getAttribute("data-index");const realHeight = el.getBoundingClientRect().height;let diffVal = positions.value[index].height - realHeight;const curItem = positions.value[index];if (diffVal !== 0) {// 说明item的高度不等于预估值curItem.height = realHeight;curItem.bottom = curItem.bottom - diffVal;for (let i = index + 1; i < positions.value.length - 1; i++) {positions.value[i].top = positions.value[i].top - diffVal;positions.value[i].bottom = positions.value[i].bottom - diffVal;}}});
}onMounted(() => {containerHeight.value = container.value.clientHeight;
});onUpdated(() => {updatePosition();
});
</script><style scoped>
.container {height: 100%;overflow: auto;position: relative;
}.placeholder {position: absolute;left: 0;top: 0;right: 0;z-index: -1;
}.card-item {padding: 10px;color: #777;box-sizing: border-box;border-bottom: 1px solid #e1e1e1;
}
</style>

总结

这篇文章我们讲了不定高的虚拟列表如何实现,首先给每个item设置一个预估高度itemSize。然后根据传入的长列表数据listData初始化一个positions数组,数组中的topbottomheight等属性表示每个item的位置。然后根据可视区域的高度加上itemSize算出可视区域内可以渲染多少renderCount个item。接着就是在onUpdated钩子函数中根据每个item的实际高度去修正positions数组中的值。

在滚动时查找第一个item的bottom大于scrollTop,这个item就是start的值。offset偏移的值为starttop属性。

值得一提的是如果不定高的列表中有图片就不能在onUpdated钩子函数中修正positions数组中的值,而是应该监听图片加载完成后再去修正positions数组。可以使用 ResizeObserver 去监听渲染的这一堆item,注意ResizeObserver的回调会触发两次,第一次为渲染item的时候,第二次为item中的图片加载完成后。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

另外欧阳写了一本开源电子书vue3编译原理揭秘,看完这本书可以让你对vue编译的认知有质的提升。这本书初、中级前端能看懂,完全免费,只求一个star。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/859659.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

如何管控经销商:从无序到有序,打造共赢生态!

企业与经销商之间的合作关系可以描述为一种基于共同利益和目标的战略联盟。这种关系旨在通过双方的协同努力,实现产品在市场上的有效推广和销售,从而为企业带来利润增长,同时也为经销商提供商业机会和收益。如何管控经销商,是确保销售渠道顺畅、维护品牌形象和提升销售业绩…

安全无忧,内外网文件交换系统打造企业信息流转新通道!

内外网文件交换系统是指一种能够在组织的内部局域网(Intranet)和外部互联网(Internet)之间实现文件传输和共享的系统,广泛应用于各种需要跨网络传输文件的场景,这些场景主要围绕数据安全、传输效率和业务需求的满足。比如: 1、企业内部数据共享 在企业内部,不同团队或部…

docker启动milvus后连接attu工具

https://help.aliyun.com/zh/milvus/user-guide/attu-user-guide

玩转前端正则表达式

文章首发本人博客,由于格式和图片解析问题,可以前往 阅读原文JavaScript中的正则是Perl的大子集,但Perl内部的一些表达式却没有继承正则表达式是用于匹配字符串中字符组合的模式(可参考MDN教程) 扫码关注公粽号,查看更多优质文章一个例子 使用正则将一个数字以科学计数法进…

重拾 iptables

iptables 是一个常看常忘的命令,本文试图从应用的角度理解它 iptables 是运行在用户空间的应用软件,通过控制 Linux 内核 netfilter 模块,来管理网络数据包的处理和转发 一些常用的场景 1. 禁止 ip 访问后端 IP 在 192.168.64.6 上增加规则: # -A INPUT: 将规则添加到 INPU…

Vue 搭建开发环境

一、下载 js包二、引用js包<!--引用Vue包--><script type="text/javascript" src="../js/vue.js"></script>三、安装Vue Devtools 1、下载链接:链接:https://pan.baidu.com/s/1tKqpbZMRG1iC2PUgjsKOow 提取码:55me 2、Chrome浏览器安装…

《HelloGitHub》第 105 期

兴趣是最好的老师,HelloGitHub 让你对编程感兴趣!简介 HelloGitHub 分享 GitHub 上有趣、入门级的开源项目。github.com/521xueweihan/HelloGitHub这里有实战项目、入门教程、黑科技、开源书籍、大厂开源项目等,涵盖多种编程语言 Python、Java、Go、C/C++、Swift...让你在短…

做销售就一定要像销售吗?

真正厉害的销售,看起来都不像销售。 我知道大家都是为了业绩,但一上来就滔滔不绝地介绍产品,恨不得把产品手册背给客户听,这恰恰是销售中的大忌。 今天就和大家聊聊,如何做一个“不像销售”的销售,这也是我带了6年团队的心得。 一、像朋友不像推销 很多销售见到客户就像打…

.NET 在 Visual Studio 中的高效编程技巧集

前言 本文大姚将为你介绍一些Visual Studio的使用技巧和建议,旨在帮助.NET开发者更加高效地利用Visual Studio进行编程工作。无论你是.NET初学者还是经验丰富的.NET开发者,这些技巧都将有助于提升你的工作效率,让你能够更快地编写出高质量的代码。让我们一起探索这些技巧,让…

读数据保护:工作负载的可恢复性18即时恢复的备份方案

即时恢复的备份方案1. 即时恢复的备份方案 1.1. 即时恢复,就是在文件、文件系统、数据库、虚拟机或应用程序遭到损坏之后,无须经历某种恢复流程就可以将其立刻恢复出来 1.2. 适合用在对RTO要求很严的场合 1.3. 最常见的即时恢复方案就是从快照里面恢复,只需要一条命令即可把…

博客园js依赖问题,defer和async

背景 我在博客园后台写的自定义script依赖某个js库,他始终提示xxx not defined. 原因通过对比,不难发现,博客园偷偷改了你的代码,用defer优化了一下,防止js代码的加载阻塞dom渲染。 defer和async script标签可以使用defer或者async属性。 defer: 到dom渲染完毕后执行。 as…

[TSDB] OpenGemini 运维指南

OpenGemini 运维指南 概述 : OpenGemini 运维 Gemix : 官方部署运维一体化工具 启动集群 : gemix cluster start {geminiClusterName} [root@vmw-b ~]# gemix cluster start gemini-test Starting cluster gemini-test... + [ Serial ] - SSHKeySet: privateKey=/root/.gemix/s…