36

JavaScript异步之从回调函数到Promise

 5 years ago
source link: http://developer.51cto.com/art/201811/587816.htm?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

QruEFnA.gif

【51CTO.com原创稿件】 JavaScript的异步处理是前端工程师必须接触的一块内容。ES6在JavaScript异步的处理上引入了新的特性,使得程序员能够更加优雅地处理异步问题。

若您想通过本教程直接上手Promise,那么请按顺序阅读。

若您只是想了解Promise概念,那么请直接阅读每章的第一小节,等需要的时候,再回过头来看具体的例子,从而不至于浪费您太多时间。

1.基于回调函数

1.1.异步动作与回调函数

在JavaScript中往往需要处理很多异步动作(asynchronous actions),如后台请求某个dashboard上的显示数据、响应一条定时信息。异步动作的执行不会阻塞其他动作,且在执行完成之后,由回调函数(callback)处理异步动作的结果。

假如你想载入一个JavaScript 脚本,并在脚本载入完毕之后调用一个回调函数来完成载入之后的操作。代码片1.1-1实现了这样一个异步函数loadScript。

代码片1.1-1

//src代表JavaScript 脚本的URL,callback代表自定义回调函数 
function loadScript(src, callback) { 
  let script = document.createElement('script'); 
  script.src = src;  
  script.onload = () => callback(script);  
  document.head.append(script); 
}  
 loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', script => { 
  alert(`Cool, the ${script.src} is loaded`); 
  alert( _ ); // function declared in the loaded script 
});  

最后一行loadScript的调用可以读作:异步函数loadScript载入脚本,并在载入执行完毕后调用毁掉函数弹出提示框。

1.2.异步动作的顺序执行

很多场景下,往往需要依次执行多个异步动作(上一个异步动作结束之后才能执行下一个)。通过结合回调函数,可以写成一个“嵌套”的异步函数,如下1.2-1。

代码片1.2-1

loadScript('/my/script.js', function(script) {  
  loadScript('/my/script2.js', function(script) {  
    loadScript('/my/script3.js', function(script) { 
      // ...continue after all scripts are loaded 
    });  
  })  
});  

1.3.异步动作的异常处理

在实际场景中,还需要根据异步函数的执行状态(正常或者异常)来执行不同的回调函数。代码片1.3-1是代码片1.1-1中的改进版本,通过增加对onerror事件的响应,异步动作抛出的异常能由用户提供的函数来接管。此时要注意的是,这里的回调函数与前面不同,它是形式为function(error,script)的函数。

代码片1.3-1

function loadScript(src, callback) { 
  let script = document.createElement('script'); 
  script.src = src; 
 
  script.onload = () => callback(, script); // 1 
  script.onerror = () => callback(new Error(`Script load error for ${src}`)); // 2 
 
  document.head.append(script); 
} 
 
loadScript('/my/script.js', function(error, script) { 
  if (error) { 
    // handle error 
  } else { 
    // script loaded successfully 
  } 
});  

这里loadScript的调用可以读作:异步函数loadScript载入脚本,并在脚本载入执行失败后调用2,在脚本载入执行成功后调用1。

1.4. 异步动作带来的问题——恶魔金字塔

结合1.2和1.3,可以得到一个包含异常处理和多个异步动作顺序执行的例子,如代码片1.4-1所示。

代码片1.4-1

loadScript('1.js', function(error, script) {  
  if (error) { 
    handleError(error); 
  } else { 
    // ... 
    loadScript('2.js', function(error, script) { 
      if (error) { 
        handleError(error); 
      } else { 
        // ... 
        loadScript('3.js', function(error, script) { 
          if (error) { 
            handleError(error); 
          } else { 
            // ...continue after all scripts are loaded (*) 
          } 
        }); 
 
      } 
    }) 
  } 
});  

可以看到,随着嵌套的深入,从左往右看代码就形成了一个金字塔结构的嵌套。这样得到的代码非常不利于维护和拓展,因此也被称为恶魔金字塔(Pyramid of doom)。

代码片1.4-2解决了恶魔金字塔的问题,但也引入了可读性和命名空间的问题,因此不算一个优雅的解决方案。

代码片1.4-2

loadScript('1.js', step1);  
function step1(error, script) { 
  if (error) { 
    handleError(error); 
  } else { 
    // ... 
    loadScript('2.js', step2); 
  } 
} 
 
function step2(error, script) { 
  if (error) { 
    handleError(error); 
  } else { 
    // ... 
    loadScript('3.js', step3); 
  } 
} 
 
function step3(error, script) { 
  if (error) { 
    handleError(error); 
  } else { 
    // ...continue after all scripts are loaded (*) 
  } 
};  

2.基于Promise

2.1.Promise是什么

Promise是为了解决回调函数的一些缺陷而在ES6中定义的异步解决方案,它的订阅模式与链式表达式能让开发者更加方便的定义自己的异步动作。

为了更好的理解Promise想要解决的问题,可以想象这样一个场景:想象你是一个知名歌手,你的粉丝问你单曲发售的消息。你让他们订阅你的消息,这样在你准备好专辑之后,就有专人负责通知你的粉丝,让他们获取关于单曲的信息,好让他们购买专辑并推荐给身边的朋友。

这里”歌手发布一首单曲”就是一个异步动作的生产代码(producing code)(实际中可能是向服务器请求一条数据),“粉丝接受单曲发售的通知,然后购买专辑并推荐给身边的朋友”,这一动作就是消费代码(consuming code)(类似回调函数),而连接两者的“专人”就是Promise。

Promise是一个JavaScript对象,它将生产代码和消费代码联系起来,从而在生产代码完成异步动作后,订阅异步动作的消费代码就能获取结果(假如初次接触Promise,到这至少已经理解一半了。但想了解如何使用Promise或者想阅读Promise相关的代码,你还得继续)。

2.2.生成一个Promise对象

根据2.1可知,Promise起到的就是“桥接”生产代码和消费代码的作用。Promise对象通过传入一个执行器(executor)执行生产代码,消费代码通过.then和.catch方法订阅结果(生产代码的结果可能是正常的返回值也可能是一个异常)。理解了生产代码的传入和消费代码如何订阅结果,也就明白了Promise的用法。

2.2.1.生产代码

Promise对象通过传入一个执行器(executor)执行生产代码。执行器是形式为function(resolve, reject)的函数,它包含了异步动作的生产代码。执行器会在Promise对象创建的时候自动执行。当执行器执行完成任务之后,会调用resolve(解析)来接受异步动作正常执行完毕的结果,调用reject(拒绝)来接受一个在异步动作中抛出的异常(Error)。

这样可能还是不够直观,那就看看代码片2.2.1-1,它利用Promise改造了代码片1.3-1。onload(表示脚本正常载入完毕)和onerror(载入过程中抛出异常)两个异步状态分别执行了resolve和reject方法,分别接受一个DOM对象和Error对象。若生产代码调用resolve解析,则Promise会把DOM对象作为结果通知给消费代码;反之若调用reject方法,则Promise把Error对象作为结果通知给消费代码。

代码片2.2.1-1

function loadScript(src) { 
  return new Promise(function(resolve, reject) { 
    let script = document.createElement('script'); 
    script.src = src; 
 
    script.onload = () => resolve(script); 
    script.onerror = () => reject(new Error("Script load error: " + src)); 
 
    document.head.append(script); 
  }); 
}  

Promise如何能够得知一个异步状态?这是因为Promise对象维护了两个重要内部属性:

  • state(状态) :初始是“pending”,执行完毕之后变化成“fulfilled”或者“rejected”。
  • result(结果):异步动作的结果值。可以任意指定,默认是undefined。

当调用resolve时设置state为fulfilled,并把result作为参数传给resolve;当调用reject时设置state为rejected,并把result作为参数传给rejected。从逻辑上来看,rejected和resolve可以看做是异步动作结果的”容器”,一旦state改变,Promise就从“容器”中取出result并通知消费代码处理。

2.2.2.消费代码

.then和.catch方法可以使消费代码能够接受Promise对象发送的消息,订阅生产代码的结果。

2.2.2.1..then方法

.then方法的强大之处在于它的灵活性,可以定义两个函数接受分别接受resolve和reject返回的结果。代码片2.2.2.1-1和代码片2.2.2.1-2分别反映了.then方法对resolve和reject结果的不同的响应。

代码片2.2.2.1-1

let promise = new Promise(function(resolve, reject) { 
  setTimeout(() => resolve("done!"), 1000); 
}); 
 
// resolve runs the first function in .then 
promise.then( 
  result => alert(result), // shows "done!" after 1 second 
  error => alert(error) // doesn't run 
);  

代码片2.2.2.1-2

let promise = new Promise(function(resolve, reject) { 
  setTimeout(() => reject(new Error("Whoops!")), 1000); 
}); 
 
// reject runs the second function in .then 
promise.then( 
  result => alert(result), // doesn't run 
  error => alert(error) // shows "Error: Whoops!" after 1 second 
);  

若.then方法只传入了一个参数,那么默认消费代码只订阅resovle接受的结果,如代码片2.2.2.1-3所示。

代码片2.2.2.1-3

let promise = new Promise(resolve => { 
  setTimeout(() => resolve("done!"), 1000); 
}); 
 
promise.then(alert); // shows "done!" after 1 second  

2.2.2.2..catch方法

若消费代码想单独捕获异常(订阅异常结果),可以考虑使用.catch。.catch是.then(null,alert)的一个快捷方式。代码片2.2.2.2-1是这两种的实现方式的例子。

代码片2.2.2.2-1

let promise = new Promise((resolve, reject) => { 
  setTimeout(() => reject(new Error("Whoops!")), 1000); 
}); 
 
// .catch(f) is the same as promise.then(null, f) 
promise.catch(alert); // shows "Error: Whoops!" after 1 second 
 
// .catch(f) is the same as promise.then(null, f) 
promise.then(,alert); // shows "Error: Whoops!" after 1 second  

2.3.使用Promise需要注意的一些细节

2.3.1.一个执行器只会执行一次resolve或者reject

在代码片2.3.1-1的执行器中,除了第一个resolve之外的其他resolve或者reject都会被忽略。这两个方法中的额外参数也会被忽略。

代码片2.3.1-1

let promise = new Promise(function(resolve, reject) { 
  resolve("done"); 
 
  reject(new Error("…")); // ignored 
  setTimeout(() => resolve("…")); // ignored 
});  

2.3.2.使用Error对象或者继承自Error类的对象作为reject的参数

这是一个好的实践,这样能对异常进行更好的处理(比如针对不通的异常类型进行不同的操作)。

2.3.3.立即执行resolve/reject

虽然在实际中,执行器往往执行一些异步操作,但是你也可以在执行器中立刻执行resolve或者reject方法,这完全没有关系。这样你的结果会被直接投递到消费代码。如代码片2.4.3-1所示。

代码片2.3.3-1

let promise = new Promise(function(resolve, reject) { 
  // not taking our time to do the job 
  resolve(123); // immediately give the result: 123 
});  

2.3.4..then和.catch中定义的handler都是异步的

.then和.catch中定义的handler都是异步的,这意味着即使Promise立刻执行了到了resolve或者reject,handler也必须等待当前的代码执行完毕才能被加载,如代码片2.3.4-1所示。虽然执行器立即执行了resolve得到了结果,但是.then(alert)也在最后被调用。如代码片2.3.4-1所示。

代码片2.3.4-1

// an "immediately" resolved Promise 
const executor = resolve => resolve("done!"); 
const promise = new Promise(executor);  
promise.then(alert); // this alert shows last (*)  
alert("code finished"); // this alert shows first  

3.Promise链

在实际中,很多时候往往需要顺序执行异步任务,但是用也带来了”恶魔金字塔”的问题(如1.4节描述)。引入Promise链,我们可以优雅的解决这个问题。

3.1.Promise链中的.then

多个.then方法可以构成一条Promise链。代码片3.1.-1就是一个简单的例子。

代码片3.1.-1

new Promise(function(resolve, reject) {  
  setTimeout(() => resolve(1), 1000); // (*)  
}).then(function(result) { // (**)  
  alert(result); // 1 
  return result * 2;  
}).then(function(result) { // (***)  
  alert(result); // 2 
  return result * 2; 
 
}).then(function(result) {  
  alert(result); // 4 
  return result * 2;  
});  

执行该代码,结果为1——2——4,这是因为.then返回一个Promise方法,并隐式地把值赋给了Promise对象的result属性,使得第一个Promise的result属性能够通过调用链不断传递。

倘若想在.then中包含异步操作,则必须返回一个包含异步对象的Promise。在处理异步操作期间,Promise链上的handler均不会执行,待异步操作完成,才将结果传递到链的下一个节点。

代码片3.1.-2

new Promise(function(resolve, reject) {  
  setTimeout(() => resolve(1), 1000);  
}).then(function(result) {  
  alert(result); // 1  
  return new Promise((resolve, reject) => { // (*) 
    setTimeout(() => resolve(result * 2), 1000); 
  }); 
 
}).then(function(result) { // (**)  
  alert(result); // 2  
  return new Promise((resolve, reject) => { 
    setTimeout(() => resolve(result * 2), 1000); 
  }); 
 
}).then(function(result) {  
  alert(result); // 4  
});  

在代码片3.1.-2中,最后的结果也是1——2——4,但是每个alter都相隔1s才会显示。可以理解为return一个Promise阻碍了结果的传播,必须要等这个异步动作结束,结果才能在Promise链中继续传递。

3.2.Promise链中的.catch

.catch可以对Promise链中的异常进行处理。考虑代码片3.2.-1。假设我们引入fetch函数(用来获取json)获取用户的头像(avatar)并显示,.catch可以捕获该Promise链中抛出的异常。

代码片3.2.-1

fetch('/article/promise-chaining/user.json') 
  .then(response => response.json()) 
  .then(user => fetch(`https://api.github.com/users/${user.name}`)) 
  .then(response => response.json()) 
  .then(githubUser => new Promise(function(resolve, reject) { 
    let img = document.createElement('img'); 
    img.src = githubUser.avatar_url; 
    img.className = "promise-avatar-example"; 
    document.body.append(img); 
 
    setTimeout(() => { 
      img.remove(); 
      resolve(githubUser); 
    }, 3000); 
  })) 
  .catch(error => alert(error.message));  

但是这样还不够好,在实际编码中,常常需要在代码中抛出异常,并根据异常的类型来做相应的处理。幸运的是,Promise链默认把在处理链中抛出的异常当reject进行处理,并让用户用.catch捕获。如代码片3.2.-2所示,loadJson函数在Promise链中会检测HTTP的状态码,若不为200(不成功),就抛出自定义异常“new HttpError(response)”,并被catch所捕获。

代码片3.2.-2

class HttpError extends Error { // (1) 
  constructor(response) { 
    super(`${response.status} for ${response.url}`); 
    this.name = 'HttpError'; 
    this.response = response; 
  } 
} 
 
function loadJson(url) { // (2) 
  return fetch(url) 
    .then(response => { 
      if (response.status == 200) { 
        return response.json(); 
      } else { 
        throw new HttpError(response); 
      } 
    }) 
} 
 
loadJson('no-such-user.json') // (3) 
  .catch(alert); // HttpError: 404 for .../no-such-user.json  

3.3.重新抛出异常及未处理异常

在一般的try…catch…结构中,若一个异常无法处理,往往可以重新抛出(Rethrowing)给上一级的异常处理函数处理。Promise链也支持这种形式。在Promis中也可以重新抛出异常,并被最近一个.catch所捕获。

考虑代码片3.3.-1。在Promise对象抛出一个异常”Whoops!”之后,这个Promise对象的状态变为拒绝(reject),链上最近的一个.catch方法被调用,并判断是否是URI异常,显然”Whoops!”不属于这类异常,因此显示”Can’t handle such error”,并重新抛出异常。该异常被链上的第二个.catch所捕获,最终显示”The unknown error has occurred: Error: Whoops!”。

代码片3.3.-1

// the execution: catch -> catch -> then 
new Promise(function(resolve, reject) { 
 
  throw new Error("Whoops!"); 
 
}).catch(function(error) { // (*) 
 
  if (error instanceof URIError) { 
    // handle it 
  } else { 
    alert("Can't handle such error"); 
 
    throw error; // throwing this or another error jumps to the next catch 
  } 
 
}).then(function() { 
  /* never runs here */ 
}).catch(error => { // (**) 
 
  alert(`The unknown error has occurred: ${error}`); 
  // don't return anything => execution goes the normal way 
 
});  

一般来说Promise链底部写上.catch来捕获异常是一个非常好的习惯。假如不这样做,那么javaScript引擎会捕获该异常并在控制台显示 。当然,也可以在浏览器中可以通过注册一个unhandledrejection事件(unhandledrejection事件是HTML标准的一部分)监听器,来捕获未处理异常,如代码片3.3.-2所示:

代码片3.3.-2

window.addEventListener('unhandledrejection', function(event) { 
  // the event object has two special properties: 
  alert(event.promise); // [object Promise] - the promise that generated the error 
  alert(event.reason); // Error: Whoops! - the unhandled error object 
}); 
 
new Promise(function() { 
  throw new Error("Whoops!"); 
}); // no catch to handle the er  

4.Promise API

Promise对象有四个静态方法:resolve/reject/all/race,可以在某些场景下让处理Promise对象的代码变得更加简洁。

4.1.Promise.resolve/Promise.reject

Promise.resolve/Promise.reject直接返回一个已经被resolve/reject的Promise对象。代码片4.1-1和代码片4.1-2分别显示了Promise.resolve和Promise.reject的等价形式。值得注意的是,Promise.resolve/Promise.reject返回的是Promise对象,因此也可用.then/.catch构成Promise链。

代码片4.1-1

let promise = Promise.resolve(value); 
let promise = new Promise(resolve => resolve(value)); 
代码片4.1-2  

代码片4.1-2

let promise = Promise.reject(error);  
let promise = new Promise((resolve, reject) => reject(error));  

4.2.Promise.all

Promise.all接受一个可迭代对象(往往是Promise数组)作为输入,并行地执行它们,等待所有Promise执行完毕之后返回一个Promise对象。这个Promise对象的result属性是包含所有对应结果的一个数组,如代码片4.2-1所示。

代码片4.2-1

Promise.all([ 
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 3000)), // 1 
  new Promise((resolve, reject) => setTimeout(() => resolve(2), 2000)), // 2 
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 1000))  // 3 
]).then(alert); // 1,2,3 when promises are ready: each promise contributes an array member  

需要指出的是,当传入的可迭代对象中包含非Promise对象的元素时,Promise.all会自动调用Promise.resolve方法将其包装成一个Promise对象并返回。如代码片4.2-2所示。

代码片4.2-2

Promise.all([ 
  new Promise((resolve, reject) => { 
    setTimeout(() => resolve(1), 1000) 
  }), 
  2, // treated as Promise.resolve(2) 
  3  // treated as Promise.resolve(3) 
]).then(alert); // 1, 2, 3  

4.3.Promise.race

Promise.race接受一个可迭代对象(往往是Promise数组)作为输入,并行地执行它们,将第一个返回的Promise对象作为结果,如代码片4.3-1所示。最后alter的结果是1

代码片4.3-1

Promise.race([ 
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)), 
  new Promise((resolve, reject) => setTimeout(() => reject(new Error("Whoops!")), 2000)), 
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000)) 
]).then(alert); // 1  

5.async/await

假如你是按顺序读完,那么到这里理解async/await关键字就非常容易。async/await关键字作为语法糖,能让操作Promise的代码更加简洁可读。

5.1.async

async关键词置于你想修饰的函数前,可以将一个非Promise的结果通过Promise.resolve的封装变成一个Promise对象,如代码片5.1-1所示。

代码片5.1-1

async function f() { 
  return 1; 
} 
 
f().then(alert); // 1 
5.2.await  

5.2.await

await的作用和.then非常相似,用来等待一个Promise对象的异步返回。await和async密不可分,await必须在async修饰的函数中才能使用。如代码片5.2-1所示。

代码片5.2-1

async function f() { 
 
  let promise = new Promise((resolve, reject) => { 
    setTimeout(() => resolve("done!"), 1000) 
  }); 
 
  let result = await promise; // wait till the promise resolves (*) 
 
  alert(result); // "done!" 
} 
 
f();  

值得注意的是,一旦使用await,就可以使用try…catch来捕获异常。相比.catch来说,这样捕获异常更加方便。

代码片5.2-2

async function f() { 
 
  try { 
    let response = await fetch('http://no-such-url'); 
  } catch(err) { 
    alert(err); // TypeError: failed to fetch 
  } 
} 
 
f();  

6.总结

本文参考在线教程并根据个人的实践经验有侧重的总结了一下ES6的异步特性:Promise概念、基本用法、静态方法以及两个关键字async和await。这里没有提到的是,Promise仍然有着一些缺点,比如它无法像RxJS一般很好地处理流事件。和所有的教程一样,本文不可能涵盖到异步编程的所有细节,但是若能对你有所启发,那就是再好不过了。

7. 参考链接

  1. https://javascript.info/callbacks

  2. https://javascript.info/promise-basics

  3. https://javascript.info/promise-chaining

  4. https://javascript.info/promise-api

  5. https://javascript.info/async-await

8.作者简介

邱仁博,多年运营商商业分析、数据中心数据库方向工作经验,现任职于某地市事业单位信息技术部。日常关注国内外极客新闻、前后端技术。海外知识搬运工。

【51CTO原创稿件,合作站点转载请注明原文作者和出处为51CTO.com】

【责任编辑:庞桂玉 TEL:(010)68476606】


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK