JavaScript的异步编程
同步
便是在同一时间内,只能进行一个操作。
若有多个操作需要执行,则按顺序排列,前一个操作执行完成,再后一个操作接着执行。
稳定,安全,效率较低。
异步
同一时间内可以执行多个操作。
快速,但如果要在异步操作中对同一个变量进行操作时,需要非常小心。
JS中的同步异步
JS是单线程的,代码流是从上而下的执行。由一个主线程负责。
当遇到异步代码时,如定时器,ajax,便会将其放入“异步进程池”,待主线程所有任务都执行完毕,异步进程池中的任务满足条件便推入“异步队列”执行。
JS中的异步一般通过回调函数
实现
如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15let a = 1;
(() => {
setTimeout(() => {
console.log('four')
}, 4000);
})();
(() => {
setTimeout(() => {
console.log(++a)
}, 2000);
})();
console.log('this is done');
若按照我们惯常思维,会先等待四秒log ‘four’,再等待两秒log ++a(2), 最后才是“this is done”。
实际情况却是,首先log “this is done”,然后等待两秒log ++a(2),最后才是“four”。
为什么会是这样的情况,回看前面的说明便可以解释了。一步一步的剖析吧。
首先,声明赋值了一个变量a的值为1。
然后遇到一个自执行函数,进入该函数,遇到一个定时器(异步代码),目前不执行,将其放入异步进程池。
又接着一个自执行函数,进入该函数,遇到一个定时器(异步代码),目前不执行,将其放入异步进程池。
最后遇到一个console.log(‘this is done’),直接输出。
主线程中的任务执行完毕,才可以去访问异步进程池中的任务了。有两个,一个延时为4秒,一个为两秒。
当从第一行代码的开始执行到最后的结束,(主线程任务全部执行完毕后)。”再”经过了2s,”才”触发两秒定时器,将之推入异步队列执行。所以 ++a(2) 第二个出现。
第二个 ++a(2)的出现,并不真的刚好是2s,而是2s + X毫秒(主线程所有任务完成时间X)。因为是毫秒,我们人类的感知几乎不到。所以无差别。
以此类推,再过两秒后,便是’four’的出现。
注意,js解释器的效率是极其快的,从开始到主线程中的任务执行完毕,也就几毫秒的时间。
也就是说,若主线程中的任务需要足够长的时间,那么我们便可以感知得到异步任务的推迟。
若有兴趣,可以尝试以下代码
1 | let start = new Date().getTime(); |
因此,假如将一个很费时的操作放入主线程,那么后果可能是会造成代码执行的阻塞,页面的无响应。
亦因此js推荐将所有的可能费时的操作放入异步进程池中,主线程的代码执行速度才会如此之快。
我们可以在上面的代码中放入一个loop。
1 | let start = new Date().getTime() |
页面可能一直处于繁忙未响应状态,连第一个log都出不来了。
当然,实际开发中是不可能有这么大的loop的。
如果再把这个loop放入异步进程中,第一个log便可以出来了。
但是异步线程可能就阻塞了,因为这个loop实在是太耗费性能了,而系统分配给浏览器的硬件资源是远不能胜任的。
实际开发过程中不用担心,可以根据业务需求优化分解的。
1 | 总结:JS是'单线程'的,主线程与异步线程不能同时进行。 |
JS异步编程的初期实现
早先的js异步实现,多是利用回调函数。
定时器触发时间到达,便开始执行第一个参数(回调函数)
但是假如回调函数中又有回调函数,嵌套层级过多,便出现了著名的“回调地狱”1
2
3
4
5
6
7
8
9
10
11
12
13$.ajax({
url: xxx,
...
success: function(){
$.ajax({
url: xxx,
...
success: function(){
....
}
})
}
})
将之抽象1
2
3
4
5
6
7
8
9
10
11asyncFunc1(opt, (...args1) => {
asyncFunc2(opt, (...args2) => {
asyncFunc3(opt, (...args3) => {
asyncFunc4(opt, (...args4) => {
// some operation
});
});
});
});
虽然我们可以将上述的ajax代码封装成一个函数调用,但本质仍然如上图所述。
随着ES6的发展,出现了promise,Generator(生成器)等回调地狱的解决方案。
换句话说,我们可以用它们实现异步编程回调嵌套了。比之前较为优美。
- promise
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21let promise = new Promise((resolve, reject) => {
$.ajax(
url: xxx,
...
success: function(res){
resolve(res)
},
err : function(err){
reject(err)
}
)
})
promise.then((res) => {
$.ajax({
})
}).catch((err) => {
....
})
下面这是promise的通常流程
1 | let promise = new Promise((resolve, reject) => { |
Promise构造函数,参数是一个function,默认两个形参,这两个形参也是函数,resolve与reject。
根据业务需求,是resolve还是reject。resolve()后,便会进入promise.then()
reject(),便会进入promise.catch()。 then与catch的组合写法多种,个人比较推崇这一种。
熟悉流程后,对于上述的ajax代码,我们可以先在Promise 函数体内写第一个ajax,把resolve()放入success中。之后我们可以把第二个ajax放入then中。流程清晰,但依旧摆脱不了多个嵌套的噩梦。
1 | promise.then(...) |
可能在实际业务中,第二级的then需要依赖第一级then的值,而第三级又要依赖第二级的值。以此类推。
为什么要有这么多的嵌套呢,因为回调之间的依赖。促使下一级的回调必须写在上一级回调中才能实现依赖。
生成器略过,因为ES7有一个生成器Generator的语法糖Async,这被人们成为JS异步编程的最终实现。
- Async Await
个人认为async的实现过程特别优美,流程清晰简单,符合直觉。
1 | async function xx(){ |
1 | // 首先我使用一个promise做一个过程示例 |
这个就等同于
1 | promise.then(...) |
现在我用async与await来实现
1 |
|
await 一般后跟一个promise对象。
当在async函数内遇到await,由于它是阻塞的,不会进行下一步,一直会等待resolve()的值。
而resolve需要两秒后才执行。
所以,在等待四秒后,才到达console.log(ex, one, two)
这里只是告诉具体的流程思路,当然,如果在实际过程中只是对一个值进行两次的自增操作是完全不需要的。把自增操作替换成一个有依赖关系的多个回调呢?
JS异步写法的不断进化,只是为了简化代码编写,使代码看起来更贴近同步。直观,简洁。