ES6-Generator and Asynchronous Application

Posted by devinemke on Sun, 12 Sep 2021 18:15:42 +0200

Introduction to Generator Functions

Basic concepts

Generator function is an asynchronous programming solution provided by ES6

Grammatical understanding

  • The Generator function is a state machine that encapsulates multiple internal states (defined by yield and return).
  • The Generator function is also a traverser-generated object, which returns a traverser object to traverse the state inside the function

Definition Formal Understanding

The Generator function is a general function that has several characteristics:

  • There is a * sign between the function keyword and the function name
  • Use the yield and return statements (end execution) internally within the function body to define different internal states
  • After the function is called, it does not execute, nor returns the result of the function, but a traverser object pointing to the beginning of the function, requiring the next function of the iterator object to change the internal state

Basic examples

function* gen() {
  yield 'hello';
  yield 'world';
  return 'ending';
}

var it = gen();
console.log(it.next()) // {value: 'hello', done: false}
console.log(it.next()) // {value: 'world', done: false}
console.log(it.next()) // {value: 'ending', done: true}
console.log(it.next()) // {value: 'undefined', done: true}

// Define Generator functions as object properties
var obj = {
    * gen() {
        yield 1;
    },
    foo: function* () {
        yield 2;
    }
    // The above two methods are equivalent
}

Association between Generator and iterator

Because the Generator function returns a traverser object with the Symbol.iterator property, the result of execution is equal to itself

function* gen() {
    yield 1;
    yield 2;
    yield 3;
}
var g = gen()
console.log(g[Symbol.iterator]() === g) // true

You can assign the Generator function to the Symbol.iterator property of an object so that it has an iterator interface

var obj = {
    name: 'xxx',
    age : 12
}
obj[Symbol.iterator] = function* gen() {
    yield 1;
    yield 2;
    yield 3;
}
for(let i of obj) {
    console.log(i) // 1, 2, 3
}

yield statement

The yield statement is a pause flag for the Generator function.

Notes on using yield statements

  • Can only be used in Generator functions, not in normal functions
function* gen() {
    yield 'hello'
}
gen() // success

function nGen() {
    yield 1 // SyntaxError: Unexpected number 
}
  • The yield statement, if used in an expression, must be enclosed in parentheses
function* gen() {
    console.log('Hello' + yield); // SyntaxError
    console.log('Hello' + yield 123); // SyntaxError

    console.log('Hello' + (yield)); // OK
    console.log('Hello' + (yield 123)); // OK
}
  • The yield statement can be used as a function parameter or as the right side of an assignment expression without parentheses
foo(yield 'a', yield 'b'); // success
let input = yield; // success

Comparison of yield and return statements

  • Both the yield and return statements return the value of the subsequent expression
  • The yield statement has location memory and continues backwards from where it was last paused; however, the return statement can only be executed once
  • Normal functions can only return one value with a return statement, but Generator functions can return a series of values with a yield statement
  • When looping the traverser object of a Generator for..of, the value of the expression following the yield statement is traversed, but the value of the expression following the return is not traversed

yield*statement

Generator function can not be nested call Generator function, need to achieve this function through yield* statement, yield* statement is not a state, does not affect the execution and pause of next function

yield* +Generator function without return

If the Generator function following yield* does not have a return statement, it is equivalent to for...of traversal

function* foo() {
    yield 'a';
    yield 'b';
}
function* gen() {
    yield 'x';
    yield * foo();
    yield 'y';
}
// Equivalent to
// function* gen() {
//     yield 'x';
//     for (let v of foo()) {
//         yield v;
//     }
//     yield 'y';
// }
vatr it = gen()
console.log(it.next()) // {value: 'x', done: false}
console.log(it.next()) // {value: 'a', done: false}
console.log(it.next()) // {value: 'b', done: false}
console.log(it.next()) // {value: 'y', done: true}

yield* +Generator function with return

If the return statement exists in the Generator function following yield*, you need to get the value of the return statement as var value = yield* iterator
The value of the return statement is acceptable or unacceptable, not required

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']

yield* + array/string/Set/Map/nodeList/arguments

Any data structure that has an Iterator interface can be traversed by yield*

// yield*array
function* gen() {
    yield 1;
    yield* [2, 3, 4];
    yield 5
}
for(let i of gen()) {
    console.log(i) // 1, 2, 3, 4, 5
}

// yield*string
function* gen() {
    yield 'Hello';
    yield* 'Today';
    yield 'world';
}
for(let i of gen()) {
    console.log(i) // 'Hello', 'T', 'o', 'd', 'a', 'y', 'world'
}

next method

Running logic of next method

  • When the next method runs, it encounters a yield statement and pauses the execution of the following until the next call;
  • The value of the expression after yield will be the value of the object returned by the next method
  • If the next method does not encounter a new yield statement, it will run until the end of the function.
  • If a return statement exists, the value of the expression following return is the value of the value property of the return object. If it does not exist, the value of the value is undefined

Parameters for the next method

The yield statement itself does not return a value (undefined). If you want it to return a value, you can pass in a parameter when you call the next method, which will be the return value of the previous yield statement

// Example 1
function* fn() {
    for(let i = 0; true; i++) {
        let res = yield i;
        if (res) {
            i = -1
        }
    }
}
var it = fn()
it.next() // Start traverser object {value: 0, done: false}
it.next() // The value of the second unpassed res execution is undefined {value: 1, done: false}
it.next() // The value of the third execution res is true i reset to -1 {value: 0, done: false}

// Example 2
function* gen(x) {
    var y = 2 * (yield (x + 1))
    var z = yield (y / 3)
    return (x + y + z)
}
var it = gen(5)
it.next() // {value: 6, done: false}
it.next() // No parameter y has a value of NaN {value: NaN, done: false}
it.next() // The return statement done is true and has no value of undefined {value: NaN, done: true} for the parameter z

// Example 3
function* gen(x) {
    var y = 2 * (yield (x + 1))
    var z = yield (y / 3)
    return (x + y + z)
}
var it = gen(5) // x = 5
it.next() // {value: 6, done: false}
it.next(12) // y = 24 {value: 8, done: false}
it.next(13) // z = 13 {value: 42, done: true}

The first time the next method is called, the parameter passed in cannot take effect because the first next method is used by default to enable the traverser object and does not need to carry parameters
You can wrap the Generator function one more layer if you need the parameters passed in the first time you call the next method to take effect

function genWrapper(fn) {
    return function (...args) {
        let it = fn(...args)
        it.next()
        return it
    }
}
var gen = genWrapper(function* (...args) {
    console.log(args)
    var one = yield 1;
    console.log(one)
    var two = yield 2;
    console.log(two)
    var three = yield 3;
    console.log(three)
})
gen('hello world!') // Internal default call to next function ['hello world!]
gen('hello world!').next(111) // The next function has been called internally and the parameter passed in is the return value of the last yield one

throw method

Traverser objects generated by the Generator function have throw methods defined on the Generator.prototype prototype chain.

Throw can be called externally, throw an exception, try...catch an exception inside the Generator function, which executes the next method by default.

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

var it = gen();
it.next() // a
it.throw() // b Call the next method once by default
it.next() // c

The try...catch inside the Generator function can only catch exceptions thrown by the first throw method, and the exceptions thrown by the remaining throw methods will be thrown outside the function and handled externally.

try {
    var g = function* () {
        try {
            yield;
        } catch (e) {
            console.log('Internal capture', e) // success internal capture first throw error
        }
    };
    var i = g();
    i.next();
    i.throw('First throw error')
    i.throw('Second throw error');
} catch(e) {
    console.log('External capture', e) // success External Capture Second Throw Error
}

If there is no exception capture inside the Generator function, it will be captured by the external catch code. If there is neither, the code block will fail and the operation will be interrupted.

function* gen(){
    yield 123;
    throw 'error'
    yield 456;
}
var it = gen()
console.log(it.next()) // success {value: 123, done: false}
console.log(it.next()) // error Uncaught error
  • If the exception thrown inside the Generator function is not caught, the default run ends, the next method is called again, and the return object is {value: undefined, done: true}
function* gen(){
    yield 123;
    throw 'error'
    yield 456;
}
var it = gen()
console.log(it.next()) // success {value: 123, done: false}
try {
    console.log(it.next())
} catch(e) {
    console.log(e) // success error; second execution throw exception caught by external catch
}
console.log(it.next()) // success { value: undefined, done: false }

return method

The traverser object generated by the Generator function has a return method defined on the Generator.prototype prototype chain.

  • The return function ends the state change of the Generator function, that is, the traversal.
  • The return method receives a parameter as the value of the return object and does not pass the default undefined
  • The return method is deferred until the final block of code in try..finally is executed
function* gen() {
    try {
        yield 1;
        yield 2;
        yield 3;
    } catch (e) {
        console.log(e)
        yield 4;
        yield 5;
    } finally {
        yield 6;
        yield 7;
        yield 8;
    }
}

var it = gen();
console.log(it.next()) // success {value: 1, done: false}
console.log(it.throw('throw')) // success {value: 4, done: false}
console.log(it.return(7)) // success {value: 6, done: false}
console.log(it.next()) // success {value: 7, done: false}
console.log(it.next()) // success {value: 8, done: false}
console.log(it.next()) // success {value: 7, done: true}

this in the Generator function

ES6 specifies that the traverser returned by the Generator function execution is its instance and inherits the method on the prototype object of the Generator function prototype chain

function* gen() {
    yield 1;
    yield 2;
}
gen.prototype.sayHello = function () {
    console.log('Hello')
}

var g = gen()

console.log(g instanceof gen) // true
g.sayHello() // 'Hello'

The Generator function cannot be used as a normal constructor because it always returns the traverser object, not the this object, and cannot be combined with the new command

// this points to
function* gen() {
    this.a = 'a'
}
var it = gen()
console.log(it.a) // undefined

// new command
function* Foo() {
    yield this.a = 1;
    yield this.b = 2;
}
new Foo() // TypeError: F is not a constructor

How to get this example

The call changes the direction of this to an externally defined empty object, in which case the traverser object and this object are not the same example

var obj = {}
function* Gen() {
    this.a = 1;
    yield this.b = 2;
    yield this.c = 3;
}
var it = Gen.call(obj)
console.log(obj) // {a : 1, b : 2, c : 3}
console.log(it.next()) // Object {value: 2, done: false}
console.log(it.next()) // Object {value: 3, done: false}
console.log(it.next()) // Object {value: undefined, done: false}

Using the prototype chain, this is pointed to the Generator function, and all defined variables and methods are mounted on the prototype chain, while traverser objects, which are instances of the Generator function, can also access the contents of the prototype chain

function* Gen() {
    this.a = 1;
    yield this.b = 2;
    yield this.c = 3;
}
var it = Gen.call(Gen.prototype)
console.log(it.next()) // Object {value: 2, done: false}
console.log(it.next()) // Object {value: 3, done: false}
console.log(it.next()) // Object {value: undefined, done: false}

console.log(it.a) // 1
console.log(it.b) // 2
console.log(it.c) // 3

Using the prototype chain, the Generator function can be encapsulated twice and transformed into a constructor

function* gen() {
  this.a = 1;
  yield this.b = 2;
  yield this.c = 3;
}

function F() {
  return gen.call(gen.prototype);
}

var f = new F();

console.log(f.next())  // Object {value: 2, done: false}
console.log(f.next())  // Object {value: 3, done: false}
console.log(f.next())  // Object {value: undefined, done: true}

console.log(f.a) // 1
console.log(f.b) // 2
console.log(f.c) // 3

Application of Generator Function

Synchronized representation of asynchronous operations

Because the parameter of the next method expresses the return value of the last yield statement, and because the internal state of the Generator function changes, it needs to be controlled by the next method to synchronize the asynchronous operation

// seTimeout simulates asynchronous operations
// Get user data first, then order data based on user data, then commodity data based on order data
function getUsers() {
    setTimeout(() => {
        var data = 'user data'
        it.next(data)
    }, 1000)
}

function getOrders() {
    setTimeout(() => {
        var data = 'Order data'
        it.next(data)
    }, 1000)
}

function getGoods() {
    setTimeout(() => {
        var data = 'Commodity data'
        it.next(data)
    }, 1000)
}

function* gen() {
    var users =  yield getUsers()
    console.log(users)
    var orders = yield getOrders()
    console.log(orders)
    var goods = yield getGoods()
    console.log(goods)
}

var it = gen()
it.next()

Control Flow Management

The Generator function can be used to linearize nested operations (not in the case of asynchronous operations)

function* longRunningTask(value1) {
  try {
    var value2 = yield step1(value1); // step1 calls next and takes the execution result as a parameter.
    var value3 = yield step2(value2); // Step 2 calls next and takes the execution result as a parameter.
    var value4 = yield step3(value3); // Step 3 calls next and takes the execution result as a parameter.
    var value5 = yield step4(value4); // Step 4 calls next and takes the execution result as a parameter.
    // Do something with value4
  } catch (e) {
    // Handle any error from step1 through step4
  }
}

var it = longRunningTask()
it.next() 

Deploy iterator interface

Because the Generator function returns a traverser object that can be traversed for...of, you can use this feature to deploy an iterator interface to any object

function* objEntries(obj) {
    let keys = Object.keys(obj)
    for(let i of keys) {
        yield [i, obj[i]]
    }
}

let myObj = { foo: 3, bar: 7 };
for (let [key, value] of objEntries(myObj)) {
    console.log(key, value);
    // foo 3
    // bar 7
}

Topics: Javascript Front-end ECMAScript data structure