diff --git a/src/views/demo/vListDoc1/img/1.png b/src/views/demo/vListDoc1/img/1.png new file mode 100644 index 0000000..28d2ec1 Binary files /dev/null and b/src/views/demo/vListDoc1/img/1.png differ diff --git a/src/views/demo/vListDoc1/img/2.png b/src/views/demo/vListDoc1/img/2.png new file mode 100644 index 0000000..ec1272a Binary files /dev/null and b/src/views/demo/vListDoc1/img/2.png differ diff --git a/src/views/demo/vListDoc1/img/3.png b/src/views/demo/vListDoc1/img/3.png new file mode 100644 index 0000000..29966f9 Binary files /dev/null and b/src/views/demo/vListDoc1/img/3.png differ diff --git a/src/views/demo/vListDoc1/img/4.png b/src/views/demo/vListDoc1/img/4.png new file mode 100644 index 0000000..d3c54ed Binary files /dev/null and b/src/views/demo/vListDoc1/img/4.png differ diff --git a/src/views/demo/vListDoc1/img/5.gif b/src/views/demo/vListDoc1/img/5.gif new file mode 100644 index 0000000..2bc81d5 Binary files /dev/null and b/src/views/demo/vListDoc1/img/5.gif differ diff --git a/src/views/demo/vListDoc1/index.vue b/src/views/demo/vListDoc1/index.vue new file mode 100644 index 0000000..76e3d9b --- /dev/null +++ b/src/views/demo/vListDoc1/index.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/views/demo/vListDoc1/vListDoc1.md b/src/views/demo/vListDoc1/vListDoc1.md new file mode 100644 index 0000000..c5a6595 --- /dev/null +++ b/src/views/demo/vListDoc1/vListDoc1.md @@ -0,0 +1,266 @@ +# 给我2分钟,保证教会你在Vue3中实现一个定高的虚拟列表 + +# 前言 + +虚拟列表对于大部分一线开发同学来说是一点都不陌生的东西了,有的同学是直接使用第三方组件。但是面试时如果你简历上面写了虚拟列表,却给面试官说是通过三方组件实现的,此时空气可能都凝固了。所以这篇文章欧阳将会教你2分钟内实现一个定高的虚拟列表,至于不定高的虚拟列表下一篇文章来写。 + +# 什么是虚拟列表 + +有的特殊场景我们不能分页,只能渲染一个长列表。这个长列表中可能有几万条数据,如果全部渲染到页面上用户的设备差点可能就会直接卡死了,这时我们就需要虚拟列表来解决问题。 + +一个常见的虚拟列表是下面这样的,如下图: +![v1](/src/views/demo/vListDoc1/img/1.png) + +其中实线框的item表示在视口区域内真实渲染DOM,虚线框的item表示并没有渲染的DOM。 + +在定高的虚拟列表中,我们可以根据`可视区域的高度`和`每个item的高度`计算得出在可视区域内可以渲染多少个item。不在可视区域里面的item那么就不需要渲染了(不管有几万个还是几十万个item),这样就能解决长列表性能很差的问题啦。 + +# 实现滚动条 + +按照上面的图,很容易想到我们的dom结构应该是下面这样的: + +```javascript + + + +``` + +给可视区域`container`设置高度`100%`,也可以是一个固定高度值。并且设置`overflow: auto;`让内容在可视区域中滚动。 + +此时我们遇见第一个问题,滚动条是怎么来的,可视区域是靠什么撑开的? + +答案很简单,我们知道每个item的高度`itemSize`,并且知道有多少条数据`listData.length`。那么`itemSize * listData.length`不就是真实的列表高度了吗。所以我们可以在可视区域`container`中新建一个名为`placeholder`的空div,将他的高度设置为`itemSize * listData.length`,这样可视区域就被撑开了,并且滚动条也有了。代码如下: + +```javascript + + + + + +``` + +`placeholder`采用绝对定位,为了不挡住可视区域内渲染的列表,所以将其设置为`z-index: -1`。 + +接下来就是计算容器里面到底渲染多少个item,很简单,`Math.ceil(可视区域的高度 / 每个item的高度)`。 + +为什么使用`Math.ceil`向上取整呢? + +只要有个item在可视区域漏了一点出来,我们也应该将其渲染。 + +此时我们就能得到几个变量: + +* `start`:可视区域内渲染的第一个item的index的值,初始化为0。 + +* `renderCount`:可视区域内渲染的item数量。 + +* `end`:可视区域内渲染的最后一个item的index值,他的值等于`start + renderCount`。注意我们这里使用`start + renderCount`实际是多渲染了一个item,比如`start = 0`和`renderCount = 2`,我们设置的是`end = 2`,实际是渲染了3个item。目的是为了预渲染下一个,后面会讲。 + + +# 监听滚动事件 + +有了滚动条后就可以开始滚动了,我们监听`container`容器的scroll事件。 + +可视区域中的内容应该随着滚动条的滚动而变化,也就是说在scroll事件中我们需要重新计算`start`的值。 + +```javascript +function handleScroll(e) { + const scrollTop = e.target.scrollTop; + start.value = Math.floor(scrollTop / itemSize); + offset.value = scrollTop - (scrollTop % itemSize); +} +``` + +如果当前`itemSize`的值为100。 + +如果此时滚动的距离在0-100之间,比如下面这样: +![v1](/src/views/demo/vListDoc1/img/2.png) +上面这张图item1还没完全滚出可视区域,有部分在可视区域内,部分在可视区域外。此时可视区域内显示的就是`item1-item7`的模块了,这就是为什么前面我们计算end时要多渲染一个item,不然这里item7就没法显示了。 + +**滚动距离在0-100之间时,渲染的DOM没有变化,我们完全是复用浏览器的滚动,并没有进行任何处理。** + +当`scrollTop`的值为100时,也就是刚刚把item1滚到可视区外面时。此时item1已经不需要渲染了,因为已经看不见他了。所以此时的`start`的值就应该从`0`更新为`1`,同理如果`scrollTop`的值为`110`,start的值也一样是`1`。所以得出`start.value = Math.floor(scrollTop / itemSize);`如下图: +![v1](/src/views/demo/vListDoc1/img/3.png) +此时的`start`从item2开始渲染,但是由于前面我们复用了浏览器的滚动,所以实际渲染的DOM第一个已经在可视区外面了。此时可视区看见的第一个是item3,很明显是不对的,应该看见的是第一个是item2。 + +此时应该怎么办呢? + +很简单,使用`translate`将列表向下偏移一个item的高度就行,也就是100px。列表偏移后就是下面这样的了: +![v1](/src/views/demo/vListDoc1/img/4.png) +如果当前`scrollTop`的值为200,那么偏移值就是200px。所以我们得出 + +```javascript +offset.value = scrollTop - (scrollTop % itemSize); +``` + +为什么这里要减去`scrollTop % itemSize`呢? + +因为在滚动时如果是在item的高度范围内滚动,我们是复用浏览器的滚动,此时无需进行偏移,所以计算偏移值时需要减去`scrollTop % itemSize`。 + +实际上从一个item滚动到另外一个item时,比如从`item0`滚动到`item1`。此时会做两件事情:将`start`的值从`0`更新为`1`和根据`scrollTop`计算得到列表的偏移值`100`,从而让新的start对应的`item1`重新回到可视范围内。 + +这个是运行效果图: +![v1](/src/views/demo/vListDoc1/img/5.gif) +下面是完整的代码: + +```javascript + + + + + +``` + +这个是父组件的代码: + +```javascript + + + + + +``` + +# 总结 + +这篇文章我们讲了如何实现一个定高的虚拟列表,首先根据可视区域的高度和item的高度计算出视口内可以渲染出来的item数量`renderCount`。然后根据滚动的距离去计算`start`的位置,计算`end`的位置时使用`start + renderCount` 预渲染一个item。在每个item范围内滚动时直接复用浏览器的滚动,此时无需进行任何处理。当从一个item滚动到另外一个item时,此时会做两件事情:更新start的值和根据`scrollTop`计算列表的偏移值让新的start对应的item重新回到可视范围内。 \ No newline at end of file diff --git a/src/views/demo/vListDoc2/img/1.png b/src/views/demo/vListDoc2/img/1.png new file mode 100644 index 0000000..f34dacd Binary files /dev/null and b/src/views/demo/vListDoc2/img/1.png differ diff --git a/src/views/demo/vListDoc2/img/2.gif b/src/views/demo/vListDoc2/img/2.gif new file mode 100644 index 0000000..1f4ca5f Binary files /dev/null and b/src/views/demo/vListDoc2/img/2.gif differ diff --git a/src/views/demo/vListDoc2/index.vue b/src/views/demo/vListDoc2/index.vue new file mode 100644 index 0000000..361616a --- /dev/null +++ b/src/views/demo/vListDoc2/index.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/views/demo/vListDoc2/vListDocs2.md b/src/views/demo/vListDoc2/vListDocs2.md new file mode 100644 index 0000000..6b7d08e --- /dev/null +++ b/src/views/demo/vListDoc2/vListDocs2.md @@ -0,0 +1,463 @@ +# 面试官:不会“不定高”虚拟列表,你在简历上面提他干嘛? + +# 前言 + +很多同学将虚拟列表当做亮点写在简历上面,但是却不知道如何手写,那么这个就不是加分项而是减分项了。在上一篇文章欧阳教会你 [如何实现一个定高虚拟列表](https://mp.weixin.qq.com/s/unNbvl6L6vLHXcnyheI1UQ) ,但是实际项目中更多的是`不定高虚拟列表`,这篇文章欧阳来教你不定高如何实现。PS:建议先看看欧阳的上一篇 [如何实现一个定高虚拟列表](https://mp.weixin.qq.com/s/unNbvl6L6vLHXcnyheI1UQ) 后再来看这篇效果更佳。 + +# 什么是不定高虚拟列表 + +不定高的意思很简单,就是不知道每一项item的具体高度,如下图: +![v1](/src/views/demo/vListDoc2/img/1.png) + +现在我们有个问题,**在不定高的情况下我们就不能根据当前滚动条的`scrollTop`去计算可视区域里面实际渲染的第一个item的index位置,也就是`start`的值。** + +没有`start`,那么就无法实现在滚动的时候只渲染可视区域的那几个item了。 + +# 预估高度 + +既然我们不知道每个item的高度,那么就采用`预估高度`的方式去实现。比如这样: + +```javascript +const { listData, itemSize } = defineProps({ + // 列表数据 + listData: { + type: Array, + default: () => [], + }, + // 预估item高度,不是真实item高度 + itemSize: { + type: Number, + default: 300, + }, +}); +``` + +还是和上一篇一样的套路,计算出当前可视区域的高度`containerHeight`,然后结合预估的`itemSize`就可以得到当前可视区域里面渲染的item数量。代码如下: + +```javascript +const renderCount = computed(() => Math.ceil(containerHeight.value / itemSize)); +``` + +注意:由于我们是预估的高度,所以这个`renderCount`的数量是不准的。 + +如果预估的高度比实际高太多,那么实际渲染的item数量就会不够,导致页面下方出现白屏的情况。 + +如果预估的高度太小,那么这里的item数量就会渲染的太多了,性能又没之前那么好。 + +所以预估item高度需要根据实际业务去给一个适当的值,理论上是宁可预估小点,也不预估的大了(大了会出现白屏)。 + +start初始值为0,并且算出了`renderCount`,此时我们也就知道了可视区域渲染的最后一个`end`的值。如下: + +```javascript +const end = computed(() => start.value + renderCount.value); +``` + +和上一篇一样计算end时在下方多渲染了一个item,第一个item有一部分滚出可视区域的情况时,如果不多渲染可能就会出现白屏的情况。 + +有了`start`和`end`,那么就知道了可视区域渲染的`renderList`,代码如下: + +```javascript +const renderList = computed(() => listData.slice(start.value, end.value + 1)); +``` + +这样我们就知道了,初始化时可视区域应该渲染哪些item了,但是因为我们之前是给每个item**预估高度**,所以我们应该将这些高度的值**纠正过来**。 + +# 更新高度 + +为了记录不定高的list里面的每个item的高度,所以我们需要一个数组来存每个item的高度。所以我们需要定义一个`positions`数组来存这些值。 + +既然都存了每个item的高度,那么同样可以使用`top`、`bottom`这两个字段去记录每个item在列表中的`开始位置`和`结束位置`。注意`bottom - top`的值肯定等于`height`的值。 + +还有一个`index`字段记录每个item的index的值。`positions`定义如下: + +```javascript +const positions = ref< + { + index: number; + height: number; + top: number; + bottom: number; + }[] +>([]); +``` + +`positions`的初始化值为空数组,那么什么时候给这个数组赋值呢? + +答案很简单,虚拟列表渲染的是props传入进来的`listData`。所以我们watch监听`listData`,加上`immediate: true`。这样就可以实现初始化时给`positions`赋值,代码如下: + +```javascript +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里面的`height`、`top`、`bottom`这几个字段的值。 + +还有一个问题,我们需要一个元素来撑开滚动条。在定高的虚拟列表中我们是通过`itemSize * listData.length`得到的。显然这里不能那样做了,由于`positions`数组中存的是所有item的位置,**那么最后一个item的bottom的值就是列表的真实高度**。前面也是不准的,会随着我们纠正`positions`中的值后他就是越来越准的了。 + +所以列表的真实高度为: + +```javascript +const listHeight = computed( + () => positions.value[positions.value.length - 1].bottom +); +``` + +此时`positions`数组中就已经记录了每个item的具体位置,虽然这个位置是错的。接下来我们就需要将这些错误的值纠正过来,如何纠正呢? + +答案很简单,使用Vue的`onUpdated`钩子函数,这个钩子函数会在**响应式状态变更而更新其 DOM 树之后调用。**也就是会在`renderList`渲染成DOM后触发! + +此时这些item已经渲染成了DOM节点,那么我们就可以遍历这些item的DOM节点拿到每个item的真实高度。都知道每个item的真实高度了,那么也就能够更新里面所有item的`top`和`bottom`了。代码如下: + +```javascript + + + +``` + +使用`: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的`height`和`bottom`。 + +由于当前item的高度变了,假如`diffVal`的值为正值,说明我们预估的高度多了。此时我们需要从当前item的下一个元素开始遍历,直到遍历完整个长列表。我们预估多了,那么只需要将后面的所有item整体都向上移一移,移动的距离就是预估的差值`diffVal`。 + +所以这里需要从`index + 1`开始遍历,将遍历到的所有元素的`top`和`bottom`的值都减去`diffVal`。 + +将可视区域渲染的所有item都遍历一遍,将每个item的高度和位置都纠正过来,同时会将后面没有渲染到的item的`top`和`bottom`都纠正过来,这样就实现了高度的更新。理论上从头滚到尾,那么整个长列表里面的所有位置和高度都纠正完了。 + +# 开始滚动 + +通过前面我们已经实现了预估高度值的纠正,渲染过的item的高度和位置都是纠正过后的了。此时我们需要在滚动后如何计算出新的`start`的位置,以及`offset`偏移量的值。 + +还是和定高同样的套路,**当滚动条在item中间滚动时复用浏览器的滚动条,从一个item滚到另外一个item时才需要更新start的值以及offset偏移量的值。如果你看不懂这句话,建议先看我上一篇[如何实现一个定高虚拟列表](https://mp.weixin.qq.com/s/unNbvl6L6vLHXcnyheI1UQ) 文章。** + +此时应该如何计算最新的`start`值呢? + +很简单!在`positions`中存了两个字段分别是`top`和`bottom`,分别表示当前item的`开始位置`和`结束位置`。如果当前滚动条的`scrollTop`刚好在`top`和`bottom`之间,也就是`scrollTop >= top && scrollTop < bottom`,那么是不是就说明当前刚好滚到这个item的位置呢。 + +并且由于在`positions`数组中`bottom`的值是递增的,那么问题不就变成了查找第一个item的`scrollTop < bottom`。所以我们得出: + +```javascript +function getStart(scrollTop) { + return positions.value.findIndex((item) => scrollTop < item.bottom); +} +``` + +每次scroll滚动都会触发一次这个查找,那么我们可以优化上面的算法吗? + +`positions`数组中的`bottom`字段是递增的,这很符合`二分查找`的规律。不了解二分查找的同学可以看看leetcode上面的这道题: https://leetcode.cn/problems/search-insert-position/description/。 + +所以上面的代码可以优化成这样: + +```javascript +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`的值为: + +```javascript +offset.value = positions.value[start.value].top; +``` + +可能有的小伙伴会迷惑,在`start`的item中的滚动值为什么不算到`offset`偏移中去呢? + +因为在`start`的item范围内滚动时都是直接使用的浏览器滚动,已经有了scrollTop,所以无需加到`offset`偏移中去。 + +所以我们得出当scroll事件触发时代码如下: + +```javascript +function handleScroll(e) { + const scrollTop = e.target.scrollTop; + start.value = getStart(scrollTop); + offset.value = positions.value[start.value].top; +} +``` + +同样`offset`偏移值使用`translate3d`应用到可视区域的div上面,代码如下: + +```javascript + + + +``` + +这个是最终的运行效果图: +![v1](/src/views/demo/vListDoc2/img/2.gif) +完整的父组件代码如下: + +```javascript + + + + + +``` + +完整的虚拟列表子组件代码如下: + +```javascript + + + + + +``` + +# 总结 + +这篇文章我们讲了不定高的虚拟列表如何实现,首先给每个item设置一个预估高度`itemSize`。然后根据传入的长列表数据`listData`初始化一个`positions`数组,数组中的`top`、`bottom`、`height`等属性表示每个item的位置。然后根据可视区域的高度加上`itemSize`算出可视区域内可以渲染多少`renderCount`个item。接着就是在`onUpdated`钩子函数中根据每个item的实际高度去修正`positions`数组中的值。 + +在滚动时查找第一个item的bottom大于scrollTop,这个item就是`start`的值。`offset`偏移的值为`start`的`top`属性。 + +值得一提的是如果不定高的列表中有图片就不能在`onUpdated`钩子函数中修正`positions`数组中的值,而是应该监听图片加载完成后再去修正`positions`数组。可以使用 [ResizeObserver](https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver) 去监听渲染的这一堆item,注意`ResizeObserver`的回调会触发两次,第一次为渲染item的时候,第二次为item中的图片加载完成后。 \ No newline at end of file