musicode / test

test
14 stars 1 forks source link

[co 源码分析] co 与 co.wrap #48

Open musicode opened 9 years ago

musicode commented 9 years ago

本文比较长,我自认为把 co 的原理说的非常清楚了,耐心看完会有收获的。

Koa之所以可以实现同步代码写异步逻辑,核心就是co

co是个非常精炼的库,没有第三方依赖,200 多行代码完事。

暴露的方法只有两个:

co(gen)传入一个GeneratorFunctiongenerator,返回一个Promise

先看官方示例:

co(function* () {
    var result = yield Promise.resolve(true);
    return result;
}).then(function (value) {
    console.log(value);
}, function (err) {
    console.error(err.stack);
});

执行GeneratorFunction返回的是generator,也就是说,co内部会做下面这件事:

if (typeof gen === 'function') {
    gen = gen.apply(ctx, args);
}

如果传入的gen不是GeneratorFunctiongenerator,说明它不具备异步控制能力,因此返回的Promise会直接resolve(gen)

也就是说,可以像下面这样写:

co(1)
.then(function (value) {
    console.log(value === 1); // true
});

不过没啥意义,不用co的异步控制能力还用它干毛。

实际上,co使用了 ES6 Generator,来看一个例子:

function* generatorFunction() {
    console.log(1);
    var a = yield 'a';
    console.log(2);
    var b = yield 'b';
    console.log(3);
    var c = yield 'c';
    return c;
}

var gen = generatorFunction();
gen.next();    // 打印 1,返回 {value: "a", done: false}
gen.next();    // 打印 2,返回 {value: "b", done: false}
gen.next();    // 打印 3,返回 {value: "c", done: false}
gen.next('d'); // 没有打印,返回 {value: "d", done: true}
gen.next();    // 没有打印,返回 {value: undefined, done: true}

gen.next()的返回对象格式如下:

{
   done: {boolean},
   value: {*}
}

done 表示迭代器是否结束,value 表示yield后面语句的返回值。

需要注意的是结束时的情况,如下:

{
    done: true,
    value: 如果有返回值,value 是返回值,否则是 undefined
}

Generator的协程特性具体就不说了,相关文章很多。比如:

这里只说co用到的特性:

结合co再来看一个例子:

var fs = require('co-fs');
co(function* () {
    var content1 = yield fs.readFile(file1);
    console.log(content1);
    var content2 = yield fs.readFile(file2);
    return content2;
})
.then(function (value) {
    console.log(value);
});

这里会把 file1 和 file2 的内容依次打印出来。

第一次看到这种写法一定觉得非常神奇,js 居然可以这样写异步逻辑。

下面详细讲解原理。

一个generator的内部可能存在多个yield,即一个yield结束之后会执行下一个yield,这个过程应该是递归的。

我们知道,递归需要两个条件:

  1. 调用自己,使结果更接近于解
  2. 必须有结束条件

对于1来说,是不断的gen.next(),对于2来说,result.done 为 true 就算结束了。

我们先实现第一版:

function next(result) {

    if (result) {

        if (result.done) {
            return resolve(result.value);
        }

        result = gen.next(result.value);

    }
    else {
        result = gen.next();
    }

    result.value.then(function (value) {
        result.value = value;
        next(result);
    });

}

result.valueyield后面语句的同步返回结果,这个语句本身,可以是同步的(如yield 1),也可以是异步的(如yield asyncWork())。

虽然在co中,写成yield 同步实属蛋疼,但作为库还是要兼容的。

co推荐yield后面的语句返回Promise,当然你也可以用回调神马的,但是官方不保证会一直支持下去,因此我们就按一切皆 Promise来讲解。

Promise规范有个术语value,一切语法正确的值都可以是value,包括undefined。这个规定和gen.next()返回值中的 value 特别 match,两者的结合真是上天注定的缘份。

我们改写一段next()中的代码:

var promise = result.value;
if (!promise || typeof promise.next !== 'function') {
    promise = new Promise(function (resolve) {
        resolve(result.value);
    });
}

这样便保证 value 一定是Promise,接下来只需要静候Promise转换状态即可。

promise.then(onFulfilled, onRejected);

Promise规范规定,操作成功时,执行onFulfilled回调函数,它的第一个参数是value;操作失败时,执行onRejected回调函数,它的第一个参数是reason

递归函数next的优化版如下:

function next(result) {

    if (result) {

        if (result.done) {
            return resolve(result.value);
        }

        result = gen.next(result.value);

    }
    else {
        result = gen.next();
    }

    var promise = result.value;
    if (!promise || typeof promise.next !== 'function') {
        promise = new Promise(function (resolve) {
            resolve(result.value);
        });
    }

    promise.then(onFulfilled, onRejected);

}

最后一句为什么不写成下面这样呢?

promise.then(
    function (value) {
        result.value = value;
        next(result);
    },
    function (reason) {
        // 暂不实现
    }
);

我的猜测是递归调用会创建太多函数,影响内存(如果不是,请告诉我为啥不这么写...)。

如果把onFulfilledonRejected定义为外部函数,它们三个的调用关系需要探讨一下。

function next(result) {}
function onFulfilled(value) {}
function onRejected(reason) {}

next肯定不能作为入口函数,因为onFulfilled只有一个 value 参数,没有办法传入 done。

onRejected作为入口函数实在太搞笑了,好吧,onFulfilled几乎就是命中注定的。

基于yield后面跟一个Promise的共识,下面来看一个读取文件的例子:

co(function* () {
    var content = yield readFile(file);
});

本例中 promise 的逻辑是读取文件,假设 readFile 函数的实现如下:

var fs = require('fs');
function readFile() {
    var args = [].slice.call(arguments);
    return new Promise(function (resolve, reject) {
        args.push(function (err, data) {
            if (err) {
                reject(err);
            }
            else {
                resolve(data);
            }
        });
        fs.readFile.apply(fs, args);
    });
}

文件读取成功会执行onFulfilled函数,实现如下:

function onFulfilled(value) {
    var result = gen.next(value);
    next(result);
}

作为入口函数来说,不传参数也是正常的。

考虑到调用gen.next()可能会报错(这里指的是业务代码,谁能保证不报错...),因此最好 try 一下:

function onFulfilled(value) {

    var result;

    try {
        result =  gen.next(value);
    }
    catch (e) {
        return reject(e);
    }

    next(result);

}

读取成功的逻辑到这就算说清楚了。

下面再说文件读取失败的情况。

我们知道,异步的失败是 catch 不到的,比如:

try {
    var promise = new Promise(function (resolve) {
        setTimeout(
            function () {
                throw new Error('haha');
            }
        );
    });
}
catch (e) {
    console.log('[error]', e);
}

结果并不会打印[error]

如果要 catch 异步失败,try 和逻辑之间必须是同步执行的关系,如下:

function asyncWork() {

}

setTimeout(
    function () {
        try {
            asyncWork();
        }
        catch (e) {
            console.log('[error]', e);
        }
    }
);

co没有这么麻烦,因为 ES6 Generator 提供了一种全新的错误处理方式,如下:

function* generatorFunction(x) {

    var y;

    try {
        y = yield x + 1;
    } 
    catch (e) { 
        console.log(e);
    }

    return y;
}

var gen = generatorFunction(1);
gen.next();  // {value: 2, done: false}
gen.throw('error');

generator中的业务逻辑一定要考虑失败的情况,比如读取文件,读取失败还怎么赋值给 content 变量呢?

如果yield后跟一段同步逻辑,try...catch 可以正常捕获;如果yield后跟一段异步逻辑,try...catch 就失效了。

异步失败需要通知给generatorgen.throw()就有能力做到。

需要注意的是,如果generator内部没有对应的 try...catch 捕获错误,gen.throw()会导致程序报错,相当于throw new Error('xxx')

下面是onRejected函数的实现:

function onRejected(reason) {

    var result;

    try {
        result = gen.throw(reason);
    }
    catch (e) {
        return reject(e);
    }

    next(result);

}

co.wrap(gen)

传入一个generator,返回一个普通函数,这个函数会返回一个Promise

看一个官方示例:

var fn = co.wrap(function* (val) {
    return yield Promise.resolve(val);
});

fn(true).then(function (val) {

});

wrap其实非常简单,几乎就是下面这样的:

function wrap(fn) {
    return function () {
        return co.call(this, fn.apply(this, arguments));
    };
}

真相就是函数 curry 化,换种方式执行co

cncoder commented 7 years ago

co.call 还是不太明白