Serenader

Learning by sharing

JavaScript Promise 初探

在 Promise 产生之前的 JavaScript

一说起 JavaScript,给人印象最深的,应该是活在浏览器里面的各种各样的脚本了。但是如今 Node.js 发展非常迅速,npm 社区也非常活跃,因此也越来越多人关注后端的 JavaScript 。
不得不说,在 JavaScript 里面,事件绑定以及事件处理是一个非常重要的部分。

前端部分,诸如鼠标点击事件的绑定,以及键盘按键的绑定,以及和后台打交道的 Ajax 等,都使用到了事件机制。最简单的一个点击事件如下:
var target = document.getElementById('target');
target.addEventListener('click', function () {
	// handle click event
});
以上我们监听了一个 ID 名为 target 的HTML元素,并赋予了一个匿名函数。当我们点击它时,则触发点击事件,然后执行相应的函数。这是最简单和基本的事件绑定和处理。但是,如果一个操作是异步的话,那么使用 JavaScript 去处理,就有点不同了。比如,使用 jQuery 进行 Ajax 操作:
$.ajax({
	url: '/example-url',
    type: 'post',
    data: {
    	data1: 'something',
        data2: 'another thing'
    },
    success: function (data) {
    	// handle ajax success
    },
    error: function (err) {
    	// ajax request failed
    }
});
或者下面这种方式:
var jqxhr = $.ajax( "example.php" )
  .done(function() {
    alert( "success" );
  })
  .fail(function() {
    alert( "error" );
  })
  .always(function() {
    alert( "complete" );
  });
以上则是非常普通的 Ajax 请求以及相应的处理。可以看出,如果是异步的方式的话,你可能要监听操作成功的事件,也要监听操作失败的事件。

后端部分,更多的是一些异步操作,以及一些事件处理。比如下面的一个非常普通的例子:
function readJSON(filename, callback){
    fs.readFile(filename, 'utf8', function (err, res){
    	if (err) return callback(err);
    	callback(null, JSON.parse(res));
    });
}

readJSON('test.json', function (err, res) {
	if (err) {
    	// handle error
    }
    // handle json content
});
以上演示了在 Node.js 中读取一个 JSON 文件的操作。类似的异步操作还有很多,比如读取\查询\更新数据库等。
这一切在以前乃至现在都可以很好的运作,而且也没有什么致命的缺点。但是,其实它是可以被改进的。使用 Promise 可以帮我们解决这些比较繁琐的东西,也能给我们的异步操作带来一个新的世界!
Promise 可以让我们更加关注于异步操作所产生的结果是什么,而不是关注于这个异步操作何时会有结果出来,何时执行成功,何时执行失败等。

Promise 定义及相关术语

What is a promise?
The core idea behind promises is that a promise represents the result of an asynchronous operation. A promise is in one of three different states:
pending - The initial state of a promise.fulfilled - The state of a promise representing a successful operation.rejected - The state of a promise representing a failed operation.
Once a promise is fulfilled or rejected, it is immutable (i.e. it can never change again).--https://www.promisejs.org/
Description
The Promise interface represents a proxy for a value not necessarily known when the promise is created. It allows you to associate handlers to an asynchronous action's eventual success or failure. This lets asynchronous methods return values like synchronous methods: instead of the final value, the asynchronous method returns a promise of having a value at some point in the future.
A pending promise can become either fulfilled with a value, or rejected with a reason. When either of these happens, the associated handlers queued up by a promise's then method are called. (If the promise has already been fulfilled or rejected when a corresponding handler is attached, the handler will be called, so there is no race condition between an asynchronous operation completing and its handlers being attached.)
As the Promise.prototype.then and Promise.prototype.catch methods return promises, they can be chained—an operation called composition.--MDN
简单的说, Promise 代表着一个异步操作的最终结果。一个 Promise 有以下三个阶段: pending,fulfilled,rejected。它可以让你为一个异步操作绑定成功或者失败之后执行的函数。这让一个异步操作的返回值类似于同步操作:不同于同步操作的最终值,Promise 返回的是一个包含未来的值的 Promise 。
当 Promise 处于 pending 状态时,它可以转化成为 fulfilled ,或者 rejected 。当转化完之后,紧接着当前状态的一个 then 方法将被执行。流程图如下:
/image/f4c23eab-2a3d-455f-979d-ef8ae47ba1ad/db59f95f-4137-42a8-b309-c40a51b93056_promises.png
Promise 流程图
值得注意的是,Promise 支持链式操作。也支持在当前状态中传递值给下一个状态。

Promise 例子

  • 读取文件
// callback-type
function readJSON(filename, callback){
    fs.readFile(filename, 'utf8', function (err, res){
    	if (err) return callback(err);
    	callback(null, JSON.parse(res));
    });
}

readJSON('test.json', function (err, res) {
	if (err) {
    	// handle error
    }
    // handle json content
});

// promise-type
function readJSON(filename){
	return fs.readFileAsync(filename, 'utf8');
}

readJSON('test.json').then(function (res) {
	// handle json content
}).catch(function (err) {
	// handle error
});

// or this way
function readJSON(filename) {
	return new Promise(function (resolve, reject) {
    	fs.readFile(filename, 'utf8', function (err, res) {
        	if (err) {
            	reject(err);
            } else {
            	resolve(res);
            }
        });
    });
}

readJSON('test.json').then(function (res) {
	// handle file content	
}, function (err) {
	// handle error
});
需要注意的是,第一个 Promise 例子中使用了 Bluebird 的 API ,其 fs.readFileAsync 是使用 Promise.promisifyAll 之后才有的方法。catch 也是 Bluebird 才有的方法,其等同于 .then(null, function (err) {}) 。
第二个 Promise 例子则是最基本的创建一个 Promise 对象,然后定义 resolve 和 reject 该何时被执行。在该例子中,由于创建的 readJSON 函数返回的是一个 Promise 对象,故可以直接调用 .then() 方法。
在 Promise 中,使用的最多的应该是这个.then() 了。then 方法接受两个参数,第一个参数是前一个阶段执行成功之后执行的函数,第二个参数是执行失败后执行的函数。then 方法里面的匿名函数只有在上一个阶段结束之后才会被执行。
在第一个 Promise 例子中,由于已经为 fs 使用 Promise.promisifyAll 进行包装了,所以在 readJSON 函数内部返回 fs.readFileAsync 实际上也是返回一个 Promise 对象。故也可以像第二个 Promise 例子那样调用 .then() 方法。
  • 读取数据库
// callback-type
Parse.User.logIn("user", "pass", {
  success: function(user) {
    query.find({
      success: function(results) {
        results[0].save({ key: value }, {
          success: function(result) {
            // the object was saved.
          }
        });
      }
    });
  }
});

// promies-type

Parse.User.logIn("user", "pass").then(function(user) {
  return query.find();
}).then(function(results) {
  return results[0].save({ key: value });
}).then(function(result) {
  // the object was saved.
});
这个例子与第一个例子基本上是一样的。只是这个例子假设了所有方法返回的都是一个 Promise 对象。故可以进行 .then() 。

使用 Promise 的好处

链式操作

Promise 对象均可以进行链式操作。即可以在一个 then 方法后面继续调用 then ,因为 then 本身返回的也是一个 Promise 对象。
doSomething().then(function () {
	return a();
}).then(function () {
	return b();
}).then(function () {
	return c();
});
以上代码首先执行 doSomething() 待执行成功之后执行 a() ,a() 执行成功之后再执行 b()b() 成功之后再执行 c 。这样我们就完全可以控制代码的执行顺序了,无论 doSomething()、 a()、 b()、 c() 是同步执行的还是异步执行的。我们不用再关心这些,我们只关注最终结果。这对于 JavaScript ,或者说 Node.js 来说,是一件非常了不起的事情!要知道,使用原生的 JavaScript 来达到这种效果需要非常冗余而且麻烦的代码。而使用 Promise 则可以非常方便的书写出来。
另外,Promise 之间的 then 是可以传递值的!
getPromise().then(function () {
	var greeting = 'hello';
    console.log(greeting);  // hello
    return greeting + '!!!';
}).then(function (greeting) {
	console.log(greeting); // hello!!!
});
由上面的代码可看出,在第一个 then 方法里面返回的值将会在之后的 then 方法里捕获到。使用创建 Promise 对象的方法也可以传递值给后面的操作。
var getPromise = function () {
	return new Promise(function (resolve, reject) {
    	resolve('hello');
    });
}

getPromise().then(function (greeting) {
	console.log(greeting); // hello
});

减少代码量

在上面的 Promise 例子中应该可以很明显的看出,使用 Promise 书写的代码,其字符总数会比使用原生 JavaScript 书写的代码少一些。
这虽然算不上是特别出众的好处,但至少可以减少我们写项目的时间(因为可以少打一些字符),以及节约存储空间等。

错误处理

另外一个非常有用也非常重要的,就是 Promise 的错误处理。

throw catch VS throw crash

如果你开发过 Node.js 程序的话,也许你有这样的经历:
一旦程序有任何小错误的话,比如某个变量名拼错了,程序立即崩溃退出。丝毫不给面子。这如果是在本地测试环境的话一回事,如果是放在生产环境的话,那么后果不堪设想。假如程序频频崩溃退出的话,那岂不是需要一直手动去启动它?就算使用类似 supervisor 的监控服务去监控的话,也算不上是一个靠谱的方案,至少它治标不治本。
使用 Promise ,你不用再担心程序会自动崩溃退出了(大部分情况下不会自动奔溃退出)。当你的程序有未处理的错误时,Promise 会在控制台抛出类似这样的信息:
Possibly unhandled ReferenceError: aaaa is not defined
    at e:\project\index.js:62:17
    at tryCatch1 (e:\project\node_modules\bluebird\js\main\util.js:43:21)
...
相比原来的程序立即崩溃退出,这次程序只是给出错误提示,并没有退出进程。这下你大可以放心的睡觉了,即使你把程序部署在生产环境下面。

No more if (err) {} else {}

在前面的 Promise 例子中可以看出,没有使用 Promise 时,你必须时时刻刻注意错误处理。不但在readJSON函数里面你要if (err) {} else {},在调用它的时候还要再 if (err) {} else {} 一次。再来看第二个例子中,如果没用 Promise 的话,完整的代码应该是这样的:
Parse.User.logIn("user", "pass", {
  success: function(user) {
    query.find({
      success: function(results) {
        results[0].save({ key: value }, {
          success: function(result) {
            // the object was saved.
          },
          error: function(result, error) {
            // An error occurred.
          }
        });
      },
      error: function(error) {
        // An error occurred.
      }
    });
  },
  error: function(user, error) {
    // An error occurred.
  }
});
相同的代码需要重复写多遍,这是非常痛苦的一件事情。但是这种事情又非常普遍的发生在 Node.js 的程序中。
那么,如果使用 Promise 的话,相信我,你会爱上这种写法的!
上面的例子使用 Promise 写的话,会是这样的:
Parse.User.logIn("user", "pass").then(function(user) {
  return query.find();
}).then(function(results) {
  return results[0].save({ key: value });
}).then(function(result) {
  // the object was saved.
}, function(error) {
  // there was some error.
});
看见没,**没有使用 Promise 之前你需要写三个错误处理,而使用 Promise 之后你只需要在最后面写一个错误处理就行了!**这是多么方便的方法啊!
这样,我们便不用再时时刻刻注意错误处理了,我们只需要把注意力集中在我们的正常逻辑中。然后在适当的地方写一个错误处理就行了。有时候也会有多个错误处理。但是这个和没用 Promise 之前的错误处理是两个不同的概念。Promise 使得错误处理变得更加容易和方便!
来看看这样一段代码:
asyncThing1().then(function() {
  return asyncThing2();
}).then(function() {
  return asyncThing3();
}).catch(function(err) {
  return asyncRecovery1();
}).then(function() {
  return asyncThing4();
}, function(err) {
  return asyncRecovery2();
}).catch(function(err) {
  console.log("Don't worry about it");
}).then(function() {
  console.log("All done!");
});
你可以尝试思考一下上面的整个代码的流程是怎样的。
当然了,结果如下:
/image/f4c23eab-2a3d-455f-979d-ef8ae47ba1ad/701213f4-f663-4842-9422-11e95100f472_promise-error.png
怎样,是不是妙不可言?如果这些都还不过打动你的话,那么请继续看吧!

队列异步操作

假设我有这样一个需求:用异步的方式在 /home 目录下创建 abcd 这四个文件夹,而且要按照顺序来。那么,如果使用原生的 Node.js 去实现的话,应该会是这样的:
fs.mkdir('/home/a', function (err) {
	if (err) {
    	console.error(err);
    } else {
    	fs.mkdir('/home/b', function (err) {
        	if (err) {
            	console.error(err);
            } else {
            	fs.mkdir('/home/c', function (err) {
                	if (err) {
                    	console.error(err);
                    } else {
                    	// do something 
                    }
                });
            }
        });
    }
});
也许你有其他比较好的方法去实现它,但是,相信我,万变不离其宗的。况且,这是个数确定的情况下的方案。如果需要创建的文件夹个数不可知呢?那该如何实现?
使用原生的 Node.js 去实现这些,未免有些麻烦,而且方法都不算是灵活。那么如果使用 Promise 的话,那会是怎样?

遍历+Promise=平行顺序地执行异步队列

var list = ['a', 'b', 'c'];
list.reduce(function (p, folder) {
	return p.then(function () {
    	return new Promise(function (resolve, reject) {
        	fs.mkdir('/home/' + folder, function (err) {
            	if (err) {
                	reject(err);
                } else {
                	resolve();
                }
            });
        });
    });
}, Promise.resolve());
搞定!如果你还是觉得太麻烦的话,那么可以使用 Bluebird 的 Promise.promisifyAll 实现更完美的操作:
var Promise = require('bluebird'),
	fs = Promise.promisifyAll(require('fs'));
    
var list = ['a', 'b', 'c'];
list.reduce(function (p, folder) {
	return p.then(function () {
    	return fs.mkdirAsync('/home/' + folder);
    });
}, Promise.resolve());
搞定!对比原来的代码,你是否也和我一样,惊讶于 Promise 的强大与方便?再者,这种思路也适合 list 个数未知的情况。
让我们来看一下这些代码内部是如何工作的。
首先,看一下 reduce 方法的介绍:

Syntax

arr.reduce(callback[, initialValue])
Parameters
  • callback 
    Function to execute on each value in the array, taking four arguments:
    • previousValue
      The value previously returned in the last invocation of the callback, or initialValue, if supplied. (See below.)
    • currentValue
      The current element being processed in the array.
    • index
      The index of the current element being processed in the array.
    • array
      The array reduce was called upon.
  • initialValue
    Optional. Object to use as the first argument to the first call of the callback.
由此可知,在
list.reduce(function (p, folder) {
	
}, Promise.resolve());
中,我们使用 Promise.resolve() 作为循环的初始值。而 Promise.resolve() 又是什么呢?
Promise.resolve 返回一个 Promise 对象,并且如果你有传递值给它时,那么它也会返回给下一个 then 。
This is the first time we've seen Promise.resolve, which creates a promise that resolves to whatever value you give it. If you pass it an instance of Promise it'll simply return it (note: this is a change to the spec that some implementations don't yet follow). If you pass it something promise-like (has a 'then' method), it creates a genuine Promise that fulfills/rejects in the same way. If you pass in any other value, eg Promise.resolve('Hello'), it creates a promise that fulfills with that value. If you call it with no value, as above, it fulfills with "undefined".
搞清楚这个之后,也就不难理解为何循环内部是以
return p.then(function () {

});
开头的。接下来就是执行我们需要执行的任务了。return fs.mkdirAsync() ,创建一个文件夹,返回一个 Promise 对象。至此第一个循环到此结束了。进入第二个循环。由于在 reduce 中,初始值即是上一次循环中所返回的值。那么,在第二次循环中,
return p.then(function () {

});
这段代码即表示在第一次循环所返回的后面添加这个 then 的内容。再把它返回给第三次循环。这样下去,就相当于在循环中把所有循环的返回值都串起来,又由于都是经过 then 链式连接的,因此每个循环内部都是以 Promise 的方式去执行。所以就达到了以平行、按顺序的方式,去执行一个异步操作队列了。
这才是 Promise 的精华所在!这个方法在 Node.js 的项目中非常有用,假如你有类似的需求需要完成的话。

Promise 目前的支持情况

Promise 目前已经是 ECMAScript 6 的一部分了!
This is an experimental technology, part of the Harmony (ECMAScript 6) proposal.
Because this technology's specification has not stabilized, check the compatibility table for usage in various browsers. Also note that the syntax and behavior of an experimental technology is subject to change in future version of browsers as the spec changes.
-- MDN
/image/f4c23eab-2a3d-455f-979d-ef8ae47ba1ad/43e5e72f-1222-489e-ba79-ff222ea352e7_Untitled.png
意味着,也许不久的未来,浏览器都原生支持 Promise 了!目前,Promise 以各种库的形式也存在一段时间了。这些库有:
上面的各种库都是使用共同的Promises/A+标准。
当然还有我们熟悉的 jQuery 。在 jQuery 里面有一个叫做 Deferred 的对象。但是,jQuery 的 Deferred 并不符合 Promises/A+ 这个标准。有兴趣的可以深入了解一下 Deferred 与上面给出的这些库的不同。
在以上那么多库中,最流行的应该算是 Q 和 Bluebird 了。我个人只使用过 Bluebird ,个人觉得其 API 非常灵活。值得一试。
既然有那么多的 Promise 库,那么难免会离不开这样一个问题:
Promise 库到底哪家强?
Google 了一下,发现这篇好文: JavaScript Promises – A Comparison Of Libraries
这里顺便给出一些对比图:
/image/f4c23eab-2a3d-455f-979d-ef8ae47ba1ad/11a3cd4f-3d9e-485c-aeee-1f8746afc2e9_promise-compare-size.png
按压缩后的文件大小对比
/image/f4c23eab-2a3d-455f-979d-ef8ae47ba1ad/24c86cf2-fcf2-4110-8bcb-570ed3dbf4da_promise-compare-speed.png
按速度对比
另外我还找到了另外一个国外开发者写的对比图:
/image/f4c23eab-2a3d-455f-979d-ef8ae47ba1ad/8a4c68bb-55fd-4528-9875-ada9a4586ae8_promise-compare-mem.png
按内存占用
/image/f4c23eab-2a3d-455f-979d-ef8ae47ba1ad/041c6e32-82ef-4ab5-90fb-e51117c94d6e_promise-compare-time.png
按完成的时间对比

结论:

  • 如果你非常在乎 JS 文件的大小的话,那么 Q 或者 when 是不错的选择。
  • 如果你不怎么在乎 JS 文件的大小的话,那么 Bluebird 是较为理想的选择。
  • 如果你非常在乎性能的话,那么 kew 值得拥有。
  • 如果你在寻找一种更加有限的方案并且兼顾速度和拥有较为小巧的文件大小的话,那么 ES6 Promise polyfill 是最理想的选择。

优化你的 Promise 代码

如何优化你的 Promise 代码,这是一个更加高级的内容了。详细的内容在此我不打算写了,因为篇幅的原因,而且这个话题也不是一句两句就能说完的。
在此建议大家阅读 Bluebird Github 的 Wiki 里面有关的内容:

Promise API 查阅

除非额外注明,Chrome、Opera 和 Firefox(nightly)均支持下列所有方法。这个 polyfill 则在所有浏览器内实现了同样的接口。

静态方法

  • Promise.resolve(promise);返回一个 Promise(当且仅当 promise.constructor == Promise
  • Promise.resolve(thenable);从 thenable 对象创建一个新的 Promise。一个 thenable(类 Promise)对象是一个带有“then”方法的对象。
  • Promise.resolve(obj);创建一个以 obj 为肯定结果的 Promise。
  • Promise.reject(obj);创建一个以 obj 为否定结果的 Promise。为了一致性和调试便利(如堆栈追踪),obj 应该是一个 Error 实例对象。
  • Promise.all(array);创建一个 Promise,当且仅当传入数组中的所有 Promise 都肯定之后才肯定,如果遇到数组中的任何一个 Promise 以否定结束,则抛出否定结果。每个数组元素都会首先经过 Promise.resolve,所以数组可以包含类 Promise 对象或者其他对象。肯定结果是一个数组,包含传入数组中每个 Promise 的肯定结果(且保持顺序);否定结果是传入数组中第一个遇到的否定结果。
  • Promise.race(array);创建一个 Promise,当数组中的任意对象肯定时将其结果作为肯定结束,或者当数组中任意对象否定时将其结果作为否定结束。备注:我不大确定这个接口是否有用,我更倾向于一个 Promise.all 的对立方法,仅当所有数组元素全部给出否定的时候才抛出否定结果

构造器

new Promise(function(resolve, reject) {});
  • resolve(thenable)你的 Promise 将会根据这个 “thenable” 对象的结果而返回肯定/否定结果
  • resolve(obj)你的 Promise 将会以 obj 作为肯定结果完成
  • reject(obj)你的 Promise 将会以 obj 作为否定结果完成。出于一致性和调试(如栈追踪)方便,obj 应该是一个 Error 对象的实例。构造器的回调函数中抛出的错误会被立即传递给 reject()。

实例方法

  • promise.then(onFulfilled, onRejected)当 promise 以肯定结束时会调用 onFulfilled。 当 promise 以否定结束时会调用 onRejected。 这两个参数都是可选的,当任意一个未定义时,对它的调用会跳转到 then 链的下一个 onFulfilled/onRejected 上。 这两个回调函数均只接受一个参数,肯定结果或者否定原因。 当 Promise.resolve 肯定结束之后,then 会返回一个新的 Promise,这个 Promise 相当于你从 onFulfilled/onRejected 中返回的值。如果回调中抛出任何错误,返回的 Promise 也会以此错误作为否定结果结束。
  • promise.catch(onRejected)promise.then(undefined, onRejected) 的语法糖。

Reference