【性能优化】封装了一个逐帧渲染长列表的通用方法

467 阅读3分钟

背景

最近实现需求时碰到这样一个场景,后端由于时效性问题,只能一次性返回所有的商品。召回的商品数量可能多,可能少。当商品数量比较多时,前端需要对拿到的数据进行预处理加上页面一次性渲染多个商品,给页面带来了明显的卡顿,本文记录如何通过requestAnimationFrame、requestIdleCallback优化这一场景,并且封装一个通用方法用于各个业务场景。

下图是当初笔者遇到的究极长任务。

imgsearchrender.png

思路(废话可不看)

其实这是个很经典的长列表渲染优化问题,这个问题的本质就是JavaScript 是单线程的,所有的任务都在一个线程上执行,这个线程被称为 JS 主线程。浏览器的渲染过程则由 GUI 渲染线程 负责。JS 主线程和 GUI 渲染线程是互斥的,当 JS 主线程执行时,GUI 渲染线程会被阻塞,无法进行渲染操作,当 JS 主线程执行一个长时间的任务时,GUI 渲染线程无法进行页面的更新和渲染,导致页面卡顿,用户无法进行交互。

既然如此,我们就把长任务拆分成一个个“小任务”,分批次执行。具象到业务中,就是拿到了商品数据,先上屏少量的商品,等到浏览器空闲时再逐帧分批渲染剩余的列表。

核心代码

废话不多说,直接上核心代码,提供了cb用于处理、更新数据,以及cancel用于取消后续的逐帧渲染任务(部分更新场景需要)。

// requestAnimationFrame 的兼容性处理
const asyncAnimationFrameFunc = (cb, time = 300) => {
  if (window.requestAnimationFrame) {
    return requestAnimationFrame(cb);
  } else {
    return setTimeout(cb, time);
  }
};

// requestIdleCallback 的兼容性处理
const asyncIdleCallbackFunc = (cb, time = 300) => {
  if (window.requestIdleCallback) {
    return requestIdleCallback(cb);
  } else {
    return setTimeout(cb, time);
  }
};

// 核心方法
const batchHandleList = ({
  list = [],
  cb = () => {},
  limit = 10,
  chunkSize = 10,
}) => {
  let isCancelled = false // 是否取消

  const cancel = () => {
    isCancelled = true
  };
  const firstScreenList = list.slice(0, limit); // 首屏数据
  cb(firstScreenList); // 拿到首屏数据后立即处理
  asyncIdleCallbackFunc(() => {
    let index = 0; // 记录索引
    const processChunk = () => {
      if (isCancelled) return; // 检查是否取消
      const end = Math.min(
        index + chunkSize,
        list.length - limit
      );
      const chunkList = []
      for (let i = index; i < end; i++) {
        chunkList.push(list[i + limit]);
      }
      index += chunkSize;
      cb(chunkList);
      if (index < list.length - limit) {
        asyncAnimationFrameFunc(processChunk);
      }
    };
    processChunk();
  })
  return cancel; // 返回取消方法 用于外界取消
}

export default batchHandleList;

DEMO

模拟了下此前我碰到的业务场景,收到大量商品数据,对商品数据进行处理,然后上屏。这里我分别实现了一次性上屏与逐帧上屏,代码如下:

<template>
  <div class="list">
    <div class="item" v-for="item in goodsList" :key="item.id">
      <div
        class="img"
        :style="{
          backgroundColor: item.color,
        }"
      >
        <img
          src="https://img.ltwebstatic.com/images3_pi/2024/05/20/2d/171618729919fdb234acfb6ebdcb5dc20d880d67be_thumbnail_240x.webp"
          alt=""
        />
      </div>
      <div class="name">
        {{ item.name }}
      </div>
      <div class="price">¥{{ item.price }}</div>
      <div class="color">
        {{ item.color }}
      </div>
      <div class="size">
        {{ item.size }}
      </div>
      <div class="stock">库存: {{ item.stock }}</div>
      <div class="sale">销量: {{ item.sale }}</div>
    </div>
  </div>
</template>

<script setup>
import { onMounted, ref } from "vue";
import batchHandleList from "./common/batchHandleList";

const goodsList = ref([]);
const MockGoodsData = [];
const FIRST_LIMIT = 10;
const CHUNK_SIZE = 4;

for (let i = 1; i <= 1000; i++) {
  MockGoodsData.push({
    id: i,
    name: `商品${i}`,
    price: i * 100,
    color: "red",
    size: "L",
    stock: 100,
    sale: 100,
  });
}
const asyncAnimationFrameFunc = (cb, time = 300) => {
  if (window.requestAnimationFrame) {
    return requestAnimationFrame(cb);
  } else {
    return setTimeout(cb, time);
  }
};

const asyncIdleCallbackFunc = (cb, time = 300) => {
  if (window.requestIdleCallback) {
    return requestIdleCallback(cb);
  } else {
    return setTimeout(cb, time);
  }
};

const oneFrameOneRender = () => {
  // 模拟请求数据

  setTimeout(() => {
    const cancel = batchHandleList({
      list: MockGoodsData,
      limit: FIRST_LIMIT,
      chunkSize: CHUNK_SIZE,
      cb: (list) => {
        goodsList.value.push(...list.map((item) => ({ ...item, handle: true })));
      }
    })
  }, 1000);
};

const onceRender = () => {
  // 模拟请求数据
  setTimeout(() => {
    const handleGoodsList = MockGoodsData.map((item) => {
      return {
        ...item,
        handle: true,
      };
    });
    goodsList.value.push(...handleGoodsList);
  }, 1000);
};

onMounted(() => {
  // 一次性渲染
  // onceRender();
  // 分批渲染
  oneFrameOneRender();
});
</script>

<style scoped>
.list {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  padding: 10px;
}
.item {
  width: 30%;
  margin-bottom: 20px;
  border: 1px solid #ccc;
  border-radius: 5px;
  padding: 10px;
}
.img {
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}
.img img {
  text-align: center;
  width: 80%;
}
</style>

最终,两种模式的火焰图如下: 一次性渲染:

oncerender.png 逐帧渲染:

oneframeonerender.png 可以看到分批次渲染以后,长任务消失了。