基本上可以这么说,promise是一个返回给你的对象,你可以往这个对象上追加一些回调函数,从而避免了以前那种往函数里塞回调函数(callback)的写法。
比如,以前你可能写过类似这样的旧回调风格代码:
function successCallback(result) { console.log("It succeeded with " + result); } function failureCallback(error) { console.log("It failed with " + error); } doSomething(successCallback, failureCallback);
用promise的话,你可以这么写:
let promise = doSomething(); promise.then(successCallback, failureCallback);
甚至可以这么写:
doSomething().then(successCallback, failureCallback);
我们称之为异步函数调用(asynchronous function call)。这种代码风格有一些优点,下面我们逐一说明。
目录
一些保证
不像旧回调风格代码,promise可以确保下面这些特性:
- 回调不会在当前事件循环结束之前被调用;
- 通过.then追加的回调函数在异步操作结束(成功或失败)之后会被调用,即便这个.then是在异步操作已经被resolved之后追加的;
- 可以通过多次追加.then来添加多个回调函数,这些回调函数会按照被添加的顺序独立执行。
不过promise的最直接的好处还是可链式使用。
链式使用
有一个常见的需求是连续执行两个或者多个异步操作,每个后续操作都在前一个操作成功后才开始执行,并且后续操作可以收到前一个成功操作返回的数据。我们可以通过promise链来实现这个需求。
敲黑板,划重点:这里的重点在于让.then方法返回一个不同于前面的、新的promise。
let promise = doSomething(); let promise2 = promise.then(successCallback, failureCallback);
或者这样写:
let promise2 = doSomething().then(successCallback, failureCallback);
这里的promise2不仅仅表示doSomething()的完成,也表示了传入的successCallback或failureCallback的完成——这里的successCallback和failureCallback可以是其他会返回promise的异步函数。如果这里的successCallback、failureCallback是会返回promise的异步函数的话,追加到promise2上的回调函数就会在successCallback或failureCallback执行完后再被调用。
基本上,在由promise构成的链上,每个promise都表示另一个异步操作的结束。
以前,要在一起写好多个异步操作代码很容易出现经典的“回调金字塔厄运”(callback pyramid of doom):
doSomething(function(result) { doSomethingElse(result, function(newResult) { doThirdThing(newResult, function(finalResult) { console.log('Got the final result: ' + finalResult); }, failureCallback); }, failureCallback); }, failureCallback);
但是现在,我们可以把回调函数追加到返回的promise上,形成一个promise链,就像这样:
doSomething().then(function(result) { return doSomethingElse(result); }) .then(function(newResult) { return doThirdThing(newResult); }) .then(function(finalResult) { console.log('Got the final result: ' + finalResult); }) .catch(failureCallback);
敲黑板,划重点:then方法里的参数是可选的。另外,catch(failureCallback) 是then(null, failureCallback) 的简写形式。如果使用箭头函数,上面的代码可以进一步简写为:
doSomething() .then(result => doSomethingElse(result)) .then(newResult => doThirdThing(newResult)) .then(finalResult => { console.log(`Got the final result: ${finalResult}`); }) .catch(failureCallback);
重点提示:总是要返回promise,否则这些回调函数就没办法链在一起了,错误也将没法被捕获。
catch之后的链
在失败(catch)之后也是可以继续链下去的,这可以让你在即使链中的某个行为失败了也可以继续完成新的行为。下面是一个示例代码:
new Promise((resolve, reject) => { console.log('Initial'); resolve(); }) .then(() => { throw new Error('Something failed'); console.log('Do this'); }) .catch(() => { console.log('Do that'); }) .then(() => { console.log('Do this whatever happened before'); });
上面的代码执行后会输入下面的内容:
Initial Do that Do this whatever happened before
注意,这里“Do this”不会被打印出来,因为”Something failed”这个错误被抛出时会触发一个rejection。
错误传播
还记得前文我们提到“回调金字塔厄运”(callback pyramid of doom)时举例用的代码吗?那块金字塔代码里,failureCallback 一共出现了三次。但是在promise链里failureCallback只需要在最底部出现一次:
doSomething() .then(result => doSomethingElse(value)) .then(newResult => doThirdThing(newResult)) .then(finalResult => console.log(`Got the final result: ${finalResult}`)) .catch(failureCallback);
基本上,如果promise链中出现了一个异常,这个链就会停止后续的动作,取而代之的是会沿着链往下找绑定catch处理代码块。这很像同步代码的书写模型:
try { let result = syncDoSomething(); let newResult = syncDoSomethingElse(result); let finalResult = syncDoThirdThing(newResult); console.log(`Got the final result: ${finalResult}`); } catch(error) { failureCallback(error); }
ECMSScript 2017引入了async/await语法糖来实现类似的同步代码书写风格:
async function foo() { try { let result = await doSomething(); let newResult = await doSomethingElse(result); let finalResult = await doThirdThing(newResult); console.log(`Got the final result: ${finalResult}`); } catch(error) { failureCallback(error); } }
async/await语法糖是建立在promise的肩膀之上的,上述代码片段里的doSomething 跟前面的函数是一样的(返回promise)。
Promise通过捕获所有的错误(包括抛出的异常和程序性错误)初步解决了回调函数金字塔的问题。这对异步操作的代码书写而言是非常有必要的。
将旧API包装为Promise
Promise可以通过对应的构造器非常方便的创建出来。但有些旧的API,需要我们自行将其包裹为一个promise。
在一个理想的代码国度里,所有异步函数应该都会返回promise。唉,但是,一些API仍旧期望使用者按以前的方式传入成功和/或失败回调。一个典型的例子就是setTimeout()函数:
setTimeout(() => saySomething("10 seconds passed"), 10000);
混合使用旧式回调和promise无异于自讨没趣。如果saySomething 失败了或者包含有语法性错误,并没有相应的代码来捕获它们。
幸运的是,我们可以将它包装为一个promise。这里有个最佳实践,就是尽可能把容易出问题的函数包装在最底层,然后从不直接调用它们:
const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); wait(10000).then(() => saySomething("10 seconds")).catch(failureCallback);
基本上,promise构造器接受一个执行函数,在这个执行函数里我们可以手动对promise进行resolve或reject。因为setTimeout并不会真的“失败”,所以在上面的这个例子里,我们省略了promise被reject的情况。
成员
Promise.resolve() 和Promise.reject() 是创建已经被resolved或rejected的promise对象的简便写法。有时这会很方便。
Promise.all() 和Promise.race() 可用于并行执行多个异步操作。
顺序执行多个异步操作可以借助下面这样“聪明”的代码实现:
[func1, func2].reduce((p, f) => p.then(f), Promise.resolve());
上面的代码可以这么理解,我们把一个由异步函数组成的数组通过数组的reduce方法拼接成了一个promise链,它等价于:Promise.resolve().then(func1).then(func2); 。
这个效果也可以通过一个可重复使用的compose函数来实现——这在函数式编程中是非常常见的:
let applyAsync = (acc,val) => acc.then(val); let composeAsync = (...funcs) => x => funcs.reduce(applyAsync, Promise.resolve(x));
composeAsync函数会接受任意数量的函数作为入参,然后返回一个新的函数,这个新的函数会接收一个初始值。
let transformData = composeAsync(func1, asyncFunc1, asyncFunc2, func2); transformData(data);
在ECMAScript 2017中,这个顺序执行的需求可以借助async/await更简单地实现:
for (let f of [func1, func2]) { await f(); }
时序
不要惊讶,传给then的函数从来都不会被同步调用,即便是在promise都已经被resolved的情况下,比如下面这样的代码,先打印出来的是1,然后才是2,尽管打印2的代码在打印1的代码之前出现:
Promise.resolve().then(() => console.log(2)); console.log(1); // 1, 2
在上面这个示例代码里,被传入then的函数不会立即被执行,而是会被堆放在一个微任务队列(microtask queue)上——这意味着这个被传入的函数会在当前JavaScript事件循环结束后微任务队列被清空时执行,反正也是相当快就会被执行到的。
下面是另一个例子:
const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); wait().then(() => console.log(4)); Promise.resolve().then(() => console.log(2)).then(() => console.log(3)); console.log(1); // 1, 2, 3, 4
参考:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises