ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

面试题:页面上有 100 万个任务需要执行,如何保证页面不卡顿?

面试题:页面上有 100 万个任务需要执行,如何保证页面不卡顿? 一、核心思路一句话不要让 100 万个任务连续霸占主线程而是把任务切成小片分批、分时执行纯计算任务优先放到 Web Worker必须操作 DOM 的任务则在主线程中利用空闲时间执行始终给浏览器留下渲染时间。二、主要矛盾和次要矛盾主要矛盾主线程被长时间占用导致浏览器没有机会处理用户交互和渲染。JavaScript 主线程上的任务通常会阻塞JavaScript 长任务 ↓ 主线程持续执行 ↓ 无法及时处理点击 / 输入 ↓ 无法及时执行渲染 ↓ 页面卡顿、掉帧、交互延迟所以真正要解决的不是“100 万这个数字太大怎么办”而是“如何避免一个超长任务长期霸占主线程”次要矛盾任务本身能不能脱离主线程。CPU 密集型 不操作 DOM ↓ Web Worker ↓ 不阻塞主线程 必须操作 DOM ↓ 只能主线程 ↓ 任务切片 分时执行三、解决方案流程图100 万个任务 │ ▼ 任务是否必须操作 DOM │ ┌───┴────┐ 否 是 │ │ ▼ ▼ CPU 密集型 主线程任务 │ │ ▼ ▼ Web Worker 任务切片 │ │ ▼ ▼ 后台线程执行 每次只执行一小批 │ │ ▼ ▼ 主线程保持响应 让出主线程 │ ▼ 浏览器处理渲染 / 交互 │ ▼ 继续执行下一批任务如果任务必须在主线程执行还可以进一步按照调度方式选择主线程任务 │ ├── requestIdleCallback │ └── 浏览器空闲时执行 │ ├── setTimeout │ └── 主动切成多个时间片 │ ├── MessageChannel │ └── 将任务拆成多个事件循环任务 │ └── requestAnimationFrame └── 与浏览器下一帧调度配合注意requestAnimationFrame的主要职责是在下一次绘制前执行回调它不是专门用来检测“这一帧还剩多少时间”的 API。要判断是否还有空闲时间更合适的是requestIdleCallback或者自己基于performance.now()做时间切片。四、底层实现原理为什么一次执行 100 万个任务会卡例如for(leti0;i1_000_000;i){doSomething(i);}虽然不是死循环但本质上也是一个长时间占用主线程的同步任务。浏览器可以简单理解为不断处理JavaScript ↓ 处理用户输入 ↓ 样式计算 ↓ 布局 ↓ 绘制 ↓ 下一帧如果 JavaScript 一直不结束JavaScript ──────────────────────────────── ↑ 浏览器没机会渲染于是就出现点击没有及时响应输入出现延迟页面动画掉帧滚动不流畅页面看起来“卡死”所以核心原则就是不要让单个 JavaScript 任务执行太久要主动把长任务拆成多个短任务。五、方案一任务切片——最通用的主线程方案例如原来100 万任务 ████████████████████████████████████████ 一次全部执行改成100 万任务 第 1 批 ███ ↓ 让出主线程 第 2 批 ███ ↓ 让出主线程 第 3 批 ███ ↓ 让出主线程 ...完整示例// 模拟一个需要执行的任务functiondoTask(index){// 实际项目中可能是数据处理、计算、状态更新等操作console.log(执行任务${index});}// 使用 requestIdleCallback 在浏览器空闲时间执行任务functionrunTasks(tasks){letindex0;functionprocess(deadline){// 在浏览器认为当前还有空闲时间时继续执行任务while(indextasks.lengthdeadline.timeRemaining()0){doTask(tasks[index]);index;}// 任务还没有执行完就继续申请下一次空闲时间if(indextasks.length){requestIdleCallback(process);}}requestIdleCallback(process);}// 创建 100 万个任务consttasksArray.from({length:1_000_000},(_,index)index);runTasks(tasks);核心机制requestIdleCallback ↓ 浏览器当前比较空闲 ↓ 执行一部分任务 ↓ 时间不够了 ↓ 立即停止 ↓ 浏览器处理渲染 / 输入 ↓ 下一次空闲继续这就是典型的“见缝插针”执行任务。边界问题requestIdleCallback并不是所有场景都适合它主要适合低优先级、非紧急任务。浏览器长期繁忙时任务可能迟迟得不到执行。可以通过timeout设置最长等待时间。例如requestIdleCallback(process,{// 最长等待 1000 毫秒避免任务长期得不到执行timeout:1000});六、方案二固定时间切片——更可控如果希望自己控制每次最多执行多长时间可以使用performance.now()。functionrunTasks(tasks){letindex0;functionprocess(){// 每次最多占用主线程约 5 毫秒constdeadlineperformance.now()5;while(indextasks.lengthperformance.now()deadline){doTask(tasks[index]);index;}// 还有任务放到下一轮事件循环继续执行if(indextasks.length){setTimeout(process,0);}}process();}functiondoTask(index){// 模拟任务}流程执行任务 ↓ 判断是否超过 5ms ↓ 没有超过 → 继续 ↓ 超过 → 停止 ↓ setTimeout ↓ 下一轮继续这个方案的优势是时间预算自己控制不依赖浏览器是否认为当前空闲。七、方案三Web Worker——CPU 密集型任务优先考虑如果 100 万个任务属于大量数学计算数据转换文件分片文件哈希大数据解析图像数据计算而且不需要直接操作 DOM那么 Web Worker 通常更合适。主线程 │ │ postMessage() ▼ Web Worker │ │ 执行大量计算 │ ▼ 计算结果 │ │ postMessage() ▼ 主线程完整示例主线程// 创建 WorkerconstworkernewWorker(./worker.js);// 将大量任务交给 Workerworker.postMessage({type:calculate,count:1_000_000});// Worker 计算完成后将结果返回主线程worker.onmessage(event){console.log(计算完成,event.data);};worker.jsself.onmessage(event){const{type,count}event.data;if(type!calculate){return;}letresult0;// 这部分计算发生在 Worker 线程// 不会持续占用页面主线程。for(leti0;icount;i){resulti;}// 将计算结果发送回主线程self.postMessage(result);};但要注意一个非常重要的边界Web Worker 不能直接操作页面 DOM。例如document.querySelector(#app)不能在普通 Web Worker 中直接使用。所以纯计算 ↓ Web Worker ✅ 计算 直接修改 DOM ↓ Web Worker ❌ ↓ 计算可以放 Worker DOM 修改仍然回到主线程八、Web Worker 也不是“万能线程”这里有一个面试容易被追问的点“既然 Worker 不阻塞主线程那把 100 万个任务全部扔给 Worker 不就行了吗”也不能简单这么做。因为还要考虑主线程 │ │ 发送大量数据 ▼ Worker │ │ 大量计算 ▼ 主线程如果数据非常庞大线程之间的数据传递、结构化克隆、内存占用本身也可能产生开销。因此更合理的是CPU 密集型任务 ↓ Worker 大量任务 ↓ Worker 内部也可以继续分批处理而不是简单粗暴地一次塞 100 万个对象过去。九、requestAnimationFrame 到底适不适合requestAnimationFrame的核心作用是让回调尽量在浏览器下一次绘制之前执行。它特别适合动画根据每一帧更新 UI与视觉刷新同步例如functionanimation(){// 更新动画状态requestAnimationFrame(animation);}requestAnimationFrame(animation);但它不是“浏览器渲染完以后看看还有多少时间然后执行任务。”这个描述不准确。如果问题是“我想利用浏览器空闲时间执行低优先级任务。”优先考虑requestIdleCallback如果问题是“我想和浏览器下一帧绘制同步。”考虑requestAnimationFrame如果需要精确控制performance.now() 时间预算十、React 场景下应该怎么考虑如果是在 React 项目中遇到100 万条数据 ↓ 全部生成 React 组件 ↓ 页面卡死单纯“任务切片”甚至可能不是最佳方案。这时候真正的主要矛盾可能是DOM 节点太多而不是 JavaScript 计算本身太多。应该考虑1. 虚拟列表只渲染用户当前看到的几十条100 万条数据 ████████████████████████████ 实际 DOM ███ ↓ 只渲染可视区域例如react-windowreact-virtualized2. 分页 / 增量加载不要一次把 100 万条数据全部加载到页面。3. Web Worker如果数据处理本身非常重可以服务器数据 ↓ Worker 计算 / 转换 ↓ 主线程 ↓ 虚拟列表渲染所以面试时最好不要只回答“使用 Web Worker。”而应该先判断100 万任务 ↓ 任务是什么 ↓ ├─ CPU 密集型计算 │ ↓ │ Web Worker │ ├─ 必须操作 DOM │ ↓ │ 主线程任务切片 │ └─ 100 万个 UI 元素 ↓ 虚拟列表 / 分页 / 增量渲染十一、各种方案怎么选场景方案CPU 密集型计算Web Worker必须操作 DOM主线程任务切片低优先级后台任务requestIdleCallback需要自己控制时间预算performance.now()setTimeout与浏览器帧同步requestAnimationFrame大量 UI 元素虚拟列表大量数据加载分页 / 增量加载Worker 数据量很大分批传输 / Transferable ObjectsReact 大量列表虚拟列表 按需渲染十二、结构化逻辑思维面试遇到“大量任务如何保证页面不卡顿”按照下面这个顺序回答最稳第一步先找瓶颈 ↓ 是不是主线程被长任务占满 ↓ 第二步判断任务性质 ↓ 是否必须操作 DOM ┌────┴────┐ 否 是 ↓ ↓ Worker 主线程 ↓ ↓ 后台计算 任务切片 ↓ 留出渲染和交互时间 ↓ requestIdleCallback / setTimeout / MessageChannel 如果是大量 UI ↓ 虚拟列表 / 分页 / 增量渲染核心不是记 API而是记住一个原则计算可以移线程主线程任务就切片UI 就按需渲染。十三、面试中的满分答案核心思路100 万个任务不能一次性占满主线程要根据任务类型进行“移线程、任务切片、按需渲染”始终给浏览器留出处理交互和渲染的时间。100 万任务 ↓ 先判断任务类型 ↓ ┌────────────────┬──────────────────┐ │ CPU 密集型计算 │ 必须操作 DOM │ └───────┬────────┴────────┬─────────┘ ↓ ↓ Web Worker 主线程切片 ↓ ↓ 后台计算 requestIdleCallback / setTimeout / MessageChannel ↓ ↓ 不阻塞主线程 分批执行 ↓ 给渲染和交互留时间 如果本质是 100 万个 UI 元素 ↓ 虚拟列表 / 分页 / 增量渲染底层原理浏览器的 JavaScript 主线程还负责用户交互和页面渲染。如果 100 万个任务连续同步执行主线程会被长时间占用浏览器没有机会及时处理输入和绘制所以页面就会卡顿。因此解决问题的关键不是“把任务全部执行得更快”而是避免单个长任务长期霸占主线程。具体来说纯计算任务放到 Web Worker让其他线程承担计算。必须操作 DOM 的任务只能在主线程执行就把任务拆成很多小批次每次执行一点然后主动让出主线程。低优先级任务可以使用requestIdleCallback在浏览器空闲时间执行。需要自己控制执行时间可以使用performance.now()配合setTimeout做时间切片。如果 100 万个任务实际上是 100 万个 UI 元素重点不是任务切片而是虚拟列表、分页和增量渲染只渲染用户当前需要看到的内容。一句话总结CPU 计算移到 Worker主线程任务切片执行海量 UI 按需渲染核心就是不要让长任务把主线程一次性占满。这个版本在面试中比单纯罗列requestAnimationFrame、requestIdleCallback、Web Worker 更完整因为它先判断任务性质和真正瓶颈再选择方案。
返回列表