【总结】2221- 十万条数据的优雅加载:分批渲染与虚拟列表

科技   2024-10-15 09:34   福建  

本文来源于掘金社区,作者:midsummer18
原文链接:https://juejin.cn/post/7414732910240874531

一:问题分析

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

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


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

二:分批渲染

递归渲染函数

  • loop函数接收两个参数:当前还需要渲染的总数(curTotal)和当前已渲染的数量(curIndex),计算本次需要渲染的数量(pageCount),并且不超过剩余的数量。
  • 使用setTimeout来异步执行DOM操作,这允许浏览器有时间去处理其他任务(如事件处理、绘制等)。
  • setTimeout的回调函数中,使用for循环创建<li>元素,并将其追加到<ul>容器中。
  • 如果还有剩余数据需要渲染,则继续递归调用loop函数。
<body>
  <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>

</body>

浏览器的刷新频率通常是每秒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 属性。

<template>
  <div class="app">
    <virtualList :listData="data" />
  </div>

</template>

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

<script setup>
import virtualList from './components/virtualList.vue';
const data = []

for (let i = 0; i < 100000; i++) {
  data.push({id: i, value: i})
}
</script>

3.2: 自定义组件virtualList.vue

1: 模板部分

<div ref="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>

</div>
  • 容器

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

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

    <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>
    • 创建一个名为 .infinite-list 的 <div> 实际列表。
    • 使用 :style 绑定属性 transform 为 getTransform 的值。
    • 使用 v-for 循环遍历 visibleData 数组,并渲染每个 item
    • 设置每个列表项的高度和行高。

2: 脚本部分

  1. 初始化容器和数据

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

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

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

    • 使用绝对定位和变换来控制列表的位置,减少DOM重排和重绘。
    • 通过占位符(phantom)来模拟整个列表的高度,确保滚动流畅。
    import { computed, nextTick, onMounted, reactive, ref } from 'vue';

    const props = defineProps({
      listData: [],
      itemSize: {
        typeNumber,
        default50
      }
    })

    const state = reactive({
      screenHeight0
      startOffset0,
      start0,
      end0
    })

    // 可视区显示的数据条数
    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
    })

    // list跟着父容器移动了,现在列表要移动回来
    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: {
          typeNumber,
          default50
        }
      })

      const state = reactive({
        screenHeight0
        startOffset0,
        start0,
        end0
      })
      • 导入必要的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),或者利用流式数据处理技术来逐步加载和渲染数据,也可以对性能进行优化。

    前端自习课
    每日清晨,享受一篇前端优秀文章。
     最新文章