Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

什么是事件循环EvenLoop?Node事件循环和JS事件循环的差异是什么? #31

Open
TieMuZhen opened this issue Nov 22, 2021 · 0 comments

Comments

@TieMuZhen
Copy link
Owner

TieMuZhen commented Nov 22, 2021

前言

建议先学习以下知识:

正文

JavaScript是一个单线程的脚本语言。

同步任务和异步任务

Javascript单线程任务被分为同步任务异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务会在异步任务有了结果后,将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行。

微任务与宏任务

javascript异步任务异步任务包含两种:宏任务和微任务

宏任务

宏任务:script(整体代码)、setTimeout、setInterval、setImmediate、I/O、UI rendering(DOM更新)、DOM事件回调、AJAX事件回调。

AJAX事件回调

const request = XMLHttpRequest();
request.open("GET","baidu.com");
request.onreadystatechange = ()=>{
    if(request.readyState === 4){
    	if(request.status >= 200 && request.status <400){
        	//success
        }else{
        	//error
        }
    } 
}

request.send();
// 代码执行到最后,这段代码就即将要完成了。V8 将要销毁这段代码的环境对象。这里就是 V8 执行微任务的一个检查点

定时器回调

微任务

requestAnimationFrame姑且也算是宏任务吧,requestAnimationFrame在MDN的定义为,下次页面重绘前所执行的操作,而重绘也是作为宏任务的一个步骤来存在的,且该步骤晚于微任务的执行。

微任务开始执行的契机是什么呢?
如上面代码所示,整段代码就即将要完成。V8 将要销毁这段代码的环境对象。这里就是 V8 执行微任务的一个检查点

修改完DOM后什么时候进行渲染?
根据HTML Standard,一轮事件循环执行结束之后,下轮事件循环执行之前开始进行UI render。即:宏任务执行完毕,接着执行完所有的微任务后,此时本轮循环结束,开始执行UI renderUI render完毕之后接着下一轮循环。


经典面试题

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

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

console.log(2)

setTimeout就是作为宏任务来存在的,而Promise.then则是具有代表性的微任务,上述代码的执行顺序就是按照序号来输出的。

所有会进入的异步都是指的事件回调中的那部分代码
也就是说new Promise在实例化的过程中所执行的代码都是同步进行的,而then中注册的回调才是异步执行的。
在同步代码执行完成后才回去检查是否有异步任务完成,并执行对应的回调,而微任务又会在宏任务之前执行。
所以就得到了上述的输出结论1、2、3、4

+部分表示同步执行的代码

+setTimeout(_ => {
-  console.log(4)
+})

+new Promise(resolve => {
+  resolve()
+  console.log(1)
+}).then(_ => {
-  console.log(3)
+})

+console.log(2)

本来setTimeout已经先设置了定时器(相当于取号),然后在当前进程中又添加了一些Promise的处理(临时添加业务)。

所以进阶的,即便我们继续在Promise中实例化Promise,其输出依然会早于setTimeout的宏任务:

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

当然了,实际情况下很少会有简单的这么调用Promise的,一般都会在里边有其他的异步操作,比如fetch是基于Promise的,是微任务。

在浏览器中的表现

在上边简单的说明了两种任务的差别,以及Event Loop的作用,那么在真实的浏览器中是什么表现呢?
首先要明确的一点是,宏任务必然是在微任务之后才执行的(因为微任务实际上是宏任务的其中一个步骤)

I/O这一项感觉有点儿笼统,有太多的东西都可以称之为I/O,点击一次button,上传一个文件,与程序产生交互的这些都可以称之为I/O

假设有这样的一些DOM结构:

<div id="outer">
  <div id="inner"></div>
</div>
const $inner = document.querySelector('#inner')
const $outer = document.querySelector('#outer')

function handler () {
  console.log('click') // 直接输出

  Promise.resolve().then(_ => console.log('promise')) // 注册微任务

  setTimeout(_ => console.log('timeout')) // 注册宏任务

  requestAnimationFrame(_ => console.log('animationFrame')) // 注册宏任务

  $outer.setAttribute('data-random', Math.random()) // DOM属性修改,触发微任务
}

new MutationObserver(_ => {
  console.log('observer')
}).observe($outer, {
  attributes: true
})

$inner.addEventListener('click', handler)
$outer.addEventListener('click', handler)

如果点击#inner,其执行顺序一定是:click -> promise -> observer -> click -> promise -> observer -> animationFrame -> animationFrame -> timeout -> timeout
因为一次I/O创建了一个宏任务,也就是说在这次任务中会去触发handler
按照代码中的注释,在同步的代码已经执行完以后,这时就会去查看是否有微任务可以执行,然后发现了PromiseMutationObserver两个微任务,遂执行之。
因为click事件会冒泡,所以对应的这次I/O会触发两次handler函数(一次在inner、一次在outer),所以会优先执行冒泡的事件(早于其他的宏任务),即inner执行完同步代码和微任务后向上冒泡,inner再执行完同步代码和微任务,直至最后一层冒泡结束后再开始执行宏任务,也就是说会重复上述的逻辑。
在执行完同步代码与微任务以后,这时继续向后查找有木有宏任务。
需要注意的一点是,因为我们触发了setAttribute,实际上修改了DOM的属性,这会导致页面的重绘,而这个set的操作是同步执行的,也就是说requestAnimationFrame的回调会早于setTimeout所执行。

换种触发方式

使用上述的示例代码,如果将手动点击DOM元素的触发方式变为$inner.click(),那么会得到不一样的结果。
在Chrome下的输出顺序大致是这样的:
click -> click -> promise -> observer -> promise -> animationFrame -> animationFrame -> timeout -> timeout
与我们手动触发click的执行顺序不一样的原因是这样的,因为并不是用户通过点击元素实现的触发事件,而是类似dispatchEvent这样的方式,我个人觉得并不能算是一个有效的I/O,在执行了一次handler回调注册了微任务、注册了宏任务以后,实际上外边的$inner.click()并没有执行完。
所以在微任务执行之前,还要继续冒泡执行下一次事件,也就是说触发了第二次的handler
所以输出了第二次click等到这两次handler都执行完毕后才会去检查有没有微任务、有没有宏任务

注意

1、.click()的这种触发事件的方式类似dispatchEvent,可以理解为同步执行的代码

document.body.addEventListener('click', _ => console.log('click'))

document.body.click()
document.body.dispatchEvent(new Event('click'))
console.log('done')

// > click
// > click
// > done

2、MutationObserver的监听不会说同时触发多次,多次修改只会有一次回调被触发。

new MutationObserver(_ => {
  console.log('observer')
  // 如果在这输出DOM的data-random属性,必然是最后一次的值,不解释了
}).observe(document.body, {
  attributes: true
})

document.body.setAttribute('data-random', Math.random())
document.body.setAttribute('data-random', Math.random())
document.body.setAttribute('data-random', Math.random())

// 只会输出一次 ovserver

在Node中的表现

Node也是单线程,但是在处理Event Loop上与浏览器稍微有些不同。

就单从API层面上来理解,Node新增了两个方法可以用来使用:微任务的process.nextTick以及宏任务的setImmediate

setImmediate与setTimeout的区别

在官方文档中的定义,setImmediate为一次Event Loop执行完毕后调用。
setTimeout则是通过计算一个延迟时间后进行执行。

但是同时还提到了如果在主进程中直接执行这两个操作,很难保证哪个会先触发。
因为如果主进程中先注册了两个任务,然后执行的代码耗时超过XXs,而这时定时器已经处于可执行回调的状态了。
所以会先执行定时器,而执行完定时器以后才是结束了一次Event Loop,这时才会执行setImmediate

setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))

执行多次会得到不同的结果。

但是如果后续添加一些代码以后,就可以保证setTimeout一定会在setImmediate之前触发了:

setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))

let countdown = 10000000

while(countdown--) { } // 我们确保这个循环的执行速度会超过定时器的倒计时,导致这轮循环没有结束时,setTimeout已经可以执行回调了,所以会先执行setTimeout再结束这一轮循环,也就是说开始执行setImmediate

如果在另一个宏任务中,必然是setImmediate先执行:

require('fs').readFile(__dirname, _ => {
  setTimeout(_ => console.log('timeout'))
  setImmediate(_ => console.log('immediate'))
})

// 如果使用一个设置了延迟的setTimeout也可以实现相同的效果

process.nextTick

就像上边说的,这个可以认为是一个类似于PromiseMutationObserver的微任务实现,在代码执行的过程中可以随时插入nextTick,并且会保证在下一个宏任务开始之前所执行。

在使用方面的一个最常见的例子就是一些事件绑定类的操作:

class Lib extends require('events').EventEmitter {
  constructor () {
    super()

    this.emit('init')
  }
}

const lib = new Lib()

lib.on('init', _ => {
  // 这里将永远不会执行
  console.log('init!')
})

因为上述的代码在实例化Lib对象时是同步执行的,在实例化完成以后就立马发送了init事件。
而这时在外层的主程序还没有开始执行到lib.on('init')监听事件的这一步。
所以会导致发送事件时没有回调,回调注册后事件不会再次发送。

我们可以很轻松的使用process.nextTick来解决这个问题:

class Lib extends require('events').EventEmitter {
  constructor () {
    super()

    process.nextTick(_ => {
      this.emit('init')
    })

    // 同理使用其他的微任务
    // 比如Promise.resolve().then(_ => this.emit('init'))
    // 也可以实现相同的效果
  }
}

这样会在主进程的代码执行完毕后,程序空闲时触发Event Loop流程查找有没有微任务,然后再发送init事件。

async/await函数

因为,async/await本质上还是基于Promise的一些封装,而Promise是属于微任务的一种。所以在使用await关键字与Promise.then效果类似:

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

async function main() {
  console.log(1)
  await Promise.resolve()
  console.log(3)
}

main()

console.log(2)

// 1
// 2
// 3
// 4

async函数在await之前的代码都是同步执行的,可以理解为await之前的代码属于new Promise时传入的代码,await之后的所有代码都是在Promise.then中的回调

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant