异步编程方案的演进
程序中现在运行的部分和将来运行的部分就是异步编程的核心
异步编程的演进大致分以下几个时期
- 回调函数时期
- promise 时期
- 生成器 (ES6) + promise 时期
- async/await 时期 (ES7)
你必须要知道的基本概念
异步、并行,并发的区别
- 异步:是关于现在和将来的事件间隙
- 并行:是关于同时完成多个任务的概念
- 并发:是指分别由任务 a 和任务 b 在一段时间内通过任务间的切换完成了这两个任务,单线程事件循环是并发的一种形式
1 | 并发是指两个或者多个事件随着时间发展交替执行,以至于从更高的层次上看是同时在运行(尽管在任意时刻只处理一个事件) |
js 里的完整运行特性
js 从不跨线程共享数据,并且由于 js 单线程的特性,函数块儿中的代码具有完整运行机制,也就是说,一旦 foo()
开始运行,它的所有代码都会在 bar()
中的任意代码运行之前完成
事件循环队列与任务队列的区别
ES6 中,有一个任务队列的概念,它是挂载在事件循环队列的每个 tick
之后的一个队列
js 引擎运行在宿主环境中(浏览器,node 端等),这些环境都有线程的概念,他们都提供了一种机制来处理程序中多个块儿的执行,且执行每块儿时调用 js 引擎,这种机制被称之为事件循环
一旦有事件需要运行,事件循环就会运行,直到队列清空,事件循环的每一轮称为一个 tick,用户交互 IO 和定时器会向事件队列中加入事件,任意时刻,一次只能从队列中处理一个事件,执行事件的时候,可能直接或者间接地引发一个或者多个后续事件
一个比较形象的比喻
- 事件循环队列:类似于一个游乐场游戏,玩过了一个游戏之后,你需要重新到队尾排队才能再玩一次
- 任务队列:玩过了游戏之后,插队接着玩
第一阶段 (回调函数时期)
任何时候,只要把一段代码包装成一个函数,并指定它在响应某个事件时执行,你就是在代码中创建了一个将来执行的块儿,也由此在这个程序中引入了异步机制。
回调实现异步的特性
- 回调函数是
js
异步的基本单元,但是随着 js 越来越成熟,对于异步编程的发展,回调已经不够用了以至于产生可怕的回调地狱,嵌套函数存在耦合性,一大有所改动就会牵一发而动全身,而且嵌套过多导致错误难以处理 - 回调表达异步流程的方式是非线性的,非顺序的,这使得正确推理这样儿的代码难度很大,难以理解,我们需要一种更同步更顺序更阻塞的方式来表达异步,就像我们的大脑一样。
- 回调会受到控制反转的影响,因为回调函数中把控制权交给第三方比如
ajax()
,会造成一系列麻烦的信任问题
可能产生的信任问题
1 | 1. 调用回调过早 |
小结
我们需要一个通用的方案来解决这些信任问题,不管我们创建多少回调,这一方案都可以复用,且没有重复代码的开销 引出了 Promise
第二阶段 (Promise 时期)
直到 ES6 Promise
的引入 JS 才真正有内建的异步概念
我们开篇就了解到了 异步编程分现在运行部分 和将来运行部分的概念
- 回调函数的模式是关于如何处理将来值
Promise
是把现在和将来归一化了 把他们都变成了将来,也就是说 它把所有的操作都变成了异步的
Promise 的特点
- 我们通过某种方式在函数完成时候得到通知,以便我们可以继续下一步
- 类似于事件订阅 我们不需要关注谁订阅了这些事件,实现了关注点分离
- Promise 封装了依赖时间的状态 - 它等待底层值的完成或者拒绝 所以 promise 本身是与时间无关的
- 它可以按照可预测的方式组合 而不用关心底层代码如何结束
- 一旦
promise
决议,它就永远保持在这个状态 - promise 是一种封装和组合未来值的易于复用的机制
Promise 基本用法
1 | var p = new Promise(function(resolve,reject){ |
实例的调用
1 |
|
如何确定某个值是不是真正的 promise(或者说 thenable)
利用鸭子模型
如果一个函数或对象具有.then () 方法,我们认为这样儿的值就是 Promise
一致的 thenable
所以不要给函数或者对象添加.then
方法,否则这个值就会误认为是一个 thenable
导致难以追踪的 bug
既然我们已经知道亟待解决的问题,把回调的缺陷解决了,否则引入 Promise
没有任何意义
解决控制反转问题
之前 我们用回调函数封装程序中的代码,然后将其交给第三方等(比如 ajax),接着期待其能调用回调实现功能
现在我们要能够把控制反转再反转回来
我们希望第三方给我们提供其任务何时结束的能力,然后由我们自己的代码来决定下一步做什么
老实说 绝大多数 JS/DOM 新增的异步 API 都是基于 Promise 构建的
解决信任问题
promise 解决调用过早
对一个 promise
调用 then 的时候,即使这个 promise
已经决议,提供给 then()
的回调总会被异步调用
promise 解决调用过晚
promise
对象创建 resolve
或者 reject
时,这个 promise 的 then 注册的观察回调就会被自动调度,可以确信,这些被调度的回调在下一个异步事件点上依次被立即调用,这些回调中的任意一个都无法影响或延误对其他回调的调用,这是 promise 的运作方式
回调未调用
没有任何东西(甚至是 js 错误)能阻止 promise 向你通知他的决议,promise 在决议时总是会调用其中一个
如果 promise 本身永远不被决议。promise 也提供了解决方案
1 | 用于一个超时的promise |
调用次数过多或过少
promise 定义方式使得它只能被决议一次
如果试图调用多次或者 resolve 和 reject 都调用,那么这个 promise 只接受第一个决议,并默默的忽略任何后续调用
当然了,如果你把同一个回调注册了不止一次 (p.then (f);p.then (f)) 那么它被调用的次数就会和注册次数相等
未能传递参数 / 环境值
promise 至多只能有一个决议值(完成或拒绝)
如果使用多个参数调用 resolve 或者 reject 第一个参数之后的所有参数都会默默忽略
如果要传递多个值,就必须把他们封装在单个值中传递 比如一个数组或者对象
吞掉错误或者异常
如果在 Promise
的创建过程中或者查看决议结果过程中的任何时间点上出现了 js 的异常错误,那么这个异常就会被捕捉,并且会使这个 Promise
拒绝 reject
promise 甚至把 js 的异常也变成了异步行为,进而极大降低了静态条件出现的可能
但是如果 promise 完成后的回调中出现了 js 异常
因为 p.then () 本身返回了另外一个 promise 正是这个 promise (下一个 promise) 将会因 TypeError 异常而被拒绝
注意:为什么它不是简单的调用我们的错误处理函数呢?
如果这样儿的话就违背了 promise 的基本原则:promise 一旦决议就不可改变
也会造成有些回调会调用,有些回调不会调用情况会非常不透明
是可信任的 Promise
promise 并没有完全摆脱回调,他们只是改变了传递回调的位置
如果你向
promise.resolve()
传递一个非promise
就会得到用这个值填充的 Promise如果你向 promise.resolve () 传递一个真正的 promise 就会返回同一个 promise
promise.resolve()
可以接受任何thenable
,得到的是一个真正的 Promise 是一个可信任的值,如果你传入的已经是真的 Promise 那么就更值得信任了对于用 promise.resolve () 为所有函数的返回值(不管是不是 thenable)都封装一层,这样儿做很容易把函数调用规范为定义良好的异步任务
Promise
这种模式通过可信任的语义把回调当参数传递,使得这种行为更加可靠合理,通过把回调的控制反转回来,我们把控制权放在了一个可信任的系统,这种系统的设计目的就是为了使得异步编码更清晰
以上 promise 解决了回调函数的致命问题
接下来 我们将展示基于 promise 的链式流作用
Promise 的链式流
Promise 并不是一个单步遵循 this-then-that
操作的机制,我们可以将多个 Promise 链接在一起表示一系列异步步骤
这种方式实现的有以下特性
- 每次你对 promise 调用 then () 它会创建并返回一个新的 promise, 我们可以将其链接起来
- 不管从 then () 调用的完成回调(第一个参数)返回的值是什么,他都会被自动设置为被链接 promise(第一点中的)的完成
- 调用 promise 的 then () 会自动创建一个新的 promise 从调用返回
- 在完成或拒绝处理函数内部,如果返回一个值或者抛出一个异常。新返回的 promise(可链接的)就相应的决议
- 如果返回或拒绝处理函数返回一个 promise, 它将会被展开,这样儿一来,不管它的决议值是什么,都会成为当前 then () 返回的链接 promise 的决议值
如下
1 | var p = Promise.resolve(21); |
第一个 then 就是异步序列的第一步
第二个 then 是第二步,只要保证把先前的 then (..) 连到自动创建的每一个 promise 即可
在这个 demo 中 我们用了立即返回的 return 语句
但是我们如果需要步骤二等待步骤一异步来完成一些事情怎么办?
也就是说我们想要使 promise 序列真正能够在每一步有异步能力?
1 | 我们可以给promise.resolve()传递非(最终值)即 **真正的promise或thenable**,Promise会直接返回真正的promise 或展开接收到的thenable值,并在持续展开`thanable`的同时递归前进 |
如下
1 | var p = Promise.resolve(21); |
我们把 42 封装到了返回的 promise 中,但是它仍然会被展开并最终成为链接的 promise 的决议,因此第二个.then 函数中的到的仍然是 42
此时,如果向封装的 promise 中引入异步,仍然会正常工作
1 | var p = Promise.resolve(21); |
完美 我们可以实现一系列个异步步骤
在这些例子中,一步一步传递的值是可选的,不传的话就是隐式返回 undefined
并且这些 Promise
仍然会以同样的方式链接到一起 每一个 Promise
的决议就成了继续下一个步骤的信号
存在默认的 resolve 和 reject 回调
默认的 reject
如果你调用.then () 函数并且只传入一个完成处理函数,一个默认拒绝处理函数就会顶替上来,默认拒绝处理函数只是把错误重新抛出,这使得错误可以继续沿着 Promise 链传播下去,直到遇到显式定义的拒绝处理函数
1 | var p = new Promise(function(resolve,reject){ |
默认的 reject
1 | var p = Promise.resolve(2) |
默认的完成处理函数只是把接受到的任何传入值传递给下一个步骤的 promise
而已
关于错误处理
之前同步的错误处理
我们通常使用 try catch 处理异常
但是它只能是同步的,无法用于异步代码模式
即使你在异步代码 比如 setTimeout 中谁用 try catch 仍然是有问题的,他们采用 error-first 回调风格,无法很好的组合,多级 error-first 回调交织,导致了回调地狱的风险
1 |
|
promise 的错误处理 增加 .catch (..) 方法
.catch (..) 会创建并返回新的 promise,这个 promise 可用于实现 promise 链式流程控制
它没有采用 error-first 回调设计风格,而是使用了分离回调风格一个回调用于完成情况 一个回调用于拒绝情况
我们了解到 在完成或拒绝处理函数内部,如果返回一个值或者抛出一个异常。新返回的 promise(可链接的)就相应的决议,默认情况下,如果你没有捕捉.then
的异常,它假定你想要 promise 状态吞掉所有的错误,如果你忘记查看这个状态,这个错误就会默默地在暗处凋零
所以为了避免被忽略的错误,promise
链的最佳实践就是最后总以一个 catch
结束
诸如
1 | var p = Promise.resolve(42); |
这样儿可以成功的捕获错误
但是 reject()
函数的任何异常都会被作为一个全局未处理的错误抛出
Promise 其他的 API
Promise.done(..)
标示 promise 链的结束
done () 不会创建和返回 promise
Promise.all([])
在异步序列中,任意时刻都只能有一个异步任务正在执行
但是我们如果想要同时执行两个或更多步骤(“并行执行”)的时候,Promise.all([])
的魅力就体现出来
了
Promise.all ([]) 接收一个数组,值可以是(promise,thenable,甚至是立即值),列表里的每个值都要经过 Promise.resolve () 过滤,以确保要等待的是一个真正的 promise,
从返回的 promise 数据也是一个数组,与传入的顺序一致
如果返回的主 promise 在且仅在所有成员 promise 都完成后才会完成 如果有热和一个被拒绝,主 promise 就会立即被拒绝,并丢弃来自其他所有 promise 的结果
所以 要为每个 promise 关联一个错误处理函数
传入空数组时,它会立即完成
Promise.race([])
与 promise.all 类似
它一旦有人黑一个 promise 决议为完成,promise.race 就会完成,一旦有任何一个 promise 决议为拒绝,它就会拒绝
它的完成值是单个消息,并不像 promise.all 那样儿是一个数组,其他的 promise 会被丢弃或者忽略
两者都会创建一个 promise 作为他们的返回值,这个 promise 的决议完全由传入的 promise 数组控制
传入空数组时,它会挂住,且永远不会决议
Promise.finally()
从行为的角度上 有些开发者提出,promise 需要一个 finally()
的回调注册,这个回调在 promise
决议后总是会被调用,并且允许你执行任何必要的清理工作
如下
1 | var p = Promise.resolve(42); |
finally 会创建并返回一个新的 promise 以支持链接继续
new Promise (..) 构造器
构造器 Promise 必须和 new 一起使用,并且必须提供一个函数回调,这个回调是同步的,这个函数接收到两个函数回调,用以支持 promise 的决议
1 | var p = new Promise(function(resolve,reject){ |
创建两种决议的快捷方式
- Promise.resolve// 用于创建一个已完成的 promise
- Promise.reject// 用于拒绝这个 promise
以下是等价的
1 |
|
##promise 的局限性
- 顺序错误处理
1 | 他们链接的方式 promise链中的错误容易被无意中忽略掉 |
- 单一值
1 | promise只能有一个完成值或一个拒绝理由 对于复杂的场景信息有点局限 |
- 单决议
- 无法取消的 promise
1 | 一旦创建了一个promise并为其注册了完成和拒绝处理函数,如果出现某种情况使得这个任务悬而未决的话 你也没有办法从外部停止它的进程 |
- promise 性能
1 | 更多的工作更多的保护 promise与回调相比 会慢一点 |
小结
promise 非常好 他们解决了我们因只用回调的代码而备受困扰的控制反转的问题
它并没有摒弃回调,只是把回调的安排转交给了一个位于我么和其他工具之间的可信任的中介机制
promise 也开始提供(尽管不完美)以顺序的方式表达异步流的一个更好的办法,这有助于我们的大脑更好的几乎是和维护异步 js 代码
#第二阶段 (生成器 Generator 时期)
先来回顾一下 回调表达异步控制流程的两个关键缺陷
- 基于回调的异步不符合大脑对任务步骤的规划方式
- 由于控制反转回调并不是可信任的
然后我们用 Promise
解决了如何把回调的控制反转 反转回来,恢复了可信任性
但是它不会暂停
现在我们寻求一种顺序,看似同步的异步流程控制表达这个 (.then () 钱嵌套多了也受不了),引出了 ES6 生成器的概念
Generator 最大的特点就是可以控制函数的执行
它会创建出一个迭代器
Generator 函数是一个状态机,封装了多个内部状态。
调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是遍历器对象(Iterator Object)
它打破了完整执行 我们不再依赖一个函数一旦开始执行,就会运行到结束,期间不会有其他代码能够打破它并插入其间的假定
ES6 中指定暂停点的语法是 yield
这样礼貌的表达了一种合作式的控制放弃
如下
1 | var x = 1; |
生成器是一种特殊的函数,可以一次或者多次的启动和停止 构建生成器是作为异步流程控制的代码模式的基础构件之一
我们可以看到 我们在暂停之后做了我们想做的操作 还执行了我们想要执行的函数 bar
生成器的输入和输出
var it = foo();
这行只是创建了一个生成器对象,把它赋给了变量 it,用于控制生成器,它是特殊的函数,也具有函数的特质,可以传递参数it.next()
指定生成器从当前位置开始继续运行,停在下一个 yield
处或者直到生成器结束,它调用的结果是一个对象,有一个 value 属性,持有从 *foo
返回的值(如果有的话)也就是说 yield
会导致生成器在执行过程中发送出一个值 (类似 return
)
如下
1 | function *foo(x,y){ |
迭代消息传递
通过 yield
和 next
实现的内建消息输入输出能力
next()
调用要比 yield
语句多一个
因为第一个 next()
总是启动一个生成器,并运行到第一个 yield
处,执行第一次 next
时候,传递参数值会被忽略
第一个 yield 基本上是提出了一个问题:我的值是多少?
谁来回答这一个问题 显然第一个 next 已经执行,因此由第二个 next 调用回答第一个 yield 提出的这个问题
从迭代器的角度看问题
消息是双向传递的 next 也可以向暂停的 yield 表达式发送值 yield 作为一个表达式可以发出消息响应 next 的调用
这里有一个例子能帮助你理解 generator 的执行
1 | //yield基本上就是提出了个问题 我的值等于什么 然后由下一个next()传递的参数回答 |
多个迭代器
每次构建一个迭代器实际上就是隐式构建生成器的一个实例 通过这个迭代器控制的是这个生成器的实例
同一个生成器的多个实例可以同时运行它们甚至可以彼此交互
生产者与迭代器
假设你要产生一系列值,其中每个值都与前面一个有特定的关系 需要一个有状态的生产者能够记住其生成的最后一个值
在此之前 我们可以使用函数闭包来实现
迭代器是一个定义良好的接口,用于从一个生产者一步步得到一系列值,js 迭代器的接口与多数语言类似,就是每次想要从生产者得到下一个值的时候调用 next ()
next 调用返回一个对象有两个属性
1 | done是一个布尔值 表示迭代器的完成状态 |
同步错误处理
类似
1 | try{ |
生成器 yield
暂停的特性意味着我们不仅能从异步函数调用的到看似同步的返回值,还可以同步捕获来自这些异步函数调用的错误 它使得生成器能够捕获错误是一个很大的进步
第四阶段(async/await 时期 ES7)
ES6 中最完美的时间就是生成器(看似同步的代码)和 promise(可信任可组合)的结合
我们不再 yield
出 Promise
而是用 await
等待它决议
它其实就是把前面的经验写进规范
ES7 的 esync 函数对于 ES6 的 generator 函数的改进体现了哪些方面
- 内置执行器
1 | generator函数的执行必须依赖于执行器,而async函数自带执行器,也就是说async函数的执行,与普通函数一模一样,只需要一行 |
- 更好的语义
1 | async和await比起 * 和 yield 语义更清楚了async表示函数有异步操作,await表示紧跟在后面的表达式需要等待的结果 |
- 更广的适用性
1 | co模块规定 yield命令后面只能是thunk函数或者promise对象,而async函数的await后面 可以是promise对象和原始类型的值(数值,布尔等但这等同于同步操作) |
- 返回值是 promise
1 | async函数返回的是promise对象,这比generator函数的返回值是 Iterator对象方便多了,你可以用then方法指定下一步的操作 |
特点
- 一个函数如果加上 async 那么它就会返回 promise
- async 就是将函数返回值使用 Promise.resolve () 包裹了下,和 then 中处理返回值一样
- await 是异步操作,它内部实现了 generator, 如果后来的表达式不返回 promise 的话,它就会被包装成 Promise.resolve (返回值),然后去执行函数的同步代码
看下面这个等价的例子
1 | async function async1(){ |
- async 及 await 配合使用
- await 就是
generator
加上promise
的语法糖 -
async/await
是异步的终极解决方案
优缺点
优势
- 处理 then 的调用链,能更清晰的写出来代码,毕竟写一堆 then 也很
- 能解决回调地狱的问题
缺点
- 因为
await
将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了await
导致性能上的降低
如下
1 | async function test(){ |
#总结
生成器是 ES6 的一个新的函数类型 它并不像普通函数那样总是运行到结束,取而代之的是生成器可以在运行当中(完全保持其状态)暂停 并且将来再从咱题 ing 的地方恢复运行
这种简体的暂停和恢复是合作型的不是抢占型的,这意味着生成器具有独一无二的能力来暂停自身,这是通过关键字
yield
来实现的 不过 只有控制生成器的迭代器具有恢复生成器的能力(next (..))yield/next 不只是控制机制 实际上也是一种双向消息传递机制 yield 表达式备注上是暂停下来等待某个值 接下来的 next 调用则是会向被暂停的 yield 表达式传回一个值
在异步控制流程方便生成器的关键优点是 生成器内部的代码是以自然的同步 / 顺序方式表达任务的一系列步骤,其技巧在于 我们把可能的异步隐藏在关键字 yield 的后面 把异步移动到控制生成器的迭代器的代码部分
换句话说 生成器为异步代码保持了顺序同步 阻塞的代码模式 这使得大脑可以更自然的追踪代码 解决了基于回调的异步的缺陷