一次性讲清楚迭代器,可迭代对象和生成器
很好请告诉我有多少人之前都没怎么了解过这些概念的最开始我知道它是在书上它是这样的形式出现的function*(x){varyyieldx1;returny}第一个反应是这是啥我用过吗好像没有。哦那就是不重要的概念忽视之。说句实在话直到现在我在代码中都还没用过yield这个关键字但是这并不影响我们实际上每天都在使用它们比如for...of再比如async/await。感谢前辈们把这些功能二次封装得很好一、先把概念说清楚为什么需要迭代器先想一个问题ES6 之前遍历不同的数据结构写法是不统一的。数组用索引for循环对象用for...in类数组如arguments又得转一道。后来 ES6 还新增了Set和Map数据结构越来越多如果每种都有自己的遍历方式代码会很乱。迭代器的出现就是为了给所有数据结构一个统一的遍历接口。有了它无论底层是数组、字符串还是 Map都能用同一套for...of来遍历。这就是这套机制存在的根本意义——统一。下面登场的三个角色正是为了实现这个统一而层层搭建的。角色一迭代器Iterator——一个按需吐值的指针迭代器是一个对象它身上有一个next()方法。每调一次next()它吐出一个结果对象{value:当前值,done:是否已经取完}把它想象成一个取号机按一下出一个号value并告诉你还有没有了done。取完了done变成true。我们手动造一个迭代器就看清它的本质了functionmakeIterator(arr){leti0;return{next(){if(iarr.length){return{value:arr[i],done:false};}else{return{value:undefined,done:true};}}};}constitmakeIterator([a,b]);it.next();// { value: a, done: false }it.next();// { value: b, done: false }it.next();// { value: undefined, done: true } —— 取完了这就是迭代器的全部一个有next()、能逐个吐值并报告是否结束的对象。这套必须有 next()、返回 {value, done}的约定叫迭代器协议Iterator Protocol。但光有迭代器还不够——for...of怎么知道去哪儿拿这个迭代器这就引出第二个角色。角色二可迭代对象Iterable——一个能交出迭代器的东西for...of遵守另一个约定一个对象如果想被for...of遍历必须有一个名为Symbol.iterator的方法这个方法返回一个迭代器。满足这个条件的对象就叫可迭代对象。这套约定叫可迭代协议Iterable Protocol。数组、字符串、Set、Map天生就有Symbol.iterator所以它们能被for...of遍历普通对象没有所以不能。我们给普通对象手动加上它立刻就能遍历了constobj{data:[x,y,z],[Symbol.iterator](){// 实现这个方法返回一个迭代器leti0;return{next:(){returnithis.data.length?{value:this.data[i],done:false}:{value:undefined,done:true};}};}};for(constvofobj)console.log(v);// x y z —— 现在能遍历了两个角色的分工很清楚角色关键方法职责可迭代对象IterableSymbol.iterator()交出一个迭代器迭代器Iteratornext()逐个吐值报告是否结束for...of的完整工作流程就是先调对象的Symbol.iterator()拿到迭代器然后不停调next()取值直到done: true停止。到这里你应该感觉到了手写迭代器很啰嗦——又要维护索引、又要判断done、又要拼{value, done}。有没有更省事的办法有这就是第三个角色。角色三生成器Generator——自动造迭代器的快捷方式生成器的意义就是把手写迭代器那套啰嗦的活儿自动化。你只管用yield把值一个个吐出来生成器自动帮你生成符合迭代器协议的next()和{value, done}。把前面手写的迭代器用生成器重写function*gen(arr){// function* 表示这是个生成器for(constxofarr){yieldx;// 每个 yield 自动对应一次 next() 的产出}}constitgen([a,b]);it.next();// { value: a, done: false } —— 格式和手写的完全一样it.next();// { value: b, done: false }it.next();// { value: undefined, done: true }对比一下生成器替你做了这些事手写迭代器生成器自己维护索引i自动记住执行到哪了靠 yield 暂停自己判断done函数跑完自动done: true自己拼{ value, done }yield x自动产出这个结构一堆样板代码几行搞定而且有个关键特性生成器调用后返回的对象本身既是迭代器、又是可迭代对象它的Symbol.iterator默认返回它自己。所以它能直接next()也能直接for...offunction*gen(){yield1;yield2;yield3;}for(constxofgen())console.log(x);// 1 2 3 —— 直接能遍历这就是生成器被称为造迭代器的快捷方式的原因它一举满足了两个协议省掉了所有样板代码。核心机制yield 是一扇旋转门生成器还有一个比自动造迭代器更深的能力——yield能双向传递数据。这是它最容易被忽略、也最强大的地方。方向一yield 把值吐出去。yield 10让函数暂停并把10通过next()返回对象的value交给外面。方向二外面把值塞回来。这一步最反直觉yield 10这个表达式的返回值不是 10而是外面下一次调用next(X)时传进来的那个 X。function*gen(){constayield10;// a 的值由外面下次 next() 传入决定console.log(a ,a);}constggen();g.next();// 跑到 yield 10 暂停吐出 10g.next(99);// 把 99 塞回给 yield于是 a 99打印 a 99把两个方向合起来yield就像一扇旋转门暂停时把一个值递出去恢复时从外面接一个值进来。const a yield X这行X是吐给外面的出现在next()返回的 value 里而a拿到的是外面下次next(值)塞回来的值。一出一进发生在同一个yield上。这扇旋转门是生成器所有高级应用的基础。它最著名的应用就是async/await——下面这一节我们就亲手把它造出来你会彻底明白yield的双向传值到底有什么用。二、旋转门的杀手级应用co 函数与 async/await 的由来开头我提到我们其实每天都在用生成器比如async/await。async/await在底层就是生成器 一个自动驱动器而这个自动驱动器历史上有个著名的实现叫co 函数。理解了 co你就理解了async/await的本质。先看一个理想场景假设我们yield出去的不是普通值而是一个Promisefunction*gen(){constuseryieldfetchUser();// 吐出一个 PromiseconstpostsyieldfetchPosts(user);// 用上一步的结果再吐出一个 Promiseconsole.log(posts);}设想有个外面的人这样配合这个生成器生成器yield出一个 Promise暂停外面接住这个 Promise等它 resolve等到了把结果通过next(结果)塞回去——于是user拿到了真实数据这正是上一节旋转门的方向二生成器从暂停处继续跑到下一个yield吐出第二个 Promise再暂停外面再等、再塞回去……循环往复直到生成器结束。如果真有这么个外面的人自动做第 2、3 步这段生成器读起来就和同步代码一模一样const user yield fetchUser()看上去就是等到用户数据、赋值给 user但中间的等待是非阻塞的。那个外面自动帮你等、帮你塞回结果的人就是co 函数。亲手写一个 co 函数co 要做的就是把等 Promise → 塞回结果 → 继续 → 再等这个循环自动化。核心就十几行functionco(generator){constggenerator();// 调用生成器函数拿到生成器对象遥控器functionstep(value){constresultg.next(value);// 把上一步的结果塞回去推进到下一个 yield// result { value: 吐出来的 Promise, done: 是否结束 }if(result.done)return;// 生成器跑完了收工// result.value 是个 Promise等它 resolve// 再把结果通过 step 塞回去驱动下一步result.value.then(resstep(res));}step();// 第一次启动首次 next 不需要传值}用语言把step这个循环读一遍就是旋转门的故事在自动转g.next(value)把上次等到的结果塞回去生成器从暂停处继续跑到下一个yield吐出新 Promiseif (result.done) return如果生成器已跑完停止result.value.then(res step(res))否则等这个 Promise 好了拿到结果再调一次step塞回去进入下一轮。配合使用co(function*(){constuseryieldfetchUser();constpostsyieldfetchPosts(user);console.log(posts);});这就是全部魔法。co 不停地推进生成器 → 拿到一个 Promise → 等它 → 把结果喂回去 → 再推进直到生成器结束。没有任何黑魔法全是上一节那扇旋转门的机械重复。async/await 就是内置的 co现在把上面那段和async/await并排放// 生成器 co // async/awaitco(function*(){asyncfunctionload(){constuseryieldfetchUser();constuserawaitfetchUser();constpostsyieldfetchPosts(user);constpostsawaitfetchPosts(user);console.log(posts);console.log(posts);});}load();几乎一模一样差别只有三处生成器 coasync/awaitfunction*async functionyieldawait需要手动包一个co不需要引擎内置了核心结论async/await本质上就是生成器语法 一个内置的自动执行器。await干的事和yield 出一个 Promise完全相同——暂停函数、等 Promise resolve、把结果塞回来继续。你不用写 co是因为 JS 引擎在背后替你做了。这就回答了开头那个困惑你没直接用过yield却天天在用async/await——因为后者就是前者被引擎封装好的样子。前辈们把这套机制二次封装得太好好到你感觉不到生成器的存在。三、用面试题把概念吃透下面是几道真实的前端面试题覆盖迭代器/生成器的高频考点。每题先自己推一遍再看答案和解析。题目 1执行时机function*foo(){console.log(A);yield;console.log(B);yield;console.log(C);}constgfoo();console.log(start);g.next();g.next();先想想输出顺序是什么const g foo()这行会打印 A 吗答案与解析答案start → A → B逐步推演const g foo()——不打印任何东西。调用生成器函数不执行函数体只返回一个处于暂停状态的生成器对象。这是最高频的考点。console.log(start)—— 打印start。第一次g.next()—— 函数开始执行打印A遇到第一个yield暂停。第二次g.next()—— 从暂停处恢复打印B遇到第二个yield暂停。代码结束C没机会打印需要第三次 next。关键认知调用生成器函数 ≠ 执行它。它返回一个遥控器函数体一行都没跑必须靠next()一步步驱动每次next()跑到下一个yield为止。题目 2next 传参双向传值function*gen(){constxyieldfirst;constyyieldx1;returnxy;}constggen();console.log(g.next());// ?console.log(g.next(10));// ?console.log(g.next(20));// ?先想想三次 next 各返回什么x 和 y 分别是多少答案与解析答案{value:first,done:false}→{value:11,done:false}→{value:30,done:true}逐步推演盯住旋转门那条认知g.next()—— 函数跑到yield first暂停吐出first。返回{value:first, done:false}。注意第一次 next 的传参没有意义因为此时还没有哪个 yield 在等待接收。g.next(10)—— 把10塞回给上一个yield first于是x 10。继续执行到yield x 1即yield 11吐出11暂停。返回{value:11, done:false}。g.next(20)—— 把20塞回给yield x1于是y 20。执行return x y即return 30函数结束。返回{value:30, done:true}。关键认知yield表达式的值来自下一次next()的参数不是 yield 后面那个值。这正是生成器能实现 async/await 的根基——把 Promise 的结果通过 next 塞回去。题目 3生成器只能迭代一次function*gen(){yield1;yield2;yield3;}constggen();constarr1[...g];constarr2[...g];console.log(arr1);// ?console.log(arr2);// ?先想想arr1 和 arr2 分别是什么答案与解析答案arr1 是[1, 2, 3]arr2 是[]空数组解析生成器对象的Symbol.iterator返回的是它自己而不是一个全新的迭代器。所以它是个一次性的可迭代对象——第一次[...g]已经把它迭代到done: true耗尽了第二次再迭代它直接返回done: true啥也取不到。这区别于数组数组每次调用Symbol.iterator都返回一个全新的迭代器所以能反复遍历。关键认知生成器对象只能迭代一次迭代完就耗尽了。如果需要反复遍历要么每次重新调用生成器函数拿新对象gen()要么把结果先存进数组。这是实际开发里容易踩的坑。题目 4手写一个可迭代对象// 让下面这个 range 对象能被 for...of 遍历输出 1 2 3 4 5constrange{from:1,to:5};for(constnofrange)console.log(n);// 目标1 2 3 4 5先想想怎么给 range 加上可迭代能力至少有两种写法。答案与解析答案实现Symbol.iterator方法。有两种写法体现了手写迭代器和用生成器的区别。写法一手写迭代器啰嗦版constrange{from:1,to:5,[Symbol.iterator](){letcurrentthis.from;constlastthis.to;return{next(){returncurrentlast?{value:current,done:false}:{value:undefined,done:true};}};}};写法二用生成器简洁版——因为生成器自动满足迭代器协议constrange{from:1,to:5,*[Symbol.iterator](){// 注意这个 * 把方法定义成生成器for(letithis.from;ithis.to;i){yieldi;}}};两种写法效果一样但写法二少了维护current、判断done、拼{value, done}的所有样板代码。让对象可迭代 给它实现Symbol.iterator用生成器实现是最省事的方式。这道题几乎是自定义可迭代对象的标准面试题。题目 5yield* 委托function*inner(){yielda;yieldb;}function*outer(){yield1;yield*inner();// 注意这个星号yield2;}console.log([...outer()]);// ?先想想yield*和yield有什么区别输出是什么答案与解析答案[1, a, b, 2]解析yield*带星号是委托——它把迭代的控制权交给另一个可迭代对象这里是inner()把对方产出的值逐个接力吐出来而不是把整个对象当成一个值吐出去。对比一下yield inner()—— 会把inner()这个生成器对象整体作为一个值吐出结果是[1, 生成器对象, 2]。yield* inner()—— 把inner()产出的a、b一个个接力吐出结果是[1, a, b, 2]。yield*用于把一个生成器/可迭代对象的值摊平接力出来常用于生成器之间的组合复用。一字之差星号行为完全不同。题目 6用生成器表达无限序列惰性求值function*naturalNumbers(){letn1;while(true){// 无限循环yieldn;}}constnumsnaturalNumbers();先想想这个while(true)会不会把页面卡死为什么怎么从里面取前 3 个数答案与解析答案不会卡死。取值nums.next().value连续调三次得到 1、2、3。解析普通函数里写while(true)会同步死循环、卡死主线程这是阻塞。但生成器不会——因为它是惰性求值的yield n每次只产出一个值就暂停主动权在外面。你调一次next()它才前进一步不调它就一直停着。nums.next().value;// 1nums.next().value;// 2nums.next().value;// 3 —— 要几个给几个永远不会一次性算完生成器的可暂停让它能表示无限序列而不撑爆内存。普通函数必须一次性算完所有值生成器则是用一个才算一个。这是生成器区别于普通函数的独特价值也是它在处理大数据流、分页加载等场景的用武之地。