老席老席杂货铺
Picture of github

Javascript 之 Promise

Promise只能进行异步操作

var promise = new Promise(function (resolve){
    console.log("inner promise"); // 1
    resolve(42);
});
promise.then(function(value){
    console.log(value); // 3
});
console.log("outer promise"); // 2

从上面的打印结果来看,Promise只会以异步的方式调用回调函数。

原因:

同步调用和异步调用同时存在导致的混乱 - Effective Javascript

  • 绝对不能对异步回调函数(即使在数据已经就绪)进行同步调用。
  • 如果对异步回调函数进行同步调用的话,处理顺序可能会与预期不符,可能带来意料之外的后果。
  • 对异步回调函数进行同步调用,还可能导致栈溢出或异常处理错乱等问题。
  • 如果想在将来某时刻调用异步回调函数的话,可以使用 setTimeout 等异步API。
function onReady(fn) {
    var readyState = document.readyState;
    if (readyState === 'interactive' || readyState === 'complete') {
        fn();
    } else {
        window.addEventListener('DOMContentLoaded', fn);
    }
}
onReady(function () {
    console.log('DOM fully loaded and parsed');
});
console.log('==Starting==');

上面的代码的意图:

  • 如果在调用onReady之前DOM已经载入的话,对回调函数进行同步调用
  • 如果在调用onReady之前DOM还没有载入的话,通过注册 DOMContentLoaded 事件监听器来对回调函数进行异步调用

因此,如果这段代码在源文件中出现的位置不同,在控制台上打印的log消息顺序也会不同。

为了避免同时使用同步、异步调用可能引起的混乱问题,Promise在规范上规定 Promise只能使用异步调用方式

每次调用then都会返回一个新创建的promise对象

之前在使用Angularjs 1.x版本的时候,它内置了一个 $q,它是由Kris Kowal's Q得到的灵感,遵循Promises/A+规范的一个轻量实现。由于当时未深入了解,只是用了用,觉得用起来很舒服,但却没有深入了解,一直以为then回调函数中要手动的返回一个Promise对象,现在想来真的是连基本的使用都不懂,不禁汗颜。所以下面的写法是完全可以的

var bPromise = new Promise(function (resolve) {
    resolve(100);
});
bPromise.then(function (value) {
    return value * 2;
}).then(function (value) {
    return value * 2;
}).then(function (value) {
    console.log("2: " + value); // => 100 * 2 * 2
});

不用手动的return一个Promise的对象,Promise的实现会帮我们自动将值转换为一个Promise对象。 也因为这个原因,所以下面的写法是不好的,或者被称为反模式

then的错误用法

function badAsyncCall() {
  var promise = Promise.resolve();
  promise.then(function() {
    throw new Error("error") // 无法在后续的then中捕获到,浏览器直接报错
  });
  return promise;
}

badAsyncCall().then(result => {
  console.log(result)
}).catch(err => {
  console.log(err)
})
// 打印 undefined
// 浏览器报错

这样写的问题在promise.then中如果出现错误,无法被外部捕获,同时也无法获取then的返回值,即使其由返回值。因为then会返回一个新的promise对象,所以我们可以用下面的写法来改善这种错误

function anAsyncCall() {
  var promise = Promise.resolve();
  return promise.then(function() {
    throw new Error("error")  // 可以在后续的then中捕获到,浏览器正常
  });
}
badAsyncCall().then(result => {
  console.log(result)
}).catch(err => {
  console.log(err)
})
// 打印 Error: error at xxx.js
// 浏览器正常

Promise集合

在开发过程中,我们经常会遇到这样的需求:一个页面有多个异步请求,希望这些请求按照顺序执行。一般遇到这样的情况,我们的做法是将这些请求按照顺序放到一个数组中,然后操作数组来实现。好处就是以后有新的请求要加入,那么我们只需要将对应的Promise按照要求的顺序放入数组中对应的位置就可以了,其他地方的代码不用动。如下面的示例代码:

let promises = []
for (let i = 0; i < 5; i++) {
  promises.push(new Promise(function(resolve, reject) {
    setTimeout(() => {
      console.log("resolve" + i)
      resolve(i)
    }, (5 - i) * 1000) //时间由长到短,为的是查看执行顺序是否是正确的
  }))
}

function execCollectionByOrder(arr) {
  let exec = function(index) {
    if (index >= arr.length) {
      return
    }
    let promise = arr[index]
    return promise.then((result) => {
      console.log(result)
      return exec(index + 1)
    })
  }

  return exec(0)
}
execCollectionByOrder(promises)
// 打印结果
// resolve4
// resolve3
// resolve2
// resolve1
// resolve0
// 0
// 1
// 2
// 3
// 4

看来一切都是正常的。但是这样的写法用到了递归,而且不能清楚的看到调用链(其实我个人觉得这样挺直观^_^)。所以可以改进为下面的方式

function execCollectionByOrder(arr) {
  return arr.reduce(function(promise, item) {
    return promise.then(function(result) {
      console.log(result)
      return item
    });
  }, Promise.resolve(-1));
}

execCollectionByOrder(promises).then(result => {
  console.log(result)
})
// 打印结果
// -1
// resolve4
// resolve3
// resolve2
// resolve1
// resolve0
// 0
// 1
// 2
// 3
// 4

为了让上面的写法看起来更像是顺序执行,我们可以改写为 forEach 的执行方式

let promise = Promise.resolve()
promises.forEach(item => {
  promise = promise.then((value) => {
    return item
  }).then(result => {
    console.log(result)
  })
})

上面的写法是使用Promise.then会返回一个Promise对象的特点,从而做到链式调用

then vs catch

一般来说,.catch 也可以理解为 promise.then(undefined, onRejected),但他们在使用中需要注意一下,以免造成错误

function throwError(value) {
  // 抛出异常
  throw new Error(value);
}
// <1> onRejected不会被调用
function badMain(onRejected) {
  return Promise.resolve(42).then(throwError, onRejected); // 浏览器报错,onRejected没有捕获到throwError里面的错误
}
// <2> 有异常发生时onRejected会被调用
function goodMain(onRejected) {
  return Promise.resolve(42).then(throwError).catch(onRejected);
}
// 运行示例
badMain(function(){
  console.log("BAD");
});
goodMain(function(){
  console.log("GOOD");
});

所以我们需要换一种写法来保证上面所有的错误被捕获

function badMain(onRejected) {
  return Promise.resolve(42).then(throwError).then(null, onRejected)
}

// 用 catch 意图会更明显,代码也更清晰
function badMain(onRejected) {
  return Promise.resolve(42).then(throwError).catch(onRejected)
}

小结

  • 使用 promise.then(onFulfilled, onRejected) 的话
    • onFulfilled 中发生异常的话,在 onRejected 中是捕获不到这个异常的。
  • promise.then(onFulfilled).catch(onRejected) 的情况下
    • then 中产生的异常能在 .catch 中捕获
  • .then.catch 在本质上是没有区别的, 需要分场合使用。

关于这些反模式,详细内容可以参考 Promise Anti-patterns

使用 reject 而不是 throw

Promise 的构造函数,以及被 then 调用执行的函数基本上都可以认为是在 try...catch 代码块中执行的,所以在这些代码中即使使用 throw ,程序本身也不会因为异常而终止。

var promise = new Promise(function(resolve, reject){
    throw new Error("message");
    // reject(new Error("message"));
});
promise.catch(function(error){
    console.error(error); // => "message"
    console.log("bbb")    // 控制台会打印出 bbb,程序继续运行
});

// 改写为下面的方式,意思更清晰,也避免了代码其他类型错误的混淆
var promise = new Promise(function(resolve, reject){
    reject(new Error("message"));
});
promise.catch(function(error){
    console.error(error);// => "message"
})

为了与程序代码中其他错误不混淆,我们可以对代码再封装一下,通过 reject 来控制会更清楚的表达意思

var onRejected = console.error.bind(console);
var promise = Promise.resolve();
promise.then(function () {
    var retPromise = new Promise(function (resolve, reject) {
       reject(new Error("this promise is rejected"));
    });
    return retPromise;
}).catch(onRejected);

// 简化为下面的方式
var onRejected = console.error.bind(console);
var promise = Promise.resolve();
promise.then(function () {
    return Promise.reject(new Error("this promise is rejected"));
}).catch(onRejected);

小结

  • 使用 reject 会比使用 throw 安全
  • then 中使用 reject 的方法

JavaScript 异常和 Promise

由于Promise的try-catch机制,有些代码层面的问题可能会被内部消化掉。 如果在调用的时候每次都无遗漏的进行 catch 处理的话当然最好了,但是如果在实现的过程中出现了下面例子中的错误的话,那么进行错误排除的工作也会变得困难。

这种错误被内部消化的问题也被称为 unhandled rejection ,从字面上看就是在Rejected时没有找到相应处理的意思。

var jsonPromise = new Promise(function(resolve, reject) {
  // JSON.parse throws an error if you feed it some
  // invalid JSON, so this implicitly rejects:
  resolve(JSON.parse("This ain't JSON"));
});

jsonPromise.then(function(data) {
  // This never happens:
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

如果你使用过其他的 Promise 实现类库的话,可能见过用 done 代替 then 的例子。 这些类库都提供了 Promise.prototype.done 方法,使用起来也和 then 一样,但是这个方法并不会返回 promise 对象。 在 Promisedone 是怎么解决上面提到的错误被忽略呢? 其实它的方法很简单直接,那就是必须要进行错误处理

if (typeof Promise.prototype.done === "undefined") {
    Promise.prototype.done = function (onFulfilled, onRejected) {
        this.then(onFulfilled, onRejected).catch(function (error) {
            setTimeout(function () {
                throw error;
            }, 0);
        });
    };
}

那么它是如何将异常抛到 Promise 的外面的呢?其实这里我们利用的是在 setTimeout 中使用 throw 方法,直接将异常抛给了外部。

try {
    setTimeout(function callback() {
        throw new Error("error"); // 这个error不会被catch到,异常会抛向全局然后 `window.onerror` 可以将其捕获
    }, 0);
} catch(error) {
    console.error(error);
}

再看下面的这个对比

function fetch(callback) {
    return new Promise((resolve, reject) => {
        throw Error('用户不存在')
    })
}

fetch().then(result => {
    console.log('请求处理', result) // 永远不会执行
}).catch(error => {
    console.log('请求处理异常', error) // 请求处理异常 用户不存在
})
function fetch(callback) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
             throw Error('用户不存在')
             // reject('用户不存在')  // 请使用 reject 来保证promise可以正常捕获到异常
        })
    })
}

fetch().then(result => {
    console.log('请求处理', result) // 永远不会执行
}).catch(error => {
    console.log('请求处理异常', error) // 永远不会执行
})

// 程序崩溃
// Uncaught Error: 用户不存在

由上面的代码可以看出,在处理异步异常的时候,需要注意处理方式,也更加说明使用 reject 是比 throw 更好的方式

小结

  • 虽然 Promise 有强大的错误处理机制,但是我们在写代码的过程中也要时时关注 catch 中的错误,以免漏掉错误
  • 想要处理异步错误,需要将 try...catch 放到异步回调函数内,例如:将 try...catch 放入 setTimeout 的处理函数内,而不是包装在外面

Deferred vs Promise

先看一下通过 Promise 来实现的 Deferred

function Deferred() {
    this.promise = new Promise(function (resolve, reject) {
        this._resolve = resolve;
        this._reject = reject;
    }.bind(this));
}
Deferred.prototype.resolve = function (value) {
    this._resolve.call(this.promise, value);
};
Deferred.prototype.reject = function (reason) {
    this._reject.call(this.promise, reason);
};
function getURL(URL) {
    var deferred = new Deferred();
    var req = new XMLHttpRequest();
    req.open('GET', URL, true);
    req.onload = function () {
        if (req.status === 200) {
            deferred.resolve(req.responseText);
        } else {
            deferred.reject(new Error(req.statusText));
        }
    };
    req.onerror = function () {
        deferred.reject(new Error(req.statusText));
    };
    req.send();
    return deferred.promise;
}
// 运行示例
var URL = "http://httpbin.org/get";
getURL(URL).then(function onFulfilled(value){
    console.log(value);
}).catch(console.error.bind(console));

由上面的代码可以看出,DeferredPromise 并不是处于竞争的关系,而是 Deferred 内涵了 Promise

在使用 Promise 的时候,需要在构造函数中写入对 resolvereject 的处理,而使用 Deferred 的话,并不需要将处理逻辑写成一大块代码,只需要先创建 deferred 对象,可以在任何时机对 resolve、reject 方法进行调用。

换句话说,Promise 代表了一个对象,这个对象的状态现在还不确定,但是未来一个时间点它的状态要么变为正常值(FulFilled),要么变为异常值(Rejected);而 Deferred 对象表示了一个处理还没有结束的这种事实,在它的处理结束的时候,可以通过 Promise 来取得处理结果。

Promise.race 实现超时处理

function delayPromise(ms) {
    return new Promise(function (resolve) {
        setTimeout(resolve, ms);
    });
}
function timeoutPromise(promise, ms) {
    var timeout = delayPromise(ms).then(function () {
            throw new Error('Operation timed out after ' + ms + ' ms');
        });
    return Promise.race([promise, timeout]);
}
// 运行示例
var taskPromise = new Promise(function(resolve){
    // 随便一些什么处理
    var delay = Math.random() * 2000;
    setTimeout(function(){
        resolve(delay + "ms");
    }, delay);
});
timeoutPromise(taskPromise, 1000).then(function(value){
    console.log("taskPromise在规定时间内结束 : " + value);
}).catch(function(error){
    console.log("发生超时", error);
});

结合 XHR 中的 abort 方法,可以做到 ajax 请求超时取消

Promise API

Promise.resolve

根据接收到的参数不同,返回不同的promise对象。虽然每种情况都会返回promise对象,但是大体来说主要分为下面3类。

  • 接收到 promise 对象参数的时候
    • 返回的还是接收到的 promise 对象
  • 接收到 thenable 类型的对象的时候
    • 返回一个新的 promise 对象,这个对象具有一个 then 方法
  • 接收的参数为其他类型的时候( 包括 JavaScript 对或 null 等 )
    • 返回一个将该对象作为值的新 promise 对象

Promise.reject

  • 即使 Promise.reject 接收到的参数是一个 promise 对象,该函数也还是会返回一个全新的 promise 对象

Promise.race

  • 生成并返回一个新的promise对象。
  • 参数 promise 数组中的任何一个 promise 对象如果变为 resolve 或者 reject 的话, 该函数就会返回,并使用这个promise对象的值进行 resolve 或者 reject

参考链接