如何在浏览器中执行100万个任务-并保证页面不卡顿
约 4375 字大约 15 分钟
2026-05-03
业务场景
- 前端超大 Excel/CSV 导入解析、批量数据校验
- Canvas / 图片像素级批量处理f
- 低代码 / 可视化平台大批量组件数据初始化渲染 ...
卡顿的本质
「主线程被单个或连续的同步任务长时间阻塞(超过16.6ms,即60fps的一帧渲染周期)」——主线程同时负责「用户交互响应、UI渲染、事件循环调度、JS执行、DOM操作」,一旦被占满,就会出现「点击无反应、滚动卡顿、动画掉帧、输入延迟」等问题。
解决100万个任务不卡顿的核心思路是:把任务「拆解、调度、转移」,不让它们连续阻塞主线程超过16.6ms,同时保证任务的执行效率和正确性。
浏览器事件循环与主线程调度(中级必掌握)
要解决问题,必须先搞懂主线程的调度规则——事件循环(Event Loop):
- 主线程的任务队列:分为「宏任务队列(Macro Task)」和「微任务队列(Micro Task)」
- 宏任务:
setTimeout/setInterval、requestAnimationFrame、I/O操作、UI渲染 - 微任务:
Promise.then/catch/finally、queueMicrotask、MutationObserver
- 宏任务:
- 执行顺序(固定):
- 执行当前调用栈的同步代码
- 清空微任务队列(所有微任务按顺序执行完)
- 从宏任务队列取一个任务执行
- 再次清空微任务队列
- 执行UI渲染(如果有需要)
- 循环,直到所有任务执行完
方案1:分片渲染(Chunked Rendering)—— 最常用、最基础的方案
适用场景:任务是「纯JS计算+DOM操作」(如渲染100万条列表数据、处理100万条JSON数据并展示),不需要跨线程通信。 核心原理:把100万个任务拆分成多个小的「任务块(Chunk)」,每个任务块的执行时间控制在10ms以内(留6.6ms给UI渲染和用户交互),通过「宏任务调度器」(requestAnimationFrame/requestIdleCallback/setTimeout)把任务块插入到事件循环的间隙中执行,不阻塞主线程。
(1)调度器选择(中级必考点,区分度高)
| 调度器 | 执行时机 | 优先级 | 适用场景 |
|---|---|---|---|
requestAnimationFrame(rAF) | 浏览器下一次UI渲染前执行,与屏幕刷新率同步(通常60fps,即每16.6ms执行一次) | 高(UI渲染前) | 任务块包含DOM操作,需要保证渲染流畅 |
requestIdleCallback(rIC) | 浏览器空闲时执行,有deadline(剩余时间)限制,默认最多50ms | 低(UI渲染后、用户交互后) | 任务块是纯JS计算,不需要立即执行,不影响用户体验 |
setTimeout(fn, 0) | 下一个宏任务执行,至少延迟4ms(浏览器限制) | 中 | 兼容性最好(IE9+),但执行时机不稳定 |
(2)代码实现(Vue3 + rAF调度器,中级必掌握)
<template>
<div class="task-container">
<h3>任务执行进度:{{ progress }}%</h3>
<button @click="startTasks" :disabled="isRunning">开始执行100万个任务</button>
<button @click="cancelTasks" :disabled="!isRunning">取消执行</button>
<div class="result-container">
<!-- 展示部分结果,避免DOM节点过多 -->
<div v-for="item in displayResults" :key="item.id">{{ item.value }}</div>
</div>
</div>
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
// 状态变量
const TOTAL_TASKS = 1000000
const CHUNK_SIZE = 1000 // 每个任务块执行1000个任务,控制在10ms以内
const progress = ref(0)
const isRunning = ref(false)
const displayResults = ref([])
let currentIndex = 0
let rafId = null
// 单个任务的逻辑(示例:生成一个随机数)
const singleTask = (index) => {
return {
id: index,
value: Math.random().toFixed(4)
}
}
// 执行一个任务块
const executeChunk = () => {
if (!isRunning.value) return
const endIndex = Math.min(currentIndex + CHUNK_SIZE, TOTAL_TASKS)
const chunkResults = []
// 执行当前任务块
for (let i = currentIndex; i < endIndex; i++) {
chunkResults.push(singleTask(i))
}
// 更新进度
currentIndex = endIndex
progress.value = Math.floor((currentIndex / TOTAL_TASKS) * 100)
// 只展示前100条结果,避免DOM节点过多导致卡顿
if (displayResults.value.length < 100) {
displayResults.value.push(...chunkResults.slice(0, 100 - displayResults.value.length))
}
// 判断是否完成
if (currentIndex < TOTAL_TASKS) {
// 未完成:用rAF调度下一个任务块
rafId = requestAnimationFrame(executeChunk)
} else {
// 完成:重置状态
isRunning.value = false
alert('100万个任务执行完成!')
}
}
// 开始执行任务
const startTasks = () => {
if (isRunning.value) return
isRunning.value = true
currentIndex = 0
progress.value = 0
displayResults.value = []
rafId = requestAnimationFrame(executeChunk)
}
// 取消执行任务
const cancelTasks = () => {
isRunning.value = false
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
}
onUnmounted(() => {
// 组件卸载时取消任务
cancelTasks()
})
</script>(3)rIC调度器的代码实现(补充,高级加分项)
// 用rIC调度器执行任务块
const executeChunkWithRIC = (deadline) => {
if (!isRunning.value) return
// 利用deadline的剩余时间执行任务,避免超过deadline
while (currentIndex < TOTAL_TASKS && deadline.timeRemaining() > 1) {
displayResults.value.push(singleTask(currentIndex))
currentIndex++
progress.value = Math.floor((currentIndex / TOTAL_TASKS) * 100)
}
if (currentIndex < TOTAL_TASKS) {
requestIdleCallback(executeChunkWithRIC)
} else {
isRunning.value = false
alert('100万个任务执行完成!')
}
}方案2:Web Worker(多线程)—— 最彻底、最高效的方案
适用场景:任务是「纯JS计算,不需要DOM操作」(如处理100万条大数据的排序、过滤、加密、解密、机器学习推理),需要跨线程通信。 核心原理:Web Worker是浏览器提供的独立于主线程的后台线程,可以执行纯JS代码,不会阻塞主线程;主线程和Worker线程通过「postMessage发送消息」和「onmessage接收消息」进行通信。
(1)核心限制(中级必考点)
- 不能操作DOM:Worker线程没有
window/document/DOM对象 - 不能访问主线程的变量:只能通过
postMessage传递数据(数据会被结构化克隆,不能传递函数、Symbol、DOM节点等) - 有创建和通信开销:创建一个Worker需要几毫秒到几十毫秒,通信也有开销,所以适合大任务、长时间运行的任务,不适合小任务
(2)代码实现(Vue3 + Web Worker,中级必掌握)
第一步:创建Worker文件(src/workers/task.worker.js)
// task.worker.js
self.onmessage = (e) => {
const { TOTAL_TASKS, CHUNK_SIZE } = e.data
const results = []
// 执行所有任务(纯JS计算)
for (let i = 0; i < TOTAL_TASKS; i++) {
results.push({
id: i,
value: Math.random().toFixed(4)
})
// 每执行CHUNK_SIZE个任务,向主线程发送一次进度
if ((i + 1) % CHUNK_SIZE === 0) {
self.postMessage({
type: 'progress',
progress: Math.floor(((i + 1) / TOTAL_TASKS) * 100),
chunkResults: results.slice(i - CHUNK_SIZE + 1, i + 1)
})
}
}
// 所有任务执行完成,向主线程发送最终结果
self.postMessage({
type: 'complete',
results: results
})
}第二步:Vue3组件中使用Worker
<template>
<div class="task-container">
<h3>任务执行进度:{{ progress }}%</h3>
<button @click="startTasks" :disabled="isRunning">开始执行100万个任务</button>
<button @click="cancelTasks" :disabled="!isRunning">取消执行</button>
<div class="result-container">
<div v-for="item in displayResults" :key="item.id">{{ item.value }}</div>
</div>
</div>
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
// 导入Worker(Vite默认支持,Webpack需要worker-loader)
import TaskWorker from './workers/task.worker.js?worker'
// 状态变量
const TOTAL_TASKS = 1000000
const CHUNK_SIZE = 10000
const progress = ref(0)
const isRunning = ref(false)
const displayResults = ref([])
let worker = null
// 开始执行任务
const startTasks = () => {
if (isRunning.value) return
isRunning.value = true
progress.value = 0
displayResults.value = []
// 创建Worker实例
worker = new TaskWorker()
// 监听Worker的消息
worker.onmessage = (e) => {
const { type, progress: p, chunkResults, results } = e.data
if (type === 'progress') {
progress.value = p
if (displayResults.value.length < 100) {
displayResults.value.push(...chunkResults.slice(0, 100 - displayResults.value.length))
}
} else if (type === 'complete') {
isRunning.value = false
alert('100万个任务执行完成!')
worker.terminate() // 终止Worker
worker = null
}
}
// 监听Worker的错误
worker.onerror = (err) => {
console.error('Worker执行错误', err)
isRunning.value = false
worker.terminate()
worker = null
}
// 向Worker发送消息,启动任务
worker.postMessage({ TOTAL_TASKS, CHUNK_SIZE })
}
// 取消执行任务
const cancelTasks = () => {
isRunning.value = false
if (worker) {
worker.terminate() // 立即终止Worker
worker = null
}
}
onUnmounted(() => {
// 组件卸载时终止Worker
cancelTasks()
})
</script>(3)高级优化:Web Worker线程池(高级加分项,大厂必问)
如果需要同时执行多个大任务,创建多个Worker会有较大的开销,此时可以用Web Worker线程池——预先创建固定数量的Worker(通常等于CPU核心数,navigator.hardwareConcurrency),把任务分配给空闲的Worker执行,避免频繁创建和销毁Worker。
方案3:混合方案(分片渲染 + Web Worker)—— 最灵活、最全面的方案
适用场景:任务是「纯JS计算 + DOM操作」(如处理100万条大数据并渲染部分结果),需要同时利用「Web Worker的多线程计算能力」和「分片渲染的DOM操作能力」。 核心原理:
- 把「纯JS计算」部分交给Web Worker执行
- Web Worker每执行一个任务块,向主线程发送一次进度和部分结果
- 主线程用「分片渲染调度器」(rAF)把部分结果渲染到DOM中
四、源码/原理级细节(高级必掌握,拉开层级)
1. requestAnimationFrame的底层原理
requestAnimationFrame是浏览器提供的「与屏幕刷新率同步的API」,底层是浏览器的「渲染线程」和「JS主线程」的协同:
- 渲染线程每16.6ms(60fps)准备一次UI渲染
- 渲染线程准备好后,会通知JS主线程执行
requestAnimationFrame的回调 - 回调执行完后,渲染线程执行UI渲染
- 这样可以保证回调在UI渲染前执行,避免DOM操作和UI渲染冲突,保证渲染流畅
2. Web Worker的底层原理
Web Worker是浏览器提供的「独立的V8/JavaScriptCore实例」,底层是操作系统的「线程」:
- 每个Worker有自己独立的「调用栈」、「堆内存」、「事件循环」
- 主线程和Worker线程通过「结构化克隆算法(Structured Cloning Algorithm)」传递数据,数据会被复制一份,不会共享内存(避免线程安全问题)
- 高级浏览器支持「Transferable Objects」,可以把「ArrayBuffer」、「ImageBitmap」等对象的所有权转移给Worker线程,不需要复制,大幅提升通信效率
五、最佳工程实践(体现工程落地能力)
- 优先选择Web Worker:如果任务是纯JS计算,不需要DOM操作,优先用Web Worker,最彻底、最高效
- 合理设置任务块大小:任务块的执行时间控制在10ms以内(留6.6ms给UI渲染和用户交互),不要太大也不要太小(太小会增加调度开销)
- 只展示必要的DOM节点:不要把100万条结果都渲染到DOM中,只展示前100条或当前可见的部分(用「虚拟滚动」),避免DOM节点过多导致卡顿
- 合理使用Transferable Objects:如果需要传递大的二进制数据(如ArrayBuffer),用Transferable Objects转移所有权,不需要复制,大幅提升通信效率
- 合理使用Web Worker线程池:如果需要同时执行多个大任务,用Web Worker线程池,避免频繁创建和销毁Worker
- 组件卸载时清理资源:组件卸载时,必须取消
requestAnimationFrame/requestIdleCallback,必须终止Web Worker,避免内存泄漏
六、实战踩坑避坑(体现真实项目能力)
- 任务块太大导致卡顿
- 原因:任务块的执行时间超过16.6ms,阻塞了主线程
- 解决方案:减小任务块大小,控制在10ms以内
- Web Worker传递大的数据导致卡顿
- 原因:用结构化克隆算法传递大的数据(如100万条JSON数据),复制数据需要很长时间,阻塞了主线程
- 解决方案:用Transferable Objects转移所有权(如果是二进制数据),或者分块传递数据
- Web Worker不能操作DOM导致错误
- 原因:在Worker线程中尝试操作DOM(如
document.getElementById) - 解决方案:把DOM操作交给主线程,Worker线程只执行纯JS计算
- 原因:在Worker线程中尝试操作DOM(如
- 组件卸载时没有清理资源导致内存泄漏
- 原因:组件卸载时,没有取消
requestAnimationFrame/requestIdleCallback,没有终止Web Worker - 解决方案:在
onUnmounted钩子中清理所有资源
- 原因:组件卸载时,没有取消
- 虚拟滚动的实现错误导致卡顿
- 原因:虚拟滚动的实现有问题(如计算可见区域的索引错误、频繁更新DOM)
- 解决方案:使用成熟的虚拟滚动库(如
vue-virtual-scroller、react-window)
一、executeChunkWithRIC(requestIdleCallback 时间切片)
1. 核心定义
requestIdleCallback(简称 rIC)是浏览器低优先级调度API,专门用于: 在浏览器「空闲时段」执行任务,绝不阻塞渲染、点击、滚动等用户交互。 它和 requestAnimationFrame(rAF)最大区别:
- rAF:必须每帧执行(16.6ms一次),适合渲染
- rIC:有空才执行,适合后台计算、非紧急任务
2. 核心API(面试必答)
requestIdleCallback((deadline) => {
// 1. deadline.timeRemaining():当前空闲剩余时间(ms),一般最大50ms
// 2. deadline.didTimeout:是否超时强制执行
})黄金规则:只要 timeRemaining() > 0 才继续执行任务,否则让出主线程。
3. 完整代码:100万任务 rIC 分片(可直接运行)
class IdleTaskRunner {
constructor(total = 1000000) {
this.total = total;
this.index = 0;
this.isRunning = false;
this.id = null; // 用于取消
}
// 单个任务
runTask(i) {
return { id: i, value: Math.random() };
}
// 核心:空闲时间执行切片
executeChunk = (deadline) => {
if (!this.isRunning) return;
// 有空闲时间 && 没跑完
while (this.index < this.total && deadline.timeRemaining() > 1) {
this.runTask(this.index);
this.index++;
}
// 没跑完,继续等下一次空闲
if (this.index < this.total) {
this.id = requestIdleCallback(this.executeChunk);
} else {
console.log("✅ 100万任务完成(rIC)");
this.isRunning = false;
}
};
start() {
this.isRunning = true;
this.index = 0;
this.id = requestIdleCallback(this.executeChunk);
}
stop() {
this.isRunning = false;
cancelIdleCallback(this.id);
}
}
// 使用
const runner = new IdleTaskRunner(1000000);
runner.start();
// runner.stop() // 随时取消二、混合方案(Web Worker + 时间切片)
1. 解决什么痛点(面试必问)
- 纯 Worker:一次性 postMessage 100万条数据 → 主线程解析卡死
- 纯 分片:100万计算在主线程 → 计算耗时、抢占资源
- 混合方案: Worker 分块计算 → 分批传数据 → 主线程 rAF 分片渲染 ✅ 计算不卡 ✅ 通信不卡 ✅ 渲染不卡
2. 完整实现(两部分代码)
① Worker 线程:分块计算,分批发送
// task.worker.js
const TOTAL = 1000000;
const CHUNK = 5000; // 每批5000条,绝不发大数据
let index = 0;
self.onmessage = () => {
function sendChunk() {
const chunk = [];
const end = Math.min(index + CHUNK, TOTAL);
for (; index < end; index++) {
chunk.push({ id: index, val: index });
}
// 分批发送,不卡主线程
self.postMessage({ type: "chunk", data: chunk, progress: (index/TOTAL)*100 });
if (index < TOTAL) setTimeout(sendChunk, 0);
else self.postMessage({ type: "done" });
}
sendChunk();
};② 主线程:接收 + rAF 分片渲染
<script setup>
import { ref } from 'vue'
import Worker from './task.worker.js?worker'
const list = ref([])
const progress = ref(0)
const worker = new Worker()
worker.onmessage = (e) => {
const { type, data, progress: p } = e.data
progress.value = p.toFixed(1)
if (type === 'chunk') {
// 主线程分片渲染,不卡DOM
let i = 0
function render() {
if (i < data.length) {
list.value.push(data[i])
i++
requestAnimationFrame(render)
}
}
render()
}
}
// 启动
worker.postMessage('start')
</script>三、Transferable Objects(可转移对象)【0拷贝通信】
1. 核心定义
浏览器 postMessage 默认是结构化克隆(复制一份数据),大数据会卡死; Transferable Objects 是「转移内存所有权」,0拷贝、0延迟,原线程直接失去数据访问权。
2. 面试必考点
支持类型
ArrayBuffer(二进制缓冲区)ImageBitmap(图片)OffscreenCanvas(离屏画布) 普通对象/数组不支持!
语法
worker.postMessage(数据, [要转移的内存对象])3. 代码对比:100万数据 克隆 vs 转移
❶ 结构化克隆(慢、复制内存)
// 100万数字数组 → 复制,主线程卡顿
const arr = new Uint32Array(1000000);
worker.postMessage(arr);❷ Transferable 转移(0拷贝、瞬间完成)
const buffer = new ArrayBuffer(4 * 1000000); // 100万数字
const arr = new Uint32Array(buffer);
// 核心:转移 buffer 所有权,不复制
worker.postMessage(arr, [buffer]);
// 注意:主线程这里 arr 已经失效,无法访问!4. Worker 接收使用
self.onmessage = (e) => {
const arr = new Uint32Array(e.data.buffer);
// 直接使用,0解析成本
};场景1:普通数组(最常用:[1,2,3,...100万条数据])
日常开发:const list = [1,2,3,4,5] 我们做两步:
- 数组 → 二进制
ArrayBuffer - 用 Transferable 0拷贝发给 Worker
完整代码(主线程)
// 1. 你的普通数组(100万条数据)
const normalArray = Array.from({ length: 1000000 }, (_, i) => i);
// 2. 数组 转 二进制(Uint8Array/Uint32Array)
const uint32Arr = new Uint32Array(normalArray);
const buffer = uint32Arr.buffer; // 拿到核心:ArrayBuffer
// 3. Transferable 0拷贝发送(关键!第二个参数传buffer)
worker.postMessage(uint32Arr, [buffer]);
// ✅ 特点:不复制、不卡顿、瞬间发送
// ❗ 注意:主线程的 uint32Arr 现在不能用了(所有权转移)Worker 接收 → 还原成普通数组
// worker.js
self.onmessage = (e) => {
// 1. 拿到二进制
const uint32Arr = e.data;
// 2. 还原成你熟悉的 普通数组
const normalArray = Array.from(uint32Arr);
console.log("拿到普通数组", normalArray);
// 这里可以做排序、过滤、计算
};场景2:普通对象(最常用:{ name, list, age })
日常开发:const data = { name: "张三", list: [1,2,3] } 方案:JSON序列化 → 转二进制 → Transferable
完整代码(主线程)
// 1. 你的普通对象(业务里最常见的格式)
const normalObj = {
name: "张三",
age: 25,
list: Array.from({ length: 100000 }, (_, i) => i) // 大数据
};
// 2. 对象 → JSON字符串 → 二进制Uint8Array
const jsonStr = JSON.stringify(normalObj);
const encoder = new TextEncoder(); // 浏览器原生API
const uint8Arr = encoder.encode(jsonStr);
const buffer = uint8Arr.buffer;
// 3. Transferable 0拷贝发送
worker.postMessage(uint8Arr, [buffer]);Worker 接收 → 还原成普通对象
// worker.js
self.onmessage = (e) => {
const uint8Arr = e.data;
// 1. 二进制 转 字符串
const decoder = new TextDecoder();
const jsonStr = decoder.decode(uint8Arr);
// 2. 还原成 你的普通对象
const normalObj = JSON.parse(jsonStr);
console.log("拿到普通对象", normalObj);
console.log(normalObj.name); // 张三
console.log(normalObj.list); // 大数据数组
};场景3:混合数据(数组+对象,业务99%场景)
比如:后端返回的列表数据 [{id:1}, {id:2}, ...] 直接用上面对象方案一套带走!
// 后端返回的普通数组对象
const tableData = [
{ id: 1, name: "商品1" },
{ id: 2, name: "商品2" },
// ... 10万条
];
// 序列化 → 二进制 → Transferable
const buffer = new TextEncoder().encode(JSON.stringify(tableData)).buffer;
worker.postMessage(buffer, [buffer]);日常开发避坑指南(必看)
1. 什么时候不用 Transferable?
- 数据量小(<1000条)
- 主线程发完还要继续用这个数据 → 直接
postMessage(data)就行,不用折腾
2. 什么时候必须用 Transferable?
- 大数据列表(1万条以上)
- 大对象、表格数据、导出数据
- Worker计算后主线程不用原数据 → 用 Transferable,页面绝对不卡
3. 最简单封装(直接复制到项目)
// 工具函数:普通数据 → Transferable发送
function sendTransfer(worker, data) {
const buffer = new TextEncoder().encode(JSON.stringify(data)).buffer;
worker.postMessage(buffer, [buffer]);
}
// 调用(一行搞定)
sendTransfer(worker, yourNormalArrayOrObject);