JavaScript的异步进化

JavaScript的异步进化

同步

便是在同一时间内,只能进行一个操作。

若有多个操作需要执行,则按顺序排列,前一个操作执行完成,再后一个操作接着执行。

稳定,安全,效率较低。

异步

在js中,异步并不需要等待当前任务的结束,便可以开始下一个任务,再利用之前任务的回调,继续执行之前任务。

快速,效率高,但如果要在异步操作中对同一个变量进行操作时,需要非常小心。


JS中的同步异步

JS是单线程的,代码流是从上而下的执行。由一个主线程负责,此外还存在一个任务队列

JS中的异步几乎都是通过回调函数实现,通过任务队列,在主线程执行完所有的任务(同步操作)完成后,便会轮询(Event Loop)任务队列,并将任务队列中的任务(回调函数)取出执行。

虽然JS是单线程的但是浏览器的内核是多线程的,在浏览器的内核中不同的异步操作由不同的浏览器内核模块调度执行,异步操作会将相关回调添加到任务队列中。而不同的异步操作添加到任务队列的时机也不同,如 onclick, setTimeout, ajax 处理的方式都不同,这些异步操作是由浏览器内核的 webcore 来执行的,webcore 包含下图中的3种 webAPI,分别是 DOM Binding、network、timer模块。
B26QYABRUGGS64VFL}0_BC1.png

如:示例1

由于使用的是console来展示结果, 需要打开浏览器的控制台,再切换到result页面才会执行代码以此看到结果,后面的都是如此。

  • 若按一般思维,会先等待四秒log(1),再等待两秒log(2), 最后才是log(3)。

  • 实际情况却是,首先log(3),然后等待两秒log(2),再等待两秒(虽然第二个定时器delay为4s,不过这4s中有2s是与第一个定时器并行的),才是log(1)。

为什么会是这样的情况,一步一步的剖析吧。

  1. 首先,声明赋值了一个变量a的值为1。

  2. 遇到一个定时器,开始计算delay

  3. 遇到一个定时器,开始计算delay

  4. 最后遇到一个console.log(3),直接输出。主线程所有的任务(同步)操作执行完成,轮询任务队列

  5. 2s定时器的delay满足,推入任务队列。

  6. 4s定时器的delay满足,推入任务队列。

tips:轮询不会只执行一次,顾名思义轮流询问访问,按照一定的频率或者满足条件访问某种活动
每当主线程的所有任务执行完成,就会去轮询任务队列。这个过程会不断重复(该过程又被称为事件轮询)。

上面的代码,第五步定时器被推入任务队列时,主线程中的任务早已执行完毕,任务队列早就处于被轮询的状态了。所以一旦被推入任务队列,便被取出执行。第六步一样。

注意,js解释器的效率是极其快的,从开始到主线程中的任务执行完毕,也就几毫秒的时间。

也就是说,若主线程中的任务需要足够长的时间(可以被我们人类感受到),那么我们便可以感知得到任务队列中任务执行的推迟。

下面的代码我刻意的设置了一个loop以阻塞主线程,大约需要十秒左右主线程中的任务才会执行完毕。

若有兴趣,可以尝试以下代码。

(这个loop的设置,小于这个上限达不到效果,大于的话浏览器可能崩溃)

如:示例2

请耐心等待十秒左右

可以看出定时器的回调函数执行时间并不是设置的delay,于是我们发现当主线程的所需运行时间远远大于定时器的delay时,即使delay已经满足了,回调被推入任务队列,也要等到主线程中的所有任务完成,回调才会执行。

再次验证了必须得等到主线程的所有任务完成,任务队列才能开始被轮询,只有任务队列被轮询,任务队列中的任务(回调)才能有机会被取出执行。

即使loop相同,每次的到达时间也不尽相同,原因很多,如JS解释器解析代码的效率, 运行的环境,平台。

因此,假如将一个很费时的操作放入主线程,那么后果可能是会造成代码执行的阻塞,页面的无响应。

亦因此js推荐将所有的可能费时的操作放入任务队列而不是主线程中,主线程的代码执行速度才会如此之快。

示例3

页面可能一直处于繁忙未响应状态,甚至崩溃,连第一个log(在代码顺序中是最后一个)都出不来了。

当然,实际开发中是不可能有这么大的loop的。

如果再把这个loop放入任务队列中,第一个log便可以出来了(因为第一个log在主线程中,并没有被阻塞)

但是任务队列可能就阻塞了,因为这个loop实在是太耗费性能了,而系统分配给浏览器的硬件资源是远不能胜任的。

总结

  • JS是'单线程'的。

  • JS的代码流从上至下的执行,在遇到异步代码时,需要满足条件才能将回调推入任务队列。

  • 只有当主线程中的任务都完成后,任务队列才能开始被轮询


进化1

js异步实现,多是利用回调函数。

  1. 定时器触发时间到达,回调推入任务队列。

  2. ajax 请求响应返回,回调推入任务队列。

  3. 事件绑定,事件触发,回调推入任务队列。

根据业务需求,假如回调函数中又有回调函数,嵌套层级过多,便出现了著名的“回调地狱”

1
2
3
4
5
6
7
8
9
10
11
12
$.ajax({
url: xxx,
...
success: function(){
$.ajax({
...
success: function(){
....
}
})
}
})

将之抽象,例如嵌套四次呢?

1
2
3
4
5
6
7
8
9
10
11
asyncFunc1(opt, (...args1) => {
asyncFunc2(opt, (...args2) => {
asyncFunc3(opt, (...args3) => {
asyncFunc4(opt, (...args4) => {
// some operation
});
});
});
});

虽然我们可以将上述的ajax代码封装成一个函数调用,但本质仍然如上图所述。

随着ES6的发展,出现了promise,Generator(生成器)等回调地狱的解决方案。
换句话说,我们可以使用另一种写法实现异步代码。比之前较为优美。


promise 进化

  1. Promise有三种状态

    • Promise对象代表一个异步操作,三种状态分别是:Pending(进行中),Resolved(已完成,又称Fulfilled)和Rejected(已失败)。

    • 只有异步操作的结果(resolve(), reject()),可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      let promise = new Promise((resolve, reject) => {
      $.ajax(
      url: xxx,
      ...
      success: function(res){
      resolve(res) // 这里resolve
      },
      err : function(err){
      reject(err) // 出错话则reject
      }
      )
      })


      promise.then((res) => {
      $.ajax({
      ... // res形参是接收resolve的传值
      })
      }).catch((err) => {
      .... // err形参是接收reject的传值
      })

Promise构造函数,参数可以是两个函数,resolve与reject,可选,命名也可以自定义只要位置对应。

根据业务需求,是resolve还是reject。

resolve()后,状态改为Resolved,进入promise.then()。

reject(),状态改为Rejected,进入promise.catch()

resolve与reject都是异步的

既然是异步,那么根据同步优先于异步的原则下面的代码结果是多少呢?

then与catch的组合

then与catch的组合写法多种,个人比较推崇这一种。

1
2
3
4
5
6
7
8
9
new Promise((resolve, reject) => {

}).then(() => {

}).catch(() => {

})

// catch后跟在then

实际情况下,catch比较少用。

Promise虽然比普通的回调看起来好了些,但如果嵌套层级过多,会造成链式调用过长,太过抽象,语义不明确。

根据上图,我们可以写成链式调用的代码。

1
2
3
promise.then()
.then()
.then()

为什么要嵌套?

因为回调是异步的,若以同步写法是捕捉不到回调返回的值的,因此促使下一级必须写在上一级回调中才能实现依赖。

可以看看这个

示例5

1
2
3
4
5
6
7
8
9
10
let v1 = '123'
$.ajax({
url: 'http://v3.wufazhuce.com:8000/api/onelist/3528/0?channel=wdj&version=4.0.2&uuid=ffffffff-a90e-706a-63f7-ccf973aae5ee&platform=android', // 公共api
success: (res) => {
v1 = res
console.log('inner step', v1)
}
})
console.log('out step', v1)
... // 对v1的后续操作

这是我之前亲身经历过类似的一个开发情境,代码简化了许多。变量v1要接收ajax响应成功后返回的res,之后再对v1进行处理(其实就是对res的操作)。但我对v1进行后续操作的代码是写在ajax外的。本以为它的值已经不再是’123’而是res了,以致于我最后得到的结果总是与预期大相径庭。console调试,发现v1的值在我进行后续操作时根本就没有发生改变还是’123’,想了想,原来是自己以同步的写法直觉来实现对异步数据的操作。

ajax的回调是异步的,代码流的从上而下执行根本就不会等待success回调后才继续,而是直接执行到外层out step了。所以在我对v1的后续操作前,根本就没有捕获到res,以致于结果与预期的差异。

所以我如果要等到v1 = res后才开始后续操作,那么必须得把后续操作都放在success的回调里进行才能保证代码如预期进行。

也便是经此开始,我才真正对js这门语言的异步机制有了兴趣与一些模糊概念,开始去有意了解它。

tips: 异步代码也存在优先级的情况,promise的优先级高于定时器。

示例6

阮老师的博客:Node 定时器详解


Async Await 进化

await必须只能在async函数体内使用

生成器略过,promise与generator都是过渡。因为ES7有一个生成器Generator的语法糖Async,这被人们称为JS异步编程的最终实现。

对上面的示例5使用async重写,我现在可以把后续操作放在await的下一行开始了。

示例7

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let v1 = '123';

let ajax1 = () => {
return new Promise(resolve => {
$.ajax({
url: 'http://v3.wufazhuce.com:8000/api/onelist/3528/0?channel=wdj&version=4.0.2&uuid=ffffffff-a90e-706a-63f7-ccf973aae5ee&platform=android',
success: res => {
resolve(res)
}
})
})
};

(async () => {
v1 = await ajax1()
console.log(v1)
... // 对v1的后续操作
})()

如果为了简洁美观,还可以再把后续操作封装成一个函数传参调用即可。


嵌套

// 首先我使用一个promise做一个示例8对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let ex = 10;

let pro = new Promise(resolve => {
resolve(++ex)
})

pro
.then(msg => {
return ++msg

/* 等同于
* return new Promise(resolve => {
* resolve(++msg)
* })
*/
})
.then(msg => {
console.log(msg)
})

简单的依赖雏形

  • 上面代码块里第二个then代码块里的msg2(++msg)必须依赖第一个then里的msg(++ex)。

  • 操作都是建立的前一步操作的基础上。这就是一个依赖的雏形。

tips: 自增++自减符–放置在变量的左边或者右边也是有区别的,但在这里没有任何差异。

现在我用async与await来实现

示例9

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let ex = 10;

let step1 = (ex) => {
return new Promise(resolve => {
resolve(++ex)
})
};

let step2 = (ex) => {
return new Promise(resolve => {
resolve(++ex)
})
};

(async () => {
let res = await step2(await step1(ex))
console.log(res)
})()

// 这样的话,流程更加清晰
// let tmp = await step1(ex)
// let res = await step2(tmp)

// 这样看来,用async实现异步代码的依赖根本就无需嵌套了,按同步直觉写法即可。
  • await 一般后跟一个promise对象,否则的话await便没有意义使用了。

  • 当在async函数内遇到await,由于await会阻塞当前所在的作用域(async函数作用域),不会直接进行下一步,一直会在等待resolve()的返回才会继续下面代码的执行。

这里只是告诉具体的流程思路,当然,如果在实际过程中只是对一个值进行两次的自增操作是完全不需要的。把自增操作替换成一个有依赖关系的多个回调呢?

  • async await的异步代码编写方式,被称作js异步的最终解决方案,代表着一种未来的趋势。

  • JS异步写法的不断进化,本质仍是利用回调函数,进化是为了让人们可以同步的写法来实现异步,不仅是为了简化代码编写,还使得代码观感更贴近同步。直观,简洁,易于理解。

文章作者: Luo Jun
文章链接: /2018/06/13/async/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Aning