进阶篇:事件循环与任务队列
JavaScript 是一门单线程执行的语言,同一时刻只能处理一段代码。这套设计规避了多线程资源竞争、DOM 并发修改冲突等问题,降低了前端开发的心智负担,但也带来代码阻塞风险。
为了解决单线程阻塞的痛点,浏览器实现了异步任务、任务队列、事件循环(Event Loop)整套调度机制,让 JS 具备非阻塞执行能力。定时器、Promise、网络请求、页面渲染的调度,全部建立在这套底层机制之上。
浏览器进程与线程模型
Chrome 五大核心进程
Chrome 使用多进程隔离架构,不同模块运行在独立进程,单个进程崩溃不会造成整个浏览器闪退。
- 浏览器主进程:浏览器顶层进程,负责地址栏、工具栏、弹窗、本地存储等不属于网页 Tab 的能力,内部包含 UI 线程、存储线程。
- 渲染进程:每一个网页 Tab 通常对应一个渲染进程,是前端最核心的进程;网页解析、资源加载、JavaScript 执行、页面渲染都在这里完成。
- 网络进程:统一接管全部网络请求,处理资源下载、请求解析、跨域校验逻辑。
- 插件进程:浏览器插件独立运行于此进程,隔离插件异常,避免插件故障导致页面崩溃。
- GPU 进程:负责图形绘制、CSS 3D、动画加速、图层合成,提升页面渲染流畅度。
渲染进程内部子线程
渲染进程内部有多条线程协同工作,JavaScript 执行和页面渲染存在互斥关系:
- JS 引擎线程(V8):唯一执行 JS 的线程,所有同步代码串行运行。
- GUI 渲染线程:解析 HTML、CSS,构建 DOM、CSSOM,执行布局、重排、重绘。和 JS 引擎线程互斥;当 JS 引擎线程正在运行代码,GUI 渲染线程会被冻结,长时间运行的 JS 代码直接造成页面卡死。
- 事件触发线程:监听各类异步事件,当事件触发条件达成,会把回调函数放入任务队列排队。
- 定时触发器线程:专门处理
setTimeout、setInterval的计时,不依赖 JS 主线程;计时到期后,将回调加入任务队列。 - 异步 HTTP 线程:处理 XHR、Fetch 网络请求;请求状态变更后,将回调推入任务队列等待执行。
JavaScript 运行时基础概念
JS 运行时是 JS 代码的执行载体,由执行上下文、执行上下文栈(调用栈)、任务队列、微任务队列、事件循环共同构成。
执行上下文
所有 JavaScript 代码都运行在执行上下文中;代码开始执行创建上下文,代码执行结束上下文随即销毁。一共分为三类:
- 全局上下文:页面初始化自动创建,存放全局作用域代码;页面关闭才销毁。
- 函数上下文:函数被调用时创建,函数执行完毕就销毁。
- eval 上下文:调用
eval()动态执行代码时临时创建。
执行上下文栈(调用栈)
执行上下文栈遵循LIFO 后进先出规则,保存当前正在运行的全部执行上下文:
- 页面初始化,全局上下文压入栈顶;
- 调用函数,生成函数上下文压入栈顶,开始执行函数;
- 函数执行完成,上下文从栈弹出,等待垃圾回收;
- JS 引擎永远只执行栈顶位置的上下文。
备注:递归函数每一次调用都会新建函数上下文入栈,递归过深会造成调用栈溢出。
任务队列
代码分为同步任务、异步任务:
- 同步任务:直接在调用栈执行,会阻塞主线程。
- 异步任务:不会阻塞当前调用栈;等待满足触发条件之后,回调函数进入任务队列排队,遵循先进先出。
任务队列进一步分为:宏任务队列、微任务队列。
备注:HTML 标准文档并没有“宏任务”这个术语,该名词属于社区约定,用来和微任务做区分。
事件循环完整执行流程
事件循环(Event Loop)是驱动 JS 异步调度的无限循环机制,规定了同步代码、微任务、动画回调、页面渲染、宏任务之间的执行顺序。
一轮事件循环:从一个宏任务开始,直到下一个宏任务即将开始之前的完整周期。
浏览器标准一轮事件循环执行顺序:
- 执行调用栈中全部同步代码,调用栈清空;
- 完整清空微任务队列:微任务执行过程中新添加的微任务,也必须在本轮全部执行完毕;
- 执行
requestAnimationFrame动画回调队列; - 浏览器执行 DOM 布局、重排、重绘,完成页面渲染;
- 从宏任务队列取出一个宏任务执行;
- 开启下一轮事件循环。
宏任务(MacroTask)
常见宏任务
- Script 顶层全局代码(页面最先执行的宏任务)
setTimeout、setInterval- UI 交互事件回调:
click、scroll、resize - 网络回调:Ajax、Fetch 回调
postMessage、I/O 读写回调- Node.js 特有:
setImmediate
宏任务关键特性
- 一轮事件循环只会执行一个宏任务;
- 宏任务运行期间不会触发页面渲染,长时间运行会阻塞 UI;
- 单个宏任务执行完毕后,先处理微任务队列、再处理渲染,之后才开启下一轮循环;
- 在宏任务内部可以新增微任务,新增微任务在本轮就会执行。
微任务(MicroTask)
微任务执行时机:当前宏任务执行完毕、DOM 渲染之前、下一个宏任务启动之前,优先级高于宏任务。
常见微任务
- Promise:
.then、.catch、.finally的回调(Promise 执行器函数同步执行) async / await:await之后的代码包装为微任务MutationObserverDOM 变更监听回调queueMicrotask():浏览器提供手动添加微任务的标准 API- Vue 框架:
$nextTick - Node.js 特有:
process.nextTick(Node 内部优先级最高)
微任务关键特性
- 本轮清空原则:微任务队列必须全部消费完;执行微任务过程中新加入的微任务,依旧在本轮执行;
- 微任务会阻塞页面渲染以及下一轮宏任务;如果无限循环添加微任务,会造成事件循环卡死;
- 微任务内部创建的宏任务,进入宏任务队列,要等到下一轮事件循环才会执行。
setTimeout / setInterval 详解
setTimeout():设置定时器,到期之后执行指定函数或者代码片段。
setInterval():以固定时间间隔重复调用回调函数。
setTimeout(function[, delay, arg1, arg2, ...])
setInterval(function[, delay, arg1, arg2, ...])
核心特性
setTimeout、setInterval共用一套定时器 ID 池;clearTimeout()、clearInterval()可以交叉清除定时器,但业务代码建议配对使用。- 支持向回调传递额外参数(IE9‑及更早版本不兼容)。
- 定时器到期之后,回调会作为宏任务,加入宏任务队列排队,不是立刻执行。
this 指向陷阱
定时器回调运行在独立全局执行环境,不会继承外层作用域的 this:
- 非严格模式:回调内部
this指向window; - 严格模式:回调内部
this值为undefined。
定时器特殊延迟规则
- 嵌套定时器最小 4ms:当定时器嵌套层级达到阈值,浏览器强制设置最小延迟为 4ms,即使传入
delay:0也不能绕过该限制。 - 后台标签页节流:处于后台非激活状态的 Tab,浏览器会限制定时器最小延迟,一般为 1000ms,降低 CPU 和电量消耗。
- 最大延时溢出:浏览器内部用 32 位有符号整数存储延时,超过
2147483647毫秒(约 24.8 天)发生溢出,定时器几乎立刻触发。
定时器计时不准的原因
setTimeout、setInterval 无法做到高精度计时,天然存在误差:
- HTML 规范规定嵌套定时器达到层级后强制保底 4ms;
- 操作系统底层时间 API 本身存在微小误差;
- JS 主线程被占用:定时器计时完成,回调进入队列之后,必须等待主线程空闲才可以执行,进一步叠加延迟。
requestAnimationFrame
requestAnimationFrame(rAF) 不属于宏任务,也不属于微任务,浏览器单独维护动画回调队列。
执行优先级顺序:微任务队列全部清空 → 执行 requestAnimationFrame 队列 → 浏览器执行 DOM 渲染
requestAnimationFrame(() => {
console.log(2)
})
setTimeout(() => {
console.log(1)
}, 0)
new Promise(resolve => resolve()).then(() => {
console.log(4)
})
console.log(3)
// 输出结果:3 4 2 1
执行拆解:
- 同步代码执行,打印
3; - Promise
.then回调加入微任务队列; setTimeout回调加入宏任务队列,rAF回调加入动画队列;- 调用栈清空,执行全部微任务,打印
4; - 执行动画队列回调,打印
2; - 浏览器执行页面渲染,本轮循环结束;
- 下一轮事件循环取出宏任务执行,打印
1。
代码案例解析
案例1:基础事件循环顺序
规则:Promise 的执行器函数同步执行;仅 .then / .catch / .finally 属于微任务回调,且必须等状态变更后才入队。
setTimeout(function(){
console.log(1)
})
new Promise(function(resolve){
console.log(2)
resolve()
}).then(function(){
console.log(3)
})
console.log(4)
// 输出顺序:2 4 3 1
步骤拆解:
- 启动全局宏任务,自上而下扫描代码;
setTimeout回调加入宏任务队列;- Promise 执行器同步执行,打印
2,调用resolve(),.then回调进入微任务队列; - 同步执行
console.log(4),打印4; - 调用栈清空,执行全部微任务,打印
3; - 页面渲染完成,本轮结束;
- 下一轮事件循环执行宏任务,打印
1。
案例2:Promise then 链式执行
ES Promise Jobs 规则要点:
.then返回普通值,下一级回调直接加入微任务队列;.then返回 Promise 对象,不会立刻把后续回调入队,需要先做 unwrap 解析动作;等当前 Job 执行完毕后,才推入 unwrapJob;unwrapJob 执行完毕,才把业务回调加入队列;- PromiseJobs 队列先进先出,任务只能追加队尾,不能插队。
Promise.resolve().then(() => {
return Promise.resolve(1)
})
.then(res => {
console.log(res)
})
Promise.resolve().then(() => {
console.log(2)
})
.then(() => {
console.log(3)
})
.then(() => {
console.log(4)
})
.then(() => {
console.log(5)
})
// 输出顺序:2 3 4 1 5
步骤拆解:
- 两个
Promise.resolve()同步完成,两个第一个.then回调入队;队列初始状态:[fnA, fnB]。 - 执行
fnA:返回Promise.resolve(1);打印1的回调不能直接入队,标记 unwrap 待解析。队列变为[fnB]。 - 执行
fnB:打印2;返回普通值,把下一级回调fnC入队;fnB执行结束,推入 unwrapJob。队列变为[fnC, unwrapJob]。 - 执行
fnC:打印3;返回普通值,推入fnD。队列:[unwrapJob, fnD]。 - 执行
unwrapJob:解析 Promise,把打印1的回调追加到队尾。队列:[fnD, fnPrint1]。 - 执行
fnD:打印4;返回普通值,推入fnE。队列:[fnPrint1, fnE]。 - 执行
fnPrint1:打印1。队列:[fnE]。 - 执行
fnE:打印5。队列清空。
案例3:Promise 嵌套场景
关键要点:
new Promise(executor)内部代码同步执行;仅.then回调进入微任务队列;.then不写 return,默认返回undefined,下一级.then直接追加队列尾部;- 队列严格先进先出;执行过程新增任务只能排到队尾,不能插队;
.then内部新建 Promise,会生成独立子链;外层链式不受内部子链阻塞,多条链的微任务混合排队。
new Promise((resolve) => {
console.log(1)
resolve()
})
.then(() => { // t1
new Promise(resolve => {
console.log(2)
resolve()
})
.then(() => { // t2
console.log(3)
})
.then(() => { // t4
console.log(6)
})
})
.then(() => { // t3
console.log(4)
new Promise((resolve) => {
console.log(5)
resolve()
})
.then(() => { // t5
console.log(7)
new Promise((resolve) => {
console.log(8)
resolve()
})
.then(() => { // t7
console.log(10)
})
})
.then(() => { // t8
console.log(11)
})
})
.then(() => { // t6
console.log(9)
})
// 输出顺序:1 2 3 4 5 6 7 8 9 10 11
核心理解:一个 .then 一个微任务;当前 .then 完成,才会去执行下一个 .then。
步骤拆解:
- 同步执行打印
1,t1入队;队列:[ t1 ] - 执行
t1:打印2;内部第一个.then(t2)、同级下一个.then(t3)入队;队列:[ t2, t3 ] - 执行
t2:打印3;下一级t4入队;队列:[ t3, t4 ] - 执行
t3:打印4;同步执行内部 Promise,打印5;内部t5、同级t6入队;队列:[ t4, t5, t6 ] - 执行
t4:打印6;队列:[ t5, t6 ] - 执行
t5:打印7;同步执行内部 Promise,打印8;内部t7、t8入队;队列:[ t6, t7, t8 ] - 执行
t6:打印9;队列:[ t7, t8 ] - 执行
t7:打印10;队列:[ t8 ] - 执行
t8:打印11;队列清空。