如何手写一个简单的任务切片调度器?
简化版
任务切片调度器用于把大量同步任务拆成小块执行,避免长时间阻塞主线程。实现思路是维护任务队列,每一轮只在时间预算内执行一部分任务,剩余任务放到下一轮继续。调度可以用 requestIdleCallback、MessageChannel 或 setTimeout。边界包括任务耗时不可控、优先级、取消、错误隔离和浏览器兼容。
详细版
基础版可以用时间片控制。
| 组成 | 作用 |
|---|---|
| queue | 保存待执行任务 |
| schedule | 安排下一轮执行 |
| deadline | 控制本轮执行多久 |
| error handling | 单个任务失败不拖垮队列 |
function createScheduler(timeSlice = 5) {
const queue: Array<() => void> = []
let scheduled = false
function flush() {
scheduled = false
const start = performance.now()
while (queue.length && performance.now() - start < timeSlice) {
queue.shift()?.()
}
if (queue.length) schedule()
}
function schedule() {
if (!scheduled) {
scheduled = true
setTimeout(flush, 0)
}
}
return { add(task: () => void) { queue.push(task); schedule() } }
}
切片调度优化的是“不要连续占用主线程”,不是让总计算量消失。
完整版教学
一、为什么需要任务切片
如果一次性处理十万条数据、渲染大量节点或做复杂计算,主线程会长时间被占用。用户点击、滚动和渲染都要排队,页面看起来就卡住了。
任务切片的目标是把一个长任务拆成多个短任务,中间把控制权还给浏览器。
二、时间片模型
调度器给每一轮执行设置预算,例如 5ms。超过预算就暂停,下一轮再继续。
这和 React Concurrent Rendering 的理念有点相似:重要任务优先,不重要任务可中断。
三、基础队列实现
最简单模型是 FIFO 队列。
const scheduler = createScheduler(8)
items.forEach(item => {
scheduler.add(() => processItem(item))
})
这样不会一次性处理所有 item,而是分多轮执行。
四、调度 API 怎么选
不同 API 有不同特点。
| API | 特点 | 适合场景 |
|---|---|---|
setTimeout | 简单,延迟不稳定 | 面试基础版 |
MessageChannel | 比 setTimeout 更快进入宏任务 | 框架调度 |
requestIdleCallback | 利用空闲时间 | 低优先级任务 |
requestAnimationFrame | 绘制前执行 | 视觉更新 |
面试手写用 setTimeout 可以,说明可替换为更合适调度源即可。
五、错误隔离
如果一个任务抛错,不能让整个队列停止。可以 try/catch 单个任务。
try {
task()
} catch (err) {
console.error(err)
}
是否继续执行要看业务,但调度器通常要具备错误隔离能力。
六、取消和优先级
工程版调度器通常支持:
- 返回任务 id。
- 按 id 取消任务。
- 高低优先级队列。
- 超时任务强制执行。
这些能力会让实现复杂很多。面试里可以先写基础版,再讲扩展方向。
七、常见误区与追问
- 误区:任务切片能减少总耗时。 它主要减少连续阻塞,让页面更可交互,总计算量通常不变。
- 误区:时间片越小越好。 太小会增加调度开销,太大又会形成长任务,需要折中。
- 误区:所有任务都适合切片。 强依赖同步结果的任务、事务性任务不适合随便拆。
- 追问:为什么不用微任务调度? 微任务会在渲染前持续清空,容易继续饿死渲染。
- 追问:如何支持优先级? 使用多个队列,每轮先取高优先级,再取低优先级。
- 追问:重计算用切片还是 Worker? 如果计算很重且可脱离 DOM,Worker 更合适;切片适合主线程轻量分批。
八、加强记忆
任务切片记成“长任务切短,中间让路”。队列保存任务,时间片控制执行,调度源安排下一轮;扩展点是错误、取消和优先级。