承诺之美 —— 浅谈基于 Promise 的异步 Javascript编程方法

回调之痛

每一位前端工程师上辈子都是折翼的天使。

相信很多前端工程师都同我一样,初次接触到前端时,了解了些许 HTML、CSS、JS 知识,便惊叹于前端的美好,沉醉于这种所见即所得的成就感之中。但很快我就发现,前端并没有想象中的那么美好,JS 也并不是弹一个 alert 这么简单。尤其是当我想这么干,却发现无法得到结果时:

var data = ajax('/url/to/data');

在查阅很多资料后,我知道了 JS 是事件驱动的,ajax 异步请求是非阻塞的,我封装的 ajax 函数无法直接返回服务器数据,除非声明为同步请求(显然这不是我想要的)。于是我学会了或者说接受了这样的事实,并改造了我的 ajax 函数:

ajax('/url/to/data', function(data){
    //deal with data
});

在很长一段时间,我并没有认为这样的代码是不优雅的,甚至认为这就是 JS 区别于其他语言的特征之一 —— 随处可见的匿名函数,随处可见的 calllback 参数。直到有一天,我发现代码里出现了这样的结构:

ajax('/get/data/1', function(data1){
    ajax('/get/data/2', function(data2){
        ajax('/get/data/3', function(data3){          
            dealData(data1, data2, data3, function(result){
                setTimeout(function(){
                    ajax('/post/data', result.data, function(ret){
                        //...
                    });
                }, 1000);
            });             
        });    
    });
});

这就是著名的回调金字塔

金字塔

在我的理想中,这段代码应该是这样的:

var data1 = ajax('/get/data/1');
var data2 = ajax('/get/data/2');
var data3 = ajax('/get/data/3');

var result = dealData(data1, data2, data3);

sleep(1000);

var ret = ajax('/post/data', result.data);
//...

承诺的救赎

理想是丰满的,奈何现实太骨干。这种回调之痛在前端人心中是挥之不去的,它使得代码结构混乱,可读性变差,维护困难。在忍受这种一坨坨的代码很久之后,有一天我偶遇了 Promise,她的优雅让我久久为之赞叹:世间竟有如此曼妙的异步回调解决方案。

Promises/A+规范中对 promise 的解释是这样的: promise 表示一个异步操作的最终结果。与 promise 进行交互的主要方式是通过 then 方法,该方法注册了两个回调函数,用于接受 promise 的最终结果或者 promise 的拒绝原因。一个 Promise 必须处于等待态(Pending)、兑现态(Fulfilled)和拒绝态(Rejected)这三种状态中的一种之中。

  1. 处于等待态时
  • 可以转移至执行态或拒绝态
  1. 处于兑现态时
  • 不能迁移至其他任何状态
  • 必须拥有一个不可变的值作为兑现结果
  1. 处于拒绝态时
  • 不能迁移至其他任何状态
  • 必须拥有一个不可变的值作为拒绝原因

通过 resolve 可以将承诺转化为兑现态,通过 reject 可以将承诺转换为拒绝态。

关于 then 方法,它接受两个参数:

promise.then(onFulfilled, onRejected)

then 方法可以被同一个 promise 调用多次:

  • promise 成功执行时,所有 onFulfilled 需按照其注册顺序依次回调
  • promise 被拒绝执行时,所有的 onRejected 需按照其注册顺序依次回调

使用 Promise 后,我的 ajax 函数使用起来变成了这个样子:

ajax('/url/to/data')
    .then(function(data){
        //deal with data
    });

看起来和普通的回调没什么变化是么?让我们继续研究 then 方法的神奇之处吧。

then 方法的返回值是一个新的 promise

    promise2 = promise1.then(onFulfilled, onRejected);

如果 onFulfilledonRejected 的返回值 x 是一个 promise,promise2 会根据 x 的状态来决定如何处理自己的状态。

  • 如果 x 处于等待态, promise2 需保持为等待态直至 x 被兑现或拒绝
  • 如果 x 处于兑现态,用相同的值兑现 promise2
  • 如果 x 处于拒绝态,用相同的值拒绝 promise2

这意味着串联异步流程的实现会变得非常简单。我试着用 Promise 来改写所有的异步接口,上面的金字塔代码便成为这样的:

when( ajax('/get/data/1'), ajax('/get/data/2'), ajax('/get/data/3') )
    .then(dealData)
    .then(sleep.bind(null,1000))
    .then(function(result){
        return ajax('/post/data', result.data);
    })
    .then(function(ret){
        //...
    });

一下子被惊艳到了啊!回调嵌套被拉平了,小肚腩不见了!这种链式 then 方法的形式,颇有几分 stream/pipe 的意味。

$.Deferred

jQuery 中很早就有 Promise 的实现,它称之为 Deferred 对象。使用 jQuery 举例写一个 sleep 函数:

function sleep(s){
    var d = $.Deferred();
    setTimeout(function(){
        d.resolve();
    }, s); 
    return d.promise(); //返回 promise 对象防止在外部被别人 resolve
}

我们来使用一下:

sleep(1000)
    .then(function(){
        console.log('1秒过去了');
    })
    .then(sleep.bind(null,3000))
    .then(function(){
        console.log('4秒过去了');
    });

jQuery 实现规范的 API 之外,还实现了一对接口:notify/progress。这对接口在某些场合下,简直太有用了,例如倒计时功能。对上述 sleep 函数改造一下,我们写一个 countDown 函数:

function countDown(second) {
    var d = $.Deferred();
    var loop = function(){
        if(second <= 0) {
            return d.resolve();
        }
        d.notify(second--);
        setTimeout(loop, 1000);
    };
    loop();
    return d.promise();
}

现在我们来使用这个函数,感受一下 Promise 带来的美好。比如,实现一个 60 秒后重新获取验证码的功能:

var btn = $("#getSMSCodeBtn");
btn.addClass("disabled");
countDown(60)
    .progress(function(s){
        btn.val(s+'秒后可重新获取');
    })
    .then(function(){
        btn.val('重新获取验证码').removeClass('disabled');
    });

简直惊艳!离绝对的同步编写非阻塞形式的代码已经很近了!

与 ES6 Generator 碰撞出火花

我深刻感受到,前端技术发展是这样一种状况: 当我们惊叹于最新技术标准的美好,感觉一个最好的时代即将到来时,回到实际生产环境,却发现一张小小的 png24 透明图片在 IE6 下还需要前端进行特殊处理。但,那又怎样,IE6 也不能阻挡我们对前端技术灼热追求的脚步,说不定哪天那些不支持新标准的浏览器就悄然消失了呢?(扯远了...)

ES6 标准中最令我惊叹的是 Generator —— 生成器。顾名思义,它用来生成某些东西。且上例子:

生成器基本使用

这里我们看到了 function*() 的新语法,还有 yield 关键字和 for/of 循环。新东西总是能让人产生振奋的心情,即使现在还不能将之投入使用(如果你需要,其实可以通过 ES6->ES5 的编译工具预处理你的 js 文件)。如果你了解 Python , 这很轻松就能理解。Generator 是一种特殊的 function,在括号前加一个 * 号以区别。Generator 通过 yield 操作产生返回值,最终生成了一个类似数组的东西,确切的说,它返回了 Iterator,即迭代器。迭代器可以通过 for/of 循环来进行遍历,也可以通过 next 方法不断迭代,直到迭代完毕。

生成器-next

yield 是一个神奇的功能,它类似于 return ,但是和 return 又不尽相同。return 只能在一个函数中出现一次,yield 却只能出现在生成器中且可以出现多次。迭代器的 next 方法被调用时,将触发生成器中的代码执行,执行到 yield 语句时,会将 yield 后的值带出到迭代器的 next 方法的返回值中,并保存好运行时环境,将代码挂起,直到下一次 next 方法被调用时继续往下执行。

有没有嗅到异步的味道?外部可以通过 next 方法控制内部代码的执行!天然的异步有木有!感受一下这个例子:

生成器-dead-loop

还有还有,yield 大法还有一个功能,它不仅可以带出值到 next 方法,还可以带入值到生成器内部 yield 的占位处,使得 Generator 内部和外部可以通过 next 方法进行数据通信!

生成器-interact

好了,生成器了解的差不多了,现在看看把 Promise 和 Generator 放一起会产生什么黑魔法吧!

生成器-Promise

这里写一个 delayGet 函数用来模拟费时操作,延迟 1 秒返回某个值。在此借助一个 run 方法,就实现了同步编写非阻塞的逻辑!这就是 TJ 大神 co 框架的基本思想。

回首一下我们曾经的理想,那段代码用 co 框架编写可以是这样的:

co(function*(){
    var data1 = yield ajax('/get/data/1');
    var data2 = yield ajax('/get/data/2');
    var data3 = yield ajax('/get/data/3');

    var result = yield dealData(data1, data2, data3);

    yield sleep(1000);

    var ret = yield ajax('/post/data', result.data);
    //...
})();

Perfect!完美!

ES7 async-await

ES3 时代我们用闭包来模拟 private 成员,ES5 便加入了 defineProperty 。Generator 最初的本意是用来生成迭代序列的,毕竟不是为异步而生的。ES7 索性引入 asyncawait关键字。async 标记的函数支持 await 表达式。包含 await 表达式的的函数是一个deferred functionawait 表达式的值,是一个 awaited object。当该表达式的值被评估(evaluate) 之后,函数的执行就被暂停(suspend)。只有当 deffered 对象执行了回调(callback 或者 errback)后,函数才会继续。

也就是说,只需将使用 co 框架的代码中的 yield 换掉即可:

async function task(){
    var data1 = await ajax('/get/data/1');
    var data2 = await ajax('/get/data/2');
    var data3 = await ajax('/get/data/3');

    var result = await dealData(data1, data2, data3);

    await sleep(1000);

    var ret = await ajax('/post/data', result.data);
    //...
}

至此,本文的全部内容都已完毕。前端标准不断在完善,未来会越来越美好。永远相信美好的事情即将发生!

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

推荐阅读更多精彩内容

  • 异步编程对JavaScript语言太重要。Javascript语言的执行环境是“单线程”的,如果没有异步编程,根本...
    呼呼哥阅读 7,296评论 5 22
  • 官方中文版原文链接 感谢社区中各位的大力支持,译者再次奉上一点点福利:阿里云产品券,享受所有官网优惠,并抽取幸运大...
    HetfieldJoe阅读 6,372评论 9 19
  • 简介 基本概念 Generator函数是ES6提供的一种异步编程解决方案,语法行为与传统函数完全不同。本章详细介绍...
    呼呼哥阅读 1,066评论 0 4
  • 在此处先列下本篇文章的主要内容 简介 next方法的参数 for...of循环 Generator.prototy...
    醉生夢死阅读 1,436评论 3 8
  • 弄懂js异步 讲异步之前,我们必须掌握一个基础知识-event-loop。 我们知道JavaScript的一大特点...
    DCbryant阅读 2,693评论 0 5