面试官: “ 说一下 JS 中什么是事件循环 ? ”

JS 中的事件循环原理以及异步执行过程

这些知识点对新手来说可能有点难,但是是必须迈过的坎,逃避是解决不了问题的,本篇文章旨在帮你彻底搞懂它们。


1. JS 是单线程的

我们都知道 JS 是单线程执行的(原因:我们不想并行地操作 DOM,DOM 树不是线程安全的,如果多线程,那会造成冲突)。

这里小说明一下:V8 是谷歌浏览器的 JS 执行引擎,在运行 JS 代码的时候,是以函数作为一个个帧(保存当前函数的执行环境)按代码的执行顺序压入执行栈(call stack)中,栈顶的函数先执行,执行完毕后弹出再执行下一个函数。其中堆是用来存放各种 JS 对象的。

假设浏览器就是上图的这种结构的话,执行同步代码是没什么问题的,如下

scss 复制代码
function foo() {
    bar()
    console.log('foo')
}
function bar() {
    baz()
    console.log('bar')
}
function baz() {
    console.log('baz')
}

foo()

我们定义了 foobarbaz 三个函数,然后调用 foo 函数,控制台输出的结果为:

复制代码
baz
bar
foo

执行过程如下:

  1. 一个全局匿名函数最先执行(JS 的全局执行入口,之后的例子将忽略),遇到 foo 函数被调用,将 foo 函数压入执行栈。
  2. 执行 foo 函数,发现 foo 函数体中调用了 bar 函数,则将 bar 函数压入执行栈。
  3. 执行 bar 函数,发现 bar 函数体中调用了 baz 函数,又将 baz 函数压入执行栈。
  4. 执行 baz 函数,函数体中只有一条语句 console.log('baz'),执行,在控制台打印:baz,然后 baz 函数执行完毕弹出执行栈。
  5. 此时的栈顶为 bar 函数,bar 函数体中的 baz() 语句已经执行完,接着执行下一条语句(console.log('bar')),在控制台打印:bar,然后 bar 函数执行完毕弹出执行栈。
  6. 此时的栈顶为 foo 函数,foo 函数体中的 bar() 语句已经执行完,接着执行下一条语句(console.log('foo')),在控制台打印:foo,然后 foo 函数执行完毕弹出执行栈。
  7. 至此,执行栈为空,这一轮执行完毕。

动图展示

还是图直观点,以上步骤对应的执行流程图如下:

非动图


2. 事件循环(event loop)

  • 事件循环:JS 处理异步任务的机制,因单线程特性,通过循环读取任务队列实现非阻塞。

  • 过程:

    1. 执行同步代码(调用栈清空)。
    2. 执行所有微任务(Promise回调等),直到微任务队列清空。
    3. 执行一个宏任务(setTimeout等),然后回到步骤 2,循环往复。

我们改变一下代码 1, 如下是代码 2:

scss 复制代码
function foo() {
    bar()
    console.log('foo')
}
function bar() {
    baz()
    console.log('bar')
}
function baz() { 
    setTimeout(() => {
        console.log('setTimeout: 2s')
    }, 2000)
    console.log('baz') 
}

foo()

根据 1 中的假设,浏览器只由一个 JS 引擎构成的话,那么所有的代码必然同步执行(因为 JS 执行是单线程的,所以当前栈顶函数不管执行时间需要多久,执行栈中该函数下面的其他函数必须等它执行完弹出后才能执行(这就是代码被阻塞的意思)),执行到 baz 函数体中的 setTimeout 时应该等 2 秒,在控制台中输出 setTimeout: 2s,然后再输出:baz。所以我们期望的输出顺序应该是:setTimeout: 2s -> baz -> bar -> foo(这是错的)。

浏览器如果真这样设计的话,肯定是有问题的!遇到 AJAX 请求、setTimeout 等比较耗时的操作时,我们页面需要长时间等待,就被阻塞住啥也干不了,出现了页面 "假死",这样绝对不是我们想要的结果。

实际当然并非我以为的那样,这里先重点提醒一下:JS 是单线程的,这一点也没错,但是浏览器中并不仅仅只是由一个 JS 引擎构成,它还包括其他的一些线程来处理别的事情。如下图 !

浏览器除了 JS 引擎(JS 执行线程,后面我们只关注 JS 引擎中的执行栈)以外,还有 Web APIs(浏览器提供的接口,这是在 JS 引擎以外的)线程、GUI 渲染线程等。JS 引擎在执行过程中,如果遇到相关的事件(DOM 操作、鼠标点击事件、滚轮事件、AJAX 请求、setTimeout 等),并不会因此阻塞,它会将这些事件移交给 Web APIs 线程处理,而自己则接着往下执行。Web APIs 则会按照一定的规则将这些事件放入一个任务队列(callback queue,也叫 task queue)中,当 JS 执行栈中的代码执行完毕以后,它就会去任务队列中获取一个事件回调放入执行栈中执行,然后如此往复,这就是所谓的事件循环机制。

线程名 作用
JS 引擎线程 也称为 JS 内核,负责处理 JavaScript 脚本。(例如 V8 引擎) 负责解析 JS 脚本,运行代码。 一直等待着任务队列中的任务的到来,然后加以处理。一个 Tab 页(renderer 进程)中无论什么时候都只有一个 JS 线程运行 JS 程序。
事件触发线程 归属于渲染进程而不是 JS 引擎,用来控制事件循环。 当 JS 引擎执行代码块如setTimeout时(也可来自浏览器内核的其他线程,如鼠标点击、Ajax 异步请求等),会将对应任务添加到事件线程中。 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理。 注意:由于 JS 的单线程关系,所以这些待处理队列中的事件都是排队等待 JS 引擎处理,JS 引擎空闲时才会执行。
定时触发器线程 setIntervalsetTimeout所在的线程。 浏览器定时计数器并不是由 JS 引擎计数的。 JS 引擎是单线程的,如果处于阻塞线程状态就会影响计时的准确,因此,通过单独的线程来计时并触发定时。 计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行。注意 :W3C 在 HTML 标准中规定,setTimeout中低于 4ms 的时间间隔算为 4ms。
异步 HTTP 请求线程 XMLHttpRequest在连接后通过浏览器新开一个线程请求。当检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调放入事件队列中,再由 JS 引擎执行。
GUI 渲染线程 负责渲染浏览器界面,包括: 解析 HTML、CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。重绘(Repaint)以及回流(Reflow)处理。

这里让我们对事件循环先来做个小总结

  1. JS线程负责处理JS代码,当遇到一些异步操作 的时候,则将这些异步事件移交给Web APIs 处理,自己则继续往下执行。
  2. Web APIs线程将接收到的事件按照一定规则按顺序添加到任务队列中(应该是添加到任务集合中的各个事件队列中)。
  3. JS线程处理完当前的所有任务以后 (执行栈为空),它会去查看任务队列中是否有等待被处理的事件,若有,则取出一个事件回调放入执行栈中执行。
  4. 然后不断循环第3步。

让我们来看看真正的浏览器中执行是什么个流程吧!

动图展示

第二段代码

细心的小伙伴可能有发现Web API在计时器时间到达后将匿名回调函数添加到任务队列中了,虽然定时器时间已到,但它目前并不能执行!!!因为JS的执行栈此时并非空,必须要等到当前执行栈为空后才有机会被召回到执行栈执行。由此,我们可以得出一个结论:setTimeout设置的时间其实只是最小延迟时间 ,而并不是确切的等待时间。(当主线程的任务耗时比较长的时候,等待时间将会变得更长


3. 事件循环(进阶)与异步

3.1 试试 setTimeout(fn, 0)

javascript 复制代码
function foo() {
    console.log('foo')
}

setTimeout(function() {
    console.log('setTimeout: 0s')
}, 0);

foo();

运行结果:

arduino 复制代码
foo
setTimeout: 0s

即使 setTimeout 的延时设置为 0(实际上最小延时 >= 4ms),JS 执行栈也将该延时事件发放给 Web API 处理,Web API 再将事件添加到任务队列中,等 JS 执行栈为空时,该延时事件再压入执行栈中执行。


3.2 事件循环中的 Promise

其实以上的浏览器模型是ES5标准的,ES6+标准中的任务队列在此基础上新增了一种,变成了如下两种:

3.2.1 宏任务 / 微任务

宏任务队列(macrotask queue) :普通优先级的任务,通常包括:

  • setTimeout / setInterval / setImmediate(Node.js)
  • I/O 操作(文件读写、Ajax事件 / 网络请求等)
  • UI 渲染事件
  • 脚本整体代码(第一次执行的同步代码)

微任务队列(microtask queue) :高优先级的任务,通常包括:

  • Promise.then / Promise.catch / Promise.finally
  • async/awaitawait 后面的代码(其实是 .then 的语法糖)
  • MutationObserver(浏览器)
  • process.nextTick(Node.js,优先级比普通微任务更高)

事件循环的处理流程变成了如下:

  1. JS 线程负责处理 JS 代码,当遇到一些异步操作的时候,则将这些异步事件移交给 Web APIs 处理,自己则继续往下执行。
  2. Web APIs 线程将接收到的事件按照一定规则添加到任务队列中,宏事件添加到宏任务队列中,微事件添加到微事件队列中。
  3. JS 线程处理完当前的所有任务以后(执行栈为空),它会先去微任务队列获取事件,并将微任务队列中的所有事件一件件执行完毕,直到微任务队列为空后再去宏任务队列中取出一个事件执行。
  4. 然后不断循环第 3 步。

排一下先后顺序: 执行栈 --> 微任务 --> 渲染 --> 下一个宏任务


3.2.2 单独使用 Promise

javascript 复制代码
function foo() {
    console.log('foo')
}

console.log('global start')

new Promise((resolve) => {
    console.log('promise')
    resolve()
}).then(() => {
    console.log('promise then')
})

foo()

console.log('global end')

控制台输出的结果为:

sql 复制代码
global start
promise
foo
global end
promise then

动图展示

代码执行过程解析(文字描述)

  1. 执行同步代码
  • 执行 console.log('global start'),控制台输出:global start
  1. 执行 new Promise(...)
  • 注意 :在使用 new 关键字创建 Promise 对象时,传递给 Promise 的函数称为 executor

    • Promise 被创建时,executor 函数会自动同步执行
    • .then 里的回调才是异步执行的部分。
  • 执行 Promise 参数中的匿名函数(同步执行):

    • 执行 console.log('promise'),控制台输出:promise

    • 执行 resolve(),将 Promise 状态变为 resolved

  • 继续执行 .then(...)

    • 遇到 .then 会将回调提交给 Web API 处理。
    • Web API 将该回调添加到 微任务队列(此时微任务队列中有一个 Promise 事件待执行)。
  1. 继续执行同步代码
  • 执行栈在提交完 Promise 事件后,继续往下执行:

    • 执行 foo() 函数,控制台输出:foo

    • 执行 console.log('global end'),控制台输出:global end

  • 至此,本轮事件循环的同步代码执行完毕,执行栈为空。

  1. 处理微任务队列
  • 事件循环机制首先查看 微任务队列 是否为空:

    • 发现有一个 Promise 事件待执行,将其压入执行栈。

    • 执行 .then 中的回调:

      • 执行 console.log('promise then'),控制台输出:promise then
    • 至此,新的一轮事件循环(Promise 事件)执行完毕,执行栈为空。

  1. 检查任务队列
  • 执行栈再次为空,事件循环:

    1. 先查看 微任务队列,发现已空。
    2. 再查看 宏任务队列,发现也为空。
  • 执行栈进入等待事件状态。


3.2.3 Promise 结合 setTimeout

javascript 复制代码
function foo() {
    console.log('foo')
}

console.log('global start')

setTimeout(() => {
    console.log('setTimeout: 0s')
}, 0)

new Promise((resolve) => {
    console.log('promise')
    resolve()
}).then(() => {
    console.log('promise then')
})

foo()

console.log('global end')

控制台输出的结果为:

arduino 复制代码
global start
promise
foo
global end
promise then
setTimeout: 0s

动图展示

  1. 执行同步代码
  • 执行 console.log('global start'),控制台输出: global start
  1. 处理 setTimeout(改变部分)
  • 继续往下执行,遇到 setTimeout

    • JS 执行栈将其移交给 Web API 处理。
    • 延迟 0 秒后,Web API 将 setTimeout 事件添加到 宏任务队列 (此时宏任务队列中有一个 setTimeout 事件待处理)。
  1. 继续执行同步代码
  • JS 线程转交 setTimeout 事件后,继续往下执行:

    • 遇到 new Promise(...)

      • Promise 的 executor 函数 同步执行

        • 执行 console.log('promise'),控制台输出:promise

        • 执行 resolve(),将 Promise 状态变为 resolved

      • 执行 .then(...)

        • 遇到 .then 会将回调提交给 Web API 处理。
        • Web API 将该回调添加到 微任务队列(此时微任务队列中有一个 Promise 事件待处理)。
  1. 继续执行同步代码
  • 执行栈在提交完 Promise 事件后,继续往下执行:

    • 执行 foo() 函数,控制台输出:foo

    • 执行 console.log('global end'),控制台输出:global end

  • 至此,本轮事件循环的同步代码执行完毕,执行栈为空。

  1. 处理微任务队列
  • 事件循环机制首先查看 微任务队列 是否为空:

    • 发现有一个 Promise 事件待执行,将其压入执行栈。

    • 执行 .then 中的回调:

      • 执行 console.log('promise then'),控制台输出: promise then
    • 至此,新的一轮事件循环(Promise 事件)执行完毕,执行栈为空。

  1. 处理宏任务队列(改变部分)
  • 执行栈再次为空,事件循环:

    1. 先查看 微任务队列,发现已空。

    2. 再查看 宏任务队列 ,发现有一个 setTimeout 事件待处理:

    • setTimeout 中的匿名函数压入执行栈执行:

      • 执行 console.log('setTimeout: 0s'),控制台输出:setTimeout: 0s
    • 至此,新的一轮事件循环(setTimeout 事件)执行完毕,执行栈为空。

7.** 检查任务队列**

  • 执行栈再次为空,事件循环:

    1. 先查看 微任务队列,发现已空。
    2. 再查看 宏任务队列,发现也为空。
  • 执行栈进入等待事件状态。


3.3 事件循环中的 async/await

这里简单介绍下async函数:

  1. 函数前面 async 关键字的作用就2点:①这个函数总是返回一个promise。②允许函数内使用await关键字。

  2. 关键字 await 使 async 函数一直等待 (执行栈当然不可能停下来等待的,await将其后面的内容包装成promise交给Web APIs后,执行栈会跳出async函数继续执行),直到promise执行完并返回结果。await只在async函数函数里面奏效

  3. async函数只是一种比promise更优雅得获取promise结果(promise链式调用时)的一种语法而已。

javascript 复制代码
function foo() {
    console.log('foo')
}

async function async1() {
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}

async function async2() {
    console.log('async2')
}

console.log('global start')
async1()
foo()
console.log('global end')

执行的结果如下:

sql 复制代码
global start
async1 start
async2
foo
global end
async1 end
  1. 执行同步代码
  • 执行 console.log('global start'),控制台输出:global start
  1. 调用 async1()
  • 执行 async1(),进入 async1 函数体:

    • 执行 console.log('async1 start'),控制台输出:async1 start

    • 执行 await async2()

      • await 关键字会暂停 async1 函数的执行,直到 await 后面的 Promise 返回结果。
      • await async2() 会像调用普通函数一样执行 async2()
  1. 执行 async2()
  • 进入 async2 函数体:

    • 执行 console.log('async2'),控制台输出:async2

    • async2 函数执行结束,弹出执行栈。

    • 由于 async2 没有显式返回 Promise,它会隐式返回一个已 resolved 的 Promise。

4.暂停 async1() 并继续执行同步代码

  • 因为 await 关键字之后的代码被暂停,async1 函数执行结束,弹出执行栈。

  • JS 主线程继续向下执行:

    • 执行 foo() 函数,控制台输出:foo

    • 执行 console.log('global end'),控制台输出:global end

  • 至此,本轮事件循环的同步代码执行完毕,执行栈为空。

  1. 事件循环处理微任务
  • 事件循环机制开始工作:

    1. 先查看 微任务队列

      • 发现有一个微任务事件,该事件是 async1 函数中 await async2() 之后的代码(可以理解为:用一个匿名函数包裹 await 之后的代码,作为微任务事件)。

      • 执行该微任务:

        • 执行 console.log('async1 end'),控制台输出:async1 end
    2. 执行栈再次为空,本轮事件执行结束。

  1. 检查任务队列
  • 事件循环机制再次查看:

    1. 微任务队列:已空。
    2. 宏任务队列:也为空。
  • 执行栈进入等待事件状态。


4. 大综合(自测)

4.1 简单融合

javascript 复制代码
async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
    console.log('async2');
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});

console.log('script end');

输出结果:

sql 复制代码
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

4.2 变形 1

javascript 复制代码
async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
    new Promise(function(resolve) {
        console.log('promise1');
        resolve();
    }).then(function() {
        console.log('promise2');
    });
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise3');
    resolve();
}).then(function() {
    console.log('promise4');
});

console.log('script end');

输出的结果:

sql 复制代码
script start
async1 start
promise1
promise3
script end
promise2
async1 end
promise4
setTimeout

4.3 变形 2

javascript 复制代码
async function async1() {
    console.log('async1 start');
    await async2();
    setTimeout(function() {
        console.log('setTimeout1');
    },0)
}
async function async2() {
    console.log('async2');
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout3');
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});

console.log('script end');

输出的结果:

sql 复制代码
script start
async1 start
async2
promise1
script end
promise2
setTimeout3
setTimeout1

4.4 变形 3

javascript 复制代码
async function a1 () {
    console.log('a1 start')
    await a2()
    console.log('a1 end')
}
async function a2 () {
    console.log('a2')
}

console.log('script start')

setTimeout(() => {
    console.log('setTimeout')
}, 0)

Promise.resolve().then(() => {
    console.log('promise1')
})

a1()

let promise2 = new Promise((resolve) => {
    resolve('promise2.then')
    console.log('promise2')
})

promise2.then((res) => {
    console.log(res)
    Promise.resolve().then(() => {
        console.log('promise3')
    })
})
console.log('script end')

输出的结果:

sql 复制代码
script start
a1 start
a2
promise2
script end
promise1
a1 end
promise2.then
promise3
setTimeout

5. 结语

  • JS 是单线程执行的,同一时间只能处理一件事。
  • 浏览器是多线程的 ,JS 引擎通过分发这些耗时的异步事件给 Web APIs 线程处理,避免了单线程被阻塞。
  • 事件循环机制是为了协调事件、用户交互、JS 脚本、页面渲染、网络请求等事件的有序执行
  • 微任务的优先级高于宏任务
相关推荐
崔庆才丨静觅20 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606121 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了21 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅21 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅1 天前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅1 天前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment1 天前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅1 天前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊1 天前
jwt介绍
前端
爱敲代码的小鱼1 天前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax