愿你坚持不懈,努力进步,进阶成自己理想的人

—— 2017.09, 写给3年后的自己

《ECMAScript6入门》学习笔记之Generator的异步应用

一、基本概念

1、异步

如果一个任务不是连续完成的,那么该任务可以被人为划分为两段,先执行第一段,然后转而执行其他任务,等做好了准备再回过头来准备第二段。而相应的,如果一个任务需要连续执行,中途不能被其他任务插入,那么这种任务就是同步的。

2、回调函数

回调函数就是将函数的第二段写在一个函数里,等到重新执行这个任务的时候,就直接调用该函数,即callback(重新调用,回调),一个例子如下:

fs.readFile('/path/to/file', 'utf-8', function(err, data) {
    // ...
});

Node约定,回调函数的第一个参数,必须是错误对象err(如果没有错误,则该参数是null),这是因为执行分为两段,当第一段执行完成后,任务所在的上下文就已经结束了,在这之后若抛出错误,原来的上下文便无法捕获到这个错误,所以就只能作为参数,传给第二段

3、协程

协程的意思是多个线程互相协作,完成异步任务,协程的运行流程大致如:

  • 第一步,协程A开始执行
  • 第二步,协程A执行到一半,暂停,执行权转移到协程B
  • 第三步,(一段时间后)协程B交还执行权
  • 第四步,协程A恢复执行

而generator是ES6中对协程的实现,读取文件的协程写法如:

function* asyncJob() {
    // ...
    var f = yield readFile(fileName);
    // ...
}

当程序执行到yield语句的时候,就会暂停,然后等到执行权返回(调用next()方法)后,再从暂停的地方继续往后执行。协程的最大优点就是代码的 写法非常像同步操作


二、使用Generator来进行异步操作

1、暂停执行

Generator函数可以说是异步任务的容器,异步操作需要暂停的地方,都用yield语句注明,如:

function* gen(x) {
    var y = yield x + 2;
    return y;
}
const g = gen(x);
g.next(); { value: 3, done: false }
g.next(); { value: undefined, done: true }

next方法的作用是分阶段执行Generator函数,每次调用next()方法,都会返回一个对象,表示当前阶段的信息(valuedone两个属性),value包含了yield语句后面表达式的值(即会对yield后接的表达式进行一次求值操作,在异步里则可以用于发出异步请求);done则表示generator函数是否执行完毕

2、数据交换与错误处理

generator的数据交换与错误处理机制,则使得generator能够更好地处理异步任务,作为异步编程的完整解决方案。由于generator可以通过next()方法的参数由外向内地输入数据给generator函数内部,所以这就使得当异步操作完成时,数据传给generator函数使用成为了可能,如:

function* gen(x) {
    var y = yield x + 2;
    return y;
}
const g = gen(1);
g.next(); // { value: 3, done: false }
g.next(2); // { value: 2, done: true }

此外,generator函数内部还能够处理函数体外抛出的错误,如:

function* gen(x) {
    try {
        var y = yield x + 2;
    } catch(e) {
        console.log(e);
    }
}
var g = gen(1);
g.next();
g.throw(new Error('出错了!'));

这就使得,异步操作里抛出的错误,最后能够返回到generator函数内部被处理,从而在generator内部就能够像同步写法那般处理错误

3、如何进行异步操作

以下来看如何使用generator来处理异步操作,如:

const fetch = require('node-fetch');
function* gen() {
    const url = 'http://api.example.com/getUser/1';
    const result = yield fetch(url);
    console.log(result);
}

const g = gen();
const p = g.next();
p.value.then(function(data) {
    return data.json();
}).then(function(data) {
    g.next(data);
});

上面代码,可以分为两部分,第一部分主要是generator函数,我们可以把其内部的代码看做同步代码,而第二部分主要是执行部分,之所以能够使得generator函数内部像同步写法一样写异步操作,分析如下:

  • yield fetch(url)时,yield后的表达式得以求值执行,所以当g.next()调用的时候,发出请求,并返回fetch(url)的返回值作为g.next()返回对象里的value部分
  • 拿到这个value后,我们对其进行操作,当这个Promise的状态resolved时,就调用g.next()交还程序执行权给generator函数,同时将异步操作得到的数据data作为参数
  • generator函数内部能够拿到data,从而成为result的值


三、Thunk函数

1、参数的求值策略

参数的求值分为传值调用传名调用,即对于以下的程序:

var x = 1;
function f(m) {
    return m*2;
}
f(x+5);

当执行f(x+5)时,参数是先计算再传给函数呢,还是原封不动传给函数呢?这就有两种策略,前者称为传值调用,后者称为传名调用,即:

// 传值调用
f(6);
funtion f(6) {
    return 6*2;
}
// 传名调用
f(x+5);
function f(x+5) {
    return (x+5)*2;
}

两种方式各有利弊,如传值调用,可能会有参数实际上没用到,却进行了计算所造成的性能损失,如:

function f(a, b) {
    return b;
}
f(/* 这是一个很复杂的计算 */, 1);

2、Thunk函数的含义

编译器的传名调用实现,往往是将参数放到一个临时的函数中,再将这个临时函数传入函数体,然后在函数体内执行,而这个临时函数就称为Thunk函数,如前面的例子可以写成:

function thunk() {
    return x + 5;
}
function f(thunk) {
    return thunk()*2;
}

3、JavaScript中的thunk函数

JavaScript是传值调用,它的thunk函数含义有所不同,在js里,thunk函数替换的不是表达式,而是多参函数,将其器换成一个只接受回调函数作为参数的单参数函数,如:

// 正常版本(多参数)
fs.readFile(fileName, callback);
// Thunk版本(单参数)
function Thunk(fileName) {
    return function(callback) {
        return fs.readFile(fileName, callback);
    }
}
const readFileThunk = Thunk(fileName);
readFileThunk(callback);

任何函数,只要参数有回调函数,就能够写成Thunk函数的形式,以下是Thunk函数的转化器:

function Thunk(fn) {
    return function(...args) {
        return function(callback) {
            return fn.call(this, ...args, callback);
        }
    }
}

所以,fs.readFile可以被转化成Thunk函数如下:

const readFileThunk = Thunk(fs.readFile);
readFileThunk(fileName)(callback);

例子如:

function f(x, cb) {
    cb(x);
}

const fThunked = Thunk(f);
fThunked(1)(console.log); // 输出:1

生产环境里,可以使用thunkify模块,如:

const thunkify = require('thunkify');
const fs = require('fs');
const read = thunkify(fs.readFile);
read('somefile.json')(function(err, data) {
    // ...
});

thunkify的源码如下(thunkify限制回调函数只能执行一次):

function thunkify(fn) {
    return function() {
        var args = new Array(arguments.length);
        var ctx = this;
        for (var i=0; i<args.length; ++i) {
            args[i] = arguments[i];
        }
        return function(done) {
            var called;
            args.push(function() {
                if (called) return;
                called = true;
                done.apply(null, arguments); 
            });
            try {
                fn.apply(ctx, args);
            } catch(err) {
                done(err);
            }
        } 
    }
}


四、Generator函数的流程管理

Thunk函数可用于generator函数的自动流程管理。
对于同步操作,generator函数可以自动执行,如:

function* gen() {
    // ...
}

const g = gen();
let res = g.next();
while (!res.done) {
    console.log(res.value);
    res = g.next();
}

但是这不适合异步操作,如果必须保证上一步执行完才能执行下一步,那么:

const fs = require('fs');
const thunkify = require('thunkify');
const readFileThunk = thunkify(fs.readFile);
function* gen() {
    const r1 = yield readFileThunk('fileName1');
    console.log(r1);
    const r2 = yield readFileThunk('fileName2');
    console.log(r2);
}

其中,yield命令用于将程序的执行权移出generator函数,那么就需要一种方法能够将执行权再交换给generator函数的,为了便于理解,需要先知道如何手动执行上面那个generator函数:

const g = gen();
let r1 = g.next();
r1.value(function(err, data) {
    if (err) throw err;
    let r2 = g.next(data);
    r2.value(function(err, data) {
        if (err) throw err;
        g.next(data);
    });
});

可以发现:generator函数的执行过程,是将同一个回调函数,反复传入next()方法返回的value属性。所以,这一过程,我们就可以用递归和Thunk函数来自动完成:

function run(fn) {
    const gen = fn();
    next();
    function next(err, data) {
        const result = gen.next(data);
        if (result.done) return;
        result.value(next);
    }
}
run(function* () {
    // ...
});

如此一来,我们就可以这么写了:

run(function* () {
    const f1 = yield readFileThunk('file1');
    const f2 = yield readFileThunk('file2');
    const f3 = yield readFileThunk('file3');
    // ...
    const fn = yield readFileThunk('fileN');
});


五、co模块

co模块是TJ Holowaychuk开发的一个小工具,用于Generator函数的自动执行。使用co模块,我们就可以无需编写Generator函数的执行器,如:

const co = require('co');
co(function* () {
    const f1 = yield readFileThunk('file1');
    const f2 = yield readFileThunk('file2');
});

co函数返回一个Promise对象,所以可以用then方法添加回调函数,如:

co(gen).then(function() {
    console.log('executed over');
});

1、模块的原理

co模块的原理是将两种自动执行器(Thunk函数和Promise对象)包装成一个模块,使用co的前提是:Generator函数的yield命令后面,只能是Thunk函数或者Promise对象。如果数组或对象的成员,全部都是Promise对象,则也可以使用co
注意: co v4.0以后,yield命令后面只能是Promise对象,不再支持Thunk函数、

2、基于Promise对象的自动执行

沿用以上例子,我们首先将fs模块的readFile方法包装成一个Promise对象,如下:

const fs = require('fs');
function readFile(fileName) {
    return new Promise(function(resolve, reject) {
        fs.readFile(fileName, function(error, data) {
            if (error) return reject(error);
            resolve(data);
        });
    });
}

function* gen() {
    const f1 = yield readFile('file1');
    const f2 = yield readFile('file2');
    console.log(f1);
    console.log(f2);
}

为了便于理解,手动执行以上的Generator函数如:

const g = gen();
g.next().value.then(function(data) {
    g.next(data).value.then(function(data) {
        g.next(data);
    });
});

所以可以写出自动执行器如下:

function run(gen) {
    const g = gen();
    next();
    function next(data) {
        const result = g.next(data);
        if (result.done) return result.value;
        result.value.then(function(data) {
            next(data);
        });
    }
}

3、co模块源码分析

首先,co模块接收Generator函数作为参数,返回一个Promise对象,如下:

// 代码片段1
function co(gen) {
    const ctx = this;
    return new Promise(function(resolve, reject)) {
        // 代码片段2
    });
}

代码片段2中,co首先检查参数gen是否为Generator函数,如果是就执行该函数,如果不是就返回,并将Promise对象的状态改为resolved,如下:

function co(gen) {
    const ctx = this;
    return new Promise(function(resolve, reject) {
        if (typeof gen === 'function') gen = gen.call(ctx);
        if (!gen || typeof gen.next !== 'function') return resolve(gen);
        // 代码片段3
    });
}

在代码片段3中,co将Generator函数指针对象的next方法,包装成onFulfilled函数,为了能够捕获抛出的错误:

function co(gen) {
    const ctx = this;
    return new Promise(function(resolve, reject) {
        if (typeof gen === 'function') gen = gen.call(ctx);
        if (!gen || typeof gen.next !== 'function') return resolve(gen);
        
        onFullfilled();
        function onFullfilled(res) {
            let ret;
            try {
                ret = gen.next(res);
            } catch(e) {
                return reject(e);
            }
            next(ret);
        }
    });
} 

next()函数是关键的函数,它会反复调用自身来实现自动执行:

function next(ret) {
    if (ret.done) return resolve(ret.value);
    let value = toPromise.call(ctx, ret.value);
    if (value && isPromise(value)) {
        return value.then(onFullfilled, onRejected);
    }
    return onRejected(
        new TypeError(
            'You may only yield a function, promise, generator, array or object, '
            + 'but the following object was passed: "'
            + String(ret.value)
            + '"'
        )
    );
}

4、co处理并发的异步操作

co可以允许某些操作同时进行,等到它们全部完成后,才进行下一步。这需要:将并发的操作都放在数组或对象里面,跟在yield语句后面,如:

co(function* () {
    const res = yield [
        Promise.resolve(1),
        Promise.resolve(2)
    ];
    console.log(res);
});