Osheep

时光不回头,当下最重要。

重读 ES6 — Promise 好在哪里

Promise 是 ES6 中一个非常棒的 API。是有别于 ES5 回调的、具有突破性的异步编程解决方案。(关于异步编程方案,搭配 这里 学习可能更完整哦)

核心 API

最开始接触 Promise 时,总是使劲的去理解 promise(“承诺”)传达的意思,什么是对未来的一个承诺……其实这种抽象概念,反而增加了对 Promise 的理解难度。

拒绝抽象概念,请讲代码。一个最简单的用法是这样的:

let promise = new Promise(function(resolve, reject) {
    setTimeout(() => {
        resolve({msg: 'done'});
    }, 1000)
})

我们来分层看 Promise API 是怎样的。

第一层:构造函数 Promise 接收一个函数作为参数,这个函数是一个进行异步操作的函数。再简写一下是这样的:

let promise = new Promise(fn);

第二层:这个异步函数fn必须形式化的提供 2 个参数:(resolve, reject),它们在函数体内,由用户根据需要调用。

function fn(resolve, reject) {
    setTimeout(() => {
        resolve({msg: 'done'});
    }, 1000)
}

第二层仅仅是规定了 fn 的书写要求。换个角度看,fnPromise 原本半毛钱关系都没有,但要想和Promise挂钩,那就得在这个函数体内,去调一下resolvereject,仅此而已。

通观第一层、第二层,总结起来就是这两点:

  • 从形式上看,Promise API 就是这三个元件:Promise, resolve, reject
  • 从使用角度看,Promise API 只不过是一套用来包裹和改造异步代码的套件

再举一个常见的例子,来强化下这种结构认识:

let promise = new Promise( (resolve, reject) => {
    $.get('/myUrl', (data, status) => {
        if(status === 'success') {
            resolve(data);
        } else {
            reject(data);
        }
    })
})

一方面,resolvereject就像两个需要带话回去的使徒,又像是潜藏在敌方的线人,起到获取fn内部情报信息的作用。

另一方面,原本的$.get异步方法被进一步改造,安插了两个线人

我们知道,一个promise 实例就包含一个状态机,能改变状态机状态的只有resolve按钮和reject按钮。

因此,一个promise就只有三种状态:

  • pending(悬而未决的初始等待状态)
  • fulfilled(已成功,按下resolve按钮导致的)
  • rejected(已失败,按下reject按钮导致的)

引入状态机 使得Promsie异步方案和传统方案有着本质的不同,状态机不用关心细节处理(当然细节还是得我们自己处理),它只管划定了几种状态,我们编程时向状态看齐,这样编程思路就更清晰。

此外,Promsie是一套声明式编程的API,无论是new Promsie().then() ,还是.catch(),都要求传入函数作为参数,强调做什么,而不是聚焦怎么做。

一句话说, Promise 就是用状态机侵入式方法封装异步操作的一套声明式的 API 规范

扁平化

扁平化是Promise为我们呈现的一个非常凸显的理念。

说到扁平化,我们年轻人都欢迎扁平化,喜欢扁平化的、无层级、消息直达的组织。相比很多传统公司,很多互联网公司就采用扁平化管理……扯远了,一个是写代码,一个是组织结构管理……

但从某种角度说,写代码也是在搭结构,也要减少嵌套层级。Promise API 凸显了这样的结构,所以不得不叹服它的设计之妙。鉴于此,这给了我们很好的启示,我们编写代码也应尽量扁平化。

回到代码,我们来看看Promise怎么让我们轻松的实现扁平化风格。

首先,依据上述 Promise 改造异步代码 这一思路,来改造一个常用的ajax方法:

let $get = function(url) {
    return new Promise((resolve, reject) => {
        $.get(url, (data, status) => {
            if(status === 'success') {
                resolve(data);
            } else {
                reject(data);
            }
        });
    })
}

如果,我们有一连串ajax请求a, b, c...,后面的请求依赖于前面请求的结果。

let promise = new Promise((resolve, reject) =>{
    //发起 a 请求
    return $get('/url_a');
}).then((res) => {
    handler_a(res);
    // 拿着 a 请求的结果,发起 b 请求
    return $get('/url_b?name='+res.name);
})
.then((res) => {
    handler_b(res);
    // 拿着 b 请求的结果,发起 c 请求
    return $get('/url_c?name='+res.name);
})
.then((res) => {
    // 处理 c 请求回调
    handler_c(res);
})

把我们的目光从函数的具体实现中抽离出来,将函数压缩成一个函数名,每一个异步函数处理自身业务,多个异步函数 “一” 字排开。

let promise = new Promise(fn_a)
    .then(fn_b)
    .then(fn_c)
    .then(callback_c);

没有对比,就没有伤害。我们来看看传统 ES5 的回调办法。

$.get('/url_a', (data, status) => {
    handler_a(data);
    $.get('/url_b?name='+data.name, (data, status) => {
        handler_b(data);
        $.get('/url_c?name='+data.name, (data, status) => {
            handler_c(data);
        });
    });
});

这种缩进和层层嵌套的方式,非常容易造成上下文代码混乱,我们不得不非常小心翼翼处理内层函数与外层函数的数据,一旦内层函数使用了上层函数的变量,这种混乱程度就会加剧……总之,这种层叠上下文的层层嵌套方式,着实增加了神经的紧张程度。

相比起来,Promisethen()方法很好的隔离了每个异步操作,异步之间只有结果数据的传递,不会有上下文的重叠所产生的干扰。

值得注意的是,对于这种层层嵌套的方式,很多人或文章人云亦云的称之为 “回调地狱”。但个人觉得这种说法真的不够准确,因为回调本身是清晰的,并没有给我们带来理解和阅读上的麻烦,反而是嵌套——嵌套本身,造成了数据、结构层叠的麻烦,所以个人觉得叫做 “嵌套迷宫” 才合适。这篇文章 甚至对这种错误理用语于愤慨。

最后,通过比较,我们知道了Promise的扁平化设计理念,也领略了这种上层设计带来的好处。

错误机制

Promsie的错误处理,是一大亮点,得益于Promsie 出色的设计,让错误处理变得轻松和方便无比。

错误机制的 API 就是reject() 方法和.catch()方法,前者负责发起一个错误、并往下游传递,后者负责捕获传从上游递下来的错误。可以说,它俩共同担当了错误机制的建设。

首先看一段简写的示例:

let promise = new Promise((resolve, reject) => {
    //setup a async operation
    reject('error');
})
.then(resolve, reject)
.catch((error) => {
    // handle the error
})

上述代码,从源头发起的错误,会依次流经.then().catch()。其中错误流经.then(resolve, reject)时,可能出现三种境况:

  • then只提供了reject方法处理回调逻辑,没有提供reject方法处理错误
  • then提供reject方法处理了错误
  • then中的代码执行本身又出了新的错误

三种情况不难用上述代码模拟出结果来,总结起来就是:只要错误被提供的reject方法处理了,下游将不会有这个错误出现;只要存在错误,并且不曾被方法处理,最终都会被.catch()捕获。

Promsie的错误机制初次看起来,规则种种,其实稍加梳理,是非常好理解的。

借助Promsie的错误机制,我们写异步代码时就能规范而统一的处理错误问题,特别在处理复杂异步问题时,不至于被绕晕。

比如,一个异步函数,可能出现的错误大概是这样的:

let $get = function(url) {
    return new Promise((resolve, reject) => {
        $.get(url, (data, status) => {
            if(status === 'success') {
                if(parseDataError) {
                    reject('local parsed error');
                }
                resolve(data);
            } else {
                reject('many kinds of failures');
            }
        });
    })
}

$get('http://myfault.com/')
    .then((data) => {})
    .catch((error) => {
        console.log(error);
    });

一个健壮的程序,是要考虑很多错误的情况的。如果能充分的挖掘错误点,就能让后期 debug 省事不少。上述典型的异步封装,错误点大概有以下:

  • 解析错误:成功后对响应数据的解析出错
  • 请求失败:请求失败的各种情况,归结成一条错误
  • 系统错误:$.get方法或者其使用出现错误

以上种种错误,统统会流到下游,只需一个.catch()方法就能轻松掌握。而且,如果.then()回调中再有错误,那也是回调中的事情,这样能与源头很好的区分开来。

试想,如果有一串异步依次调用,那Promsie的错误机制则更显示出它的优越性;而 “层层嵌套” 那种传统异步回调处理这些错误,将如同制造出一团乱麻出来(哦,是意大利面条……)。

批量异步操作

以上三个话题,都是关于 Promise API 本身的梳理,比较无聊。而本话题——批量异步操作,则是从笔者实际的使用经历中提炼出来的,仍然不得不感叹 Promise API 的设计非常到位。

比如一个复杂的电商网站,一般一个页面会有多个模块,比如A、B、C,每个都是从后台拉取数据然后渲染在页面上的。如果要求三个模块必须同时显示出来(任何一个出错了,都不显示),将怎样做?

《重读 ES6 — Promise 好在哪里》

A、B、C三个模块.png

当然现在的办法很多可以用 React.js 处理模块和数据问题,但在 JQuery + require.js 时代(不远,也就在去年前年),处理批量操作全部完成问题,就得同时发出三个请求,每个请求的回调函数都去做一次check,检查是否三个异步都已完成了。

$.get('url_a', function(data){
    if(checkAllWith('a')) {
        mainRender(data);
    }
})

$.get('url_b', function(data){
    if(checkAllWith('b')) {
        mainRender(data);
    }
})

$.get('url_c', function(data){
    if(checkAllWith('c')) {
        mainRender(data);
    }
})

这样的代码是可以解决问题的——如果三个请求都正常返回了,那么排在最后才响应的那个请求,其检验一定会通过,它的回调就会启动全部的渲染。最后一次性将三个模块同时显示出来。反之,任何一个请求无响应,最后都不会启动主渲染。

传统方法处理起来也并不复杂,但要是碰上 Promise,那就更简单了。对于相关问题,Promise 提供了两个 API 处理它们。

  • promise.all
  • promise.race
// $get 是上文中用过的 $.get() 方法的 promise 封装
// $getB、$getC 依次类推
let $getA = function( ) {
  return $get('a').then(callback_a);
}
let all = Promise.all([$getA, $getB, $getC]);
// 3 个 promse 全部完成后,才会触发回调
all.then((data) => {
  mainRender(data);
})

新旧方法比较起来,相当于Promise.all自己维护了checkAllWith() 方法。

在批量异步竞争问题上,Promise.race 则可以轻松胜任。

在更多复杂异步编程中,Promise.all, Promise.race 它们可以相互配合,最终给出优雅的解决方案。可以毫不夸张的说, Promise 就是异步编程专家。

点赞