28、Event Loop 事件循环

本文详细解析了JavaScript中的异步任务和事件循环机制(EventLoop),阐述了为何需要异步任务以及宏任务和微任务的区别。通过实例分析了任务执行的顺序,并探讨了setTimeout倒计时误差的问题及解决方案。重点介绍了Promise、MutationObserver和setTimeout在事件循环中的位置,以及如何优化DOM操作以提升界面响应速度。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

28.1 为什么要有异步任务

因为JS是单线程的。所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。

由于程序运行时,可能会因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据)而发生程序运行较慢。

为提高程序相应速度,于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。

  • 同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
  • 异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)

  • (1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

  • (2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。

  • (3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。

  • (4)主线程不断重复上面的第三步。

28.2 浏览器中的 Event Loop

涉及面试题:异步代码执行顺序?解释一下什么是 Event Loop

  • 主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。

具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)
在这里插入图片描述上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(clickloaddone)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。

  • JS 在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中。如果遇到异步的代码,会被挂起并加入到 Task(有多种 task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为

  • 不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobsmacrotask 称为 task

1. 微任务(jobs)

  • process.nextTick(Node.js环境)
  • promise
  • Object.observe(废弃)
  • MutationObserver

2. 宏任务(task)

  • script
  • setTimeout
  • setInterval
  • setImmediate(Node.js环境)
  • I/O
  • UI rendering

宏任务中包括了 script ,浏览器会先执行一个宏任务,接下来有异步代码的话就将回调函数放入任务队列,之后先执行微任务,最后再执行之前宏任务放入的回调函数

//一道事件循环的题
console.log('script start');  //宏任务 同步

setTimeout(function() {
  console.log('setTimeout');  //宏任务 异步
}, 0);

new Promise((resolve) => {
    console.log('Promise')	  //宏任务 同步
    resolve()
}).then(function() {
  console.log('promise1');    //微任务
}).then(function() {
  console.log('promise2'); 	  //微任务
});

console.log('script end');	  //宏任务 同步
// script start => Promise => script end => promise1 => promise2 => setTimeout
  1. 以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务
  2. setTimeout(fn,0)的含义是指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它的回调函数是在下一轮“事件循环”开始时执行的。
  3. Promise.resolve()在本轮“事件循环”结束时执行

在每一次事件循环中,macrotask 只会提取一个执行(setTimeout(fn,0)是宏任务),而 microtask 会一直提取,直到 microtasks 队列清空。

28.3 一次正确的 Event loop顺序

JS是单线程的,为了防止一个函数执行时间过长阻塞后面的代码,所以会先将同步代码压入执行栈中,依次执行,将异步代码推入异步队列,异步队列又分为宏任务队列和微任务队列,因为宏任务队列的执行时间较长,所以微任务队列要优先于宏任务队列。微任务队列的代表就是,Promise.then,MutationObserver,宏任务的话就是setImmediate setTimeout setInterval

  • 执行同步代码,这属于宏任务,遇到宏任务中的异步任务则将异步任务的回调函数放入此次事件循环的最后
  • 执行栈为空,查询是否有微任务需要执行,把微任务添加到任务队列中
  • 立即执行任务队列中的所有微任务(依次执行),最后是setTimeout(fn,0)(异步回调函数执行)
  • 开始检查渲染,然后 GUI 线程接管渲染
  • 渲染完毕,JS 线程继续接管,然后开始下一轮 Event loop

通过上述的 Event loop 顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作 DOM 的话,为了更快的响应界面响应,我们可以把操作 DOM 放入微任务中

//一道事件循环的题
new Promise(resolve => {
    resolve(1);
    Promise.resolve().then(() => {
    	// t2
    	console.log(2)
    });
    console.log(4)
}).then(t => {
	// t1
	console.log(t)
});
console.log(3);

这段代码的流程大致如下:

  1. script 任务先运行。首先遇到 Promise 实例,构造函数首先执行,所以首先输出了 4。此时 microtask 的任务有 t2 和 t1
  2. script 任务继续运行,输出 3。至此,第一个宏任务执行完成。
  3. 执行所有的微任务,先后取出 t2 和 t1,分别输出 2 和 1
  4. 代码执行完毕
    综上,上述代码的输出是:4321

为什么 t2 会先执行呢?理由如下:

  • 根据 Promises/A+规范:
    实践中要确保 onFulfilledonRejected 方法异步执行,且应该在 then 方法被调用的那一轮事件循环之后的新执行栈中执行
  • Promise.resolve 方法允许调用时不带参数,直接返回一个resolved 状态的 Promise 对象。立即 resolvedPromise 对象,是在本轮“事件循环”(event loop)的结束时,而不是在下一轮“事件循环”的开始时。

补充:setTimeout 倒计时为什么会出现误差

上面讲了定时器是属于 宏任务(macrotask) 。如果当前 执行栈 所花费的时间大于定时器时间,那么定时器的回调在宏任务(macrotask) 里,来不及去调用,所有这个时间会有误差。

根据以下代码进行讨论

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

//某个执行时间很长的函数();

setInterval(function(){ 
  let j = 0
  while(j++ < 100000000)
}, 0)

如果定时器下面的函数执行要 5秒钟,那么定时器里的log 则需要 5秒之后再执行,函数占用了当前 执行栈 ,等执行栈执行完毕后需要再去读取 微任务(microtask),等微任务(microtask) 完成,这个时候才会去读取 宏任务(macrotask) 里面的 setTimeout 回调函数执行。setInterval 同理,例如每3秒放入宏任务,也要等到执行栈的完成。

如何解决倒计时误差问题

  • 一般的解决方法是前端定时向服务器发送请求获取最新的时间差来校准倒计时时间,主动(程序里设置定时请求)或被动的(F5 已被用户按坏)区别而已。这个方法简单但也有点粗暴,

下面提供一种方法,能够一定程度上不依赖服务端实现倒计时的纠偏。

const interval = 1000
let ms = 50000,  // 从服务器和活动开始时间计算出的时间差,这里测试用 50000 ms
let count = 0
const startTime = new Date().getTime()
let timeCounter
if( ms >= 0) {
  timeCounter = setTimeout(countDownStart, interval)
}
 
function countDownStart () {
   count++
   const offset = new Date().getTime() - (startTime + count * interval) // A
   let nextTime = interval - offset
   if (nextTime < 0) { 
     nextTime = 0 
   }
   ms -= interval
   console.log(`误差:${offset} ms,下一次执行:${nextTime} ms 后,离活动开始还有:${ms} ms`)
   if (ms < 0) {
     clearTimeout(timeCounter)
   } else {
     timeCounter = setTimeout(countDownStart, nextTime)
   }
 }

通过递归调用 setTimeout 进行倒计时操作的执行。而每次执行函数时会维护一个 count 变量,用以记录已经执行过的倒计时次数,使用代码 A 处的公式可计算出当前执行倒计时的时间与实际应执行时间的偏差,进而可以计算出下次执行倒计时的时间。

参考文章

JavaScript 运行机制详解:再谈Event Loop

带你彻底弄懂Event Loop

前端进阶之setTimeout 倒计时为什么会出现误差?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值