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

1,904 阅读9分钟

前言

在面试中这种场景题也经常会被面试官问道,因为这也是前端性能优化的一种,下面小编将给大家来介绍几种方法,当后端一次性返回过多的数据时,我们前端应该如何渲染?

正文

方法一:直接暴力渲染

咱们先模拟下这个数据很多时的情景,我生成十万条li,然后每个li都是随机生成数,生成一个append挂载一下

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <ul id="container">

    </ul>
    <script>
        let ul = document.getElementById("container");
        const total = 100000
        let now = Date.now();
        for(let i = 0; i < total; i++){
            let li = document.createElement("li")
            li.innerHTML = ~~(Math.random() * total)
            ul.appendChild(li)
        }
        console.log('JS运行耗时',Date.now() - now);
        setTimeout(()=>{
            console.log('页面加载总时长:',Date.now() - now);
            
        })
    </script>
</body>
</html>

暴力渲染是我们能想到的最简单的实现方法,但是毕竟是最简单的方法,所以会存在各种问题.

代码从上往下执行,因此执行完代码就是js的运行时间

那为何这里写个定时器就能代表渲染页面的时间,代表页面加载的总时长呢,

其实,再v8的事件循环机制event-loop中,v8在执行完同步代码、微任务之后,在执行宏任务之前,这中间还有一个页面渲染的过程,定时器前面的代码都是同步代码,定时器是个宏任务,但是浏览器执行宏任务之前会判断是否有需要渲染页面的任务,如果有就会先去渲染页面,因此这里写个定时器的打印就能代表页面渲染了多久时间,页面加载了多久

image-20240916170814604

js运行这段代码只需要0.1S左右,而页面渲染加载完成需要2.5s,因此,如果用这种方法,问题就出现在页面渲染上,而非代码的执行,v8引擎的性能还是很高的。

缺点

1、页面渲染要很久

2、需要十万次的回流重绘

方法二:定时器

如果有十万条数据,用户的屏幕又不能一次性全部展现出来,为啥要一次性加载呢,所以咱们可以用个定时器啊,一次性只加载一点。

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

像这样的话,每次调用loop都只会先加载20个li,这样一来,用户打开界面就比之前流畅多了。

但是其实这样还是有个问题的,就是上拉或者下拉都会有个留白。

为什么会有留白会闪屏呢?

这跟电脑的刷新率和定时器回调的执行有关

电脑的刷新率一般都是 60Hz , 60Hz 就代表着 1s 内电脑连续播放 60 张图片,或者说刷新 60 次,这么设计的原因就是我们人眼的视觉反应时间就是 16.7ms ,1000ms 内刷新 60 次就是 16.7ms 刷新一次,这样人眼就感受不到电脑的卡顿了

然而,浏览器的定时器执行不受我们人为严格控制的。浏览器有个主线程用于处理更新界面等操作,执行完主线程才会执行其他线程,而定时器专门有个定时器线程,因此定时器里面回调的执行一般就会比你设置的时间晚一点,哪怕你设定的时间是 0ms

我们回到上面的代码,第一次执行的时候创建 20 个li,此时浏览器的任务队列是干干净净的,因此去执行appendChild,将 20 个li去渲染到页面上去,渲染页面的过程一定是耗时的,等到你第二次执行递归的时候,又要去创建一个定时器,定时器里面的回调是创建 20 个li,刚才说了,浏览器执行定时器的回调需要等前面的渲染执行完毕,因此第二次创建li的时候,并不是我们预期的 0ms 创建 20 个li,这中间有个时差,这才导致了闪屏问题

也就是说:

1、定时器的执行需要等待前面的渲染队列执行完毕,而定时器的执行又恰好是创建li,这才导致一个非预期时间产生li导致的闪屏问题

2、定时器在 0ms 时走回调去创建 20 个li,但是浏览器的视图刷新需要等到 16.7ms 之后, 0ms 和 16.7ms 之间的时差就导致了闪屏,也就是说定时器的时间和屏幕的刷新率不一致

优点

打开页面的时候可以不需要一次性渲染所有的数据,打开首页很快

缺点

定时器的执行和屏幕的刷新时间不一致产生一个闪屏的问题,并且需要回流十万次

方法三:时间分片(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来解决这个问题。

这个咱们之前也提到过,大家可以去看看我的这篇文章。

输入url到页面渲染后半段 :回流、重绘、优化前言 当咱们在浏览器中输入一段url地址栏时会发生什么呢?它会经过两个过程 - 掘金 (juejin.cn)

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 API 替换可视区域中的数据,做到动态加载十万条数据

scroll 解决方案

先说 scroll 解决方案,简单来说,就是对其传来的数据进行分割展示,用到 slice 方法,它会返回一个新的数组

我们假设单个列表高度为 30px,一页展示的列表数量为 const count = Math.ceil(列表高度 / 30),展示的数据就是 visibleData = data.slice(start, start + count)(start 一开始为0)

当滚动时,动态修改 start 和 visibleData

IntersectionObserver 解决方案

通过 IntersectionObserver 的特性,当目标对象中的 entry.isIntersecting 为 true 或者 intersectionRatio > 0 (元素与祖先元素交叉、可见)时,说明本来不可见的元素浮现在视图中,表示它向上或向下滑动,我们动态设置视图中的顶部和底部 id 即可对其判断。当下滑时 entry.traget.id === 'bottom',我们修改 start 和 end;同理,当上滑时entry.traget.id === 'top 时,我们也一样修改 start 和 end

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

image-20240916175429704

考虑到这里js代码会比较多,咱们cdn引入vue来实现一下

并且vue的源码引入需要在前面引入,不然加载v-for中的item时会直接显示{{item}}一会儿

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

接下来咱们来模拟一个接口请求,拿到数据后push到allList中去

vue3中setup就是一个声明周期,因此咱们可以直接写在setup全局内。

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>

最终效果如下:

3.gif

最终也是成功的拿到了所有的数据,并且获取到了可视区域的高度,在滚动页面的过程中实时计算可视区域可以展示数据的起始下标和结束下标,去原数组截取要展示的数据

优点

无论多少数据都不会卡顿。

总结

本文介绍了四种前端渲染大量数据的问题的方法,这也是面试中的经典问题,但是两种是可行方案,一种是时间分片,还有一种就是虚拟列表,时间分片写起来很简单,虚拟列表写起来有点复杂但是实现的效果很优雅,不会有任何卡顿。