事件循环

JavaScript事件循环

JavaScript是单线程非阻塞的脚本语言,这意味着JavaScript只有一条主线程来处理所有任务,当主线程遇到同步任务时会直接处理此任务,当遇到异步任务时,会先将此任务“挂”起来,等到条件成熟时再去执行该任务的回调函数。
JavaScript存在一个类似while(true)一样的循环机制,一直运行着,它的作用就是处理同步任务和异步任务。

执行栈

当我们我们执行一个方法时,JavaScript会生成与这个方法对应的执行环境,我们称它为执行上下文,这个执行上下文保存着这个方法的私有作用域、上层作用域的指向、方法的参数、this对象和局部变量。而执行上下文会被添加到一个栈里,这个栈称为执行栈,遵循后进先出的规则。
执行上下文有三种:
  • 全局执行上下文
  • 函数执行上下文
  • eval执行上下文(eval函数容易导致恶意攻击,不推荐使用)
我们以主线程执行函数调用语句为例,当主线程执行到一条函数调用语句时,并不会立马执行该函数,而是会先生成该函数的执行上下文,将函数执行上下文压入栈中,然后再执行函数内的代码,然后返回结果。当函数内的代码未执行完毕时,此函数的执行上下文一直在栈中,而函数执行完毕时,该函数执行上下文会出栈销毁,这种出栈入栈的过程反复进行,直到执行栈中的代码全部执行完毕。
由此可见,执行栈是JavaScript管理执行上下文的。在整个函数执行过程中,确保函数执行上下文在函数执行过程中一直存在,进而确保函数执行时函数内的this对象、局部变量、参数等可被访问。
注意:并非所有栈顶的执行上下文出栈都会被销毁,如果它是被挂起并保留以供可访问的生成器对象,则不能销毁它。

事件队列

当主线程遇到异步任务时,会将其加入到队列(先进先出)中,而根据异步任务的不同,所进入的队列又有所不同,宏任务进入任务队列,微任务会进入到微任务队列。
异步任务分为
  • 宏任务
  • 微任务
属于微任务的事件包括但不限于以下几种:
  • Promise.then/catch/finally
  • MutationObserver
  • Object.observe
  • process.nextTick
属于宏任务的事件包括但不限于以下几种:
  • setTimeout
  • setInterval
  • setImmediate
  • MessageChannel
  • requestAnimationFrame
  • I/O
  • UI交互事件

事件循环

事件循环有以下四个关键步骤:
  1. Evaluate Script:执行script中的js代码,同步任务直接入栈执行,异步任务则加入到对应的队列中
  1. Run a Task:取出一个任务队列中的宏任务执行
  1. Run all Microtasks:取出微任务队列中所有的微任务执行
  1. Rerender:重新渲染UI,进入下一个循环,重新执行第二步
也就是说,主线中遇到script中首先会评估script中的代码,如果遇到同步任务则直接压入执行栈中执行,而遇到异步任务则跟随其是宏任务还是微任务而添加到对应的任务队列或微任务队列中去,执行检查微任务队列,将微任务队列中的所有微任务执行完毕,然后重新渲染UI,进入下一次事件循环,从第二步Run a Task开始,从任务队列中取出一个宏任务执行,如果执行的过程中产生了新的宏任务和微任务则同样是加入到对应的任务队列中去,然后执行微任务队列,最后重新渲染UI,进入下一次事件循环。

练手

console.log(1) async function async1() { await async2() console.log('2') } async function async2() { console.log('3') } async1() setTimeout(function() { console.log('4') }, 0) new Promise(resolve => { console.log('5') resolve() }) .then(function p1() { console.log('6') }) .then(function p2() { console.log('7') }) console.log('8') // 1 3 5 8 2 6 7 4
执行步骤
  1. 同步任务console.log('1')直接执行,打印1
  1. 运行async1,执行await async2(),打印3,await后面的代码console.log('2')当做微任务添加到微任务队列中,执行后面的代码。
  1. setTimeout是宏任务,添加到宏任务队列
  1. new Promise()是同步任务,Promise.then()才是微任务,因此打印 5
  1. Promise.then()是微任务,将p1函数添加到微任务队列
  1. 同步任务console.log('8'), 直接打印 8
  1. 执行微任务队列中的微任务,打印 2 ,6,执行p1时又创建了新的微任务p2,一起执行,打印7
  1. re-render
  1. 检查宏任务队列,取出一个宏任务执行,打印 4
  1. 执行完毕

扩展

无限微任务

根据事件循环的特性,假设有下面两种函数,哪一种会导致浏览器卡死?
function p1(){Promise.resolve().then(p1);console.log(1)} function p2(){setTimeout(p2);console.log(2)}
很明显是p1,由于p1函数一直会创建新的微任务,导致事件循环一直卡在执行微任务步骤中,无法进入下一次循环,也无法重新渲染UI,因此页面就会出现卡死的情况。
这提示我们,不要无限地创造微任务。另外一种可能会出现无限创建微任务的情况是在解析自身的thenable 对象上调用Promise.resolve
let thenable = { then: (resolve, reject) => { resolve(thenable) } } Promise.resolve(thenable) //这会造成一个死循环

避免js长时间执行

试试运行以下代码
window.onscroll = function(){ let a=0; for(let i=0;i<Number.MAX_SAFE_INTEGER;i++){ a++ }; console.log(a) }
事件处理程序是宏任务,当事件处理程序执行时间过长时,就会阻塞UI重新渲染,导致页面卡顿,甚至是直接卡死。

参考

一个可视化Even Loop的工具,推荐使用:JS Visualizer 9000 (jsv9000.app)