Promise
是我最喜欢的es6语法,也是面试中最容易问到的部分。那么怎么做到在使用中得心应手,在面试中脱颖而出呢?
先来个面试题做做:
面试题:用Promise封装一下原生ajax
面试官经常会让手写一个Promise封装,写出下面这一版就行了(想了解更多的可自行扩展):
function ajaxMise(url, method, data, async, timeout) {
var xhr = new XMLHttpRequest()
return new Promise(function (resolve, reject) {
xhr.open(method, url, async);
xhr.timeout = options.timeout;
xhr.onloadend = function () {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304)
resolve(xhr);
else
reject({
errorType: 'status_error',
xhr: xhr
})
}
xhr.send(data);
//错误处理
xhr.onabort = function () {
reject(new Error({
errorType: 'abort_error',
xhr: xhr
}));
}
xhr.ontimeout = function () {
reject({
errorType: 'timeout_error',
xhr: xhr
});
}
xhr.onerror = function () {
reject({
errorType: 'onerror',
xhr: xhr
})
}
})
}
Promise简介
Promise
是一个对象,保存着未来将要结束的事件。她有两个特征,引用阮一峰老师的描述就是:
(1)对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是Promise这个名字的由来,它的英语意思就是“承诺”,表示其他手段无法改变。
(2)一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise对象的状态改变,只有两种可能:从pending变为fulfilled和从pending变为rejected。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为 resolved(已定型)。如果改变已经发生了,你再对Promise对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。
Promise基本用法
let promise1 = new Promise(function (resolve, reject){
setTimeout(function (){
resolve('ok') //将这个promise置为成功态(fulfilled),会触发成功的回调
},1000)
})
promise1.then(fucntion success(val) {
console.log(val) //一秒之后会打印'ok'
})
最简单代码实现一个Promise
class PromiseM {
constructor (process) {
this.status = 'pending'
this.msg = ''
process(this.resolve.bind(this), this.reject.bind(this))
return this
}
resolve (val) {
this.status = 'fulfilled'
this.msg = val
}
reject (err) {
this.status = 'rejected'
this.msg = err
}
then (fufilled, reject) {
if(this.status === 'fulfilled') {
fufilled(this.msg)
}
if(this.status === 'rejected') {
reject(this.msg)
}
}
}
//测试代码
var mm=new PromiseM(function(resolve,reject){
resolve('123');
});
mm.then(function(success){
console.log(success);
},function(){
console.log('fail!');
});
Micro-task / event loop
上面提到
Promise
和事件的不同,除此之外还有一个重要不同,就是
Promise
创建是
micro-task
。再看一道面试题:
面试题:写出下面代码的输出顺序
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function () {
console.log('promise1');
}).then(function () {
console.log('promise2');
});
console.log('script end');
正确答案是:'script start'、'script end'、'promise1'、'promise2'、'setTimeout'。原因就是:
- setTimeout(或者事件)注册的是一个task,由Event Loop控制
-
Promise注册的是一个micro-task
Event Loop
是js的一个重要机制,就是遇到事件或者
setTimeout
等就会把对应的回调函数放入一个事件队列(task queue),等到主程序执行完毕就依次把队列里的函数压入栈中执行。可以参考阮一峰老师的
JavaScript 运行机制详解:再谈Event Loop
,不过貌似老师的网站被攻击还没有恢复。
但是
Promise
不是上面的机制,她创建的是一个微任务(micro-task),
micro-task
的执行总是在当前执行栈结束和下一个
task
执行之前,顺序就是“当前执行栈” -> “micro-task” -> “task queue中取一个回调” -> “micro-task” -> ... (不断消费task queue) -> “micro-task”,总之就是当前执行栈为空时,就到了一个
micro-task
的检查点。
下面是
micro-task
的定义:
Microtasks are usually scheduled for things that should happen straight after the currently executing script, such as reacting to a batch of actions, or to make something async without taking the penalty of a whole new task.
Promise
注册的是
micro-task
,所以上面题目中:主线程中'script start'、'script end'先打印,然后清空微任务队列,'promise1'、'promise2'打印,然后取出
task queue
中的回调执行,'setTimeout'打印。
为什么出现promise
Promise
提供了对js异步编程的新的解决方案,因为我们一直使用的回调函数其实是存在很大问题,只是限制于js的单线程等原因不得不大量书写。当然
Promise
并不是完全摆脱回调,她只是改变了传递回调的位置。那么传统的回调存在什么问题呢?
嵌套
这里所说的嵌套是指大量的回调函数会使得代码难以读懂和修改,试想一个这个场景:让你把下面的url4的调用提到url2之前。你需要非常小心的剪切代码,并且笨拙的粘贴,result4这个参数你还不敢修改,因为这要额外花费很多功夫并且存在风险。
$.ajax('url1',function success(result1){
$.ajax('url2',function success(result2){
$.ajax('url3',function success(result3){
$.ajax('url4',function success(result4){
//……
})
})
})
})
当然,上面的问题有点戏剧成分,现实中极少出现这种难搞的情况。与此相比,回调函数带来的思维上的难以理解是更致命的,因为我们的大脑更喜欢同步的逻辑,这也是为什么
await
关键字那么受欢迎的原因。
我记得有一次我给后端的同学做JS新特性分享的时候,说到
await
关键字,有个人惊呼:“哇!这个不错啊,这就可以像写java一样写代码了”。
信任
除去书写的不优雅和维护的困难以外,回调函数其实还存在信任问题。
事实上回调函数不一定会像你期望的那样被调用。因为控制权不在你的手上。这种问题被称作“控制反转”。例如下面的例子:
$.ajax('xxxxxx',function success(result1){
//比如成功之后我会操作数据库记录结算金额
})
上面是
jQuery
中的
ajax
调用,我们期望在某些事件结束后,让第三方(jQ)帮我们执行我的程序(回调)。
那么,我们和第三方之间并没有一个契约或者规范可以遵循,除非你把你想使用的第三方库通读一遍,保证它做了你想做的事,但事实上你很难确定。即使在自己的代码中,或者自己编写的工具,我们都很难做到百分之百信任。
Promise解决方案
Promise
是一个规范,尝试以一种更加友好的方式书写代码。
Promise
对象接受一个函数作为参数,函数提供两个参数:
-
resolve:将
promise
从未完成切换到成功状态,也就是上面提到的从pending
切换到fufilled
,resolve
可以传递参数,下一级promise
中的成功函数会接收到它 -
reject:将
promise
从未完成切换到失败状态,即从pending
切换到rejected
let promise1 = new Promise(function(reslove, reject){
//reslove或者reject或者出错
})
promise1.then(fufilled, rejected).then().then() //这是伪代码
promise1.then(fufilled, rejected)//可以then多次
function fufilled(data) {
console.log(data)
}
function rejected(e){
console.log(e)
}
正如上面提到的两个特征,一旦状态改变,这个
Promise
就已经完成决议(不会再更改),并且返回一个新的
Promise
,可以链式调用。并且可以注册多个
then
方法,他们同时决议并且互不影响。这种设计明显比回调函数要优雅的多,也更易于理解和维护。那么在信任问题上她又有哪些改善呢?
Promise
通过通知的机制将“控制反转”的关系又“反转”回来。回调是我传递给第三方一个函数,期望它在事件发生时帮我执行,而
Promise
是在大家都遵循规范的前提下,我会在事件发生时得到通知,这时我决定做一些事(执行一些函数)。看到了吧,这是有本质差异的。
此外,回调函数还有以下信任问题,
Promise
也都做了相关约束:
- 回调调用过早
- 回调调用过晚(或者没有调用)
- 调用次数太多
- 没有把参数成功传递给你的回调
- 吐掉了错误或者异常
过早或者过晚
一个
Promise
回调一定会在当前栈执行完毕和下一个异步时机点上调用,即使像下面这样的同步
resolve
代码也会异步执行,而你传给工具库的回调函数却可能被同步执行(调用过早)或者被忘记执行(或者过晚)。