JavaScript与异步编程

开发 前端
因为 setTimeout 的存在,至少在被 ECMA 标准化的那一刻起,JavaScript 就支持异步编程了。与其他语言的 sleep 不同,setTimeout 是异步的——它不会阻挡当前程序继续往下执行。然而异步编程真正发展壮大,Ajax 的流行功不可没。

JavaScript与异步编程

【引自老帖子的博客】什么是异步(Asynchrony)

按照维基百科上的解释:独立于主控制流之外发生的事件就叫做异步。比如说有一段顺序执行的代码

  1. void function main() { 
  2.   fA(); 
  3.   fB(); 
  4. }(); 

 

fA => fB 是顺序执行的,永远都是 fA 在 fB 的前面执行,他们就是 同步 的关系。加入这时使用 setTimeout 将 fB 延后

  1. void function main() { 
  2.   setTimeout(fA, 1000); 
  3.   fB(); 
  4. }(); 

 

这时,fA 相对于 fB 就是异步的。main 函数只是声明了要在一秒后执行一次 fA,而并没有立刻执行它。这时,fA 的控制流就独立于 main 之外。

JavaScript——天生异步的语言

因为 setTimeout 的存在,至少在被 ECMA 标准化的那一刻起,JavaScript 就支持异步编程了。与其他语言的 sleep 不同,setTimeout 是异步的——它不会阻挡当前程序继续往下执行。

然而异步编程真正发展壮大,Ajax 的流行功不可没。Ajax 中的 A(Asynchronous)真正点到了异步的概念——这还是 IE5、IE6 的时代。

回调函数——异步编程之痛

异步任务执行完毕之后怎样通知开发者呢?回调函数是最朴素的,容易想到的实现方式。于是从异步编程诞生的那一刻起,它就和回调函数绑在了一起。

例如 setTimeout。这个函数会起一个定时器,在超过指定时间后执行指定的函数。比如在一秒后输出数字 1,代码如下:

  1. setTimeout(() => { 
  2.   console.log(1); 
  3. }, 1000); 

 

常规用法。如果需求有变,需要每秒输出一个数字(当然不是用 setInterval),JavaScript 的初学者可能会写出这样的代码:

  1. for (let i = 1; i < 10; ++i) { 
  2.   setTimeout(() => { // 错误! 
  3.     console.log(i); 
  4.   }, 1000); 

 

执行结果是等待 1 秒后,一次性输出了所有结果。因为这里的循环是同时启了 10 个定时器,每个定时器都等待 1 秒,结果当然是所有定时器在 1 秒后同时超时,触发回调函数。

解法也简单,只需要在前一个定时器超时后再启动另一个定时器,代码如下:

  1. setTimeout(() => { 
  2.   console.log(1); 
  3.   setTimeout(() => { 
  4.     console.log(2); 
  5.     setTimeout(() => { 
  6.       console.log(3); 
  7.       setTimeout(() => { 
  8.         console.log(4); 
  9.         setTimeout(() => { 
  10.           console.log(5); 
  11.           setTimeout(() => { 
  12.             // ... 
  13.           }, 1000); 
  14.         }, 1000); 
  15.       }, 1000) 
  16.     }, 1000) 
  17.   }, 1000) 
  18. }, 1000); 

 

层层嵌套,结果就是这样的漏斗形代码。可能有人想到了新标准中的 Promise,可以改写如下:

  1. function timeout(delay) { 
  2.   return new Promise(resolve => { 
  3.     setTimeout(resolve, delay); 
  4.   }); 
  5.  
  6. timeout(1000).then(() => { 
  7.   console.log(1); 
  8.   return timeout(1000); 
  9. }).then(() => { 
  10.   console.log(2); 
  11.   return timeout(1000); 
  12. }).then(() => { 
  13.   console.log(3); 
  14.   return timeout(1000); 
  15. }).then(() => { 
  16.   console.log(4); 
  17.   return timeout(1000); 
  18. }).then(() => { 
  19.   console.log(5); 
  20.   return timeout(1000); 
  21. }).then(() => { 
  22.   // .. 
  23. }); 

 

漏斗形代码是没了,但代码量本身并没减少多少。Promise 并没能干掉回调函数。

因为回调函数的存在,循环就无法使用。不能循环,那么只能考虑递归了,解法如下:

  1. let i = 1; 
  2. function next() { 
  3.   console.log(i); 
  4.   if (++i < 10) { 
  5.     setTimeout(next, 1000); 
  6.   } 
  7. setTimeout(next, 1000); 

 

注意虽然写法是递归,但由于 next 函数都是由浏览器调用的,所以实际上并没有递归函数的调用栈结构。

Generator——JavaScript 中的半协程

很多语言都引入了协程来简化异步编程,JavaScript 也有类似的概念,叫做 Generator。

MDN 上的解释:Generator 是一种可以中途退出之后重入的函数。他们的函数上下文在每次重入后会被保持。简而言之,Generator 与普通 Function ***的区别就是:Generator 自身保留上次调用的状态。

举个简单的例子:

  1. function *gen() { 
  2.   yield 1; 
  3.   yield 2; 
  4.   return 3; 
  5.  
  6. void function main() { 
  7.   var iter = gen(); 
  8.   console.log(iter.next().value); 
  9.   console.log(iter.next().value); 
  10.   console.log(iter.next().value); 
  11. }(); 

 

代码的执行顺序是这样:

  1. 请求 gen,得到一个迭代器 iter。注意此时并未真正执行 gen 的函数体。
  2. 调用 iter.next(),执行 gen 的函数体。
  3. 遇到 yield 1,将 1 返回,iter.next() 的返回值即为 { done: false, value: 1 },输出 1
  4. 调用 iter.next()。从上次 yield 出去的地方继续往下执行 gen。
  5. 遇到 yield 2,将 2 返回,iter.next() 的返回值即为 { done: false, value: 2 },输出 2
  6. 调用 iter.next()。从上次 yield 出去的地方继续往下执行 gen。
  7. 遇到 return 3,将 3 返回,return 表示整个函数已经执行完毕。iter.next() 的返回值即为 { done: true, value: 3 },输出 3

调用 Generator 函数只会返回一个迭代器,当用户主动调用了 iter.next() 后,这个 Generator 函数才会真正执行。

你可以使用 for ... of 遍历一个 iterator,例如

  1. for (var i of gen()) { 
  2.   console.log(i); 

 

输出 1 2,*** return 3 的结果不算在内。想用 Generator 的各项生成一个数组也很简单,Array.from(gen()) 或直接用 [...gen()] 即可,生成 [1, 2] 同样不包含***的 return 3。

Generator 是异步的吗

Generator 也叫半协程(semicoroutine),自然与异步关系匪浅。那么 Generator 是异步的吗?

既是也不是。前面提到,异步是相对的,例如上面的例子

  1. function *gen() { 
  2.   yield 1; 
  3.   yield 2; 
  4.   return 3; 
  5.  
  6. void function main() { 
  7.   var iter = gen(); 
  8.   console.log(iter.next().value); 
  9.   console.log(iter.next().value); 
  10.   console.log(iter.next().value); 
  11. }(); 

 

我们可以很直观的看到,gen 的方法体与 main 的方法体在交替执行,所以可以肯定的说,gen 相对于 main 是异步执行的。然而此段过程中,整个控制流都没有交回给浏览器,所以说 gen 和 main 相对于浏览器是同步执行的。

用 Generator 简化异步代码

回到最初的问题:

  1. for (let i = 0; i < 10; ++i) { 
  2.   setTimeout(() => { 
  3.     console.log(i); 
  4.   }, 1000); 
  5.   // 等待上面 setTimeout 执行完毕 

 

关键在于如何等待前面的 setTimeout 触发回调后再执行下一轮循环。如果使用 Generator,我们可以考虑在 setTimeout 后 yield 出去(控制流返还给浏览器),然后在 setTimeout 触发的回调函数中 next,将控制流交还回给代码,执行下一段循环。

  1. let iter; 
  2.  
  3. function* run() { 
  4.   for (let i = 1; i < 10; ++i) { 
  5.     setTimeout(() => iter.next(), 1000); 
  6.     yield; // 等待上面 setTimeout 执行完毕 
  7.     console.log(i); 
  8.   } 
  9.  
  10. iter = run(); 
  11. iter.next(); 

 

代码的执行顺序是这样:

  • 请求 run,得到一个迭代器 iter。注意此时并未真正执行 run 的函数体。
  • 调用 iter.next(),执行 run 的函数体。
  • 循环开始,i 初始化为 1。
  • 执行 setTimeout,启动一个定时器,回调函数延后 1 秒执行。
  • 遇到 yield(即 yield undefined),控制流返回到***的 iter.next() 之后。因为后面没有其他代码了,浏览器获得控制权,响应用户事件,执行其他异步代码等。
  • 1 秒后,setTimeout 超时,执行回调函数 () => iter.next()。
  • 调用 iter.next()。从上次 yield 出去的地方继续往下执行,即 console.log(i),输出 i 的值。
  • 一次循环结束,i 自增为 2,回到第 4 步继续执行
  • ……

这样即实现了类似同步 sleep 的要求。

async、await——用同步语法写异步代码

上面的代码毕竟需要手工定义迭代器变量,还要手工 next;更重要的是与 setTimeout 紧耦合,无法通用。

我们知道 Promise 是异步编程的未来。能不能把 Promise 和 Generator 结合使用呢?这样考虑的结果就是 async 函数。

用 async 得到代码如下

  1. function timeout(delay) { 
  2.   return new Promise(resolve => { 
  3.     setTimeout(resolve, delay); 
  4.   }); 
  5.  
  6. async function run() { 
  7.   for (let i = 1; i < 10; ++i) { 
  8.     await timeout(1000); 
  9.     console.log(i); 
  10.   } 
  11. run(); 

 

按照 Chrome 的设计文档,async 函数内部就是被编译为 Generator 执行的。run 函数本身会返回一个 Promise,用于使主调函数得知 run 函数什么时候执行完毕。所以 run() 后面也可以 .then(xxx),甚至直接 await run()。

注意有时候我们的确需要几个异步事件并行执行(比如调用两个接口,等两个接口都返回后执行后续代码),这时就不要过度使用 await,例如:

  1. const a = await queryA(); // 等待 queryA 执行完毕后 
  2. const b = await queryB(); // 执行 queryB 
  3. doSomething(a, b); 

 

这时 queryA 和 queryB 就是串行执行的。可以略作修改:

  1. const promiseA = queryA(); // 执行 queryA 
  2. const b = await queryB(); // 执行 queryB 并等待其执行结束。这时同时 queryA 也在执行。 
  3. const a = await promiseA(); // 这时 queryB 已经执行结束。继续等待 queryA 执行结束 
  4. doSomething(a, b); 

 

我个人比较喜欢如下写法:

  1. const [ a, b ] = await Promise.all([ queryA(), queryB() ]); 
  2. doSomething(a, b); 

 

将 await 和 Promise 结合使用,效果更佳!

结束语

如今 async 函数已经被各大主流浏览器实现(除了 IE)。如果要兼容旧版浏览器,可以使用 babel 将其编译为 Generator。如果还要兼容只支持 ES5 的浏览器,还可以继续把 Generator 编译为 ES5。编译后的代码量比较大,小心代码膨胀。

如果是用 node 写 Server,那就不用纠结了直接用就是了。koa 是用 async 是你的好帮手。

责任编辑:庞桂玉 来源: 老帖子的博客
相关推荐

2020-10-15 13:29:57

javascript

2014-05-23 10:12:20

Javascript异步编程

2015-04-22 10:50:18

JavascriptJavascript异

2016-09-07 20:43:36

Javascript异步编程

2013-03-08 09:33:25

JavaScript同步异步

2021-06-02 09:01:19

JavaScript 前端异步编程

2011-11-11 15:47:22

JavaScript

2021-12-10 07:47:30

Javascript异步编程

2017-05-11 20:20:59

JavascriptPromiseWeb

2011-11-10 10:23:56

Jscex

2023-12-04 13:22:00

JavaScript异步编程

2021-06-06 19:51:07

JavaScript异步编程

2011-07-27 14:10:43

javascript

2022-10-31 09:00:24

Promise数组参数

2013-01-07 10:44:00

JavaScriptjQueryJS

2016-10-21 11:04:07

JavaScript异步编程原理解析

2013-04-01 15:38:54

异步编程异步编程模型

2021-11-01 22:36:04

JavaScript

2020-03-23 11:28:56

PythonJavaScript技术

2011-11-16 13:22:38

Jscex
点赞
收藏

51CTO技术栈公众号