javascript是单线程的语言,默认情况下一个时间点只能做一件事情,因此引入异步模型
javascript是一门解释性脚本语言,即(边解释边运行)
同步代码
代码会严格按照单线程(从上到下, 从左到右)执行代码逻辑,以此标准来进行代码的解释和运行
const a = 1,
b = 2
let d1 = new Date().getTime(),
d2 = new Date().getTime()
//这段代码会占用执行栈2s
while(d2 - d1 < 2000){
d2 = new Date().getTime()
}
//2s后才会输出结果
console.log(a + b)
上面代码会遵循从上到下,从左到右的执行顺序, d1, d2之间只有毫秒级的差异,因此会进入第6行的while循环,重复给d2赋值,直到满足跳出循环条件(也就是2s后),然后才会执行console.log打印操作这里就是同步代码带来的阻塞
异步执行的代码
JavaScript引擎在工作时,依然是按照自上而下的顺序解释和运行代码。
在解释时,如果遇到需要异步执行的代码,就将其挂起
并略过,继续向下执行同步代码,等当前执行栈同步代码全部执行完毕后,程序将去任务队列拿可以执行的异步任务,将其放入执行栈,依次执行异步代码不会阻塞同步代码的执行,会等待同步代码执行完毕后再执行
const a = 1,
b = 2
let d1 = new Date().getTime(),
d2 = new Date().getTime()
setTimeout(() => {
console.log('异步代码')
},1000)
//会运行2s的同步代码
while(d2 - d1 > 2000){
d2 = new Date().getTime()
}
console.log('同步代码')
运行结果是:
1、遇到setTimeout 异步任务,将其挂起
2、1s过后, SetTimeout结束等待,进入任务队列
3、 2s 过后, while循环结束,继续向下执行
4、打印 '同步代码’
5、去任务队列拿得到结果的异步任务
6、打印 ’异步任务‘
在浏览器中,是以多个线程协助操作来实现 JS 的 单线程异步模型的,具体有以下线程
我们可以发现,在浏览器中运行Javascript的线程并不只有一个
在javascript代码运行过程中实际程序执行是只存在一个活动线程,这里实现同步异步就是靠多线程的切换形式来实现的
所以当我们通常分析时,将上面的细分线程归纳为下列两条线程:
javascript的运行模型
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。
参考:Event Loop- 阮一峰
(1)、如果当前同步任务耗时过久,会影响延时到期定时器的执行
(2)、如果setTimeout存在嵌套调用, 那么系统会设置最短时间间隔为4ms
function cb() { setTimeout(cb, 0)}
setTimeout(cb, 0)
在chrome中,如果定时器被嵌套调用5次以上,系统会判定该方法被阻塞了,之后如果该定时器调用间隔小于4ms,浏览器会将每次调用间隔设置为4ms
(3)、未激活的页面,setTimeout执行最小间隔为1000ms
如果当前标签不是被激活标签,那么定时器的最小间隔就是1000ms
(4)、使用setTimeout 设置的回调函数中的this 不符合直觉
let name = 'global'
let myObj = {
name: 'myObj',
sayName: function(){
console.log(this.name)
}
}
//这里相当于直接放入myObj.sayName这个函数的地址,等到函数执行栈中解析时, this是指向全局对象的
setTimeout(myObj.sayName(), 1000) //'global'
这里打印出的结果是‘global’,这段代码在编译的时候,执行上下文中的this指向全局对象window, 如果是严格模式,会被设置为undefined
如何解决:
//这里解析时,是先找到myObj,然后调用他上面的sayName方法
setTimeout(() => {
myObj.sayName()
}, 1000)
//或:
setTimeout(function(){
myObj.sayName()
},1000)
setTimeout(myObj.sayName.bind(myObj), 1000)
function task1(){
console.log('第一个任务')
}
function task2(){
cosnole.log('第二个任务')
}
function task3(){
console.log('第三个任务')
}
function task4(){
console.log('第四个任务')
}
task1()
setTimeout(task2, 1000)
setTimeout(task3, 500)
task4()
// 输出 一、四、三、二
函数执行栈是一个栈的数据结构,满足先进后出, 当我们运行单层函数时, 执行栈执行的函数进栈后,会出栈销毁然后下一个函数进行进栈出栈, 当遇到函数嵌套时,就会堆积栈帧
function task1(){
console.log('task1执行开始')
task2()
console.log('task2执行结束')
}
function task2(){
console.log('task2执行开始')
task3()
console.log('task3执行结束')
}
function task3(){
console.log('task3执行开始')
}
task1()
console.log('task1执行结束')
执行结果为:
task1执行开始
task2执行开始
task3执行开始
task3执行结束
task2执行结束
task1执行结束
执行流程分析
第一次执行的时候调用task1函数执行到 cosnole.log(‘task1执行开始’),进行打印输出, 接下来遇到task2的调用:
进入task2, 解释道console.log进行打印输出,然后解释道task3()函数的执行,将task3函数放入栈顶
task3中只有一行代码, console.log, 打印完之后将task3进行出栈工作
task2中执行完打印操作后,没有其他代码,同样会进行出栈操作
最后执行栈中只剩task1,在执行完task2() 之后代码, 也会进行出栈
最后,task1执行完毕,退出执行栈,执行完console.log()后,执行栈清空
清除上面的执行栈逻辑后,我们来梳理一下递归函数,递归函数在项目中也是比较常见的。如果递归层级过深,就会触发大量的栈帧堆积,如果处理的数据过多,会导致执行栈啊的高度不够放入新的栈帧,从而造成栈溢出的错误
执行栈的深度根据浏览器和JS引擎有着不同的区别,我们在Chrome浏览器中运行一下代码:
let i = 1
function task(){
i++
console.log(`执行了${i}次`)
task()
}
task()
我们可以看到,在执行了9157次后,收到了栈溢出的提示,也就是说无法在进行深层次的递归了
如何解决这种问题,我们尝试将代码更改一下
let i = 1
function task(){
i++
console.log(`执行了${i}次`)
setTimeout(() => {
task()
}, 0)
}
task()
可以看到,此时我们的递归是可以一直执行的
第二种方法
将递归操作放入任务队列,使我们的执行栈中在执行的只有一条记录
可以看到,加入setTimeout后,task在将 自身放入工作线程后就可以出栈销毁了。 执行栈中永远只有一个任务在运行,就避免了栈帧的无限叠加, 但是我们上面说到过, setTimeout是无法保证代码运行时效的,这样做只是解决了递归深度问题, 这个例子只是为了加深对于事件循环的理解, 真正循环还是要用指针循环
宏任务可以满足我们大部分的日常需求,不过如果有对时间精度要求较高的需求,宏任务就难以实现了
页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。为了直观理解,可以看下面这段代码:
<!DOCTYPE html>
<html>
<body>
<div id='demo'>
<ol>
<li>test</li>
</ol>
</div>
</body>
<script type="text/javascript">
function timerCallback2(){
console.log(2)
}
function timerCallback(){
console.log(1)
setTimeout(timerCallback2,0)
}
setTimeout(timerCallback,0)
</script>
</html>
在这段代码中,我的目的是想通过 setTimeout 来设置两个回调任务,并让它们按照前后顺序来执行,中间也不要再插入其他的任务,因为如果这两个任务的中间插入了其他的任务,就很有可能会影响到第二个定时器的执行时间了。
但实际情况是我们不能控制的,比如在调用 setTimeout 来设置回调任务的间隙,消息队列中就有可能被插入很多系统级的任务。
可以看到,两个定时器之间被插入了 浏览器在处理的任务(渲染工作等),如果插入的任务运行时间较长,就会影响后面任务的执行
因此我们需要用微任务来解决此类问题,微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
异步回调主要有两种方式:
也就是说,每个宏任务都有其关联的微任务队列,也可以理解为:由JS引擎产生的任务是微任务,由宿主API(setTimeout等)产生的任务是宏任务
在浏览器里面,产生微任务的方式有两种:
通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JS 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。
如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。
由此可见,微任务的工作流程为
在JavaScript中,代码执行的顺序是:
宏任务:
# | 浏览器 | Node |
---|---|---|
I/O | ✅ | ✅ |
setTimeout | ✅ | ✅ |
setInterval | ✅ | ✅ |
setImmediate | ❌ | ✅ |
requestAnimationFrame | ✅ | ❌ |
微任务:
# | 浏览器 | Node |
---|---|---|
process.nextTick | ❌ | ✅ |
MutationObserver | ✅ | ❌ |
Promise.resolve()、 Promise.reject() | ✅ | ✅ |
一、
document.addEventListener('click', function(){
Promise.resolve().then(() => {console.log(1)})
console.log(2)
})
document.addEventListener('click', function(){
Promise.resolve().then(() => {console.log(3)})
console.log(4)
})
因为事件监听不会在阻断JS默认代码的执行,所以事件监听也是异步任务,并且是宏任务,所以两个事件相当于按顺序执行的两个宏任务
执行顺序是, 第一个点击事件先进入任务队列,立即执行console.log(2)
, 而Promise.resolve()产生的是微任务,会在下一次宏任务开始前立即执行,在输出4 前会先输出1
因此顺序就是 2 1 4 3