拥抱javascript的promise
在本文中,我们看到如何通过promises,在javascript中实现异步调用,从而写出更加优雅的代码。这篇文章不是一个完整,有深度的对promises的探索。如果是为了了解更深入,我强烈建议你去看下 Jake Archibald's post on HTML5 Rocks,
在这里我将会使用es6-promise库,这是一个在ECMAScript6中存在的原生promise实现。
我所有的代码都会在Node.js运行,和在浏览器环境下运行结果一致。当你在代码中看到Promise的时候,这里都是基于polyfill使用的,但是如果你读到了浏览器中广泛实现的单词promises,你将会发现所有运行结果是一样的。
解决错误
第一个要处理的主题就是promise的错误处理。这个问题曾经被许多人问过,也阻碍了很多人的理解。让我们看下下面的代码,当我运行如下内容时,你希望打印出什么?
var someAsyncThing = function() {
return new Promise(function(resolve, reject) {
// this will throw, x does not exist
resolve(x + 2);
});
};
someAsyncThing().then(function() {
console.log('everything is great');
});
你可能觉得一个错误会被抛出,因为x并不存在。这种情况只会在上面这段代码在promise外面时才会发生。但是,运行这段代码,控制台不会有任何输出,没有错误被抛出。在promise中,任何错误将被抛出,被当做一个promise 拒绝异常。这意味着我们必须要捕获这种异常:
someAsyncThing().then(function() {
console.log('everything is great');
}).catch(function(error) {
console.log('oh no', error);
});
现在运行这个:
oh no [ReferenceError: x is not defined]
你需要理解promises的链式异常捕获机制。例如:
var someAsyncThing = function() {
return new Promise(function(resolve, reject) {
// this will throw, x does not exist
resolve(x + 2);
});
};
var someOtherAsyncThing = function() {
return new Promise(function(resolve, reject) {
reject('something went wrong');
});
};
someAsyncThing().then(function() {
return someOtherAsyncThing();
}).catch(function(error) {
console.log('oh no', error);
});
这里我们依旧得到结果:oh no [ReferenceError: x is not defined]。因为someAsyncThing 中使用reject。如果someAsyncThing 使用resolve,我们依旧会在someOtherAsyncThing在reject时返回这个错误:
var someAsyncThing = function() {
return new Promise(function(resolve, reject) {
var x = 2;
resolve(x + 2);
});
};
var someOtherAsyncThing = function() {
return new Promise(function(resolve, reject) {
reject('something went wrong');
});
};
someAsyncThing().then(function() {
return someOtherAsyncThing();
}).catch(function(error) {
console.log('oh no', error);
});
现在,我们获得的结果是:oh no something went wrong。当一个promises reject时,链式的第一个捕获会被调用。
另一个重要的点是这里的捕获没有什么特别的地方。它只是一个方法,注册了一个处理函数,当promises reject时触发该处理函数。它不会停止下一步的执行:
someAsyncThing().then(function() {
return someOtherAsyncThing();
}).catch(function(error) {
console.log('oh no', error);
}).then(function() {
console.log('carry on');
});
以上给出的代码,一旦出现 reject,carry on将会在控制台被打印出。当然,如果在catch中的代码抛出异常,则不会是这样:
someAsyncThing().then(function() {
return someOtherAsyncThing();
}).catch(function(error) {
console.log('oh no', error);
// y is not a thing!
y + 2;
}).then(function() {
console.log('carry on');
});
现在,捕获的调用被执行了,但是carry on却没有,因为catch调用抛出了一个异常。需要注意的是这里没有错误任何记录,也不会打日志,不会有任何显示的异常抛出。如果你在最后添加了另外一个捕获,则捕获函数将会执行,因为当一个回调函数抛出异常时,调用链中的下个捕获才能够被调用。
Promises链式传递
这是来自我最近做过的一个添加CSV导出到我们客户端应用程序的功能。在那个例子里,使用了$q框架,这个框架里面包含了AngularJS的应用程序,但是我已经将其替换,以便于我可以用其作为一个例子:
导出CSV的步骤如下(CSV文件是在浏览器使用FileSaver建立的):
从构成CSV数据的API处获取数据(可能意味着多个API请求),将数据传递给一个object,在object中对数据做一些编辑,以便为填充到CSV中做好准备。
填写数据到CSV中。给用户一个消息,确认他们的CSV已经被成功创建按,或者是有一个错误。
我们不会去看代码工作的细节,但是我想在一定程度上了解我们是如何使用Promises建立一个健壮的解决方案。在这样的一个复杂的操作中,任意环节都可能产生错误(API功能可能会失效,代码解析数据时可能会抛出一个异常,或者CSV可能不会被正确地保存),同时我们发现通过promises,使用then和catch的组合,我们能够非常优雅地解决这个问题。
正如你将看到的,我们将停止在promises的调用链中增加环节。在我看来,promises的链式写法的确让问题处理方式有了亮点,但是也需要我们去习惯这个写法,他们的工作方式在一开始看来会有点奇怪。
Jake Archibald 将这个进行了优化:
当你从“then”回调函数返回值时,是有点小技巧的。如果你返回了一个value,下一个附带返回值的then将会被调用。但是,如果你返回了一些值例如promise,下一个then则等候,同时只有在这个promise成功或者失败时,下一个then才会被调用。
如果想深入了解promises,我还是必须强烈推荐这篇博客Jake Archibald's post on HTML5 Rocks。
让我们从一个简单的函数开始,这个函数只是返回一些数据。在一个真正的应用程序中,这里将会有一个http 调用。在我们的例子里,50ms后这个promise将会处理一个users数组,这个数组是我们想导出到CSV中的:
var fetchData = function() {
return new Promise(function(resolve, reject) {
setTimeout(function() {
resolve({
users: [
{ name: 'Jack', age: 22 },
{ name: 'Tom', age: 21 },
{ name: 'Isaac', age: 21 },
{ name: 'Iain', age: 20 }
]
});
}, 50);
});
}
接下来,这里有一个函数,为CSV准备数据。在这个例子里,这个函数做的就是立即解决给出的数据,但是在一个实际的应用程序中,它将做更多的工作:
var prepareDataForCsv = function(data) {
return new Promise(function(resolve, reject) {
// imagine this did something with the data
resolve(data);
});
};
这里需要强调一点:在这个例子里(在一个实际的app中),prepareDataForCsv做的工作不是异步的。没有必要将其包含在一个promise中。但是当一个函数作为一个大的链式调用中的一部分中时,我发觉将其包含在一个promise中的确是很有好处的,这意味着所有的错误处理能够通过promises完成。另外的,如果你必须通过promises在某个地方进行错误处理,在另外一个地方则同通过try catch来进行处理。
最终,我们有一个函数将数据写入csv中:
var writeToCsv = function(data) {
return new Promise(function(resolve, reject) {
// write to CSV
resolve();
});
};
现在我们能够将他们放到一起了:
fetchData().then(function(data) {
return prepareDataForCsv(data);
}).then(function(data) {
return writeToCsv(data);
}).then(function() {
console.log('your csv has been saved');
});
这里非常简明,可读性也很好。能够很清晰地看到什么在运行,以及发生的事情的顺序。我们能够在以后进行整理。如果你有个函数,只是取一个参数,你可以直接传递给then,而不是通过一个回调函数来进行调用。
fetchData().then(prepareDataForCsv).then(writeToCsv).then(function() {
console.log('your csv has been saved');
});
始终要记得构成promises基础的代码很复杂(至少,在一个真正的应用程序中),高层API读起来很顺。一旦你习惯了使用他们,你能够写出一些比较优雅同时容易被理解的代码。
到目前为止我们没有任何的错误处理函数,但是我们能够添加到代码的另一块区域。
fetchData().then(prepareDataForCsv).then(writeToCsv).then(function() {
console.log('your csv has been saved');
}).catch(function(error) {
console.log('something went wrong', error);
});
正如之前所说的promises的链式以及错误处理方式,这意味着调用链末尾仅有的一个catch函数被当做可以捕捉任何错误抛出的处理函数。这使得错误处理更加直接。
为了示范这一点,我将会改变prepareDataForCsv函数,让其在promise中reject:
var prepareDataForCsv = function(data) {
return new Promise(function(resolve, reject) {
// imagine this did something with the data
reject('data invalid');
});
};
现在运行代码,会打印出错误。这的确很赞。prepareDataForCsv处于链式调用的中间位置,但是我们不需要做任何额外的工作来处理错误。另外的,catch不仅仅捕获我们通过promise reject触发的错误,同时也会捕获那些我们意想不到的异常。这意味着即使一个真正的预料之外的非主流程代码触发了js异常,用户还是有其希望的异常处理函数进行处理。
我们发现的另外一种有效的方式是修改函数,通过以数据为入参,而不是返回一个将会解决数据的promise。我们修改prepareDataForCsv如下:
var prepareDataForCsv = function(dataPromise) {
return dataPromise().then(function(data) {
return data;
});
};
我们发觉这是一个非常友好的整理代码的模式,并且能够保持代码的通用性。在一个应用程序里大部分工作是异步的时候,传递promises而不是等待异步完成,再进行数据传递及解决会更加简单。
通过以上的改变,新的代码看起来是这个样子的:
prepareDataForCsv(fetchData).then(writeToCsv).then(function() {
console.log('your csv has been saved');
}).catch(function(error) {
console.log('something went wrong', error);
});
这里的好处在于错误处理没有被改变。fetchData能够在一些表单中拒绝,错误将依然在最后的catch中调用。在你的头脑中想象一下,一旦点击,你将会发现,promises确实很不错,可能比错误处理更加的友好。
Promises 中的递归
我们需要处理的问题是当我们从API拉取数据时,可能需要多次请求。当你需要获取更多的数据而不是将所有数据放入一次响应中,对我们的API请求进行分页,你就能够发起多次请求。我们的API会告诉你是否会有更多数据需要拉取,在这个部分,我们将会解释怎么在promises的结合中使用递归,以及载入所有数据。
var count = 0;
var http = function() {
if(count === 0) {
count++;
return Promise.resolve({ more: true, user: { name: 'jack', age: 22 } });
} else {
return Promise.resolve({ more: false, user: { name: 'isaac', age: 21 } });
}
};
首先,我们有http,将会作为一个伪HTTP请求,请求我们的API。(Promises.resolve将会创建一个promise,立即处理你传递给他的任何值)。第一次我发起一个请求时,它将会返回附带一个user的响应但是同时有更多的标志位置为true,表明将会有更多的数据需要拉取(这不是实际API所返回的响应,但是将会出于文章的目的给出这个响应)。第二次请求时API将会返回一个user,但是更多的标志位是false。因此拉取所有需要的数据,我们需要发起两次API请求。fetchData如下:
var fetchData = function() {
var goFetch = function(users) {
return http().then(function(data) {
users.push(data.user);
if(data.more) {
return goFetch(users);
} else {
return users;
}
});
}
return goFetch([]);
};
fetchData完成了定义和调用其他goFetch的功能。goFetch获取users的一个数组,接着调用http(),对数据进行处理。http()返回的user将被push到users数组中,函数将会查看data.more
字段。如果为true,则将会继续调用自身,传新的user。如果为false,则不再获取数据,它将会返回users数组。最重要的事情和工作的原理是它在每一阶段都返回了值。fetchData返回了一个goFetch,goFetch返回了自身或者是一个users数组。每一个它所返回的值都允许promise的链式递归调用的建立。
总结
promises将会成为处理大量异步操作的一个标准方法。但是我发现在处理复杂顺序操作的时候,一些是同步的,一些是异步的,promises也提供了很多的好处。如果你还没试过,我建议你在下一个项目中进行尝试。