FED

©FrontEndDev.org
2015 - 2024
web@2.22.0 api@2.20.0

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);
        });
}

没错,仅仅调换了getDataFromWebStoragerequestData的调用顺序,给用户的体验就大不同了!

竞争模式

笔者觉得,在实际业务中往往会忽略竞争模式,下面用一个比较典型例子来加深读者对竞争模式的印象:

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的使命就有了微妙的变化。