Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

手淘Promise实践 #22

Open
terrykingcha opened this issue Nov 24, 2015 · 10 comments
Open

手淘Promise实践 #22

terrykingcha opened this issue Nov 24, 2015 · 10 comments

Comments

@terrykingcha
Copy link
Member

之前较早的时候,在我们团队中已经陆续分享过几次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的使命就有了微妙的变化。

@kujian
Copy link

kujian commented Nov 24, 2015

mark

2 similar comments
@zaleer
Copy link

zaleer commented Nov 25, 2015

mark

@byszhao
Copy link

byszhao commented Nov 25, 2015

mark

@LingyuCoder
Copy link

同样喜欢把各个分散的逻辑包裹成promise,不过还是结合co用爽歪歪,想同步就同步,想异步就异步,想并发就并发,现在node 4.0+就可以玩

@terrykingcha
Copy link
Member Author

楼上,其实可以不用generator/co了,直接上async/wait了

@BuilderQiu
Copy link

mark

@Thinking80s
Copy link

直接上async/wait需要做些什么准备呐?

@Thinking80s
Copy link

mark

2 similar comments
@freeacger
Copy link

mark

@luoyjx
Copy link

luoyjx commented Mar 8, 2016

mark

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

9 participants