本文已参与「新人创作礼」活动,一起开启创作之路。
做为一个前端开发,要想深入学习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代码运行的过程中实际执行程序时,同时只存在一个活动线程,这里实现同步异步就是靠多线程切换的形式来实现的。
所以通常我们将上面的细分线程归纳为下列两条线程:
- 【主线程】:这个线程用来执行页面的渲染,JavaScript代码的运行,事件的触发等等
- 【工作线程】:这个线程是在幕后工作的,用来处理异步任务的执行来实现非阻塞的运行模式
3、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,同步代码,直接放入执行栈中执行,并出栈
当同步代码执行完,会清空微任务队列,然后才会执行宏任务队列,见下图,继续执行
执行栈
执行栈是一个栈的数据结构,当我们运行单层函数时,执行栈执行的函数进栈后,会出栈销毁然后下一个进栈下一个出栈,当有函数嵌套调用的时候栈中就会堆积栈帧
function sixth() { }
function fifth() { sixth() }
function fourth() { fifth() }
function third() { fourth() }
function second() { third() }
function first() { second() }
关于递归
递归函数是项目开发时经常涉及到场景。在未知尝试的树形结构,或其他合适的场景中使用递归。递归的风险问题:如果发解了执行栈的执行逻辑后,递归函数就可以看成是在一个函数中嵌套n层执行,那么在执行过程中会触发大量的栈帧堆积,如果处理的数据过大,会导致执行栈的高度不够放置新的栈帧,而造成栈溢出的错误。
如何跨越递归限制
var i = 0
function task() {
let index = i++
console.log(`递归了${index}次`)
// task()
setTimeout(tack,0)// 让递归正常出栈
console.log(`递归了${index}次,完成`)
}
task()
如何能通过技术手段跨越递归的限制。可以将代码做如下更改,这样就不会出现递归问题了。
有了异步任务之后递归就不会叠加栈帧了,因为放入工作线程之后该函数就结束了,可以出栈销毁,那么在执行栈中就永远只有一个任务在运行,这样就防止了栈帧的无限叠加,从而解决了无限递归的问题,不过异步递归的过程是无法保证速度的,在实际的工作场景中,如果考虑性能问题,尽量避免递归循环,因为递归循环就算控制在有限栈帧的叠加,其性能也远远不及指针循环。
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