如何实现一个无尽的长列表?

353 阅读6分钟

如果不考虑DOM节点回收,也就是一直不停向后加入节点,这样的长列表是比较容易的。那我们为什么要考虑DOM节点回收呢?

DOM节点本身并非耗能大户,但是也不是一点都不消耗性能,每一个节点都会增加一些额外的内存、布局、样式和绘制。如果一个站点的DOM节点过多,在低端设备上会发现明显的变慢,如果没有彻底卡死的话。同样需要注意的一点是,在一个较大的DOM中每一次重新布局或重新应用样式(在节点上增加或删除样式所触发的过程)的系统开销都会比较昂贵。所以进行DOM回收意味着我们会保持DOM节点在一个比较低的数量上,进而加快上面提到的这些处理过程。

那我们针对DOM节点回收来实现一个无尽的长列表。(最终实例代码在底部)

实现思路

我们先把这个存在数据,但不在视图显示的列表叫做虚拟列表。

在这里插入图片描述
具体效果就像这样
在这里插入图片描述

判断临界点

那么如何判断到达DOM回收的时机呢,也就是实际ViewList到达顶部或者顶部,要回收或者释放DOM。有两种主流的判断方式:

一、Scroll事件

    window.addEventListener("scroll",function(e){
        if(window.scrollY===顶部||window.scrollY===底部){
        ...
        }
    })

这个方法麻烦的地方在于,你需要去实时计算viewport顶部和底部的Y值。像我的实现代码里最大放三个ul在viewport中,而且HTML结构简单,我只要取得顶部ul.offsetTop(元素到offsetParent顶部的距离),底部ul.offsetTop+offsetHeight就是对于的Y值了。需要注意的是,我的ul父级简单,大小位置几乎和body一样,如果你的parentDiv嵌套太复杂,还有各自的offsetTop,就另当别论。只有元素显示了(渲染完成)才会计算入offsetTop。

二、使用IntersectionObserver

IntersectionObserver接口(从属于IntersectionObserverAPI)为开发者提供了一种可以异步监听目标元素与其祖先或视窗(viewport)交叉状态的手段。祖先元素与视窗(viewport)被称为根(root)。

应用在这个实例中就是,在顶部和底部各自加一个小元素,监听他们,当他出现的时候,就会及其祖先交叉,就会发出事件。

    function addIntersectionObserver(){
        var intersectionObserver = new IntersectionObserver(function(entries) {
            if (entries[0].intersectionRatio <= 0) return;
            if(entries[0].target === topDiv){
                //到达顶部
            }else if(entries[0].target === bottomDiv){
               //到达底部
            }
        });
        intersectionObserver.observe(topDiv);
        intersectionObserver.observe(bottomDiv);
    }

DOM回收

时机确定,接下来就是关键的DOM回收了。

//存放被回收顶部List
const beforeFragment = document.createDocumentFragment();
//存放被回收底部List
const afterFragment = document.createDocumentFragment();
//存放当前显示的List
const fragment = document.createDocumentFragment();

简单介绍下DocumentFragment接口,它他表示没有父级的最小文档对象,也就不会加入真实的DOMTree,进行渲染,只是一个虚拟的dom节点,存在于内存中,所以对片段所做的更改不会影响文档,导致回流,或者在进行更改时可能会发生任何性能影响。但操作方法属性还是像标准节点一样,故被用作轻量级版本,像标准文档一样存储由节点组成的文档结构的片段。

switch(临界情况):
  case 到达顶部: 
     if(beforeFragment.lastChild)
        beforeFragment弹出最后一个节点lastChild1
        加入fragment头部
        fragment弹出最后一个节点lastChild2
        加入afterFragment头部
  case 到达底部:
     if(afterFragment.firstChild)
        afterFragment弹出第一个节点firstChild1
        加入fragment头部
        fragment弹出最后一个节点firstChild2
        加入beforeFragment头部
    else
       向后端请求数据加入fragment
重新加入DomTree
box.appendChild(fragment);

滚动条的问题

如果元素直接从视图删除一些加入一些,还要保持当前视图在当前DOM位置,会造成滚动条的跳动。总结了两种方法解决这种问题:

一、直接隐藏滚动条,再重新绘制一个自己控制的滚动条

body::-webkit-scrollbar {
    display: none;
}

首先隐藏这个CSS只有Chrome支持,而且绘制也比较麻烦,如果适合业务场景可以考虑。

二、使用padding或者其他什么占位符替换被回收的DOM位置

这样滚动条只可能被缩小,而不会跳动。我的实例使用的是padding,也可以用其他。 根据被回收的DOM节点大小,分别更新顶部和底部的padding。

topDiv.style.paddingTop = `${paddingTop}px`;
bottomDiv.style.paddingBottom = `${paddingBottom}px`;

现存问题

当然我的实例还存在一些问题,比如说极其快速向上或者向下滚动时,来不及释放被回收的元素并绘制出来,会出现白屏。希望大佬们看到,能提出问题,交流解决方案,谢谢观看!

最终代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
    <style type="text/css">
        *{
            padding: 0;
            margin: 0;
        }
        body{
            height: 100vh;
        }
        #box{
            width: 90%;
            margin: 20px auto;
        }
        #top,#bottom{
            height: 40px;
        }
        ul{
            list-style: none;
            margin: 5px;
            text-align: center;
        }
        #box ul div{
            width: 100%;
            margin: 5px;
            height: 100px;
            border: 2px solid red;
            font-size: 48px;
        }
    </style>
</head>
<body>
    <ul id="top">到顶了</ul>
    <div id="box">
    </div>
    <ul id="bottom">加载中</ul>
</body>


<script type="text/javascript">
    let index= 1;
    var box = document.getElementById("box");
    var body = document.querySelector("body");
    const beforeFragment = document.createDocumentFragment();
    const afterFragment = document.createDocumentFragment();
    const fragment = document.createDocumentFragment();
    const [topDiv,bottomDiv] = [document.getElementById("top"),document.getElementById("bottom")];

     
    //函数节流,就是指连续触发事件但是在 n 秒中只执行一次函数
    function throttle(fn, wait,self=null) {
        let _fn = fn,       
            timer;
        return function(...args) {
            if (timer) return false;
            _fn.apply(self, [...args]); 
            timer = setTimeout(()=> { 
                clearTimeout(timer);  
                timer = null;  
            }, wait);
            return true;
        }
    }

    //获取图片(这是非真实场景,用了定时器假装异步请求)
    function createImg (count) {
        const ul = document.createElement("ul");
        for(var i = 0; i < count; i++) {
            var div = document.createElement("div");
            div.innerText = index;
            index++;
            var li = document.createElement("li");
            li.appendChild(div);
            ul.appendChild(li);
        }
        return new Promise(function(resolve, reject) {
            let timer = setTimeout(function() { 
                clearTimeout(timer);  
                resolve(ul);
                timer = null;  
            }, 500);
        });
    }



    /**
     * 维护长列表(只在视图内显示60条)
     * type:0为向上,1为向下
     */
     let paddingTop = 0;
     let paddingBottom = 0;
     async function removeOverDom(type){
        let y =0;
        const scrollY = window.scrollY;
        if(box.children.length>2){
            if(type===0&&!beforeFragment.lastChild) return true;
            //为了防止白屏,先异步请求完成后再操作
            if(type===1&&!afterFragment.firstChild) ul = await createImg(20);
            // appendChil加入后box就会移除这个元素,随后获取不到其高度
            y = type?-box.children[0].scrollHeight:box.children[2].scrollHeight;
            fragment.appendChild(box.children[0]);
            fragment.appendChild(box.children[0]);
            fragment.appendChild(box.children[0]);
            switch (type) {
                case 0:                   
                    afterFragment.prepend(fragment.lastChild);
                    fragment.prepend(beforeFragment.lastChild);
                    break;
                case 1:
                    beforeFragment.appendChild(fragment.firstChild);
                    if(afterFragment.firstChild){
                        fragment.appendChild(afterFragment.firstChild);
                    }else{
                        fragment.appendChild(ul);
                    }
                    break;
                default:
                    break;
            }
            box.appendChild(fragment);
            if(y<0||(y>0&&paddingTop>0)) paddingTop -=y;
            if(y>0||(y<0&&paddingBottom>0)) paddingBottom +=y;
            topDiv.style.paddingTop = `${paddingTop}px`;
            bottomDiv.style.paddingBottom = `${paddingBottom}px`;
            window.scrollTo(0,scrollY,"smooth");
            body.style.overflow="scroll";
        }
        if(box.children.length<3){
            ul = await createImg(20);
            fragment.appendChild(ul);
            box.appendChild(fragment);
        }
        return true;
    }
    
    //判断是否滚到了顶部或者底部
    const removeOverDomT = throttle(removeOverDom,10);
    
    function addIntersectionObserver(){
        var intersectionObserver = new IntersectionObserver(function(entries) {
            if (entries[0].intersectionRatio <= 0) return;
            if(entries[0].target === topDiv){
                removeOverDomT(0);
            }else if(entries[0].target === bottomDiv){
                removeOverDomT(1);
            }
        });
        intersectionObserver.observe(topDiv);
        intersectionObserver.observe(bottomDiv);
    }

    addIntersectionObserver();
</script>
</html>

参考文章:

[译] 无尽滚动的复杂度 -- 来自 Google 大神的拆解

一个简洁、有趣的无限下拉方案