来自《JavaScript 标准参考教程(alpha)》,by 阮一峰

# 概述

Promise 对象是 JavaScript 的异步操作解决方案,为异步操作提供统一接口。它起到代理作用(proxy),充当异步操作与回调函数之间的中介,使得异步操作具备同步操作的接口。Promise 可以让异步操作写起来,就像在写同步操作的流程,而不必一层层地嵌套回调函数。

注意,本章只是 Promise 对象的简单介绍。为了避免与后续教程的重复,更完整的介绍请看《ES6 标准入门》的《Promise 对象》一章。

首先,Promise 是一个对象,也是一个构造函数。

1
2
3
4
5
function f1(resolve, reject) {
// 异步代码...
}

var p1 = new Promise(f1);

上面代码中, Promise 构造函数接受一个回调函数 f1 作为参数, f1 里面是异步操作的代码。然后,返回的 p1 就是一个 Promise 实例。

Promise 的设计思想是,所有异步任务都返回一个 Promise 实例。Promise 实例有一个 then 方法,用来指定下一步的回调函数。

1
2
var p1 = new Promise(f1);
p1.then(f2);

上面代码中, f1 的异步操作执行完成,就会执行 f2 。

传统的写法可能需要把 f2 作为回调函数传入 f1 ,比如写成 f1(f2) ,异步操作完成后,在 f1 内部调用 f2 。Promise 使得 f1 和 f2 变成了链式写法。不仅改善了可读性,而且对于多层嵌套的回调函数尤其方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 传统写法
step1(function (value1) {
step2(value1, function (value2) {
step3(value2, function (value3) {
step4(value3, function (value4) {
// ...
});
});
});
});

// Promise 的写法
new Promise(step1).then(step2).then(step3).then(step4);

从上面代码可以看到,采用 Promises 以后,程序流程变得非常清楚,十分易读。注意,为了便于理解,上面代码的 Promise 实例的生成格式,做了简化,真正的语法请参照下文。

总的来说,传统的回调函数写法使得代码混成一团,变得横向发展而不是向下发展。Promise 就是解决这个问题,使得异步流程可以写成同步流程。

Promise 原本只是社区提出的一个构想,一些函数库率先实现了这个功能。ECMAScript 6 将其写入语言标准,目前 JavaScript 原生支持 Promise 对象。

# Promise 对象的状态

Promise 对象通过自身的状态,来控制异步操作。Promise 实例具有三种状态。

  • 异步操作未完成(pending)
  • 异步操作成功(fulfilled)
  • 异步操作失败(rejected)

上面三种状态里面, fulfilled 和 rejected 合在一起称为 resolved (已定型)。

这三种的状态的变化途径只有两种。

  • 从 “未完成” 到 “成功”
  • 从 “未完成” 到 “失败”

一旦状态发生变化,就凝固了,不会再有新的状态变化。这也是 Promise 这个名字的由来,它的英语意思是 “承诺”,一旦承诺成效,就不得再改变了。这也意味着,Promise 实例的状态变化只可能发生一次。

因此,Promise 的最终结果只有两种。

  • 异步操作成功,Promise 实例传回一个值(value),状态变为 fulfilled 。
  • 异步操作失败,Promise 实例抛出一个错误(error),状态变为 rejected 。

# Promise 构造函数

JavaScript 提供原生的 Promise 构造函数,用来生成 Promise 实例。

1
2
3
4
5
6
7
8
9
var promise = new Promise(function (resolve, reject) {
// ...

if (/* 异步操作成功 */){
resolve(value);
} else { /* 异步操作失败 */
reject(new Error());
}
});

上面代码中, Promise 构造函数接受一个函数作为参数,该函数的两个参数分别是 resolve 和 reject 。它们是两个函数,由 JavaScript 引擎提供,不用自己实现。

resolve 函数的作用是,将 Promise 实例的状态从 “未完成” 变为 “成功”(即从 pending 变为 fulfilled ),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去。 reject 函数的作用是,将 Promise 实例的状态从 “未完成” 变为 “失败”(即从 pending 变为 rejected ),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。

下面是一个例子。

1
2
3
4
5
6
7
function timeout(ms) {
return new Promise((resolve, reject) => {
setTimeout(resolve, ms, "done");
});
}

timeout(100);

上面代码中, timeout(100) 返回一个 Promise 实例。100 毫秒以后,该实例的状态会变为 fulfilled 。

# Promise.prototype.then()

Promise 实例的 then 方法,用来添加回调函数。

then 方法可以接受两个回调函数,第一个是异步操作成功时(变为 fulfilled 状态)时的回调函数,第二个是异步操作失败(变为 rejected )时的回调函数(该参数可以省略)。一旦状态改变,就调用相应的回调函数。

1
2
3
4
5
6
7
8
9
10
11
var p1 = new Promise(function (resolve, reject) {
resolve("成功");
});
p1.then(console.log, console.error);
// "成功"

var p2 = new Promise(function (resolve, reject) {
reject(new Error("失败"));
});
p2.then(console.log, console.error);
// Error: 失败

上面代码中, p1 和 p2 都是 Promise 实例,它们的 then 方法绑定两个回调函数:成功时的回调函数 console.log ,失败时的回调函数 console.error (可以省略)。 p1 的状态变为成功, p2 的状态变为失败,对应的回调函数会收到异步操作传回的值,然后在控制台输出。

then 方法可以链式使用。

1
p1.then(step1).then(step2).then(step3).then(console.log, console.error);

上面代码中, p1 后面有四个 then ,意味依次有四个回调函数。只要前一步的状态变为 fulfilled ,就会依次执行紧跟在后面的回调函数。

最后一个 then 方法,回调函数是 console.log 和 console.error ,用法上有一点重要的区别。 console.log 只显示 step3 的返回值,而 console.error 可以显示 p1 、 step1 、 step2 、 step3 之中任意一个发生的错误。举例来说,如果 step1 的状态变为 rejected ,那么 step2 和 step3 都不会执行了(因为它们是 resolved 的回调函数)。Promise 开始寻找,接下来第一个为 rejected 的回调函数,在上面代码中是 console.error 。这就是说,Promise 对象的报错具有传递性。

# then () 用法辨析

Promise 的用法,简单说就是一句话:使用 then 方法添加回调函数。但是,不同的写法有一些细微的差别,请看下面四种写法,它们的差别在哪里?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 写法一
f1().then(function () {
return f2();
});

// 写法二
f1().then(function () {
f2();
});

// 写法三
f1().then(f2());

// 写法四
f1().then(f2);

为了便于讲解,下面这四种写法都再用 then 方法接一个回调函数 f3 。写法一的 f3 回调函数的参数,是 f2 函数的运行结果。

1
2
3
4
5
f1()
.then(function () {
return f2();
})
.then(f3);

写法二的 f3 回调函数的参数是 undefined 。

1
2
3
4
5
6
f1()
.then(function () {
f2();
return;
})
.then(f3);

写法三的 f3 回调函数的参数,是 f2 函数返回的函数的运行结果。

1
f1().then(f2()).then(f3);

写法四与写法一只有一个差别,那就是 f2 会接收到 f1() 返回的结果。

1
f1().then(f2).then(f3);

# Promise 的实例

# 加载图片

我们可以把图片的加载写成一个 Promise 对象。

1
2
3
4
5
6
7
8
var preloadImage = function (path) {
return new Promise(function (resolve, reject) {
var image = new Image();
image.onload = resolve;
image.onerror = reject;
image.src = path;
});
};

# Ajax 操作

Ajax 操作是典型的异步操作,传统上往往写成下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function search(term, onload, onerror) {
var xhr, results, url;
url = "http://example.com/search?q=" + term;

xhr = new XMLHttpRequest();
xhr.open("GET", url, true);

xhr.onload = function (e) {
if (this.status === 200) {
results = JSON.parse(this.responseText);
onload(results);
}
};
xhr.onerror = function (e) {
onerror(e);
};

xhr.send();
}

search("Hello World", console.log, console.error);

如果使用 Promise 对象,就可以写成下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function search(term) {
var url = "http://example.com/search?q=" + term;
var xhr = new XMLHttpRequest();
var result;

var p = new Promise(function (resolve, reject) {
xhr.open("GET", url, true);
xhr.onload = function (e) {
if (this.status === 200) {
result = JSON.parse(this.responseText);
resolve(result);
}
};
xhr.onerror = function (e) {
reject(e);
};
xhr.send();
});

return p;
}

search("Hello World").then(console.log, console.error);

加载图片的例子,也可以用 Ajax 操作完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function imgLoad(url) {
return new Promise(function (resolve, reject) {
var request = new XMLHttpRequest();
request.open("GET", url);
request.responseType = "blob";
request.onload = function () {
if (request.status === 200) {
resolve(request.response);
} else {
reject(new Error("图片加载失败:" + request.statusText));
}
};
request.onerror = function () {
reject(new Error("发生网络错误"));
};
request.send();
});
}

# 小结

Promise 的优点在于,让回调函数变成了规范的链式写法,程序流程可以看得很清楚。它有一整套接口,可以实现许多强大的功能,比如同时执行多个异步操作,等到它们的状态都改变以后,再执行一个回调函数;再比如,为多个回调函数中抛出的错误,统一指定处理方法等等。

而且,Promise 还有一个传统写法没有的好处:它的状态一旦改变,无论何时查询,都能得到这个状态。这意味着,无论何时为 Promise 实例添加回调函数,该函数都能正确执行。所以,你不用担心是否错过了某个事件或信号。如果是传统写法,通过监听事件来执行回调函数,一旦错过了事件,再添加回调函数是不会执行的。

Promise 的缺点是,编写的难度比传统写法高,而且阅读代码也不是一眼可以看懂。你只会看到一堆 then ,必须自己在 then 的回调函数里面理清逻辑。

# 微任务

Promise 的回调函数属于异步任务,会在同步任务之后执行。

1
2
3
4
5
6
7
new Promise(function (resolve, reject) {
resolve(1);
}).then(console.log);

console.log(2);
// 2
// 1

上面代码会先输出 2,再输出 1。因为 console.log(2) 是同步任务,而 then 的回调函数属于异步任务,一定晚于同步任务执行。

但是,Promise 的回调函数不是正常的异步任务,而是微任务(microtask)。它们的区别在于,正常任务追加到下一轮事件循环,微任务追加到本轮事件循环。这意味着,微任务的执行时间一定早于正常任务。

1
2
3
4
5
6
7
8
9
10
11
12
setTimeout(function () {
console.log(1);
}, 0);

new Promise(function (resolve, reject) {
resolve(2);
}).then(console.log);

console.log(3);
// 3
// 2
// 1

上面代码的输出结果是 321 。这说明 then 的回调函数的执行时间,早于 setTimeout(fn, 0) 。因为 then 是本轮事件循环执行, setTimeout(fn, 0) 在下一轮事件循环开始时执行。

# 参考链接

  • Sebastian Porto, Asynchronous JS: Callbacks, Listeners, Control Flow Libs and Promises
  • Rhys Brett-Bowen, Promises/A+ - understanding the spec through implementation
  • Matt Podwysocki, Amanda Silver, Asynchronous Programming in JavaScript with “Promises”
  • Marc Harter, Promise A+ Implementation
  • Bryan Klimt, What’s so great about JavaScript Promises?
  • Jake Archibald, JavaScript Promises There and back again
  • Mikito Takada, 7. Control flow, Mixu’s Node book
Edited on