JavaScript的迭代器和生成器
1. 迭代器Iterator
1. 基本概念
JavaScript 表示集合的对象大致有Object,Array,Map,Set四种,并且这四种类型的数据之间可以相互以成员嵌套(如Array的成员可以是Object,而Map又可以嵌入Object的成员中),为了处理所有不同的数据结构,就需要统一的接口机制。
迭代器(Iterator)就是这样一种机制。它是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作。
Iterator 的作用有三个
- 为各种数据结构,提供统一、简便的访问接口;
- 使数据结构的成员能够按某种次序排列;
- ES6 创造了一种新的遍历命令
for...of
循环,Iterator 接口主要供for...of
消费。
2. Iterator 的遍历过程
- 创建一个指针对象,指向当前数据结构的起始位置。也就是说,遍历器对象本质上,就是一个指针对象。
- 第一次调用指针对象的
next
方法,将指针指向数据结构的第一个成员。第二次调用,指向数据结构的第二个成员.......... - 不断调用指针对象的
next
方法,直到它指向数据结构的结束位置。
如下,模拟Iterator 的遍历过程:
const it = makeIterator(['a', 'b'])
it.next() // { value: "a", done: false }
it.next() // { value: "b", done: false }
it.next() // { value: undefined, done: true }
function makeIterator(array) {
let nextIndex = 0
return {
next: function () {
return nextIndex < array.length ?
{ value: array[nextIndex++], done: false }
: { value: undefined, done: true }
}
}
}
注意:Iterator() 实际上不能被显示的构造。
它通常由集合对象内置迭代器方法( Symbol.iterator )返回。如果你需要显示的创建迭代器对象,推荐使用生成器Generator来创建。
3. 默认的Iterator
Iterator 接口的目的,就是为所有数据结构,提供了一种统一的访问机制,即for...of循环。当使用for...of循环遍历某种数据结构时,该循环会自动去寻找 Iterator 接口。
ES6 规定,默认的 Iterator 接口部署在数据结构的Symbol.iterator属性。一种数据结构只要部署了 Iterator 接口(或者说具有Symbol.iterator属性),我们就称这种数据结构实现了可迭代协议,是“可遍历的(iterable)”,(原型链上的对象具有该方法也可)。
Symbol.iterator属性本身是一个函数,就是当前数据结构默认的遍历器生成函数。执行这个函数,就会返回一个遍历器。
let arr = ['a', 'b', 'c'];
typeof arr[Symbol.iterator]
// "function"
let iter = arr[Symbol.iterator]();
iter.next() // { value: 'a', done: false }
iter.next() // { value: 'b', done: false }
iter.next() // { value: 'c', done: false }
iter.next() // { value: undefined, done: true }
4. Iterator接口的应用场合
-
for...of循环
-
解构赋值,对数组和 Set 结构进行解构赋值时,会默认调用Symbol.iterator方法
-
扩展运算符,扩展运算符(...)也会调用默认的 Iterator 接口。所以利用扩展运算符我们可以将任何部署了 Iterator 接口的数据结构,转为数组。
-
类数组对象,如字符串 ,DOM NodeList 对象、arguments对象,它们也原生具有 Iterator 接口。
-
其他场合,由于数组的遍历会调用遍历器接口,所以任何接受数组作为参数的场合,其实都调用了遍历器接口。下面是一些例子。
for...of
Array.from()
Map(), Set(), WeakMap(), WeakSet()(比如new Map([['a',1],['b',2]]))
Promise.all()
Promise.race()
注意: 普通的对象是不能直接使用for...of的,因为它没有默认部署 Iterator 接口。
let obj = { a: 1, b: 2 }
for (let key of obj) {
console.log(key)// TypeError: obj is not iterable
}
你可以使用for...in来遍历键名,或者使用Object.keys
方法将对象的键名生成一个数组,然后遍历这个数组。还可以使用 Generator 函数将对象重新包装一下(下面会详细介绍)
tips:for...in是专门为遍历对象而设计的,它会自动遍历原型链。不适合用来遍历数组
2. 生成器Generator
1. 基本语法
生成器 ( Generator )函数,简单来讲就是一种可中断函数,它可以使用yield暂停函数的执行,并在之后再次从暂停的地方继续执行。使用next()来启动函数的执行,从上一次暂停的位置开始。
与一般函数不同的是,它使用 function* 标识符来声明,如
function* tasks() {
yield 1
yield 2 + 3
yield 3 - 1
}
const it = tasks()
it.next() // { value: 1, done: false }
it.next() // { value: 5, done: false }
it.next() // { value: 1, done: false }
it.next() // { value: undefined, done: true }
实际上,当我们执行 Generator 函数时,它会返回一个迭代器对象,而调用这个迭代器的next()方法,它会返回yield右侧代码的执行结果。
2. 理解Generator
生成器 ( Generator ) 函数实际上是ES6提供的一种异步编程解决方案(后面介绍),语法行为与传统函数完全不同。可以理解为 “ Generator 是一个内部暂停,封装了多个状态的状态机”。而yeild 也正如它的英文意思“产出” ,用来产出内部的状态。
next()方法返回的对象中,value属性就是yield表达式的值,done属性表示遍历是否结束。当Generator中的状态都被遍历完后,无论再next多少次,结果都是{ value:undefined,done:true}
注意:
1. 这个“产出” 是惰性的。yeild 右边的表达式 ( 如上面的2+3,3-1)也只有当调用next()方法,内部指针指向该语句时,才会执行。
2. 如果,Generator内部不使用 yeild,那么它就只是一个单纯的暂缓执行函数
function* f() {
console.log('执行了!')
}
var generator = f()
setTimeout(function () {
generator.next()
}, 2000)
上面代码中,函数f如果是普通函数,在为变量generator赋值时就会执行。但是,函数f是一个 Generator 函数,就变成只有调用next方法时,函数f才会执行。
3. yield表达式只能用在 Generator 函数里面,用在其他地方都会报错
3. Generator 与 Iterator的关系
上面说过,任意一个对象的Symbol.iterator方法,等于该对象的遍历器生成函数,调用该函数会返回该对象的一个遍历器对象。
1. 由于 Generator 函数就是遍历器生成函数,因此可以把 Generator 赋值给对象的Symbol.iterator属性,从而使得该对象具有 Iterator 接口。
var myIterable = {}
myIterable[Symbol.iterator] = function* () {
yield 1
yield 2
yield 3
}
;[...myIterable] // [1, 2, 3]
2. 与迭代器一样,for...of循环可以遍历 Generator 函数运行时生成的Iterator对象,且此时不再需要调用next方法。
function* foo() {
yield 1;
yield 2;
return 3;
}
for (let v of foo()) {
console.log(v);
}
// 1 2 3
3. 包括解构赋值,Array.from()等,迭代器能够应用的场合,生成器基本都能插上一脚
4. next 的参数
yield 的“产出”默认为其表达式的结果,没有则为undefined。但next方法可以带一个参数,此时,该参数就会被当作上一个yield表达式的返回值。
function* f() {
for (var i = 0; true; i++) {
var reset = yield i
if (reset) {
i = -1
}
}
}
var g = f()
g.next() // { value: 0, done: false }
g.next() // { value: 1, done: false }
g.next(true) // { value: 0, done: false }
这是一个很有用的功能,因为Generator 函数从暂停状态到恢复运行,它的上下文状态(context)是不变的。但通过next方法的参数,就能够实现在 Generator 函数运行的不同阶段,从外部向内部注入不同的值,来调整函数行为。
5. Generator 的实例方法
除了上面提到的next方法,Generator函数返回的迭代器对象,都还还有throw和return两个方法。
1. throw,可以在函数体外抛出错误,然后在 Generator 函数体内捕获。
function* tasks() {
try {
yield 1
} catch (err) {
console.log('catched by generator *tasks', err)
}
return 2
}
const t = tasks()
t.next().value // 1
t.next().value // 2
t.throw('err 1') // catched by generator *tasks err 1
注意:如果该错误没有被 generator 本身 catch 住,则会往外暴露给外层,也就是 generator 的调用方。如果调用方也没有 catch 住,则正常抛错。
function* tasks() {
yield 1
return 2
}
const t = tasks()
t.next().value // 1
try {
t.throw('err 2')
} catch (err) {
console.log('catched by generator *tasks caller', err)
//catched by generator *tasks caller err 2
}
2. return,强制 Generator 函数完成,并固定返回值,且其返回 IteratorResult 中的 done 将为 true
function* tasks() {
yield 1
yield 2
return 3
}
const t = tasks()
t.next()
const obj = t.return('终结任务')
console.log(obj) // { value: '终结任务', done: true }
3. next,throw 和 return,三者本质上是做了同一件事 —— 让 Generator 函数恢复执行,并且使用不同的语句替换yield表达式。
7. yeild* 表达式
如果在 Generator 函数内部,调用另一个 Generator 函数。需要在前者的函数体内部,自己手动完成遍历。如果有多个 Generator 函数嵌套,写起来就非常麻烦。
function* foo() {
yield 'a'
yield 'b'
}
function* bar() {
yield 'x'
// 手动遍历 foo()
for (let i of foo()) {
console.log(i)
}
yield 'y'
}
for (let v of bar()) {
console.log(v)//x a b y
}
所以,ES6 提供了yield*表达式,作为解决办法,用来在一个 Generator 函数里面执行另一个 Generator 函数。如下:
function* bar() {
yield 'x'
yield* foo()
yield 'y'
}
for (let v of bar()) {
console.log(v) //x a b y
}
8. Generator的简单应用
1. 作为对象的属性
let obj = {
*myGeneratorMethod() {
// ···
},
//或
myGeneratorMethod: function* () {
// ···
}
}
2. 部署 Iterator 接口,利用 Generator 函数,可以在任意对象上部署 Iterator 接口。
function* iterEntries(obj) {
let keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
let key = keys[i]
yield [key, obj[key]]
}
}
let myObj = { foo: 3, bar: 7 }
for (let [key, value] of iterEntries(myObj)) {
console.log(key, value) // foo 3 bar 7
}
3. 作为数据结构
Generator 可以看作是数据结构,更确切地说,可以看作是一个数组结构,因为 Generator 函数可以返回一系列的值,这意味着它可以对任意表达式,提供类似数组的接口。
function* doStuff() {
yield fs.readFile.bind(null, 'hello.txt');
yield fs.readFile.bind(null, 'world.txt');
yield fs.readFile.bind(null, 'and-such.txt');
}
9. Generator的注意事项
1. Generator不能与new操作符一起使用。会报错
function* task() {}
const t = new task() // TypeError: task is not a constructor
2. Generator返回的总是遍历器对象,而不是this对象
function* genenrator() {}
genenrator.prototype.hello = function () {
return 'hi!'
}
let iterator = genenrator()
iterator instanceof g // true
iterator.hello() // 'hi!'
上面的代码说明,Generator是和普通构造函数有相似之处的。它的实例能够访问到原型上的属性,但不能访问this实例属性,如下:
function* genenrator() {
this.hello = 'hi!'
}
let iterator = genenrator()
iterator.hello // undefined
3. Generator的异步应用
JavaScript 异步编程,参考:异步编程(Promise详解)
在ES6之前,异步编程解决方案最常用的应该就是Promise,链式调用使得异步编程的逻辑变得更加清晰,优化了回调地狱的问题,让异步任务的多个执行行看的更加清除了。
但与此同时,也有着代码冗余的问题,原本的任务被 Promise 包装了一下后,不管什么操作,一眼看去都是一堆then
,原来的语义变得很不清楚。
那么,有没有更好的写法呢?
1. 异步操作的同步化表达
Generator 函数有暂停执行的效果,意味着可以把异步操作写在yield表达式里面,等到调用next方法时再往后执行。这实际上等同于不需要写回调函数了,因为异步操作的后续操作可以放在yield表达式下面,反正要等到调用next方法时再执行。所以,Generator 函数的一个重要实际意义就是用来处理异步操作,改写回调函数。
function* loadUI() {
showLoadingScreen();
yield loadUIDataAsynchronously();
hideLoadingScreen();
}
var loader = loadUI();
// 加载UI
loader.next()
// 卸载UI
loader.next()
上面代码中,初次调用loadUI时,该函数不会执行,仅返回一个迭代器。对该迭代器调用next,会显示Loading界面(showLoadingScreen),并异步加载数据(loadUIDataAsynchronously)。等到数据加载完成,再一次使用next方法,则会隐藏Loading界面。
可以看到,这种写法的好处是所有Loading界面的逻辑,都被封装在一个函数,按部就班非常清晰。
Ajax 是典型的异步操作,通过 Generator 函数部署 Ajax 操作,可以用同步的方式表达。
function* main() {
var result = yield request("http://some.url");
var resp = JSON.parse(result);
console.log(resp.value);
}
function request(url) {
makeAjaxCall(url, function (response) {
it.next(response);
});
}
var it = main();
it.next();
上面代码的main函数,就是通过 Ajax 操作获取数据。可以看到,除了多了一个yield,它几乎与同步操作的写法完全一样。注意,makeAjaxCall函数中的next方法,必须加上response参数,因为yield表达式,本身是没有值的,总是等于undefined。
2. 控制流管理
对于多层步骤的异步任务,如果我们采用回调函数,那很大概率会演变成臭名昭著的“回调地狱”。
step1(function (value1) {
step2(value1, function (value2) {
step3(value2, function (value3) {
step4(value3, function (value4) {
//.....
});
});
});
});
如果采用promise的链式调用,虽然改成了比较清晰的直线形式,但是加入了大量的Promise语法,一眼看过去全是Promise API(then,catch),操作本身的语义反而不容易看出来。
Promise.resolve(step1)
.then(step2)
.then(step3)
.then(step4)
.then(function (value4) {
//.....
}.catch(err)
如果采用Generator,就能更进退一步改善代码陨星流程。
function* longRunningTask(value1) {
try {
var value2 = yield step1(value1);
var value3 = yield step2(value2);
var value4 = yield step3(value3);
var value5 = yield step4(value4);
// ........
} catch (e) {
//.....
}
}
3. Generator异步管理的缺陷
仔细观察1和2中的例子,可以发现,虽然 Generator 函数可以让异步操作写得更清晰、更加同步化,但它在实际使用中仍有一些缺点,尤其是在流程控制方面:
1. 手动执行每个阶段
在使用 Generator 函数时,异步操作的每个阶段(yield 的地方)都需要手动调用 next() 方法来向下执行。这意味着,如果你有多个 yield,你需要按顺序调用多次 next(),在复杂的流程中显得很繁琐,并且需要判断何时执行第一阶段、何时执行第二阶段......
2. 不支持自动处理错误
如果异步操作在 yield 处抛出错误,需要手动处理,否则可能会导致整个 Generator 函数无法正常继续。
3. 回调地狱问题并未完全解决
虽然 Generator 通过 yield 来暂停执行,使异步操作写得更像同步代码,但如果流程复杂,还是可能需要回调函数来执行每个 next() 调用,从而可能导致回调嵌套。
为了解决这些问题,W3C官方,在ES2017标准中引入了async 函数,进一步简化了异步代码的写法,使得流程管理更方便。
4. async
1. 基本概念
本质上来说,async就是 Generator 函数的语法糖
async function asyncTask() {
try {
const result1 = await fetchData1();
const result2 = await fetchData2(result1);
const result3 = await fetchData3(result2);
return result3;
} catch (error) {
console.error(error);
}
}
与Generator一比较就会发现,在语法上,async函数就是将 Generator 函数的星号(*)替换成async,将yield替换成await,仅此而已。
但相比于Generator函数,它又进行了进一步优化:
1. 更好的语义,async和await,比起星号和yield,语义更清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。
来一组示例,直观的感受
实际开发中,经常遇到一组异步操作,需要按照顺序完成。比如,依次远程读取一组 URL,然后按照读取的顺序输出结果。
Promise 的写法如下。
unction logInOrder(urls) {
// 远程读取所有URL
const textPromises = urls.map(url => {
return fetch(url).then(response => response.text());
});
// 按次序输出
textPromises.reduce((chain, textPromise) => {
return chain.then(() => textPromise)
.then(text => console.log(text));
}, Promise.resolve());
}
async 函数写法如下
async function logInOrder(urls) {
for (const url of urls) {
const response = await fetch(url);
console.log(await response.text());
}
}
啥也不说,async牛皮!代码极大的简化了
不过这里有有一个不公平的地方,上面的async写法中,操作都是继发的,需要等上一个操作完成,才会执行下一个操作,效率太差。现在换成并发请求
async function logInOrder(urls) {
// 并发读取远程URL
const textPromises = urls.map(async url => {
const response = await fetch(url);
return response.text();
});
// 按次序输出
for (const textPromise of textPromises) {
console.log(await textPromise);
}
}
哈哈,还是async赢了!!!
2. 自动执行流程,使用 async 和 await 后,JavaScript 引擎会自动控制异步代码的执行顺序,不需要手动调用 next()。
3. 更好地处理错误,async/await 可以使用 try...catch 语句进行错误捕获,处理错误的逻辑更加清晰。
4. 更广的适用性,yield命令后面只能是Thunk 函数(有兴趣的参考:Thunk 函数 - 阮一峰) 或是Promise 对象 ,而async函数的await命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。
5. 返回值是 Promise,async函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用then方法指定下一步的操作。
进一步说,async函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而await命令就是内部then命令的语法糖。
2. 基本用法
1. async函数返回一个 Promise 对象,可以使用then方法添加回调函数。
2. async函数内部return语句返回的值,会成为then方法回调函数的参数。
但是需要注意:只有async函数内部的异步操作执行完,才会执行then方法指定的回调函数。
function asynchronous() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1000)
}, 1000)
})
}
async function testFn() {
return await asynchronous()
}
testFn().then((res) => {
console.log(res) // 1000
})
3. async函数内部抛出错误,会导致返回的 Promise 对象变为reject状态。抛出的错误对象会被catch方法回调函数接收到。
如果await后面的异步操作出错,那么等同于async函数返回的 Promise 对象被reject
async function testFn() {
try {
throw new Error('error') //被内部catch捕获
} catch (error) {
console.log(error.message) //error
}
throw new Error('我被外部catch抓住了') //被外部catch 捕获
}
testFn()
.then((res) => {
console.log(res) // 1000
})
.catch((error) => {
console.log(error.message) //我被外部catch抓住了
})
4. 正常情况下,await命令后面是一个 Promise 对象,返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值。
async function f() {
// 等同于 return 123;
return await 123;
}
f().then(v => console.log(v))// 123
5. 顶层await
早期的语法规定是,await命令只能出现在 async 函数内部,否则都会报错。
const data = await fetch('https://api.example.com');
但从ES2022开始,允许在模块的顶层独立使用await命令,使得上面那行代码不会报错了。它的主要目的是使用await解决模块异步加载的问题。
注意!!!!
浏览器目前无法识别ES2022,如果需要使用,你需要借助webpack,vite等构建工具。
注意!!!!
顶层await只能用在 ES 模块,不能用在 CommonJS 模块。这是因为 CommonJS 模块的require()是同步加载,如果有顶层await,就没法处理加载了。
若有错误或描述不当的地方,烦请评论或私信指正,万分感谢 😃