JS考题-你给我渲染一百万条数据(拿捏面试官版)

274 阅读5分钟

首先,当我们要同时渲染大量的数据,例如我用一个for循环去给一个节点增加十万条子项,并打印出时间如下:

let start = new Date();
for (let i = 0; i < total; i++) {
  let li = document.createElement('li');
  li.innerText = i;
  ul.appendChild(li)
}
let end = new Date();
console.log(start-end)

但是这样打印时间是不对的,因为这打印出的只是主线程执行的时间,我们的页面还得进行渲染。

这是由于事件循环机制,页面渲染通常发生在微任务完成后。我们事件循环的机制是这样的(引导面试官问事件循环): 1.Script主线程 2.清空微任务队列 3.执行渲染 4.执行宏任务中的一个任务,并进入下一轮事件循环。

我们的渲染发生在主线程之后,所以我们应该在宏任务中完成定时。

let start = new Date();
for (let i = 0; i < total; i++) {
  let li = document.createElement('li');
  li.innerText = i;
  ul.appendChild(li)
}
let end = new Date();
settimeout(() = > console.log(start-end),0)

我通过浏览器的performance工具(展示实力)查看之后发现,主要是页面的计算样式和渲染占据的时间长(引导面试官问渲染相关知识)。

造成了页面的卡顿,因为js交互为宏任务,无法继续循环。并且一次性渲染过多数据,页面渲染时间长。

因此我们采用分批次递归加入宏任务队列的方法,这样js事件也能从中间穿插,用户仍然能进行交互。

        let ul = document.getElementById('container');
        let total = 100000;
        let once = 20;
        let times = Math.ceil(total / once);
        let index = 0;
        function render() {
            for (let i = 0; i < once; i++) {
                let li = document.createElement('li');
                li.innerText = 1;
                ul.appendChild(li);
            }
        }
        function loop() {
            setTimeout(() => {
                render();
                index++;
                if (index < times) {
                    loop();
                }
            }, 0); 
        }
        loop();

但是这时候还有问题,我们的浏览器刷新页面是60帧,也就是将近16.7ms每次,但是 某部分行为,例如查看滚动位置,(详情看我之前的文章事件循环)。这时候会立即的执行一次渲染。因为他要获取准确的计算结果。这种情况会造成大量的重排,就会引起卡顿。

这时候可以利用 嵌套 requestAnimationFrame 函数,避免中间渲染。提高浏览器的性能。 当嵌套 requestAnimationFrame 函数时候,他会在下一次渲染前执行一次这个回调。

并且他相比我们定时器的好处是定时器可能有几ms的延迟,并且可能在页面刷新时间执行,从而延迟我们的下一帧的刷新。

但是raf 一定会在我们的页面刷新前执行一次,这样能够很好的维护。并且他能随着我们的刷新率主动降级。做到精准同频帧率。

还可以配合上文档碎片(documentFragment),比如跨任务异步操作,移动DOM,能将文档碎片DOM一次性的添加进去。

或者我们也可以通过懒加载的方式,监听用户的onScreen事件来做到懒加载。

终极方案:虚拟列表

虚拟列表就是通过下滑的同时进行列表项的向下偏移。接着根据ScrollTop的位置来决定列表的向下偏移量,达到可以一直滑动的效果。同时根据位置来更新数据项。

每次滑动都会造成样式的重新计算,会造成性能的浪费,可以通过节流函数来进行节流。但是最后一次可能监听不到,需要使用一个多执行一次的高级节流函数。更合理的方案是使用raf来进行计算。

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

const list = ref([])
for(let i = 0; i < 1000000; i++) {
  list.value.push({ id: i, name: `张三${i}` })
}

const itemHeight = 100
const scrollTop = ref(0)

// 真正的节流函数
const throttle = (fn, delay) => {
  let lastCall = 0
  return function(...args) {
    const now = Date.now()
    if (now - lastCall >= delay) {
      lastCall = now
      fn.apply(this, args)
    }
  }
}

const handleScroll = throttle((e) => {
  scrollTop.value = e.target.scrollTop
}, 16) // ~60fps

// 根据滚动位置计算起始索引
const startIndex = computed(() => {
  return Math.floor(scrollTop.value / itemHeight)
})

// 计算可见列表
const visibleList = computed(() => {
  return list.value.slice(startIndex.value, startIndex.value + 5)
})

// 计算所有项的总高度
const totalHeight = computed(() => {
  return list.value.length * itemHeight
})

// 计算列表位置偏移
const startOffset = computed(() => {
  return startIndex.value * itemHeight
})
</script>

<template>
  <div class="container">
    <div 
      class="scroll-container" 
      :style="{
        overflow: 'auto',
        height: '400px',
        width: '120px',
        border: '1px solid #ccc',
        position: 'relative'
      }"
      @scroll="handleScroll"
    >
      <!-- 撑开滚动区域的占位符 -->
      <div :style="{ height: totalHeight + 'px' }"></div>
      
      <!-- 列表项容器 - 整体移动 -->
      <div 
        class="list-items-container" 
        :style="{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          transform: `translateY(${startOffset}px)`
        }"
      >
        <div 
          v-for="item in visibleList" 
          :key="item.id"
          :style="{
            height: itemHeight + 'px',
            width: '100%',
            backgroundColor: 'red',
            border: '2px solid black',
            boxSizing: 'border-box',
            color: 'white',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center'
          }"
        >
          {{ item.name }}
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>

</style>

  • 虚拟列表与传统渲染方式相比,具体能提升哪些性能指标?

    只需要渲染可视区域元素,大大减少了DOM渲染,首屏加载时间减少

  • IntersectionObserver API可以用于优化虚拟列表

    能够精确的监听进入视口的时间,这样不用任何滚动事件都去触发onScroll事件。

  • 虚拟列表当快速或者跳跃式滑动时出现的白屏如何解决

    增加渲染缓冲区 !!! 跳跃滚动不节流。使用raf代替节流。 假如出现极大的跳跃式滚动的话,单独处理跳跃逻辑,占位图或者骨架屏。 硬件加速 translateZ(0);

  • 为什么渲染选择20条数据?

    单条数据渲染约0.5-1ms,20条数据通常能在10-15ms内完成,太小(如5条):需要更多渲染批次,完成总渲染时间长,太大(如50条)可能导致单次渲染阻塞时间过长,影响页面与交互流畅度。

  • 你使用了 transform 来偏移列表,而不是改变 top 值,这两种方式在性能上有什么区别?为什么前者更好?

transform可能会进行GPU的加速,并且transform不会引起重排。top是在layout布局阶段干的事。

  • 在虚拟列表实现中,当列表项高度不一致时,你的算法需要如何调整?能否详细描述这种情况下的实现思路?

根据高度计算偏移量,通过总高度决定渲染数量。计算存储每个项起始位置。