打开Promise的正确姿势
发布在IMWeb社区2016年11月1日view:1957YiksiAssowBrettBat前端工程师
在文章任何区域双击击即可给文章添加【评注】!浮到评注点上可以查看详情。

本文作者:imweb 孙世吉 原文出处:imweb社区

引言

最近实践中需要用到大量的异步回调风格代码的编写,作者最近处于同步编程风格转为异步编程风格的状态。同时第一时间遇到了下面提到的代码,第一直觉就是该代码肯定有问题!但是问题在哪里有讲不出来,感觉非常蛋疼与疑惑。先上当时遇到的代码:

// 删除,先检查是否存在,存在再执行真正的删除动作
function del() {
    // 查找
    return find().then(function(resultOfFind) {
        // 如果没找到,直接返回
        if (!resultOfFind) {
            return false;
        }
        // 执行真正做删除操作的方法
        return reallyDelete();
    }, function(err) {
        //  处理错误情况
        handle(err)
    })
}

function deleteItem(req, res) {
    // 删除
    del().then(function(resultOfDelete) {
        // 判断是没找到或者是真正做删除操作的结果,进行处理
        //  ...
        res(resultOfDelete)
    }, function(err) {
        // 处理错误情况
        // ...
        res(err)
    })
}

上面代码做的事情很简单:
1、删除前检查是否存在;
2、存在则执行删除操作 / 不存在则直接返回;
3、使用删除的结果 / 处理删除产生的错误,返回响应;

你看出来哪里可能出现问题了吗?

OK,这里容作者买一个小小的关子。同学们如果有疑问,那就继续往下阅读吧。
看出问题的大神们也请不吝指教。
让作者为你带来打开Promise的正确姿势,让你使用Promise的时候用的更爽,后人接手你的代码看的更爽,也避免出现莫名其妙的问题而无法对问题进行定位的情况。

目录

1、 Promise基础介绍
2、 Promise与金字塔问题
3、 Promise与循环
4、 resolve(value) VS resolve(promise)
5、 then返回的promise实例
6、 Promise与错误处理
7、 Promise状态透传

1. Promise基础介绍

Promise是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由社区最早提出和实现,ES6将其写进了语言标准,统一了用法,原生提供了Promise对象。
本文所描述的Promise指Promises/A+规范定义的Promise,可参考Promise/A+,一个可靠的可共同协作的JavaScript Promise开放标准

1.1 常见的用法

var promise = new Promise(function(resolve, reject) {
    // ... some code
    if ( /* 异步操作成功 */ ) {
        resolve(value);
    } else {
        reject(error);
    }
});

promise.then(function(value) {
    // success 
}, function(error) {
    // failure 
});

简单来说:
1、Promise构造方法接受一个方法作为参数,该方法传入两个参数,resolve和reject。
2、resolve用来将Promise对象的状态置为成功,并将异步操作结果value作为参数传给成功回调函数。
3、reject用来将Promise对象的状态置为失败,并将异步操作错误error作为参数传给失败回调函数。
4、then方法绑定两个回调函数,第一个用来处理Promise成功状态,第二个用来处理Promise失败状态。

1.2 Promise 状态

Promise对象有三种状态:

  • Pending(进行中)
  • Fulfilled(已完成,又称为Resolved)
  • Rejectd(已失败)


如上图所示,Promise对象有两个特点:
1、对象状态只由异步操作结果决定。resolve方法会使Promise对象由pendding状态变为fulfilled状态;reject方法或者异常会使得Promise对象由pendding状态变为rejected状态。Promise状态变化只有上图这两条路径。
2、对象状态一旦改变,任何时候都能得到这个结果。即状态一旦进入fulfilled或者rejected,promise便不再出现状态变化,同时我们再添加回调会立即得到结果。这点跟事件不一样,事件是发生后再绑定监听,就监听不到了。

举个例子:

var promise = new Promise(function(resolve, reject) {
    resolve('value'); //1
    // reject('error'); //2
    // throw Error('exception'); //3
    // return 'return' //4
});

promise.then(function(value) {
    console.log('fulfilled', value)
}, function(error) {
    console.log('rejected', error)
});
// 上面的代码中1-4处代码的调用分别输出:
// fulfilled value  
// rejected error  
// rejected Error: exception(…)  
// [没有输出]

// 测试状态变更后才绑定事件
// setTimeout(function() {
//     promise.then(function(value) {
//         console.log('fulfilled1', value)
//     })
// }, 1000)

当然也可以参考Promise/A+里面的定义:

2.1.Promise状态

  promise状态为pending, fulfilled和rejected中的其中一种。

  2.1.1.当promise状态为pending时:

    2.1.1.1.promise的状态可以转换为fulfilled或rejected。

  2.1.2.当promise状态为fulfilled时:

    2.1.2.1.无法转换为其他状态。

    2.1.2.2.必须有一个不可改变的值作为onFulfilled事件处理函数的入参

  2.1.3.当promsie状态为rejected时:

    2.1.3.1.无法转换为其他状态。

    2.1.3.2.必须有一个不可改变的值作为onRejected事件处理函数的入参

2. Promise与金字塔问题

金字塔问题指的是我们的程序中如果出现大量的回调任务需要顺序执行时,可能会出现这些代码慢慢向右侧屏幕延伸的问题。通常我们多次异步操作需要依赖上一次异步操作的结果时,我们会这样写。举个例子:

getUserAdmin().then(function(result) {
    if ( /*管理员*/ ) {
        getProjectsWithAdmin().then(function(result) {
            /*根据项目id,获取模块列表*/
            getModules(result.ids).then(function(result) {
                /*根据模块id,获取接口列表*/
                getInterfaces(result.ids).then(function(result) {
                    // ...
                })
            })
        })
    } else {
        //...
    }
})

上面的例子数据有项目-模块-接口这样的层次关系。为了获取接口列表,每一次操作都需要依赖上一个异步操作的结果。你会发现使用顺序调用的逻辑这样写使得代码层次嵌套过深,逻辑不清晰,很难进行阅读。如果我们像使用回调一样使用Promise,虽然结果是正确的,但是这完全没有利用到Promise的优势。

我们应该像下面这样写:

getUserAdmin().then(function(reult) {
    if ( /*管理员*/ ) {
        return getProjectsWithAdmin();
    } else {
        return getProjectsWithUser();
    }
}).then(function(result) {
    /*获取project id列表*/
    return getModules(result.ids);
}).then(function(result) {
    /*获取project id列表*/
    return getInterfaces(result.ids)
}).then(function(result) {
    // ...
})

如果你再将这些操作封装到一个命名函数中,你就会得到下面这样的代码:

getUserAdmin()
    .then(getProjects)
    .then(getModules)
    .then(getInterfaces)
    .then(procResult)

是不是觉得赏心悦目,作者第一次看到这样的代码时简直惊为天人,这简直是在写诗好吗?

这种写法被称为 composing promises ,是 promises 的强大能力之一。这样写可以带来好处:

  • 清晰的代码结构。
  • 避免始料不及的错误。进行快速的问题定位,避免难以调试更甚至于失败了而没有任何反馈。

关于第二点,会在下面错误处理中进行说明。

3. Promise与循环

上面讲到了异步操作的顺序执行,那如果我们需要同时执行一组异步操作呢?
举个例子,我们需要删除指定项目ID下的所有子模块。有的同学可能会这样写:

getModules(projectID).then(function(modules) {
    modules.forEach(function(module) {
        removeModule(module.moduleID);
    })
    // A
}).then(function(result) {
    // ...
})

这里存在一个问题,就是A位置并不会等待所有的removeModule方法结束,而是直接返回undefined,这意味着后面的方法即不会等待删除动作结束也无法获得删除动作的结果,所以你没办法保证删除动作已经完成。 关于在then方法绑定的回调函数中的返回值,我们会在第五节中进行讨论。

那我们怎么保证所有异步操作都成功了呢?

Promise提供了一个很方便的方法叫做Promise.all。我们可以这样做:

getModules(projectID).then(function(modules) {
    var tasks = [];
    modules.forEach(function(module) {
        tasks.push(removeModule(module.moduleID));
    })
    return Promise.all(tasks);// 注意这里一定要有return
}).then(function(result) {
    // ...
})

Promise.all方法用于将多个Promise实例,包装成一个新的Promise实例。
Promise.all方法接受一个数组作为参数,数组里的元素都是Promise对象的实例,如果不是,就会先调用下面讲到的Promise.resolve方法,将参数转为Promise实例,再进一步处理。(Promise.all方法的参数可以不是数组,但必须具有Iterator接口,且返回的每个成员都是Promise实例。)
- 当该数组里的所有Promise实例都进入Fulfilled状态,Promise.all返回的实例才会变成Fulfilled状态。并将Promise实例数组的所有返回值组成一个数组,传递给Promise.all返回实例的回调函数。 - 当该数组里的某个Promise实例都进入Rejected状态,Promise.all返回的实例会立即变成Rejected状态。并将第一个rejected的实例返回值传递给Promise.all返回实例的回调函数。

Promise.race方法跟Promise.all方法差不多。唯一的区别在于该方法返回的Promise实例并不会等待所有Proimse都跑完,而是只要有一个Promise实例改变状态,它就跟着改变状态。并使用第一个改变状态实例的返回值作为返回值。

当然前述的代码还可以更优雅一点:

getModules(projectID).then(function(modules) {
    return Promise.all(modules.map(module) { // 注意这里一定要有return
        return removeModule(module.moduleID); // 注意这里一定要有return
    });
}).then(function(result) {
    // ...
})

4. resolve(value) VS resolve(promise)

我们会在异步操作成功时调用resolve函数,其作用是将Promise对象的状态从Pending变为Resolved,并将异步操作的结果,作为参数传递给Fulfilled状态的回调函数

而我们传入resolve的值实际上并不一定出现在Fulfilled状态的回调函数中,为什么呢?

我们来先看一个使用resolve传递操作结果的例子:

var d = new Date();

var promise = new Promise(function(resolve, reject) {
    // 一秒后进入resolve,并传递值
    setTimeout(resolve, 1000, 'resolve from promise');
});

// 绑定回调函数
promise.then(
    result => console.log('result:', result, new Date() - d),
    error => console.log('error:', error)
)
// result: resolve from promise 1002

大约过了一秒左右,我们可以看到在fulfilled状态的回调方法中,我们打印出了上面注释中的内容。我们能够通过resolve方法传递操作的结果,然后在回调方法中使用这些结果

如果我们在resolve中传入一个Promise实例呢?

var d=new Date();

// 创建一个promise实例,该实例在2秒后进入fulfilled状态
var promise1 = new Promise(function(resolve, reject) {
    setTimeout(resolve, 2000, 'resolve from promise 1');
});

// 创建一个promise实例,该实例在1秒后进入fulfilled状态
var promise2 = new Promise(function(resolve, reject) {
    setTimeout(resolve, 1000, promise1); // resolve(promise1)
});

promise2.then(
    result => console.log('result:', result,new Date()-d),
    error => console.log('error:', error)
)

上面的例子中,你可能觉得再经过一秒左右后会打印出promise1实例。实际上上面的代码最后打印出:

result: resolve from promise 1 2002


OK,我们看一下Promise/A+是怎么说的:
[[Resolve]](promise, x)

2.3.2.如果x是一个promise实例, 则以x的状态作为promise的状态

  2.3.2.1.如果x的状态为pending, 那么promise的状态也为pending, 直到x的状态变化而变化。

  2.3.2.2.如果x的状态为fulfilled, promise的状态也为fulfilled, 并且以x的不可变值作为promise的不可变值。

  2.3.2.3.如果x的状态为rejected, promise的状态也为rejected, 并且以x的不可变原因作为promise的不可变原因。

2.3.4.如果x不是对象或函数,则将promise状态转换为fulfilled并且以x作为promise的不可变值。

简单来说呢,就是因为promise2中调用了resolve(promise1),<u>此时promise1的状态会传递给promise2,或者说promise1的状态决定了promise2的状态</u>。所以当promise1进入fulfilled状态,promise2的状态也变为fulfilled,同时将promise1自己的不可变值作为promise2的不可变值,所以promise2的回调函数打印出了上述结果。promise1进入rejected状态的结果,同学们可以自己试一试。

而当<u>我们resolve(value)的时候就遵循Promise/A+中的2.3.4条规范,将value传递给了fulfilled状态的回调函数</u>。

另外,通过这里例子我们也可以发现。运行时间是2秒而不是3秒。也就是说<u>Promise新建后就会立即执行</u>。

ps:resolve方法传入的实参不限于值类型或者Promise实例。更多内容请参考Promise/A+

5. then返回的promise实例

then方法返回的是一个新的Promise实例
then方法返回的是一个新的Promise实例
then方法返回的是一个新的Promise实例
重要的事情要说三遍。这也是我们能够使用第二节中的链式写法的最重要原因。举个例子:

var d = new Date();

var promise = new Promise(function(resolve, reject) {
    setTimeout(resolve, 1000, 'resolve from promise');
});


var promise2 = promise.then(function(result) {
    console.log(result);
});
//promise.then() 返回的是一个新的Promise实例
promise.tag = '1';
console.log(promise)
console.log(promise2)
// 打印结果:
// Promise {tag: "1", [[PromiseStatus]]: "pending", [[PromiseValue]]: undefined}
// Promise {[[PromiseStatus]]: "pending", [[PromiseValue]]: undefined}
// resolve from promise

当然你可以在同一个Promise实例中多次调用.then绑定回调方法,当该Promise实例状态变化时,将按调用.then的顺序执行回调方法。

那在.then绑定的回调方法onFulfilled和onRejected中,不同的返回值对后续链式有什么影响呢?

5.1 回调方法返回 值

var d = new Date();

var promise1 = new Promise(function(resolve, reject) {
    setTimeout(resolve, 1000, 'resolve from promise1');
    // setTimeout(reject, 1000, 'reject from promise1');
});

var promise2 = promise1.then(function(result) {
    console.log('promise1.then(resolve):', result);
    return result;
}, function(error) {
    console.log('promise1.then(reject):', error);
    return error;
});

promise2.then(
    result => console.log('result:', result, new Date() - d),
    error => console.log('error:', error, new Date() - d)
)

// promise1.then(resolve): resolve from promise1
// result: resolve from promise1 1012

通过运行上面的例子,我们会发现promise的状态无论是fulfilled或者rejected,其绑定的.then方法返回的Promise实例(即promise2)都只会执行它的onFulfilled回调方法。

5.2 回调方法返回 promise实例

var d = new Date();

var promise1 = new Promise(function(resolve, reject) {
    setTimeout(resolve, 1000, 'resolve from promise1');
});

var promise2 = promise1.then(function(result) {
    console.log('promise1.then(resolve):', result);
    return new Promise(function(resolve, reject) {
        setTimeout(resolve, 2000, 'from new promise');
    });
})
promise2.then(
    result => console.log('result:', result, new Date() - d),
    error => console.log('error:', error, new Date() - d)
)
// promise1.then(resolve): resolve from promise1
// result: from new promise 3021

上面的例子中,promise1的回调方法onFulfilled返回的是一个新的promise实例,该实例在2秒后进入fulfilled状态。
运行该例子,我们会发现最后promise2的回调方法近三秒钟后才执行。也就是promise1和promise2的总共运行时间,为什么呢?
同时第二行打印的内容来自于promise2回调方法中返回的新Promise实例,这就是怎么一个过程呢?

Promise/A+规定

2.2.7. then方法必须返回一个promise实例

promise2 = promise1.then(onFulfilled, onRejected);

  2.2.7.1. 如果 onFulfilledonRejected 函数返回值为x,那么执行Promise处理过程 [[Resolve]](promise2, x)

  2.2.7.2. 如果 onFulfilledonRejected 函数抛出异常e,那么promise2将执行 reject(e)

查看其中2.2.7.1的规定,我们会发现在onFulfilled或者onRejected中,无论是return值或者return Promise实例,实际上都是去调用[[Resolve]](promise2, x)

当我们在promise1的回调方法中返回x的时候,相当于调用promise2.resolve(x)

所以结合本文第四节的内容,我们可以就知道 :
x为值的时候,promise2直接进入fulfilled状态,无论promise1的状态是fulfilled或者是rejected,并把x传给onFulfilled回调方法;
x为promise实例的时候,x的状态决定了promise2的状态

5.3 回调方法没有返回语句

如果promise1的回调方法中没有返回语句,那promise2的回调方法中会打印什么内容呢?

var d = new Date();

var promise1 = new Promise(function(resolve, reject) {
    setTimeout(resolve, 1000, 'resolve from promise1');
});

var promise2 = promise1.then(function(result) {
    console.log('promise1.then(resolve):', result);
})

promise2.then(
    result => console.log('result:', result, new Date() - d),
    error => console.log('error:', error, new Date() - d)
)
// promise1.then(resolve): resolve from promise1
// result: undefined 1009

当js函数中没有返回语句的时候,相当于是return undefined。也就是说相当调用了promise2.resolve(x),而这里的x为undefined,所以我们在promise2的回调方法中打印出了undefined。

所以这里作者给的建议是:<u>在回调方法中一定要有return语句,放弃在回调方法中使用return,相当于放弃获取在该回调方法中的所有操作结果</u>

6. Promise与错误处理

.then(onFulfilled,onRejected)
.then传入的第二个回调方法在Promise实例状态变为rejected的时候会被调用,通常用于处理异步操作失败的情况。
让我们再来瞄一眼本文开头的代码:

// 删除,先检查是否存在,存在再执行真正的删除动作
function del() {
    // 查找
    return find().then(function(resultOfFind) {
        // 如果没找到,直接返回
        if (!resultOfFind) {
            return false;
        }
        // 执行真正做删除操作的方法
        return reallyDelete();
    }, function(err) {
        //  处理错误情况 —— A
        handle(err)
    })
}

function deleteItem(req, res) {
    // 删除
    del().then(function(resultOfDelete) {
        // 判断是没找到或者是真正做删除操作的结果,进行处理 —— C
        //  ...
        res(resultOfDelete)
    }, function(err) {
        // 处理错误情况 ——B
        // ...
        res(err)
    })
}

经过上面前五节的讲解,想必你一定可以找出问题:
A位置:如果find方法返回的promise实例如果进入rejected状态,经过handle的处理后,没有明确的将错误返回,或者将该promise实例置为rejected状态。这种情况在第5.3节已经提过了,这里del方法返回的promise实例直接就是fulfilled状态,而且传入的回调参数为undefined,也就是说这边的resultOfDelete为undefined。这可能导致后续的回调函数没办法正常的工作,而且对这种异常情况完全没有做处理。

B位置:通过对A位置的分析,相比你也发现B位置的错误处理实际上并不能很好的处理到promise实例的异常。除了上面的情况下B位置无法处理到来自del方法的rejected状态,当C位置出现错误的时候,B位置的错误处理代码也同样无法处理。

那么我们应该怎么做呢?
首先在A位置,如果你需要在这里对异常或者rejected状态做操作,例如记录系统日志。请考虑后续是否可能会用到del的操作结果,如果存在这种情况(例如上文我们的用法),那么一定要将该状态暴露到下一个promise中。

function del() {
    // 查找
    return find().then(function(resultOfFind) {
        // ...
    }, function(err) {
        handle(err);
        return Promise.reject(err); // 这里不要遗漏return关键字
    })
}

但是这样又可能出现对同一个错误多次处理的情况。
作者这里推荐使用.catch方法

.catch

Promise.prototype.catch方法是.then(null, rejection)的别名,用于指定发生错误时的回调函数。

// 下面两种写法是等价的
somePromise.catch(function(err) {
    //...
})
somePromise.then(null, function(err) {
    //...
})

但是下面两段代码是不等价的

// 1
somePromise.then(function() {
    return someOtherPromise();
}, function(err) {
    //...
})
// 2
somePromise.then(function() {
    return someOtherPromise();
}).catch(function(err) {
    //...
})

为什么呢?举一个例子你可能就明白了:

somePromise.then(function() {
    throw new Error('oh no');
}, function(err) {
    //...
})
somePromise.then(function() {
    throw new Error('oh no');
}).catch(function(err) {
    //...
})

你会发现上一种写法你没办法处理onFulfilled回调函数抛出的异常,而第二种是可以处理这种情况的异常的。所以作者推荐大家都是用catch来处理失败情况,而不是then的第二个参数。你可以在你的promise最后都加上一个catch,以处理你可能没有察觉到的错误情况。
当然有些情况下我们不得不使用then的第二个参数,这时候你就需要注意是否存在别人调用这个方法的可能,并做好错误处理。
所以文章开头出现的问题可以这样解决:

//删除,先检查是否存在,存在再执行真正的删除动作
function del() {
    // 查找
    return find().then(function(resultOfFind) {
        // 如果没找到,直接返回
        if (!resultOfFind) {
            return false;
        }
        // 执行真正做删除操作的方法
        return reallyDelete();
    })
}

function deleteItem(req, res) {
    // 删除
    del().then(function(resultOfDelete) {
        // 判断是没找到或者是真正做删除操作的结果,进行处理
        //  ...
        res(resultOfDelete)
    }).catch(function(err) {
        // 处理错误情况
        // ...
        res(err)
    })
}

7.Promise状态透传

在看上一节内容的时候你可能会有疑问,为什么find.then中没有设置onRejected回调函数对rejected状态进行处理,后面可以使用catch直接捕获之前的结果呢?
我们来先看个例子:

var d = new Date();
// 一秒后进入rejected状态
var promise1 = new Promise(function(resolve, reject) {
    setTimeout(reject, 1000, 'reject from promise1');
});
// 只绑定了onFulfilled回调
var promise2 = promise1.then(result => {
    console.log('promise1.then(resolve):', result);
});
// 绑定了onFulfilled和onRejected。(这里为了演示,正常情况下,建议使用catch处理rejected状态)
promise2.then(
    result => console.log('result:', result, new Date() - d),
    error => console.log('error:', error, new Date() - d)
);

//error: reject from promise1 1004

是的,正如我们所想,promise2的onRjected回调方法正确的处理了来自promise1的rejected状态。
也就是说promise1的rejected状态以及不可变原因都传递给了promise2。

来看Promise/A+是怎么说的:

2.2.7. then方法必须返回一个promise实例

promise2 = promise1.then(onFulfilled, onRejected);

  2.2.7.3. 如果 promise1的 onFulfilled 不是函数,那么promise1的不可变值将传递到promise2并作为promise2的不可变值。

  2.2.7.4. 如果 promise1的 onRejected不是函数,那么promise1的不可变原因将传递到promise2并作为promise2的不可变原因,并作为promise2的 onRejected 的入参。

这就是Promise的状态透传特点,<u>如果当前的promise实例没有绑定回调函数,或者绑定的不是函数,那么当前实例就会把其状态以及不可变值或者不可变原因传递给当前实例调用.then方法返回的新promise实例</u>。
在上述例子中就表现为,promise1把它的不可变原因以及rejected状态传递给了promise2,所以promise2的onRejected回调方法就把promise1中reject的内容打印出来了。
这也是为什么我们在第六节提到可以使用最后的catch捕获之前没有进行处理的rejected状态的原因了。

最后

本文从项目中遇到的代码出发,先是讲解Promise链式调用与循环调用的常见用法,接着结合Promise/A+规范,对Promise使用过程中容易出现的疑点进行了剖析与验证,并解决了项目代码的问题。

请大家记住三点:

  • 回调方法中一定要使用return语句,避免调用者丢失其处理状态与结果。
  • 在promise实例的最后使用catch方法,用来做整体的异常捕获与处理。
  • 利用Promise.then方法对回调函数返回结果的封装,写出清晰漂亮的链式调用代码。

参考资料

Promise/A+
promise对象
关于promises,你理解了多少?

评论
发表评论
暂无评论
WRITTEN BY
IMWeb团队
腾讯 Web 前端 IMWeb团队招聘邮箱:henryguo@tencent.com,官网:http://imweb.io/
TA的新浪微博
PUBLISHED IN
IMWeb社区

腾讯 Web 前端 IMWeb团队招聘邮箱:henryguo@tencent.com,官网:http://imweb.io/

我的收藏