Event Loop事件循环机制(浏览器)

673 阅读8分钟

Event Loop 事件循环

Event Loop即事件循环,是指浏览器或Node的一种解决javaScript单线程运行时不会阻塞的一种机制,也就是我们经常使用异步的原理。

事件循环是javaScript实现异步的一种方法,也是js的执行机制。

为什么 JavaScript 是单线程?

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准? 所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

任务队列

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。 所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。 具体来说,异步执行的运行机制如下:(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)

只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。这个过程会不断重复。

宏任务 & 微任务 都属于异步任务

这里需要注意的是new Promise会进入到主线程中立刻执行,而promise.then属于微任务

  • 宏任务(macro-task)整体代码scriptsetTimeOutsetIntervalI/OUI Rendering
  • 微任务(mincro-task)promise.thenpromise.nextTick(node、)MutationObserver(具体使用方式查看👉这里

EventLoop 事件循环

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

  1. 整体的script(作为第一个宏任务)开始执行的时候,会把所有代码分为两部分:“同步任务”、“异步任务”;
  2. 同步任务直接进入主线程依次执行
  3. 异步任务会再分为宏任务和微任务
  4. 宏任务进入到Event Table中,并在里面注册回调函数,每当指定的事件完成时,Event Table会将这个函数移到Event Queue中;
  5. 微任务也会进入到另一个Event Table中,并在里面注册回调函数,每当指定的事件完成时,Event Table会将这个函数移到Event Queue中;
  6. 主线程内的任务执行完毕主线程为空时,会检查微任务的Event Queue,如果有任务,就全部执行,如果没有就执行下一个宏任务

上述过程会不断重复,这就是Event Loop事件循环

简单概况:js代码 执行时,先分同步和异步任务,先执行同步任务,依次执行,异步任务再分宏任务和微任务,都会进入到Event Table中,当主线程内的任务执行完毕,主线程为空时,会检查微任务的Event Queue,如果有任务,就全部执行,如果没有就执行下一个宏任务;上述过程会不断重复,这就是Event Loop事件循环;

当当前执行栈执行完毕时会立刻先处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。

详见下图:

事件循环
事件循环

在主线程上添加宏任务与微任务

执行顺序:主线程 => 主线程上创建的微任务 => 主线程上创建的宏任务

console.log('start');

setTimeout(() => {
  console.log('setTimeout');  // 将回调代码放入另一个宏任务队列
}, 0);

new Promise((resolve, reject) => {
  for (let i = 0; i < 5; i++) {
    console.log(i);
  }
  resolve()
}).then(()=>{
  console.log('Promise实例成功回调执行'); // 将回调代码放入微任务队列
})

console.log('end')

# 浏览器运行结果
start
0
1
2
3
4
end
Promise实例成功回调执行
setTimeout

在微任务中创建微任务

执行顺序:主线程 => 主线程上创建的微任务1 => 微任务1上创建的微任务2 => 主线程上创建的宏任务

setTimeout(_ => console.log(4))

new Promise(resolve => {
  resolve()
  console.log(1)
}).then(_ => {
  console.log(3)
  Promise.resolve().then(_ => {
    console.log('before timeout')
  }).then(_ => {
    Promise.resolve().then(_ => {
      console.log('also before timeout')
    })
  })
})

console.log(2)
# 浏览器运行结果
1
2
3
'before timeout'
'also before timeout'
4

宏任务中创建微任务

执行顺序:主线程 => 主线程上的宏任务队列1 => 宏任务队列1中创建的微任务

// 宏任务队列 1
setTimeout(() => {
  // 宏任务队列 2.1
  console.log('timer_1');
  setTimeout(() => {
    // 宏任务队列 3
    console.log('timer_3')
  }, 0)
  new Promise(resolve => {
    resolve()
    console.log('new promise')
  }).then(() => {
    // 微任务队列 1
    console.log('promise then')
  })
}, 0)

setTimeout(() => {
  // 宏任务队列 2.2
  console.log('timer_2')
}, 0)

console.log('========== Sync queue ==========')

// 执行顺序:主线程(宏任务队列 1)=> 宏任务队列 2 => 微任务队列 1 => 宏任务队列 3
# 浏览器运行结果
'========== Sync queue =========='
'timer_1'
'new promise'
'promise then'
'timer_2'
'timer_3'

微任务队列中创建的宏任务

执行顺序:主线程 => 主线程上创建的微任务 => 主线程上创建的宏任务 => 微任务中创建的宏任务

// 宏任务1
new Promise((resolve) => {
  console.log('new Promise(macro task 1)');
  resolve();
}).then(() => {
  // 微任务1
  console.log('micro task 1');
  setTimeout(() => {
    // 宏任务3
    console.log('macro task 3');
  }, 0)
})

setTimeout(() => {
  // 宏任务2
  console.log('macro task 2');
}, 1000)

console.log('========== Sync queue(macro task 1) ==========');

# 浏览器运行结果
'new Promise(macro task 1)'
'========== Sync queue(macro task 1) =========='
'micro task 1'
'macro task 3'
'macro task 2'

 记住,如果把setTimeout(() => { console.log('macro task 2'); }, 1000)改为立即执行setTimeout(() => { console.log('macro task 2'); }, 0)那么它会在macro task 3之前执行,因为定时器是过多少毫秒之后才会加到事件队列里
 
# 浏览器运行结果
'new Promise(macro task 1)'
'========== Sync queue(macro task 1) =========='
'micro task 1'
'macro task 2'
'macro task 3'

总结

微任务队列优先于宏任务队列执行,微任务队列上创建的宏任务会被后添加到当前宏任务队列的尾端,微任务队列中创建的微任务会被添加到微任务队列的尾端。只要微任务队列中还有任务宏任务队列就只会等待微任务队列执行完毕后再执行

async/await

当我们在函数前使用async的时候,使得该函数返回的是一个Promise对象

async function test() {
    return 1   // async的函数会在这里帮我们隐士使用Promise.resolve(1)
}
// 等价于下面的代码
function test() {
   return new Promise(function(resolve, reject) {
       resolve(1)
   })
}
# 可见 async 只是一个语法糖,只是帮助我们返回一个Promise而已

await 表示等待,是右侧「表达式」的结果,这个表达式的计算结果可以是 Promise 对象的值或者一个函数的值(换句话说,就是没有特殊限定)。并且只能在带有async的内部使用.

使用await时,会从右往左执行,当遇到await时,会阻塞函数内部处于它后面的代码,去执行该函数外部的同步代码,当外部同步代码执行完毕,再回到该函数内部执行剩余的代码, 并且当await执行完毕之后,会先处理微任务队列的代码.

     async function async1() {
            console.log( 'async1 start' )  // 2
            await async2() // 阻塞后面的代码,去外部执行同步代码 进入new Promise
            console.log( 'async1 end' ) // 6
        }
        async function async2() {
            console.log( 'async2' ) // 3
        }
        console.log( 'script start' ) // 1
        setTimeout( function () {
            console.log( 'setTimeout' ) // 8
        }, 0 )
        async1(); 
        new Promise( function ( resolve ) {
            console.log( 'promise1' ) // 4
            resolve();
        } ).then( function () {
            console.log( 'promise2' ) // 7
        } )
        console.log( 'script end' ) // 5

# 浏览器运行结果:
'script start'
'async1 start'
'async2' 
'promise1'
'script end'
'async1 end'
'promise2'
setTimeout

使用事件循环机制分析:

  1. 首先执行同步代码,console.log( 'script start' )
  2. 遇到setTimeout,会被推入宏任务队列
  3. 执行async1(), 它也是同步的,只是返回值是Promise,在内部首先执行console.log( 'async1 start' )
  4. 然后执行async2(), 然后会打印console.log( 'async2' )
  5. 从右到左会执行, 当遇到await的时候,阻塞后面的代码,去外部执行同步代码
  6. 进入new Promise,打印console.log( 'promise1' )
  7. .then放入事件循环的微任务队列
  8. 继续执行,打印console.log( 'script end' )
  9. 外部同步代码执行完毕,接着回到async1()内部, 由于async2()其实是返回一个Promise, await async2()相当于获取它的值,其实就相当于这段代码Promise.resolve(undefined).then((undefined) => {}),所以.then会被推入微任务队列, 所以现在微任务队列会有两个任务。接下来处理微任务队列,打印console.log( 'promise2' ),后面一个.then不会有任何打印,但是会执行
  10. 执行后面的代码, 打印console.log( 'async1 end' )
  11. 进入第二次事件循环,执行宏任务队列, 打印console.log( 'setTimeout' )

资料查阅:

本文使用 👉mdnice 排版