回调之痛
每一位前端工程师上辈子都是折翼的天使。
相信很多前端工程师都同我一样,初次接触到前端时,了解了些许 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)这三种状态中的一种之中。
- 处于等待态时
- 可以转移至执行态或拒绝态
- 处于兑现态时
- 不能迁移至其他任何状态
- 必须拥有一个不可变的值作为兑现结果
- 处于拒绝态时
- 不能迁移至其他任何状态
- 必须拥有一个不可变的值作为拒绝原因
通过 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);
如果 onFulfilled
、onRejected
的返回值 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
方法不断迭代,直到迭代完毕。
yield
是一个神奇的功能,它类似于 return
,但是和 return
又不尽相同。return
只能在一个函数中出现一次,yield
却只能出现在生成器中且可以出现多次。迭代器的 next
方法被调用时,将触发生成器中的代码执行,执行到 yield
语句时,会将 yield
后的值带出到迭代器的 next
方法的返回值中,并保存好运行时环境,将代码挂起,直到下一次 next
方法被调用时继续往下执行。
有没有嗅到异步的味道?外部可以通过 next
方法控制内部代码的执行!天然的异步有木有!感受一下这个例子:
还有还有,yield
大法还有一个功能,它不仅可以带出值到 next
方法,还可以带入值到生成器内部 yield
的占位处,使得 Generator 内部和外部可以通过 next
方法进行数据通信!
好了,生成器了解的差不多了,现在看看把 Promise 和 Generator 放一起会产生什么黑魔法吧!
这里写一个 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 索性引入 async
、await
关键字。async
标记的函数支持 await
表达式。包含 await
表达式的的函数是一个deferred function
。await
表达式的值,是一个 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);
//...
}
至此,本文的全部内容都已完毕。前端标准不断在完善,未来会越来越美好。永远相信美好的事情即将发生!