当前位置: 代码迷 >> 综合 >> 异步翊驱--Generator 函数JS中的异步方案
  详细解决方案

异步翊驱--Generator 函数JS中的异步方案

热度:73   发布时间:2023-10-17 03:49:36.0

前言

异步编程对 JavaScript 语言及其重要,因为JavaScript 语言中只有一根线程,如果没有异步编程,则势必日湮月塞,非卡死不可。ES6或许会迟到,但它从不缺席,ECMAScript 6 (简称 ES6 )作为下一代 JavaScript 语言,不仅对JS全面提升,同时也将 JavaScript 异步编程带入了一个全新的阶段,Generator函数便是其中之一。

异步编程的语法目标,就是怎样让它更像同步编程本文我们将一起来看一下Generator这种异步编程解决方案,学习它的语法,以及一些异步应用。


一、首先看一下基本概念

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

语法上,首先可以把它理解成,Generator 函数是一个状态机,封装了多个内部状态。

其次执行 Generator 函数后,会返回一个遍历器对象,具备Iterator 接口,可以被for...of和拓展运算符(...)遍历。

也就是说,Generator 函数 = 状态机  + 遍历器对象生成函数

返回的遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。

形式上,Generator 函数是一个普通函数,但是有两个特征:

一是,function关键字与函数名之间,有一个星号;

二是,函数体内部使用yield表达式,定义不同的内部状态(yield在英语里的意思就是“产出”)。

也就是说,Generator 函数的写法特征 = 星号  + yield表达式

ES6 没有规定,function关键字与函数名之间的星号,写在哪个位置。这导致下面的写法都能通过。

function * foo(x, y) { ··· } 
function *foo(x, y) { ··· }
function* foo(x, y) { ··· } // ??
function*foo(x, y) { ··· }

由于 Generator 函数仍然是普通函数,所以一般的写法是上面的第三种,即星号紧跟在function关键字后面。本处也采用这种写法。

最后补充一点,Generator 函数最大的特点就是可以交出函数的执行权(即暂停执行)。

二、熟悉概念之后,我们先来看一个最简单的Generator实例

function* generatorTest() {yield 'my'yield 'Generator'return 'ending'
}let gt = generatorTest()

上面代码定义了一个 Generator 函数generatorTest(),它内部有两个yield表达式(helloworld),即该函数有三个状态:my,Generator 和 return 语句(结束执行)。

然后,Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。

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

yield表达式是暂停执行的标记,而next方法可以恢复执行,如下

gt.next()
// { value: 'my', done: false }gt.next()
// { value: 'Generator', done: false }gt.next()
// { value: 'ending', done: true }gt.next()
// { value: undefined, done: true }

上面代码一共调用了四次next方法。next方法返回一个对象:{ value, done }

value属性就是当前yield表达式的值,done属性是一个布尔值,表示遍历有没有结束。

第一次调用,Generator 函数开始执行,直到遇到第一个yield表达式为止。

第二次调用,Generator 函数从上次yield表达式停下的地方,一直执行到下一个yield表达式。

第三次调用,Generator 函数从上次yield表达式停下的地方,一直执行到return语句(如果没有return语句,就执行到函数结束)。

第四次调用,此时 Generator 函数已经运行完毕,next方法返回对象的value属性为undefineddone属性为true

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

三、 yield表达式:

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

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

1、遇到yield表达式,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回对象value属性值。

2、下一次调用next方法时,在继续往下执行,直到遇到下一个yield表达式。

3、如果没有再遇到新的yield表达式,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回对象的value属性值。

4、如果该函数没有return语句,则返回的对象的value属性值为undefined。

需要注意的是,yield后面的表达式,只有当调用next方法、内部指针指向该语句时才会执行,因此等于为JavaScript提供了手动的“惰性求值”(Lazy Evaluation)的语法功能。

function* gen() {yield  123 + 456
}

上面的代码只有在next方法将指针移到时,才会求值。

Generator函数也可以不使用yield表达式,这样就成了一个单纯的延时执行函数。

function* f() {console.log('延时两秒执行')
}let generator = f()setTimeout(function () {generator.next()
}, 2000)

上面代码中,函数f如果是普通函数,在为变量generator赋值时就会执行。但是,函数f是一个Generator函数,就变成只有调用next方法时,函数f才会执行。

yield表达式只能用在Generator函数里面,用在其他地方都会报错。

(function (){yield 1
})()
// SyntaxError: Unexpected number
// 任何企图在普通函数中使用yield表达式的操作都会报错

再看另一个例子,下面是实现函数扁平化的一种方案:

let arr = [1, [[2, 3], 4], [5, 6]]let flat = function* (a) {a.forEach(function (item) {if (typeof item !== 'number') {yield* flat(item)} else {yield item}})
}for (let f of flat(arr)){console.log(f)
}

上面的代码也会产生语法错误,因为forEach方法的参数是一个普通函数,但是在里面使用了yield表达式(这个函数里面还使用了yield*表达式)。这个问题可以将其换成for循环来解决:

let arr = [1, [[2, 3], 4], [5, 6]]let flat = function* (a) {let length = a.lengthfor (let i = 0; i < length; i++) {let item = a[i]if (typeof item !== 'number') {yield* flat(item)} else {yield item}}
}for (let f of flat(arr)) {console.log(f)
}
// 1, 2, 3, 4, 5, 6

如果yield表达式用在另一个表达式之中,必须放在圆括号里面。

function* demo() {console.log('Hello' + yield) // SyntaxErrorconsole.log('Hello' + yield 123) // SyntaxErrorconsole.log('Hello' + (yield)) // OKconsole.log('Hello' + (yield 123)) // OK
}

yield表达式用作函数参数或放在赋值表达式的右边,可以不加括号。

function* demo() {foo(yield 'a', yield 'b') // OKlet input = yield // OK
}

四、next方法的参数

yield表达式本身没有返回值,或者说它总是返回unfined。 next方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值。

function* f() {for(let i = 0; true; i++) {let reset = yield iif(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 }

上面先定义了一个可以无限运行的Generator函数,next方法没有参数时,每运行到yield表达式,变量reset的值总是undefined

当next方法带一个参数true时,变量reset就被重置为这个参数(即true),因此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() // Object{value:6, done:false}
a.next() // Object{value:NaN, done:false}
a.next() // Object{value:NaN, done:true}let b = foo(5)
b.next() // { value:6, done:false }
b.next(12) // { value:8, done:false }
b.next(13) // { value:42, done:true }

上面首先展示了不带参数的情况,这导致了 y = 2 * undefined(即NaN),除以3以后还是NaN,因此返回对象的value属性也等于NaN。第三次运行Next方法依旧没参数,导致最终结果还是NaN。

但如果我们对next方法提供参数,那么事情就不一样了:

第一次调用b的next方法时,返回x + 1的值6。(x的值是5)

第二次调用next方法,将上一次yield表达式的值设为12,因此 y = 24,返回 y / 3 = 8

第三次调用next方法,将上一次yield表达式的值设为13,因此z等于13,这时z = 13, x = 5, y = 24,所以return语句的值等于42。

注意:next方法的参数表示上一个yield表达式的返回值,所以在第一次使用next方法时,传递参数是无效的。甚至V8引擎就直接忽略第一次使用next方法时的参数了,只有从第二次使用next方法开始,参数才是有效的。

从语义上讲,第一个next方法用来启动遍历器对象,所以不用带有参数。

再看一个通过next方法的参数,向 Generator 函数内部输入值的例子。

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

上面代码是一个很直观的例子,每次通过next方法向 Generator 函数输入值,然后打印出来。

如果想要第一次调用next方法时,就能够输入值,可以在 Generator 函数外面再包一层。

function wrapper(generatorFunction) {return function (...args) {let generatorObject = generatorFunction(...args)generatorObject.next()return generatorObject}
}const wrapped = wrapper(function* () {console.log(`First input: ${yield}`)return 'DONE'
})wrapped().next('hello!')// First input: hello!

可以看到在Generator函数外面用warpper先包一层,就可以第一次调用next方法,便输入参数。

五、yield* 表达式

如果在 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

上面代码中,foor和bar都是Generator函数,在bar里面调用foo,就需要手动遍历foo。如果由多个Generator函数嵌套,写起来就非常麻烦。ES6提供了 yield*表达式 ,作为解决办法,用来在一个Generator函数里面,执行另一个Generator函数。

function* bar() {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 bar()){console.log(v)
}
// "x"
// "a"
// "b"
// "y"

再来看一个对比的例子:

function* inner() {yield 'hello!'
}function* outer1() {yield 'open'yield inner()yield 'close'
}let gen = outer1()
gen.next().value // "open"
gen.next().value // 返回一个遍历器对象
gen.next().value // "close"function* outer2() {yield 'open'yield* inner()yield 'close'
}let gen = outer2()
gen.next().value // "open"
gen.next().value // "hello!"
gen.next().value // "close"

上面的代码中,outer2使用了yield*,outer1没有用。结果,outer1返回一个遍历器对象,outer2返回该遍历器对象的内部值。

语法上,如果yield表达式后面跟的是一个遍历器对象,需要在yield表达式后面加上星号,表明他返回的是一个遍历器对象。这被称为yield*表达式。

let delegatedIterator = (function* () {yield 'Hello!'yield 'Bye!'
}())let delegatingIterator = (function* () {yield 'Greetings!'yield* delegatedIteratoryield 'Ok, bye.'
}())for(let value of delegatingIterator) {console.log(value)
}
// "Greetings!
// "Hello!"
// "Bye!"
// "Ok, bye."

delegatingIterator是代理者,delegatedIterator是被代理者。由于yield* delegatedIterator 语句得到的值,是一个遍历器,所以要用星号表示。运行结果就是使用一个遍历器,遍历了多个Generator函数,有递归的效果。

yield*后面的Generator函数(没有return语句时),等同于在Generator函数内部,部署一个for...of循环。

function* concat(iter1, iter2) {yield* iter1yield* iter2
}// 等同于function* concat(iter1, iter2) {for (let value of iter1) {yield value}for (let value of iter2) {yield value}
}

上面的代码说明,yield*后面的Generator函数(没有return语句时),不过是for...of的一种简写形式,完全可以用后者替代前者。反之,在有return语句时,则需要用let value = yield* iterator的形式获取return语句的值。

如果yield*后面跟着一个数组,由于数组原生支持遍历器,因此就会遍历数组成员。

function* gen(){yield* ["a", "b", "c"]
}gen().next() // { value:"a", done:false }

上面代码中,yield命令后面如果不加星号,返回的是整个数组,加星号就表示返回的是数组的遍历器对象。

实际上,任何数据结构只要有Iterator接口,就可以被yield*遍历。

let read = (function* () {yield 'hello'yield* 'hello'
})()read.next().value // "hello"
read.next().value // "h"

上面代码中,yield表达式返回整个字符串,yield*语句返回单个字符。因为字符串具有Iterator接口,所以被yield*遍历。

如果被代理的Generator函数有return语句,那么就可以向代理它的Generator函数返回数据。

function* foo() {yield 2;yield 3;return "foo"
}function* bar() {yield 1let v = yield* foo()console.log("v: " + v)yield 4
}let it = bar()it.next()
// {value: 1, done: false}
it.next()
// {value: 2, done: false}
it.next()
// {value: 3, done: false}
it.next()
// "v: foo"
// {value: 4, done: false}
it.next()
// {value: undefined, done: true}

上面代码在第四次调用next方法的时候,屏幕上会有输出,这是因为函数foo的return语句,向函数bar提供了返回值。

再看一个例子: 

function* genFuncWithReturn() {yield 'a'yield 'b'return 'The result'
}
function* logReturned(genObj) {let result = yield* genObjconsole.log(result)
}[...logReturned(genFuncWithReturn())]
// The result
// 值为 [ 'a', 'b' ]

上面存在两次遍历。第一次是拓展运算符遍历函数logReturned返回的遍历器对象,第二次是yield*语句遍历函数genFuncWithReturn返回的遍历器对象。这两次遍历的效果是叠加的,最终表现为拓展运算符遍历函数genFuncWithReturn凡蝴蝶遍历器对象。所以,对后的数据表达式得到的值等于['a', 'b']。但是,函数genFuncWithReturn的return语句的返回值The result,会返回给函数logReturned内部的result变量,因此会有终端输出。

六、Generator函数的应用

Generator 可以暂停函数执行,返回任意表达式的值。这种特点使得 Generator 有多种应用场景。

1、实现状态机

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

let ticking = true
let clock = function() {if (ticking)console.log('Tick!')elseconsole.log('Tock!')ticking = !ticking
}

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

var clock = function* () {while (true) {console.log('Tick!')yieldconsole.log('Tock!')yield}
}

上面的Generator实现与ES5实现对比,可以看到少了用来保存外部变量ticking,这样就更简洁,更安全(状态不会被非法篡改)、更符合函数式编程的思想,在写法上也更优雅。Generator 之所以可以不用外部变量保存状态,是因为它本身就包含了一个状态信息,即目前是否处于暂停态。

2、异步操作的同步化表达

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,它几乎与同步操作的写法完全一样。由于yield表达式本身没有值,总是等于undefined,所以makeAjaxCall函数中next方法必须加上response参数。

下面是另一个例子,通过 Generator 函数逐行读取文本文件。

function* numbers() {let file = new FileReader("numbers.txt")try {while(!file.eof) {yield parseInt(file.readLine(), 10)}} finally {file.close()}
}

上面代码打开文本文件,使用yield表达式可以手动逐行读取文件。

3、控制流管理

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

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 {var value2 = yield step1(value1)var value3 = yield step2(value2)var value4 = yield step3(value3)var value5 = yield step4(value4)// Do something with value4} catch (e) {// Handle any error from step1 through step4}
}

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

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

注意,上面这种做法,只适合同步操作,即所有的task都必须是同步的,不能有异步操作。因为这里的代码一得到返回值,就继续往下执行,没有判断异步操作何时完成。至于要如何控制异步的操作流程,请收看劣者的下一篇拙文。

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

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

上面的代码中,数组steps封装了一个任务的多个步骤,Generator 函数iterateSteps则是依次为这些步骤加上yield命令。

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

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

上面数组jobs封装了一个项目的多个任务,Generator 函数iterateJobs则是依次为这些任务加上yield*命令。

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

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

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

for...of的本质是一个while循环,所以上面的代码实质上执行的是下面的逻辑。

var it = iterateJobs(jobs)
var res = it.next()while (!res.done){var result = res.value// ...res = it.next()
}

4、部署 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

上面myObj是一个普通对象,通过iterEntries函数,就有了Iterator接口。也就是说,可以在任意对象上部署next方法。

下面是一个对数组部署 Iterator 接口的例子,尽管数组原生具有这个接口。

function* makeSimpleGenerator(array){var nextIndex = 0while(nextIndex < array.length){yield array[nextIndex++]}
}var gen = makeSimpleGenerator(['yo', 'ya'])gen.next().value // 'yo'
gen.next().value // 'ya'
gen.next().done  // true

5、作为数据结构

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')
}

上面代码就是依次返回三个函数,但是由于使用了 Generator 函数,导致可以像处理数组那样,处理这三个返回的函数。

for (task of doStuff()) {// task是一个函数,可以像回调函数那样使用它
}

实际上,如果用 ES5 表达,完全可以用数组模拟 Generator 的这种用法。

function doStuff() {return [fs.readFile.bind(null, 'hello.txt'),fs.readFile.bind(null, 'world.txt'),fs.readFile.bind(null, 'and-such.txt')];
}

上面的函数,可以用一模一样的for...of循环处理,两相一比较,就不难看出 Generator 使得数据或者操作,具备了类似数组的接口。

  相关解决方案