试想一个场景,我在页面发起了一个ajax请求,请求返回期间,我啥也做不了,只能等着,这显然是无法接受的,所以js是支持异步的,但是明明是单线程的,又如何支持异步呢?
此异步非彼异步
我认为js并未实现真正意义上的异步,只是借助事件循环模型在恰当的时候执行了未来需要执行的代码,这和其他语言的多进程,多线程异步有本质上的差别。
单线程还是多线程
网页有一个主线程负责解析js代码,绑定事件,触发定时器等等。所有的任务都有条不紊的执行,不会出现竞态问题,但是只有主线程是远远不够的,因为我不希望耗时的ajax请求,定时器阻塞当前的页面操作。所以浏览器还有一些其他的线程来协助主线程,比如定时触发器线程,事件触发线程,异步 http 请求线程。
所以我们看到的异步并非是js的异步,而是浏览器的异步,浏览器的多线程让我们的网页有了异步能力。
事件循环模型
事件循环队列
上面我们说了,浏览器的多线程让网页有了异步能力,比如我们看下面的代码:
setTimeout(()=>{
console.log(1)
},1000)
console.log(2)
当调用 setTimeout 的时候,浏览器的计时器线程会异步计时,js会向下执行,在控制台打印2
,1秒后再打印1
。一切顺利,但是有个问题需要我们思考,对于setTimeout
的回调函数是需要回到主线程执行的,浏览器是如何通知js的呢?
计时器线程:嘿,你有个任务到时间了,快来执行了。
主线程:知道了,你给我吧?
计时器线程:怎么给你呢?
我们会和计时器线程有同样的疑问
主线程:放到事件队列就行,我看到就处理了。
如何调度
上一章我们引入了事件循环队列,事件循环队列就是一个先进先出的队列。浏览器的异步线程会在恰当的时候将任务添加到事件队列,比如计时器到时间,ajax请求到结果。那js引擎什么时候会来事件队列取任务执行呢?
答案是执行上下文栈为空的时候。(忘了这个知识点的同学可以参考 js的执行上下文)
当执行上下文为空的时候,js引擎会从事件循环队列依次取任务放到执行上下文栈中执行,伪代码如下:
// eventLoop是一个用作队列的数组
// (先进,先出)
var eventLoop = [ ];
var event;
// “永远”执行
while (true) {
// 一次tick
if (eventLoop.length > 0) {
// 拿到队列中的下一个事件
event = eventLoop.shift();
// 现在,执行下一个事件
try {
event();
}
catch (err) {
reportError(err);
}
}
}
不准的计时器
事件循环模型这种特殊的机制,势必会带来一些不良影响。既然使用了队列,那便需要排队,队列里的任务需要等执行上下文栈清空才能执行,队首的任务也优于队尾的任务执行,但这些任务从业务优先级上讲是一样的,是要同时执行的,所以浏览器是不建议我们创建过于耗时的任务的。我想这和浏览器的使用场景有关,用户能忍受网页的响应时间也是很短的。
事件循环模型解决了异步中现在执行和将来执行场景,但是这里的将来并非是准确的将来。
我们来看一个例子
window.onload=function() {
const begin=new Date().getSeconds()
setTimeout(function() {
console.log(1)
},1000)
while(true) {
const current=new Date().getSeconds()
if(current-begin>2) {
console.log(2)
break
}
}
}
虽然我们的计时器设置延迟时间是1秒,但并非是1秒后执行,而是1秒后写入时间循环队列,等待执行上下文栈为空的时候才会执行。看上去计时器是不准的,延后执行了。
再来看个零延迟的例子
const begin=new Date().getSeconds()
console.log(1)
setTimeout(function() {
console.log(2)
},1000)
setTimeout(function() {
console.log(3)
},0)
while(true) {
const current=new Date().getSeconds()
if(current-begin>2) {
console.log(4)
break
}
}
打印顺序是1,4,3,2,说明 0延迟并非立即执行,还是会写入队列。
异步ajax请求,事件触发同理。
这里有一个点需要注意,通过js模拟事件触发,是同步执行的,并不会进入到事件循环队列
来看一个例子
<button onclick="clickMe()" id="but">点我</button>
function clickMe(){
console.log(4)
}
window.onload=function() {
const begin=new Date().getSeconds()
console.log(1)
setTimeout(function() {
console.log(2)
},1000)
//立即打印 4
document.getElementById('but').click()
while(true) {
const current=new Date().getSeconds()
if(current-begin>2) {
console.log(3)
break
}
}
}
代码执行到document.getElementById('but').click()
会立即打印4,但是在循环等待期间,我们通过鼠标点击按钮,是不会打印4的,直到两秒时间到了,才会依次执行点击回调。
如果一切都这么简单就好了,但实际上要复杂一些,因为还有页面渲染和微任务。
互斥的渲染线程
渲染线程和js引擎线程是互斥的,为了避免渲染页面同时js修改了dom结构,会导致页面混乱。
并非同步渲染
现在广泛使用的屏幕的渲染频率是60赫兹,也就是1秒钟渲染60次。浏览器超过这个频率取渲染页面是没有意义的,我们从屏幕上看不到效果,所以浏览器会选择恰当的时机去渲染页面。
我们来看一个例子
<body>
<div id="in"></div>
</body>
<script>
window.onload=function() {
var then=Date.now()
var i=0
var el=document.getElementById('in')
while(true) {
var now=Date.now()
if(now-then>1000) {
if(i++>=5) {
break;
}
el.innerText+='hello!\n'
console.log(i)
then=now
}
}
}
</script>
我们看到的现象是所有的文本在js脚本执行结束后全部渲染了出来。
原因很简单,js引擎线程和渲染线程是互斥的,js执行完之后才会去渲染页面,其次是下次渲染之前,渲染线程会合并修改的结果,所以我们并未看到5个文本依次呈现。
事件循环队列会阻塞渲染吗
显然是 会,因为事件循环队列里的任务最终也需要js引擎去执行。
来看下面的例子
<body>
<div id="in"></div>
</body>
<script>
window.onload=function() {
const begin=new Date().getSeconds()
console.log(1)
setTimeout(function() {
while(true) {
const current=new Date().getSeconds()
if(current-begin>2) {
console.log(2)
break
}
}
},0)
document.getElementById('in').innerText = 'hello world'
console.log(3)
}
</script>
多刷新几次你会发现,文本内容有时候是打印3
的时候渲染的,有时候是在打印2
的时候渲染的。当执行上下文栈空的时候,要么渲染页面,要么执行循环队列里的任务。所以我们仍然要避免在异步回调里些耗时的操作,这会影响到页面渲染。
微任务 vs 任务
微任务有着比宏任务更高的访问级别,并且向开发者提供了异步开发的能力。
区别
-
当执行来自任务队列中的任务时,在每一次新的事件循环开始迭代的时候运行时都会执行队列中的每个任务。在每次迭代开始之后加入到队列中的任务需要在下一次迭代开始之后才会被执行.
-
每次当一个任务退出且执行上下文为空的时候,微任务队列中的每一个微任务会依次被执行。不同的是它会等到微任务队列为空才会停止执行——即使中途有微任务加入。换句话说,微任务可以添加新的微任务到队列中,并在下一个任务开始执行之前且当前事件循环结束之前执行完所有的微任务。
微任务的执行优先级高于宏任务,并且在微任务执行期间,新增的微任务也会执行,直到微任务队列为空
举个例子
window.onload=function() {
console.log(1)
Promise.resolve().then(() => {
setTimeout(() => {
console.log(2)
})
}).then(() => {
console.log(3)
}).then(() => {
console.log(4)
})
setTimeout(() => {
console.log(5)
},0)
console.log(6)
}
打印顺序是 1,6,3,4,5,2
互斥
同样,微任务,宏任务,页面渲染是互斥的,微任务执行事件过长同样会导致页面卡顿。
举个例子
<body>
<div id="in">
hellohellohellohellohellohellohellohellohello
hellohellohellohellohellohellohellohello
hellohellohellohellohellohellohellohello
</div>
<button onclick="console.log(4)">点我</button>
</body>
<script>
window.onload=function() {
console.log(1)
const begin=new Date().getSeconds()
Promise.resolve().then(() => {
while(true) {
const current=new Date().getSeconds()
if(current-begin>2) {
console.log(2)
break
}
}
})
console.log(3)
}
</script>
控制台在输出2
之前页面是无法选中文本的,点击按钮也不会有交互样式,但是在微任务执行完之后,点击的回调会依次执行(点击事件是异步宏任务)