刚开始使用JS异步的时候,有这样的疑问:JS不是单线程的吗?为什么会有异步机制?但是如果没有异步机制,定时器又是怎样工作的?HTTP请求又是怎样进行的?
要理解JS的异步机制,就要先理解浏览器的事件处理方式,因此我首先去了解了一些浏览器的相关实现,之后整理了异步的几种处理方式。
本文结构:
-
JS的异步机制
- 什么是同步,什么是异步
- JS的单线程和浏览器的多线程
- 事件循环(event loop)
- 任务队列(task)
- micro task
- event loop的处理过程
-
异步的几种处理方式
- 函数嵌套
- 回调函数
- Promise
一、JS的异步机制
1、什么是同步,什么是异步
一般而言,操作分为发出调用和得到结果两步。发出调用后一直等待,直到拿到结果(这段时间不能做任何事)为同步;发出调用后不等待,继续执行下一个任务,就是异步任务。
为什么要异步?因为在执行一些耗时任务(如ajax请求、事件监听、定时器等)时,如果仍采用同步,浏览器就会停在那里一直等待,造成浏览器假死的现象。所以,浏览器为异步事件开辟了单独的线程来执行。
2、JS的单线程和浏览器的多线程
首先明确两个概念:
- JS的确是单线程的。
- 浏览器是允许多个线程异步执行的,除了JS引擎线程外还有GUI渲染线程、事件触发线程、HTTP请求线程、定时触发线程、下载线程等;其中,JS引擎线程、事件触发线程、GUI渲染线程属于常驻线程。
浏览器的多线程:
- JS引擎是基于事件驱动单线程执行的,JS引擎一直等待着任务队列中任务的到来,然后加以处理(只有当前函数执行栈执行完毕,才会去任务队列中取任务执行)。因此浏览器无论什么时候,始终只有一个JS线程在运行JS程序。
- 事件触发线程,当一个事件被触发时该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理。这些事件可能来自JS引擎当前执行的代码块如setTimeout,也可来自浏览器内核的其他线程如鼠标点击、AJAX异步请求等,但由于JS的单线程特性,这些事件都需要排队等待JS引擎处理。
- GUI渲染线程负责渲染浏览器界面,当界面需要重排、重绘或由于某种操作引发回流时,该线程就会执行。虽说浏览器支持线程异步执行,但是GUI渲染线程和JS引擎是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会保存在一个队列中等到JS引擎空闲时立即为执行。这就是JS阻塞页面加载。我们举个例子来看效果:
document.getElementsByTagName('body')[0].style.background='pink';
for(var i=0;i<100000;i++){
console.log(i)
}
按照代码来看,页面应先变成粉色,再打印出所有i。
现在来进行验证。我们打开任意页面的控制台,输入上述代码。下图为未执行代码时的情况:
执行上述代码中:
我们看到,i已经在打印了,但界面的背景颜色并没有改变。
代码执行完成后,背景颜色变成了粉色,效果如下图:
了解浏览器的线程至关重要。JS之所以有异步机制,正是浏览器的多线程作用的结果。
接下来,我们来看具体是怎样作用的。
3、事件循环(event loop)
事件循环,可以理解为实现异步的一种方式。HTML Standard这样定义——为了协调事件、用户交互、脚本、渲染、网络等,用户代理必须使用本节所述的event loop。
也就是说,其实我们无时无刻不在使用event-loop。触发一个click事件、进行一次ajax请求,背后都有event loop运作。
我们来看MDN的相关文档——并发模型与事件循环:
这张图形象地描述了一段代码中栈、堆、队列的存在和调用方式:
- 栈(Stack)中存储的是同步任务,同步任务是指在主线程上排队执行的任务,如变量和函数的初始化、事件的绑定等可以立即执行、不耗时的任务;
- 堆(Heap)用来存储对象、函数等;
- 队列(Queue),即任务队列,用来存储异步任务,在“4、任务队列(task)”中会详细介绍。
4、任务队列(task)
几个基本概念:
- 一个event loop有一个或多个task队列;
- 当用户代理安排一个任务,必须将该任务增加到相应的event-loop的一个task队列中。
- 每一个task都来源于指定的任务源,比如可以为鼠标、键盘事件提供一个task事件,其他事件又是一个单独的队列。
task也被称为macro task(宏任务)(与之相对的是micro task,在“5、micro task”将作介绍),由指定的任务源去提供任务。
task任务源通常分为四种:DOM操作任务源、用户交互任务源、网络任务源、history traversal任务源。task任务源非常宽泛,比如ajax的onload、click事件,基本上我们绑定的各种事件都是task任务源,另外还有setTimeout、setInterval、setImmediate也是task任务源。
总结来说,macro task的任务源有:
- script(整体代码)
- setTimeout
- setInterval
- setImmediate
- I/O
- UI rendering
5、micro task
micro task(微任务)在最新的标准中也被称为“jobs”。每一个event loop都只有一个micro task队列,一个micro task会被push进micro task队列而非task队列。
通常认为micro task任务源有:
- process.nextTick
- Promises
- Object.observe(已废弃)
- MutationObserver(HTML5新特性)
我们只需知道Promises是属于micro task就可以了。这一点对于Promises的使用至关重要。
6、event loop的处理过程
事件循环的顺序,决定了JS代码的执行顺序。概括起来,event loop的处理过程如下:
- 它从script(整体代码)开始第一次循环,之后全局上下文进入函数调用栈,直到调用栈清空(只剩全局)。
- 执行所有的micro task。
- 执行完micro task队列里的任务,有可能会渲染更新。
- 执行一个最老的macro task。
- 到第2步,一直这样循环下去。
关于浏览器的相关知识就说到这里,在这里只作简要理解,只要知道它大致的工作流程就可以了。推荐一段视频Loupe(全英文)可以让你更加直观地了解以上内容。
二、异步的几种处理方式
通过以上部分,我们了解了异步任务的执行方式。接下来我们以ajax为例,来看异步的几种处理方式。
1、函数嵌套
我们直接发起一段ajax请求:
var xhr=new XMLHttpRequest();
xhr.open('GET','xxx'); //网址自己模拟
xhr.send();
xhr.onreadystatechange=function(){
if(xhr.readyState===4){
if(xhr.status>=200 && xhr.status<400){
console.log('成功:');
console.log(xhr.responseText);
}else if(xhr.status>=400){
console.log('失败!');
}
}
}
然后我们打印出:
现在想对上面调用ajax得到的结果进行一系列操作:
var xhr=new XMLHttpRequest();
xhr.open('GET','xxx'); //网址自己模拟
xhr.send();
xhr.onreadystatechange=function(){
if(xhr.readyState===4){
if(xhr.status>=200 && xhr.status<400){
console.log('成功:');
console.log(xhr.responseText);
//---------------------------------------------
//第一种方法:在这里写接下来需要用xhr.responseText数据的一些代码
// ······
// ······
//----------------------------------------------
// 或者:
//---------------------------------------------
//第二种方法:把接下来需要进行的操作写在函数里,并把xhr.responseText当作参数传入
// success.call(null,xhr.responseText);
//----------------------------------------------
}else if(xhr.status>=400){
console.log('失败!');
}
}
}
上面的第二种方法就是函数嵌套。虽然它把处理的代码写成了函数,与第一种相比确实好了很多,但是,这段ajax请求实际上是可以封装的,因为除了GET、URL、结果处理代码fn是变化的之外,其他的部分都是可以共用的,所以我们自然而然地用到了回调函数。
2、回调函数
我们把以上ajax请求封装成一个函数:
//options为对象,包含了method、url、success等变量
function ajax(options){
let {method,url,success}=options;
var xhr=new XMLHttpRequest();
xhr.open(method,url);
xhr.send();
xhr.onreadystatechange=function(){
if(xhr.readyState===4){
if(xhr.status>=200 && xhr.status<400){
console.log('成功:');
console.log(xhr.responseText);
success && success.call(null,xhr.responseText);
}else if(xhr.status>=400){
console.log('失败!');
}
}
}
}
调用上面的函数:
ajax({
method:'GET',
url:'xxx',
success:function(data){
console.log(data);
//---------------------------------------------
//在这里写接下来需要用xhr.responseText数据的一些代码
// ······
//----------------------------------------------
}
});
在上面的代码中,success就是回调函数。ajax响应成功后,将调用success函数,并进行一系列的操作,ajax()函数和success函数实际上是分离开的。
那如果在得到第一次ajax的success结果后,我们还要多次进行ajax请求,并继续得到其success结果呢?我们这样做:
ajax({
method:'GET',
url:'xxx',
success:function(data){
console.log(data);
//---------------------------------------------
//在这里写接下来需要用data数据的一些代码
ajax({
method:'GET',
url:'yyy',
success:function(data){
console.log(data);
//---------------------------------------------
//在这里写接下来需要用data数据的一些代码
ajax({
method:'GET',
url:'zzz',
success:function(data){
console.log(data);
//---------------------------------------------
//在这里写接下来需要用data数据的一些代码
// ······
//----------------------------------------------
}
});
//----------------------------------------------
}
});
//----------------------------------------------
}
});
这就是多层回调,但是这种方式有一个很明显的缺点,就是会产生“回调地狱”,代码也是相当的不易读,于是,我们开始使用promise。
3、Promise
关于Promise,我在博客Promise整理中整理了关于Promise的状态、Promise.resolve、Promise.reject、Promise.all、Promise.race等相关知识,此处不再赘述。
1)、自己实现一个Promise
只需对上面的ajax函数做简单修改:
//options为对象,包含了method、url、success等变量
function ajax(options){
return new Promise(function(resolve,reject){
let {method,url,success}=options;
var xhr=new XMLHttpRequest();
xhr.open(method,url);
xhr.send();
xhr.onreadystatechange=function(){
if(xhr.readyState===4){
if(xhr.status>=200 && xhr.status<400){
console.log('成功:');
console.log(xhr.responseText);
resolve.call(null,xhr.responseText);
}else if(xhr.status>=400){
reject.call(bull,xhr);
}
}
});
}
通过以上代码,我们即可链式调用Promise:
ajax({method:'GET',url:xxx}).then(successFn1,errorFn1).then(successFn2,errorFn2);
2)、Promise的then方法和setTimeout里的方法谁先执行?
举个例子:
Promise.resolve().then(function promise1 () {
console.log('promise1');
})
setTimeout(function setTimeout1 (){
console.log('setTimeout1')
Promise.resolve().then(function promise2 () {
console.log('promise2');
})
}, 0)
setTimeout(function setTimeout2 (){
console.log('setTimeout2')
}, 0)
结果:
如果你理解了博客上半部分讲的macro task、micro task和event loop的相关知识,这个答案应该是没有悬念的。
关于JS的异步机制,就先整理到这里。由于个人水平有限,博客错误之处,烦请指正!
参考资料:
1、并发模型与事件循环
2、javascript的单线程事件循环及多线程介绍
3、从event loop规范探究javaScript异步及浏览器更新渲染时机
4、JavaScript单线程和异步机制
5、从Promise来看JavaScript中的Event Loop、Tasks和Microtasks