前言

本读书笔记节是针对阮一峰编写的 《ECMAScript 6 入门教程》的个人解读。适合已经掌握 ES5 的读者,用来了解这门语言的最新发展。笔记内容仅包括日常开发中可能会用到的概念,剩余部分请自行阅读。


16. Promise 对象

16.1 事件循环

推荐阅读:js 事件循环机制 event-loop


16.2 Promise 的含义

Promise 是异步编程的一种解决方案,比传统的回调函数事件,更合理和更强大。它类似于一个容器,里面保存着某个未来才会结束的事件,通常是一个异步操作的结果。

Promise对象有以下两个特点:

  • Promise对象的状态不受外界影响Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态
  • Promise对象一旦状态改变,就不会再变Promise对象的状态改变,只有两种可能:从pending变为fulfilled和从pending变为rejected。只要这两种情况发生,状态就凝固了,这时就称为 resolved(已定型)。

Promise对象的好处,是可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise对象提供统一的接口,使得控制异步操作更加容易。

Promise也有缺点。首先,无法取消Promise,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。第三,当处于pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。


16.3 基本用法

Promise的构造函数接受一个函数作为参数,该函数的两个参数分别是resolvereject。它们是两个函数,由 JavaScript 引擎提供,不用自己部署。

  • resolve函数的作用是,将Promise对象的状态从“未完成”变为“成功”(即从 pending 变为 resolved),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去。
  • reject函数的作用是,将Promise对象的状态从“未完成”变为“失败”(即从 pending 变为 rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。
const promise = new Promise(function(resolve, reject) {
// ...
if (/* 异步操作成功 */){
resolve(value);
} else {
reject(error);
}
});

Promise实例生成后可以then方法指定resolvedrejected的回调函数。这两个函数都是可选的,不一定要提供。它们都接受Promise对象传出的值作为参数

promise.then(
function (value) {
// success
},
function (error) {
// failure
}
);

下面是一个Promise对象的例子。

function timeout(ms) {
return new Promise((resolve, reject) => {
// https://developer.mozilla.org/en-US/docs/Web/API/setTimeout
setTimeout(resolve, ms, "done"); // 'done' 会传入回调函数 resolve
});
}

timeout(1000).then((value) => {
console.log(value);
});
// 1秒后 "done"

上面代码中,timeout方法返回一个Promise实例,表示一段时间以后才会发生的结果。过了指定的时间后,Promise实例的状态变为resolved,就会触发then方法绑定的回调函数。

Promise 新建后就会立即执行

let promise = new Promise(function (resolve, reject) {
console.log("Hello");
resolve();
});

promise.then(function () {
console.log("resolved.");
});

console.log("Hi!");

// Hello
// Hi!
// resolved

上面代码中,Promise 新建后立即执行,所以首先输出的是Hello。然后,then方法指定的回调函数,将在当前脚本所有同步任务执行完才会执行,所以resolved最后输出。

如果调用resolve函数和reject函数时带有参数,那么它们的参数会被传递给回调函数

  • reject函数的参数通常是Error对象的实例,表示抛出的错误。
  • resolve函数的参数除了正常的值以外,还可能是另一个 Promise 实例。
const p1 = new Promise(function (resolve, reject) {
// ...
});

const p2 = new Promise(function (resolve, reject) {
// ...
resolve(p1);
});

上面代码中,p1p2都是 Promise 的实例,但是p2resolve方法将p1作为参数,即一个异步操作的结果是返回另一个异步操作。这时p1的状态就会传递给p2,此时p1的状态决定了p2的状态。

  • 如果p1的状态是pending,那么p2的回调函数就会等待p1的状态改变。
  • 如果p1的状态已经是resolved或者rejected,那么p2的回调函数将会立刻执行。
const p1 = new Promise(function (resolve, reject) {
setTimeout(() => reject(new Error("fail")), 3000);
});

const p2 = new Promise(function (resolve, reject) {
setTimeout(() => resolve(p1), 1000);
});

p2.then((result) => console.log(result)).catch((error) => console.log(error));
// Error: fail

上面代码中,p1是一个 Promise3 秒之后变为rejectedp2的状态在 1 秒之后改变,而且resolve方法返回的是p1

  • 由于p2返回的是另一个 Promise,导致p2自己的状态无效了,p1的状态决定p2的状态。所以,后面的then语句都变成针对后者p1。又过了 2 秒,p1变为rejected,导致触发catch方法指定的回调函数。

注意,调用resolvereject并不会终结 Promise 的参数函数的执行。

new Promise((resolve, reject) => {
resolve(1);
console.log(2);
}).then((r) => {
console.log(r);
});
// 2
// 1

上面代码中,调用resolve(1)以后,后面的console.log(2)还是会执行,并且会首先打印出来。这是因为立即 resolvedPromise 是在本轮事件循环的末尾执行,总是晚于本轮循环的同步任务

一般来说,调用resolvereject以后,Promise 的使命就完成了,后继操作应该放到then方法里面,而不应该直接写在resolvereject的后面。所以,最好在它们前面加上return语句,这样就不会有意外。

new Promise((resolve, reject) => {
return resolve(1);
// 后面的语句不会执行
console.log(2);
});

16.4 then 方法

Promise.then()方法的作用是为 Promise 实例添加状态改变时的回调函数。then方法的第一个参数是resolved状态的回调函数,第二个参数是rejected状态的回调函数,它们都是可选的。

then方法返回的是一个新的Promise实例,因此可以采用链式写法,即then方法后面再调用另一个then方法。

getJSON("/posts.json")
.then(function (json) {
return json.post;
})
.then(function (post) {
// ...
});

上面的代码使用then方法,依次指定了两个回调函数。第一个回调函数完成以后,会将返回结果作为参数,传入第二个回调函数

链式写法的then,可以指定一组按照次序调用的回调函数。这时,前一个回调函数,有可能返回的还是一个Promise对象(即有异步操作),这时后一个回调函数,就会等待该Promise对象的状态发生变化,才会被调用

getJSON("/post/1.json")
.then(function (post) {
return getJSON(post.commentURL);
})
.then(
function (comments) {
console.log("resolved: ", comments);
},
function (err) {
console.log("rejected: ", err);
}
);

上面代码中,第一个then方法指定的回调函数,返回的是另一个Promise对象。这时,第二个then方法指定的回调函数,就会等待这个新的Promise对象状态发生变化

  • 如果变为resolved,第二个then方法就调用第一个回调函数。
  • 如果状态变为rejected,第二个then方法就调用第二个回调函数。

如果采用箭头函数,上面的代码可以写得更简洁。

getJSON("/post/1.json")
.then((post) => getJSON(post.commentURL))
.then(
(comments) => console.log("resolved: ", comments),
(err) => console.log("rejected: ", err)
);

16.5 catch 方法

Promise.catch()方法用于指定发生错误时的回调函数。它返回的是一个新的Promise对象

getJSON("/posts.json")
.then(function (posts) {
// ...
})
.catch(function (error) {
// 处理 getJSON 和 前一个回调函数运行时发生的错误
console.log("发生错误!", error);
});

上面代码中,getJSON()方法返回一个 Promise 对象。

  • 如果该对象状态变为resolved,则会调用then()方法指定的回调函数。
  • 如果异步操作抛出错误,状态就会变为rejected,就会调用catch()方法指定的回调函数处理这个错误。

注意,then()方法指定的回调函数,如果运行中抛出错误,也会被catch()方法捕获

下面代码中,promise抛出一个错误,被catch()方法指定的回调函数捕获。

const promise = new Promise(function (resolve, reject) {
throw new Error("test"); // or reject(new Error('test'));
});

promise.catch(function (error) {
console.log(error);
});
// Error: test

下面两种写法与上面是等价的。(比较喜欢try...catch...的写法)

// 写法一
const promise = new Promise(function (resolve, reject) {
try {
throw new Error("test");
} catch (e) {
reject(e);
}
});

promise.catch(function (error) {
console.log(error);
});

// 写法二
const promise = new Promise(function (resolve, reject) {
reject(new Error("test"));
});

promise.catch(function (error) {
console.log(error);
});

注意,如果Promise状态已经变成resolved,再抛出错误是无效的。

const promise = new Promise(function (resolve, reject) {
resolve("ok");
throw new Error("test");
});

promise
.then(function (value) {
console.log(value);
})
.catch(function (error) {
console.log(error);
});
// ok

上面代码中,Promiseresolve语句后面,再抛出错误,并不会被捕获。因为 Promise 的状态一旦改变,就永久保持该状态。

Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个catch语句捕获。

getJSON("/post/1.json")
.then(function (post) {
return getJSON(post.commentURL);
})
.then(function (comments) {
// some code
})
.catch(function (error) {
// 处理前面三个Promise产生的错误
});

上面代码中,一共有三个 Promise 对象:一个由getJSON()产生,两个由then()产生。它们之中任何一个抛出的错误,都会被最后一个catch()捕获。

一般来说,不要在then()方法里面定义 Reject 状态的回调函数,总是使用catch方法

// bad
promise.then(
function (data) {
// success
},
function (err) {
// error
}
);

// good
promise
.then(function (data) {
// success
})
.catch(function (err) {
// error
});

跟传统的try/catch代码块不同的是,如果没有使用catch()方法指定错误处理的回调函数,Promise 对象抛出的错误不会传递到外层代码,即不会有任何反应。

const someAsyncThing = function () {
return new Promise(function (resolve, reject) {
// 下面一行会报错,因为 x 没有声明
resolve(x + 2);
});
};

someAsyncThing().then(function () {
console.log("everything is great");
});

setTimeout(() => {
console.log(123);
}, 2000);
// Uncaught (in promise) ReferenceError: x is not defined
// 123

上面代码中,someAsyncThing()函数产生的 Promise 对象,内部有语法错误。运行到这一行时,会打印出错误提示,但是不会终止脚本执行,2 秒之后还是会输出123。这就是说,Promise 内部的错误不会影响到 Promise 外部的代码,通俗的说法就是“Promise 会吃掉错误”。

所以建议,Promise 对象后面要跟catch()方法,这样可以处理 Promise 内部发生的错误。注意,catch()方法返回的是一个新的Promise对象,因此后面还可以接着调用then()方法,或者catch()方法,去处理前一个catch()方法抛出的错误。


16.6 finally 方法

Promise.finally()方法用于指定不管Promise对象最后状态如何,都会执行的操作。

promise
.then(result => {···})
.catch(error => {···})
.finally(() => {···});

上面代码中,不管promise最后的状态,在执行完thencatch指定的回调函数以后,都会执行finally方法指定的回调函数。

下面是一个例子,服务器使用 Promise 处理请求,然后使用finally方法关掉服务器。

server
.listen(port)
.then(function () {
// ...
})
.finally(server.stop);

由于finally方法的回调函数不接受任何参数,这表明finally方法里面的操作,应该是与状态无关的,不依赖于Promise的执行结果


16.7 all 方法

Promise.all()方法用于将多个Promise实例,包装成一个新的Promise实例。

const p = Promise.all([p1, p2, p3]); // 接受一个数组作为参数

上面p的状态由p1p2p3决定,分成两种情况:

  • 只有p1p2p3状态都变成fulfilledp的状态才会变成fulfilled,此时p1p2p3的返回值组成一个数组,传递给p的回调函数
  • 只要p1p2p3之中有一个被rejectedp的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数
// 生成一个Promise对象的数组
const promises = [2, 3, 5, 7, 11, 13].map(function (id) {
return getJSON("/post/" + id + ".json");
});

Promise.all(promises)
.then(function (posts) {
// ...
})
.catch(function (reason) {
// ...
});

上面代码中,只有promises6Promise实例的状态都变成fulfilled,或者有一个变为rejected,才会调用Promise.all()方法后面的回调函数。

下面是另一个例子。

const databasePromise = connectDatabase(); // 这里应该是返回一个 Promise 实例

const booksPromise = databasePromise.then(findAllBooks); // 返回一个 Promise Object,为当前 Promise 的状态

const userPromise = databasePromise.then(getCurrentUser);

Promise.all([booksPromise, userPromise]).then(([books, user]) =>
pickTopRecommendations(books, user)
);

上面代码中,booksPromiseuserPromise是两个异步操作,只有等到它们的结果都返回了,才会触发pickTopRecommendations这个回调函数。

注意,如果作为参数的 Promise 实例,自己定义了catch方法,那么它一旦被rejected,并不会触发Promise.all()catch方法

const p1 = new Promise((resolve, reject) => {
resolve("hello");
})
.then((result) => result)
.catch((e) => e);

const p2 = new Promise((resolve, reject) => {
throw new Error("报错了");
})
.then((result) => result)
.catch((e) => e);

Promise.all([p1, p2])
.then((result) => console.log(result))
.catch((e) => console.log(e));
// ["hello", Error: 报错了]

上面代码中,p1resolvedp2会被rejected。但是p2有自己的catch方法,返回一个新的 Promise 实例,p2实际指向的是这个新的实例。该实例执行完catch方法后,也会变成resolved,导致Promise.all()方法参数里面的两个实例都会resolved,因此会调用then方法指定的回调函数,而不会调用catch方法指定的回调函数。

如果p2没有自己的catch方法,就会调用Promise.all()catch方法。


16.8 race 方法

Promise.race()方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。

const p = Promise.race([p1, p2, p3]);

上面代码中,只要p1p2p3之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给p的回调函数。

下面如果指定时间内没有获得结果,就将 Promise 的状态变为reject,否则变为resolve

const p = Promise.race([
fetch("/resource-that-may-take-a-while"),
new Promise(function (resolve, reject) {
setTimeout(() => reject(new Error("request timeout")), 5000);
}),
]);

p.then(console.log).catch(console.error);

上面代码中,如果 5 秒之内fetch方法无法返回结果,变量p的状态就会变为rejected,从而触发catch方法指定的回调函数。


16.9 allSettled 方法

有时候,我们希望等到一组异步操作都结束,不管每一个操作是成功还是失败,再进行下一步操作。为了解决这个问题,ES6 引入了Promise.allSettled()方法。

Promise.allSettled()方法接受一个Promise数组作为参数,并返回一个新的 Promise 对象。只有等到参数数组的所有 Promise 对象都发生状态变更,不管是fulfilled还是rejected,返回的 Promise 对象才会发生状态变更

const promises = [fetch("/api-1"), fetch("/api-2"), fetch("/api-3")];

await Promise.allSettled(promises);
removeLoadingIndicator();

上面示例中,数组promises包含了三个请求,只有等到这三个请求都结束了,不管请求成功还是失败,removeLoadingIndicator()才会执行。

Promise.allSettled()返回的 Promise 实例,状态总是fulfilled,不会变成rejected

const resolved = Promise.resolve(42);
const rejected = Promise.reject(-1);

const allSettledPromise = Promise.allSettled([resolved, rejected]);

allSettledPromise.then(function (results) {
console.log(results);
});
// [
// { status: 'fulfilled', value: 42 },
// { status: 'rejected', reason: -1 }
// ]

上面代码中,Promise.allSettled()的返回值allSettledPromise,状态只可能变成fulfilled它的回调函数接收到的参数是数组results,对应传入Promise.allSettled()的数组里面的两个 Promise 对象

上面results的每个成员是一个对象,对应异步操作的结果。(重点)

// 异步操作成功时
{status: 'fulfilled', value: value}

// 异步操作失败时
{status: 'rejected', reason: reason}

成员对象的status属性的值只可能是字符串fulfilled或字符串rejected,用来区分异步操作是成功还是失败。如果是成功fulfilled,对象会有value属性,如果是失败rejected,会有reason属性,对应两种状态时前面异步操作的返回值。


16.10 any 方法

Promise.any() 接受一组 Promise 实例作为参数,包装成一个新的 Promise 实例返回。

Promise.any([
fetch("https://v8.dev/").then(() => "home"),
fetch("https://v8.dev/blog").then(() => "blog"),
fetch("https://v8.dev/docs").then(() => "docs"),
])
.then((first) => {
// 只要有一个 fetch() 请求成功
console.log(first);
})
.catch((error) => {
// 所有三个 fetch() 全部请求失败
console.log(error);
});
  • 只要参数实例有一个变成fulfilled状态,包装实例就会变成fulfilled状态。
  • 如果所有参数实例都变成rejected状态,包装实例就会变成rejected状态。

下面是Promise()await命令结合使用的例子。

const promises = [
fetch("/endpoint-a").then(() => "a"),
fetch("/endpoint-b").then(() => "b"),
fetch("/endpoint-c").then(() => "c"),
];

try {
const first = await Promise.any(promises);
console.log(first);
} catch (error) {
console.log(error);
}

上面代码中,Promise.any()方法的参数包含三个 Promise 操作。其中只要有一个变成fulfilledPromise.any()返回的 Promise 对象就变成fulfilled。如果所有三个操作都变成rejected,那么await命令就会抛出错误。


16.11 resolve 方法

有时需要将现有对象转为 Promise 对象,Promise.resolve()方法就起到这个作用。

(1)参数是一个 thenable 对象

thenable对象指的是具有then方法的对象,比如下面这个对象。

let thenable = {
then: function (resolve, reject) {
resolve(42);
},
};

let p1 = Promise.resolve(thenable);

p1.then(function (value) {
console.log(value); // 42
});

上面代码中,resolve()方法会将这个对象转为 Promise 对象,然后立即执行thenable对象的then()方法

(2)参数不是具有 then()方法的对象,或根本就不是对象

如果参数是一个原始值,或者是一个不具有then()方法的对象,则resolve()方法返回一个新的 Promise 对象,状态为resolved

const p = Promise.resolve("Hello");

p.then(function (s) {
console.log(s);
});
// Hello

(3)不带有任何参数

resolve()方法调用时可以不带参数,直接返回一个resolved状态的 Promise 对象

const p = Promise.resolve();

p.then(function () {
// ...
});

立即resolve()Promise 对象,是在本轮“事件循环”的结束时执行,而不是在下一轮“事件循环”的开始时

setTimeout(function () {
console.log("three");
}, 0);

Promise.resolve().then(function () {
console.log("two");
});

console.log("one");

// one
// two
// three

上面代码中,setTimeout(fn, 0)在下一轮“事件循环”开始时执行,resolve()在本轮“事件循环”结束时执行,console.log('one')则是立即执行,因此最先输出。


16.12 reject 方法

Promise.reject(reason)方法也会返回一个新的 Promise 实例,该实例的状态为rejected

const p = Promise.reject("出错了");
// 等同于
const p = new Promise((resolve, reject) => reject("出错了"));

p.then(null, function (s) {
console.log(s);
});
// 出错了

Promise.reject()方法的参数,会原封不动地作为reject的理由,变成后续方法的参数。

Promise.reject("出错了").catch((e) => {
console.log(e === "出错了");
});
// true

17. Iterator 和 for…of 循环

17.1 Iterator 概念

遍历器Iterator是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作,依次处理该数据结构的所有成员。

Iterator 的遍历过程:

  1. 创建一个指针对象,指向当前数据结构的起始位置。
  2. 第一次调用指针对象的next方法,可以将指针指向数据结构的第一个成员。
  3. 第二次调用指针对象的next方法,指针就指向数据结构的第二个成员。
  4. 不断调用指针对象的next方法,直到它指向数据结构的结束位置。

每一次调用next方法,都会返回数据结构的当前成员的信息。具体来说,就是返回一个包含valuedone两个属性的对象。其中,value属性是当前成员的值,done属性是一个布尔值,表示遍历是否结束

const a = [1, 5, 15, 25];

const it = a[Symbol.iterator]();

it.next().value; // 1
it.next().value; // 5
it.next().value; // 15

ES6 规定,默认的 Iterator 接口部署在数据结构的Symbol.iterator属性,或者说,一个数据结构只要具有Symbol.iterator属性,就可以认为是“可遍历的”。

const obj = {
numbers: [1, 2, 3],
[Symbol.iterator]: function () {
const self = this;
let idx = -1; // <--- 抵消 ++ 的 side-effect
return {
next: function () {
idx++;
// 可以尝试 log 出下面的 object
return {
value: self.numbers[idx],
done: idx >= self.numbers.length,
};
},
};
},
};

for (const num of obj) console.log(num); // 1 2 3

上面代码中,对象obj是可遍历的,因为具有Symbol.iterator属性。执行这个属性,会返回一个遍历器对象。该对象的根本特征就是具有next方法。每次调用next方法,都会返回一个代表当前成员的信息对象,具有valuedone两个属性。

// 上面 iterator 打印出来的 objects
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: false }
{ value: undefined, done: true }

注意,done 不代表返回的值是否是最后一个,而是表示迭代器是否没有更多的值可以返回。


17.2 for…of 循环

(1)数组,Set 与 Map

ES6 的数组,SetMap 原生具有 Iterator 接口。

const arr = ["red", "green", "blue"];

for (let color of arr) {
console.log(color);
}
// red green blue

let set = new Set(arr);
for (let color of set) {
console.log(color);
}
// red green blue

let map = new Map().set("a", 1).set("b", 2);
for (let pair of map) {
console.log(pair);
}
// ['a', 1]
// ['b', 2]

for (let [key, value] of map) {
console.log(key + " : " + value);
}
// a : 1
// b : 2

上面代码可以看出,SetMap 遍历的顺序是按照各个成员被添加进数据结构的顺序。

(2)计算生成的数据结构

有些数据结构是在现有数据结构的基础上,计算生成的。比如,ES6 的数组、SetMap 都部署了以下三个方法,调用后都返回遍历器对象。

  • entries() 返回一个遍历器对象,用来遍历[键名, 键值]组成的数组。
  • keys() 返回一个遍历器对象,用来遍历所有的键名。
  • values() 返回一个遍历器对象,用来遍历所有的键值。
let arr = ["a", "b", "c"];
for (let pair of arr.entries()) {
console.log(pair);
}
// [0, 'a']
// [1, 'b']
// [2, 'c']

(3)类似数组的对象

字符串是一个类似数组的对象,也原生具有 Iterator 接口。

// 字符串
let str = "hello";

for (let s of str) {
console.log(s); // h e l l o
}

// arguments对象
function printArgs() {
for (let x of arguments) {
console.log(x);
}
}

printArgs("a", "b");
// 'a'
// 'b'

(4)对象

for...of不能直接使用于普通对象上,必须部署 Iterator 接口后才能使用。

  • 一种解决方法是,遍历Object.keys()生成的对象键名的数组。
  • 一种解决方法是,使用 Generator 函数将对象重新包装。
const obj = { a: 1, b: 2, c: 3 };

// 方案一
for (let key of Object.keys(obj)) {
console.log(key, "->", value);
}

// 方案二
function* entries(obj) {
for (let key of Object.keys(obj)) {
yield [key, obj[key]];
}
}

for (let [key, value] of entries(obj)) {
console.log(key, "->", value);
}
// a -> 1
// b -> 2
// c -> 3

18. Generator 函数

18.1 基本概念

Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同。

  • 语法上:Generator 函数类似一个状态机,封装了多个内部状态。执行 Generator 函数会返回一个遍历器对象,可以依次遍历 Generator 函数内部的每一个状态
  • 形式上:Generator 函数是一个普通函数,但是有两个特征。一是,function关键字与函数名之间有一个星号;二是,函数体内部使用yield表达式,定义不同的内部状态。
function* hwGenerator() {
yield "hello";
yield "world";
return "ending";
}

let hw = hwGenerator();

上面代码定义了一个 Generator 函数hwGenerator,它内部有两个yield表达式helloworld,即该函数有三个状态:helloworldreturn 语句。

Generator 函数的调用方法与普通函数一样。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象。通过调用遍历器对象的next方法,使得指针移向下一个状态

每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。

hw.next();
// { value: 'hello', done: false }

hw.next();
// { value: 'world', done: false }

hw.next();
// { value: 'ending', done: true }

hw.next();
// { value: undefined, done: true }

总结,调用 Generator 函数,返回一个遍历器对象,代表 Generator 函数的内部指针。每次调用遍历器对象的next方法,就会返回一个有着valuedone两个属性的对象。value属性表示当前的内部状态的值,是yield表达式后面那个表达式的值;done属性是一个布尔值,表示是否遍历结束。


18.2 yield 表达式

由于 Generator 函数返回的遍历器对象,只有调用next方法才会遍历下一个内部状态,所以Generator其实提供了一种可以暂停执行的函数yield表达式就是暂停标志。

遍历器对象的next方法的运行逻辑如下:

  • 遇到yield表达式,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回的对象的value属性值
  • 下一次调用next方法时,再继续往下执行,直到遇到下一个yield表达式。
  • 如果没有再遇到新的yield表达式,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值。
  • 如果该函数没有return语句,则返回的对象的value属性值为undefined
function* gen() {
yield 123 + 456;
}

let g = gen();
g.next(); // {value: 579, done: false}
g.next(); // {value: undefined, done: true}

yield表达式与return语句既有相似之处,也有区别。相似之处在于,都能返回紧跟在语句后面的那个表达式的值。区别在于每次遇到yield,函数暂停执行,下一次再从该位置继续向后执行,而return语句不具备位置记忆的功能。一个函数里面,只能执行一次return语句,但是可以执行多次yield表达式。


18.3 与 Iterator 接口的关系

由于 Generator 函数就是遍历器生成函数,因此可以把 Generator 赋值给对象的Symbol.iterator属性,从而使得该对象具有 Iterator 接口。

let myIterable = {};
myIterable[Symbol.iterator] = function* () {
yield 1;
yield 2;
yield 3;
};

[...myIterable]; // [1, 2, 3]

上面代码中,Generator 函数赋值给Symbol.iterator属性,从而使得myIterable对象具有了 Iterator 接口,可以被...运算符遍历了。


18.4 next 方法的参数

yield表达式本身没有返回值,或者说总是返回undefinednext方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值。(每一次调用next方法,都会返回数据结构的当前成员的信息)

function* f() {
for (let i = 0; true; i++) {
let reset = yield i;
if (reset) {
i = -1;
}
}
}

let g = f(); // 启动

g.next(); // { value: 0, done: false }
g.next(); // { value: 1, done: false }
g.next(true); // { value: 0, done: false }
// 此时 rest 为 true,所以 i = -1,又因为 i++,
// 所以下一次运行到 yield i 的时候 i 就是 0

上面代码定义了一个可以无限运行的 Generator 函数f,如果next方法没有参数,每次运行到yield表达式,变量reset的值总是undefined。当next方法带一个参数true时,变量reset就被重置为这个参数,因此i会等于-1,下一轮循环就会从-1开始递增。

这个功能有很重要的意义。Generator 函数从暂停状态到恢复运行,它的上下文状态context是不变的。通过next方法的参数,就有办法在 Generator 函数开始运行之后,继续向函数体内部注入值。从而在 Generator 函数运行的不同阶段,调整函数行为

function* foo(x) {
let y = 2 * (yield x + 1);
let z = yield y / 3;
return x + y + z;
}

let a = foo(5);
a.next(); // { value:6, done:false }
a.next(); // 此时 y 为 undefined, { value:NaN, done:false }
a.next(); // 此时 z 为 undefined, { value:NaN, done:true }

上面代码中,第二次运行next()的时候不带参数,导致 y 的值等于2 * undefined,除以 3 以后变成NaN,因此返回对象的value属性也等于NaN。第三次运行next方法的时候不带参数,所以z等于undefined,返回对象的value属性等于5 + NaN + undefined,即NaN

如果向next方法提供参数,返回结果就完全不一样。

let b = foo(5);
b.next(); // { value:6, done:false }
b.next(12); // 此时 y 为 24, 因为 yield (x + 1) = 12, { value:8, done:false }
b.next(13); // 此时 z 为 13, 因为 yield (y / 3) = 13, { value:42, done:true }

上面代码第一次调用next方法时,返回x+1的值6;第二次调用next方法,将上一次yield表达式的值设为12,因此y等于24,返回y / 3的值8;第三次调用next方法,将上一次yield表达式的值设为13,因此z等于13,这时x等于5y等于24,所以return语句的值等于42

注意,由于next方法的参数表示上一个yield表达式的返回值,所以在第一次使用next方法时,传递参数是无效的。


18.5 for…of 循环

for...of可以自动遍历 Generator 函数生成的Iterator对象,且不需要调用next方法。

function* foo() {
yield 1;
yield 2;
yield 3;
return 4;
}

for (let v of foo()) {
console.log(v);
}
// 1 2 3 (没有4)

上面的代码可以看出,一旦返回对象的done属性为truefor...of循环就会中止,且不包含该返回对象。所以上面代码的return语句返回的4,不包括在for...of循环之中。

除了for...of循环以外,扩展运算符、解构赋值和Array.from方法内部调用的,都是遍历器接口。这意味着,它们都可以将 Generator 函数返回的遍历器对象,作为参数。

function* numbers() {
yield 1;
yield 2;
return 3;
yield 4;
}

// 扩展运算符
[...numbers()]; // [1, 2]

// Array.from 方法
Array.from(numbers()); // [1, 2]

// 解构赋值
let [x, y] = numbers();
x; // 1
y; // 2

18.6 throw 方法

遍历器对象的throw()方法,可以在函数体外抛出错误,然后在 Generator 函数体内捕获

let g = function* () {
try {
yield;
} catch (e) {
console.log("内部捕获", e);
}
};

let i = g();
i.next(); // 初始化启动

try {
i.throw("a");
i.throw("b");
} catch (e) {
console.log("外部捕获", e);
}
// 内部捕获 a
// 外部捕获 b

上面代码中,遍历器对象i连续抛出两个错误。第一个错误被 Generator 函数体内的catch语句捕获i第二次抛出错误,由于 Generator 函数内部的catch语句已经执行过了,不会再捕捉到这个错误了,所以这个错误就被函数体外的catch语句捕获

注意,不要混淆遍历器对象的throw方法和全局的throw命令。

try {
throw new Error("a");
throw new Error("b");
} catch (e) {
console.log("外部捕获", e);
}
// 外部捕获 [Error: a]

上面代码之所以只捕获了a,是因为函数体外的catch语句块,捕获了抛出的a错误以后,就不会再继续try代码块里面剩余的语句了。

  • 如果 Generator 函数内部没有部署try...catch代码块,那么throw方法抛出的错误,将被外部try...catch代码块捕获
  • 如果 Generator 函数内部和外部,都没有部署try...catch代码块,那么程序将报错,直接中断执行。

throw方法抛出的错误要被内部捕获,前提是必须至少执行过一次next方法。同时,throw方法被捕获后,会附带执行下一条yield表达式,等同于执行一次next方法。

let gen = function* gen() {
try {
yield console.log("a");
} catch (e) {
// ...
}
yield console.log("b");
yield console.log("c");
};

let g = gen();
g.next(); // a
g.throw(); // b
g.next(); // c

上面代码中,g.throw()方法被捕获以后,自动执行了一次next方法,所以会打印b。这里可以看出,只要 Generator 函数内部部署了try...catch代码块,那么遍历器的throw方法抛出的错误,不影响下一次遍历

Generator 函数体外抛出的错误,可以在函数体内捕获;反过来,Generator 函数体内抛出的错误,也可以被函数体外的catch捕获。

function* foo() {
let x = yield 3;
let y = x.toUpperCase();
yield y;
}

let it = foo();

it.next(); // { value:3, done:false }

try {
it.next(42);
} catch (err) {
console.log(err);
}

上面代码中,第二个next方法向函数体内传入一个参数 42,数值是没有toUpperCase方法的,所以会抛出一个 TypeError 错误,被函数体外的catch捕获。


18.7 return 方法

遍历器对象的return()方法,可以返回给定的值,并且终结遍历 Generator 函数

function* gen() {
yield 1;
yield 2;
yield 3;
}

let g = gen();

g.next(); // { value: 1, done: false }
g.return("foo"); // { value: "foo", done: true }
g.next(); // { value: undefined, done: true }

上面代码中,遍历器对象g调用return()方法后,返回值的value属性就是return()方法的参数foo。并且,Generator 函数的遍历就终止了,返回值的done属性为true

  • 如果return()方法调用时,不提供参数,则返回值的value属性为undefined
  • 如果 Generator 函数内部有try...finally代码块,且正在执行try代码块,那么return()方法会导致立刻进入finally代码块。
function* numbers() {
yield 1;
try {
yield 2;
yield 3;
} finally {
yield 4;
yield 5;
}
yield 6;
}

let g = numbers();
g.next(); // { value: 1, done: false }
g.next(); // { value: 2, done: false }
g.return(7); // { value: 4, done: false }
g.next(); // { value: 5, done: false }
g.next(); // { value: 7, done: true }

上面代码中,调用return()方法后,就开始执行finally代码块,不执行try里面剩下的代码了,然后等到finally代码块执行完,再返回return()方法指定的返回值。


18.8 方法的共同点

Generator 函数返回的遍历器对象的next()throw()return()本质上是同一件事,它们的作用都是让 Generator 函数恢复执行,并且使用不同的语句替换yield表达式。

  • next()方法是将yield表达式替换成一个值。
const g = function* (x, y) {
let result = yield x + y;
return result;
};

const gen = g(1, 2);
gen.next(); // Object {value: 3, done: false}

gen.next(1); // Object {value: 1, done: true}
// 相当于将 let result = yield x + y
// 替换成 let result = 1;
  • throw()方法是将yield表达式替换成一个throw语句。
gen.throw(new Error("出错了")); // Uncaught Error: 出错了
// 相当于将 let result = yield x + y
// 替换成 let result = throw(new Error('出错了'));
  • return()方法是将yield表达式替换成一个return语句。
gen.return(2); // Object {value: 2, done: true}
// 相当于将 let result = yield x + y
// 替换成 let result = return 2;

18.9 yield* 表达式

ES6 提供的yield*表达式,可以在一个 Generator 函数里面执行另一个 Generator 函数。

let delegatedIterator = (function* () {
yield "Hello!";
yield "Bye!";
})();

let delegatingIterator = (function* () {
yield "Greetings!";
yield* delegatedIterator;
yield "Ok, bye.";
})();

for (let value of delegatingIterator) {
console.log(value);
}
// "Greetings!
// "Hello!"
// "Bye!"
// "Ok, bye."

上面代码中,delegatingIterator是代理者,delegatedIterator是被代理者。如果被代理的 Generator 函数有return语句,那么就可以向代理它的 Generator 函数返回数据。

function* genFuncWithReturn() {
yield "a";
yield "b";
return "The result";
}
function* logReturned(genObj) {
let result = yield* genObj;
console.log(result);
}

[...logReturned(genFuncWithReturn())];
// The result
// 值为 [ 'a', 'b' ]

上面代码中,存在两次遍历。

  • 第一次是扩展运算符遍历函数logReturned返回的遍历器对象。
  • 第二次是yield*语句遍历函数genFuncWithReturn返回的遍历器对象。

这两次遍历的效果是叠加的,最终表现为扩展运算符遍历函数genFuncWithReturn返回的遍历器对象。所以最后的数据表达式得到的值等于[ 'a', 'b' ]。同时,函数genFuncWithReturnreturn语句的返回值The result,会返回给函数logReturned内部的result变量。


18.10 作为对象的属性

如果一个对象的属性是 Generator 函数,可以简写成下面的形式。

const obj = {
* myGeneratorMethod() {
···
}
};

它的完整形式如下,与上面的写法是等价的。(详细请见上一章的属性的简洁表示法)

const obj = {
myGeneratorMethod: function* () {
// ···
},
};

18.11 含义

(1)Generator 与状态机

Generator 是实现状态机的最佳结构。比如,下面的clock函数就是一个状态机。

let ticking = true;
let clock = function () {
if (ticking) console.log("Tick!");
else console.log("Tock!");
ticking = !ticking;
};

上面代码的clock函数一共有两种状态TickTock,每运行一次,就改变一次状态。这个函数如果用 Generator 实现,就是下面这样。

let clock = function* () {
while (true) {
console.log("Tick!");
yield;
console.log("Tock!");
yield;
}
};

上面的 Generator 实现与 ES5 实现对比,可以看到少了用来保存状态的外部变量ticking,这样就更简洁,更安全,在写法上也更优雅。

(2)Generator 与协程

协程 coroutine 是一种程序运行的方式,可以理解成“协作的线程”或“协作的函数”。协程既可以用单线程实现,也可以用多线程实现。

  • 协程与子例程的差异
    传统的“子例程” subroutine 采用堆栈式“后进先出”的执行方式,只有当调用的子函数完全执行完毕,才会结束执行父函数。协程与其不同,多个线程(单线程情况下,即多个函数)可以并行执行,但是只有一个线程(或函数)处于正在运行的状态,其他线程(或函数)都处于暂停态 suspended,线程(或函数)之间可以交换执行权。这种可以并行执行、交换执行权的线程(或函数),就称为协程。

  • 协程与普通线程的差异
    协程适合用于多任务运行的环境。在这个意义上,它与普通的线程很相似,都有自己的执行上下文、可以分享全局变量。它们的不同之处在于,同一时间可以有多个线程处于运行状态,但是运行的协程只能有一个,其他协程都处于暂停状态。此外,普通的线程是抢先式的,到底哪个线程优先得到资源,必须由运行环境决定,但是协程是合作式的,执行权由协程自己分配

Generator 函数是 ES6 对协程的实现,但属于不完全实现Generator 函数被称为“半协程”,意思是只有 Generator 函数的调用者,才能将程序的执行权还给 Generator 函数。如果是完全实现的协程,任何函数都可以让暂停的协程继续执行。

function* A() {
console.log("我是A");
yield B(); // A停住,在这里转交线程执行权给B
console.log("结束了");
}

function B() {
console.log("我是B");
return 100; // 返回,并且将线程执行权还给A
}

let gen = A();
gen.next();
gen.next();

// 我是A
// 我是B
// 结束了

上面代码中,A将执行权交给B,我们称AB的父协程。那么现在B执行,A就相当于处于暂停的状态。等到B最后return 100,执行权就会还给A

(3)Generator 与上下文

执行 JS 代码时,会产生一个全局的上下文环境,包含了当前所有的变量和对象。执行函数或块级代码时,又会在当前上下文环境的上层 ,产生一个 函数运行的上下文 ,作为 当前active的上下文 ,由此形成一个上下文环境的堆栈。

这个堆栈是“后进先出”的数据结构,最后产生的上下文环境首先执行完成,退出堆栈,然后再执行完成它下层的上下文,直至所有代码执行完成,堆栈清空。

Generator 函数不一样,它执行产生的上下文环境,一旦遇到yield命令,就会暂时退出堆栈,但是并不消失,里面的所有变量和对象会冻结在当前状态。等到对它执行next命令时,这个上下文环境又会重新加入调用栈,冻结的变量和对象恢复执行。

function* gen() {
yield 1;
return 2;
}

let g = gen();

console.log(g.next().value, g.next().value); // 1, 2

上面代码中,第一次执行g.next()时,Generator 函数gen的上下文会加入堆栈,即开始运行gen内部的代码。等遇到yield 1时,gen上下文退出堆栈,内部状态冻结。第二次执行g.next()时,gen上下文重新加入堆栈,变成当前的上下文,重新恢复执行。


18.12 应用

(1)异步操作的同步化表达

Generator 函数的暂停执行的效果,意味着可以把异步操作写在yield表达式里面,等到调用next方法时再往后执行。所以,Generator 函数的一个重要实际意义就是用来处理异步操作,改写回调函数。

function* loadUI() {
showLoadingScreen();
yield loadUIDataAsynchronously();
hideLoadingScreen();
}

let loader = loadUI();
// 加载UI
loader.next();
// 卸载UI
loader.next();

上面代码中,第一次调用loadUI函数时,该函数不会执行,仅返回一个遍历器。下一次对该遍历器调用next方法,则会显示Loading界面,并且异步加载数据。等到数据加载完成,再一次使用next方法,则会隐藏Loading界面。

(2)控制流管理

如果有一个多步操作,采用回调函数,可能会写成下面这样。

step1(function (value1) {
step2(value1, function (value2) {
step3(value2, function (value3) {
step4(value3, function (value4) {
// Do something with value4
});
});
});
});

采用 Promise 改写上面的代码。

Promise.resolve(step1)
.then(step2)
.then(step3)
.then(step4)
.then(
function (value4) {
// Do something with value4
},
function (error) {
// Handle any error from step1 through step4
}
)
.done();

上面代码已经把回调函数,改成了直线执行的形式,但是加入了大量 Promise 的语法。Generator 函数可以进一步改善代码运行流程。

function* longRunningTask(value1) {
try {
let value2 = yield step1(value1);
let value3 = yield step2(value2);
let value4 = yield step3(value3);
let value5 = yield step4(value4);
// Do something with value4
} catch (e) {
// Handle any error from step1 through step4
}
}

然后,使用一个函数,按次序自动执行所有步骤

scheduler(longRunningTask(initialValue));

function scheduler(task) {
let taskObj = task.next(task.value);
// 如果Generator函数未结束,就继续调用
if (!taskObj.done) {
task.value = taskObj.value;
scheduler(task);
}
}

注意,上面这种做法,只适合同步操作,即所有的task都必须是同步的,不能有异步操作。因为这里的代码一得到返回值,就继续往下执行,没有判断异步操作何时完成。

(3)控制流管理 II

利用for...of循环会自动依次执行yield命令的特性,能提供一种更一般的控制流管理的方法。

首先,用数组steps封装了一个任务的多个步骤,并依次为这些步骤加上yield命令。

let steps = [step1Func, step2Func, step3Func];

function* iterateSteps(steps) {
for (let i = 0; i < steps.length; i++) {
let step = steps[i];
yield step();
}
}

然后,将任务分解成步骤之后,还可以再将项目分解成多个依次执行的任务jobs

let jobs = [job1, job2, job3];

function* iterateJobs(jobs) {
for (let i = 0; i < jobs.length; i++) {
let job = jobs[i];
yield* iterateSteps(job.steps);
}
}

最后,就可以用for...of循环一次性依次执行所有任务的所有步骤。

for (let step of iterateJobs(jobs)) {
console.log(step.id);
}

再次提醒,上面的做法只能用于所有步骤都是同步操作的情况,不能有异步操作的步骤。


19. Generator 函数异步应用

19.1 基本概念

(1)异步

所谓”异步”,可以理解成一个任务被人为分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。

  • 比如,有一个任务是读取文件进行处理,任务的第一段向操作系统发出请求,要求读取文件。然后,程序执行其他任务,等到操作系统返回文件,再接着执行任务的第二段处理文件。

相应地,连续的执行就叫做同步。由于是连续执行,不能插入其他任务,所以操作系统从硬盘读取文件的这段时间,程序只能干等着。

(2)回调函数

JS 语言对异步编程的实现,就是回调函数。所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数

fs.readFile("/etc/passwd", "utf-8", function (err, data) {
if (err) throw err;
console.log(data);
});

上面代码中,readFile函数的第三个参数,就是回调函数,也就是任务的第二段。等到操作系统返回了/etc/passwd这个文件以后,回调函数才会执行。

(3)Promise

回调函数的问题出现在多个回调函数嵌套。假定读取A文件之后,再读取B文件。

fs.readFile(fileA, "utf-8", function (err, data) {
fs.readFile(fileB, "utf-8", function (err, data) {
// ...
});
});

不难想象,如果依次读取两个以上的文件,就会出现多重嵌套。因为多个异步操作形成了强耦合,只要有一个操作需要修改,它的上层回调函数和下层回调函数,可能都要跟着修改。

Promise 对象就是为了解决这个问题而提出的。它不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用

let readFile = require("fs-readfile-promise");

readFile(fileA)
.then(function (data) {
console.log(data.toString());
})
.then(function () {
return readFile(fileB);
})
.then(function (data) {
console.log(data.toString());
})
.catch(function (err) {
console.log(err);
});

上面代码中的fs-readfile-promise模块,返回一个 Promise 版本的readFile函数,并通过then方法加载回调函数,catch方法捕捉执行过程中抛出的错误。

Promise 的写法只是回调函数的改进,使用then方法以后,异步任务的两段执行看得更清楚了。但是,Promise 的最大问题是代码冗余,原来的任务被 Promise 包装了一下,不管什么操作,一眼看去许多then,原来的语义变得很不清楚。

(4)Generator 函数

“协程” coroutine,是多个线程互相协作,完成异步任务。协程有点像函数,又有点像线程。它的运行流程大致如下:

  • 第一步,协程A开始执行。
  • 第二步,协程A执行到一半,进入暂停,执行权转移到协程B
  • 第三步,一段时间后,协程B交还执行权。
  • 第四步,协程A恢复执行。

上面流程的协程A,就是异步任务,因为它分成两段或多段执行。

function* asyncJob() {
// ...其他代码
let f = yield readFile(fileA);
// ...其他代码
}

上面代码的函数asyncJob是一个协程,它的奥妙就在其中的yield命令,表示执行到此处,执行权将交给其他协程。也就是说,yield命令是异步两个阶段的分界线

Generator 函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行)。它是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield语句注明。

function* gen(x) {
let y = yield x + 2;
return y;
}

let g = gen(1);
g.next(); // { value: 3, done: false }
g.next(); // { value: undefined, done: true }

上面代码中,调用 Generator 函数,会返回一个内部指针(即遍历器)g。这是 Generator 函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针gnext方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的yield语句,上例是执行到x + 2为止。

换言之,next方法的作用是分阶段执行Generator函数。每次调用next方法,会返回一个对象,表示当前阶段的信息(value属性和done属性)。value属性是yield语句后面表达式的值,表示当前阶段的值;done属性是一个布尔值,表示 Generator 函数是否执行完毕,即是否还有下一个阶段。


19.2 数据交换和错误处理

Generator 函数可以暂停和恢复执行,这是它能封装异步任务的根本原因。除此之外,它还有两个特性,使它可以作为异步编程的完整解决方案:函数体内外的数据交换和错误处理机制

next返回值的 value 属性,是 Generator 函数向外输出数据。next方法还可以接受参数,向 Generator 函数体内输入数据。

function* gen(x) {
let y = yield x + 2;
return y;
}

let g = gen(1);
g.next(); // { value: 3, done: false }
g.next(2); // { value: 2, done: true }

上面代码中,第一个next方法的value属性,返回表达式x + 2的值。第二个next方法带有参数,这个参数被传入 Generator 函数,作为上个阶段异步任务的返回结果,也就是y的值。因此,这一步的value属性,返回的就是2

Generator 函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。

function* gen(x) {
try {
let y = yield x + 2;
} catch (e) {
console.log(e);
}
return y;
}

let g = gen(1);
g.next();
g.throw("出错了");
// 出错了

上面代码的最后一行,Generator 函数体外,使用指针对象的throw方法抛出的错误,可以被函数体内的try...catch代码块捕获。这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的


19.3 异步任务的封装

使用Generator封装异步操作的核心思路:

  • 在异步任务执行时,使用yield交出执行权。
  • 在异步任务结束后,使用next交还执行权。

下面看看如何使用 Generator 函数,执行一个真实的异步任务。

// 1. 首先写一个异步任务,在一秒后返回特定字符串
function asyncTask(callback) {
setTimeout(() => {
callback("Hello World");
}, 1000);
}

// 2. 接下来写出期望执行的顺序
function* runTask() {
let text = yield asyncTask;
console.log(text); // 我们期望这里正常输出 Hello World
}

// 3. 按照期望值执行函数
const gen = runTask(); // 此时执行权已经交出

// 这里 gen.next().value 就是 asyncTask,执行 asyncTask
// 关键点在于 callback 里调用 next 交还执行权
// 由于 next 方法带有参数 text,且是 callback 的返回值,所以有终端输出。
gen.next().value(function (text) {
gen.next(text);
});

上面代码虽然很粗糙,但是已经反映了使用Generator封装异步任务的核心思想。最直观的受益就是,runTask的内容与同步代码相似,条理清晰,很适合阅读。

可以看到,虽然 Generator 函数将异步操作表示得很简洁,但是上面代码中第三部分的流程管理却不方便,即何时执行第一阶段、何时执行第二阶段。


19.4 Thunk 函数

Thunk 函数是自动执行 Generator 函数的一种方法。

(1)参数的求值策略

Thunk 函数早在上个世纪 60 年代就诞生了。那时,编程语言刚刚起步,计算机学家还在研究,编译器怎么写比较好。一个争论的焦点是”求值策略”,即函数的参数到底应该何时求值。

let x = 1;
function f(m) {
return m * 2;
}

f(x + 5);

上面代码先定义函数f,然后向它传入表达式x + 5。请问,这个表达式应该何时求值?

一种意见是“传值调用”,即在进入函数体之前,就计算x + 5的值,再将这个值传入函数fC 语言就采用这种策略。

f(x + 5);
// 传值调用时,等同于
f(6);

另一种意见是“传名调用”,即直接将表达式x + 5传入函数体,只在用到它的时候求值。Haskell 语言采用这种策略。

f(x + 5)(
// 传名调用时,等同于
x + 5
) * 2;

传值调用和传名调用,哪一种比较好?回答是各有利弊。传值调用比较简单,但是对参数求值的时候,实际上还没用到这个参数,有可能造成性能损失。

(2)Thunk 函数的含义

编译器“传名调用”的实现,往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体。这个临时函数就叫做 Thunk 函数。

function f(m) {
return m * 2;
}

f(x + 5);

// 等同于

let thunk = function () {
return x + 5;
};

function f(thunk) {
return thunk() * 2;
}

上面代码中,函数 f 的参数x + 5被一个函数替换了。凡是用到原参数的地方,对Thunk函数求值即可。这就是 Thunk 函数的定义,它是“传名调用”的一种实现策略,用来替换某个表达式

(3)JS 语言的 Thunk 函数

JavaScript 是传值调用,它的 Thunk 函数含义有所不同。在 JavaScript 中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成一个只接受回调函数作为参数的单参数函数。

// 正常版本的 readFile(多参数版本)
fs.readFile(fileName, callback);

// Thunk版本的 readFile(单参数版本)
let Thunk = function (fileName) {
return function (callback) {
// 注意
return fs.readFile(fileName, callback);
};
};

let readFileThunk = Thunk(fileName);
readFileThunk(callback);

上面代码中,fs模块的readFile方法是一个多参数函数,两个参数分别为文件名和回调函数。经过转换器处理,它变成了一个单参数函数,只接受回调函数作为参数。这个单参数版本,就叫做 Thunk 函数。

任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。(推荐阅读

const Thunk = function (fn) {
return function (...args) {
return function (callback) {
return fn.call(this, ...args, callback);
};
};
};

(4)Thunkify 模块

Thunkify 模块的使用方式如下。

let thunkify = require("thunkify");
let fs = require("fs");

let read = thunkify(fs.readFile);
read("package.json")(function (err, str) {
// ...
});

Thunkify 的源码与上一节那个简单的转换器非常像。(源码解读

function thunkify(fn) {
return function () {
let args = new Array(arguments.length);
let ctx = this;

for (let i = 0; i < args.length; ++i) {
args[i] = arguments[i];
}

return function (done) {
let called;

args.push(function () {
if (called) {
return;
}
called = true;
done.apply(null, arguments);
});

try {
fn.apply(ctx, args);
} catch (err) {
done(err);
}
};
};
}

它的源码主要多了一个检查机制,变量called确保回调函数只运行一次。这样的设计与下面的 Generator 函数相关。

function f(a, b, callback) {
let sum = a + b;
callback(sum);
callback(sum);
}

let ft = thunkify(f);
let print = console.log.bind(console);
ft(1, 2)(print);
// 3

上面代码中,由于thunkify只允许回调函数执行一次,所以只输出一行结果。

(5)Generator 函数的流程管理

Thunk 函数可以用于 Generator 函数的自动流程管理。

function* gen() {
// ...
}

let g = gen();
let res = g.next();

while (!res.done) {
console.log(res.value);
res = g.next();
}

上面代码中,Generator 函数gen会自动执行完所有步骤。

但是,这不适合异步操作。如果必须保证前一步执行完,才能执行后一步,上面的自动执行就不可行。这时,Thunk 函数就能派上用处。

以读取文件为例。下面的 Generator 函数封装了两个异步操作。

let fs = require("fs");
let thunkify = require("thunkify");
let readFileThunk = thunkify(fs.readFile);

let gen = function* () {
let r1 = yield readFileThunk("/etc/fstab");
console.log(r1.toString());
let r2 = yield readFileThunk("/etc/shells");
console.log(r2.toString());
};

上面代码中,yield命令用于将程序的执行权移出 Generator 函数,那么就需要一种方法,将执行权再交还给 Generator 函数

这种方法就是 Thunk 函数,因为它可以在回调函数里,将执行权交还给 Generator 函数

let g = gen();

let r1 = g.next();
r1.value(function (err, data) {
// r1.value 就是 readFileThunk 方法
if (err) {
throw err; // 错误处理
}
let r2 = g.next(data); // 如果 data 从 readFileThunk 方法回调出来
// 归还执行权,r1 赋值成功,运行至下一个 yield
r2.value(function (err, data) {
if (err) {
throw err;
}
g.next(data);
});
});

上面代码中,变量gGenerator 函数的内部指针,表示目前执行到哪一步。next方法负责将指针移动到下一步,并返回该步的value属性和done属性。

仔细看上面的代码,可以发现 Generator 函数的执行过程,其实是将同一个回调函数,反复传入next方法的value属性。这使得我们可以用递归来自动完成这个过程。(理论上)

(6)Thunk 函数的自动流程管理

Thunk 函数真正的威力,在于可以自动执行 Generator 函数。下面就是一个基于 Thunk 函数的 Generator 执行器。

function run(fn) {
let gen = fn();

function next(err, data) {
let result = gen.next(data);
if (result.done) {
return;
}
result.value(next);
}

next();
}

function* g() {
// ...
}

run(g);

上面代码的run函数,就是一个 Generator 函数的自动执行器。内部的next函数就是 Thunk 的回调函数。next函数先将指针移到 Generator 函数的下一步(gen.next方法),然后判断 Generator 函数是否结束(result.done属性),如果没结束,就将next函数再传入 Thunk 函数(result.value属性),否则就直接退出。

有了这个执行器,执行 Generator 函数方便多了。不管内部有多少个异步操作,直接把 Generator 函数传入run函数即可。注意,前提是每一个异步操作,都要是 Thunk 函数,也就是说,跟在yield命令后面的必须是 Thunk 函数

let g = function* () {
let f1 = yield readFileThunk("fileA");
let f2 = yield readFileThunk("fileB");
// ...
let fn = yield readFileThunk("fileN");
};

run(g);

上面代码中,函数g封装了n个异步的读取文件操作,只要执行run函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。

Thunk 函数并不是 Generator 函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制,自动控制 Generator 函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise 对象也可以做到这一点。


19.5 co 模块

下面是一个 Generator 函数,用于依次读取两个文件。

let gen = function* () {
let f1 = yield readFile("/etc/fstab");
let f2 = yield readFile("/etc/shells");
console.log(f1.toString());
console.log(f2.toString());
};

co 模块可以用于 Generator 函数的自动执行,不用再编写 Generator 函数的执行器。

let co = require("co");
co(gen);

上面代码中,Generator 函数只要传入co函数,就会自动执行。

co函数返回一个Promise对象,因此可以用then方法添加回调函数。

co(gen).then(function () {
console.log("Generator 函数执行完成");
});

上面代码中,等到 Generator 函数执行结束,就会输出一行提示。

(1)co 模块的原理

为什么 co 可以自动执行 Generator 函数?

前面说过,Generator 就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权

两种方法可以做到这一点:

  • 回调函数。将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。
  • Promise 对象。将异步操作包装成 Promise 对象,用then方法交回执行权。

co 模块其实就是将两种自动执行器(Thunk 函数和 Promise 对象),包装成一个模块。

  • 使用 co 的前提条件是,Generator 函数的yield命令后面,只能是 Thunk 函数或 Promise 对象。如果数组或对象的成员,全部都是 Promise 对象,也可以使用 co

(2)基于 Promise 对象的自动执行

沿用上面的例子。首先,把fs模块的readFile方法包装成一个 Promise 对象。

let fs = require("fs");

let readFile = function (fileName) {
return new Promise(function (resolve, reject) {
// Promise 新建后立即执行
fs.readFile(fileName, function (error, data) {
if (error) {
return reject(error);
}
resolve(data); // 读取成功,传 data 进 then 方法
});
});
};

let gen = function* () {
let f1 = yield readFile("/etc/fstab");
let f2 = yield readFile("/etc/shells");
console.log(f1.toString());
console.log(f2.toString());
};

然后,手动执行上面的 Generator 函数。

let g = gen();
// 注意,此时 gen.next().value 是 readFile 返回的 Promise 对象
// resolve 返回的 data 进入回调函数
g.next().value.then(function (data) {
g.next(data).value.then(function (data) {
g.next(data);
});
});

手动执行就是用then方法,层层添加回调函数。理解了这一点,就可以写出一个自动执行器。

function run(gen) {
let g = gen();

function next(data) {
let result = g.next(data);
if (result.done) {
return result.value;
}
result.value.then(function (data) {
next(data);
});
}

next();
}

run(gen);

上面代码中,只要 Generator 函数还没执行到最后一步,next函数就调用自身,实现自动执行。

(3)co 模块的源码

首先,co 函数接受 Generator 函数作为参数,返回一个 Promise 对象。

function co(gen) {
let ctx = this;

return new Promise(function (resolve, reject) {});
}

在返回的 Promise 对象里面,co 先检查参数gen是否为 Generator 函数。如果是,就执行该函数,得到一个内部指针对象;如果不是就返回,并将 Promise 对象的状态改为resolved

function co(gen) {
let ctx = this;

return new Promise(function (resolve, reject) {
if (typeof gen === "function") {
gen = gen.call(ctx);
}
if (!gen || typeof gen.next !== "function") {
return resolve(gen);
}
});
}

接着,coGenerator 函数的内部指针对象的next方法,包装成onFulfilled函数。这主要是为了能够捕捉抛出的错误。

function co(gen) {
let ctx = this;

return new Promise(function (resolve, reject) {
if (typeof gen === "function") {
gen = gen.call(ctx);
}
if (!gen || typeof gen.next !== "function") {
return resolve(gen);
}

onFulfilled();
function onFulfilled(res) {
let ret;
try {
ret = gen.next(res);
} catch (e) {
return reject(e);
}
next(ret);
}
});
}

最后,就是关键的next函数,它会反复调用自身。

function next(ret) {
if (ret.done) {
return resolve(ret.value);
}
let value = toPromise.call(ctx, ret.value);
if (value && isPromise(value)) {
return value.then(onFulfilled, onRejected);
}
return onRejected(
new TypeError(
"You may only yield a function, promise, generator, array, or object, " +
'but the following object was passed: "' +
String(ret.value) +
'"'
)
);
}

上面代码中,next函数的内部代码,一共只有四行命令。

  • 检查当前是否为 Generator 函数的最后一步,如果是就返回。
  • 确保每一步的返回值,是 Promise 对象。
  • then方法为返回值加上回调函数,然后通过onFulfilled函数再次调用next函数。
  • 在参数不符合要求的情况下(参数非 Thunk 函数和 Promise 对象),将 Promise 对象的状态改为rejected,从而终止执行。

(4)处理并发的异步操作

co 支持并发的异步操作,即允许某些操作同时进行,等到它们全部完成,才进行下一步。这时,要把并发的操作都放在数组或对象里面,跟在yield语句后面。(例子

// 数组的写法
co(function* () {
let res = yield [Promise.resolve(1), Promise.resolve(2)];
console.log(res);
}).catch(onerror);

// 对象的写法
co(function* () {
let res = yield {
1: Promise.resolve(1),
2: Promise.resolve(2),
};
console.log(res);
}).catch(onerror);

附录

该学习笔记的所有章节均保存在Github中,如有需要,可下载并在Typora中阅读。