Talking about the asynchronous application of ES6 Generator function and the realization principle of co module

Talking about the asynchronous application of ES6 Generator function and the realization principle of co module

1. The concept of Generator function

    Generator function is an asynchronous programming solution provided by ES6. The Promise object discussed earlier is also an asynchronous solution provided by ES6, so why propose a Generator?

    Using Promise objects to handle asynchrony has many advantages, especially the processing of callback hell can be turned into a chain call of then. But there are inevitably some shortcomings. For example, Promise-wrapped asynchronous will contain a lot of Promise nouns (resolve, reject, then...), which is not readable.

    In fact, the best way to handle asynchronous tasks should be to operate asynchronous tasks like synchronous tasks , that is, the code after the asynchronous task is written directly under the asynchronous, rather than in the callback function or the then method. The Generator function was proposed to solve this problem. How to synchronize asynchronous operations? Just imagine, if we can give the function the function of "pause" execution, that is, when an asynchronous task is encountered, the state of the current context is temporarily stored, and after the asynchronous task is over, the asynchronous result is obtained and then the execution continues , so that it can be realized The above requirements. This is the asynchronous processing idea of the Generator function.

    How can the "pause" execution of the function be realized? Here to introduce the concept of the Iterator interface (traverser)

2. The concept of Iterator

    Iterator is an interface that provides a unified access mechanism for different data structures. Any data structure can complete the traversal operation as long as the Iterator interface is deployed.

    Iterator can be considered as a pointer object. The data structure is traversed through the next method. Each time the next method is called, the pointer points to the next member of the array and returns the information of the current member of the data structure. The information is an object, containing two attributes: value and done. Among them, the value attribute is the value of the current member, and the done attribute is a boolean value, indicating whether the traversal is over.

    ES6 stipulates that the Iterator interface is deployed in the Symbol.iterator property of the data structure . Calling this interface will return an iterator object. Let's use an array as a chestnut demonstration

let arr = [1, 2, 3]; //return the iterator object let it = arr[Symbol.iterator](); //Traverse through the next method console.log(it.next())//{value: 1, done: false} console.log(it.next())//{value: 2, done: false} console.log(it.next())//{value: 3, done: false} console.log(it.next())//{value: undefined, done: true} Copy code

    Not all data structures have an Iterator interface natively. The data structure native to ES6 with the Iterator interface is as follows.

  • Array
  • Map
  • Set
  • String
  • TypedArray
  • The arguments object of the function
  • NodeList object

    The Iterator interface is used for for...of loops, that is to say, a data structure can be traversed by for...of as long as the Iterator interface is deployed. Otherwise, it cannot be traversed (such as object).

    However, we can manually deploy this interface for data structures that do not have a native Iterator interface. Specifically, it is to add the Symbol.iterator property, which is a function, call the function, and return the iterator object. In this way, when we traverse it with for...of, we will manually call the Iterator interface that we deployed. The following demonstrates the deployment of the Iterator interface to object.

const obj = { a:'a', b:'b', c:'c', [Symbol.iterator]: function () { let keys = Object.keys(this); let index = 0; return { next: function () { return index <keys.length ? { value: this[keys[index++]], done: false, } : { value: undefined, done: true, }; }.bind(this), }; }, }; for (const it of obj) { console.log(it) } //a //b //c Copy code

    After the above discussion, we know that for the traverser, only when the next method is executed will it continue to traverse downward . The Generator function takes advantage of this to realize the synchronization of asynchronous operations. Furthermore, executing the Generator function will return a iterator object. It can traverse multiple states encapsulated inside the Generator function . The following is a specific analysis.

3. The form and basic use of the Generator function

1. The form of the Generator function.

Generator function has two obvious characteristics that are different from ordinary functions.

  • There is an asterisk between the function keyword and the function name.
  • Inside the function body, the yield expression is used to divide different states.
function*gen(){ yield 1 yield 2 } //get the iterator object let g = gen() console.log(g.next())//{value: 1, done: false} console.log(g.next())//{value: 2, done: false} console.log(g.next())//{value: undefined, done: true} Copy code

2. Yield expression

    From the above example, we can see that the yield expression is used to divide the various states of the Generator function, which can be understood as a sign of the function's pause . When executed next () method, the expression yield encountered, execution will halt the operation of the back, and the yield value of the value attribute information of the object as the return value of the expression next method . The next () method is called next time, and the operation after the yield expression continues. This is very important, and we will use this to achieve asynchronous operation like synchronous operation.

function*gen(){ yield 1+2 yield 2+3 } //get the iterator object let g = gen() console.log(g.next())//{value: 3, done: false} console.log(g.next())//{value: 5, done: false} console.log(g.next())//{value: undefined, done: true} Copy code

Four. Asynchronous application of Generator function

    We have already understood the basic characteristics of the Generator function, back to the original question, how to achieve synchronization of asynchronous operations. Our requirement is to perform the following operations after the asynchronous operation is over, and the feature of the Generator function is that only after the next method is executed, the function changes from the current state to the next state. Therefore, we only need to use yield to divide each asynchronous operation into a state, so that we can ensure that the function suspends execution when an asynchronous operation is encountered. At the end of each asynchronous operation, the next method is called to make the function continue to execute, which realizes the use of synchronous operation logic to operate asynchronously.

    To achieve the above, two problems need to be solved.

1. Deliver asynchronous results

    We know that processing after asynchronous operations often requires asynchronous return results, so a primary problem is how to deliver the asynchronous return results.

    We have to make it clear that the yield expression has no return value (undefinded), that is to say, it is not feasible to use the following method directly.

function*gen(){ const res = yield async1() yield async2(res) } Copy code

    To deliver the result, we have to use the next method. If the next method has an input parameter, the parameter will be used as the return value of the previous yield expression.

function*gen(){ const res1 = yield 1 const res2 = yield res1+1 yield res2+2 } //get the iterator object let g = gen() console.log(g.next())//{value: 1, done: false} //The next method passes in 3 and thinks res1=3 3+1=4 console.log(g.next(3))//{value: 4, done: false} console.log(g.next(4))//{value: 6, done: false} Copy code

Therefore, we only need to pass the asynchronous return result into the next method.

2. Call the next method after the end of asynchrony

    Our daily processing of asynchronous is nothing more than callback function and Promsie two ways, so there are two ways to solve this problem.

2.1 Generator asynchronous process processing based on callback function

    We only need to call the next method in the asynchronous callback function to continue executing the Generator function after the asynchronous end.

const async1 = () => { setTimeout(() => { //Execute the next method to deliver asynchronous results g.next(1); }); }; const async2 = (res) => { setTimeout(() => { console.log(res + "from async1"); g.next(2); }); }; const async3 = (res) => { setTimeout(() => { console.log(res + "from async2"); }); }; function* gen() { const res1 = yield async1(); const res2 = yield async2(res1); yield async3(res2); } //get the iterator object let g = gen(); g.next(); //1 from async1 //2 from async2 Copy code

    The above code basically fulfills the requirements. We found that the asynchronous logic processing inside the Generator function is basically the same as the synchronous operation if the yield is removed.

    However, the problem with the above code is also obvious, we need to deal with each asynchronous callback. This is very inefficient, because we found that what is actually done in the callback is the same thing, which is to execute the next method and pass in the asynchronous return result . If we can extract this process and execute it automatically. Will greatly simplify the code logic. Solve these two problems in turn below.

  • Extract the processing of the callback function

    How to extract the processing of the callback function?

    Take the setTimeot function as an example. It accepts two parameters, the callback function and the delay time. And we hope to pass in these two parameters separately and handle them separately. Here you can think of the currying function discussed earlier . A currying function can transform a function that accepts multiple parameters into a function that accepts a single parameter and returns a function that accepts the remaining parameters .

    Take the setTimeot function as an example. After currying, we can pass in the delay time first, and then pass in the callback to the returned function, which fulfills the above requirements. Like below

function currying(time) { return (cb) => { return setTimeout(cb, time); }; } const curryTimeout = currying(500); curryTimeout(() => { console.log("timeOut"); }); Copy code

    The next question is where to handle asynchronous callbacks. We know that the value attribute of the return value of the next method is the result of the execution of the yield expression. If we execute the asynchronous currying process (such as currying(500) in the above example) after the yield, the value attribute of the return value of the next method will be a function, and asynchronous callbacks can be passed in. Therefore, we only need to pass the callback function to the value attribute of the next method . The above example is modified based on the above.

function currying(time) { return (cb) => { return setTimeout(cb, time); }; } function* gen() { const res1 = yield currying(500); const res2 = yield currying(res1); yield currying(res2); } const g = gen(); g.next().value(() => { console.log("async1"); g.next(500).value(() => { console.log("async2"); g.next(500).value(() => { console.log("async3"); }); }); }); //Print async1 async2 async3 in sequence every 0.5 seconds Copy code

    You can see that the code logic is much clearer. Here is another point. In fact, the so-called asynchronous currying process is the Thunk function . The so-called Thunk function is actually a temporary function. It can replace a multi-parameter function with a single-parameter function that only accepts a callback function as a parameter. Like the curryTimeout function in the above example, it is an intermediate function that only accepts a callback function as a parameter, that is, the Thunk function. In the words of Teacher Ruan Yifeng : Any function, as long as the parameter has a callback function, can be written in the form of a Thunk function. The above example is equivalent to a manual implementation of a Thunk function converter. The Thunkify module of nodejs is generally used in the production environment, which can realize the conversion of Thunk functions. The next thing to do is to change from manual execution to automatic execution.

  • Automatic execution

    Careful observation of the code that manually executes the Generator function will show that we actually only do one thing, that is, pass the same callback to the value attribute of the next method, and what the callback needs to do is to execute the next method and deliver the asynchronous result.     Based on the above, we can implement a program that the Generator function automatically executes according to the established logic. It only needs to judge the done attribute of the return value of the next method, and as long as it is not true, it will always pass the callback to the value attribute of the next method.

    Let's demonstrate with readFileAPI of node.js fs module, and use thunkify module to convert asynchronous API into Thunk function. Prepare two text files, the content of which are "Dong Jiu Dang Ge" and "Life Geometry".

const thunkify = require("thunkify"); const fs = require("fs"); const readFileThunk = thunkify(fs.readFile) function* gen() { yield readFileThunk("./text1.txt"); yield readFileThunk("./text2.txt"); } function run(fn) { const gen = fn(); function next(err, data) { //Error-first callback if (data) console.log(data.toString()); const res = gen.next(data); if (res.done) return; res.value(next); } next(); } run(gen); //Singing to the wine //The geometry of life Copy code

    As you can see, with the automatic executor, we just deal with the asynchronous inside the Generator function, and finally pass the Generator function directly to the run function. Of course, the premise is that the yield expression must be a Thunk function.

2.2 Generator asynchronous process processing based on Promise

    By observing the callback-based Generator function automatic executor implemented earlier, it is not difficult to see that the key to automatic execution is actually to call the next method after the end of asynchrony to let the Generator function continue to execute. The same can be done using the Promise.then method.

    Following the above example to transform it, what we have to do is actually very simple

  • Wrap the readFile function as a Promise
  • Automatic execution using Promise.then method
const fs = require("fs"); function promisify_readFile(path) { return new Promise((resolve, reject) => { fs.readFile(path, (err, data) => { if (err) reject(err); else resolve(data); }); }); } function* gen() { yield promisify_readFile("./text1.txt"); yield promisify_readFile("./text2.txt"); } function run(fn) { const gen = fn(); function next(data) { if (data) console.log(data.toString()); const res = gen.next(data); if (res.done) return; //res.value returns a Promise, you can continue to execute the Generator through the then method res.value.then(next,(r)=>console.log(r)) } next(); } run(gen); Copy code

    So far we have basically realized the requirements like the beginning of the article and realized automatic execution. In fact, this is the core realization principle of the famous co module.

Five. co module and its realization principle

    The co module is a well-known module for automatic execution of Generator functions. Its use is very simple, just pass the Generator function to co, and it can be executed automatically.

const co = require("co"); function* gen() { const res1 = yield promisify_readFile("./text1.txt"); console.log(res1.toString()) const res2 = yield promisify_readFile("./text2.txt"); console.log(res2.toString()) } co(gen) //Singing to the wine //The geometry of life Copy code
Realization principle

    In fact, after the above discussion on the automatic execution of the Generator function, we can know that the core implementation principle of the co module is the extension of the run function we implemented. details as follows

  • co returns a Promise object, so we need to add some logic to change the state of the Promise
  • Make sure that the return value of each step is Promise

The following implements a beggar version of the co module

function co(gen) { return new Promise(function (resolve, reject) { gen = gen(); if (!gen || typeof gen.next !== "function") return resolve(gen); function next(data) { const res = gen.next(data); if (res.done) { return resolve(res.value); } else { //Ensure that the return value of each step is Promise const value = Promise.resolve(res.value); value.then(next, (r) => reject(r)); } } next(); }); } //Since co returns a Promise, you can specify the then method to make //Perform some operations after the Generator is executed co(gen).then(()=>console.log('end')) //Singing to the wine //The geometry of life //end Copy code

    The co module is the predecessor of the async/await keyword. Async/await is known as the ultimate solution for asynchronous programming, which will be introduced later.

Reference: es6.ruanyifeng.com/#docs/gener...