I didn’t think it was necessary to learn about Javascript generators as a transitional solution to asynchronous behavior, but it wasn’t until recently that I learned more about the tool library that I realized its power. You may not have written a generator by hand, but it is widely used, especially by excellent open source tools such as Redux-Saga and RxJS.
Iterables and iterators
It’s important to make it clear that generators actually come from a design pattern — the iterator pattern, which in Javascript is represented by an iterable protocol, and which is where iterators and iterables come from in ES2015, two confusing concepts. But in fact ES2015 makes a clear distinction.
define
Objects that implement the Next method are called iterators. The next method must return an IteratorResult object of the form:
{ value: undefined.done: true }
Copy the code
Value indicates the result of the iteration, and done indicates whether the iteration is complete.
An object that implements the @@iterator method is called an iterable, that is, it must have a property named [symbol. iterator], which is a function and whose return value must be an iterator.
String, Array, TypedArray, Map, and Set are built-in iterables in Javascript, such as, Array.prototype[symbol. iterator] and array.prototype. entries return the same iterator:
const a = [1.3.5];
a[Symbol.iterator]() === a.entries(); // true
const iter = a[Symbol.iterator](); // Array Iterator {}
iter.next() // { value: 1, done: false }
Copy the code
New array destructions in ES2015 also use iterators by default:
const arr = [1.3.5];
[...a]; / / [1, 3, 5]
const str = 'hello';
[...str]; // ['h', 'e', 'l', 'l', 'o']
Copy the code
Custom iteration behavior
Since an iterable is an object that implements the @@iterator method, an iterable can override the @@iterator method to implement custom iteration behavior:
const arr = [1.3.5.7];
arr[Symbol.iterator] = function () {
const ctx = this;
const { length } = ctx;
let index = 0;
return {
next: (a)= > {
if (index < length) {
return { value: ctx[index++] * 2.done: false };
} else {
return { done: true}; }}}; }; [...arr];// [2, 6, 10, 14]
Copy the code
As you can see above, the iteration ends when the next method returns {done: true}.
Generators are both iterables and iterators
There are two ways to return a generator:
- Creating a generator function using the constructor of a generator function, which returns a generator, is rarely used and is not covered in this article
- use
function*
The declared function is a generator function, and the generator function returns a generator
const counter = (function* () {
let c = 0;
while(true) yield++c; }) (); counter.next();// {value: 1, done: false}, counter is an iterator
counter[Symbol.iteratro]();
// counterGen {[[GeneratorStatus]]: "suspended"}, counter is an iterable
Copy the code
Counter in the above code is a generator that implements a simple counting function. There are no closures or global variables, and the implementation is elegant.
Basic syntax for generators
The power of generators is the ease with which you can control the logic inside a generator function. Inside a generator function, by yield or yield*, control of the current generator function is transferred to the external, which returns control to the generator function by calling the generator’s next or throw or return methods, and can also pass data to it.
Yield and yield* expressions
Yield and yield* can only be used in generator functions. The generator function returns ahead of time internally by yield, and the previous counters use this feature to pass the result of the count externally. If you want to count a finite number of values, you need to use a return expression in the generator function:
const ceiledCounter = (function* (ceil) {
let c = 0;
while(true) {
++c;
if (c === ceil) return c;
yield c;
}
})(3);
ceiledCounter.next(); // { value: 1, done: false }
ceiledCounter.next(); // { value: 2, done: false }
ceiledCounter.next(); // { value: 3, done: true }
ceiledCounter.next(); // { value: undefined, done: true }
Copy the code
Yield can be used without any expression, and the value returned is undefined:
const gen = (function* () {
yield; }) (); gen.next();// { value: undefined, done: false }
Copy the code
Generator functions delegate to another iterable, including generators, by using yield* expressions.
Delegate to a Javascript built-in iterable:
const genSomeArr = function* () {
yield 1;
yield* [2.3];
};
const someArr = genSomeArr();
greet.next(); // { value: 1, done: false }
greet.next(); // { value: 2, done: false }
greet.next(); // { value: 3, done: false }
greet.next(); // { value: undefined, done: true }
Copy the code
Delegate to another generator (again using the genGreet generator function above) :
const genAnotherArr = function* () {
yield* genSomeArr();
yield* [4.5];
};
const anotherArr = genAnotherArr();
greetWorld.next(); // { value: 1, done: false}
greetWorld.next(); // { value: 2, done: false}
greetWorld.next(); // { value: 3, done: false}
greetWorld.next(); // { value: 4, done: false}
greetWorld.next(); // { value: 5, done: false}
greetWorld.next(); // { value: undefined, done: true}
Copy the code
The yield expression returns a value. The behavior is explained next.
Next, throw, and return methods
It is through these three methods that the outside of the generator function controls the internal execution of the generator function.
next
A generator function can pass an argument to the next method, which is treated as the return value of the previous yield expression. If no arguments are passed, the yield expression returns undefined:
const canBeStoppedCounter = (function* () {
let c = 0;
let shouldBreak = false;
while (true) {
shouldBreak = yield ++c;
console.log(shouldBreak);
if (shouldBreak) return; }}; canBeStoppedCounter.next();// { value: 1, done: false }
canBeStoppedCounter.next();
// undefined, the return value of the first yield expression
// { value: 2, done: false }
canBeStoppedCounter.next(true);
// true, the return value of the yield expression executed the second time
// { value: undefined, done: true }
Copy the code
Let’s look at another example of passing consecutive values:
const greet = (function* () {
console.log(yield);
console.log(yield);
console.log(yield);
return; }) (); greet.next();// Execute the first yield expression
greet.next('How'); // The first yield expression returns "How" and outputs "How"
greet.next('are'); // The second yield expression returns "are" and outputs "are"
greet.next('you? '); // The third yield expression returns "you?" , output "you"
greet.next(); // { value: undefined, done: true }
Copy the code
throw
A generator function can pass an argument to the throw method, which will be caught by the catch statement. If no argument is passed, the catch statement will catch undefined, and the catch statement will resume the generator. Returns an IteratorResult:
const caughtInsideCounter = (function* () {
let c = 0;
while (true) {
try {
yield ++c;
} catch (e) {
console.log(e); }}}) (); caughtInsideCounter.next();// { value: 1, done: false}
caughtIndedeCounter.throw(new Error('An error occurred! '));
// Output An error occurred!
// {value: 2, done: false}
Copy the code
Note that if there is no catch inside the generator function, it will be caught outside, and if there is no catch outside, it will cause the program to terminate like all uncaught errors:
return
The generator’s return method terminates the generator and returns an IteratorResult, where done is true and value is the argument passed to the return method. If no argument is passed, value will be undefined:
const g = (function* () {
yield 1;
yield 2;
yield 3; }) (); g.next();// { value: 1, done: false }
g.return("foo"); // { value: "foo", done: true }
g.next(); // { value: undefined, done: true }
Copy the code
Through the above three methods, the outside of the generator function has a very strong control over the internal program execution flow of the generator function.
Asynchronous use of generators
The combination of generator functions with asynchronous operations is a very natural expression:
const fetchUrl = (function* (url) {
const result = yield fetch(url);
console.log(result); }) ('https://api.github.com/users/github');
const fetchPromise = fetchUrl.next().value;
fetchPromise
.then(response= > response.json())
.then(jsonData= > fetchUrl.next(jsonData));
// {login: "github", id: 9919, avatar_url: "https://avatars1.githubusercontent.com/u/9919?v=4", gravatar_id: "", url: "Https://api.github.com/users/github",... }
Copy the code
In the code above, the fetch method returns a Promise object, fetchPromise, which, after a series of parses, returns a JSON-formatted object, jsonData, Pass it to Result in the generator function via fetchUrl’s next method, and print it out.
Limitations of generators
As you can see from the above procedure, the generator and Promise can do asynchronous operations very succinctly, but it’s not enough because we wrote the entire asynchronous process manually. As asynchronous behavior becomes more complex (such as a queue for asynchronous operations), the generator’s asynchronous flow management procedures become harder to write and maintain.
For generators to really come in handy, you need a tool that can automate asynchronous tasks. There are two common approaches to implementing such a tool:
- The thunkify module is based on this idea by continuously executing the callback function until the whole process is completed.
- Using Javascript native support
Promise
Object, which flattens asynchronous processes based on this ideacoModule;
Let’s understand and implement them separately.
thunkify
The thunkify module is a common solution for asynchronous operations. The source code for Thunkify is very concise, with only about 30 lines of comments, and is recommended for all developers learning asynchronous programming.
Once you understand thunkify’s ideas, you can reduce them to a simplified version (for comprehension only, not production) :
const thunkify = fn= > {
return (. args) = > {
return callback= > {
return Reflect.apply(fn, this, [...args, callback]);
};
};
};
Copy the code
As can be seen from the above code, thunkify is suitable for asynchronous functions whose callback is the last parameter. Let’s construct an asynchronous function that conforms to this style for debugging purposes:
const asyncFoo = (id, callback) = > {
console.log(`Waiting for ${id}. `)
return setTimeout(callback, 2000.`Hi, ${id}`)};Copy the code
First, the basics:
const foo = thunkify(asyncFoo);
foo('Juston') (greetings= > console.log(greetings));
// Waiting for Juston...
// ... 2s later ...
// Hi, Juston
Copy the code
Next, we simulate the actual requirements to output the results every 2s. The first is to construct the generator function:
const genFunc = function* (callback) {
callback(yield foo('Carolanne'));
callback(yield foo('Madonna'));
callback(yield foo('Michale'));
};
Copy the code
Next we implement a helper function, runGenFunc, that automatically executes the generator:
const runGenFunc = (genFunc, callback, ... args) = > {
constg = genFunc(callback, ... args);const seqRun = (data) = > {
const result = g.next(data);
if (result.done) return;
result.value(data= > seqRun(data));
}
seqRun();
};
Copy the code
Note that g.ext ().value is a function and takes a callback function as an argument, runGenFunc implements two key steps in line 7:
- A will
yield
The result of an expression returns a generator function - Execution of the current
yield
expression
Finally, we call runGenFunc and pass in genFunc, the required callback, and other generator function arguments (the generator function here has only one callback as an argument) :
runGenFunc(genFunc, greetings => console.log(greetings));
// Waiting for Carolanne...
// ... 2s later ...
// Hi, Carolanne
// Waiting for Madonna...
// ... 2s later ...
// Hi, Madonna
// Waiting for Michale...
// ... 2s later ...
// Hi, Michale
Copy the code
You can see that the output is exactly as expected, every 2s.
Using the Thunkify module for asynchronous process management is still not convenient because we had to introduce a helper runGenFunc function to automate the asynchronous process execution.
co
Co module can help us to complete the automatic execution of asynchronous processes. The CO module is based on the Promise object. Co module source code is also very concise, but also more suitable for reading.
There are only two APIS for the CO module:
-
co(fn*).then(val => )
The CO method takes a generator function as a unique argument and returns a Promise object, basically using the following method:
const promise = co(function* () { return yield Promise.resolve('Hello, co! '); }) promise .then(val= > console.log(val)) // Hello, co! .catch((err) = > console.error(err.stack)); Copy the code
-
fn = co.wrap(fn*)
The co.wrap method takes the CO method a step further and returns a function similar to createPromise, which differs from the CO method in that it can pass arguments to an internal generator function, basically as follows.
const createPromise = co.wrap(function* (val) { return yield Promise.resolve(val); }); createPromise('Hello, jkest! ') .then(val= > console.log(val)) // Hello, jkest! .catch((err) = > console.error(err.stack)); Copy the code
The CO module requires us to transform the object after the yield keyword into a CO module custom yieldable object, which is usually thought of as a Promise object or a data structure based on a Promise object.
After understanding the use of CO module, it is not difficult to write the automatic execution process based on CO module.
You simply modify the asyncFoo function to return a yieldable object, in this case a Promise object:
const asyncFoo = (id) = > {
return new Promise((resolve, reject) = > {
console.log(`Waiting for ${id}. `);
if(! setTimeout(resolve,2000.`Hi, ${id}`)) {
reject(new Error(id)); }}); };Copy the code
The call can then be made using the CO module. Since we need to pass a callback argument to the genFunc function, we must use the co.wrap method:
co.wrap(genFunc)(greetings= > console.log(greetings));
Copy the code
The results were in line with expectations.
In fact, the internal implementation of the CO module is similar to that of the runGenFunc function in thunkify. Both of them use recursive functions to repeatedly execute yield statements until the iteration of the generator function ends. The main difference is that the CO module is implemented based on promises.
It is possible to use external modules for most of the actual work, but to understand the implementation or not to reference external modules, it is important to understand the use of generators in depth. In my next article, Observer Pattern in Javascript, I’ll explore the implementation of RxJS, which also covers the iterator pattern mentioned in this article. Finally, relevant references are attached for interested readers to continue their study.
The resources
- Iterator pattern – Wikipedia
- Iteration protocols – MDN
- Asynchronous application of Generator functions
- Going Async With ES6 Generators