JS异步编程——从一个应用场景讲起

本文旨在通过实例演示JS异步编程几种常见的解决方案,让你写出最优雅的异步代码。

异步是Javascript区别于其他编程语言的一个优秀的特性。由于JS是单线程执行的,如果没有引入异步,站点页面的性能将会是一个难以解决的、致命的问题。而随之node的兴起,异步更是被推崇到极致,跃身成为网络编程的强手。

一段完整的任务A执行到一半,暂停,去执行另外一段任务B,完了再回到断点处继续执行A,这是异步编程最直观的理解。这里的断点,最普遍的应用场景就是发出一个耗时不可知的I/O任务,http请求或文件读取。本文就这样一个逻辑场景的假定,来聊聊JS异步编程的解决方案(为简化思路,场景设计得略为蹩脚):

  • JS读取文件a.txt,获取到数据dataA=1;
  • 再读取文件b.txt,获取到数据dataB=2;
  • 再读取文件c.txt,获取到数据dataC=3;
  • 求和 sum = 6。

这类场景所呈现出来的顺序依赖的问题,大多数情况下是可以通过修改设计逻辑来解决的,但有些时候并不适合。我曾在项目中遇到过向服务端按序同步做出多个http请求的需求,并且此时重新制定API的成本已经相当大了,所以,对于这种常见菜式,如何高效实现异步是JS开发的关键。下边讨论的事件回调、发布/订阅模式、Promise、Generator,以及最出神入化的 Async/Await 新语法,都是异步编程可选的解决渠道。


事件回调

事件(event)与回调(callback)在JS中随处可见,回调是常用的解决异步的方法,易于理解,在许多库与函数中也容易实现。如果使用 node 原生支持的 fs.readFile() 来编写那会是这样的:

const fs = require('fs');

fs.readFile('a.txt', {encoding: 'utf8'}, function(err, dataA){
    if(err) throw Error('fail');
    console.log('dataA is %d', dataA);

    fs.readFile('b.txt', {encoding: 'utf8'}, function(err, dataB){
        if(err) throw Error('fail');
        console.log('dataB is %d', dataB);

        fs.readFile('c.txt', {encoding: 'utf8'}, function(err, dataC){
            if(err) throw Error('fail');
            console.log('dataC is %d', dataC);

            console.log('sum is %d', parseInt(dataA) + parseInt(dataB) + parseInt(dataC));
        })
    })
});

// $node index.js
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6

readFile()会在文件 I/O 返回结果之后触发回调函数,通过这种嵌套的方式,我们能够保证文件是按序读取的,这种方式在JS中十分常见,比如定时器setInterval()setTimeout()。回调实现的异步代码易于理解,但问题也很明显,层层嵌套使得代码逐层缩进,严重降低了可读性,我们把这种现象称为回调金字塔。

发布/订阅模式

事件回调是通过触发实现的,而发布/订阅(pub/sub)模式实现的原理与事件回调基本一致。事实上,发布/订阅模式更像是一般化的事件回调,是对事件回调的拆解和拓展。和事件回调机制一样,发布/订阅模式需要提供回调函数(订阅),不同的是事件回调机制是自行触发,而发布/订阅模式把触发权限交给了开发者,因此你可以选择在任意时刻触发回调(发布)。

在事件机制上,node 内置了一个功能强大的events模块,对发布/订阅模式提供了完美的支持,我们可以用它来实现这个应用场景:

const events = require('events');  
const fs = require('fs');
const eventEmitter = new events.EventEmitter();

const files = [
    { fileName: 'a.txt', dataName: 'dataA' },
    { fileName: 'b.txt', dataName: 'dataB' },
    { fileName: 'c.txt', dataName: 'dataC' },
];
let index = 0;

// 订阅自定义事件
eventEmitter.on('next', function(data){
    if(index>2) return console.log('sum is %d', parseInt(data));

    fs.readFile(files[index].fileName, {encoding: 'utf8'}, function(err, newData){
        if(err) throw Error('fail');
        console.log(files[index].dataName+' is %d', newData);
        index++;
        // 驱动执行
        eventEmitter.emit('next', parseInt(data)+parseInt(newData));
    })
});

// 触发自定义事件执行
eventEmitter.emit('next', 0);

// $ node index.js
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6

上面代码挂载了一个自定义事件next,通过一个守卫变量index,使得next事件总能在前一步文件读取完成后触发,并且在按序完成三次文件读取后输出和。

事件机制在JS编程中十分常见,原生的 XHR 对象就是通过事件实现 AJAX 请求的。但从上面的代码我们可以看出这种模式解决深度嵌套问题依然显得吃力,events对象的引入、事件的订阅和发布,让代码掺杂了许多逻辑以外的东西,十分晦涩难懂。人类语言的语法逻辑是同步的,我们希望避开丑陋的异步代码,用同步的编写方式去实现异步逻辑。

Promise

在 ES6 出现之前,人们饱受“回调地狱”的煎熬,现在,我们大可以使用 ES6 提供的 Promise 对象实现异步,彻底地告别“回调地狱”。Promise 是对社区早有的 Promise/Deferred 模式的实现,该模式最早出现在 jQuery1.5 版本,使得改写后的 Ajax 支持链式表达。Promise 译为“承诺”,个人理解为 Promise 对象承诺在异步操作完成后调用 then 方法指定的回调函数,因此,Promise的本质依然是事件回调,是基于事件机制实现的。

const fs = require('fs');

new Promise(function(resolve, reject){
    fs.readFile('a.txt', {encoding: 'utf8'}, function(err, newData){
        if(err) reject('fail');
        console.log('dataA is %d', newData);

        resolve(newData);
    });
}).then(function(data){
    // 返回一个新的 promise 对象,使得下一个回调函数会等待该异步操作完成
    return new Promise(function(resolve, reject){
        fs.readFile('b.txt', {encoding: 'utf8'}, function(err, newData){
            if(err) reject('fail');
            console.log('dataB is %d', newData);

            resolve(parseInt(data)+parseInt(newData));
        });
    });
}).then(function(data){
    return new Promise(function(resolve, reject){
        fs.readFile('c.txt', {encoding: 'utf8'}, function(err, newData){
            if(err) reject('fail');
            console.log('dataC is %d', newData);

            resolve(parseInt(data)+parseInt(newData));
        });
    });
}).then(function(data){
    console.log('sum is %d', parseInt(data));
}).catch(function(err){
    throw Error('fail');
});

// $ node index.js
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6

上面的代码中,异步操作被按序编写在每个 Promise 对象的then()方法中,then表示“然后”,可见按照这种方式编写的代码逻辑和业务逻辑是一致的,保证了代码的可读性,但引入了 Promise 对象后,代码中出现了一堆的 then 和 catch 方法,这些代码是业务逻辑之外的代码,是影响阅读、不应该出现的。我们不止希望代码逻辑和业务逻辑是一致的,我们还想要最简洁、最清晰的表达方式。

Generator

Generator 函数是 ES6 标准引入的新特性,旨在提供一类完全不同于以往异步编写方式的解决方案。依照阮一峰老师在《Generator 函数的语法》一文中的说法,Generator 函数是一个状态机,封装了多个内部状态。Generator 函数提供了一种机制,通过yield关键字和next()方法来交付和归还线程执行权,实现代码异步。我们前边说过,异步直观理解是中断程序A去执行B之后再回到断点处继续执行A,本质上这就是一种执行权的借还。

Generator 函数不同于普通函数,Generator 函数执行到yield关键字处会自动暂停,保护现场,返回一个遍历器(Iterator)对象,完成执行权的交付。之后,我们通过调用该遍历器对象的next()方法,回归现场,驱动 Generator 函数从断点处继续执行,完成执行权归还。

归还执行权一般借助于 Promise 对象来实现。

const fs = require('fs');

const getData = function(fileName){
    return new Promise(function(resolve, reject){
        fs.readFile(fileName, {encoding: 'utf8'}, function(err, data){
            if(err) throw Error('fail');
            resolve(data);
        })
    });
}

const g = function* (){
    try{
        let dataA = yield getData('a.txt');  // yield 在暂停时刻并没有赋值,dataA 的值是在重新执行时刻由 next 方法的参数传入的
        console.log('dataA is %d', dataA);  
        let dataB = yield getData('b.txt');  
        console.log('dataB is %d', dataB); 
        let dataC = yield getData('c.txt');
        console.log('dataC is %d', dataC);

        console.log('sum is %d', parseInt(dataA) + parseInt(dataB) + parseInt(dataC));
    }catch(err){
        console.log(err);
    }
};

// 驱动 Generator 执行
function run (generator) {
    let it = generator();

    function go(result) {
        // 判断是否遍历完成,标志位 result.done 为 true 表示遍历完成
        if (result.done) return result.value;
        // result.value 即为返回的 promise 对象
        return result.value.then(function (value) {
            return go(it.next(value));
        }, function (error) {
            return go(it.throw(error));
        });
    }

    go(it.next());
}

run(g);

// $ node index.js
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6

上面代码中,getData()函数返回了一个负责读取文件的 Promise 对象, Promise 对象会在读取到数据后驱动 Generator 函数继续执行,run()函数是 Generator 函数的自动执行器。如果你把目光放到 Generator 函数上,你会惊讶地发现,这异步代码简直跟同步编写的一模一样!可以说Generator 函数的出现,为JS异步编程带来了另一种风景,没有累赘的代码,没有回调金字塔,代码逻辑与设计思路映射完全一致。

基于此设计的 thunkify + co 组合,可以说极大地简化了 Generator 的实现方式。thunkify 模块是一个偏函数,用于将参数包含【执行参数】和【回调函数】的函数(比如fs.readFile(path[, options], callback))转化为一个二级函数(形如readFile(path[, options])(callback)),而 co 模块则完成对 Generator 函数的驱动,也就是上面代码中 getData()run()实现的功能。

const co = require('co');
const thunkify = require('thunkify');

const fs = require('fs');
const readFile = thunkify(fs.readFile);

co(function* (){
    let dataA = yield readFile('a.txt', {encoding: 'utf8'});  
    console.log('dataA is %d', dataA);  
    let  dataB = yield readFile('b.txt', {encoding: 'utf8'});  
    console.log('dataB is %d', dataB); 
    let dataC = yield readFile('c.txt', {encoding: 'utf8'});  
    console.log('dataC is %d', dataC); 
    console.log('sum is %d', parseInt(dataA) + parseInt(dataB) + parseInt(dataC));
});
console.log('异步执行');

// $ node index.js
// 异步执行
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6

对照上面两种实现方法,可以发现 co 帮我们处理掉了许多业务逻辑之外的累赘代码。另外结果表明,程序依然还是完美地异步执行的,但我们已经基本看不出代码的异步特性了。

Async/Await

在追求巅峰造极的路上,JS永远是先锋。ES7标准引入的 Async/Await 语法,可以说是JS异步编程的最佳实现。Async/Await 语法本质上只是 Generator 函数的语法糖,像 co 一样,它内置了 Generator 函数的自动执行器,并且支持更简洁更清晰的异步写法。

const fs = require('fs');

// 封装成 await 语句期望的 promise 对象
const readFile = function(){
    let args = arguments;
    return new Promise(function(resolve, reject){
        fs.readFile(...args, function(err, data){
            // await 会吸收 resolve 传入的值作为返回值赋给变量
            resolve(data);
        })
    })
};

const asyncReadFile = async function(){
    let dataA = await readFile('a.txt', {encoding: 'utf8'});
    console.log('dataA is %d', dataA);
    let dataB = await readFile('b.txt', {encoding: 'utf8'});
    console.log('dataB is %d', dataB);
    let dataC = await readFile('c.txt', {encoding: 'utf8'});
    console.log('dataC is %d', dataC);
    console.log('sum is %d', parseInt(dataA) + parseInt(dataB) + parseInt(dataC));
};

asyncReadFile();
console.log('异步执行');

// $ node index.js
// 异步执行
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6

到这里,可能有些人已经不太敢相信这其实是一段异步代码了。Async/Await 同样需要借助 Promise 对象实现,但已经尽最大努力弱化了逻辑之外的辅助代码了。当异步逻辑更加复杂时,Async/Await 语法编写的异步代码冗余成分比例将大大减小,我们所能注意到的,就只剩下优雅的逻辑代码了。


以上多为个人心得,错漏之处请指出。

参考自阮一峰ECMAScript 6 入门

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容