ES6前沿特性:生成器和promise
- 生成器是一种特殊类型的函数。当从头到尾运行标准函数时,它最多只生成一个值。然而生成器函数会在几次运行请求中暂停,因此每次运行都可能会生成一个值。
- 生成器经常被当作一种古怪不常用的语言特性。
- 本章会学到如何使用生成器来简化复杂循环,如何利用生成器的能力来挂起和恢复循环的执行,这些技巧可以帮助写出更简单、更优雅的异步代码。
- 对象的一个新的内置类型promise,也能帮助编写异步代码。promise对象是一个占位符,暂时替代那些尚未计算出但未来会计算出的值。对于多个异步操作来说,使用promise对象是非常有好处的。
6.1 使用生成器和promise编写优雅的异步代码
6.2 使用生成器函数
-
生成器函数几乎是一个完全崭新的函数类型,它和标准的普通函数完全不同。生成器函数能生成一组值的序列,但每个值的生成是基于每次请求,并不同于标准函数那样立即生成。我们必须显式地向生成器请求一个新的值,随后生成器要么响应一个新生成的值,要么就告诉我们它之后都不会再生成新的值。更让人好奇的是,每当生成器函数生成了一个值,它都不会像普通函数一样停止执行,相反,生成器几乎从不挂起。随后,当对另一个值的请求到来后,生成器就会从上次离开的位置恢复执行。
-
使用生成器函数生成了一系列武器数据。
"use strict" function* WeaponGenerator(){ yield "Katana"; yield "Wakizashi"; yield "Kusarigama"; } for(let weapon of WeaponGenerator()) { assert(weapon !== undefined, weapon); }
-
定义一个生成器,它能生成一系列武器的数据。创建一个生成器函数非常简单:只需要在关键字function后面加上一个星号。这样一来生成器函数体内就能使用新关键字yield,从而生成独立的值。
-
本例创建了一个WeaponGenerator的生成器,用于生成一系列武器数据:Katana、Wakizashi、Kusarigama。作为取出武器数据序列值的方法之一,for-of是一种用于循环结构的新类型:
for(let weapon of WeaponGenerator()) { assert(weapon, weapon); }
-
生成器函数和标准函数非常不同。调用生成器函数不会执行生成器函数,相反,它会创建一个叫做迭代器(iterator)的对象。
6.2.1 通过迭代器对象控制生成器
-
调用生成器函数不一定会执行生成器函数体。通过创建迭代器对象,可以与生成器通信。例如,可以通过迭代器对象请求满足条件的值。稍微修改一下之前的示例,看看迭代器是如何工作的。
function* WeaponGenerator(){ yield "Katana"; yield "Wakizashi"; } const weaponsIterator = WeaponGenerator(); const result1 = weaponsIterator.next(); assert(typeof result1 === "object" && result1.value === "Katana" && !result1.done,"Katana received!"); const result2 = weaponsIterator.next(); assert(typeof result2 === "object" && result2.value === "Wakizashi" && !result2.done,"Wakizashi received!"); const result3 = weaponsIterator.next(); assert(typeof result3 === "object" && result3.value === undefined && result3.done,"There are no more results!");
-
调用生成器后,就会创建一个迭代器:
const weaponsIterator = WeaponGenerator();
-
迭代器用于控制生成器的执行。迭代器对象暴露的最基本接口是next方法。这个方法可以用来向生成器请求一个值,从而控制生成器:
const result1 = weaponsIterator.next();
-
next函数调用后,生成器开始执行代码,当代码执行到yield关键字时,就会生成一个中间结果(生成值序列中的一项),然后返回一个新对象,其中封装了结果值和一个指示完成的指示器。
-
每当生成一个值后,生成器就会非阻塞地挂起执行,随后耐心等待下一次值请求的到达。这是普通函数完全不具有的强大特性。
-
在本例中,第一次调用生成器的next方法让生成器代码执行到第一个yield表达式
yield "Katana"
,然后返回了一个对象。该对象的属性value的值设置为Katana,属性done的值设置为false,表明之后还会有值生成。 -
随后再次调用weaponsIterator的next方法,再次向生成器请求另一个值:
const result2 = weaponsIterator.next();
-
该操作将生成器从挂起状态唤醒,中断执行的生成器从上次离开的位置继续执行代码,直到再次遇到另一个中间值:
yield "Wakizashi"
。随即生成了一个包含着值Wakizashi的对象,生成器挂起。 -
最后,当第三次执行next方法后,生成器恢复执行。但这一次,没有更多可供它执行的代码了,所以生成器带回了一个对象,属性value被置为undefined,属性done被置为true,表明它的工作已经完成了。
对迭代器进行迭代
-
通过调用生成器得到的迭代器,暴露出一个next方法能让我们向生成器请求一个新值。next方法返回一个携带着生成值的对象,而该对象中包含的另一个属性done也向我们指示了生成器是否还会追加生成值。
-
试着使用while循环来迭代生成器生成的值序列
function* WeaponGenerator(){ yield "Katana"; yield "Wakizashi"; } const weaponsIterator = WeaponGenerator(); let item; while(!(item = weaponsIterator.next()).done) { assert(item !== null, item.value); }
-
本例中,通过调用生成器函数创建了一个迭代器对象
const weaponsIterator = WeaponGenerator();
-
创建一个变量item,用于保存由生成器生成的单个值。随后给while循环指定了条件:
while(!(item = weaponsIterator.next()).done) { assert(item !== null, item.value); }
-
在每次迭代中,通过调用迭代器weaponsIterator的next方法从生成器中取一个值,然后把值放在item变量中。和所有next返回的对象一样,item变量引用的对象中包含一个属性value为生成器返回的值,一个属性done,指示生成器是否已经完成了值的生成。如果生成器中的值没有生成完毕,就会进入下次循环迭代,反之,停止循环。
-
这就是使用for-of的原理。for-of是对迭代器进行迭代的语法糖
for (let item of WeaponGenerator()) { assert(item !== null, item) }
-
不同于手动调用迭代器的next方法,for-of循环同时还要查看生成器是否完成,它在后台自动做完了相同的工作。
把执行权交给下一个生成器
-
如在标准函数中调用另一个标准函数,需要把生成器的执行委托给另一个生成器。
//使用yield操作符将执行权交给另一个生成器 "use strict"; function* WarriorGenerator(){ yield "Sun Tzu"; yield* NinjaGenerator(); yield "Genghis Khan"; } function* NinjaGenerator(){ yield "Hatori"; yield "Yoshi"; } for(let warrior of WarriorGenerator()){ assert(warrior !== null, warrior); }
-
执行代码后会输出Sun Tzu、Hatori、Yoshi、Genghis Khan。
-
在迭代器上使用yield* 操作符,程序会跳转到另一个生成器上执行。本例中,程序从WarriorGenerator跳转到一个新的NinjaGenerator生成器上,每次调用WarriorGenerator返回迭代器的next方法,都会使执行重新寻址到了NinjaGenerator上。该生成器会一直持有执行权直到无工作可做。仅当NinjaGenerator的工作完成后,调用原来的迭代器才会继续输出值Genghis Khan。
-
注意:
- 对于调用最初的迭代器代码来说,这一切都是透明的。
- for-of循环不会关心WarriorGenerator委托到了另一个生成器上,它只关心在done状态到来之前都一直在调用next方法。
6.2.2 使用生成器
用生成器生成ID序列
-
在创建某些对象时,经常需要为每个对象赋值一个唯一的ID。最简单方式是通过一个全局的计数器变量,但这是一种丑陋的写法,因为这个计数器变量很容易就会不慎淹没在混乱的代码中。
-
另一种方法就是使用生成器。
function *IdGenerator(){ let id = 0; while(true){ yield ++id; } } const idIterator = IdGenerator(); const ninja1 = { id: idIterator.next().value }; const ninja2 = { id: idIterator.next().value }; const ninja3 = { id: idIterator.next().value }; assert(ninja1.id === 1, "First ninja has id 1"); assert(ninja2.id === 2, "Second ninja has id 2"); assert(ninja3.id === 3, "Third ninja has id 3");
-
开始,迭代器中包含一个局部变量id,其代表了ID计数器。局部变量id仅能在该生成器中被访问,因此不需担心在代码其他位置修改id的值。随后是一个无限的while循环,其每次迭代都能生成一个新id值并挂起执行,直到下一次id请求到达:
function *IdGenerator(){ let id = 0; while(true){ yield ++id; } }
-
注意:
- 标准函数中一般不应该写无限循环的代码,但是在生成器中没问题。当生成器遇到了一个yield语句,它就会一直挂起直到下一次调用next方法,所以只有每次调用一次next方法,while循环才会迭代一次并返回下一个ID值。
-
定义生成器后,又创建了一个迭代器对象:
const idIterator = IdGenerator();
-
可以调用idIterator.next()方法来控制生成器执行。每当遇到一次yield语句,生成器就会停止执行,返回一个新的id值可以用于给对象赋值。
const ninja1 = { id: idIterator.next().value };
-
代码中没有任何会被不小心修改的全局变量。使用迭代器从生成器中请求值。如果还需要使用另外一个迭代器来记录ID序列,只需要直接再初始化一个新迭代器就可以了。
使用迭代器遍历DOM树
-
遍历DOM相对简单的方式是实现一个递归函数,在每次访问节点的时候都会被执行。
//递归遍历DOM <div id="subTree"> <form> <input type="text"/> </form> <p>Paragraph</p> <span>Span</span> </div> <script> "use strict"; function traverseDOM(element, callback) { callback(element); element = element.firstElementChild; while (element) { traverseDOM(element, callback); element = element.nextElementSibling; } } const subTree = document.getElementById("subTree"); traverseDOM(subTree, function(element) { assert(element !== null, element.nodeName); }); </script>
-
使用一个递归函数来遍历id为subtree的所有节点,在访问每个节点的过程中,记录了该节点的类型。
-
使用生成器遍历DOM树
<div id="subTree"> <form> <input type="text"/> </form> <p>Paragraph</p> <span>Span</span> </div> <script> "use strict"; function* DomTraversal(element){ yield element; element = element.firstElementChild; while (element) { yield* DomTraversal(element); element = element.nextElementSibling; } } const subTree = document.getElementById("subTree"); for(let element of DomTraversal(subTree)) { assert(element !== null, element.nodeName); } </script>
-
可以通过生成器实现DOM遍历,就像标准递归一样简单,但它不必书写简陋的回调函数代码。不同于在下一层递归处理每个访问过的节点子树,我们为每一个访问过的节点创建了一个生成器并将执行权交给它,从而使我们能够以迭代的方式书写概念上的递归代码。它的好处在于我们能够不凭借讨厌的回调函数,仅仅以一个简单的for-of循环就能处理生成的节点。
-
这个例子告诉我们如何在不必使用回调函数的情况下,使用生成器函数来解耦代码,从而将生产值(本例是HTML节点)的代码和消费值(本例中的for-of循环打印、访问过的节点)的代码分隔开。
-
在很多场景下,使用迭代器要比使用递归更自然,保持一个开放的思路很重要。
6.2.3 与生成器交互
- 不仅可以使用yield表达式从生成器中返回多个值,还可以向生成器发送值,从而实现双向通信。
- 使用生成器我们能够生成中间结果,在生成器以外我们也能够使用该结果进行任何什么操作,然后,一旦准备好了,就能把整个新计算得到的数据再完完全全返回给生成器。
作为生成器函数参数发送值
-
向生成器发送值的最简单方法如其他函数一样,调用函数并传入实参。
//向生成器发送数据及从生成器接收数据 function* NinjaGenerator(action) { const imposter = yield ("Hatori " + action); assert(imposter === "Hanzo", "The generator has been infiltrated"); yield ("Yoshi (" + imposter + ") " + action); } const ninjaIterator = NinjaGenerator("skulk"); const result1 = ninjaIterator.next(); assert(result1.value === "Hatori skulk", "Hatori is skulking"); const result2 = ninjaIterator.next("Hanzo"); assert(result2.value === "Yoshi (Hanzo) skulk", "We have an imposter!");
使用next方法向生成器发送值
- 除了在第一次调用生成器的时候向生成器提供数据,还能通过next方法向生成器传入参数。在这个过程中,把生成器函数从挂起状态恢复到了执行状态。生成器把传入的值用于整个yield表达式(生成器当前挂起的表达式)的值。
- 这个例子调用了两次
ninjaIterator
的next方法。第一次调用ninjaIterator.next()
,请求了生成器的第一个值。由于生成器还没有执行,这次调用启动了生成器,对表达式"Hatori " + action
进行求值,得到了值Hatori skulk
,并将该生成器的执行挂起。 - 第二次调用
ninjaIterator.next()
发生了有趣的事:ninjaIterator.next("Hanzo")
。这次,使用next方法将计算得到的值又传递回生成器。生成器函数耐心等待着,在表达式yield ("Hatori " + action)
位置挂起,故而值Hanzo作为参数传入了next()方法,并用作整个yield表达式的值。本例中,表示语句imposter = yield ("Hatori " + action);
中的变量imposter
的最终值为Hanzo。 - 上面展示了如何在生成器中双向通信。通过yield语句从生成器中返回值,再使用迭代器的next()方法把值传回生成器。
- 注意:
- next()方法给等待中的yield表达式提供了值,所以,如果没有等待中的yield表达式,也就没有什么值能应用的。基于这个原因,无法通过第一次调用next方法向生成器提供该值。
- 如果只需要为生成器提供一个初始值,可以调用生成器自身,就像
NinjaGenerator('skulk')
。
抛出异常
-
一种不那么正统的方式将值应用到生成器上:通过抛出一个异常。
-
每个迭代器除了有一个next方法,还有一个throw方法。
//向生成器抛出异常 function* NinjaGenerator() { try{ yield "Hatori"; fail("The expected exception didn’t occur"); } catch(e){ assert(e === "Catch this!", "Aha! We caught an exception"); } } const ninjaIterator = NinjaGenerator(); const result1 = ninjaIterator.next(); assert(result1.value === "Hatori", "We got Hatori"); ninjaIterator.throw("Catch this!");
-
生成器函数体内稍有不同,这次把整个函数体用try-catch块包裹了起来:
function* NinjaGenerator() { try{ yield "Hatori"; fail("The expected exception didn’t occur"); } catch(e){ assert(e === "Catch this!", "Aha! We caught an exception"); } }
-
通过创建一个迭代器继续执行,然后从生成器中获取一个值:
const ninjaIterator = NinjaGenerator();
const result1 = ninjaIterator.next();
-
最后,通过使用在所有迭代器上都有效的throw方法,向生成器抛出一个异常:
ninjaIterator.throw("Catch this!");
-
运行这个清单的代码后,可以看到异常的抛出情况如我们所料。
6.2.4 探索生成器内部构成
-
调用一个生成器不会实际执行它,它会创建一个新的迭代器,通过该迭代器才能从生成器中请求值。在生成器生成了一个值后,生成器会挂起执行并等待下一个请求的到来。在某种方面说,生成器的工作更像一个小程序,一个在状态中运动的状态机:
- 挂起开始——创建一个生成器后,它最先以这种状态开始。其中的任何代码都未执行。
- 执行——生成器中的代码执行的状态。执行要么是刚开始,要么是从上次挂起的时候继续的。当生成器对应的迭代器调用了next方法,并且当前存在可执行的代码时,生成器都会转移到这个状态。
- 挂起让渡——当生成器在执行过程中遇到了一个yield表达式,它会创建一个包含着返回值的新对象,随后再挂起执行。生成器在这个状态暂停并等待继续执行。
- 完成——在生成器执行期间,如果代码执行到return语句或者全部代码执行完毕,生成器就进入该状态。
-
看看生成器是如何跟随执行环境上下文的
function *NinjaGenerator() { yield 'Hatori'; yield 'Yoshi'; }
const ninjaIterator = NinjaGenerator();
创建生成器,处于挂起开始状态。const result1 = ninjaIterator.next();
激活生成器,从挂起状态转换为执行状态。执行到yield ‘Hatori’;语句中止,进而转为挂起让渡状态,返回新对象{value: ‘Hatori’, done: false}。const result2 = ninjaIterator.next();
重新激活生成器。从挂起让渡状态转为执行状态,执行到yield ‘Yoshi’;语句中止进而转为挂起让渡状态,返回新对象{value: ‘Yoshi’, done: false}。const result3 = ninjaIterator.next();
重新激活生成器。从挂起让渡状态转为执行状态,没有代码可执行,转为完成状态,返回新对象{value: undefined, done: true}。
通过执行上下文跟踪生成器函数
-
执行环境上下文是一个用于跟踪函数执行的JavaScript内部机制。尽管有些特别,生成器依然是一种函数。
function *NinjaGenerator(action) { yield 'Hatori' + action; yield 'Yoshi' + action; } const ninjaIterator = NinjaGenerator('skulk'); const result1 = ninjaIterator.next(); const result2 = ninjaIterator.next();
-
这里对生成器进行了重用,生成了两个值:Hatori skulk和Yoshi skulk。
6.3 使用promise
-
使用JavaScript编写代码会大量的依赖异步计算,计算那些现在不需要但是将来某时可能需要的值。ES6引入了一个新概念,用于更简单地处理异步代码:Promise。
-
Promise对象是对我们现在尚未得到但将来会得到的值的占位符;它是对最终能得知异步计算结果的一种保证。如果说我们兑现了我们的承诺,那结果会得到一个值。如果发生了问题,结果则是一个错误,一个为什么不能交付的借口。使用promise的一个最佳例子是从服务器获取数据:我们要承诺最终会拿到数据,但其实总有可能发生错误。
-
新建一个Promise对象
const ninjaPromise = new Promise((resolve, reject) => { resolve("Hatori"); //reject("An error resolving a promise!"); }); ninjaPromise.then(ninja => { assert(ninja === "Hatori", "We were promised Hatori!"); }, err => { fail("There shouldn’t be an error"); });
-
使用新的内置构造函数Promise来创建一个promise需要传入一个函数,这个函数被称为执行函数,它包含两个参数resolve和reject。当把两个内置函数:resolve和reject作为参数传入Promise构造函数后,执行函数会立即被调用。可以手动调用resolve让承诺兑现,也可以在发生错误时手动调用reject。
-
代码调用Promise对象内置的then方法,向这个方法中传入了两个回调函数:一个成功回调函数和一个失败回调函数。当承诺兑现(在Promise上调用了resolve),前一个回调函数会被调用,当出现错误时就会调用后一个回调函数(可能发生了一个未处理的异常,也可以是在Promise上调用了reject)。
-
示例代码,通过向resolve函数传递参数Hatori从而创建了一个承诺并立即兑现。因此,当调用then方法时,首先达到成功状态,第一个回调函数被执行,输出We were promised Hatori!,测试通过。
6.3.1 理解简单回调函数所带来的问题
-
使用异步代码的原因在于不希望执行长时间任务的时候,应用程序的执行被阻塞(影响用户体验)。当前,通过回调函数解决这个问题:对长时间执行的任务提供一个函数,当任务结束后会调用该回调函数。
-
例如,从服务端获取JSON字符串文件是一个长时间任务,在这个长时间任务执行期间我们不希望用户感觉到应用未响应。因此,提供了一个回调函数用于任务结束后调用:
getJSON('data/ninjas.json', () => {处理方法})
-
长时间任务下发生错误也是很自然现象。问题在于当回调函数发生错误时,无法用内置语言结构来处理,类似下面使用try-catch的方式
tyr { getJSON('data/ninjas.json', () => {处理方法}); } catch(e) {处理方法}
-
导致这个问题的原因在于,当长时间任务开始运行,调用回调函数的代码一般不会和开始任务中的这段代码位于事件循环的同一步骤。导致的结果就是,错误经常会丢失。因此许多函数库定义了各自的报错误规约。在Node.js中,回调函数一般具有两个参数:err和data。当错误在某处发生时,err参数中将会是个非空值。这就引起了第一个问题:错误处理困难。
-
当执行了一个长时间运行的任务后,经常希望用获取的数据来做些什么。这会导致开始另一项长期运行的任务,该任务最后又会触发另一个长期运行的任务,如此一来导致了互相依赖的一系列异步回调任务。如果希望找到所有‘忍者’来执行一项秘密任务,首先要找到第一个忍者所在的位置,然后向它下达一些命令,最后会出现以下类似情况:
getJSON('data/ninjas.json',function(err, ninjas) { getJSON(ninjas[0].location, function(err, locationInfo) { sendOrder(locationInfo, function(err, status){ 处理函数 }) }) });
-
结果可能是,至少写了一两次类似的结构的代码:一堆嵌套的回调函数用来表明需要执行的一系列步骤。还会意识到这样的代码难以理解,向其中再插入几步简直是一种痛苦,增加错误处理也会大大增加代码的复杂度。金字塔噩梦在不断增长,代码越来越难以管理。这就是回调函数的第二个问题:执行连续步骤非常棘手。
-
有时候得到最终结果的这些步骤并不相互依赖,所以不必让它们按顺序执行。为了节省时间可以并行地执行这些任务。例如,想要设定一个行动计划,而该计划要求我们知道有哪些忍者,这个计划本身,以及我们的计划将要实行的地点,那么就可以使用jQuery的get方法编写类似下面的代码:
var ninjas, mapInfo, plan; $.get('data/ninjas.json', function(err, data) { if (err) {processError(err); return;} ninjas = data; actionItemArrived(); }); $.get('data/mapInfo.json', function(err, data) { if (err) {processError(err); return;} mapInfo = data; actionItemArrived(); }); $.get('data/plan.json', function(err, data) { if (err) {processError(err); return;} plan = data; actionItemArrived(); }); function actionItemArrived () { if (ninjas !== null && mapInfo !==null && plan !== null) { console.log('The plan is ready to be set in motion') } } function processError (err) { alert('Error', err) }
-
这段代码中,执行了获取忍者的行动:由于行动之间互不依赖,所以在获取地图信息的同时获取计划。只需要关心这两点内容最后就能获取所有数据。不知道这些数据获取的顺序,每次获取一些数据,都检查看看是否是最后一段缺失的数据。最后,当所有数据都获取到了,就应立刻开始行动。依然不得不书写很多样板代码仅仅用于并行执行多个行动。这导致了回调函数的第三个问题:执行很多并行任务也很棘手。
-
看过了第一个回调函数问题及错误处理——我们看到了为何不能使用语言的基本构造,例如try-catch语句。循环也有类似问题如果想为集合中的每一项执行异步任务,必须越过重重关卡才能完成。
-
可以专门写一个函数库来简化处理所有这些问题。但是这也常常导致大量的稍有一点不同的解决方案,而它们仅仅是为了解决同样的问题,所以开发JavaScript语言的作者开发了Promise,它是用于处理异步计算的关键方法。
6.3.2 深入研究promise
-
Promise对象用于作为异步任务结果的占位符。它代表了一个暂时还没获得但在未来有希望获得的值。基于这个原因,在一个Promise对象的整个生命周期中,它会经历多种状态。 一个Promise对象从等待(pending)状态开始,此时我们对承诺的值一无所知。因此一个等待状态的promise对象也称为未实现(unresolved)的promise。在程序执行的过程中,如果promise的resolve函数被调用,promise就会进入完成状态(fulfilled),在该状态下能成功获得承诺的值。如果promise的reject函数被调用,或者如果一个未处理的异常在promise调用的过程中发生了,promise就会进入到拒绝状态,尽管在该状态下无法取得承诺的值,但至少知道了原因。一旦某个promise进入到完成态或者拒绝态,它的状态都不能再切换了(一个promise对象无法从完成态再进入拒绝态或者相反)。
report("At code start"); const ninjaDelayedPromise = new Promise((resolve, reject) => { report("ninjaDelayedPromise executor"); setTimeout(() => { report("Resolving ninjaDelayedPromise"); resolve("Hatori"); }, 500); }); assert(ninjaDelayedPromise !== null, "After creating ninjaDelayedPromise"); ninjaDelayedPromise.then(ninja => { assert(ninja === "Hatori", "ninjaDelayedPromise resolve handled with Hatori"); }); const ninjaImmediatePromise = new Promise((resolve, reject) => { report("ninjaImmediatePromise executor. Immediate resolve."); resolve("Yoshi"); }); ninjaImmediatePromise.then(ninja => { assert(ninja === "Yoshi", "ninjaImmediatePromise resolve handled with Yoshi"); }); report("At code end");
-
代码从打印日志‘’At code start 开始,下一步通过调用Promise构造函数创建了一个新的promise对象,它会立即调用执行函数并创建一个定时器,计时器会在500ms后执行promise的resolve方法。在ninjaDelaydPromise被创建后,依然无法得知最终会得到什么值,或者无法保证promise会成功进入完成状态。(它会一直等待计时器到时后调用resolve函数)。所以,在构造函数调用后,ninjaDelaydPromise就进入了promise的第一个状态——等待状态。
-
然后调用ninjaDelaydPromise的then方法,用于建立一个预计在promise被成功实现后执行的回调函数,这个回调函数总会被异步调用,无论promise当前是什么状态。继续创建另一个promise——ninjaImmediatePromise,它会在对象构造阶段立刻调用promise的resolve函数,立刻完成承诺。不同于ninjaDelaydPromise对象在构造后进入等待状态,ninjaImmediatePromise对象在解决状态下完成了对象的构造,所以该promise对象就已经获得了值Yoshi。然后通过调用ninjaImmediatePromise的then方法,为其注册一个回调函数,用于在promise成功被解决后调用。然而此时promise已经被解决了,这难道意味着这个成功的回调函数会被立即调用,或者被忽略吗?答案都不是。
-
Promise是设计用来处理异步任务的,所以JavaScript引擎经常会凭借异步处理使promise的行为得以预见。JavaScript通过在本次事件循环中的所有代码都执行完毕后,调用then回调函数来处理promise。因此会先输出“At code end”,然后记录ninjaImmediatePromise已经被解决。最后,经过500ms,ninjaDelayedPromise也被解决,从而响应的回调函数被调用。
6.3.3 拒绝promise
-
拒绝一个promise有两种方式:显示拒绝,即在一个promise的执行函数中调用传入的reject方法;隐式拒绝,正处理一个promise的过程中抛出了一个异常。
const promise = new Promise((resolve, reject) => { reject("Explicitly reject a promise!"); }); promise.then( () => fail("Happy path, won't be called!"), error => pass("A promise was explicitly rejected!") );
-
通过调用传入的reject函数可以显式拒绝promise:reject(“Explicitly reject a promise!”);。如果promise被拒绝,则第二个回调函数error总会被调用。
-
还可以使用替代语法来处理拒绝promise,通过使用内置的catch方法。
const promise = new Promise((resolve, reject) => { reject("Explicitly reject a promise!"); }); promise.then(() => fail("Happy path, won't be called!")) .catch(() => pass("Third promise was also rejected"));
-
通过在then方法后链式调用catch方法,同样可以在promise进入被拒绝状态时为其提供错误回调函数。
-
如果在程序执行过程中遇到了一个异常,除了显式拒绝(调用reject),promise还可以被隐式拒绝。
const promise = new Promise((resolve, reject) => { undeclaredVariable++; }); promise.then(() => fail("Happy path, won't be called!")) .catch(error => pass("The promise was rejected because an exception was thrown!"));
-
在promise函数体内,对变量undeclaredVariable进行自增,该变量并未在程序中定义。因此产生一个异常。由于执行函数中没有try-catch语句,所以当前的promise被隐式拒绝了,catch回调函数最后被调用。这种情况下,如果把错误回调函数作为then函数的第二个参数,结果也是相同的。
-
在then方法后链式调用catch方法这种方式处理promise中发生的错误相当简便。无论promise是被如何拒绝的,显式调用reject还是隐式调用,只要发生了异常,所有错误和拒绝原因都会在拒绝回调函数中被定位。
6.3.4 创建第一个真实的promise案例
-
客户端最通用的异步任务就是从服务器获取数据。使用内置XMLHttpRequest对象来完成底层的实现。
function getJSON(url) { return new Promise((resolve, reject) => { const request = new XMLHttpRequest(); request.open("GET", url); request.onload = function () { try { if (this.status === 200) { resolve(JSON.parse(this.response)); } else { reject(this.status + " " + this.statusText); } } catch (e) { reject(e.message); } }; request.onerror = function () { reject(this.status + " " + this.statusText); }; request.send(); }); } getJSON("data/ninjas.json").then((ninjas) => { assert(ninjas !== null, "Ninjas obtained!"); }).catch(e => fail("Shouldn’t be here:" + e));
-
为了从服务端异步获取JSON格式的数据,要创建一个getJSON函数,它返回一个promise对象。通过该对象,可以注册成功和失败的回调函数。采用内置的XMLHttpRequest对象来完成底层实现。该内置对象提供两种事件:onload和onerror。当浏览器从服务端接收到了一个响应,onload事件就会被触发,当通信出错则会触发onerror事件,一旦这个事件发生后,浏览器就会异步调用响应的事件处理函数。如果通信中出现了错误,则完全无法从服务器中获取数据,所以最直接的方式就是拒绝掉承诺。
-
如果从服务器端接收了一个响应,则必须分析响应内容并判断当前处在什么情况。由于服务器会返回各种各样的内容,所以不必考虑太多,上述代码仅仅关心响应成功(状态码200)。如果不是这种状态,则一律将promise拒绝。
-
尽管服务器成功地接收到了响应数据,但这并不意味着完全清楚了。目标是从服务器端获取JSON格式的数据,而JSON代码很容易出现语法错误。所以把对JSON.parse的调用包裹在一个try-catch语句中,如果在解析服务器响应内容的时候发生了错误,同样要拒绝掉promise。
-
上述代码有3个潜在的错误源:客户端和服务器之间的连接错误、服务器返回错误的数据(无效响应状态码),以及无效的JSON代码。使用getJSON函数,不必关心错误源的种类,只需提供一个回调函数,当一切工作正常且数据也正确返回时触发该回调函数,并提供另一个错误时触发的回调函数,当任何错误发生时触发该回调函数。这种方式能减轻工作量。
-
Promise的另一个最大优点:
- 优雅的编码方式。通过在一连串不同步骤中链式调用多个promise来展开代码编写。
6.3.5 链式调用promise
-
处理一连串相互关联步骤导致的“回调地狱”,嵌套太深将形成难以维护的回调函数序列。由于promise可以链式调用,因此它也是解决该问题的重要一步。
-
可以在then函数上注册一个回调函数,一旦promise成功兑现就会触发该回调函数。还有一个秘密是调用then方法后还会再返回一个新的promise对象。所以可以按照需要链式调用许多then方法。
getJSON("data/ninjas.json") .then(ninjas => getJSON(ninjas[0].missionsUrl)) .then(missions => getJSON(missions[0].detailsUrl)) .then(mission => assert(mission !== null, "Ninja mission obtained!")) .catch(error => fail("An error has occured"));
- 这段代码会创建一系列promise,一个接一个地被解决。首先使用getJSON(“data/ninjas.json”)方法从服务器中的文件上获取一个“忍者”列表数据。接收到这个列表后,把信息告诉第一位“忍者”,然后请求分给该“忍者”的任务列表:getJSON(ninjas[0].missionsUrl))。当任务到达时,开始请求第一项任务的详情: getJSON(missions[0].detailsUrl))。最后把任务详情写入日志。
- 使用标准回调函数书写上述代码会生成很深的嵌套回调函数序列。很难准缺地识别出当前进行到哪一步,在序列中增加一个额外的助手也非常棘手。
promise链中的错误捕捉
-
当处理一连串异步任务步骤的时候,任何一步都可能出现错误。既可以通过then方法传递第二个回调函数,也可以链式调用一个catch方法并向其中传入错误处理回调函数。当仅关心整个序列步骤的成功/失败时,为每一步都指定错误处理回调函数就显得很冗长乏味,所以可以利用catch方法:
.catch(error => fail("An error has occured:" + error ));
-
如果错误在前面的任何一个promise中产生,catch方法就会捕捉到它。如果没有发生任何错误,则程序流程只会无障碍地继续通过。
-
用promise处理一连串步骤比常规回调函数更加方便,但现在代码还不够优雅。
6.3.6 等待多个promise
-
除了处理相互依赖的异步任务序列以外,对于等待多个独立的异步任务,promise也能够显著地减少代码量。
-
获取可以被支配的“忍者”列表,复杂的计划,以及行动执行地点的地图。
Promise.all([getJSON("data/ninjas.json"), getJSON("data/mapInfo.json"), getJSON("data/plan.json")]).then(results => { const ninjas = results[0], mapInfo = results[1], plan = results[2]; assert(ninjas !== undefined && mapInfo !== undefined && plan !== undefined, "The plan is ready to be set in motion!"); }).catch(error => fail("A problem in carrying out our plan!"));
-
不必关心任务执行的顺序,以及它们是不是都已经进入完成态。通过使用内置方法Promise.all 可以等待多个promise。这个方法将一个promise数组作为参数,然后创建一个新的promise对象,一旦数组中的promise全部被解决,这个返回的promise就会被解决,而一旦其中的一个promise失败了,那么整个新promise对象也会被拒绝。后续的回调函数接收成功值组成的数组,数组中的每一项都对应promise数组中的对应项。
-
Promise.all 方法等待列表中的所有promise。但如果只关心第一个成功(失败)的promise,可以认识一下Promise.race方法。
6.3.7 promise竞赛
-
假设支配一只“忍队”,希望给第一个回答命令的“忍者”分配一个任务。
Promise.race([getJSON("data/yoshi.json"), getJSON("data/hatori.json"), getJSON("data/hanzo.json")]) .then(ninja => { assert(ninja !== null, ninja.name + " responded first"); }).catch(error => fail("Failure!"));
-
不需要手动跟踪所有代码。使用Promise.race方法并传入一个promise数组会返回一个全新的promise对象,一旦数组中某一个promise被处理或者被拒绝,这个返回的promise就同样会被处理或被拒绝。
6.4 把生成器和promise相结合
-
结合生成器(以及生成器暂停和恢复执行的能力)和promise,来实现更加优雅的异步代码。
-
数据被存储在远程服务器上,并以JSON格式编码。
-
所有子任务都是长期运行并且相互依赖的。
-
同步方式:
try { const ninjas = syncGetJSON("data/ninjas.json"); const missions = syncGetJSON(ninjas[0].missionUrl); const missionDetails = syncGetJSON(missions[0].detailUrl); } catch (e) { }
- 尽管这种方法对简化错误处理很方便,但UI被阻塞了。所以最好修改这段代码,让其运行长时间任务也不会发生阻塞。一种方法是将生成器和promise相结合,从生成器中让渡后会挂起执行而不会发生阻塞,仅需调用生成器迭代器的next方法就可以唤醒生成器并继续执行。而promise在未来触发某种条件的情况下我们可以得到它事先许诺的值,而当错误发生后也会执行相应的回调函数。
-
-
这个方法要结合生成器和promise:把异步任务放到一个生成器中,然后执行生成器函数。在生成器执行过程中,每执行到一个异步任务,就会创建一个promise用于代表该异步任务的执行结果。因为无法知道承诺什么时候会被兑现(甚至不会兑现),所以在执行生成器的的时候,会将执行权让渡给生成器,从而不会导致阻塞。过一会儿,当承诺被兑现,会继续通过迭代器的next方法执行生成器。只要有需要就可以重复此过程。
async(function* () { try { const ninjas = yield getJSON("data/ninjas.json"); const missions = yield getJSON(ninjas[0].missionsUrl); const missionDescription = yield getJSON(missions[0].detailsUrl); assert(ninjas !== null && missions !== null && missionDescription !== null, "All ready!"); } catch (e) { fail("We weren't able to get mission details"); } }); function async(generator) { const iterator = generator(); function handle(iteratorResult) { if (iteratorResult.done) { return; } const iteratorValue = iteratorResult.value; if (iteratorValue instanceof Promise) { iteratorValue.then(res => handle(iterator.next(res))).catch(err => iterator.throw(err)) } } try { handle(iterator.next()); } catch (e) { iterator.throw(e); } }
-
async函数获取一个生成器,调用它并创建了一个迭代器来恢复生成器的执行。在async函数内,声明一个handler函数用于处理从生成器中返回的值——迭代器的一次迭代。如果生成器的结果是一个被成功兑现的承诺,就用迭代器的next方法把承诺的值返回给生成器并恢复执行。如果出现错误,承诺被违背,就是用迭代器的throw方法抛出一个异常。直到生成器的工作完成前,一直会重复这几个操作。
注意:- 一个最小化的代码应把生成器和promise结合在一起。生产环境不适合使用这种代码。
-
这个生成器在第一次调用迭代器的next方法后,生成器执行第一次getJSON(“data/ninjas.json”)调用,此次调用创建了一个promise,该promise最终会包含“忍者”的信息。但是因为这个值是异步获取的,所以完全不知道浏览器会用多少时间来获取它。不想在等待中阻塞应用的执行,对于这个问题,在执行的那一刻,生成器让渡了控制权,生成器暂停,并把控制权还给了回调函数的执行。由于让渡的值是一个promise对象getJSON,在这个回调函数中通过使用promise的then和catch方法,注册了一个成功和一个错误回调函数,从而继续了函数的执行。然后,控制流就离开了处理函数的执行及async函数的函数体,直到调用async函数后才继续执行。这次,生成器函数耐心地等待着挂起,也没有阻塞程序的执行。
-
又过了很久,当浏览器接收到了响应(可能响应成功,也可能响应失败),promise的两个回调函数之一则被调用了。如果promise被成功解决,则会执行success回调函数,随之而来则是迭代器next方法的调用,用于向生成器请求新的值,从而生成器从挂起状态恢复,并把得到的值回传给回调函数。这意味着,程序又重新进入到生成器函数的体内,当第一次执行yield表达式后,得到的值变成从服务器端获取的“忍者列表”。
-
下一行代码的生成器函数中,使用获取到的数据ninjas[0].missionsUrl)来发起新的getJSON请求,从而创建了一个新的promise对象,最后会返回最受欢迎的“忍者”列表数据。但是依然无法得知这个异步任务要执行多久,所在再一次让渡了这次执行,并重复这个过程。只要生成器中有异步任务,这个过程就会重复一次。
例子包含知识点:
- 函数是第一类对象——向async函数传入了一个参数,该参数也是函数。
- 生成器函数——用其特性来挂起和恢复执行。
- promise——处理异步代码。
- 回调函数——在promise对象上注册成功和失败的回调函数。
- 箭头函数——箭头函数的简洁适合用在回调函数上。
- 闭包——在控制生成器的过程中,迭代器在async函数内被创建,随之在promise的回调函数内通过闭包来获取该迭代器。
-
最终结合了同步代码和异步代码的优点。
- 对于同步代码:更容易理解、使用标准控制流以及异常处理机制、try-catch语句的能力。
- 对于异步代码:有着天生的非阻塞,当等待长时间运行的异步任务时,应用的执行不会被阻塞。
面向未来的async函数
(async function () {
try {
const ninjas = await getJSON('data/ninjas.json');
const missions = await getJSON(ninjas[0].missionsUrl);
} catch(e) {
console.log("error" + e);
}
})()
- 通过在关键字function之前使用关键字async,可以表明当前的函数依赖一个异步返回的值。在每个调用异步任务的位置上,都要放置一个await关键字,用来告诉JavaScript引擎,请在不阻塞应用执行的情况下在这个位置上等待执行结果。