理解 JavaScript(ECMAScript 6)—— 异步编程

JavaScript 作为主要面向 Web 编程而创建的语言,其诞生初期即具有了应对异步的用户交互(如点击鼠标、按下键盘等)的能力。后续的 Node.js 引入了 callbacks 作为除事件模型以外的另一种实现异步编程的方式,而之后的 Promise 又使得 JavaScript 处理异步需求的能力更为强大。

一、异步编程基础

Event Model

当用户点击鼠标或按下键盘上的某个按键时,一个对应的特殊事件(比如 onclick)触发,该事件关联的一系列响应动作即被添加到工作队列中最终被执行。这是 JavaScript 中最基本的异步编程方式。

1
2
3
4
5
6
7
<button id="my-btn">Click Me</button>
<script>
let button = document.getElementById("my-btn")
button.onclick = function(event) {
console.log("Clicked")
}
</script>

callback

callback 模式与基于事件模型的异步编程类似,异步代码都是在后续的某个特定时间点执行。不同的是,其异步执行的操作(函数)需要作为参数传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
let fs = require("fs")

fs.readFile("example.txt", { encoding: "utf8" }, function(err, contents){
if (err) {
throw err
}
console.log(contents)
})

console.log("Hi!")

// Hi!
// This is an example text file

上面的例子使用了经典的 Node.js error-first 回调函数模式。readFile() 函数从硬盘读取某个文件,在文件读取完成之后执行 callback 函数。如果读取文件时发生错误,则传递给回调函数的 err 参数为错误对象;未发生错误则 content 参数中包含了读取的文件内容。

在 callback 模式中,readFile() 会立即开始执行,并且在文件读取的进度开启之后暂停。紧接着 readFile() 后面的 console.log("Hi!") 会立即执行并输出 Hi! 到屏幕上(此时 readFile() 处于暂停状态,回调函数中的 console.log(contents) 也并未执行)。
文件读取结束后,一个新的任务(即 callback 函数以及传递给它的参数)被添加到任务队列中等待最终被执行。
从输出中可以看到,Hi! 要先于 contents 被打印。

callback 模式的问题在于,当有过多的 callback 函数嵌套时,会出现称为 callback hell 的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
method1(function(err, result) {
if (err) {
throw err;
}
method2(function(err, result) {
if (err) {
throw err;
}
method3(function(err, result) {
if (err) {
throw err;
}
method4(function(err, result) {
if (err) {
throw err;
}
method5(result);
});
});
});
});

二、Promise

除了前面提到的 callback hell 会使代码变得过于繁杂以至于难以理解和调试之外,callback 模式对于处理某些较复杂的逻辑也有一定的局限性。
比如希望两个异步操作并行执行,在双方都完成之后提醒用户;或者两个异步任务同时开始但是只获取第一个任务执行完后的结果。在这些情景下,就需要同时追踪多个 callback 函数的状态。
promise 则针对以上情况做了相应的提升。

promise 是一种对应异步操作执行结果的“占位符”。

1
2
// readFile “保证”会在未来的某个时间点完成
let promise = readFile("example.txt")

readFile() 并不会立即开始读取文件。相反,它会直接返回一个 promise 对象表示异步的读取操作,方便在后续的代码中通过这个 promise 对象访问读取任务的结果。该 promise 代表的结果是否可用取决于其生命周期所处的阶段。

Promise 生命周期

promise 的生命周期起始于 pending (unsettled) 状态,表明对应的异步操作还未完成。比如前面的 let promise = readFile("example.txt"),在 readFile() 函数返回后 promise 就立即进入了 pending 状态。
而异步操作最终完成时,promise 则进入 settled 状态,具体包含两种情况:

  • Fulfilled:promise 对应的异步操作成功执行完毕
  • Rejected:promise 对应的异步操作未执行完毕(出现错误或其他情况)

Promise lifecycle

内部属性 [[PromiseState]] 用来标记其生命周期状态(如 pendingfulfilledrejected),该属性不对 promise 对象外部暴露,因此不可以人为修改 promise 对象的生命周期。但是可以在 promise 的状态改变时通过 then() 方法自动触发一系列动作。

所有的 promise 对象都具有 then() 方法,该方法可以接收两个函数作为参数。第一个参数为当 promise 状态为 fulfilled 时调用的函数,所有与异步操作相关的数据都会被传递给该函数;第二个参数为当 promise 状态为 rejected 时调用的函数。这两个参数都是可选的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let promise = readFile("example.txt");

promise.then(function(contents) {
// fulfillment
console.log(contents)
}, function(err) {
// rejection
console.error(err.message)
});

promise.then(function(contents) {
// fulfillment
console.log(contents);
});

promise.then(null, function(err) {
// rejection
console.error(err.message);
});

promise.catch(function(err) {
// rejection
console.error(err.message);
});

创建 Promise

promise 可以使用 Promise 构造器创建,该构造器接收一个称为 executor 的函数作为参数,包含了初始化 promise 的代码。
executor 接收 resolve()reject() 两个函数作为参数,resolve() 将在 executor 执行成功后调用,传递 promise 已做好准备的信号;executor 执行失败了则调用 reject()

一个 Promise 的完整示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
let fs = require("fs")

function readFile(filename) {
return new Promise(function(resolve, reject) {
fs.readFile(filename, { encoding: "utf8" }, function(err, contents) {
// check for errors
if (err) {
reject(err)
return
}
// the read succeeded
resolve(contents)
})
})
}

let promise = readFile("example.txt")

// listen for both fulfillment and rejection
promise.then(function(contents) {
// fulfillment
console.log(contents)
}, function(err) {
// rejection
console.error(err.message)
})

console.log("Hi!")
// Hi!
// This is an example text file

Promise 的执行流程

参考如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
console.log("At code start")

var delayedPromise = new Promise((resolve, reject) => {
console.log("delayedPromise executor")
setTimeout(() => {
console.log("Resolving delayedPromise")
resolve("Hello")
}, 1000)
})

console.log("After creating delayedPromise")

delayedPromise.then(contents => {
console.log("delayedPromise resolve handled with", contents)
})

const immediatePromise = new Promise((resolve, reject) => {
console.log("immediatePromise executor")
resolve("World")
})

immediatePromise.then(contents => {
console.log("immediatePromise resolve handled with", contents)
})

console.log("At code end")
// At code start
// delayedPromise executor
// After creating delayedPromise
// immediatePromise executor
// At code end
// immediatePromise resolve handled with World
// Resolving delayedPromise
// delayedPromise resolve handled with Hello

具体的执行逻辑为:

  • 代码开始执行,通过 Promise 构造器创建一个 delayedPromise,其中的 console.log()setTimeout()(也可以是其他异步操作)函数立即执行
  • delayedPromise 创建之后,其最终的结果和状态(是否成功执行)不能立即知晓,因此处于 pending 状态
  • 调用 delayedPromisethen 方法,将一个当 promise 成功 resolve 后才执行的 callback 函数放到执行计划中
  • 继续创建另一个 immediatePromise,该 promise 会在创建的过程中立即 resolve,因此其创建完成后即处于 resolved 状态
  • 调用 immediatePromisethen 方法,注册一个当 promise 成功 resolve 后才执行的 callback 函数

从最终的结果中可以看出,即便 immediatePromise 在创建后即处于 resolved 状态,At code end 实际上是先于前面的 immediatePromise.then() 输出的。
原因是 promise 被设计成专门针对异步操作,then() 方法中的 callback 会永远在当前事件循环中所有代码执行完后才开始触发。

因此实际的执行顺序为:
At code start -> 创建 delayedPromise -> 通过 then() 注册 delayPromise 状态为 resolved 时触发的 callback -> 创建 immediatePromise -> 通过 then() 注册 immediatePromise 状态为 resolved 时触发的 callback -> At code end -> immediatePromise 先 resolved,其关联的 callback 执行 -> delayedPromise resolved,其关联的 callback 执行

三、Chaining Promises

截止到前面的介绍,promise 看起来只不过在 callback 的基础上做了一点点有限的提升。实际 promise 支持多种形式的连接,足以完成更加复杂的异步逻辑。

每次对 promise 的 then()catch() 方法的调用,实际上都会创建和返回另一个 promise 对象。第二个 promise 对象只有在第一个 promise fulfilled 或 rejected 后才会被 resolve。

1
2
3
4
5
6
7
8
9
10
11
12
let p1 = new Promise(function(resolve, reject) {
resolve(42)
})

p1.then(function(value) {
console.log(value)
}).then(function() {
console.log("Finished")
})

// 42
// Finished

unchained 版本:

1
2
3
4
5
6
7
8
9
10
11
let p1 = new Promise(function(resolve, reject) {
resolve(42)
})

let p2 = p1.then(function(value) {
console.log(value)
})

p2.then(function() {
console.log("Finished")
})

p2.then() 也会返回一个 promise 对象,只不过它没有在代码中使用。

错误捕获

Promise chaining 允许用户捕获之前的 promise 中出现的错误。

1
2
3
4
5
6
7
8
9
10
let p1 = new Promise(function(resolve, reject) {
resolve(42)
})

p1.then(function(value) {
throw new Error("Boom!")
}).catch(function(error) {
console.log(error.message)
})
// Boom!

p1 的 fulfillment handler 抛出异常,第二个 promise 的 catch() 方法通过它的 rejection handler 接收到该异常。同样的方式也适用于 rejection handler 抛出异常:

1
2
3
4
5
6
7
8
9
10
11
12
let p1 = new Promise(function(resolve, reject) {
throw new Error("Explosion!")
})

p1.catch(function(error) {
console.log(error.message)
throw new Error("Boom!")
}).catch(function(error) {
console.log(error.message)
})
// Explosion!
// Boom!

executor 抛出异常触发 p1 的 rejection handler,该 handler 又抛出另一个异常触发第二个 promise 的 rejection handler。

Promise Chain 中的返回值

Promise Chain 中另一个很重要的特性即在两个 promise 之间传递数据。之前的代码中,可以通过 executor 中的 resovle() 函数将值传递给该 promise 的 fulfillment handler。此外,还可以通过为 fulfillment handler 指定一个返回值,将该值沿着 promise chain 传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
let p1 = new Promise(function(resolve, reject) {
resolve(42)
})

p1.then(function(value) {
console.log(value)
return value + 1
}).then(function(value) {
console.log(value)
})

// 42
// 43

同样的操作也可以用在 rejection handler 上:

1
2
3
4
5
6
7
8
9
10
11
12
13
let p1 = new Promise(function(resolve, reject) {
reject(42)
})

p1.catch(function(value) {
console.log(value)
return value + 1
}).then(function(value) {
console.log(value)
})

// 42
// 43

四、响应多个 Promise

之前的代码中都是一次只响应一个 promise,但是有时候需要监控多个 promise 的状态并决定之后的动作。ECMAScript 6 提供了两种方法(Promise.all()Promise.race)应对这些情况。

Promise.all()

Promise.all() 方法只接收一个包含所有需要监控的 promise 的可迭代对象(如列表)作为参数,并且只有当这些需要监控的 promise 全部 resolved 时,Promise.all() 返回的 promise 才会 resolved。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let p1 = new Promise(function(resolve, reject) {
resolve(42)
})

let p2 = new Promise(function(resolve, reject) {
resolve(43)
})

let p3 = new Promise(function(resolve, reject) {
resolve(44)
})

let p4 = Promise.all([p1, p2, p3])

p4.then(function(value) {
console.log(Array.isArray(value)) // true
console.log(value[0]) // 42
console.log(value[1]) // 43
console.log(value[2]) // 44
})

Promise.all() 创建了 promise p4。只有当列表中的 promise p1,p2,p3 全部 fulfilled 之后,p4 最终才会 fulfilled。
前面 3 个 promise resolve 的数字组成列表传递给 p4 的 fulfillment handler,这些数字与生产它们的 promise 的位置是一一对应的。

如果任意一个传入 Promise.all() 的 promise 状态是 rejected,则 Promise.all() 返回的 promise 也会立即 rejected,不会等待其他 promise 结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let p1 = new Promise(function(resolve, reject) {
resovle(42)
})

let p2 = new Promise(function(resolve, reject) {
reject(43)
})

let p3 = new Promise(function(resolve, reject) {
resolve(44)
})

let p4 = Promise.all([p1, p2, p3])
p4.catch(function(value) {
console.log(Array.isArray(value)) // false
console.log(value) // 43
})

在上面的代码中,p2 的状态为 rejected,p4 的 rejection handler 会立即调用,不会等待 p1 和 p3 执行完毕(p1 和 p3 最终会执行完毕,只是 p4 不会等它们)。

Promise.race()

Promise.race() 同样接收一个包含需要监控的多个 promise 的可迭代对象,返回一个新的 promise。但是不同于 Promise.all() 会等待所有监控中的 promise resolved,Promise.race() 会在列表中任意一个 promise resolve 后立即返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let p1 = new Promise(function(resolve, reject) {
setTimeout(resolve, 500, 42)

})

let p2 = new Promise(function(resolve, reject) {
setTimeout(resolve, 100, 43)
})

let p3 = new Promise(function(resolve, reject) {
setTimeout(resolve, 200, 44)

})

let p4 = Promise.race([p1, p2, p3])

p4.then(function(value) {
console.log(value)
})

// 43

传递给 Promise.race() 的 promise 像是处在一个赛道中,看哪一个先执行完毕。如果第一个运行完的 promise 状态为 fulfilled,则最后返回的 promise 状态为 fulfilled;如果第一个运行完的 promise 状态为 rejected,则最后返回的 promise 状态为 rejected。

参考资料

Understanding ECMAScript 6
Secrets of the JavaScript Ninja, Second Edition