我对异步的好奇心起于学习Promise时老是一知半解,最近在看《你所不知道的js(中)》,书中对异步这部分的讲解还是很到位的,所以结合自己的理解整理一下相关知识点。
本文将从是什么、为什么、怎么样这三步式来讲这个问题。
一、什么是异步?
我们一般喜欢把异步和同步、并行拿出来比较,我以前的理解总是很模糊,总是生硬地记着“同步就是排队执行,异步就是一起执行”,现在一看,当初简直就是傻,所以我们第一步先把这三个概念搞清楚,我不太喜欢看网上有些博客里很含糊地说“xxxx是同步,xxxx是异步”,还有举什么通俗的例子,其实对不懂的人来说还是懵逼。
首先我们要知道这一切的根源都是“Javascript是单线程”,也就是一次只能做一件事,那么为什么是单线程呢?因为js渲染在浏览器上,包含了许多与用户的交互,如果是多线程,那么试想一个场景:一个线程在某个DOM上添加内容,而另一个线程删除这个DOM,那么浏览器要如何反应呢?这就乱套了。
单线程下所有的任务都是需要排队的,而这些任务分为两种:同步任务和异步任务,同步任务就是在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程
、而进入任务队列
(task queue)的任务,只有任务队列
通知主线程
,某个异步任务可以执行了,该任务才会进入主线程
执行。所以说同步执行其实也是一种只有主线程的异步执行。这里有一个视频关于异步操作是如何被执行的,讲得非常好《what the hack is event loop》,我给大家画个图再来理解一下。
这里补充说明下不同的异步操作添加到任务队列的时机不同,如 onclick, setTimeout, ajax 处理的方式都不同,这些异步操作是由浏览器内核的 webcore 来执行的,webcore 包含上面提到的3种 webAPI,分别是 DOM Binding、timer、network模块。
onclick 由浏览器内核的 DOM Binding 模块来处理,当事件触发的时候,回调函数会立即添加到任务队列中。
setTimeout 会由浏览器内核的 timer 模块来进行延时处理,当时间到达的时候,才会将回调函数添加到任务队列中。
ajax 则会由浏览器内核的 network 模块来处理,在网络请求完成返回之后,才将回调添加到任务队列中。
最后再来说下并行,并行是关于能够同时发生的事情,是一种多线程的运行机制,而不管同步异步都是单线程的。
二、为什么要用异步操作
这个很好理解,同步下前一个事件执行完了才能执行后一个事件,那么要是遇到Ajax请求这种耗时很长的,那页面在这段时间就没法操作了,卡在那儿,更有甚者,万一这个请求由于某种原因一直没有完成,那页面就block了,很不友好。
三、如何实现异步
我们可以通过回调函数
、Promise
、生成器
、Async/Await
等来实现异步。
今天我们先说最基础的回调函数处理方法来实现,列举几个大家熟悉的使用场景,比如:ajax请求、IO操作、定时器。
ajax(url, function(){
//这就是回调函数
});
setTimeOut(function(){
//回调函数
}, 1000)
回调本身是比较好用的,但是随着Javascript越来越成熟,对于异步编程领域的发展,回调已经不够用了,体现在以下几点:
1、大脑处理程序是顺序的,对于复杂的回调函数会不易理解,我们需要一种更同步、更顺序的方式来表达异步。
举例说明:
//回调函数实现两数相加
function add(getX, getY, cb){
var x, y;
getX(function(xVal){
x=xVal;
if(y!=undefined){
cb(x+y);
}
});
getY(function(){
y=yVal;
if(x!=undefined){
cb(x+y);
}
});
}
add(fetchX, fetchY, function(sum){
console.log(sum);
})
//Promise实现两数相加
function add(xPromise, yPromise){
return Promise.all([xPromise, yPromise])
.then(function(values){
return value[0] + value[1];
});
}
//fetchX()、fetchY()返回相应值的Promise
add(fetchX(), fetchY())
.then(function(sum){
console.log(sum);
})
只看结构是不是Promise的写法更顺序话一些。
2、回调一般会把控制权交给第三方,从而带来信任问题,比如:
- 调用回调过早
- 调用回调过晚(或未调用)
- 调用回调次数过多或过少
- 未能传递所需的环境和参数
- 吞掉可能出现的错误和异常
而Promise的特性就有效地解决了这些问题,它是如何解决的呢?
调用回调过早
这种顾虑主要是代码是否会引入类Zalgo效应,也就是一个任务有时会同步完地成,而有时会异步地完成,这将导致竟合状态。
Promise被定义为不能受这种顾虑的影响,因为即便是立即完成的Promise(比如 new Promise(function(resolve){ resolve(42); }))也不可能被同步地 监听。也就是说,但你在Promise上调用then(..)的时候,即便这个Promise已经被解析了,你给then(..)提供的回调也将总是被异步地调用。
调用回调过晚
当一个Promise被调用时,这个Promise 上的then注册的回调函数都会在下一个异步时机点上,按顺序地,被立即调用。这些回调中的任意一个都无法影响或延误对其它回调的调用。
举例说明:
p.then( function(){
p.then( function(){
console.log( "C" );
} );
console.log( "A" );
} );
p.then( function(){
console.log( "B" );
} );
// A B C
为什么“C”没有排到“B”的前面?因为因为“C”所处的.then回调函数是在下一个事件循环tick。
回调未调用
这是一个很常见的顾虑。Promise用几种方式解决它。
首先,当Promise被解析后,在代码不出错的情况下它一定会告知你解析结果。如果代码有错误,归类于后面的“吞掉错误或异常”中。
那如果Promise本身不管怎样永远没有被解析呢?那么Promise会用Promise.race来解决。
看代码示例:
// 一个使Promise超时的工具
function timeoutPromise(delay) {
return new Promise( function(resolve,reject){
setTimeout( function(){
reject( "Timeout!" );
}, delay );
} );
}
// 为`foo()`设置一个超时
Promise.race( [
foo(), // 尝试调用`foo()`
timeoutPromise( 3000 ) // 给它3秒钟
] )
.then(
function(){
// `foo(..)`及时地完成了!
},
function(err){
// `foo()`不是被拒绝了,就是它没有及时完成
// 那么可以考察`err`来知道是哪种情况
}
);
调用次数过少或过多
正常是调用一次,“过少”就是未被调用,参考上文;“过多”的情况也很容易理解。Promise的定义方式使得它只能被决议一次,如果出于某种情况决议了多次,Promise也只会接受第一次决议,并忽略后续调用。
未能传递所需的参数/环境值
Promise只会有一个解析结果(完成或拒绝)。如果没有用一个值明确地解析它,它的值就是undefined,就像JS中常见的那样。
吞掉错误或异常
Promise中异常会被捕获,并且使这个Promise被拒绝。
举个例子:
var p = new Promise( function(resolve,reject){
foo.bar(); // `foo`没有定义,所以这是一个错误!
resolve( 42 ); // 永远不会跑到这里 :(
} );
p.then(
function fulfilled(){
// 永远不会跑到这里 :(
},
function rejected(err){
// `err`将是一个来自`foo.bar()`那一行的`TypeError`异常对象
}
);
Promise就先说到这里,关于PromiseAPI及其源码还有生成器、Async/Await 在后续文章中整理报道。
【写得不好的地方请大胆吐槽,非常感谢大家带我进步。】
参考资料:
阮一峰event-loop
王福朋深入理解javascript异步系列一
你不知道的javascript
你不懂JS: 异步与性能 第三章: Promise(上)