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

55 阅读4分钟

直接渲染就不说了 直接说推荐的做法:

requestAnimationFrame + fragment(时间分片)

既然定时器的执行时间和浏览器的刷新率不一致,那么我就可以用requestAnimationFrame来解决

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

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

<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) 

        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>

目前这个代码还可以优化一下,每一次appendChild都是新增一个新的li,也就意味着需要回流一次,总共十万条数据就需要回流十万次

此前讲回流的时候提出过虚拟片段fragment来解决这个问题 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) 

        window.requestAnimationFrame(() => {
            let fragment = document.createDocumentFragment() // 创建一个虚拟文档碎片
            for (let i = 0; i < pageCount; i++) {
                let li = document.createElement('li')
                li.innerHTML = ~~(Math.random() * total)
                fragment.appendChild(li) // 挂到fragment上
            }
            ul.appendChild(fragment) // 现在才回流
            loop(curTotal - pageCount)
        })
    }

    loop(total)
</script>

这个方案被称之为时间分片,解决了定时器执行时机与屏幕刷新刷新不匹配的问题,并且用fragment优化了回流次数过多问题,同样会有闪屏,这个闪屏是下拉太快导致的,无法规避.

虚拟列表

核心思想:在可视窗口维护一个列表,可视窗口上下都会有个缓存区域,真实dom只会在可视窗口和上下缓存区存在

虚拟列表

核心思想:在可视窗口维护一个列表,可视窗口上下都会有个缓存区域,真实dom只会在可视窗口和上下缓存区存在

image.png

考虑到js代码会比较多,这里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>
    <style>
        *{
            margin: 0;
            padding: 0;
        }
        .v-scroll{
            width: 300px;
            height: 400px;
            border: 1px solid #000;
            margin: 100px 0 0 100px;
            overflow-y: scroll;
        }
        li{
            list-style: none;
            padding-left: 20px;
            height: 40px;
            line-height: 40px;
            box-sizing: border-box;
        }
    </style>
</head>
<body>
    <div id="app">
        <div class="v-scroll">
            <ul>
                <li v-for="(item, index) in currentList">{{index + 1}} -- {{item}}</li>
            </ul>
        </div>
    </div>

    <script>
        const { createApp, ref } = Vue
        
        createApp({
            setup() {
                const allList = ref([]) // 所有数据
                const currentList = ref([]) // 可视区域要渲染的数据

                return {
                    allList,
                    currentList
                }
            }
        })
    </script>
</body>
</html>

接下来模拟一个接口请求,拿到数据后pushallList中去

这个接口请求我直接写在setup全局内,按道理接口请求写在生命周期中,其实vue3的setup也是个生命周期,它顶替掉了vue2的beforeCreatedCreated

const getAllList = (count) => { // 接口请求
    for (let i = 0; i < count; i++) {
        allList.value.push(`我是列表${allList.value.length + 1}项`)
    }
}
getAllList(400) 

另外需要拿到包裹ul的滚动容器,vue拿到dom是通过打ref标记实现的,并且想要拿到dom需要放到生命周期onMounted

拿到一个dom的高度可以用offSetHeight,还有个clientHeight,前者加上了边框,这里必然选择后者

onMounted(() => { // 挂载后才能拿到dom
    boxHeight.value = scrollBox.value.clientHeight // clientHeight只包含内容,不含边框
    // console.log(scrollBox.value.offsetHeight);
})

然后需要清楚可视区要放下多少个li,这里向下取整 + 2是因为,最上面和最下面可能都会露出一点li,因此需要加两个,用计算属性实现

const itemNum = computed(() => { // 可视区放下多少个li
    return ~~(boxHeight.value / itemHeight.value) + 2
})

再记录一个列表开始的索引,然后监听页面的滚动事件,监听的时候需要更新好列表开始的索引

const startIndex = ref(0) // 索引

// 页面滚动
const doScroll = () => { // div内部滚动距离 / 每项的高度 = 滚了多少项
    const index = ~~(scrollBox.value.scrollTop / itemHeight.value)
    if (index === startIndex.value) return // 滚到最开始的位置
    startIndex.value = index // 可视区的第一条数据下标
}

然后记录好最后的索引,这个索引一定是在下缓存区的最后一个index,这里写下缓存区的长度等同于可视区,因此乘以2

const endIndex = computed(() => { // 可视区最后一个下标
    let index = startIndex.value + itemNum.value * 2 // 考虑用户体验,准备可视区一倍的li
    if (!allList.value[index]) { // 已经滚超了,回来一个位置
        index = allList.value.length - 1
    } 
    return index
})

重写下currentList,拿到初始下标和最后的下标进行截取

const currentList = computed(() => {
    let index = 0
    if (startIndex.value <= itemNum.value) { // [0, 21] [0, 22] …… [0, 30] [1, 31]
        index = 0
    } else {
        index = startIndex.value - itemNum.value
    }
    return allList.value.slice(index, endIndex.value + 1)
})

再写入一个blankStyle动态样式确保滚动的平滑性,这个样式返回的paddingToppaddingBottom的值根据当前可见区域之前和之后的空白区域高度来动态计算

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"           
    };
});

我再引入下lodash的节流进行优化doScroll函数

最终代码如下,大家可以拿到代码自行运行下,效果很棒~

<!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);

                const itemNum = computed(() => {
                    return ~~(boxHeight.value / itemHiehgt.value) + 2;
                });

                const startIndex = ref(0);

                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>

image.png

实现

  1. 拿到所有数据
  2. 获取可视区域的高度
  3. 滚动页面的过程中实时计算可视区域可以展示数据的起始下标结束下标,去原数组截取要展示的数据

优点

无论数据多少都不会卡顿. 其实关于前端渲染大量数据的问题是个非常经典的面试题,本文实现了两种可行方案,一种是通过requestAnimationFrame + Fragment时间分片,还有一种就是虚拟列表,时间分片写起来很简单,虚拟列表写起来复杂点,但是实现起来非常优雅,不会产生任何卡顿

作者:Dolphin_海豚
链接:juejin.cn/post/735494…
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。