本文已参与「新人创作礼」活动,一起开启创作之路。

做为一个前端开发,要想深入学习JavaScript进阶知识,就不得不了解JavaScript的事件循环。JavaScript的事件循环抽象,不易理解,谁都可以说出单线程,宏任务,微任务,但大部分人只是停留在理论上,在实践中却不是太清晰,给大家推荐一个可视化EventLoop演示网址:JS Visualizer 9000 (jsv9000.app),可以动态演示每个执行步骤,方便大家理解。

1、JS的运行机制

javascript是一门单线程语言,默认情况下,既然js是单线程,那就像只有一个窗口的银行,客户需要排队一个一个办理业务,同理js任务也要一个一个顺序执行。如果按单线程同步的方式运行,一旦HTTP请示向服务器发送,就会出现等待数据返回之前网页假死的效果,这就导致页面渲染和事件的执行,在这个过程中无法进行。显然在实际开发中并没有出现这种情况。

关于同步和异步

基于以上的描述,我们知道在JavaScript的世界中,应该有一种方案,来处理单线程造成的问题。这就是同步和异步执行模式的出现。

同步(阻塞):

同步的意思是JavaScript会严格按照单线程(从上到下,从左到右的方式)执行代码逻辑。

let a = 1
let b = 2
let c = a + b
function sleep(ms) {
  let start = Date.now()
  while (Date.now() - start < ms) {}
}
console.log('a=', a)
console.log('b=', b)
sleep(2000)
console.log('c=', c)

异步(非阻塞)

 let a = 1
let b = 2
let c = a + b     
console.log('a=', a)
console.log('b=', b)      
/*异步代码*/
setTimeout(() => {
  console.log('2秒后输出setTimeout')
}, 2000)
console.log('c=', c)

非阻塞式运行的代码,程序运行到该代码片段时,执行引擎人将程序保存到一个暂存区,等待所有同步代码全部执行完毕后,非阻塞式的代码会按照特定的执行顺序,分步执行。这就是单线程异步的特点。

总结

JavaScript的运行顺序就是完全单线程的异步模型:同步在前,异步在后。所有的异步任务都要等待当前的同步任务执行完毕之后才能执行。

2、JS的线程组成

虽然浏览器是单线程执行JavaScript代码的,但是浏览器实际是以多个线程协助操作来实现单线程异步模型的,具体线程组成如下:

  • GUI渲染线程
  • JavaScript引擎线程
  • 事件触发线程(按钮、事件)
  • 定时器触发线程
  • http请示线程
  • 其他线程

在JavaScript代码运行的过程中实际执行程序时,同时只存在一个活动线程,这里实现同步异步就是靠多线程切换的形式来实现的。

所以通常我们将上面的细分线程归纳为下列两条线程:

  1. 【主线程】:这个线程用来执行页面的渲染,JavaScript代码的运行,事件的触发等等
  2. 【工作线程】:这个线程是在幕后工作的,用来处理异步任务的执行来实现非阻塞的运行模式

3、JavaScript的运行模型

JavaScript事件循环详解

function logA() {
  console.log('A')   
  setTimeout(()=>{
    console.log('logA-setTimeout')
  })  
}
function logB() { console.log('B') }
function logC() { console.log('C') }
function logD() { console.log('D') }
logA(); // 代码1
setTimeout(logB, 1000); // 代码2
Promise.resolve().then(logC); // 代码3
logD(); // 代码4

上述代码,

第一轮执行

1、首先执行代码1,同步代码A直接执行,setTimeout放到Task Queue异步工作队列中

2、然后执行代码2,setTimeout异步任务,把logB放入到Task Queue异步工作队列中

3、代码3,Promise微任务,将logC放到Microtask Queue微任务队列

4、代码4,同步代码,直接放入执行栈中执行,并出栈

JavaScript事件循环详解

JavaScript事件循环详解
当同步代码执行完,会清空微任务队列,然后才会执行宏任务队列,见下图,继续执行

JavaScript事件循环详解

JavaScript事件循环详解

JavaScript事件循环详解

JavaScript事件循环详解

执行栈

执行栈是一个栈的数据结构,当我们运行单层函数时,执行栈执行的函数进栈后,会出栈销毁然后下一个进栈下一个出栈,当有函数嵌套调用的时候栈中就会堆积栈帧


function sixth() { }
function fifth() { sixth() }
function fourth() { fifth() }
function third() { fourth() }
function second() { third() }
function first() { second() }

JavaScript事件循环详解

关于递归

递归函数是项目开发时经常涉及到场景。在未知尝试的树形结构,或其他合适的场景中使用递归。递归的风险问题:如果发解了执行栈的执行逻辑后,递归函数就可以看成是在一个函数中嵌套n层执行,那么在执行过程中会触发大量的栈帧堆积,如果处理的数据过大,会导致执行栈的高度不够放置新的栈帧,而造成栈溢出的错误。

如何跨越递归限制

var i = 0
function task() {
  let index = i++
  console.log(`递归了${index}次`)
  // task()
  setTimeout(tack,0)// 让递归正常出栈
  console.log(`递归了${index}次,完成`)
}
task()

如何能通过技术手段跨越递归的限制。可以将代码做如下更改,这样就不会出现递归问题了。

JavaScript事件循环详解
有了异步任务之后递归就不会叠加栈帧了,因为放入工作线程之后该函数就结束了,可以出栈销毁,那么在执行栈中就永远只有一个任务在运行,这样就防止了栈帧的无限叠加,从而解决了无限递归的问题,不过异步递归的过程是无法保证速度的,在实际的工作场景中,如果考虑性能问题,尽量避免递归循环,因为递归循环就算控制在有限栈帧的叠加,其性能也远远不及指针循环。

4、宏任务和微任务

任务队列的数据结构是队列结构。所有除同步任务外的代码都会在工作线程中,按照他到达的时间节点有序的进入任务队列,而且任务队列中异步任务又分为【宏任务】和【微任务】。

宏任务

宏任务是JavaScript中最原始的异步任务,包括setTimeout、setInterVal、AJAX等,在代码执行环境中按照同步代码的顺序,逐个进入工作线程挂起,再按照异步任务到达的时间节点,逐个进入异步任务队列,最终按照队列中的顺序进入函数执行栈进行执行。

微任务

微任务是随着ECMA标准升级提出的新的异步任务,微任务在异步任务队列的基础上增加了【微任务】的概念,每个宏任务执行前,程序会先检测代码中是否有当次事件循环未执行的微任务,优先清空本次的微任务后,再执行下一个宏任务,每一个宏任务内部可注册当次任务的微任务队列,再下一个宏任务执行前运行,微任务也是按照进入队列的顺序执行的。

/*宏任务 微任务
 * 同一作用域下:同步代码>微任务>宏任务
 */
var a = '我是同步代码'
setTimeout(function () {
  // 同一作用域下:同步代码>微任务>宏任务
  Promise.resolve().then(function () {
    console.log('Macro1-Micro1:我是第一个宏任务中的微任务')
  })
  console.log('Macro1:我是宏任务1')
  setTimeout(function () {
    console.log('Macro1-Macro1:我是第一个宏任务中的宏任务')
  })
})
setTimeout(function () {
  console.log('Macro2:我是宏任务2')
})
Promise.resolve().then(function () {
  console.log('Micro0:我是微任务0')
})
console.log(a)

Promise

Promise是(同步执行),但Promise 的回调函数属于异步任务,会在同步任务之后执行(比如说 then、 catch 、finally)

 new Promise(function(res,rej){
    console.log('AAA')
    res('CCC')
    console.log('BBB')
  }).then(res=>{
    console.log(res)
  })
  console.log('A') 
// 执行顺序:AAA>BBB>A>CCC