Skip to content
索引

事件循环

事件循环(Event Loop)是 JavaScript 的一种执行机制,它是 JavaScript 实现异步编程的核心机制之一。

JavaScript 是一门单线程语言,它在主线程上执行代码,遇到同步代码就立即执行,遇到异步代码则将其加入到任务队列中,等待主线程空闲时执行。事件循环负责监听任务队列,当主线程执行完同步代码后,会去任务队列中查找是否有任务需要执行,如果有,就会将任务取出来执行,如果没有,就等待新的任务加入。

事件循环通常包括以下几个阶段:

  1. 执行完同步代码,查看任务队列是否有待执行的宏任务,如果有,则进入宏任务执行阶段;
  2. 执行完一个宏任务后,查看微任务队列是否有待执行的任务,如果有,则依次执行微任务,直到微任务队列为空;
  3. 如果宏任务执行过程中产生了新的宏任务,则将其加入到任务队列中;
  4. 重复执行上述步骤,直到任务队列和微任务队列中都没有待执行的任务。

由于 JavaScript 是单线程执行的,事件循环机制就是通过异步回调函数的方式来模拟多线程的执行效果,从而实现异步编程。

任务/同步异步

在JavaScript中,任务是指需要执行的一段代码。任务可以分为同步任务和异步任务。

同步任务是指在主线程上排队执行的任务,必须等到前面的任务执行完成才能执行下一个任务。例如,赋值操作、函数调用、算术运算等操作都是同步任务。

异步任务是指不进入主线程,而进入“任务队列”(task queue)的任务,只有等主线程上的同步任务执行完成后,才会去执行异步任务。异步任务包括定时器函数(setTimeout/setInterval)、Ajax 请求、DOM 事件处理函数、Promise 等。

异步任务可以分为宏任务和微任务,它们的区别在于执行顺序和执行时机的不同。宏任务执行的优先级低于微任务,所以当有宏任务和微任务同时存在时,微任务会先执行完毕。常见的宏任务有:setTimeout/setInterval、Ajax 请求、DOM 事件处理函数、requestAnimationFrame、setImmediate(Node.js 环境)。常见的微任务有:Promise.then/catch/finally、process.nextTick(Node.js 环境)、MutationObserver、Object.observe(已废弃,被 Proxy 对象替代)。

宏任务和微任务的区别在于它们被推入执行队列的时机和执行顺序。在事件循环中,宏任务被推入宏任务队列,微任务被推入微任务队列,当主线程执行完一个宏任务后,会先执行微任务队列中的任务,然后再执行宏任务队列中的任务。这个过程就是事件循环。

宏任务(macro task)与微任务(micro task)

在 JavaScript 中,任务(task)是指要执行的一段代码,而事件循环(event loop)则是用来处理任务的机制。在事件循环中,任务可以分为两种类型:宏任务(macro task)和微任务(micro task)。

宏任务(macro task)

包括以下几种:

  1. Script(整体代码):指整个 JavaScript 代码块,从头到尾顺序执行。
  2. setTimeout 和 setInterval:通过定时器 API 提交的任务,分别在指定的时间后或每隔一定时间执行。
  3. setImmediate(Node.js 独有):在 I/O 事件的回调函数执行完毕之后立即执行的任务,优先级高于 setTimeout。
  4. requestAnimationFrame(浏览器独有):在浏览器重绘之前执行的任务,通常用于实现动画效果。
  5. I/O:包括网络请求、文件读写等 I/O 操作。
  6. UI rendering(浏览器独有):在浏览器进行页面渲染之前执行的任务,通常与 DOM 相关。

当执行一个宏任务时,JavaScript 引擎会把它放在任务队列的尾部。当事件循环开始下一轮时,它会从任务队列中取出队首的任务,然后执行它。

如何理解script(整体代码)是宏任务

宏任务指的是 JavaScript 引擎中的一些大型操作,例如 script 脚本执行、setTimeout/setInterval 定时器回调等。而微任务指的是一些较小的任务,例如 Promise 的回调函数、MutationObserver 的回调函数等。

对于 script(整体代码),它是一个宏任务,也就是说它会被作为一个整体来执行。当浏览器加载一个 HTML 页面时,它会将页面中的所有 script 标签中的代码都放到一个宏任务队列中,并按照顺序依次执行。也就是说,在一个宏任务中执行的所有代码都会被视为一个整体,并且在宏任务结束后才会执行下一个宏任务。

需要注意的是,即使是在一个 script 标签中的代码,也可以通过 Promise 等机制来创建微任务。这些微任务会在当前宏任务执行结束后立即执行。所以,当我们在 script 标签中写代码时,需要注意微任务和宏任务的执行顺序,以免出现不符合预期的结果。

示例

js
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      console.log(1);
      setTimeout(() => {
        console.log(2);
      }, 100);
    </script>
    <script>
      setTimeout(() => {
        console.log(4);
      }, 0);
      console.log(3);
    </script>
  </body>
</html>

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      console.log(1);
      setTimeout(() => {
        console.log(2);
      }, 100);
    </script>
    <script>
      setTimeout(() => {
        console.log(4);
      }, 0);
      console.log(3);
    </script>
  </body>
</html>

输出结果1342

微任务

(micro task)包括以下几种:

  1. Promise.then:通过 Promise API 创建的任务,在 Promise 对象的状态变为 resolved 或 rejected 时执行。
  2. MutationObserver:用于监测 DOM 变化的 API,当 DOM 节点被添加、删除或修改时,会触发 MutationObserver 中注册的回调函数。
  3. Object.observe(已废弃;Proxy 对象替代):在 ECMAScript 6 中被引入的 API,用于监测对象的变化。但由于该 API 性能低下且易被滥用,已被废弃。
  4. process.nextTick(Node.js 独有):在 Node.js 中用于排队执行任务的 API,它会在当前执行栈执行完毕后立即执行,并且优先级高于 Promise。

当执行一个微任务时,JavaScript 引擎会把它放在微任务队列的尾部。在当前宏任务执行完毕之后,JavaScript 引擎会立即执行微任务队列中的所有微任务。如果在执行微任务的过程中又产生了新的微任务,那么这些新的微任务会被添加到队列的末尾,等待下一轮的执行。

在实际开发中,可以利用宏任务和微任务的特性来实现一些复杂的异步操作,比如在渲染 UI 之前先进行一些数据计算和处理等。需要注意的是,在编写异步代码时,要充分考虑到宏任务和微任务之间的交互,以避免出现不可预期的结果。

js
console.log(1)

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

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

console.log(3)
console.log(1)

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

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

console.log(3)

实际结果是:1=>'new Promise'=> 3 => 'then' => 2

在宏任务执行过程中产生的新的宏任务和微任务的执行顺序

  1. 首先,当前宏任务的同步任务执行完毕后,会检查该宏任务产生的微任务队列是否为空。如果不为空,会按照先进先出的顺序,依次取出微任务并执行它们,直到微任务队列为空。
  2. 接下来,会检查当前宏任务产生的宏任务队列是否为空。如果不为空,则会取出一个宏任务并执行它,执行完成后继续检查该宏任务的微任务队列和宏任务队列,直到两个队列都为空。
  3. 如果在执行新的宏任务过程中又产生了新的宏任务和微任务,就按照上述步骤依次执行,直到所有任务执行完成。

需要注意的是,当产生新的宏任务时,新的宏任务会被添加到宏任务队列的末尾,而微任务则会被添加到微任务队列的末尾。因此,先产生的微任务会先被执行,而后产生的宏任务会后被执行。

JS
console.log("1");
setTimeout(() => {
  console.log("2");
  Promise.resolve().then(() => {
    console.log("3");
  });
  console.log("4");
  setTimeout(() => {
    console.log("7");
    Promise.resolve().then(() => {
      console.log("8");
    });
  }, 0);
}, 0);
Promise.resolve().then(() => {
  console.log("5");
});
console.log("6");
console.log("1");
setTimeout(() => {
  console.log("2");
  Promise.resolve().then(() => {
    console.log("3");
  });
  console.log("4");
  setTimeout(() => {
    console.log("7");
    Promise.resolve().then(() => {
      console.log("8");
    });
  }, 0);
}, 0);
Promise.resolve().then(() => {
  console.log("5");
});
console.log("6");
js
1
6
5
2
4
3
7
8
1
6
5
2
4
3
7
8

事件循环

事件循环的一个基本流程,可以简单地概括为以下几个步骤:

  1. 任务进入执行栈,判断同步任务还是异步任务。
  2. 同步任务进入主线程(即主执行栈),异步任务进入任务队列。
  3. 主线程内的任务执行完毕为空,会去任务队列读取对应的任务,推入主线程执行。
  4. 上述过程不断重复,即形成了事件循环。

需要注意的是,异步任务在执行完毕后,会被放入对应的任务队列中,等待 JavaScript 引擎的处理。其中,有两种类型的任务队列,分别是宏任务队列和微任务队列。当当前宏任务执行完毕后,JavaScript 引擎会首先处理微任务队列中的所有任务,然后再从宏任务队列中取出一个任务进行执行,这样不断地重复,直到任务队列中没有任务为止。

另外,需要注意的是,事件循环是单线程的,即 JavaScript 引擎只有一个主线程,所有的任务都在这个主线程上执行。由于 JavaScript 采用的是事件驱动模型,因此在执行过程中可能会出现一些异步操作,比如定时器、网络请求等,这些操作会在后台继续执行,等到它们所属的任务队列中的任务被执行时,才会被推入主线程执行。

示例-首先处理微任务队列中的所有任务,然后再从宏任务队列中取出一个任务进行执行

js
console.log('script start');

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

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

console.log('script end');
console.log('script start');

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

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

console.log('script end');

上述代码中,我们通过 setTimeoutPromise 分别创建了一个宏任务和一个微任务。在执行过程中,console.log('script start') 是同步任务,会先进入主线程执行,然后将异步任务推入任务队列,接着执行 console.log('script end'),同样也是同步任务,也会进入主线程执行,最后任务队列中有两个任务,分别是 setTimeoutPromise

此时,JavaScript 引擎会先处理微任务队列中的任务,即 Promise 的两个 then 方法中的回调函数

接着,JavaScript 引擎会从宏任务队列中取出一个任务,也就是 setTimeout 的回调函数,执行完毕后,控制台会输出 setTimeout。因此,最终输出的结果是:

js
script start
script end
promise1
promise2
setTimeout
script start
script end
promise1
promise2
setTimeout

总的来说,JavaScript 引擎的执行顺序是先处理微任务队列中的任务,然后再处理宏任务队列中的任务。

示例-当一个宏任务产生了多个新的微任务时

当一个宏任务产生了多个新的微任务时,它们会按照产生的顺序被加入到微任务队列中,并且在当前宏任务执行完成后,JavaScript 引擎会依次处理这些微任务,直到微任务队列为空,才会去取下一个宏任务执行。

js
console.log('start');

setTimeout(() => {
  console.log('setTimeout');
  setTimeout(() => {
    console.log('setTimeout1');
  }, 0);
  Promise.resolve().then(() => console.log('promise1'));
  Promise.resolve().then(() => console.log('promise2'));
}, 0);

console.log('end');
console.log('start');

setTimeout(() => {
  console.log('setTimeout');
  setTimeout(() => {
    console.log('setTimeout1');
  }, 0);
  Promise.resolve().then(() => console.log('promise1'));
  Promise.resolve().then(() => console.log('promise2'));
}, 0);

console.log('end');

在执行过程中,控制台输出的顺序是:

js
start
end
setTimeout
promise1
promise2
setTimeout1
start
end
setTimeout
promise1
promise2
setTimeout1

如何理解setTimeout(func, 0)

setTimeout函数用来指定某个函数或某段代码,在多少毫秒之后执行。它返回一个整数,表示定时器的编号,以后可以用来取消这个定时器

在 JavaScript 中,setTimeout() 方法是一个异步函数,它的作用是在指定的时间后向任务队列中添加一个新的宏任务。当我们调用 setTimeout() 时,浏览器会将其注册到一个名为 "Event Table" 的数据结构中,并指定其需要等待的时间。当等待时间到达后,该函数会被移动到任务队列中,等待 JavaScript 引擎执行。

简单来说,setTimeout() 方法并不会立即执行传入的回调函数,而是会将其注册到 "Event Table" 中,并指定一个等待时间。只有当等待时间到达后,回调函数才会被添加到任务队列中,等待 JavaScript 引擎执行。因此,我们可以将 setTimeout() 视为一个将函数添加到宏任务队列中的定时器。

参数

  • function

    function 是你想要在到期时间 (delay毫秒) 之后执行的函数

  • code

    这是一个可选语法,你可以使用字符串而不是function ,在delay毫秒之后编译和执行字符串 (使用该语法是不推荐的, 原因和使用 eval(),在delay毫秒之后编译和执行字符串 (使用该语法是不推荐的, 原因和使用 eval()一样,有安全风险)

  • delay 可选

    延迟的毫秒数 (一秒等于 1000 毫秒),函数的调用会在该延迟之后发生。如果省略该参数,delay 取默认值 0,意味着“马上”执行,或者尽快执行。不管是哪种情况,实际的延迟时间可能会比期待的 (delay 毫秒数) 值长,原因请查看实际延时比设定值更久的原因:最小延迟时间

  • arg1, ..., argN 可选

    附加参数,一旦定时器到期,它们会作为参数传递给function

    js
     return new Promise(function (resolve, reject) {
            log('calculating ' + input + ' x ' + input + '...')
            // 这里的第三个参数会传递给第一个函数,作为其参数
            setTimeout(resolve, 500, input * input)
        })
    
     return new Promise(function (resolve, reject) {
            log('calculating ' + input + ' x ' + input + '...')
            // 这里的第三个参数会传递给第一个函数,作为其参数
            setTimeout(resolve, 500, input * input)
        })
    

setTimeout在指定的时间后向任务队列中添加一个新的宏任务,JavaScript在主线程上执行代码,立即执行同步代码,执行完同步代码,查看到任务队列有待执行的宏任务,进入宏任务执行阶段

所以输出结果是acb

js
console.log("a");
setTimeout(() => {
  console.log("b")
},0)
console.log("c");
console.log("a");
setTimeout(() => {
  console.log("b")
},0)
console.log("c");

示例-setTimeout在指定的时间后向任务队列中添加一个新的宏任务

js
console.log(1);
setTimeout(() => {
  console.log(2)
  setTimeout(() => {
    console.log(3)
  },10)
},0)
setTimeout(() => {
  console.log(5)
},100)
console.log(4);
console.log(1);
setTimeout(() => {
  console.log(2)
  setTimeout(() => {
    console.log(3)
  },10)
},0)
setTimeout(() => {
  console.log(5)
},100)
console.log(4);

输出结果是14235 ,宏任务的顺序是235

Event Table是什么

Event Table(事件表)是浏览器内部的一个数据结构,用于存储异步事件的相关信息。它包含了注册的事件的类型、回调函数等信息,并在事件触发时将事件信息传递给事件队列(Event Queue)。

当我们使用 setTimeoutsetInterval 等定时器 API 时,浏览器会将相关信息添加到 Event Table 中,并在设定的时间到达后,将这些事件信息添加到宏任务队列中等待执行。因此,Event Table 是宏任务的来源之一。

Released under the MIT License.