vue轻松实现虚拟滚动
前言
移动端网页的日常开发中,偶尔会包含一些渲染长列表的场景.比如某旅游网站需要完全展示出全国的城市列表,再有将所有通讯录的姓名按照
A,B,C...
首字母依次排序展示.
长列表的数量一般在几百条范围内不会出现意外的效果,浏览器本身足以支撑.可一旦数量级达到上千,页面渲染过程会出现明显的卡顿.数量突破上万甚至十几万时,网页可能直接崩溃了.
为了解决长列表造成的渲染压力,业界出现了相应的应对技术,即长列表的
虚拟滚动
.
虚拟滚动
的本质,不管页面如何滑动,
HTML 文档
只渲染当前屏幕视口展现出来的少量
Dom
元素.
假设长列表有
10
万条数据,对用户而言,他永远只会看到屏幕展现出的那十几条数据.因此页面滑动时,通过监听滚动事件快速切换视口的数据,就能高度模拟滚动效果.
虚拟滚动
最终只需要渲染少量的
Dom
元素就能模拟出相似的滚动效果,这让前端工程师开发几万甚至十几万条的长列表都成为了可能.
下图是手机上实测滑动一张涵盖全球所有城市的长列表页面(源代码贴在了文章结尾).
滚动原理
为了理解虚拟滚动的实现原理,首先观察下面图片.手指向下滑动时,
HTML
页面也会随之向上滚动.
通过图片标记的距离,我们可以得出这样的结论.当屏幕视口的上边沿和
id
为
item
的
div
元素上边沿重合时,
item
元素距离长列表顶部的距离刚好等于页面的滚动距离
scrollTop
(这个结论会在后面计算距离时用到).
虚拟滚动为了模拟出逼真的滚动效果,首先应该满足以下两个要求.
-
虚拟滚动列表的滚动条和普通列表保持一致.比如列表包含
1000
条数据,当浏览器使用普通渲染的方式,假设滚动条需要向下滚动5000px
才能贴底.那么应用虚拟滚动
技术后,滚动条也应该保证具备相同的特征,向下滚动5000px
才能贴底.
-
虚拟滚动只会渲染视口以及上下两侧的部分
Dom
元素.随着滚动条往下滑动,视图的内容要实时更新,保证同普通渲染长列表时,看到的内容一致.
为了满足上面的要求,
html
设计结构如下.
.wrapper
是最外层的容器元素,
position
设置成
absolute
或
relative
,子元素依据它做定位.
子元素
.background
和
.list
是实现虚拟滚动的关键.
.background
是一个空的
div
,但它需要设置高度,高度值等于长列表所有列表项高度相加的总和.另外还要将其设置成绝对定位,
z-index
的值置为
-1
.
.list
内部负责动态渲染视口观察到的
Dom
元素,
position
设置成
absolute
.
<template>
<div class="wrapper">
<div class="background" :style="{height:`${total_height}px`}"></div>
<div class="list">
<div class="line">
<div class="item lt">BEIJING</div>
<div class="item gt">北京</div>
<div class="line">
<div class="item lt">shanghai</div>
<div class="item gt">上海</div>
<div class="line">
<div class="item lt">guangzhou</div>
<div class="item gt">广州</div>
... //省略
</template>
<style lang="scss" scoped>
.wrapper {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 60px;
overflow-y: scroll;
.background {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: -1;
.list {
position: absolute;
top: 0;
left: 0;
right: 0;
</style>
假如上面代码
total_height
等于
10000px
,页面运行效果图如下所示.
由于子元素
.background
设置了高度,父元素
.wrapper
就会被子元素支撑起来,同时会出现滚动条.
如果此时向下滑动,两个子元素
.background
和
.list
会同时向上滚动.当滚动距离达到了
9324px
,滚动条也抵达了底部.
这是因为父元素
.wrapper
本身高度为
676px
,加上滑动距离
9324px
,结果就刚好等于列表总高度
10000px
.
通过观察以上行为可知,
.background
虽然只是一个空的
div
,但是通过给它赋予列表的总高度,可以让右侧的滚动条和普通长列表渲染产生的滚动条保持外观和行为上一致.
滚动条的问题解决了,但随着滚动条往下滑,数据列表随之上移,列表全部移出了屏幕之后,接下来的滑动全是白屏.
为了解决白屏问题,视口必须始终展现出滑动的数据.那么
.list
元素要根据滑动的距离动态更新自身绝对定位的
top
值,这样就能确保
.list
不被划出屏幕之外.同时还要依据滑动的距离动态渲染当前视口应该展示的数据.
观察下面动效图,右侧
Dom
结构展示了滑动时的变化.
滚动条往下快速滑动后,列表的
Dom
元素快速渲染刷新.此时除了
.list
内部的
Dom
元素不断的更换,
.list
元素自身也在不断修改
transform: translate3d(0, ? px ,0)
样式值(修改
translate3d
能达到和修改
top
属性值相似的效果).
经过上面的讲解,虚拟滚动的实现逻辑已经清晰.首先
js
监听滚动条的滑动事件,再通过滑动距离计算出
.list
元素要渲染哪些子元素,其次更新
.list
元素位置.滚动条不断滑动时,子元素和位置也不断更新,视口上便模拟出了滚动效果.
实现
开发的
Demo
页面如下图所示.列表项包含了以下三种结构:
-
小型列表项,城市首字母单独成一行,高度为
50px
; -
普通列表项,左侧英文名,右侧中文名,高度为
100px
; -
大型列表项,左侧英文名,中间中文名,右侧一张图片,高度为
150px
;
列表数据
city_data
的
json
结构类似如下,
type
为
1
代表采用小型列表项的样式结构渲染,
2
代表普通列表项,
3
代表大型列表项.
[{"name":"A","value":"","type":1},{"name":"Al l'Ayn","value":"艾因","type":2},{"name":"Aana","value":"阿纳","type":3} ... ]
city_data
包含了长列表的所有数据,
city_data
获取后先遍历调整每一项的数据结构(代码如下).
通过以下方法处理,每一个列表项最终都包含一个
top
和
height
值.
top
表示该项距离长列表顶部的长度,而
height
值指该项的高度.
total_height
即整个列表的总高度,最终要赋予上文提及的
.background
元素.处理完后的数据赋予
this.list
存储,并记录下最小列表项的高度
this.min_height
.
mounted () {
function getHeight (type) { // 根据 type 值返回高度
switch (type) {
case 1: return 50;
case 2: return 100;
case 3: return 150;
default:
return "";
let total_height = 0;
const list = city_data.map((data, index) => {
const height = getHeight(data.type);
const ob = {
index,
height,
top: total_height,
total_height += height;
return ob;
this.total_height = total_height; // 列表总高度
this.list = list;
this.min_height = 50; // 最小高度是50
//屏幕最大能容纳的列表项数量,containerHeight是父容器高度,按照最小高度来计算
this.maxNum = Math.ceil(containerHeight / this.min_height);
html
根据
type
值渲染不同的样式结构(代码如下).父容器
.wrapper
绑定一个滑动事件
onScroll
,列表元素
.list
内部不是遍历
this.list
数组,因为
this.list
是原始数据,包含了所有的列表项.
<template>
模板里面只需要遍历视口需要展现的数据
runList
,数组
runList
包含的数据会随着滚动事件不断更新.
<template>
<div class="wrapper" ref="wrapper" @scroll="onScroll">
<div class="background" :style="{height:`${total_height}px`}"></div>
<div class="list" ref="container">
<div v-for="item in runList" :class="['line',getClass(item.data.type)]" :key="item">
<div class="item lt">{{item.data.name}}</div>
<div class="item gt">{{item.data.value}}</div>
<div v-if="item.data.type == 3" class="img-container">
<img src="../../assets/default.png" />
</template>
滚动事件触发
onScroll
方法(代码如下),由于滚动条的触发频率非常高,为了减少浏览器的计算量,使用
requestAnimationFrame
函数做一下节流.
滚动事件对象
e
可以获取当前滚动条滑动的距离
distance
.依据
distance
,只要计算出
runList
的列表数据以及修改
.list
的位置信息就大功告成了.
onScroll (e) {
if (this.ticking) {
return;
this.ticking = true;
requestAnimationFrame(() => {
this.ticking = false;
const distance = e.target.scrollTop;
this.distance = distance;
this.getRunData(distance);
如何根据滚动距离快速找到屏幕视口下应该渲染的首个列表项元素呢?
this.list
是长列表的数据源,其中每一个列表项都存储自己距离长列表顶部的距离
top
以及自身的高度
height
.
上文提到一个结论,页面滚动过程中,如果视口的上边沿和某个列表项的上边沿刚好重合时,滑动距离
scrollTop
刚好等于该列表项距离长列表顶部的距离
top
.
那么此时如果页面再往上挪动一点,视口下第一个列表项只显示了部分,另一部分划出了屏幕外看不见.这时我们仍然判定视口下的起始元素仍然是该列表项,除非它继续往上移动直至彻底移出屏幕.
那么我们判端视口下渲染的第一个元素的标准便是页面的滚动距离
scrollTop
位于该列表项元素的
top
和
top + height
之间.
依据上面原理,可以采用二分法实现快速查询(代码如下).
//二分法计算起始索引,scrollTop为滚动距离
getStartIndex (scrollTop) {
let start = 0, end = this.list.length - 1;
while (start < end) {
const mid = Math.floor((start + end) / 2);
const { top, height } = this.list[mid];
if (scrollTop >= top && scrollTop < top + height) {
start = mid;
break;
} else if (scrollTop >= top + height) {
start = mid + 1;
} else if (scrollTop < top) {
end = mid - 1;
return start;
二分法计算出了视口下渲染的第一个元素位于
this.list
数组中的索引,命名为起始索引
start_index
.接下来进入核心函数
getRunData
(代码如下).它主要做了以下两件事.
-
动态更新
runList
列表数据 -
动态更新
.list
长列表元素的的位置
实际开发中,假设屏幕高度为
1000px
,最小的列表项为
50px
,那么屏幕所能容纳的最大列表项数量
this.maxNum
为
20
个.
根据滑动距离计算出起始索引
start_index
,再从数据源
this.list
依据
start_index
截取
20
个元素赋予
this.runList
不就完成数据更新了吗?
this.runList
如果只盛放刚好一个屏幕容纳的最大数量,当滚动条快速滚动后,界面的渲染速度会跟不上手指滑动速度,底部会有白屏闪烁.
解决此问题的方法可以在
HTML
文档上多渲染一点缓冲数据.比如下面
getRunData
函数会渲染能容纳三张屏幕高度的列表项数量,分别对应上屏、中屏和下屏.
中屏即当前视口对应的屏幕,上屏和下屏存放着视口上下两边没有展现的缓冲
Dom
.首先利用二分法可以查询到屏幕视口下第一个列表项元素索引
start_index
,那么上屏和下屏的首个列表项索引也能依据
start_index
轻松得出.
getRunData (distance = null) {
//滚动距离
const scrollTop = distance ? distance : this.$refs.container.scrollTop;
//在哪个范围内不执行滚动
if (this.scroll_scale) {
if (scrollTop > this.scroll_scale[0] && scrollTop < this.scroll_scale[1]) {
return;
//起始索引
let start_index = this.getStartIndex(scrollTop);
start_index = start_index < 0 ? 0 : start_index;
//上屏索引,this.cache_screens默认为1,缓存一个屏幕
let upper_start_index = start_index - this.maxNum * this.cache_screens;
upper_start_index = upper_start_index < 0 ? 0 : upper_start_index;
// 调整offset
this.$refs.container.style.transform = `translate3d(0,${this.list[upper_start_index].top}px,0)`;
//中间屏幕的元素
const mid_list = this.list.slice(start_index, start_index + this.maxNum);
// 上屏
const upper_list = this.list.slice(upper_start_index, start_index);
// 下屏元素
let down_start_index = start_index + this.maxNum;
down_start_index = down_start_index > this.list.length - 1 ? this.list.length : down_start_index;
this.scroll_scale = [this.list[Math.floor(upper_start_index + this.maxNum / 2)].top, this.list[Math.ceil(start_index + this.maxNum / 2)].top];