33-js-concepts之12. Promise

ren渣翻谷歌 Web上面的promise文章的部分(简体中文版本太可怕了看不下去惹):
JavaScript Promises: an Introduction

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的回调函数。在回调函数中做一些事情,比如异步操作,如果成功了就调用resolve,否则调用reject。reject参数可以是Error对象,好处是error会捕捉stack trace,对debug工具更有用处。

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

then()方法接收两个回调函数作为参数,一个成功时调用,一个失败时调用,两个参数都是可选的,你可以只传入一个。

让复杂的代码变简单

假设我们想:

  1. Start a spinner to indicate loading
  2. Fetch some JSON for a story, which gives us the title, and urls for each chapter
  3. Add title to the page
  4. Fetch each chapter
  5. Add the story to the page
  6. Stop the spinner
    … 还要告诉用户在这个过程中是否有什么出错了。我们也想让spinner在那个时候停止,否则它会一直旋转,和其他UI产生混乱。

让我们先从从网络中获取数据开始。

将XHR promise化

写一个get请求吧:

function get(url) {
  return new Promise(function(resolve, reject) {
    const req = new XMLHttpRequest();
    req.open('GET', url);
    req.onreadystatechange = function() {
      if (req.readyState === 4) {
        // This is called even on 404 etc, so check the status
        if (req.status === 200) {
          resolve(req.response);
        } else {
          reject(Error(req.statusText));
        }
      }
    }
    req.onerror = function() {
      reject(new Error('network error'));
    }
    req.send();
  }); 
}

现在我们来使用这个函数:

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

现在就不需要再反复手写XHR了。

Chaining

then()不是故事的终点。你可以把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
})

回到上面获取json的promise:

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

我们获得的响应是json,但这里最终获得的是普通文本。可以在get函数中修改JSON的responseType,但也可以在这个promise里修改:

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

由于JSON.parse()接收一个参数并返回一个转换好的值,可以更简便地书写:

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

进一步,可以把这个过程封装成一个获取JSON响应的函数:

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

getJSON依旧返回一个promise,这个promise接收一个url并将获得的response转换成JSON。

排列异步action

你也可以串联then()来执行一步操作。在then()函数中带return语句时,事情变得有些微妙惹。如果返回的是一个值,下一个then函数就会得到这个值作为参数;如果返回的是一个promise,下一个then函数会在这个promise完成(成功or失败)后开始执行。例如:

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

这里获得了story的章节链接后,又去请求该story第一章链接的数据。这里的应用就是promise突出于其他回调模式的原因。我们可以写一个获取章节的简便函数:

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);
})

当我们需要获取chapter的时候才会去请求获取story,而获取story只需要请求一次,再次请求chapter的时候storyPromise可以重复利用。Promise牛逼!

错误处理

除了上面then()接收两个参数,第二个参数作为错误处理以外,还可以使用catch():

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

两者区别很细微,但却非常有用处。Promise reject带着一个reject回调函数跳到下一个then()(或者catch())。then(func1, func2)的写法,func1或者func2会被调用,但不会同时都被调用。而then(func1).catch(func2)的写法,则如果func1 reject,func1和func2都会被调用, 因为它们在调用链中是分开的步骤。看下面的代码:

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!");
})
33-js-concepts之12. Promise_第1张图片
流程图,蓝色路线表示fufilled,红色表示rejected

promise还有一个好处是,显式地reject或者在代码执行中遇到的异常都会被自动捕获,变成rejection:

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);
})

还比如:

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);
})

回到story和chapter的应用,错误处理可以这样写:

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';
})

这样我们就能成功获取一个章节了。但我们想要的是所有章节。怎么办呢?

Parallelism and Sequencing的结合

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';
})

如何循环有序获取每个章节呢?forEach不支持异步,因此无法按照原有顺序,会按照下载顺序出现isn't async-aware, so our chapters would appear in whatever order they download, which is basically how Pulp Fiction was written. This isn't Pulp Fiction, so let's fix it.

创造一个序列

我们想将chapterUrls数组转化成promise序列

// 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()会创建一个resolver任何传进去的值的promise。如果你传一个promise实例,它会直接返回这个实例(注:这个对规范的改变可能还不被一些实现允许)。如果你传一个类似promise的(有then()方法的)东西进去,它会创建一个真的Promise,这个promise以相同的方式fulfill或者reject。如果你传任何其他的值,例如 Promise.resolve('Hello'), 它会创建一个以那个值fulfill的promise。如果你像上面那样不传递值,它以“undefined”来fulfill。
你也可以Promise.reject(val),这样会创建一个reject了的promise,带有你传递的值或者是undefined。

我们可以用JavaScript数组的reduce方法来优化上面的代码:

story.chapterUrls.reduce(function(sequence, chapterUrl) {
  return sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
},Promise.resolve());

这和上个代码做的事情一样,但是不需要单独的sequence变量了。reduce回调为数组中的每个元素所调用。"sequence"第一回是Promise.resolve() , 但剩余的sequence是我们从上个调用中返回的。array.reduce用于将一个数组列成一个单独的值是非常有用的,在这里就是一个promise。

将代码整合到一起就是下面这样。每获取到一个章节就添加到页面上,显示的效果就是章节逐个显示。

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';
})

其实浏览器很擅长一次下载多个资源,因此上面的做法会对性能有损耗。我们可以把它们同时下载,然后在全部下载完成后处理它们。幸好对此有专门的API:

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

Promise.all接收promise数组并在所有promise都成功完成之后创建一个fulfill的promise。你会获得一个结果数组,里面的元素就是promise fulfill得到的,顺序和传入的promise的顺序相同。这样显示的效果就是加载一段时间后所有章节的内容一次过全部显示出来。

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);
  return Promise.all(story.chapterUrls.map(getJSON));
}).then(function(chapters) {
  chapters.forEach(function(chapter) {
    addHtmlToPage(chapter.html);
  });
  addTextToPage('All done!');
}).catch(function(err) {
  addTextToPage('Argh, broken: ' + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
});

然而,我们依旧可以提升感知上的性能。上面的做法是下载完所有内容再一次全部添加到页面中。而当第一章下载完成时我们应该就将它添加到页面。这让用户可以在剩余章节被加载完成之前就开始阅读。当第三章加载完成时,我们不应该将它添加到页面当中,因为用户可能没有意识到第二章不见了。然后当第二章也加载完成时,我们再将第二三章都添加到页面中。

要实现这个,我们同时去获取所有章节的JSON,然后创建一个将它们添加到页面的序列:

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, chaperPromise) {
    // 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(chaper.html)
    });
  }, Promise.resolve());
}).then(fucntion() {
  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';
});

这是坠吼滴!下载的时间一样,但是用户获得第一部分内容的速度更快了。
在这个小栗子中,所有章节几乎是在同一时间到达的,但是一次展示一段的好处会在更多更大章节中有所放大。

Bonus: Promise和Generator

ES6的新特征还有generator,允许函数在某个特定的位置退出,就像‘return’。但是稍后可以从相同的位置继续开始。例如:

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

注意函数名前的星星符号。这就表明这是一个generator。yield就是return/resume点。我们可以这样用:

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

但这对promise意味着什么呢?唔,你可以用这种return/resume操作来写看起来像同步代码的异步代码。不用太担心逐行理解,不过这里有一个helper函数来让我们使用yield来等待promise完成(说实话没看懂):

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();
}

有了这个,我们可以写出最佳代码,混合一些新的ES6特性,变成这样:

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';
})

这里包含了ES6的一些新特性: promises, generators, let, for-of. 当我们yield一个 promise, spawn helper 等待这个promise完成并返回最终的值 。如果promise reject了, spawn让我们的yield语句抛出一个异常,而这个异常可以通过普通的JS try/catch捕获。牛掰的简单异步编码!

这个模式很有用,在ES7中的async/await就能简单实现,不再需要spawn方法。

你可能感兴趣的:(33-js-concepts之12. Promise)