😁 作者简介:一名大三的学生,致力学习前端开发技术
⭐️个人主页:夜宵饽饽的主页
❔ 系列专栏:JavaScript进阶指南
👐学习格言:成功不是终点,失败也并非末日,最重要的是继续前进的勇气
🔥前言:
这里是关于异步操作之一的知识点Generator函数的使用和理解。 这是我自己的学习JavaScript的笔记,希望可以帮助到大家,欢迎大家的补充和纠正
🌻 理解异步操作的中级篇在下一节:JavaScript的Generator函数的异步应用,异步操作思路之一(中级)
文章目录
第16章 Generator函数的语法
16.1 简介
16.1.1 基本概念
Generator函数是ES6提供的一种异步编程的解决方案,语法行为与传统函数完全不同,我们可以从多个角度理解
- 从语法上:可以把它理解成一个状态机,封装了多个内部状态
- 从形式上:其是一个普通函数,但是有两个特征:一是function命令与函数名之间有一个星号;二是函数体内部使用yield语句定义不同的内部状态
- 从返回结果上:其调用方法虽然与普通函数一样,但是调用Generator函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是遍历器对象
- 从执行上:Generator函数是分段执行的,yield语句是暂停执行的标记,而next方法可以恢复执行
实例代码:
function* helloWorldGenerator(){
yield 'hello';
yield 'world';
return 'ending'
}
var hw=helloWorldGenerator()
var hw1=hw.next()
console.log(hw1)
//{ value: 'hello', done: false }
var hw2=hw.next()
console.log(hw2)
//{ value: 'world', done: false }
var hw3=hw.next()
console.log(hw3)
//{ value: 'ending', done: true }
var hw4=hw.next()
console.log(hw4)
//{ value: undefined, done: true }
上面的代码一共调用了4次next方法
- 第一次调用,Generator函数开始执行,直到遇到第一条yield语句为止,next方法返回一个对象,它的value属性的值解释当前yield语句的值,而done属性的值false表示遍历还没有结束
- 第二次调用,Generator函数从上次yield语句停下的地方,一直执行到下一条yield语句,如此反复调用执行
- 第三次调用,此时遇到return语句(如果没有return语句,就执行到函数结束)。next方法返回的对象的value属性的值解释return语句后面的表达式的值(如果没有return语句,则value属性的值为undefined,done属性的值true表示遍历已经结束)
- 第四次调用:此时Generator函数已经运行完毕,next方法返回的对象的value属性为undefined,done属性为true,以后再调用next方法,返回的都是这个值
总结一下:调用Generator函数返回一个遍历器对象,代表Generator函数的内部指针。以后,每次调用遍历器对象的next方法,就会返回一个有着value和done两个属性的对象。value属性表示当前的内部状态的值,是yield语句后面那个表达式的值;done属性是一个布尔值,表示是否遍历结束
📝 补充一点:关于function关键字和函数名之间的星号写在哪个位置,下面的四种写法都是可以的
function * foo(x,y){}
function *foo(x,y){}
function* foo(x,y){}
function*foo(x,y){}
由于Generator函数依旧是普通函数,所以一般的写法是上面的第三种
16.1.2 yield表达式
由于Generator函数返回的遍历器对象只有调用next方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数,yield语句就是暂停标志
遍历器的对象的next方法的运行逻辑如下:
- 遇到yield语句就暂停执行后面的操作,并将紧跟在yield后的表达式的值作为返回值的对象的value的值
- 下一次调用next方法时再继续往下面执行,直到遇到下一条yield语句
- 如果没有遇到新的yield语句,就一直运行到函数结束,直到return语句为止
- 如果该函数没有return语句,则返回对象的value的值
❗️ 注意:只有调用next方法且内部指针指向该语句时才会执行yield语句后面的表达式,因此等于为js提供了手动的惰性求值的语法功能
function* gen(){
return 123+789
}
上面的代码中,表达式123+789不会立即求值,只有在next方法将指针移到这一句时才会求值
yield和return的语句的异同点:
相同点:都可以返回紧跟在语句后的表达式的值
不同点:
- 遇到yield函数暂停执行,下一次会从该位置继续向后执行,一个函数可以有多条yield语句
- 遇到return语句不会具备位置记忆功能,一个函数只能有一个return
📝 使用细节:
-
yield表达式只能用在Generator函数里面,用在其他地方会报错
//易错的案例 var arr=[1,[[2,3],4],[5,6]] var flat=function* (a){ a.forEach(item => { if(typeof item !== 'number'){ yield* flat(item) } else{ yield item } }); } for(var f of flat(arr)){ console.log(f) }
上述的代码中,因为forEach方法的参数时一个普通函数,但是在里面使用yield表达式
-
yield表达式如果用另一个表达式之中,必须放在圆括号里面
function* demo(){ console.log('Hello'+yield) //SyntaxError console.log('Hello'+(yield)) //OK }
-
yield表达式用作函数参数或放在表达式的右边可以不加括号
function* demo(){ foo(yield 'a',yield 'b') let input=yield }
16.1.3 与Iterator接口的关系
由于Generator函数就是遍历器生成函数,因此可以把Generator赋值给对象的Symbol,iterator属性,从而使得该对象具有Iterator接口
var myIterable={}
myIterable[Symbol.iterator]=function* (){
yield 1;
yield 2;
yield 3
}
[...myIterable] //[1,2,3]
上面的代码中,Generator函数赋值给Symbol.iterator属性,从而使得myIterable对象具有Iterator接口
Generator函数执行后,返回一个遍历器对象,该对象本身也具有Symbol.iterator属性,执行后返回自身
function* gen(){
// some code
}
var g=gen()
g[Symbol.iterator]()===g
//true
上面的代码中,gen是一个Generator函数,调用它会生成一个遍历器对象g。它的Symbol.iterator属性也是一个遍历器对象生成函数,执行后返回自己
16.2 next方法的参数
yieldd语句本身是没有返回值,或者说总是返回undefined。next方法可以带有一个参数,该参数会被当作为上一条yield语句的返回值
function* foo(x){
var y=2 * (yield (x+1))
var z=yield (y/3)
return (x+y+z)
}
var a=foo(5)
console.log(a.next());
console.log(a.next());
console.log(a.next());
var b=foo(5)
console.log(b.next());
console.log(b.next(12));
console.log(b.next(13));
上面的代码中,与正常的yield语句不同的是,每一层的yield语句都是有依靠关系的,下一层的yield语句的表达式中,依靠上一层yield语句的返回值,所以这种情况下next中传递参数显得尤为重要
这个功能有很重要的语法意义,Generator函数从暂停状态到恢复运行,其上下文状态是不变的,通过next方法的参数就有办法在Generator函数开始运行后继续向函数体内部注入参数,也就是说,可以在Generator函数运行的不同阶段从外部向内部注入不同的值,从而调整函数行为
❗️ 注意:由于next方法的参数表示上一条yield语句的返回值,所以第一次使用next方法时传递的参数时无效,V8引擎直接忽略第一次使用next方法时的参数,只有从第二次使用next方法开始,参数才是有效的,从语义上讲,第一个next方法用来启动遍历器对象,所以不用带有参数
⭐️ 下面是一个直观的特别的调用例子:
function* dataConsumer(){
console.log('Started')
console.log(`1. ${yield}`)
console.log(`2. ${yield}`)
return 'result'
}
let genObj=dataConsumer()
genObj.next()
//Started
genObj.next('a')
//1. a
genObj.next('b')
//2. b
16.3 for…of循环
for…of循环可以自动遍历Generator函数生成的Iterator对象,且此时不再需要调用next方法
function* foo(){
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
return 6;
}
for(let v of foo()){
console.log(v)
}
//1 2 3 4 5
上面的代码使用fot…of循环依次显示5条语句的值
❗️ 注意**:一旦next方法的返回对象的done属性为true,for…of循环就会终止,且不包含该返回对象,所以上面的return语句返回的6不包括在for…of循环中**
📖 关于原生js对象没有遍历接口,无法使用for…of循环,这里可以使用Generator函数两种思路
- 通过Generator函数为它加上这个接口后就可以使用
- 将Generator函数加到对象的SYmbol.iterator属性上
使用细节:
- 除了for…of循环,扩展运算符(…),解构赋值和Array.from方法内部调用的都是遍历器接口,这意味着,它们都可以将Generator函数返回的Iterator对象作为参数
16.4 Generator.prototypr.throw()
Generator函数返回的遍历器对象都有一个throw方法,可以在函数体外抛出错误,然后再Generator函数体内捕获
var g=function* (){
try {
yield;
} catch (e) {
console.log('内部捕获',e)
}
}
var i=g()
i.next();
try{
i.throw('a')
i.throw('b')
}catch(e){
console.log('外部捕获',e)
}
上面的代码中,遍历器对象i连续抛出两个错误,第一个错误被Generator函数体内的catch语句捕获。i第二次抛出错误,由于Generator函数内部的catch语句已经执行过了,不会再捕捉到这个错误了,所以这个错误就被抛出了Generator函数体,被函数体外的catch语句捕获。
使用细节:
- throw方法可以接受一个参数,该参数会被catch语句接收,建议抛出Error对象的实例
- 遍历器对象的throw方法和全局的throw命令是不一样的,是无关的,两种互相不影响,上面例子的错误是用遍历器对象throw方法抛出的,而不是全局的throw命令,使用throw命令抛出的错误只能被函数体外的catch捕获
- 如果Generator函数体内没有部署try…catch代码块,那么throw方法抛出的错误将会被外部try…catch代码块捕获
- 如果Generator函数体内部署了try…catch代码块,那么遍历器的throw方法抛出的错误不影响下一次遍历,否则遍历直接终止
- throw方法被捕获以后会附带执行下一条yield表达式,即附带执行一次next方法
🤔 问题:这种函数体内捕获错误的机制的益处是什么?
😄 答:这种函数函数体内捕获错误的机制大大方便了对错误的处理,对于多个yield表达式,可以只用一个try…catch代码块来捕获错误,如果使用回调函数的写法想要捕获多个错误,就不得不每个函数写一个错误处理语句,而现在只再Generator函数内部写一次catch语句就可以了
Generator函数体外抛出的错误可以在函数体内捕获,反过来,Generator函数体内抛出的错误也可以被函数体外的catch捕获
function* foo(){
var x=yield 3;
var y=x.toUpperCase()
yield y;
}
var it=foo()
console.log(it.next())
try {
it.next(42)
} catch (e) {
console.log(e)
}
这个和在外部抛出,内部捕获错误的不同点就是,一旦Generator执行的过程中抛出错误,就不会再执行下去了,如果此后还调用next方法,将返回一个value属性等于undefined,done属性等于true的对象,即js引擎认为这个Generator已经结束运行。
👨 总结一下
我们可以把抛出错误分为三种情况:全局throw,外部it.throw(),内部的错误
我们可以把捕获错误分为两种情况:内部捕获,外部捕获
可以确定出思维图:先后表示执行顺序
16.5 Generator.prototype.return()
Generator函数返回的遍历器对象还有一个return方法,可以返回给定的值,并终结Generator函数的遍历
function* gen(){
yield 1;
yield 2;
yield 3;
}
var g=gen()
g.next() //{value:1,done:false}
g.return('foo') //{value:'foo',done"true}
g.next() //{value:undefined,done:true}
📝 使用细节:
- 如果return方法调用时不提供参数,则返回值的value属性为undefined
- 如果Generator函数内部有try…finally代码块,那么return方法会推迟到finally代码块执行完再执行
16.6 yield*表达式
如果在Generator函数内部调用另一个Generator函数,默认情况下是没有效果
不过我们可以调用yield*语句,用来在一个Generator函数里面执行另一个Generator函数
function* foo(){
yield 'a';
yield 'b';
}
function* bar(){
yield 'x';
foo()
yield 'y'
}
for(let v of bar()){
console.log(v);
}
//x
//y
//使用yield*调用Generator函数
function* bar1(){
yield 'x';
yield* foo();
yield 'y';
}
//等同于
function* bar(){
yield 'x';
yield 'a';
yield 'b';
yield 'y';
}
//等同于
function* bar(){
yield 'x';
for(let v of foo()){
yield v
}
yield 'y'
}
for(let v of bar1()){
console.log(v)
}
//x
//a
//b
//y
从语法的角度看,如果yield命令后面跟一个遍历器对象,那么想要在yield命令后面加上星号,表明返回的是一个遍历器对象,这被称为yield*语句
📝 使用细节:
1. yield*后面的Generator函数在有没有return语句时会表现出不同的语法行为
- 没有return语句时,等同于在Generator函数内部部署一个for…of循环,简单来说yield*就是for…of的简介形式
- 有return语句时,则需要用var value=yield* iterator的形式获取return语句的值
2.任何数据结构只要有Iterator接口,就可以被yield*遍历
🌵 实例:
1. yield命令可以很方便地取出来嵌套数组的所有成员
function* iterTree(tree){
if(Array.isArray(tree)){
for(let i=0;i<tree.length;i++){
yield* iterTree(tree[i])
}
}else{
yield tree
}
}
const tree=['a',['b','c'],['d','e']]
for(let x of iterTree(tree)){
console.log(x)
}
2.使用yield语句完成遍历二叉树
16.7 作为对象属性的Generator函数
如果一个对象的属性是Generator函数,那么可以简写成下面的形式
let obj={
*muGeneratorMethod(){
}
}
16.8 Generator函数this
Generator函数总是返回一个遍历器,ES6规定这个遍历器是Generator函数的实例,它也继承了Generator函数的prototype对象上的方法
function* g(){}
g.prototype.hello=function(){
return 'hi'
};
let obj=g()
console.log(obj instanceof g) //true
console.log(obj.hello()) //'hi
上面的代码表明,Generator函数g返回遍历器obj是g的实例,而且继承g.prototypr。但是,如果把g当作普通的构造函数,则不会生效,因为g的返回值总是遍历器对象,而不是this对象
function* g(){
this.a=11
}
let obj=g()
obj.a //undefined
📝 使用细节:
1. Generator函数不能跟new命令一起使用,否则会报错
🤔 问题:那么有没有办法可以让Generator函数返回一个正常的对象实例,即可以用next方法,又可以获得正常的this呢
😄 答:有两种方案:
-
首先生成一个对象,使用call方法绑定Generator函数内部的this,这样,构造函数调用以后,这个空对象就是Generator函数的实例对象
function* f(){ this.a=1; yield this.b=2 yield this.c=3 } var obj={} var f=F.call(obj) f.next() //Object{value:2,done:false} f.next() //Object{value:3,done:false} f.next() //Object{value:undefined,done:false} obj.a //1 obj.a //2 obj.c //3
-
将上述代码中的obj换成F.prototype
16.9 含义
16.9.1 Generator与状态机
Gnerator是实现状态机的最佳结构,比如下面的clock函数就是一个状态机
var ticking=true
var clock=function(){
if(ticking){
console.log('Tick!')
}else{
console.log('Yock!')
}
ticking=!ticking
}
上面的clock函数一共有两种状态(Tick和Tock),每运行一次,就改变一次状态,这个函数如果用Generator实现,代码如下
var clock=function* (){
while(true){
console.log('Tick!')
yield;
console.log('Tock!')
yield;
}
}
对比上面的Generator实现与ES5实现,可以看到少了用来保存状态的外部变量ticking,这样就更加简洁,更安全(状态不会被非法篡改),更符合函数式编程的思想
16.9.2 Generatoe协程
协程是一种程序运行的方式,可以理解成“协作的线程”或“协作的函数”。协程即可以用单线程,也可以用多线程实现,前者是一种特殊的子例程,后者是一种特殊的线程。
协程与子例程的差异:
传统的“子例程”(subroutine)采用堆栈式“后进先出”的执行方式,只有当调用的子数完全执行完毕,才会结束执行父函数。协程与其不同,多个线程(单线程情况下即多个函数)可以并行执行,但只有一个线程(或函数)处于正在运行的状态,其他线程(或函数)都处于暂停态(suspended),线程(或函数)之间可以交换执行权。也就是说,一个线程(或函数)执行到一半,可以暂停执行,将执行权交给另一个线程(或函数),等到稍后收回执行权时再恢复执行。这种可以并行执行、交换执行权的线程(或函数),就称为协程。
从实现上看,在内存中子例程只使用一个栈(stack),而协程是同时存在多个栈,但只有一个栈是在运行态。也就是说,协程是以多占用内存为代价实现多任务的并行运行。
协程与普通线程的差异
不难看出,协程适用于多任务运行的环境。在这个意义上,它与普通的线程很相似,都有自己的执行上下文,可以分享全局变量。它们的不同之处在于,同一时间可以有多个线程处于运行态,但是运行的协程只能有一个,其他协程都处于暂停态。此外,普通的线程是抢占式的,到底哪个线程优先得到资源,必须由运行环境决定,但是协程是合作式的,执行权由协程自己分配。
由于JavaScript是单线程语言,只能保持一个调用栈。引入协程以后,每个任务可以保持自己的调用栈。这样做的最大好处,就是抛出错误的时候,可以找到原始的调用栈。不至于异步操作的回调函数那样,一旦出错原始的调用栈早就结束。
Generator函数是ES6对协程的实现,但属于不完全实现。Generator函数被称为“半协程”,意思是只有Generator函数的调用者才能将程序的执行权还给Generator函数,如果是完成实现的协程,任何函数都可以让暂停的协程继续执行
如果将Generator函数当作协程,完全可以将多个需要互相协作的任务写成Generator函数,它们之间使用yield语句交换控制权
16.10 应用
Generator可以暂停函数执行,返回任意表达式,这种特点使得其有多种应用
16.10.1 异步操作的同步化表达
Generator函数的暂停执行效果,意味着可以把异步操作写在yield语句中,等到调用next方法往后面执行,所以Generator函数一个重要实际意义就是用于处理异步操作,改写回调函数
function* loadUI(){
showLoadingScreen()
yield loadUIDataAsynchronously()
hideLoadingScreen()
}
var loader=loadUI()
//加载UI
loader.next()
//卸载UI
loader.next()
上面代码中,第一次调用loadUI函数时,该函数不会执行,仅返回一个遍历器。下一次的该遍历器使用next方法,则会显示Loading界面(showLoadingScrren),并且异步加载数据(loadUIDataAsynchronously) 。等到数据加载完成,再一次使用next方法,则会隐藏Loading界面,可以看到,这种写法的好处就是所有Loading界面的逻辑,都被封装在一个函数,按部就班非常清晰
16.10.2 控制流管理
function *longRunningTask(value1){
tyr{
var value2=yield step1(value1)
var value3=yield step1(value2)
var value4=yield step1(value3)
var value5=yield step1(value4)
}catch{
//Handle any error from step1 through step4
}
}
scheduler(longRunningTask(initialValue));
function scheduler(task){
var taskObj=task.next(task.value)
if(!taskObj.done){
task.value=taskObj.value
scheduler(task)
}
}
❗️ 注意:上面的这种做法只适合同步操作,即所有的task都必须同步,不能有异步,因为这里的代理一得到返回值就继续往下执行,没有判断异步操作何时完成。如果要控制异步操作流程,详见后文关于异步操作的内容
16.10.3 部署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
16.10.4 作为数据结构
Generator可以看作数据结构,更确切,可以看作一个数据结构,因为Generator函数可以返回一系列的值,者意味着它可以对任意表达式提供类似的接口