十万条数据的优雅加载:分批渲染与虚拟列表

一:问题分析

最近秋招开始面试了,在前端岗的面试中遇到这样的一个情景题,这题目考察的是对前端性能优化的理解以及处理大数据量时的技术方案。下面带友友们来剖一剖,首先我们先来一个小demo来看看一次性渲染十万条数据的效果是怎么样的,我们在一个HTML页面中创建一个包含10万个

  • 元素的
      列表,并记录整个过程的时间开销

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

      99e58a1ceb56fdd35b07e85cfc7e348.jpg

      可以看到,导致页面加载缓慢的是页面渲染速度过慢,js的运行速度还算可以的。以上demo中是暴力渲染,接下来带友友们了解两个方法来加快渲染速度,提升用户体验感以及页面渲染效率

      二:分批渲染

      递归渲染函数

      • loop函数接收两个参数:当前还需要渲染的总数(curTotal)和当前已渲染的数量(curIndex),计算本次需要渲染的数量(pageCount),并且不超过剩余的数量。
      • 使用setTimeout来异步执行DOM操作,这允许浏览器有时间去处理其他任务(如事件处理、绘制等)。
      • setTimeout的回调函数中,使用for循环创建
      • 元素,并将其追加到
          容器中。
        • 如果还有剩余数据需要渲染,则继续递归调用loop函数。
        
          <ul id="container">ul>
        
          <script>
            let ul = document.getElementById("container");
            const total = 100000
            let once = 20 
            let page = total / once
            let index = 0
        
            function loop(curTotal, curIndex) {
              let pageCount = Math.min(once, curTotal)
        
              setTimeout(() => {
                for (let i = 0; i < pageCount; i++) {
                  let li = document.createElement("li")
                  li.innerHTML = curIndex + i + ':' + ~~(Math.random() * total)
                  ul.appendChild(li)
                }
                loop(curTotal - pageCount, curIndex + pageCount)
              })
            }
            loop(total, index)
          script>
        
        

        浏览器的刷新频率通常是每秒60帧,即大约每16.7毫秒刷新一次,虽然使用setTimeout可以减轻浏览器的负担,但setTimeout默认延迟时间为0,这意味着它会在当前任务队列结束后执行,也就是说定时器生效时间并不是固定的。v8引擎的事件循环机制中,下一个事件不一定要等到16.7ms,但如果v8引擎没有跟上,在一个或者多个16.7ms后没有进入到下一个事件中,由于是非阻塞的,就可能造成它的执行时间与页面的刷新时间并不完全同步。这意味着浏览器在渲染时可能无法及时更新屏幕,特别是在大量DOM操作的情况下。这可能导致以下问题:

        • 闪屏:当浏览器试图渲染大量的DOM元素时,如果DOM操作过于密集,浏览器可能无法及时完成渲染,导致用户看到部分渲染的内容,造成屏幕闪烁。
        • 白屏:在极端情况下,如果DOM操作过于复杂或耗时,浏览器可能无法在短时间内完成渲染,导致屏幕呈现为空白状态,直到渲染完成。

        我们将setTimeout改为requestAnimationFrame,可以很好解决这个不同步的问题,requestAnimationFrame具有以下特性:

        1. 同步刷新频率requestAnimationFrame 虽然是嵌入到事件循环机制中的,但它是在渲染阶段之前执行,而不是像 setTimeoutsetInterval 那样在回调队列中排队执行,并且requestAnimationFrame会在浏览器准备绘制下一帧前调用提供的回调函数,这样可以确保动画与屏幕刷新频率同步。
        2. 性能优化:如果浏览器处于后台或者标签页不可见状态,requestAnimationFrame 会自动暂停,从而节省CPU资源。

        解决了以上不同步的问题,还有性能方面的细节我们也要注意。由于不知道优化队列具体能装多少条数据,并且每循环一次就要回流重绘一次,因此以上的分批渲染会引起多次回流重绘。为了避免上述问题,可以使用文档片段(Document Fragment)来构建DOM结构。

        文档片段是一个没有标签的节点,可以在内存中构建完整的DOM结构,然后再一次性插入到文档中,这样可以显著减少页面的回流次数。

          requestAnimationFrame(() => {
            let fragment = document.createDocumentFragment()
        
            for (let i = 0; i < pageCount; i++) {
              let li = document.createElement("li")
              li.innerHTML = curIndex + i + ':' + ~~(Math.random() * total)
              fragment.appendChild(li)
            }
            ul.appendChild(fragment)
        
            loop(curTotal - pageCount, curIndex + pageCount)
          })
        

        三:虚拟列表

        虚拟列表通过只渲染当前可视区域的数据,而不是整个数据集,从而减少DOM操作和提高了应用性能,虚拟列表的关键在于动态计算和渲染当前可视区域内的数据,并在用户滚动时更新这些数据。

        核心思路

        1. 获取整个页面的真实数据的高度。
        2. 计算可视区的高度以及其中可以放置的数据条数。
        3. 在用户滚动页面时,实时计算出起始下标和结束下标。
        4. 对样式进行偏移,避免屏幕错误的移动。

        下面用vue项目进行展示,带友友们实现虚拟列表,主要涉及两个页面:App.vue和自定义组件virtualList.vue

        3.1: 主组件App.vue

        创建一个容器,用于展示虚拟列表组件, 将虚拟列表组件 virtualList 渲染到容器中,并传递 listData 属性。

        
        

        导入 virtualList 组件。 初始化 data 数组,包含 10 万个对象,每个对象都有 id 和 value 属性。

        
        

        3.2: 自定义组件virtualList.vue

        1: 模板部分

        "listRef" class="infinite-list-container" @scroll="scrollEvent()"> <div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }">div> <div class="infinite-list" :style="{ transform: getTransform }"> <div class="infinite-list-item" v-for="item in visibleData" :key="item.id" :style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }" > {{ item.value }} div> div>
        • 容器

          1
          "listRef" class="infinite-list-container" @scroll="scrollEvent()">
          • 创建一个名为 .infinite-list-container 的 
             容器。
          • 通过 ref 获取该元素的引用。
          • 添加 @scroll 事件监听器,当容器滚动时触发 scrollEvent 函数。
        • 占位符

          class="infinite-list-phantom" :style="{ height: listHeight + 'px' }">
          • 创建一个名为 .infinite-list-phantom 的 
             占位符。
          • 设置占位符的高度为 listHeight
        • 实际列表

          class="infinite-list" :style="{ transform: getTransform }"> <div class="infinite-list-item" v-for="item in visibleData" :key="item.id" :style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }" > {{ item.value }} div>
          • 创建一个名为 .infinite-list 的 
             实际列表。
          • 使用 :style 绑定属性 transform 为 getTransform 的值。
          • 使用 v-for 循环遍历 visibleData 数组,并渲染每个 item
          • 设置每个列表项的高度和行高。

        2: 脚本部分

        1. 初始化容器和数据

          • 使用 ref 获取列表容器的引用。
          • 初始化状态对象 state,包括可视区高度、偏移量、起始索引和结束索引。
        2. 计算可视区数据

          • 计算可视区高度和可显示的数据条数。
          • 通过 slice 方法截取当前可视区域内的数据片段。
        3. 动态更新列表

          • 在滚动事件中实时更新起始索引和结束索引,从而更新当前可视区域的数据。
          • 使用 transform 属性对列表进行偏移,确保列表随用户的滚动而平滑移动。
        4. 样式优化

          • 使用绝对定位和变换来控制列表的位置,减少DOM重排和重绘。
          • 通过占位符(phantom)来模拟整个列表的高度,确保滚动流畅。
        import { computed, nextTick, onMounted, reactive, ref } from 'vue';
        
        const props = defineProps({
          listData: [],
          itemSize: {
            type: Number,
            default: 50
          }
        })
        
        const state = reactive({
          screenHeight: 0, 
          startOffset: 0,
          start: 0,
          end: 0
        })
        
        
        const visibleCount = computed(() => {
          return state.screenHeight / props.itemSize
        })
        
        
        const visibleData = computed(() => {
          return props.listData.slice(state.start, Math.min(state.end, props.listData.length))
        })
        
        
        const listHeight = computed(() => {
          return props.listData.length * props.itemSize
        })
        
        
        const getTransform = computed(() => {
          return `translateY(${state.startOffset}px)`
        })
        
        const listRef = ref(null)
        onMounted(() => {
          state.screenHeight = listRef.value.clientHeight
          state.end = state.start + visibleCount.value
        })
        
        const scrollEvent = () => {
          let scrollTop = listRef.value.scrollTop
          state.start = Math.floor(scrollTop / props.itemSize)
          state.end = state.start + visibleCount.value
          state.startOffset = scrollTop - (scrollTop % props.itemSize)
        }
        
        • 导入和定义属性

          import { computed, nextTick, onMounted, reactive, ref } from 'vue';
          
          const props = defineProps({
            listData: [],
            itemSize: {
              type: Number,
              default: 50
            }
          })
          
          const state = reactive({
            screenHeight: 0, 
            startOffset: 0,
            start: 0,
            end: 0
          })
          
          • 导入必要的Vue Composition API函数。
          • 定义 props 属性,包含 listData 和 itemSize
          • 使用 reactive 创建响应式状态对象 state
        • 计算属性

          const visibleCount = computed(() => {
            return state.screenHeight / props.itemSize
          3})
          
          const visibleData = computed(() => {
            return props.listData.slice(state.start, Math.min(state.end, props.listData.length))
          })
          
          const listHeight = computed(() => {
            return props.listData.length * props.itemSize
          })
          
          const getTransform = computed(() => {
            return `translateY(${state.startOffset}px)`
          })
          
          • visibleCount 计算可视区可以显示的数据条数。
          • visibleData 计算当前可视区域的实际数据。
          • listHeight 计算整个列表的高度。
          • getTransform 计算列表的偏移量。
        • 引用和生命周期钩子

          const listRef = ref(null)
          onMounted(() => {
            state.screenHeight = listRef.value.clientHeight
            state.end = state.start + visibleCount.value
          })
          
          • 使用 ref 获取容器的引用。
          • 在 onMounted 生命周期钩子中初始化 screenHeight 和 end
        • 滚动事件处理

          const scrollEvent = () => {
            let scrollTop = listRef.value.scrollTop
            state.start = Math.floor(scrollTop / props.itemSize)
            state.end = state.start + visibleCount.value
            state.startOffset = scrollTop - (scrollTop % props.itemSize)
          }
          
          • scrollEvent 函数在滚动时更新 startend 和 startOffset

        四:总结

        在前端面试中探讨一次性渲染十万条数据的问题时,面试官主要想考察的是,是否理解性能优化的重要性,比如通过分页或无限滚动来减少单次加载的数据量,是否掌握虚拟滚动技术,仅渲染当前可视区域的内容,以及是否了解如何利用虚拟DOMWeb Workers等技术来提升应用性能,确保良好的用户体验。

        本文带友友们实现了前两种,至于Web Workers之后会单开一篇仔细讲讲。此外,在面试中遇到这样的问题,友友们要有 性能意识,最好可以掌握 分页技术懒加载(lazy loading)无限滚动(infinite scrolling)虚拟滚动(virtual scrolling)。当然,像数据压缩,服务器端渲染在某些场景下的优势(如SEO),或者利用流式数据处理技术来逐步加载和渲染数据,也可以对性能进行优化。

  • 阅读全文
    下载说明:
    1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
    2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
    3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
    4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
    5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
    6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
    7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
    8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
    原文链接:https://www.shuli.cc/?p=21404,转载请注明出处。
    0

    评论0

    显示验证码
    没有账号?注册  忘记密码?