面试官:如何一次性渲染十万条数据

27,793 阅读8分钟

前言

后端一次性返回过多数据时,我们前端应该如何渲染,本文提供了两种比较适合方案供大家参考

直接渲染

先看下直接渲染会有什么问题吧

先模拟下这个数据过多时的情景,我生成十万条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>
        const total = 100000
        let ul = document.getElementById('container')

        for (let i = 0; i < total; i++) {
            let li = document.createElement('li')
            li.innerHTML = ~~(Math.random() * total)
            ul.appendChild(li)
        }
    </script>
</body>
</html>

1.gif

这加载起来有点慢啊~

不防看下耗时多少,写个时间放进去

<script>
    let prevTime = Date.now()
    const total = 100000
    let ul = document.getElementById('container')

    for (let i = 0; i < total; i++) {
        let li = document.createElement('li')
        li.innerHTML = ~~(Math.random() * total)
        ul.appendChild(li)
    }
    console.log('v8执行代码的时间:', Date.now() - prevTime); 
    setTimeout(() => {
        console.log('渲染页面的时间:', Date.now() - prevTime);
    }, 0)
</script>

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

为何这里写个定时器就能代表渲染页面的时间,大家可能都知道event-loop中,同步、微任务结束之后就是宏任务,殊不知,这中间还有个页面渲染的过程,定时器前面的代码都是同步代码,定时器是个宏任务,但是浏览器执行宏任务之前会判断是否有需要渲染页面的任务,如果有就会先去渲染页面,因此这里写个定时器的打印就能代表页面渲染了多久时间

1.png

诺~,v8 执行下代码只需要0.4秒,而页面渲染需要3.3秒

因此这么写,问题出现在页面渲染上,而非代码的执行,v8 性能是很高的

缺点

  • 页面渲染很久
  • 十万次的回流

定时器

这十万条数据,用户的屏幕又不能一次性全部展现出来,何苦一次性加载,那我就用个定时器,一次定时器加载一点

<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,这样一来,用户打开界面就比之前流畅了

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

2.gif

为何闪屏

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

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

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

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

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

其实还有个原因也导致了这个闪屏:定时器在 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到页面渲染后半段:回流,重绘,优化【一次性带你搞明白】 - 掘金 (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只会在可视窗口和上下缓存区存在

2.png

考虑到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>

接下来模拟一个接口请求,拿到数据后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>

效果如下

3.gif

总结

实现

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

优点

无论数据多少都不会卡顿

最后

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

如果你对春招感兴趣,可以加我的个人微信:Dolphin_Fung,我和我的小伙伴们有个面试群,可以进群讨论你面试过程中遇到的问题,我们一起解决

另外有不懂之处欢迎在评论区留言,如果觉得文章对你学习有所帮助,还请”点赞+评论+收藏“一键三连,感谢支持!