前端 JavaScript 事件循环与任务调度
前端 JavaScript 事件循环与任务调度
JavaScript 通常在一个主线程上执行代码。网络回调、定时器和用户输入看起来可以并发发生,实际都要通过事件循环安排到合适的时机。理解任务队列有助于解释执行顺序和页面卡顿。
一、同步代码先执行
当前调用栈中的同步代码会先运行完毕。栈清空后,事件循环才会处理后续任务。因此,长循环会阻塞点击、绘制和输入响应。
console.log("开始");
setTimeout(() => console.log("定时任务"), 0);
console.log("结束");
输出顺序是“开始”“结束”,随后才是“定时任务”。延时为零表示尽早调度,不表示立即执行。
二、微任务在任务之间清空
Promise 的 then、catch、finally 和 queueMicrotask 会进入微任务队列。一个任务完成后,浏览器通常会清空当前微任务队列,再决定是否进行渲染并处理下一个任务。
setTimeout(() => console.log("任务"), 0);
Promise.resolve().then(() => console.log("微任务"));
console.log("同步");
输出顺序是“同步”“微任务”“任务”。如果微任务不断创建新的微任务,队列可能迟迟无法清空,页面也就没有机会绘制。
三、异步函数仍遵循同一调度规则
调用 async 函数会立即执行到第一个 await。await 后的代码会在等待结果后继续执行,其续执行过程通过 Promise 微任务调度。
async function loadPanel() {
console.log("请求前");
const result = await Promise.resolve("完成");
console.log(result);
}
loadPanel();
console.log("调用后");
先输出“请求前”和“调用后”,再输出“完成”。async 不会自动把函数放到后台线程运行。
四、长任务要主动拆分
处理大量数据时,应让出主线程,使浏览器有机会响应输入和绘制。可以分批处理,并安排下一批稍后执行。
function processInChunks(items, chunkSize, onChunk) {
let index = 0;
function runChunk() {
const end = Math.min(index + chunkSize, items.length);
onChunk(items.slice(index, end));
index = end;
if (index < items.length) setTimeout(runChunk, 0);
}
runChunk();
}
分块大小应根据单批执行时间调整。耗时纯计算可以放入 Web Worker,避免占用页面主线程。
五、动画更新放在帧回调中
动画应与浏览器刷新节奏同步。使用 requestAnimationFrame 更新视觉状态;组件销毁或动画结束时取消回调,避免后台继续访问已卸载的 DOM。
let frameId;
function animate() {
updatePosition();
frameId = requestAnimationFrame(animate);
}
function stopAnimation() {
cancelAnimationFrame(frameId);
}
六、定时器不能代替节流策略
防抖适合用户停止输入后执行搜索;节流适合限制持续事件的处理频率。定时器延迟会受主线程繁忙程度影响,不应拿来保证精确时刻。
七、微任务饥饿与任务让步
微任务会在当前任务和下一次渲染之间持续清空。如果一个微任务不断创建下一个微任务,渲染机会就可能一直被推迟。Promise 链适合短小的异步续接,不适合用来切分需要让浏览器响应的长计算。
对于可以分批处理的数据,记录当前游标,每批完成后安排后续任务,让主线程有机会响应输入。分批大小需要通过设备测量选择;任务太大仍会阻塞,太小则会产生过多调度开销。若工作完全是计算且数据可独立传递,可以评估 Web Worker。
八、动画与渲染的协作
requestAnimationFrame 回调安排在浏览器下一次绘制前,适合更新视觉状态;它不保证精确的固定帧率,也不适合执行长时间计算。使用时间戳计算运动距离,避免设备刷新频率不同造成动画速度差异。
let previousTime = 0;
function animate(time) {
const delta = previousTime === 0 ? 0 : time - previousTime;
previousTime = time;
updateByElapsedTime(delta);
frameId = requestAnimationFrame(animate);
}
暂停动画时要取消已排队帧,并重置时间基准,避免恢复后因时间差过大产生位置跳变。页面进入后台后也应停止无意义的视觉循环。
九、异步错误与取消
定时器回调中的异常不会自动成为原调用栈的同步异常。Promise 链应有明确的失败处理,事件处理器也应捕获可预期错误并更新界面状态。取消任务是控制流程,不能作为网络故障展示。
十、调度选择由用户体验决定
需要跟随刷新节奏的视觉变化使用帧回调;必须尽早完成的异步续接可使用微任务;可以稍后执行的批量工作应主动让出主线程。不要把延时为零理解成后台线程,也不要用任意定时器模拟精确动画时钟。
实施中的取舍与复核
定时器、输入事件和网络回调都受到主线程占用影响,因此“安排了回调”并不等于回调会准时发生。调度时要区分任务何时进入队列、何时具备执行条件以及执行多久。零延时定时器只能把工作排到后续任务,浏览器仍可能受节流、后台标签页策略和繁忙主线程影响。依赖固定毫秒数推断业务顺序容易在设备差异下出错,应使用明确的状态和完成信号。
微任务适合让 Promise 链继续完成、协调状态更新或在当前任务后执行短小工作。它们连续运行时会压缩浏览器处理输入和绘制的机会,所以不要用无限递归的微任务模拟轮询,也不要把大量数组转换放进单个 then 回调。可把大集合按批次切分,保存游标与处理结果,每轮结束后将后续批次交还事件循环;如任务需要保持可取消,还应在每批开始时检查取消标记。
任务拆分存在吞吐和响应之间的取舍。批次越大,总调度开销越小,但每轮可能造成可感知停顿;批次越小,界面更容易响应,却会增加回调、分配和合并结果的成本。用低端设备和真实数据量测量单批耗时,设置合理上限,并在页面隐藏时停止纯视觉循环。若计算可以独立运行,可传递精简数据给 Worker;DOM 更新仍应回到主线程,并避免频繁往返传输大对象。
异步流程还应考虑异常传播和生命周期。定时器中的异常不会回到创建定时器的同步代码,Promise 拒绝需要在链条末端处理;组件销毁或路由变化后,待执行任务也应能停止更新旧界面。测试时用明确的调度顺序断言验证关键行为,并避免测试依赖真实等待时长。对于动画则按帧时间累积位移,页面暂停后重置时间基准,避免恢复时把休眠期间的时间一次性补算。
总结
同步代码、微任务和任务队列有明确的先后关系。微任务适合延续异步流程,但不适合无限递归;大量工作要拆分或交给 Worker;动画随帧调度,并在生命周期结束时取消。
- 点赞
- 收藏
- 关注作者
评论(0)