优雅的实现简易虚拟滚动

1,445 阅读6分钟

背景

作为前端开发人员,经常会遇到渲染长列表的情况。说不定哪一天就会遇到一个后端同学,直接甩给你10w条数据。那我们必然要和这位同学对线(-w-)。那如果对线对不过咋办?把数据全部渲染的话必然会造成首屏白屏的情况,同时在滑动时会使用户感到卡顿,不流畅。那么有什么比较好的方法可以在对线失败情况下实现长列表渲染的同时又不影响用户体验呢? 必然就是虚拟滚动。

方法分析

很明显,我们肯定不能全部都渲染完,不然会卡的妈都不认识。那既然不能全部加载完,我们分段加载并渲染数据不就好了么。那么我们怎样去进行数据的分段呢?我们要知道,用户浏览网页时,关注点只会于屏幕的可视区域上。假设说我们有一千条数据,那么OK,我们先假定以20条数据为一组进行渲染。在用户进行滚动的时候,我们只需要监听到目前用户的滚动距离,并计算我们目前的列表高度(假设这种情况下每行高度均相等),当监听到要滚动到目前渲染的最后一个元素时,进行渲染数据组的更改,以乡下滚动为例,简单粗暴一点的方法就是:

center

OK,目前有一点点虚拟滚动的样子了,那么我们是不是可以进行一下优化呢?目前当滚动到底部时我们采用的策略时新插入节点,再进行一下操作,这样子随着滚动的进行,子节点会越来越多,最终必然会造成性能问题。这不行呀,所以我们需要减少元素数量。之前提到了,对于用户来说,所关心的只有可视区域的信息,对于之前的list我们根本不需要去关心,只要保证用户滚动到指定位置时匹配显示出用户需要的list就可以了,所以当用户滚动到底部时(仍然以向下滚动为例),我们只需要把需要的元素append进去,把之前以及出现过的remove掉。 那我们是否可以继续优化呢?既然我们需要append并remove,那实际上只需要维护好一个公共的dom元素,每次更新时只要去变更插入其中的元素就可以了。 所以目前,我们的页面结构就是这样的

页面demo

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>VScroll</title>
</head>
<style>
    li {
        height: 150px;
    }
<body>
    <div class="list">
        <li id="first"></li>
            --li*18--
        <li id="last"></li>
    </div>
    <script src="/index.js"></script>
</body>
</html>

这里假设有20个li 现在需要去实现我们的index.js了,实现思路如上所述,很简单,就是一个监听函数。但是持续监听页面滚动时对性能影响很大,加入节流又可能使滚动不够流畅。那么有么有跟好的实现监听的方法呢? 不好意思还真有

Observer API

Observer是浏览器自带的观察者,它主要提供了Interp, Mutation, Resize, Performance这四类观察者,这里我们用到的是Intersection Observer API

Intersection Observer API

这个API可以自动"观察"元素是否可见。由于可见(visible)的本质是,目标元素与viewport产生一个交叉区,所以这个 API 叫做"交叉观察器"。 对于虚拟滚动的实现,我们的核心思想就是根据目前的滚动位置来判断那些元素应该显示在我们的页面中,那么我们可以简化一下判断条件,根据判断目前所渲染的最好一个元素是否出现在视图中来决定是否要进行列表数据更新。那么我们只需要使用IntersectionObserver来“观察”最后一个元素是否进入了可视区就好了。

API

用法简直简单

const observe = new IntersectionObserver(callback, option?);

// 开始观察
observe.observe(document.getElementById('example'));
// 停止观察
observe.unobserve(element);
// 关闭观察器
observe.disconnect();

Callback

entries:Array<IntersectionObserverEntry>

const callback = (entries) => {
            
        };

参数 entries 是一个数组,每个成员都是一个IntersectionObserverEntry对象。举例来说,如果同时有两个被观察的对象的可见性发生变化,entries数组就会有两个成员。

IntersectionObserverEntry

{
    boundingClientRect: {
        bottom: -6
        height: 150
        left: 8
        right: 924
        top: -156
        width: 916
        x: 8
        y: -156
    },//根元素的矩形区域的信息,getBoundingClientRect()方法的返回值,如果没有根元素(即直接相对于视口滚				动),则返回null
    intersectionRatio: 0.02666666731238365, //目标元素的可见比例,即intersectionRect占boundingClientRect的比		例,完全可见时为1,完全不可见时小于等于0
    intersectionRect:  { x: 8, y: -10, width: 916, height: 4, top: -10, … },//目标元素与视口(或根元素)的交叉区域的		信息
    isIntersecting: true,
    isVisible: false,
    rootBounds:  { x: -10, y: -10, width: 952, height: 680, top: -10, … }, //根元素的矩形区域的信息,getBoundingClientRect()方法的返回值,如果没有根元素(即直接相对于视口滚动),则返回null
    target: li#first ,//被观察的目标元素,是一个 DOM 节点对象
    time: 1587.2399999934714 ,//可见性发生变化的时间,是一个高精度时间戳,单位为毫秒
}

JS实现

所以我们只需要实例一个observer来监听两个元素,即列表内的第一个与最后一个元素即可。向下滚动时,当最后一个元素刚刚出现时,就触发渲染函数,将接下来的数据插入列表中,同时,为了整个列表的滚动流畅,list节点需要足够长,已使得我们的数据进行pagesize/2的偏移后不会出现空白块,这里我们去20条数据为一组,每次偏移10条数据后进行重新渲染。代码如下所示。 index.js

function scroll(height, pageSize) {
    this.height = height
    this.pageSize = pageSize
    //let newPaddingTop = paddingTop + (height*pageSize)
    let firstIndex = 0
    //let lastIndex = 19
    //pageSize = 10
    //选择dom节点
    const list = document.querySelectorAll(".list>li")
    //定义监听回调
    const callback = (entries) => {
        entries.forEach((entry) => {
            if (entry.target.id === "first") {
                //first出现
                if (entry.intersectionRatio > 0) {
                    console.log('first');
                    firstIndex === 0 ?
                        firstIndex :
                        firstIndex = firstIndex - pageSize
                    render(firstIndex)
                    document.querySelector(".list").style.paddingTop = firstIndex * height + "px"
                    console.log(entry);
                } else {
                    //first不可见的一些逻辑
                }
            } else if (entry.target.id === "last") {
                //last出现
                if (entry.intersectionRatio > 0) {
                    console.log("last");
                    firstIndex = firstIndex + pageSize
                    render(firstIndex)
                    
                    console.log(entry);
                } else {
                    //last不可见的一些逻辑
                }
            }
        });
    };
    //渲染函数
    const render = (data) => {
        console.log(firstIndex);
        for (let i = 0; i < 20; i++) {
            list[i].textContent = data + i
        }
        document.querySelector(".list").style.paddingTop = data * height + "px"
    }
    return new IntersectionObserver(callback, { rootMargin: '10px' })

}

var observe = scroll(150, 10)
observe.observe(document.querySelector("#first"))
observe.observe(document.querySelector("#last"))

效果预览

基础demo就是这样来着,当然还有很多优化的地方,但是我懒,算了算了。 Intersection Observer API 也可以用在懒加载场景下使用,优雅满分。