编者按:本文由codercao在众成翻译平台上翻译。
原文名为《JavaScript是如何工作的:事件循环、异步编程的兴起、5个关于如何使用async/await编写更简洁代码的技巧》。
书接上回。昨天我们已经介绍了事件循环和Job Queue的机制,今天分享的是原文的后半部分——异步代码的发展历程,并比较三种异步模式的优缺点,希望大家看完能够收获新知识~
(图片来自网络)
回调
你已经知道,回调是目前为止在JavaScript程序中表达和管理异步的最常用的方式。实际上,回调是JavaScript语言中最基本的异步模式。无数的JS程序,甚至是非常复杂的程序,都是基于回调编写的,除了回调并没有用到其他的异步基础。
不过回调并不完美。许多开发人员都在尝试寻找更好的异步模式。但是,如果你不理解底层的实际情况,就不可能有效地使用任何抽象。
在接下来的章节中,我们将深入探讨这些抽象概念,以说明为什么更复杂的异步模式(将在后续文章中讨论)是必要的,甚至是推荐的。
嵌套回调
看看下面这段代码
listen('click', function (e){
setTimeout(function(){
ajax('https://api.example.com/endpoint', function (text){
if (text == "hello") {
doSomething();
}
else if (text == "world") {
doSomethingElse();
}
});
}, 500);
});
我们已经将三个函数的嵌套成了一条链,每一环都表示异步序列中的一个步骤。
这种代码通常被称为“回调地狱”。但是“回调地狱”实际上与缩进/缩进几乎没有任何关系。这是一个更深层次的问题。
首先,我们在等待“click”事件,然后等待计时器触发,然后等待Ajax响应返回,此时可能会再次出现。
乍一看,这段代码似乎可以自然地将它的异步映射为连续的步骤:
listen('click', function (e) {
});
然后我们有:
setTimeout(function(){
}, 500);
然后我们有:
ajax('https://api.example.com/endpoint', function (text){
});
最后:
if (text == "hello") {
doSomething();
}
else if (text ==
"world") {
doSomethingElse();
}
因此,这样一种顺序的方式来表达您的异步代码似乎更自然,不是吗?一定有这样的方法,对吧?
Promise
看看下面这段代码:
var x = 1;
var y = 2;
console.log(x + y);
这段代码非常简单:它对
x
和
y
的值进行求和,并将其打印到控制台。但是,如果
x
或
y
的值缺失了,还有待确定呢?比如,我们需要从服务器检索
x
和
y
的值,然后才能在表达式中使用它们。假设我们有一个函数
loadX
和
loadY
,分别从服务器加载
x
和
y
的值。然后,想象一下,我们有一个函数
sum
,一旦它们都被加载了,它就将
x
和
y
的值相加。 它可能是这样的(很难看,不是吗)
function sum(getX, getY, callback) {
var x, y;
getX(function(result) {
x = result;
if (y !== undefined) {
callback(x + y);
}
});
getY(function(result) {
y = result;
if (x !== undefined) {
callback(x + y);
}
});
}
function fetchX() {
}
function fetchY() {
}
sum(fetchX, fetchY, function(result) {
console.log(result);
});
这里有一些非常重要的东西——在这个片段中,我们将
x
和
y
作为
未来
的值,并表示了一个操作
sum(…)
(从外部看),它并不关心
x
和
y
当前可不可用。
当然,这种粗糙的基于调用的方法还有很多值得期待的。这只是迈向理解
未来值
的好处的第一步,而不用担心时间的问题。
Promise 值
让我们简要地看看我们如何用Promise来表达
x+y
的例子:
function sum(xPromise, yPromise) {
return Promise.all([xPromise, yPromise])
.then(function(values){
return values[0] + values[1];
} );
}
sum(fetchX(), fetchY())
.then(function(sum){
console.log(sum);
});
在这段代码中有两层Promise。
fetchX()
和
fetchY()
被直接调用,它们返回的值(Promise!)被传递给
sum(...)
。这些Promise所代表的潜在值可能在
现在
或者
将来
准备好,但是无论如何,每个Promise都将其行为规范化为相同的。我们以一种独立于时间的方式来解释
x
和
y
的值。它们在一段时间内,是
未来值
。
第二层是
sum(...)
创建(通过
Promise.all([ ... ])
)和返回的promise,我们通过调用
then(...)
来等待它完成。当
sum(...)
操作完成,我们的
未来值
,即求和结果已经准备好了,我们可以打印出来。我们隐藏了在
sum(...)
中等待
x
和
y
未来值
的逻辑。
注意
:
在
sum(…)
内部,
Promise.all([ … ])
调用创建了一个promise(它等待
promiseX
和
promiseY
完成),链式调用
.then(...)
来创建另一个promise,返回的
values[0]+ values[1]
会立即决议(返回相加的结果)。因此,我们在
sum(...)
调用后加上的
then(...)
——在代码段的最后——实际上是在第二个promise的返回后执行,而不是第一个创建的
Promise.all([ ... ])
。还有,虽然我们还没有在第二个
then(...)
后面继续添加
then
,它也创造了另一个promise,我们可以选择观察/使用它。本章后面的内容将在后面详细解释。 使用Promise,
then(...)
的调用实际上可以有两个方法,第一个是完成(如上所示),第二个是拒绝
:
sum(fetchX(), fetchY())
.then(
function(sum) {
console.log( sum );
},
function(err) {
console.error( err );
}
);
如果在得到
x
或
y
的时候出现了问题,或者在添加的过程中出现了一些失败,那么可以
sum(…)
返回的promise将被拒绝,传递给
then(...)
的第二个回调错误处理程序,它将收到来自promise拒绝的值。 因为Promises 封装了依赖于时间的状态——等待内在值的实现或拒绝——从外部来看,Promises 本身是时间独立的,因此可以以可预测的方式组合,而不考虑底层的时间和结果。
而且,一旦一个Promise得到决议,它就会永远保持这种状态——在那个时候它就变成了一个
不可改变的值
——然后就可以在必要的时候多次被
观察
。
实际上你可以链式Promise非常有用:
function
delay(time) {
return new Promise(function(resolve, reject){
setTimeout(resolve, time);
});
}
delay(1000)
.then(function(){
console.log("after 1000ms");
return delay(2000);
})
.then(function(){
console.log("after another 2000ms");
})
.then(function(){
console.log("step 4 (next Job)");
return delay(5000);
})
调用
delay(2000)
创造一个在2000ms完成的Promise,然后我们从第一个
then(…)
完成回调中返回,导致第二个
then(...)
的promise再等待2000ms执行。
注意
: 因为Promise 一旦决议,从外部就不可改变了,所以现在可以安全地将这个值传递给任何一方,因为它知道它不能被意外或恶意地修改。对于观察该promise的多方来说,这一点尤其正确。任意一方不可能影响另一方观察到的决议结果。不变性可能听起来像是一个学术话题,但它实际上是Promise 设计最基本和最重要的方面之一,不应该被随意地忽略。
如何辨别
Promise
?
关于Promises的一个重要细节是确定是否某些值是真正的Promise。换句话说,它的值会像一个Promise吗?
我们知道Promises是由
new Promise(…)
语法构造的,你可能会认为
p instanceof Promise
是一个充分的检查。好吧,不完全是。
主要是因为你可以从另一个浏览器窗口(例如iframe)获得一个 Promise的值,它有自己独立的Promise类,不同于当前窗口或框架中的一个,因此该检查将无法识别Promise实例。
而且,一个库或框架可以选择发布它自己的Promise,而不是使用ES6原生的Promise实现。事实上,你很可能会在没有任何Promise的老式浏览器中使用第三方的 Promise。
吞掉异常
如果在创建Promise的任何时候,或者在对其决议的观察中,抛出了一个JavaScript异常错误,比如“TypeError”或“ReferenceError”,那么这个异常就会被捕获,它将迫使这个Promise被拒绝。
例如:
var p = new Promise(function(resolve, reject){
foo.bar();
resolve(374);
});
p.then(
function fulfilled(){
},
function rejected(err){
}
);
但是如果一个Promise完成了,却在观察结果时(在
then(…)
注册回调)发生了JS异常会怎样呢?即使这个错误不会被丢失,你可能会对它们处理的方式感到惊讶。除非你进一步挖掘:
var p = new Promise( function(resolve,reject){
resolve(374);
});
p.then(function fulfilled(message){
foo.bar();
console.log(message);
},
function rejected(err){
}
);
它看起来像“foo . bar()”真的是被吞没了。其实并不是。不过,一些更深层次的问题出现了,我们没能监听到。“p.then(…)调用本身会返回另一个promise,而这个promise将会被
TypeError
异常所拒绝。
处理未捕获异常
还有其他“更好”的方法。 常见的一个建议是应该给Promise增加一个
done(…)
,用于标志Promise链的结束。
done(…)
不会创建并返回一个Promise,所以传递到
done(…)
的回调显然不会把问题报告给一个不存在的链式Promise。 在未捕获错误的情况下,会按照你期望的方式处理:在
done(..)
中的拒绝处理函数中如果有任何异常,该异常将被抛出为一个全局未捕获的错误(通常在开发人员控制台能看到):
var p = Promise.resolve(374);
p.then(function fulfilled(msg){
console.log(msg.toLowerCase());
})
.done(null, function() {
});
在ES8里 Async/await会发生什么
JavaScript ES8引入了
async/await
,使得Promise更容易使用。我们将简要介绍
async/await
提供的可能性,以及如何利用它们来编写异步代码。 因此,让我们看看async/await如何工作。
使用
async
函数声明来定义一个异步函数。这样的函数返回一个
AsyncFunction
对象,
AsyncFunction
对象代表了执行代码的异步函数,
AsyncFunction
包含在该函数中。 当调用async函数时,它返回一个
Promise
。当async函数返回一个值时,这不是一个
Promise
。而是会自动创建一个
Promise
,它将使用函数的返回值来决议。当
async
函数抛出异常时,
Promise
将使用抛出的值来拒绝。 一个
async
函数可以包含一个
await
表达式,该表达式暂停执行该函数,并等待传递给它的Promise被决议,然后恢复async函数的执行并返回决议值。 你可以把JavaScript的
Promise
看作是Java的
Future
或
c#
的任务。
async/await
的目的是简化使用Promise的行为。 让我们看一下下面的例子:
function getNumber1() {
return Promise.resolve('374');
}
async function getNumber2() {
return 374;
}
同样,抛出异常的函数等价于返回已被拒绝的promise的函数:
function f1() {
return Promise.reject('Some error');
}
async function f2() {
throw 'Some error';
}
await
关键字只能在
async
函数中使用,并允许您同步等待一个Promise。如果我们在
async
函数之外使用Promise,我们仍然需要使用
then
回调:
async function loadData() {
var promise1 = rp('https://api.example.com/endpoint1');
var promise2 = rp('https://api.example.com/endpoint2');
var response1 = await promise1;
var response2 = await promise2;
return response1 + ' ' + response2;
}
loadData().then(() => console.log('Done'));
你还可以使用“async函数表达式”来定义async函数。一个async函数表达式非常类似,它的语法和async函数声明差不多。async函数表达式和async函数声明之间的主要区别是函数名,它可以在async函数表达式中省略,以创建匿名函数。一个async函数表达式可以作为一个IIFE(立即执行函数表达式)来使用,当IIFE被定义完就会运行。 它看起来像这样:
var loadData = async function() {
var promise1 = rp('https://api.example.com/endpoint1');
var promise2 = rp('https://api.example.com/endpoint2');
var response1 = await promise1;
var response2 = await promise2;
return response1 + ' ' + response2;
}
更重要的是,在所有主流浏览器中都支持async/await:
如果这个兼容性不是你想要的,那么也有几个JS的转换器,比如
Babel
和
TypeScript
在一天结束的时候,重要的是不要盲目地选择“最新”的方法来编写异步代码。理解异步JavaScript的内部原理是很重要的,了解它为什么如此重要,并深入理解您选择的方法的内部原理。每种方法都有利有弊。
编写高度可维护的、健壮的异步代码的5个技巧
1.
干净的代码:
使用async/await允许您编写更少的代码。每次使用async/await你能跳过一些不必要的步骤:写
.then
,创建一个匿名函数来处理响应,在该回调中命名响应变量,比如:
rp(‘https://api.example.com/endpoint1').then(function(data) {
});
对比:
var response = await rp(‘https://api.example.com/endpoint1')