JavaScript: Promise

 20th August 2020 at 2:19pm

Why Promise?

Promise 的出现是为了解决异步编程中,主要使用的回调机制的几个问题:

  • Callback hell:Promise 可以把一层层嵌套的 callback 变成 .then().then()...,从而使代码编写和阅读更直观
  • 错误处理难:Promise 比 callback 在错误处理上更清晰直观
  • 同时进行多个异步操作的代码编写困难:Promise 可以简单处理此情况

Google 的这篇 文档 写得非常好。但是它引入 Promise 用的 img.ready() 让我觉得没有道理,毕竟 Promise 函数更像是你告诉它开始干活(所以应该是 img.display())而不应该像事件一样的 img.ready()

目前看浏览器环境中,使用 Promise 的场景主要就是发异步请求。

Promise 的重点在于:

  • 它是如何解决 callback hell,并提供直观的 API 和机制去让异步代码编写变得简单
    • 在此基础上,它怎样让控制流变得清晰(比如多个 promise 链在一起时,发生错误时应该怎样流转)
  • 有哪些常见的问题场景以及相应的解决方式(即是 pattern)

What's a Promise

一个 Promise 对象中定义的主要是一段执行具体操作的代码,并且在这段代码中,会执行两个回调函数,一个表示操作成功(resolve),一个表示操作失败(reject),比如下面这段发起 AJAX 请求的代码(关注 new Promise() 开始那部分):

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

Promise 构造函数中的参数被称为 executor。我们在后面还会提到它。

调用 get(url) 函数会生成一个 Promise 实例。当调用这个实例的 .then() 方法时,Promise 中定义的代码会被开始被执行。一个 Promise 实例有这几个状态:

  • pending:未确定状态。刚 new 出来的 Promise 处于这个状态;然后会马上执行 executor 中定义的语句
  • resolved:代码执行到 resolve() 语句后
  • rejected:代码执行到 reject() 语句后
  • settled:resolved 或者 rejected 状态都属于 settled

实例调用的代码如下:

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

.then() 可以接受两个参数,第一个是执行成功后的回调函数,第二个是失败后的回调函数。executor 中调用 resolve() 或者 rejected() 时传的参数,会被这两个回调函数获得。

如何解决 callback hell?

.then() 看起来并没有绕开 callback 的模式。那 Promise 是如何解决 callback hell 的?答案就在 Promise 的 chaining。

.then() 的参数——即这两个回调函数,可以是多种类型的:

  • 没有返回值的函数,会使得 Promise 链不再延续,此时你再往后面调用 .then() 是没有作用的。比如:
    Promise.resolve('foo').then(function(s) {
      console.log(s);
    }).then(function(s) {
      // Never executed
      console.log(s);
    });
    
    // Output:
    // foo
  • 有返回值的函数,会使 Promise 链可以继续,如:
    Promise.resolve('foo').then(function(s) {
      console.log(s);
      return s + 'bar';
    }).then(function(s) {
      console.log(s);
    });
    
    // Output:
    // foo
    // foobar
  • 有返回值且返回值为另一个 Promise 对象的函数,也会使 Promise 继续。与前者的区别在于,再次调用 .then() 时可能会触发的是异步操作,因此不是马上触发下一轮 resolve()
    Promise.resolve('foo').then(function(s) {
      return new Promise((resolve, reject) => {
    	  console.log(s);
    	  setTimeout(() => {
    		  resolve(s + 'bar')
    		}, 1000);
    	});
    }).then(function(s) {
      console.log(s);
    });
    
    // Output:
    // foo
    // foobar (1s after displaying "foo")

A Realworld Example

// Including the `get()` function above...
function getJSON(url) {
  return get(url).then(JSON.parse);
}

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

Control Flow (and error handling)

当调用 .then() 时同时提供了两个参数,那么只会有其中一个被调用:

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

如果是 .then(resolve_func).catch(reject_func) 的形式:

get('story.json').then(function(response) {
  a + 1   // throws ReferenceError
}).catch(function(error) {
  console.log("Failed!", error);
})

那么在 get('story.json') 以及 catch 之前的 resolve 函数中的 a + 1 报错都会被捕捉。

Promise 定义的规则是,错误只会往后抛,直到遇到下一个 .catch 或者带 reject 函数的 .then。Google 的文档中提供了一个很好的例子:

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

执行流为:

Promise in Practice

Promise 代码编写起来,相对同步代码有两个挑战。

数据传递

由于 Promise 代码的异步特性,以及大量使用函数,因此变量数据的传递和共享变得复杂。

设想有这样的同步代码:

let story = getJsonSync('story.json');
addHtmlToPage(story.heading);

for (let chapterUrl of story.chapterUrls) {
  let chapter = getJsonSync(chapterUrl);
	addHtmlToPage(chapter.html);
}

如果要写成异步,就会比较麻烦。因为你需要把各个 Promise 串起来,这其中数据的作用域只在各自的 resolve 函数中。你可以通过几种方式共享数据:

  • 嵌套 Promise,即是在一个 resolve 函数中再去新建 Promise 并调用 .then()
    getJson('story.json').then(story => {
      addHtmlToPage(story.heading);
      return story.chapterUrls.reduce((sequence, chapterUrl) => {
    		// 注意这里应该是 sequence.then() 打头。不然整条 Promise 链不起来。实际模拟一把 reduce 过程可以理解
        return sequence.then(() => {
          return getJson(chapterUrl);
        }).then(chapter => {
          addHtmlToPage(chapter.html);
        })
      }, Promise.resolve());
    })
  • 将数据通过 resolve(data) 或者在 executor 中返回数据来传递给下一环:
    getJson('story.json').then(story => {
      addHtmlToPage(story.heading);
      return story.chapterUrls;
    }).then(chapterUrls => {
      chapterUrls.reduce((sequence, chapterUrl) => {
        return sequence.then(() => {
          return getJson(chapterUrl);
        }).then(chapter => {
          addHtmlToPage(chapter.html);
        })
      }, Promise.resolve());
    });
  • 在 Promise 代码外设置变量用于传递数据:
    // 这个例子比较啰嗦,仅供说明做法
    let chapterContents = [];
        getJson('story.json').then(story => {
          addHtmlToPage(story.heading);
          return story.chapterUrls.reduce((sequence, chapterUrl) => {
            return sequence.then(() => {
              return getJson(chapterUrl);
            }).then(chapter => {
              chapterContents.push(chapter.html)
            })
          }, Promise.resolve());
        }).then(() => {
          for (let chapterContent of chapterContents) {
            addHtmlToPage(chapterContent);
          }
        })

Promise.resolve() 的作用,类似于累加中的初始值 0,或者累乘的初始值 1。

同步执行多事件的完成时间的不一致性

Promise 代码中,特别值得注意的一点是,Promise 的 executor 在 Promise 对象被创建时就被执行了。因此如果想实现同时做多任务,但是对任务结果的处理是按顺序来的,需要一定的技巧。

下面演示如何同时下载多个章节内容,但是按顺序将最快获取到的章节显示出来。效果是:

拉取到章节的顺序21354
显示内容不显示显示 1, 2添加 3不添加添加 4, 5

代码如下:

function parallel_download_and_display_immediately() {
  getJson('story.json').then(story => {
    addHtmlToPage(story.heading);
    // 1. 创建拉取各章节的 Promise 对象,使得实际拉取动作开始
    // 2. 使用 Array.reduce pattern 把各 Promise 对象的 `.then()` 串起来,
    //    实现章节拉取完成的结果是被顺序处理的
    return story.chapterUrls.map(getJson).reduce((sequence, chapterPromise) => {
      return sequence.then(() => chapterPromise).then(chapter => {
        console.log(`Add chapter ${chapter.chapter}`)
        addHtmlToPage(chapter.html);
      });
    }, Promise.resolve())
  }).catch(() => {
    addTextToPage(`Error occurred. ${err.message}`);
  }).then(() => {
    document.querySelector('.spinner').style.display = 'none';
  });
}

Resources