JavaScript异步编程解决方案(Promise + Generaor + Co => Async/Await)

JavaScript与异步编程

为了避免资源管理等复杂性的问题, javascript被设计为单线程的语言,即使有了html5 worker,也不能直接访问dom。javascript 设计之初是为浏览器设计的GUI编程语言,GUI编程的特性之一是保证UI线程一定不能阻塞,否则体验不佳,甚至界面卡死。一般安卓开发,会有一个界面线程,一个后台线程,保证界面的流畅。由于javascript是单线程,所以采用异步非阻塞的编程模式,javascript的绝大多数api都是异步api。关于JavaScript异步编程的详细内容可以参考我之前的一篇文章:什么是JavaScript异步编程?

总之,异步编程对 JavaScript 语言太重要。JavaScript 只有一根线程,如果没有异步编程,根本没法用,非卡死不可。而JavaScript 语言对异步编程的最早实现,就是回调函数。

回调函数

所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。它的英语名字 callback,直译过来就是"重新调用"。读取文件进行处理,是这样写的。

fs.readFile('/etc/passwd', function (err, data) {
    if (err) throw err;
    console.log(data);
});

上面代码中,readFile 函数的第二个参数,就是回调函数,也就是任务的第二段。等到操作系统返回了 /etc/passwd 这个文件以后,回调函数才会执行。

一个有趣的问题是,为什么 Node.js 约定,回调函数的第一个参数,必须是错误对象err(如果没有错误,该参数就是 null)?

原因是执行分成两段,在这两段之间抛出的错误,程序无法捕捉,只能当作参数,传入第二段。

回调函数本身并没有问题,它的问题出现在多个回调函数嵌套。假定读取A文件之后,再读取B文件,再读取C文件,代码如下。

fs.readFile(fileA, function (err, data) {
    if (err) throw err;
    console.log(data)
    fs.readFile(fileB, function (err, data) {
        if (err) throw err;
        console.log(data)
        fs.readFile(fileC, function (err, data) () {
            if (err) throw err;
            console.log(data)
        })
    });
});

不难想象,如果依次读取多个文件,就会出现多重嵌套。代码不是纵向发展,而是横向发展,很快就会乱成一团,无法管理。这种情况就称为“回调地狱”(callback hell)。

于是,Promise出现了。

Promise

要澄清一点,Promise的概念并不是ES6新出的,而是ES6整合了一套新的写法。它不是新的语法功能,而是一种新的写法,允许将回调函数的横向加载,改成纵向加载。同样继续上面的例子,使用Promise代码就变成这样了:

var readFile = require('fs-readfile-promise');

readFile(fileA)
    .then(function(data){
        console.log(data.toString());    
    })
    .then(function(){
        return readFile(fileB);
    })
    .then(function(data){
        console.log(data.toString());
    })
    .then(function(){
        return readFile(fileC);
    })
    .then(function(data){
        console.log(data.toString());
    })
    .catch(function(err) {
        console.log(err);
    });

注意:上面代码使用了Node封装好的Promise版本的readFile函数,它的原理其实就是返回一个Promise对象,咱也简单地写一个:

var fs = require('fs');

var readFile = function(path) {
    return new Promise((resolve, reject) => {
        fs.readFile(path, (err, data) => {
            if (err) reject(err)
            resolve(data)
        })
    })
}

module.export = readFile

而像这样的,将一个普通方法经过一系列骚操作最终变为一个Promise方法的行为,就是 Promise化

上面代码中,我使用了 fs-readfile-promise 模块,它的作用就是返回一个 Promise 版本的 readFile 函数。Promise 提供 then 方法加载回调函数,catch方法捕捉执行过程中抛出的错误。

但是,Promise的写法只是回调函数的改进,使用then()之后,异步任务的两段执行看得更清楚,除此之外并无新意。撇开优点,Promise的最大问题就是代码冗余,原来的任务被Promise包装一下,不管什么操作,一眼看上去都是一堆then(),原本的语意变得很不清楚。

Generator

协程

在引入generator之前,先介绍一下什么叫 协程。

所谓 "协程" ,就是多个线程相互协作,完成异步任务。协程有点像函数,又有点像线程。其运行流程大致如下:

  • 第一步,协程A开始执行。
  • 第二步,协程A执行到一半,进入暂停,执行权转移到协程B。
  • 第三步,(一段时间后)协程B交还执行权。
  • 第四步,协程A恢复执行。
function asyncJob() {
    // ... 其他代码
    var f = yield readFile(fileA);
    // ... 其他代码 
}

上面的asyncJob()就是一个协程,它的奥妙就在于其中的yield命令。它表示执行到此处执行权交给其他协程,换而言之,yield就是异步两个阶段的分界线。协程遇到yield命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点就是代码的写法非常像同步操作,如果除去 yield命令,简直一模一样。

Generator概念

Generator函数是协程在ES6中的实现,最大的特点就是可以交出函数的执行权(即暂停执行)。整个Generator函数就是一个封装的异步任务,或者说就是异步任务的容器。

function* gen(x) {
    var y = yield x + 2;
    return y;
} 

var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

上面的代码中,调用Generator函数,会返回一个内部指针(即遍历器)g,这是Generator函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针g的next()方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的yield语句。

换而言之,next()方法的作用是分阶段执行Generator函数。每次调用next()方法,会返回一个对象,表示当前阶段的信息(value属性和done属性)。value属性是yield语句后面表达式的值,表示当前阶段的值;done属性是一个布尔值,表示Generator函数是否执行完毕,即是否还有一个阶段。

Generator函数的数据交换和错误处理

Generator函数可以暂停执行和恢复执行,这是它封装异步任务的根本原因。除此之外,它还有两个特性,使它可以作为异步编程的解决方案:函数体内外的数据交换和错误处理机制。

next()方法返回值的value属性,是Generator函数向外输出的数据;next()方法还可以接受参数,向Generator函数体内输入数据。

function* gen(x) {
    var y = yield x + 2;
    return y;
} 

var g = gen(1);
g.next()      // { value: 3, done: false }
g.next(2)     // { value: 2, done: true }

上面的代码中,第一个next()方法的value属性,返回表达式x+2的值(3)。第二个next()方法带有参数2,这个参数可以传入Generator函数,作为上个阶段异步任务的返回结果,被函数体内的变量y接收,因此这一步的value属性返回的就是2(变量y的值)。

Generator函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。

function* gen(x) {
    try {
        var y = yield x + 2
    } catch(e) {
        console.log(e)
    }
    return y
}

var g = gen(1);
g.next();
g.throw('出错了');

上面代码的最后一行,Generator函数体外,使用指针对象的throw方法抛出的错误,可以被函数体内的try...catch 代码块捕获。这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的。

Generator函数的用法

下面看看如何使用 Generator 函数,执行一个真实的异步任务。

var fetch = require('node-fetch');

function* gen(){
    var url = 'https://api.github.com/users/github';
    var result = yield fetch(url);
    console.log(result.bio);
}

上面代码中,Generator 函数封装了一个异步操作,该操作先读取一个远程接口,然后从 JSON 格式的数据解析信息。就像前面说过的,这段代码非常像同步操作,除了加上了 yield 命令。

执行这段代码的方法如下。

var g = gen();
var result = g.next();

result.value
    .then(function(data){
        return data.json();
    }).then(function(data){
        g.next(data);
    });

上面代码中,首先执行 Generator 函数,获取遍历器对象,然后使用 next 方法(第二行),执行异步任务的第一阶段。由于 Fetch 模块返回的是一个 Promise 对象,因此要用 then 方法调用下一个next 方法。

可以看到,虽然 Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。于是,就有了Co函数。

Co函数

co函数库是著名程序员 TJ Holowaychuk 于2013年6月发布的一个小工具,用于 Generator 函数的自动执行。

比如,有一个 Generator 函数,用于依次读取三个文件。

var gen = function* (){
    var f1 = yield readFile('/etc/fstab');
    var f2 = yield readFile('/etc/shells');
    var f3 = yield readFile('/etc/services');
    console.log(f1.toString());
    console.log(f2.toString());
    console.log(f3.toString());
}

co 函数库可以让你不用编写 Generator 函数的执行器。

var co = require('co');
co(gen).then(function (){
    console.log('Generator 函数执行完成');
})

上面代码中,Generator 函数只要传入 co 函数,就会自动执行。co 函数返回一个 Promise 对象,因此可以用 then 方法添加回调函数。上面代码中,等到 Generator 函数执行结束,就会输出一行提示。

 co 函数库的原理

为什么 co 可以自动执行 Generator 函数?前面文章说过,Generator 函数就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。两种方法可以做到这一点。

  • 回调函数。将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。
  • Promise 对象。将异步操作包装成 Promise 对象,用 then 方法交回执行权。

co 函数库其实就是将两种自动执行器(Thunk 函数和 Promise 对象),包装成一个库。使用 co 的前提条件是,Generator 函数的 yield 命令后面,只能是 Thunk 函数或 Promise 对象。

至于什么是Thunk函数,这里就不展开描述了,因为在js中都是讲异步操作包装成Promise对象的,所以接下来着重描述co函数将异步操作包装成Promise对象的情况。当然如果又兴趣可以参考:Thunk 函数的含义和用法

co用promise的特性,将整个Generator函数包装在一个promise下,利用Generator的next链,循环调用co方法将不同的next任务分别包装为不同的子promise。根据next的状态来执行不同的resolve,进而实现自动执行。基本流程如下图

还是沿用上面的例子。首先,把 fs 模块的 readFile 方法 Promise化 一下。

var fs = require('fs');

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

var gen = function* (){
    var f1 = yield readFile('/etc/fstab');
    var f2 = yield readFile('/etc/shells');
    var f3 = yield readFile('/etc/services');
    console.log(f1.toString());
    console.log(f2.toString());
    console.log(f3.toString());
};

然后,手动执行上面的 Generator 函数。

var g = gen();

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

手动执行其实就是用 then 方法,层层添加回调函数。理解了这一点,就可以写出一个自动执行器。

function Co(gen){
    var g = gen();

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

    next();
}

Co(gen);

上面代码中,只要 Generator 函数还没执行到最后一步,next 函数就调用自身,以此实现自动执行。其实就是递归啦,原理很简单的。

co 函数库源码

co 就是上面那个自动执行器的扩展,它的源码只有几十行,非常简单。

首先,co 函数接受 Generator 函数作为参数,返回一个 Promise 对象。

function co(gen) {
    var ctx = this;
    return new Promise(function(resolve, reject) {});
}

在返回的 Promise 对象里面,co 先检查参数 gen 是否为 Generator 函数。如果是,就执行该函数,得到一个内部指针对象;如果不是就返回,并将 Promise 对象的状态改为 resolved 。

function co(gen) {
    var 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);
    });
}

接着,co 将 Generator 函数的内部指针对象的 next 方法,包装成 onFulefilled 函数。这主要是为了能够捕捉抛出的错误。

function co(gen) {
    var 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);
        onFulfilled();
        function onFulfilled(res) {
            var ret;
            try {
                ret = gen.next(res);
            } catch (e) {
                return reject(e);
            }
            next(ret);
        }    
    });
}

最后,就是关键的 next 函数,它会反复调用自身。

function next(ret) {
    if (ret.done) 
        return resolve(ret.value);
    var value = toPromise.call(ctx, ret.value);
    if (value && isPromise(value)) 
        return value.then(onFulfilled, 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) + '"'));
    }
})

上面代码中,next 函数的内部代码,一共只有四行命令。

  • 第一行,检查当前是否为 Generator 函数的最后一步,如果是就返回。
  • 第二行,确保每一步的返回值,是 Promise 对象。
  • 第三行,使用 then 方法,为返回值加上回调函数,然后通过 onFulfilled 函数再次调用 next 函数。
  • 第四行,在参数不符合要求的情况下(参数非 Thunk 函数和 Promise 对象),将 Promise 对象的状态改为 rejected,从而终止执行。

终极解决Async/Await

异步操作是 JavaScript 编程的麻烦事,麻烦到一直有人提出各种各样的方案,试图解决这个问题。

从最早的回调函数,到 Promise 对象,再到 Generator 函数,每次都有所改进,但又让人觉得不彻底。它们都有额外的复杂性,都需要理解抽象的底层运行机制。

异步I/O不就是读取一个文件吗,干嘛要搞得这么复杂?异步编程的最高境界,就是根本不用关心它是不是异步。

async 函数就是隧道尽头的亮光,很多人认为它是异步操作的终极解决方案。

async 函数是什么

一句话,async 函数就是 Generator 函数的语法糖,同样是执行Generator函数,但是不用显式的调用Co函数了。

看例子:

var fs = require('fs');

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

var gen = function* (){
    var f1 = yield readFile('/etc/fstab');
    var f2 = yield readFile('/etc/shells');
    var f3 = yield readFile('/etc/services');
    console.log(f1.toString());
    console.log(f2.toString());
    console.log(f3.toString());
};

function Co(gen){
    var g = gen();

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

    next();
}

Co(gen);

写成 async 函数,就是下面这样。

var asyncReadFile = async function (){
    var f1 = await readFile('/etc/fstab');
    var f2 = await readFile('/etc/shells');
    var f3 = await readFile('/etc/services');
    console.log(f1.toString());
    console.log(f2.toString());
    console.log(f3.toString());
};

发现了吧,async函数就是将Generator函数的*替换成了async,将yield替换成await,除此之外,还对 Generator做了以下四点改进:

  • 内置执行器。Generator函数的执行比如靠执行器,所以才有了co模块等异步执行器,而async函数是自带执行器的。也就是说:async函数的执行,与普通函数一模一样,只要一行:
var result = asyncReadFile();
  • 上面的代码调用了asyncReadFile(),就会自动执行,输出最后结果。这完全不像Generator函数,需要调用next()方法,或者使用co模块,才能得到真正执行,从而得到最终结果。
  • 更好的语义。asyncawait比起星号和yield,语义更清楚。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。
  • 更广的适用性。async函数的await命令后面可以是Promise对象和原始类型的值(数值、字符串和布尔值,而这是等同于同步操作)。
  • 返回值是Promise,这比Generator函数返回的是Iterator对象方便多了。你可以用then()指定下一步操作。

不知道你看到这里,有没有一种...额...算了。反正从Promise => Generaor => Co => Async/Await。所有的代码都是为了异步编程能够优雅的实现,代码量也经历了一个从少到多再到精炼的过程。

async 函数的实现

async 函数的实现,就是将 Generator 函数和自动执行器,包装在一个函数里。

async function fn(args){
    // ...
}

// 等同于

function fn(args){ 
    return spawn(function*() {
        // ...
    }); 
}

所有的 async 函数都可以写成上面的第二种形式,其中的 spawn 函数就是自动执行器。下面给出 spawn 函数的实现,基本就是前文自动执行器的翻版。

function spawn(genF) {
    return new Promise(function(resolve, reject) {
        var gen = genF();
        function step(nextF) {
            try {
                var next = nextF();
            } catch(e) {
                return reject(e); 
            }
            if(next.done) {
                return resolve(next.value);
            } 
            Promise.resolve(next.value).then(function(v) {
                step(function() { return gen.next(v); });      
            }, function(e) {
                step(function() { return gen.throw(e); });
            });
        }
        step(function() { 
            return gen.next(undefined); 
        });
    });
}

async 函数的用法

同 Generator 函数一样,async 函数返回一个 Promise 对象,可以使用 then 方法添加回调函数。当函数执行的时候,一旦遇到 await 就会先返回,等到触发的异步操作完成,再接着执行函数体内后面的语句。下面是一个例子。

async function getStockPriceByName(name) {
    var symbol = await getStockSymbol(name);
    var stockPrice = await getStockPrice(symbol);
    return stockPrice;
}

getStockPriceByName('goog').then(function (result){
    console.log(result);
});

上面代码是一个获取股票报价的函数,函数前面的async关键字,表明该函数内部有异步操作。调用该函数时,会立即返回一个Promise对象。下面的例子,指定多少毫秒后输出一个值。

function timeout(ms) {
    return new Promise((resolve) => {
        setTimeout(resolve, ms);
    });
}

async function asyncPrint(value, ms) {
    await timeout(ms);
    console.log(value)
}

asyncPrint('hello world', 50);

上面代码指定50毫秒以后,输出"hello world"。

注意

await 命令后面的 Promise 对象,运行结果可能是 rejected,所以最好把 await 命令放在 try...catch 代码块中。

async function myFunction() {
    try {
        await somethingThatReturnsAPromise();
    } catch (err) {
        console.log(err);
    }
}

// 另一种写法

async function myFunction() {
    await somethingThatReturnsAPromise().catch(function (err){
        console.log(err);
    });
}

await 命令只能用在 async 函数之中,如果用在普通函数,就会报错。

async function dbFuc(db) {
    let docs = [{}, {}, {}];

    // 报错
    docs.forEach(function (doc) {
        await db.post(doc);
    });
}

上面代码会报错,因为 await 用在普通函数之中了。但是,如果将 forEach 方法的参数改成 async 函数,也有问题。

async function dbFuc(db) {
    let docs = [{}, {}, {}];

    // 可能得到错误结果
    docs.forEach(async function (doc) {
        await db.post(doc);
    });
}

上面代码可能不会正常工作,原因是这时三个 db.post 操作将是并发执行,也就是同时执行,而不是继发执行。正确的写法是采用 for 循环。

async function dbFuc(db) {
    let docs = [{}, {}, {}];

    for (let doc of docs) {
        await db.post(doc);
    }
}

如果确实希望多个请求并发执行,可以使用 Promise.all 方法。

async function dbFuc(db) {
    let docs = [{}, {}, {}];
    let promises = docs.map((doc) => db.post(doc));
    let results = await Promise.all(promises);
    console.log(results);
}

// 或者使用下面的写法

async function dbFuc(db) {
    let docs = [{}, {}, {}];
    let promises = docs.map((doc) => db.post(doc));
    let results = [];
    for (let promise of promises) {
        results.push(await promise);
    }
    console.log(results);
}

 

猜你喜欢

转载自blog.csdn.net/weixin_40851188/article/details/90714369