- 原文地址(本人博客):www.waylon.online/blog?id=45b…
背景
最近实现需求时碰到这样一个场景,后端由于时效性问题,只能一次性返回所有的商品。召回的商品数量可能多,可能少。当商品数量比较多时,前端需要对拿到的数据进行预处理加上页面一次性渲染多个商品,给页面带来了明显的卡顿,本文记录如何通过requestAnimationFrame、requestIdleCallback优化这一场景,并且封装一个通用方法用于各个业务场景。
下图是当初笔者遇到的究极长任务。
思路(废话可不看)
其实这是个很经典的长列表渲染优化问题,这个问题的本质就是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>
最终,两种模式的火焰图如下: 一次性渲染:
逐帧渲染:
可以看到分批次渲染以后,长任务消失了。