当前位置:首页>IT那点事>前端面试必备:如何优雅处理10万条数据渲染?时间分片与虚拟列表详解

前端面试必备:如何优雅处理10万条数据渲染?时间分片与虚拟列表详解

在前端开发中,性能优化是一个永恒的话题。我们经常需要处理和展示大量的数据,所以当面试官问到:“如何一次性渲染十万条数据而不影响用户体验?”你会怎么回答?直接渲染十万条数据可能会导致页面卡顿、响应迟缓,甚至浏览器崩溃。本篇文章详细介绍时间分片和虚拟列表的解决方案,帮助你轻松拿下面试~

前置知识

js是单线程的,会有一个同步和异步的概念,为了确保主线程不会被长时间阻塞,js引擎就会依照「事件循环机制」来执行代码:

  1. 先执行同步代码(也属于是宏任务)
  2. 同步执行完毕后,检查是否有异步代码需要执行
  3. 执行所有的微任务
  4. 微任务执行完毕后,若有需要就会渲染页面
  5. 执行宏任务(也就是下一次事件循环开始)
  • 微任务:Promise.then()、process.nextTick()、async/await、MutationObserver()等
  • 宏任务:script(开启一整份代码的执行)、setTimeout、setInterval、setImmediate、I/O操作、UI-Rendering、同步代码等

时间分片

v8引擎执行 js 代码速度很快,然而渲染页面时间相对来说要长很多。如果直接将十万条数据给到渲染引擎,很容易造成页面卡顿或白屏,所以一次性渲染十万条数据的关键在于——要让浏览器的渲染线程尽量均匀流畅地将数据渲染上去。

时间分片的核心思想是将一个大的任务分解成多个小的任务,使用  setTimeoutrequestAnimationFrame 分批次地渲染一部分数据。

使用 setTimeout

  1. 「初始化」:定义总数据条数 total、每次渲染的数据条数 once、需要渲染的总次数 page 和当前渲染的索引 index
  2. 「递归渲染」loop 函数通过递归调用来逐步渲染数据。每次for循环渲染 once 条数据,并使用 setTimeout 将渲染操作放入下一个事件循环中。
  3. 「定时器」setTimeout 确保每次渲染操作不会阻塞主线程,从而保持页面的流畅性和响应性。
  4. 「结束条件」:当所有数据都渲染完毕后(即 curTotal - pageCount <= 0),递归调用停止。
<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.innerText = curIndex + i + ':' + ~~(Math.random() * total);                         
                    ul.appendChild(li);                    
                }                    
                loop(curTotal - pageCount, curIndex + pageCount)                
            })            
        }                        
        loop(total, index)
	</script>
</body>

不让v8一次事件循环就把js部分执行掉,浏览器一次性暴力渲染十万条,而是让v8执行五千次事件循环,浏览器每次只渲染二十条。这样v8分摊了浏览器渲染线程的压力,能减少页面加载时间

requestAnimationFrame

使用 setTimeout 将渲染操作放入下一个事件循环会有个小问题:假设浏览器页面刷新时间为 16.7ms,如果v8引擎的性能不够高,进行完一次事件循环的时间比 16.7ms 要长,那么浏览器在 16.7ms 内渲染完了 20 条数据,而 v8引擎还没将下一个20条数据给出来,这样就很可能会造成页面的闪屏或卡顿。

要解决定时器带来的事件循环与屏幕刷新不同步的问题,我们可以用 requestAnimationFrame() 取代 setTimeout()

在此基础上,我们需要尽量人为地减少回流重绘次数。如果每一次for循环都渲染一条数据,那这样高频地操作DOM会浪费开销影响性能。

所以,我们可以使用文档碎片 document.createDocumentFragment()——一个虚拟的DOM, 来批量插入 li 元素。使得生成好一条数据后,先不要往 ul 里面添加,固定每20个li只回流一次

<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);
            
            requestAnimationFrame(() => {
                // 创建一个文档碎片,是一个虚拟的DOM结构
                let fragment = document.createDocumentFragment();
                
                // 循环创建当前页的列表项
                for (let i = 0; i < pageCount; i++) {
                    let li = document.createElement('li');
                    li.innerText = curIndex + i + ':' + ~~(Math.random() * total);
                    fragment.appendChild(li);
                }
                
                // 固定每20个li只回流一次
                ul.appendChild(fragment);
                
                // 递归调用,处理剩余数据
                loop(curTotal - pageCount, curIndex + pageCount);
            });
        }
        
        // 开始渲染
        loop(total, index);
    </script>
</body>

虚拟列表

虚拟列表技术通过「只渲染当前可见区域的数据」来提高性能,而不是一次性渲染所有数据。这样可以显著减少 DOM 元素的数量,从而提高页面的加载速度和滚动流畅性。

核心思想

  1. 「初始化容器和数据」:创建固定高度的容器,准备数据源。
  2. 「计算可视区域」:获取容器高度,计算每个项的高度和可视区域的数据条数。
  3. 「渲染初始可见区域」:计算起始和结束索引,渲染初始数据。
  4. 「监听滚动事件」:绑定滚动事件,计算新的起始和结束索引,更新渲染数据。
  5. 「调整样式」:计算偏移量,处理实际列表跟随父容器一起移动的情况

接下来用 vue 技术栈展示虚拟列表实现步骤:

根组件App.vue中:

  • 定义数据源 data,里面存放一千个对象,每个对象包含 id 和 value 属性
  • 引入自定义的 virtualList 组件,并通过 :listData 属性将 data 传递给它
  • 设置 .app 类的样式,使其具有固定的宽度和高度,并添加边框以区分容器区域
<template>
 <div class="app">
   <virtualList :listData="data" />
 </div>
</template>

<script setup>
import { ref } from 'vue'
import virtualList from './components/virtualList.vue'

// 初始化数据源
const data = ref([])

// 生成1000条测试数据
for (let i = 0; i < 1000; i++) {
 data.value.push({
   id: i,
   value: i
 })
}
</script>

<style lang="css" scoped>
.app {
 width: 300px;
 height: 400px;
 border: 1px solid #000;
}
</style>

自定义virtualList组件中:

「模板部分:」

  • 根元素infinite-list-container:绑定了 ref 为 listRef,用于后续获取 DOM 元素,并且绑定滚动事件处理器 scrollEvent
  • 虚拟占位元素infinite-list-phantom:用于撑开父容器的高度,确保可以滚动。其高度由 listHeight 计算得出
  • 实际列表元素infinite-list:使用 transform 属性来控制列表的位置
  • 列表项元素infinite-list-item:通过 v-for 循环渲染 visibleData  中的数据项。每个项的高度和行高由 itemSize 控制
<template>
 <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
       v-for="item in visibleData"
       :key="item.id" 
       class="infinite-list-item"
       :style="{
         height: itemSize + 'px',
         lineHeight: itemSize + 'px'
       }"
     >
       {{ item.value }}
     </div>
   </div>
 </div>
</template>

「脚本及样式部分:」

<script setup>
import { onMounted, reactive, ref, computed } from 'vue'

// 定义props
const props = defineProps({
 listData: [],
 // 每个item的高度
 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(props.listData.length, state.end)
 )
})

// 当前列表总高度
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)
}
</script>

<style lang="css" scoped>
.infinite-list-container {
 height: 100%;
 overflow: auto;
 position: relative;
 -webkit-overflow-scrolling: touch; /* 启用触摸滚动 */
}

.infinite-list-phantom {
 position: absolute;
 left: 0;
 top: 0;
 right: 0;
 z-index: -1; /* 置于背景层 */
}

.infinite-list {
 position: absolute;
 left: 0;
 top: 0;
 right: 0;
 text-align: center;
}

.infinite-list-item {
 border-bottom: 1px solid #000;
 box-sizing: border-box;
}
</style>
image-10.png

总结

  • 「时间分片」
    • 根据事件循环机制,每一次事件循环都会先进行页面渲染,再执行宏任务。
    • 所以可以使用 setTimeoutrequestAnimationFrame 定时器将生成数据的js线程操作和渲染数据的渲染线程操作隔离到两次事件循环中,这样浏览器就能分批次地渲染一部分数据。
    • 再配合文档碎片 document.createDocumentFragment()减少回流次数,提高性能
  • 「虚拟列表」
    • 拿到所有数据,计算出所有数据应有列表高度;
    • 获取可视区域的高度,计算出可视区域中能渲染的数据条数
    • 在实时滚动时计算要渲染的数据起始和截止下标,仅渲染那些在当前视口中的数据项
    • 计算偏移量,调整样式

除了以上这两种方法,还可以采用懒加载、Web Workers等方法对渲染大量数据的操作进行优化,希望对你有所帮助。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。

给TA打赏
共{{data.count}}人
人已打赏
6 条回复 A文章作者 M管理员
  1. 板凳温柔

    装装逼

  2. 翅膀酷炫

    点个赞

个人中心
搜索

本站承接WordPress建站仿站、二次开发、主题插件定制等PHP开发服务!