ES6(ECMAScript 6) Promises

JavaScript是单线程的,这意味着不能同时运行两个脚本,只能顺序一个接一个地执行。

在浏览器中JavaScript共享一个线程。JavaScript通常与绘制,更新样式和处理用户操作位于同一队列中。其中一项活动会延误其他事项。这种模式下对用户体验大打折扣。

通常会使用事件和回调来解决这类问题。

var img1 = document.querySelector('.img-1');

img1.addEventListener('load', function() {
  //image loaded
});

img1.addEventListener('error', function() {
  // error
});

或者

var img1 = document.querySelector('.img-1');

function loaded() {
  //加载图像
}

if (img1.complete) {
  loaded();
}
else {
  img1.addEventListener('load', loaded);
}

img1.addEventListener('error', function() {
  // 出错了
});

在这些示例中,监听生效之前发生的错误都没办法捕获,DOM也没有提供这方面的处理方法。这只是加载一张图片,如果处理一组图片事情会变得更复杂。

事件不是万能的

事件非常适合处理在同一个对象和反复多次发生的事情。因为这些事件,并不关心在监听之前发生的事情。但如果谈到异步成功/失败时,理想情况下可能需要这样做:

img1.callThisIfLoadedOrWhenLoaded(function() {
  // loaded
}).orIfFailedCallThis(function() {
  // failed
});

whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
  // all loaded
}).orIfSomeFailedCallThis(function() {
  // one or more failed
});

这就是promise所要做的。如果HTML的image元素有一个“ready”方法,能够返回一个promise:

img1.ready().then(function() {
  // loaded
}, function() {
  // failed
});

Promise.all([img1.ready(), img2.ready()]).then(function() {
  // all loaded
}, function() {
  // one or more failed
});

promises具有事件监听器的基本功能,但具备更多的特性:

promises只能有一次成功或失败,也就是说它不会重复成功或失败,也不能从成功切换到失败,反之亦然。

如果promise收到成功或失败,即便之后加入成功/失败回调函数,这些回调函数也能被正确调用,即使事件发生在回调函数加入之前。

这对处理异步成功/失败非常有用,因为,通常只会对结果感兴趣,对确切的发生时间并不感兴趣。

Promise相关术语

fulfilled:与promise成功事件相关。

rejected:与promise失败事件相关。

pending:尚未完成或被拒绝。

settled:已完成或被拒绝

Promises的由来

Promises以库的形式出现已经有一段时间了,比如:QwhenWinJSRSVP.js,这些libraries与JavaScript中的Promises都遵循Promise/A+的通用标准,也就是说他们的行为具有一定的共同性。

jQuery中类似Promises的对象是Deferreds对象。但是,Deferreds不兼容Promise/A+标准,所以,在使用时应注意到它们之间的差异。jQuery也有一个Promise类型,但它只是Deferreds的子集。

创建promise

虽然promise实现遵循标准化行为,但它们之间的API略有不同。JavaScript中的promises API与RSVP.js类似,以下是创建promise的方式:

var promise = new Promise(function(resolve, reject) {
  // do a thing, possibly async, then…

  if (/* everything turned out fine */) {
    resolve("Stuff worked!");
  }
  else {
    reject(Error("It broke"));
  }
});

promise构造函数接受一个参数,这个参数是一个回调函数,这个回调函数有两个参数,resolve和reject。在回调(可能是async)中执行一些操作,如果一切正常调用resolve,否则调用reject。

以前习惯在JavaScript中抛出异常,使用Error对象来拒绝是一种习惯,但并不是必需。错误对象的好处是能够捕获跟踪堆栈,借助调试工具加快解决问题。

在promise中可以这样做:

promise.then(function(result) {
  console.log(result); // "Stuff worked!"
}, function(err) {
  console.log(err); // Error: "It broke"
});

then函数接受两个参数,一个用于成功回调,另一个用于失败回调。二者都是可选的,因此,可以只为成功或失败添加回调。

JavaScript promise最初在DOM中被称为“future”,后来改名为“promise”,最后直接加入到JavaScript。在JavaScript中使用它们比依赖DOM好,因为,它们可以在非浏览器环境中使用,比如Node.js(能否在核心api中使用它们是另一个问题)。

虽然它们是JavaScript特性,但是不妨碍与DOM之间的协作。事实上,所有使用异步方法的新DOM API都使用了promise。这存在于Quota ManagementFont Load Events, ServiceWorker, Web MIDI, Streams等方面。

支持promises的浏览器

目前支持promises的浏览器:Chrome 32,Opera 19,Firefox 29,Safari 8、Microsoft Edge,默认都启用了promises。

缺少promises支持的浏览器可以添加polyfill(2k gzip)实现支持。它是rsvp.js的一个子集。

与其他promises库的兼容性

JavaScript promise API使用then()方法处理问题,所以,如果使用其他的库(如:Q )返回promise,JavaScript promise也能很好的处理。jQuery Deferreds虽然很棒,但还可以把jQuery Deferreds转成标准的promise:

var jsPromise = Promise.resolve($.ajax('/whatever.json'));

在这里,jQuery $.ajax返回Deferred,由于Deferred也有一个then()方法,所以, Promise.resolve()可以把它变成一个JavaScript promise。但是,有时deferreds也会将多个参数传递给回调函数,例如:

var jqDeferred = $.ajax('/whatever.json');

jqDeferred.then(function(response, statusText, xhrObj) {
  // ...
}, function(xhrObj, textStatus, err) {
  // ...
})

这时JS promise会忽略第一个参数后的所有参数:

jsPromise.then(function(response) {
  // ...
}, function(xhrObj) {
  // ...
})

另外,需要注意jQuery不遵循将错误对象传递给rejections的约定。

Promise简化异步代码

Promise风格的ajax

function get(url) {
  // Return a new promise.
  return new Promise(function(resolve, reject) {
    // Do the usual XHR stuff
    var req = new XMLHttpRequest();
    req.open('GET', url);

    req.onload = function() {
      // This is called even on 404 etc
      // so check the status
      if (req.status == 200) {
        // Resolve the promise with the response text
        resolve(req.response);
      }
      else {
        // Otherwise reject with the status text
        // which will hopefully be a meaningful error
        reject(Error(req.statusText));
      }
    };

    // Handle network errors
    req.onerror = function() {
      reject(Error("Network Error"));
    };

    // Make the request
    req.send();
  });
}

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.error("Failed!", error);
})

promise链式调用

then()函数返回promise,所以可以连续多次调用then函数。

var promise = new Promise(function(resolve, reject) {
  resolve(1);
});

promise.then(function(val) {
  console.log(val); // 1
  return val + 2;
}).then(function(val) {
  console.log(val); // 3
})

通过then函数对ajax回调处理:

get('story.json').then(function(response) {
  return JSON.parse(response);
}).then(function(response) {
  console.log("JSON!", response);
})

由于JSON.parse()只需要一个参数并返回一个转换后的值,所以,可以创建一个快捷方式:

get('story.json').then(JSON.parse).then(function(response) {
  console.log("Yey JSON!", response);
})

也可以创建一个getJSON()函数:

function getJSON(url) {
  return get(url).then(JSON.parse);
}

getJSON() 仍然返回一个promise,它将通过url获取数据,并将数据解析成JSON。

promise有序异步操作

使用链式调用模式多次调用then函数,然后以顺序的方式运行异步操作。

当从第一个then()的回调中得到返回数据后,则使用该值调用下一个then()。但是,如果返回类似promise的东西,则下一个then()会等待它,并在promise成功/失败时被调用。例如:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  console.log("Got chapter 1!", chapter1);
})

在示例这里,首先发出了一个异步请求story.json,然后在回调中又发出了一个请求。简单是promise能够从回调模式中脱颖而出的原因。

Promise实战

接下来通过一个获取文章的需求,理解promise api的应用场景。

var storyPromise;

function getChapter(i) {
  storyPromise = storyPromise || getJSON('story.json');

  return storyPromise.then(function(story) {
    return getJSON(story.chapterUrls[i]);
  })
}

// and using it is simple:
getChapter(0).then(function(chapter) {
  console.log(chapter);
  return getChapter(1);
}).then(function(chapter) {
  console.log(chapter);
})

这种模式下可以重用Promise中的getChapter,只调用一次story.json。

Promise错误处理

正如之前看到的,then()需要两个论点,一个是成功的,一个是失败的:

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.log("Failed!", error);
})

还可以使用catch():

get('story.json').then(function(response) {
  console.log("Success!", response);
}).catch(function(error) {
  console.log("Failed!", error);
})

catch()没有什么特别之处,它只是用于then(undefined, func)函数中的语法糖,增加代码可读性。注意,上面两个示例的行为并不相同,后者相当于:

get('story.json').then(function(response) {
  console.log("Success!", response);
}).then(undefined, function(error) {
  console.log("Failed!", error);
})

这种差别很细微,但非常有用。Promise rejections通过一个rejection回调(或catch())跳到下一个then()。 func1或func2都将被调用,但永远不会同时调用。但是使用then(func1).catch(func2),如果func1 rejects,这两个函数都将被调用,因为它们之间都是独立。考虑以下事项:

asyncThing1().then(function() {
  return asyncThing2();
}).then(function() {
  return asyncThing3();
}).catch(function(err) {
  return asyncRecovery1();
}).then(function() {
  return asyncThing4();
}, function(err) {
  return asyncRecovery2();
}).catch(function(err) {
  console.log("Don't worry about it");
}).then(function() {
  console.log("All done!");
})

上面的流程与普通的JavaScript try / catch非常相似,在“try”中发生的错误会立即转到catch()块。流程图如下所示,蓝线表示Promise 完成,红色表示Promise拒绝。

ec6-primers

JavaScript exceptions与promises exceptions

Rejections在promises中是一种显式拒绝,如果在构造函数回调中抛出一个错误是一种隐式拒绝:

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构造中传入回调函数,在其中执行与promise相关的工作是很有用的,错误会被自动捕获并被拒绝。then()回调中抛出的错误也是如此。

get('/').then(JSON.parse).then(function() {
  // This never happens, '/' is an HTML page, not JSON
  // so JSON.parse throws
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

在实际处理错误时,使用catch显示错误:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  addHtmlToPage(chapter1.html);
}).catch(function() {
  addTextToPage("Failed to show chapter");
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

如果获取story.chapterUrls[0]失败,它将跳过后续所有的成功回调函数,并跳到catch回调,页面会显示“无法显示文章”。就像JavaScript中的try/catch一样,错误被捕获后,后续代码会继续。以下内容为非阻塞异步版本:

try {
  var story = getJSONSync('story.json');
  var chapter1 = getJSONSync(story.chapterUrls[0]);
  addHtmlToPage(chapter1.html);
}
catch (e) {
  addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'

如果只想通过catch()记录日志,而无需从错误中恢复,只需重新抛出错误。

function getJSON(url) {
  return get(url).then(JSON.parse).catch(function(err) {
    console.log("getJSON failed for", url, err);
    throw err;
  });
}

Promise并行和顺序

异步并不容易,下载会被同步并阻塞浏览器。为了使这个工作异步,使用then()函数来让处理单元一个接一个地执行。

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

Promise创建序列

希望将chapterUrls数组转换为一系列Promise。可以这样使用then():

// Start off with a promise that always resolves
var sequence = Promise.resolve();

// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
  // Add these actions to the end of the sequence
  sequence = sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
})

这是看到Promise.resolve(),它创建了一个promise,resolves接收传给它的任何value。如果传递的是一个实例,Promise会直接返回这个实例。如果传递了一个类似Promise的对象(包含一个then()方法),它将创建一个真正的Promise。如果传入其他值,例如Promise.resolve('Hello'),它将创建一个使用该值实现的promise。如果没有传入任何参数,它会用“undefined”来填充。还有Promise.reject(val),它会创建一个Promise,使用传入的值创建一个rejects(或undefined)。

Promise与array.reduce

也可以使用array.reduce整理上述代码 

// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
  // Add these actions to the end of the sequence
  return sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
}, Promise.resolve())

与前一个示例相同,但不需要单独的“sequence”变量。reduce回调函数会为数组中的每一项执行Promise.resolve,对于后续的回调函数,“sequence”是上一个调用返回的值。 array.reduce对于将数组化简为单个值时非常有用。最终的代码如下所示:

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  return story.chapterUrls.reduce(function(sequence, chapterUrl) {
    // Once the last chapter's promise is done…
    return sequence.then(function() {
      // …fetch the next chapter
      return getJSON(chapterUrl);
    }).then(function(chapter) {
      // and add it to the page
      addHtmlToPage(chapter.html);
    });
  }, Promise.resolve());
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

ec6-promise

Promise异步版本

浏览器非常擅长一次下载多个东西,所以一个接一个地下载性能并不高。想要同时下载所有内容使用如下API:

Promise.all(arrayOfPromises).then(function(arrayOfResults) {
  //...
})

Promise.all创建了一个Promise数组,并创造了一个在所有Promise成功完成时执行的Promise,并把结果装入数组中,其顺序与传入的Promise保持一致。

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // Take an array of promises and wait on them all
  return Promise.all(
    // Map our array of chapter urls to
    // an array of chapter json promises
    story.chapterUrls.map(getJSON)
  );
}).then(function(chapters) {
  // Now we have the chapters jsons in order! Loop through…
  chapters.forEach(function(chapter) {
    // …and add to the page
    addHtmlToPage(chapter.html);
  });
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened so far
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

这比逐个加载要快几秒,文章可以按任何顺序下载,但它们以正确的顺序组织后显示在屏幕上。

ec6 promise

但仍然还有提高的空间。当第一部分到达时,将它添加到页面中,用户可以在剩余部分未到达时就开始阅读。当第三部分到来时,不再立即将它添加到页面中,而是等待第二部分到达后,将两部分按顺序添加到页面上。为此,可以同时发出请求,然后创建一个序列,最后将它们添加到dom中:

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // Map our array of chapter urls to
  // an array of chapter json promises.
  // This makes sure they all download in parallel.
  return story.chapterUrls.map(getJSON)
    .reduce(function(sequence, chapterPromise) {
      // Use reduce to chain the promises together,
      // adding content to the page for each chapter
      return sequence.then(function() {
        // Wait for everything in the sequence so far,
        // then wait for this chapter to arrive.
        return chapterPromise;
      }).then(function(chapter) {
        addHtmlToPage(chapter.html);
      });
    }, Promise.resolve());
}).then(function() {
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

这样做的好处就是,用第一部分数据,争取最佳用户体验。后续的数据以最佳性能进行合理安排。

ec6 promise

ES6生成器与promises

对于ES6(ECMAScript 6 )这些只是预告片,想看真正的大片还得再了解一点其他的特性,如生成器。

ES6 生成器允许函数在特定点退出,如“return”,稍后从同一点恢复,例如:

function *addGenerator() {
  var i = 0;
  while (true) {
    i += yield i;
  }
}

注意函数名称前面的星号,这是生成器与普通函数的区别。yield关键字是返回/恢复点,可以像这样使用它:

var adder = addGenerator();
adder.next().value; // 0
adder.next(5).value; // 5
adder.next(5).value; // 10
adder.next(5).value; // 15
adder.next(50).value; // 65

这对promises意味着什么呢?使用ES6生成器特性,可以让编写异步代码如同编写同步代码一般简单。下面示例是常见写法,与利用yield等待promises之间的区别。

function spawn(generatorFunc) {
  function continuer(verb, arg) {
    var result;
    try {
      result = generator[verb](arg);
    } catch (err) {
      return Promise.reject(err);
    }
    if (result.done) {
      return result.value;
    } else {
      return Promise.resolve(result.value).then(onFulfilled, onRejected);
    }
  }
  var generator = generatorFunc();
  var onFulfilled = continuer.bind(continuer, "next");
  var onRejected = continuer.bind(continuer, "throw");
  return onFulfilled();
}

spawn(function *() {
  try {
    // 'yield' effectively does an async wait,
    // returning the result of the promise
    let story = yield getJSON('story.json');
    addHtmlToPage(story.heading);

    // Map our array of chapter urls to
    // an array of chapter json promises.
    // This makes sure they all download in parallel.
    let chapterPromises = story.chapterUrls.map(getJSON);

    for (let chapterPromise of chapterPromises) {
      // Wait for each chapter to be ready, then add it to the page
      let chapter = yield chapterPromise;
      addHtmlToPage(chapter.html);
    }

    addTextToPage("All done");
  }
  catch (err) {
    // try/catch just works, rejected promises are thrown here
    addTextToPage("Argh, broken: " + err.message);
  }
  document.querySelector('.spinner').style.display = 'none';
})