ES6学习笔记 • 中
前言
本读书笔记节是针对阮一峰编写的 《ECMAScript 6 入门教程》的个人解读。适合已经掌握
ES5
的读者,用来了解这门语言的最新发展。笔记内容仅包括日常开发中可能会用到的概念,剩余部分请自行阅读。
16. Promise 对象
16.1 事件循环
推荐阅读:js 事件循环机制 event-loop
16.2 Promise 的含义
Promise
是异步编程的一种解决方案,比传统的回调函数和事件,更合理和更强大。它类似于一个容器,里面保存着某个未来才会结束的事件,通常是一个异步操作的结果。
Promise
对象有以下两个特点:
Promise
对象的状态不受外界影响。Promise
对象代表一个异步操作,有三种状态:pending
(进行中)、fulfilled
(已成功)和rejected
(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。Promise
对象一旦状态改变,就不会再变。Promise
对象的状态改变,只有两种可能:从pending
变为fulfilled
和从pending
变为rejected
。只要这两种情况发生,状态就凝固了,这时就称为resolved
(已定型)。
Promise
对象的好处,是可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise
对象提供统一的接口,使得控制异步操作更加容易。
Promise
也有缺点。首先,无法取消Promise
,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise
内部抛出的错误,不会反应到外部。第三,当处于pending
状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
16.3 基本用法
Promise
的构造函数接受一个函数作为参数,该函数的两个参数分别是resolve
和reject
。它们是两个函数,由 JavaScript
引擎提供,不用自己部署。
resolve
函数的作用是,将Promise
对象的状态从“未完成”变为“成功”(即从pending
变为resolved
),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去。reject
函数的作用是,将Promise
对象的状态从“未完成”变为“失败”(即从pending
变为rejected
),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。
const promise = new Promise(function(resolve, reject) { |
Promise
实例生成后可以用then
方法指定resolved
和rejected
的回调函数。这两个函数都是可选的,不一定要提供。它们都接受Promise
对象传出的值作为参数。
promise.then( |
下面是一个Promise
对象的例子。
function timeout(ms) { |
上面代码中,timeout
方法返回一个Promise
实例,表示一段时间以后才会发生的结果。过了指定的时间后,Promise
实例的状态变为resolved
,就会触发then
方法绑定的回调函数。
Promise
新建后就会立即执行。
let promise = new Promise(function (resolve, reject) { |
上面代码中,Promise
新建后立即执行,所以首先输出的是Hello
。然后,then
方法指定的回调函数,将在当前脚本所有同步任务执行完才会执行,所以resolved
最后输出。
如果调用resolve
函数和reject
函数时带有参数,那么它们的参数会被传递给回调函数。
reject
函数的参数通常是Error
对象的实例,表示抛出的错误。resolve
函数的参数除了正常的值以外,还可能是另一个Promise
实例。
const p1 = new Promise(function (resolve, reject) { |
上面代码中,p1
和p2
都是 Promise
的实例,但是p2
的resolve
方法将p1
作为参数,即一个异步操作的结果是返回另一个异步操作。这时p1
的状态就会传递给p2
,此时p1
的状态决定了p2
的状态。
- 如果
p1
的状态是pending
,那么p2
的回调函数就会等待p1
的状态改变。 - 如果
p1
的状态已经是resolved
或者rejected
,那么p2
的回调函数将会立刻执行。
const p1 = new Promise(function (resolve, reject) { |
上面代码中,p1
是一个 Promise
,3
秒之后变为rejected
。p2
的状态在 1
秒之后改变,而且resolve
方法返回的是p1
。
- 由于
p2
返回的是另一个Promise
,导致p2
自己的状态无效了,由p1
的状态决定p2
的状态。所以,后面的then
语句都变成针对后者p1
。又过了2
秒,p1
变为rejected
,导致触发catch
方法指定的回调函数。
注意,调用resolve
或reject
并不会终结 Promise
的参数函数的执行。
new Promise((resolve, reject) => { |
上面代码中,调用resolve(1)
以后,后面的console.log(2)
还是会执行,并且会首先打印出来。这是因为立即 resolved
的 Promise
是在本轮事件循环的末尾执行,总是晚于本轮循环的同步任务。
一般来说,调用resolve
或reject
以后,Promise
的使命就完成了,后继操作应该放到then
方法里面,而不应该直接写在resolve
或reject
的后面。所以,最好在它们前面加上return
语句,这样就不会有意外。
new Promise((resolve, reject) => { |
16.4 then 方法
Promise.then()
方法的作用是为 Promise
实例添加状态改变时的回调函数。then
方法的第一个参数是resolved
状态的回调函数,第二个参数是rejected
状态的回调函数,它们都是可选的。
then
方法返回的是一个新的Promise
实例,因此可以采用链式写法,即then
方法后面再调用另一个then
方法。
getJSON("/posts.json") |
上面的代码使用then
方法,依次指定了两个回调函数。第一个回调函数完成以后,会将返回结果作为参数,传入第二个回调函数。
链式写法的then
,可以指定一组按照次序调用的回调函数。这时,前一个回调函数,有可能返回的还是一个Promise
对象(即有异步操作),这时后一个回调函数,就会等待该Promise
对象的状态发生变化,才会被调用。
getJSON("/post/1.json") |
上面代码中,第一个then
方法指定的回调函数,返回的是另一个Promise
对象。这时,第二个then
方法指定的回调函数,就会等待这个新的Promise
对象状态发生变化。
- 如果变为
resolved
,第二个then
方法就调用第一个回调函数。 - 如果状态变为
rejected
,第二个then
方法就调用第二个回调函数。
如果采用箭头函数,上面的代码可以写得更简洁。
getJSON("/post/1.json") |
16.5 catch 方法
Promise.catch()
方法用于指定发生错误时的回调函数。它返回的是一个新的Promise
对象。
getJSON("/posts.json") |
上面代码中,getJSON()
方法返回一个 Promise
对象。
- 如果该对象状态变为
resolved
,则会调用then()
方法指定的回调函数。 - 如果异步操作抛出错误,状态就会变为
rejected
,就会调用catch()
方法指定的回调函数处理这个错误。
注意,then()
方法指定的回调函数,如果运行中抛出错误,也会被catch()
方法捕获。
下面代码中,promise
抛出一个错误,被catch()
方法指定的回调函数捕获。
const promise = new Promise(function (resolve, reject) { |
下面两种写法与上面是等价的。(比较喜欢try...catch...
的写法)
// 写法一 |
注意,如果Promise
状态已经变成resolved
,再抛出错误是无效的。
const promise = new Promise(function (resolve, reject) { |
上面代码中,Promise
在resolve
语句后面,再抛出错误,并不会被捕获。因为 Promise
的状态一旦改变,就永久保持该状态。
Promise
对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个catch
语句捕获。
getJSON("/post/1.json") |
上面代码中,一共有三个 Promise
对象:一个由getJSON()
产生,两个由then()
产生。它们之中任何一个抛出的错误,都会被最后一个catch()
捕获。
一般来说,不要在then()
方法里面定义 Reject
状态的回调函数,总是使用catch
方法。
// bad |
跟传统的try/catch
代码块不同的是,如果没有使用catch()
方法指定错误处理的回调函数,Promise
对象抛出的错误不会传递到外层代码,即不会有任何反应。
const someAsyncThing = function () { |
上面代码中,someAsyncThing()
函数产生的 Promise
对象,内部有语法错误。运行到这一行时,会打印出错误提示,但是不会终止脚本执行,2
秒之后还是会输出123
。这就是说,Promise
内部的错误不会影响到 Promise
外部的代码,通俗的说法就是“Promise
会吃掉错误”。
所以建议,Promise
对象后面要跟catch()
方法,这样可以处理 Promise
内部发生的错误。注意,catch()
方法返回的是一个新的Promise
对象,因此后面还可以接着调用then()
方法,或者catch()
方法,去处理前一个catch()
方法抛出的错误。
16.6 finally 方法
Promise.finally()
方法用于指定不管Promise
对象最后状态如何,都会执行的操作。
promise |
上面代码中,不管promise
最后的状态,在执行完then
或catch
指定的回调函数以后,都会执行finally
方法指定的回调函数。
下面是一个例子,服务器使用 Promise
处理请求,然后使用finally
方法关掉服务器。
server |
由于finally
方法的回调函数不接受任何参数,这表明finally
方法里面的操作,应该是与状态无关的,不依赖于Promise
的执行结果。
16.7 all 方法
Promise.all()
方法用于将多个Promise
实例,包装成一个新的Promise
实例。
const p = Promise.all([p1, p2, p3]); // 接受一个数组作为参数 |
上面p
的状态由p1
、p2
、p3
决定,分成两种情况:
- 只有
p1
、p2
、p3
的状态都变成fulfilled
,p
的状态才会变成fulfilled
,此时p1
、p2
、p3
的返回值组成一个数组,传递给p
的回调函数。 - 只要
p1
、p2
、p3
之中有一个被rejected
,p
的状态就变成rejected
,此时第一个被reject
的实例的返回值,会传递给p
的回调函数。
// 生成一个Promise对象的数组 |
上面代码中,只有promises
中6
个Promise
实例的状态都变成fulfilled
,或者有一个变为rejected
,才会调用Promise.all()
方法后面的回调函数。
下面是另一个例子。
const databasePromise = connectDatabase(); // 这里应该是返回一个 Promise 实例 |
上面代码中,booksPromise
和userPromise
是两个异步操作,只有等到它们的结果都返回了,才会触发pickTopRecommendations
这个回调函数。
注意,如果作为参数的 Promise
实例,自己定义了catch
方法,那么它一旦被rejected
,并不会触发Promise.all()
的catch
方法。
const p1 = new Promise((resolve, reject) => { |
上面代码中,p1
是resolved
,p2
会被rejected
。但是p2
有自己的catch
方法,返回一个新的 Promise
实例,p2
实际指向的是这个新的实例。该实例执行完catch
方法后,也会变成resolved
,导致Promise.all()
方法参数里面的两个实例都会resolved
,因此会调用then
方法指定的回调函数,而不会调用catch
方法指定的回调函数。
如果p2
没有自己的catch
方法,就会调用Promise.all()
的catch
方法。
16.8 race 方法
Promise.race()
方法同样是将多个 Promise
实例,包装成一个新的 Promise
实例。
const p = Promise.race([p1, p2, p3]); |
上面代码中,只要p1
、p2
、p3
之中有一个实例率先改变状态,p
的状态就跟着改变。那个率先改变的 Promise
实例的返回值,就传递给p
的回调函数。
下面如果指定时间内没有获得结果,就将 Promise
的状态变为reject
,否则变为resolve
。
const p = Promise.race([ |
上面代码中,如果 5
秒之内fetch
方法无法返回结果,变量p
的状态就会变为rejected
,从而触发catch
方法指定的回调函数。
16.9 allSettled 方法
有时候,我们希望等到一组异步操作都结束,不管每一个操作是成功还是失败,再进行下一步操作。为了解决这个问题,ES6
引入了Promise.allSettled()
方法。
Promise.allSettled()
方法接受一个Promise
数组作为参数,并返回一个新的 Promise
对象。只有等到参数数组的所有 Promise
对象都发生状态变更,不管是fulfilled
还是rejected
,返回的 Promise
对象才会发生状态变更。
const promises = [fetch("/api-1"), fetch("/api-2"), fetch("/api-3")]; |
上面示例中,数组promises
包含了三个请求,只有等到这三个请求都结束了,不管请求成功还是失败,removeLoadingIndicator()
才会执行。
Promise.allSettled()
返回的 Promise
实例,状态总是fulfilled
,不会变成rejected
。
const resolved = Promise.resolve(42); |
上面代码中,Promise.allSettled()
的返回值allSettledPromise
,状态只可能变成fulfilled
。它的回调函数接收到的参数是数组results
,对应传入Promise.allSettled()
的数组里面的两个 Promise
对象。
上面results
的每个成员是一个对象,对应异步操作的结果。(重点)
// 异步操作成功时 |
成员对象的status
属性的值只可能是字符串fulfilled
或字符串rejected
,用来区分异步操作是成功还是失败。如果是成功fulfilled
,对象会有value
属性,如果是失败rejected
,会有reason
属性,对应两种状态时前面异步操作的返回值。
16.10 any 方法
Promise.any()
接受一组 Promise
实例作为参数,包装成一个新的 Promise
实例返回。
Promise.any([ |
- 只要参数实例有一个变成
fulfilled
状态,包装实例就会变成fulfilled
状态。 - 如果所有参数实例都变成
rejected
状态,包装实例就会变成rejected
状态。
下面是Promise()
与await
命令结合使用的例子。
const promises = [ |
上面代码中,Promise.any()
方法的参数包含三个 Promise
操作。其中只要有一个变成fulfilled
,Promise.any()
返回的 Promise
对象就变成fulfilled
。如果所有三个操作都变成rejected
,那么await
命令就会抛出错误。
16.11 resolve 方法
有时需要将现有对象转为 Promise
对象,Promise.resolve()
方法就起到这个作用。
(1)参数是一个 thenable 对象
thenable
对象指的是具有then
方法的对象,比如下面这个对象。
let thenable = { |
上面代码中,resolve()
方法会将这个对象转为 Promise
对象,然后立即执行thenable
对象的then()
方法。
(2)参数不是具有 then()方法的对象,或根本就不是对象
如果参数是一个原始值,或者是一个不具有then()
方法的对象,则resolve()
方法返回一个新的 Promise
对象,状态为resolved
。
const p = Promise.resolve("Hello"); |
(3)不带有任何参数
resolve()
方法调用时可以不带参数,直接返回一个resolved
状态的 Promise
对象。
const p = Promise.resolve(); |
立即resolve()
的 Promise
对象,是在本轮“事件循环”的结束时执行,而不是在下一轮“事件循环”的开始时。
setTimeout(function () { |
上面代码中,setTimeout(fn, 0)
在下一轮“事件循环”开始时执行,resolve()
在本轮“事件循环”结束时执行,console.log('one')
则是立即执行,因此最先输出。
16.12 reject 方法
Promise.reject(reason)
方法也会返回一个新的 Promise
实例,该实例的状态为rejected
。
const p = Promise.reject("出错了"); |
Promise.reject()
方法的参数,会原封不动地作为reject
的理由,变成后续方法的参数。
Promise.reject("出错了").catch((e) => { |
17. Iterator 和 for…of 循环
17.1 Iterator 概念
遍历器Iterator
是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator
接口,就可以完成遍历操作,依次处理该数据结构的所有成员。
Iterator
的遍历过程:
- 创建一个指针对象,指向当前数据结构的起始位置。
- 第一次调用指针对象的
next
方法,可以将指针指向数据结构的第一个成员。 - 第二次调用指针对象的
next
方法,指针就指向数据结构的第二个成员。 - 不断调用指针对象的
next
方法,直到它指向数据结构的结束位置。
每一次调用next
方法,都会返回数据结构的当前成员的信息。具体来说,就是返回一个包含value
和done
两个属性的对象。其中,value
属性是当前成员的值,done
属性是一个布尔值,表示遍历是否结束。
const a = [1, 5, 15, 25]; |
ES6
规定,默认的 Iterator
接口部署在数据结构的Symbol.iterator
属性,或者说,一个数据结构只要具有Symbol.iterator
属性,就可以认为是“可遍历的”。
const obj = { |
上面代码中,对象obj
是可遍历的,因为具有Symbol.iterator
属性。执行这个属性,会返回一个遍历器对象。该对象的根本特征就是具有next
方法。每次调用next
方法,都会返回一个代表当前成员的信息对象,具有value
和done
两个属性。
// 上面 iterator 打印出来的 objects |
注意,done
不代表返回的值是否是最后一个,而是表示迭代器是否没有更多的值可以返回。
17.2 for…of 循环
(1)数组,Set 与 Map
ES6
的数组,Set
和 Map
原生具有 Iterator
接口。
const arr = ["red", "green", "blue"]; |
上面代码可以看出,Set
和 Map
遍历的顺序是按照各个成员被添加进数据结构的顺序。
(2)计算生成的数据结构
有些数据结构是在现有数据结构的基础上,计算生成的。比如,ES6
的数组、Set
、Map
都部署了以下三个方法,调用后都返回遍历器对象。
entries()
返回一个遍历器对象,用来遍历[键名, 键值]组成的数组。keys()
返回一个遍历器对象,用来遍历所有的键名。values()
返回一个遍历器对象,用来遍历所有的键值。
let arr = ["a", "b", "c"]; |
(3)类似数组的对象
字符串是一个类似数组的对象,也原生具有 Iterator
接口。
// 字符串 |
(4)对象
for...of
不能直接使用于普通对象上,必须部署 Iterator
接口后才能使用。
- 一种解决方法是,遍历
Object.keys()
生成的对象键名的数组。 - 一种解决方法是,使用
Generator
函数将对象重新包装。
const obj = { a: 1, b: 2, c: 3 }; |
18. Generator 函数
18.1 基本概念
Generator
函数是 ES6
提供的一种异步编程解决方案,语法行为与传统函数完全不同。
- 语法上:
Generator
函数类似一个状态机,封装了多个内部状态。执行Generator
函数会返回一个遍历器对象,可以依次遍历Generator
函数内部的每一个状态。 - 形式上:
Generator
函数是一个普通函数,但是有两个特征。一是,function
关键字与函数名之间有一个星号;二是,函数体内部使用yield
表达式,定义不同的内部状态。
function* hwGenerator() { |
上面代码定义了一个 Generator
函数hwGenerator
,它内部有两个yield
表达式hello
和world
,即该函数有三个状态:hello
,world
和 return
语句。
Generator
函数的调用方法与普通函数一样。不同的是,调用 Generator
函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象。通过调用遍历器对象的next
方法,使得指针移向下一个状态。
每次调用next
方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield
表达式(或return
语句)为止。
hw.next(); |
总结,调用 Generator
函数,返回一个遍历器对象,代表 Generator
函数的内部指针。每次调用遍历器对象的next
方法,就会返回一个有着value
和done
两个属性的对象。value
属性表示当前的内部状态的值,是yield
表达式后面那个表达式的值;done
属性是一个布尔值,表示是否遍历结束。
18.2 yield 表达式
由于 Generator
函数返回的遍历器对象,只有调用next
方法才会遍历下一个内部状态,所以Generator
其实提供了一种可以暂停执行的函数。yield
表达式就是暂停标志。
遍历器对象的next
方法的运行逻辑如下:
- 遇到
yield
表达式,就暂停执行后面的操作,并将紧跟在yield
后面的那个表达式的值,作为返回的对象的value
属性值。 - 下一次调用
next
方法时,再继续往下执行,直到遇到下一个yield
表达式。 - 如果没有再遇到新的
yield
表达式,就一直运行到函数结束,直到return
语句为止,并将return
语句后面的表达式的值,作为返回的对象的value
属性值。 - 如果该函数没有
return
语句,则返回的对象的value
属性值为undefined
。
function* gen() { |
yield
表达式与return
语句既有相似之处,也有区别。相似之处在于,都能返回紧跟在语句后面的那个表达式的值。区别在于每次遇到yield
,函数暂停执行,下一次再从该位置继续向后执行,而return
语句不具备位置记忆的功能。一个函数里面,只能执行一次return
语句,但是可以执行多次yield
表达式。
18.3 与 Iterator 接口的关系
由于 Generator
函数就是遍历器生成函数,因此可以把 Generator
赋值给对象的Symbol.iterator
属性,从而使得该对象具有 Iterator
接口。
let myIterable = {}; |
上面代码中,Generator
函数赋值给Symbol.iterator
属性,从而使得myIterable
对象具有了 Iterator
接口,可以被...
运算符遍历了。
18.4 next 方法的参数
yield
表达式本身没有返回值,或者说总是返回undefined
。next
方法可以带一个参数,该参数就会被当作上一个yield
表达式的返回值。(每一次调用next
方法,都会返回数据结构的当前成员的信息)
function* f() { |
上面代码定义了一个可以无限运行的 Generator
函数f
,如果next
方法没有参数,每次运行到yield
表达式,变量reset
的值总是undefined
。当next
方法带一个参数true
时,变量reset
就被重置为这个参数,因此i
会等于-1
,下一轮循环就会从-1
开始递增。
这个功能有很重要的意义。Generator
函数从暂停状态到恢复运行,它的上下文状态context
是不变的。通过next
方法的参数,就有办法在 Generator
函数开始运行之后,继续向函数体内部注入值。从而在 Generator
函数运行的不同阶段,调整函数行为。
function* foo(x) { |
上面代码中,第二次运行next()
的时候不带参数,导致 y
的值等于2 * undefined
,除以 3
以后变成NaN
,因此返回对象的value
属性也等于NaN
。第三次运行next
方法的时候不带参数,所以z
等于undefined
,返回对象的value
属性等于5 + NaN + undefined
,即NaN
。
如果向next
方法提供参数,返回结果就完全不一样。
let b = foo(5); |
上面代码第一次调用next
方法时,返回x+1
的值6
;第二次调用next
方法,将上一次yield
表达式的值设为12
,因此y
等于24
,返回y / 3
的值8
;第三次调用next
方法,将上一次yield
表达式的值设为13
,因此z
等于13
,这时x
等于5
,y
等于24
,所以return
语句的值等于42
。
注意,由于next
方法的参数表示上一个yield
表达式的返回值,所以在第一次使用next
方法时,传递参数是无效的。
18.5 for…of 循环
for...of
可以自动遍历 Generator
函数生成的Iterator
对象,且不需要调用next
方法。
function* foo() { |
上面的代码可以看出,一旦返回对象的done
属性为true
,for...of
循环就会中止,且不包含该返回对象。所以上面代码的return
语句返回的4
,不包括在for...of
循环之中。
除了for...of
循环以外,扩展运算符、解构赋值和Array.from
方法内部调用的,都是遍历器接口。这意味着,它们都可以将 Generator
函数返回的遍历器对象,作为参数。
function* numbers() { |
18.6 throw 方法
遍历器对象的throw()
方法,可以在函数体外抛出错误,然后在 Generator
函数体内捕获。
let g = function* () { |
上面代码中,遍历器对象i
连续抛出两个错误。第一个错误被 Generator
函数体内的catch
语句捕获。i
第二次抛出错误,由于 Generator
函数内部的catch
语句已经执行过了,不会再捕捉到这个错误了,所以这个错误就被函数体外的catch
语句捕获。
注意,不要混淆遍历器对象的throw
方法和全局的throw
命令。
try { |
上面代码之所以只捕获了a
,是因为函数体外的catch
语句块,捕获了抛出的a
错误以后,就不会再继续try
代码块里面剩余的语句了。
- 如果
Generator
函数内部没有部署try...catch
代码块,那么throw
方法抛出的错误,将被外部try...catch
代码块捕获。 - 如果
Generator
函数内部和外部,都没有部署try...catch
代码块,那么程序将报错,直接中断执行。
throw
方法抛出的错误要被内部捕获,前提是必须至少执行过一次next
方法。同时,throw
方法被捕获后,会附带执行下一条yield
表达式,等同于执行一次next
方法。
let gen = function* gen() { |
上面代码中,g.throw()
方法被捕获以后,自动执行了一次next
方法,所以会打印b
。这里可以看出,只要 Generator
函数内部部署了try...catch
代码块,那么遍历器的throw
方法抛出的错误,不影响下一次遍历。
Generator
函数体外抛出的错误,可以在函数体内捕获;反过来,Generator
函数体内抛出的错误,也可以被函数体外的catch
捕获。
function* foo() { |
上面代码中,第二个next
方法向函数体内传入一个参数 42
,数值是没有toUpperCase
方法的,所以会抛出一个 TypeError
错误,被函数体外的catch
捕获。
18.7 return 方法
遍历器对象的return()
方法,可以返回给定的值,并且终结遍历 Generator
函数。
function* gen() { |
上面代码中,遍历器对象g
调用return()
方法后,返回值的value
属性就是return()
方法的参数foo
。并且,Generator
函数的遍历就终止了,返回值的done
属性为true
。
- 如果
return()
方法调用时,不提供参数,则返回值的value
属性为undefined
。 - 如果
Generator
函数内部有try...finally
代码块,且正在执行try
代码块,那么return()
方法会导致立刻进入finally
代码块。
function* numbers() { |
上面代码中,调用return()
方法后,就开始执行finally
代码块,不执行try
里面剩下的代码了,然后等到finally
代码块执行完,再返回return()
方法指定的返回值。
18.8 方法的共同点
Generator
函数返回的遍历器对象的next()
、throw()
、return()
本质上是同一件事,它们的作用都是让 Generator
函数恢复执行,并且使用不同的语句替换yield
表达式。
next()
方法是将yield
表达式替换成一个值。
const g = function* (x, y) { |
throw()
方法是将yield
表达式替换成一个throw
语句。
gen.throw(new Error("出错了")); // Uncaught Error: 出错了 |
return()
方法是将yield
表达式替换成一个return
语句。
gen.return(2); // Object {value: 2, done: true} |
18.9 yield* 表达式
ES6
提供的yield*
表达式,可以在一个 Generator
函数里面执行另一个 Generator
函数。
let delegatedIterator = (function* () { |
上面代码中,delegatingIterator
是代理者,delegatedIterator
是被代理者。如果被代理的 Generator
函数有return
语句,那么就可以向代理它的 Generator
函数返回数据。
function* genFuncWithReturn() { |
上面代码中,存在两次遍历。
- 第一次是扩展运算符遍历函数
logReturned
返回的遍历器对象。 - 第二次是
yield*
语句遍历函数genFuncWithReturn
返回的遍历器对象。
这两次遍历的效果是叠加的,最终表现为扩展运算符遍历函数genFuncWithReturn
返回的遍历器对象。所以最后的数据表达式得到的值等于[ 'a', 'b' ]
。同时,函数genFuncWithReturn
的return
语句的返回值The result
,会返回给函数logReturned
内部的result
变量。
18.10 作为对象的属性
如果一个对象的属性是 Generator
函数,可以简写成下面的形式。
const obj = { |
它的完整形式如下,与上面的写法是等价的。(详细请见上一章的属性的简洁表示法)
const obj = { |
18.11 含义
(1)Generator 与状态机
Generator
是实现状态机的最佳结构。比如,下面的clock
函数就是一个状态机。
let ticking = true; |
上面代码的clock
函数一共有两种状态Tick
和Tock
,每运行一次,就改变一次状态。这个函数如果用 Generator
实现,就是下面这样。
let clock = function* () { |
上面的 Generator
实现与 ES5
实现对比,可以看到少了用来保存状态的外部变量ticking
,这样就更简洁,更安全,在写法上也更优雅。
(2)Generator 与协程
协程 coroutine
是一种程序运行的方式,可以理解成“协作的线程”或“协作的函数”。协程既可以用单线程实现,也可以用多线程实现。
协程与子例程的差异
传统的“子例程”subroutine
采用堆栈式“后进先出”的执行方式,只有当调用的子函数完全执行完毕,才会结束执行父函数。协程与其不同,多个线程(单线程情况下,即多个函数)可以并行执行,但是只有一个线程(或函数)处于正在运行的状态,其他线程(或函数)都处于暂停态suspended
,线程(或函数)之间可以交换执行权。这种可以并行执行、交换执行权的线程(或函数),就称为协程。协程与普通线程的差异
协程适合用于多任务运行的环境。在这个意义上,它与普通的线程很相似,都有自己的执行上下文、可以分享全局变量。它们的不同之处在于,同一时间可以有多个线程处于运行状态,但是运行的协程只能有一个,其他协程都处于暂停状态。此外,普通的线程是抢先式的,到底哪个线程优先得到资源,必须由运行环境决定,但是协程是合作式的,执行权由协程自己分配。
Generator
函数是 ES6
对协程的实现,但属于不完全实现。Generator
函数被称为“半协程”,意思是只有 Generator
函数的调用者,才能将程序的执行权还给 Generator
函数。如果是完全实现的协程,任何函数都可以让暂停的协程继续执行。
function* A() { |
上面代码中,A
将执行权交给B
,我们称A
是B
的父协程。那么现在B
执行,A
就相当于处于暂停的状态。等到B
最后return 100
,执行权就会还给A
。
(3)Generator 与上下文
执行 JS
代码时,会产生一个全局的上下文环境,包含了当前所有的变量和对象。执行函数或块级代码时,又会在当前上下文环境的上层 ,产生一个 函数运行的上下文 ,作为 当前active的上下文 ,由此形成一个上下文环境的堆栈。
这个堆栈是“后进先出”的数据结构,最后产生的上下文环境首先执行完成,退出堆栈,然后再执行完成它下层的上下文,直至所有代码执行完成,堆栈清空。
Generator
函数不一样,它执行产生的上下文环境,一旦遇到yield
命令,就会暂时退出堆栈,但是并不消失,里面的所有变量和对象会冻结在当前状态。等到对它执行next
命令时,这个上下文环境又会重新加入调用栈,冻结的变量和对象恢复执行。
function* gen() { |
上面代码中,第一次执行g.next()
时,Generator
函数gen
的上下文会加入堆栈,即开始运行gen
内部的代码。等遇到yield 1
时,gen
上下文退出堆栈,内部状态冻结。第二次执行g.next()
时,gen
上下文重新加入堆栈,变成当前的上下文,重新恢复执行。
18.12 应用
(1)异步操作的同步化表达
Generator
函数的暂停执行的效果,意味着可以把异步操作写在yield
表达式里面,等到调用next
方法时再往后执行。所以,Generator
函数的一个重要实际意义就是用来处理异步操作,改写回调函数。
function* loadUI() { |
上面代码中,第一次调用loadUI
函数时,该函数不会执行,仅返回一个遍历器。下一次对该遍历器调用next
方法,则会显示Loading
界面,并且异步加载数据。等到数据加载完成,再一次使用next
方法,则会隐藏Loading
界面。
(2)控制流管理
如果有一个多步操作,采用回调函数,可能会写成下面这样。
step1(function (value1) { |
采用 Promise
改写上面的代码。
Promise.resolve(step1) |
上面代码已经把回调函数,改成了直线执行的形式,但是加入了大量 Promise
的语法。Generator
函数可以进一步改善代码运行流程。
function* longRunningTask(value1) { |
然后,使用一个函数,按次序自动执行所有步骤。
scheduler(longRunningTask(initialValue)); |
注意,上面这种做法,只适合同步操作,即所有的task
都必须是同步的,不能有异步操作。因为这里的代码一得到返回值,就继续往下执行,没有判断异步操作何时完成。
(3)控制流管理 II
利用for...of
循环会自动依次执行yield
命令的特性,能提供一种更一般的控制流管理的方法。
首先,用数组steps
封装了一个任务的多个步骤,并依次为这些步骤加上yield
命令。
let steps = [step1Func, step2Func, step3Func]; |
然后,将任务分解成步骤之后,还可以再将项目分解成多个依次执行的任务jobs
。
let jobs = [job1, job2, job3]; |
最后,就可以用for...of
循环一次性依次执行所有任务的所有步骤。
for (let step of iterateJobs(jobs)) { |
再次提醒,上面的做法只能用于所有步骤都是同步操作的情况,不能有异步操作的步骤。
19. Generator 函数异步应用
19.1 基本概念
(1)异步
所谓”异步”,可以理解成一个任务被人为分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。
- 比如,有一个任务是读取文件进行处理,任务的第一段向操作系统发出请求,要求读取文件。然后,程序执行其他任务,等到操作系统返回文件,再接着执行任务的第二段处理文件。
相应地,连续的执行就叫做同步。由于是连续执行,不能插入其他任务,所以操作系统从硬盘读取文件的这段时间,程序只能干等着。
(2)回调函数
JS
语言对异步编程的实现,就是回调函数。所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。
fs.readFile("/etc/passwd", "utf-8", function (err, data) { |
上面代码中,readFile
函数的第三个参数,就是回调函数,也就是任务的第二段。等到操作系统返回了/etc/passwd
这个文件以后,回调函数才会执行。
(3)Promise
回调函数的问题出现在多个回调函数嵌套。假定读取A
文件之后,再读取B
文件。
fs.readFile(fileA, "utf-8", function (err, data) { |
不难想象,如果依次读取两个以上的文件,就会出现多重嵌套。因为多个异步操作形成了强耦合,只要有一个操作需要修改,它的上层回调函数和下层回调函数,可能都要跟着修改。
Promise
对象就是为了解决这个问题而提出的。它不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用。
let readFile = require("fs-readfile-promise"); |
上面代码中的fs-readfile-promise
模块,返回一个 Promise
版本的readFile
函数,并通过then
方法加载回调函数,catch
方法捕捉执行过程中抛出的错误。
Promise
的写法只是回调函数的改进,使用then
方法以后,异步任务的两段执行看得更清楚了。但是,Promise
的最大问题是代码冗余,原来的任务被 Promise
包装了一下,不管什么操作,一眼看去许多then
,原来的语义变得很不清楚。
(4)Generator 函数
“协程” coroutine
,是多个线程互相协作,完成异步任务。协程有点像函数,又有点像线程。它的运行流程大致如下:
- 第一步,协程
A
开始执行。 - 第二步,协程
A
执行到一半,进入暂停,执行权转移到协程B
。 - 第三步,一段时间后,协程
B
交还执行权。 - 第四步,协程
A
恢复执行。
上面流程的协程A
,就是异步任务,因为它分成两段或多段执行。
function* asyncJob() { |
上面代码的函数asyncJob
是一个协程,它的奥妙就在其中的yield
命令,表示执行到此处,执行权将交给其他协程。也就是说,yield
命令是异步两个阶段的分界线。
Generator
函数是协程在 ES6
的实现,最大特点就是可以交出函数的执行权(即暂停执行)。它是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield
语句注明。
function* gen(x) { |
上面代码中,调用 Generator
函数,会返回一个内部指针(即遍历器)g
。这是 Generator
函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针g
的next
方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的yield
语句,上例是执行到x + 2
为止。
换言之,next
方法的作用是分阶段执行Generator
函数。每次调用next
方法,会返回一个对象,表示当前阶段的信息(value
属性和done
属性)。value
属性是yield
语句后面表达式的值,表示当前阶段的值;done
属性是一个布尔值,表示 Generator
函数是否执行完毕,即是否还有下一个阶段。
19.2 数据交换和错误处理
Generator
函数可以暂停和恢复执行,这是它能封装异步任务的根本原因。除此之外,它还有两个特性,使它可以作为异步编程的完整解决方案:函数体内外的数据交换和错误处理机制。
next
返回值的 value
属性,是 Generator
函数向外输出数据。next
方法还可以接受参数,向 Generator
函数体内输入数据。
function* gen(x) { |
上面代码中,第一个next
方法的value
属性,返回表达式x + 2
的值。第二个next
方法带有参数,这个参数被传入 Generator
函数,作为上个阶段异步任务的返回结果,也就是y
的值。因此,这一步的value
属性,返回的就是2
。
Generator
函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。
function* gen(x) { |
上面代码的最后一行,Generator
函数体外,使用指针对象的throw
方法抛出的错误,可以被函数体内的try...catch
代码块捕获。这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的。
19.3 异步任务的封装
使用Generator
封装异步操作的核心思路:
- 在异步任务执行时,使用
yield
交出执行权。 - 在异步任务结束后,使用
next
交还执行权。
下面看看如何使用 Generator
函数,执行一个真实的异步任务。
// 1. 首先写一个异步任务,在一秒后返回特定字符串 |
上面代码虽然很粗糙,但是已经反映了使用Generator
封装异步任务的核心思想。最直观的受益就是,runTask
的内容与同步代码相似,条理清晰,很适合阅读。
可以看到,虽然 Generator
函数将异步操作表示得很简洁,但是上面代码中第三部分的流程管理却不方便,即何时执行第一阶段、何时执行第二阶段。
19.4 Thunk 函数
Thunk
函数是自动执行 Generator
函数的一种方法。
(1)参数的求值策略
Thunk
函数早在上个世纪 60
年代就诞生了。那时,编程语言刚刚起步,计算机学家还在研究,编译器怎么写比较好。一个争论的焦点是”求值策略”,即函数的参数到底应该何时求值。
let x = 1; |
上面代码先定义函数f
,然后向它传入表达式x + 5
。请问,这个表达式应该何时求值?
一种意见是“传值调用”,即在进入函数体之前,就计算x + 5
的值,再将这个值传入函数f
。C
语言就采用这种策略。
f(x + 5); |
另一种意见是“传名调用”,即直接将表达式x + 5
传入函数体,只在用到它的时候求值。Haskell
语言采用这种策略。
f(x + 5)( |
传值调用和传名调用,哪一种比较好?回答是各有利弊。传值调用比较简单,但是对参数求值的时候,实际上还没用到这个参数,有可能造成性能损失。
(2)Thunk 函数的含义
编译器“传名调用”的实现,往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体。这个临时函数就叫做 Thunk
函数。
function f(m) { |
上面代码中,函数 f
的参数x + 5
被一个函数替换了。凡是用到原参数的地方,对Thunk
函数求值即可。这就是 Thunk
函数的定义,它是“传名调用”的一种实现策略,用来替换某个表达式。
(3)JS 语言的 Thunk 函数
JavaScript
是传值调用,它的 Thunk
函数含义有所不同。在 JavaScript
中,Thunk
函数替换的不是表达式,而是多参数函数,将其替换成一个只接受回调函数作为参数的单参数函数。
// 正常版本的 readFile(多参数版本) |
上面代码中,fs
模块的readFile
方法是一个多参数函数,两个参数分别为文件名和回调函数。经过转换器处理,它变成了一个单参数函数,只接受回调函数作为参数。这个单参数版本,就叫做 Thunk
函数。
任何函数,只要参数有回调函数,就能写成 Thunk
函数的形式。(推荐阅读)
const Thunk = function (fn) { |
(4)Thunkify 模块
Thunkify
模块的使用方式如下。
let thunkify = require("thunkify"); |
Thunkify
的源码与上一节那个简单的转换器非常像。(源码解读)
function thunkify(fn) { |
它的源码主要多了一个检查机制,变量called
确保回调函数只运行一次。这样的设计与下面的 Generator
函数相关。
function f(a, b, callback) { |
上面代码中,由于thunkify
只允许回调函数执行一次,所以只输出一行结果。
(5)Generator 函数的流程管理
Thunk
函数可以用于 Generator
函数的自动流程管理。
function* gen() { |
上面代码中,Generator
函数gen
会自动执行完所有步骤。
但是,这不适合异步操作。如果必须保证前一步执行完,才能执行后一步,上面的自动执行就不可行。这时,Thunk
函数就能派上用处。
以读取文件为例。下面的 Generator
函数封装了两个异步操作。
let fs = require("fs"); |
上面代码中,yield
命令用于将程序的执行权移出 Generator
函数,那么就需要一种方法,将执行权再交还给 Generator
函数。
这种方法就是 Thunk
函数,因为它可以在回调函数里,将执行权交还给 Generator
函数。
let g = gen(); |
上面代码中,变量g
是 Generator
函数的内部指针,表示目前执行到哪一步。next
方法负责将指针移动到下一步,并返回该步的value
属性和done
属性。
仔细看上面的代码,可以发现 Generator
函数的执行过程,其实是将同一个回调函数,反复传入next
方法的value
属性。这使得我们可以用递归来自动完成这个过程。(理论上)
(6)Thunk 函数的自动流程管理
Thunk
函数真正的威力,在于可以自动执行 Generator
函数。下面就是一个基于 Thunk
函数的 Generator
执行器。
function run(fn) { |
上面代码的run
函数,就是一个 Generator
函数的自动执行器。内部的next
函数就是 Thunk
的回调函数。next
函数先将指针移到 Generator
函数的下一步(gen.next
方法),然后判断 Generator
函数是否结束(result.done
属性),如果没结束,就将next
函数再传入 Thunk
函数(result.value
属性),否则就直接退出。
有了这个执行器,执行 Generator
函数方便多了。不管内部有多少个异步操作,直接把 Generator
函数传入run
函数即可。注意,前提是每一个异步操作,都要是 Thunk
函数,也就是说,跟在yield
命令后面的必须是 Thunk
函数。
let g = function* () { |
上面代码中,函数g
封装了n
个异步的读取文件操作,只要执行run
函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。
Thunk
函数并不是 Generator
函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制,自动控制 Generator
函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise
对象也可以做到这一点。
19.5 co 模块
下面是一个 Generator
函数,用于依次读取两个文件。
let gen = function* () { |
co 模块可以用于 Generator
函数的自动执行,不用再编写 Generator
函数的执行器。
let co = require("co"); |
上面代码中,Generator
函数只要传入co
函数,就会自动执行。
co
函数返回一个Promise
对象,因此可以用then
方法添加回调函数。
co(gen).then(function () { |
上面代码中,等到 Generator
函数执行结束,就会输出一行提示。
(1)co 模块的原理
为什么 co
可以自动执行 Generator
函数?
前面说过,Generator
就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。
两种方法可以做到这一点:
- 回调函数。将异步操作包装成
Thunk
函数,在回调函数里面交回执行权。 Promise
对象。将异步操作包装成Promise
对象,用then
方法交回执行权。
co
模块其实就是将两种自动执行器(Thunk
函数和 Promise
对象),包装成一个模块。
- 使用
co
的前提条件是,Generator
函数的yield
命令后面,只能是Thunk
函数或Promise
对象。如果数组或对象的成员,全部都是Promise
对象,也可以使用co
。
(2)基于 Promise 对象的自动执行
沿用上面的例子。首先,把fs
模块的readFile
方法包装成一个 Promise
对象。
let fs = require("fs"); |
然后,手动执行上面的 Generator
函数。
let g = gen(); |
手动执行就是用then
方法,层层添加回调函数。理解了这一点,就可以写出一个自动执行器。
function run(gen) { |
上面代码中,只要 Generator
函数还没执行到最后一步,next
函数就调用自身,实现自动执行。
(3)co 模块的源码
首先,co
函数接受 Generator
函数作为参数,返回一个 Promise
对象。
function co(gen) { |
在返回的 Promise
对象里面,co
先检查参数gen
是否为 Generator
函数。如果是,就执行该函数,得到一个内部指针对象;如果不是就返回,并将 Promise
对象的状态改为resolved
。
function co(gen) { |
接着,co
将 Generator
函数的内部指针对象的next
方法,包装成onFulfilled
函数。这主要是为了能够捕捉抛出的错误。
function co(gen) { |
最后,就是关键的next
函数,它会反复调用自身。
function next(ret) { |
上面代码中,next
函数的内部代码,一共只有四行命令。
- 检查当前是否为
Generator
函数的最后一步,如果是就返回。 - 确保每一步的返回值,是
Promise
对象。 then
方法为返回值加上回调函数,然后通过onFulfilled
函数再次调用next
函数。- 在参数不符合要求的情况下(参数非
Thunk
函数和Promise
对象),将Promise
对象的状态改为rejected
,从而终止执行。
(4)处理并发的异步操作
co
支持并发的异步操作,即允许某些操作同时进行,等到它们全部完成,才进行下一步。这时,要把并发的操作都放在数组或对象里面,跟在yield
语句后面。(例子)
// 数组的写法 |