2015阿里11.11:手淘Promise实践
之前较早的时候,在我们团队中已经陆续分享过几次Promise的实践,主要分享了Promise的常用特性,包括then/catch,链式调用等。而本次借双11技术巡演的机会,主要结合手淘前端的一些日常业务,来阐述Promise的编程模式。
为什么选择Promise
笔者对Promise的态度是极其推崇的,不仅仅因为它能被完美的Polyfill和解决异步调用的问题,更从ES6/7的发展来看,Promise有更大的用武之地(ES6的generator以及ES7的async/wait)。
从数据到渲染
手淘H5首页在优化数据请求时,跳出原有的框架,尝试多种策略来保证用户体验和数据的稳定,下面将要讲到的几种模式的例子都是和这些优化密切相关的:
并行模式
var dataPromise = requestData('data_api_path');
var templatePromise = requestTemplate('template_path');
var domReadyPromise = new Promise(function(resolve, rejcet) {
if (document.readyState === 'complete') {
resolve();
} else {
document.addEventListener('DOMContentLoaded', resolve);
}
});
Promise.all([dataPromise, templatePromise, domReadyPromise])
.then(function([data, tpl]) { // 为了代码简洁,请允许笔者使用下解构语法,解构语法可参考阮一峰老师的《ES6入门》
document.body.appendChild(tpl.render(data));
});
假设已存在requestData/requestTemplate方法的情况下,上述代码在任何时间点运行都可以正常工作。对比用callback的方式,就会让代码很拘谨:
document.addEventListener('DOMContentLoaded', function() {
requestData('data_api_path', function(data) {
requestTemplate('template_path', function(tpl){
document.body.appendChild(tpl.render(data));
});
});
});
流程是顺序的,且想要调整会非常的麻烦。即使费尽周折,也显得不值得:
var dataReady;
var templateReady;
var domReady;
function ifDone() {
if (!!dataReady && !!templateReady && !!domReady) {
document.body.appendChild(templateReady.render(dataReady));
}
}
requestData('data_api_path', function(data) {
dataReady = data;
ifDone();
})
requestTemplate('template_path', function(tpl) {
tplReady = tpl;
ifDone();
});
if (document.readyState === 'complete') {
domReady = true;
ifDone();
} else {
document.addEventLisener('DOMContentLoaded', function() {
domReady = true;
ifDone();
});
}
有人会说,Promise其实就是屏蔽了callback的一些细节而已,而且一些异步任务的库也能解决这个问题。我认同一些异步的任务库(比如windjs)可以如同Promise一样工作,但不认同只是屏蔽了callback的一些细节。事实证明,当Promise/A+成为标准后,windjs也完成了它光荣的使命。一个(将来)原生的多任务异步管理模式没有理由不取代一个js库。
异常流
手淘双11期间的产品特别是大型运营活动的页面,一些细微样式上的不兼容或适配问题往往是可以容忍的(这些要产生P1故障真的很难),但是万万不能让数据出问题。但实际上,数据出问题的场景实在太多了,比如字段类型不对,js没做容错,又比如,网络故障或为了防止突然间的大流量而智能限流等等。前端很有可能拿到一份期望之外的东西,这个时候,就需要一些降级或容错处理。
Promise的异常流跟try/catch很像,例如:
promise.then(function() {
// process 1
}).then(function() {
// process 2
}).catch(function() {
// error
})
那么在数据这个层面的操作,就好比Promise的字面意思一样,给使用方一个承诺,即提供的数据是可用的。
优雅降级
手淘在前端数据的保障上做的特别多,例如有本地存储,APP网关缓存,服务端的打底数据等。当真实的业务接口无法承受压力时,上述数据保障就会发挥作用了。但,面对各种数据保障,怎样能在代码层面上优雅的实现它呢?请看下面的例子:
function getDataFromWebStorage() {
if (window.localStorage && window.localStorage['DATA']) {
return Promise.resolve(window.localStorage['DATA']);
} else {
return Promise.reject();
}
}
function getDataFromAppCache() {
return requestHybridAPI('get_catch_data', 'data_api_path');
}
function getDataFromBackup() {
return requestBackup('data_api_path');
}
funtion parseData(dataStr) {
return JSON.parse(dataStr);
}
function getData() {
var dataPromise = requestData('data_api_path')
.then(parseData) // will throw a parse error
.catch(function() {
// catch request & parse error
return getDataFromWebStorage().then(parseData);
})
.catch(function() {
// catch webstorage & parse error
return getDataFromAppCache().then(parseData);
})
.catch(function() {
// catch appcache & parse error
return getDataFromBackup().then(parseData);
})
.then(function(data) {
if (window.localStorage) {
window.localStroage['DATA'] = JSON.stringify(data);
}
return data;
})
.catch(function(err) {
// catch kinds of error
handleKindsOfError(err);
});
}
上述的异常处理,是希望在获取原有数据失败或解析失败时,尝试从其它渠道来重新获取一份可能有点过时但没那么糟糕的数据。最坏情况,所有渠道的数据都没能复合期望,还可以根据不同的错误分类给出不同的友好提示。
不熟悉Promise异常流的读者,可能会对上述的异常流处理有些疑问:
catch可以捕获到的范围?
从当前catch沿着链式调用往前找,它可以一直捕获到前一个catch或者链式调用最开始为止。也就是在调用最开始到第一个catch之间,或者两个catch之间(包括前一个catch)中的所有错误,都会被后面的catch捕获到。
为什么有些Promise调用链没有catch?
因为Promise的抛出异常的堆栈和函数调用的堆栈是相反的(这个和原生的抛出异常堆栈是相同的),当getDataFromWebStorage
这个方法获取数据失败,抑或是解析数据失败的异常都会抛到上一个调用堆栈上(即上一层的Promise调用链)并寻找下一个最近的catch。如果找不到catch,则会一直往上抛直到有能够处理的catch或者达到调用堆栈的顶端(达到顶端后就会在控制台提示错误了)。
如图:
高效切换策略
上面在处理异常流的代码中,细心的读者可能已经发现,Promise的代码流,不仅仅给异常处理带来了方便,还可以在适当的场景下,运用不同的策略来展示数据。
例如,在保证用户体验的情况下,希望更快的把页面内容展示给用户。那么请求接口数据的耗时是一个优化点。如果,通过优先展示本地储存数据,先让用户看到页面(可能数据是1分钟前的),如果缓存没有数据,再请求业务数据接口。这样的策略下,上述代码稍作修改就可以轻而易举的胜任:
function getData() {
var dataPromise = getDataFromWebStorage()
.then(parseData)
.catch(function() {
return requestData('data_api_path').then(parseData);
})
.then(function(data) {
if (window.localStorage) {
window.localStroage['DATA'] = JSON.stringify(data);
}
return data;
})
.catch(function(err) {
// catch kinds of error
handleKindsOfError(err);
});
}
没错,仅仅调换了getDataFromWebStorage
和requestData
的调用顺序,给用户的体验就大不同了!
竞争模式
笔者觉得,在实际业务中往往会忽略竞争模式,下面用一个比较典型例子来加深读者对竞争模式的印象:
element.style.transition = 'opacity 0.4s ease 0s';
element.style.opacity = '0';
var eventPromise = new Promise(function(resolve, reject) {
element.addEventListener('transitionend', function handler() {
element.removeEventListener('transitionend', handler);
resolve();
});
});
var timeoutPromise = new Promise(function(resolve, reject) {
setTimeout(resolve, 400);
});
Promise.race([eventPromise, timeoutPromise])
.then(function() {
element.style.transition = '';
element.style.display = 'none';
});
之前在处理手淘H5首页的跑马灯动画时,预期动画能顺利结束并触发transitionend事件,但适配的结果不尽如人意。事实上在复杂的DOM环境加上浏览器实现的bug,甚至业务代码的一些疏漏,会导致transitionend无法被触发。这种情况下,用一个超时来保证流程能正常执行下去就显得非常必要了。
分工合作,各司其职
特别强调Promise的字面意思是承诺,而实际使用起来它就是一个承诺。而当承诺不可拆分时,即保证了它的原子性。我们在业务代码中,要尽量让每个独立的事务保证自身的原子性,这样在不同的业务场景下,才能随心所欲的串联这些事务。这里笔者所说的事务,其实并不一定要拘泥于是异步事务。手淘H5首页的模板是和iOS模板同构的,模板渲染后需要对模板生成的模块绑定事件或交互。在这种场景下,笔者设计了一种分工模式
,来协调每个不同层次间的合作。这使得,代码流看起来跟传统的调用API方式完全不同。
请允许笔者在以下的代码中,使用ES6的export/import语法,更详细的语法介绍可参阅阮一峰老师的《ES6入门》
分工模式
A攻城师负责获取数据(data.js):
export var button = requestData('button_data_api_path');
B攻城师负责获取模板(template.js):
export var button = requestTemplate('button_template_path');
C攻城师负责渲染数据(render.js):
import dataPromise from './data.js';
import templatePromise from './template.js';
import {domReadyPromise} from './util.js';
var deferred = {};
deferred.promise = new Promise(function(resolve, reject) {
deferred.resolve = resolve;
deferred.reject = reject;
});
export var renderCompletePromise = deferred.promise;
Promise.all([dataPromise.button, templatePromise.button, domReadyPromise])
.then(function([data, tpl]) {
var buttonElement = tpl.render(data);
document.body.appendChild(buttonElement);
deferred.resolve(buttonElement);
});
D攻城师负责赋予交互行为(ctrl.js):
import {renderCompletePromise} from './render.js';
renderCompletePromise.then(function(element) {
element.addEventListener('click', function() {
location.href = '//m.taobao.com';
});
});
代码中用到了domReadyPromise
这个变量,它的实现在最早的示例代码中已经有体现,这里不再赘述。
几个攻城师之间只要互相给出一个承诺就可以,而不用操心对方的API什么时候又更新然后被坑到了等等,这样的合作方式是不是很赞!!当然为了例子生动,笔者用不同攻城师开发一个项目当中的不同层次代码来阐述Promise分工模式,实际情况其实不需要那么多攻城师,读者一个人完全可以在项目中也完成这样的Promise分工模式。
推迟兑现
上述例子中,用到了一个名词,即deferred
,其原型defer
字面意思是推迟
。如果屏蔽掉一些代码细节,希望是这样的:
var deferred = defer([promise]);
传统Promise的方式,创建承诺(new Promise)和兑现承诺(resolve)是同一维度下进行的。而,defer
先是创建了一个承诺,其后可以在任何维度下兑现它。例如,下面一个例子:
deferData.js:
export var deferred;
export function getDeferData() {
var dataPromise = requestData('data_api_path');
deferred = defer(dataPromise);
return deferred.promise;
}
deferRender.js:
import {getDeferData} from './deferData.js';
getDeferData().then(function(data) {
// TODO render
});
deferCtrl.js:
import {deferred} from './deferData.js';
import {domReadyPromise} from './util.js';
domReadyPromise.then(function() {
var button = document.querySelector('button');
button.addEventListener('click', function handler() {
button.removeEventListener('click', handler)
deferred.resolve();
});
});
上述三个分工模式下的分层代码,完成了在用户点击某按钮后再渲染页面的流程,
深挖,注意有坑
并行模式也好,分工模式也罢,刚接触时兴奋的任何代码都想Promise下,但各位读者应该避免过度使用Promise。例如笔者曾经这样使用过Promise:
function wait(element, eventName) {
return new Promise(function(resolve, reject) {
element.addEventListener(eventName, function handler() {
element.removeEventListener(eventName, handler);
resolve();
})
});
}
function sleep(resolve) {
return new Promise(function(resolve, reject) {
setTimeout(resolve, time);
});
}
void function circle() {
wait(element, 'click')
.then(function() {
return sleep(300);
})
.then(function() {
alert('clicked');
})
.then(circle);
}();
DOM事件是一个可被连续触发的事务,所以它本身并不是一个承诺。上述例子中的循环模式在一些场景下还是挺有用的,但是,毕竟它打破了DOM事件的原有特性,这里并不推荐为了Promise而用Promise。
小结
Promise的实践远远不止这么一些,一两篇篇幅恐怕很难涵盖所有可以为开发者所用的模式,之后的一篇实践会涉及generator/co甚至async/wait,在引入这些未来的ES特性后,Promise的使命就有了微妙的变化。