前端如何一次性渲染十万条数据

755 阅读4分钟

1. 传统做法

对于长列表渲染,传统的方法是使用懒加载的方式,下拉到底部获取新的内容加载进来,其实就相当于是在垂直方向上的分页叠加功能。

<script>
        const total = 100000
        let ul = document.getElementById('container')
        let once = 20
        let page = total / once
    
        function loop(curTotal) {
            if (curTotal <= 0) return 
    
            let pageCount = Math.min(curTotal, once) // 最后一次渲染一定少于20条,因此取最小
    
            setTimeout(() => {
                for (let i = 0; i < pageCount; i++) {
                    let li = document.createElement('li')
                    li.innerHTML = ~~(Math.random() * total)
                    ul.appendChild(li)
                }
                loop(curTotal - pageCount)
            }, 0)
        }
    
        loop(total)
</script>

但随着加载数据越来越多,浏览器的回流和重绘的开销将会越来越大,整个滑动也会造成卡顿,定时器的执行和屏幕的刷新时间不一致产生一个闪屏的问题,并且需要回流十万次。这个时候我们就可以考虑使用虚拟列表来解决问题

2.时间分片

requestAnimationFrame +fragment

requestAnimationFrame也是个定时器,不同于setTimeout,它的时间不需要我们人为指定,这个时间取决于当前电脑的刷新率,如果是 60Hz ,那么就是 16.7ms 执行一次,如果是 120Hz 那就是 8.3ms 执行一次

因此requestAnimationFrame也是个宏任务

这么一来,每次电脑屏幕 16.7ms 后刷新一下,定时器就会产生 20 个lidom结构的出现和屏幕的刷新保持了一致

fragment是虚拟文档碎片,我们一次for循环产生 20 个li的过程中可以全部把真实dom挂载到fragment上,然后再把fragment挂载到真实dom上,这样原来需要回流十万次,现在只需要回流100000 / 20

缺点

同样会有闪屏,这个闪屏是下拉太快导致的,无法规避

    <script>
        const total = 100000
        let ul = document.getElementById('container')
        let once = 20
        let page = total / once
    
        function loop(curTotal) {
            if (curTotal <= 0) return 
            let pageCount = Math.min(curTotal, once) // 最后一次渲染一定少于20条,因此取最小
            // 时间分片
            window.requestAnimationFrame(()=>{
                for(let i=0;i<pageCount;i++){
                    let li=document.createElement('li')
                    li.innerHTML=~~(Math.random()*total)
                    ul.appendChild(li)
                }
                loop(curTotal-pageCount)

            })
        }
    
        loop(total)
    </script>

3. 虚拟列表

其核心思想就是在处理用户滚动时,只改变列表在可视区域的渲染部分,具体步骤为:

先计算可见区域起始数据的索引值startIndex和当前可见区域结束数据的索引值endIndex,假如元素的高度是固定的,那么startIndex的算法很简单

startIndex = Math.floor(scrollTop/itemHeight)
endIndex = startIndex + (clientHeight/itemHeight) - 1

再根据startIndexendIndex取相应范围的数据,渲染到可视区域再计算startOffset(上滚动空白区域)和endOffset(下滚动空白区域)

这两个偏移量的作用就是来撑开容器元素的内容,从而起到缓冲的作用,使得滚动条保持平滑滚动,并使滚动条处于一个正确的位置

上述的操作可以总结成五步:

  • 不把长列表数据一次性全部直接渲染在页面上
  • 截取长列表一部分数据用来填充可视区域
  • 长列表数据不可视部分使用空白占位填充(下图中的startOffsetendOffset区域)
  • 监听滚动事件根据滚动位置动态改变可视列表
  • 监听滚动事件根据滚动位置动态改变空白填充

image.png

// 虚拟列表DOM结构
<div className='container'>
  // 监听滚动事件的盒子,该高度继承了父元素的高度
  <div className='scroll-box' ref={containerRef} onScroll={boxScroll}>
    // 该盒子的高度一定会超过父元素,要不实现不了滚动的效果,而且还要动态的改变它的padding值用于控制滚动条的状态
    <div style={topBlankFill.current}>
      {
      showList.map(item => <div className='item' key={item.commentId || (Math.random() + item.comments)}>{item.content}</div>)
      }
    </div>
  </div>
</div>

cdn引入vue来实现示例:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
    <style>
        * {
            margin: 0;
            padding: 0;
        }

        .v-scroll {
            width: 300px;
            height: 400px;
            border: 1px solid black;
            overflow-y: scroll;
            margin: 100px 0 0 100px;
        }

        li {
            list-style: none;
            padding-left: 20px;
            line-height: 40px;
            height: 40px;
            box-sizing: border-box;
        }
    </style>
</head>

<body>
    <div id="app">
        <div class="v-scroll" @scroll="doScroll" ref="scrollBox">
            <ul :style="blankStyle" style="height: 100%">
                <li v-for="item in currentList" :key="item.id">
                    {{ item }}
                </li>
            </ul>
        </div>
    </div>


    <script>
        const { createApp, ref, onMounted, computed } = Vue

        createApp({
            setup() {
                const allList = ref([]);

                getAllList(300); 

                function getAllList(count) {
                    const length = allList.value.length;
                    for (let i = 0; i < count; i++) {
                        allList.value.push(`我是列表${length + i + 1}项`)
                    }
                }

                const scrollBox = ref(null);

                const boxHeight = ref(0);

                function getScrollBoxHeight() {
                    boxHeight.value = scrollBox.value.clientHeight;
                }

                onMounted(() => {
                    getScrollBoxHeight();
                    window.onresize = getScrollBoxHeight;
                    window.onorientationchange = getScrollBoxHeight;
                })

                const itemHiehgt = ref(40);
        //需要清楚可视区要放下多少个li,这里向下取整 + 2是因为最上面和最下面可能都会露出一点li,
        //因此需要加两个,用计算属性实现
                const itemNum = computed(() => {
                    return ~~(boxHeight.value / itemHiehgt.value) + 2;
                });

                const startIndex = ref(0);

              //引入lodash的节流进行优化doScroll函数
                const doScroll = _.throttle(() => {
                    const index = ~~(scrollBox.value.scrollTop / itemHiehgt.value);
                    if (index === startIndex.value) return;
                    startIndex.value = index;
                }, 200)

                const endIndex = computed(() => {
                    let index = startIndex.value + itemNum.value * 2;
                    if (!allList.value[index]) {
                        index = allList.value.length - 1;
                    }
                    return index;
                });

                const currentList = computed(() => {
                    let index = 0;
                    if (startIndex.value <= itemNum.value) {
                        index = 0;
                    } else {
                        index = startIndex.value - itemNum.value;
                    }
                    return allList.value.slice(index, endIndex.value + 1);
                });

                const blankStyle = computed(() => {
                    let index = 0;
                    if (startIndex.value <= itemNum.value) {
                        index = 0;
                    } else {
                        index = startIndex.value - itemNum.value;
                    }
                    return {
                        paddingTop: index * itemHiehgt.value + "px",

                        paddingBottom: (allList.value.length - endIndex.value - 1) * itemHiehgt.value + "px"           
                    };
                });

                return {
                    allList,
                    currentList,
                    boxHeight,
                    itemHiehgt,
                    scrollBox,
                    doScroll,
                    blankStyle
                }
            }
        }).mount('#app')
    </script>
</body>
</html>

步骤分析

  1. 初始化列表

    function getAllList(count) {
        const length = allList.value.length;
        for (let i = 0; i < count; i++) {
            allList.value.push(`我是列表${length + i + 1}项`)
        }
    }
    
  2. 计算视口高度

    function getScrollBoxHeight() {
        boxHeight.value = scrollBox.value.clientHeight;
    }
    
  3. 计算可见项数

    const itemNum = computed(() => {
        return ~~(boxHeight.value / itemHiehgt.value) + 2;
    });
    
  4. 滚动事件处理

    const doScroll = _.throttle(() => {
        const index = ~~(scrollBox.value.scrollTop / itemHiehgt.value);
        if (index === startIndex.value) return;
        startIndex.value = index;
    }, 200);
    
  5. 计算当前显示的列表项

    const currentList = computed(() => {
        let index = 0;
        if (startIndex.value <= itemNum.value) {
            index = 0;
        } else {
            index = startIndex.value - itemNum.value;
        }
        return allList.value.slice(index, endIndex.value + 1);
    });
    
  6. 设置空白填充

    const blankStyle = computed(() => {
        let index = 0;
        if (startIndex.value <= itemNum.value) {
            index = 0;
        } else {
            index = startIndex.value - itemNum.value;
        }
        return {
            paddingTop: index * itemHiehgt.value + "px",
            paddingBottom: (allList.value.length - endIndex.value - 1) * itemHiehgt.value + "px"
        };
    });