JavaScript异步终极解决方案
同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。
相对地,异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。异步操作经常是必要的,因为强制进程等待一个长时间的操作通常是不可行的(同步操作则必须要等)。如果代码要访问一些高延迟的资源,比如向远程服务器发送请求并等待响应,那么就会出现长时间的等待。
早期的异步编程
在早期JavaScript中,对异步的处理方式并不理想,主要是采用回调函数的方式。
复制 request ( 'https://example.com' , function (err){
if (err){ return ;}
})
回调函数
回调函数字面意思就是"回过头调用的函数",回调函数一般事先定义好,然后在某些时候"回过头"来调用,例如我们使用的addEventListener
,第二个参数就是一个回调函数,特定事件触发后就会调用这个函数。
复制 let p = document .querSelector ( '#p' );
p .addEventListener ( 'click' , funtion (){
// 这是一个回调函数,当事件被触发时执行
})
回调函数的概念可以这样理解,执行一个异步操作(这个异步操作可能是一个点击事件,也可能是一个http请求),并且要在异步操作执行完毕后执行一些处理程序,但是由于我们不知道异步操作什么时候执行完,所以我们会先定义好一个函数但是不执行,等异步操作执行完后再调用。
复制 // 先把回调函数定义好
function fn (){
// 执行某些操作
}
// 异步操作
function asyncFn (fn){
// 执行某些代码
// 执行完后执行回调函数
fn ()
}
asyncFn (fn);
回调函数在Node.js里用的非常多,Node.js大多数异步API都是基于回调函数的。
回调地狱
用回调函数处理异步最大的问题就是容易造成回调地狱。
回调地狱是指多个回调函数深度嵌套,造成代码难以阅读和维护。
想象一下,如果我们需要从后端获取a接口、b接口、c接口中的数据,然后进行某些处理,那么我们的代码可能会像这样:
复制 request ( 'https://example.com/a' , function (err){
if (err){ return ;}
request ( 'https://example.com/b' , function (err){
if (err){ return ;}
request ( 'https://example.com/c' , function (err){
if (err){ return ;}
// 进行处理
})
})
})
显而易见,代码复杂且不易维护,开发体验非常不好。
解决回调地狱
JavaScript实现异步最大的问题就是容易出现回调地狱。
复制 const fs = require ( "fs" );
fs .stat ( './1.txt' , (err , data) => {
if (err){
// 错误处理
} else {
fs .readFile ( './1.txt' , (err , data) => {
if (err){
// ...
} else {
// ...
}
})
}
})
随着代码越来越复杂,函数嵌套的就越来越多,这是对于代码维护来说是一件非常痛苦的事情,所以我们会想能不能将里面的代码提取到外面来,比如这样
复制 const fs = require ( "fs" );
fs .stat ( './1.txt' , (err , data) => {
if (err){
// 错误处理
} else {
}
})
fs .readFile ( './1.txt' , (err , data) => {
if (err){
// ...
} else {
// ...
}
})
可惜这种行为很显然会有问题,因为fs.stat
是一个异步操作,因此fs.readFile
不会等到fs.stat
执行完毕后才运行,它很可能在fs.stat
还没执行完毕前就执行了,这就导致了逻辑错误。
这里要解决的问题就是当fs.stat
执行完毕得出结果时,再执行fs.reaFile
,这里很自然的可以想到用一个flag
变量来标识fs.stat
的执行状态,未执行完为false
,执行完为true
复制 const fs = require ( "fs" );
let flag = false ;
fs .stat ( "./1.txt" , (err , data) => {
// fs.stat此时已执行完毕
flag = true ;
});
const timer = setInterval (() => {
if (flag === true ) {
fs .readFile ( "./1.txt" , (err , data) => {
console .log ( "成功" )
});
clearInterval (timer);
}
} , 1 );
// 成功
这种情况固然能解决我们的问题,但是setInterval
这种方法太浪费资源了,不可能大规模使用。我们可以换个思路,可以用发布订阅模式来替代setInterval
。
复制 const fs = require ( "fs" );
// 实现发布订阅模式
function bus (fn) {
if ( ! Array .isArray (fn)) {
bus .fns = [fn];
} else {
bus . fns .push (fn);
}
}
bus . run = function () {
for ( let fn of bus .fns) {
fn ();
}
};
bus ( function () {
console .log ( "后执行" );
});
fs .stat ( "./1.txt" , (err , data) => {
console .log ( "先执行" );
bus .run ();
});
// 先执行
// 后执行
这样能达到我们的要求,我们可以改一下代码,让它更方便使用。
复制 const fs = require ( "fs" );
function Bus (fn) {
const fns = [];
function resolve () {
for (fn of fns) {
fn ();
}
}
fn (resolve);
this . then = function (resolveFn) {
fns .push (resolveFn);
};
}
const bus = new Bus ( function (resolve) {
fs .stat ( "./1.txt" , (err , data) => {
console .log ( "先执行" );
resolve ();
});
});
bus .then ( function () {
console .log ( "后执行" );
});
// 先执行
// 后执行
改完之后使用起来方便了很多,我们来分析一下。
对于后执行的函数(即打印后执行
的那个函数),我们将他们存放到一个数组fns
里,方便遍历执行,而为了能够添加后执行函数,我们添加一个实例方法then
,它会将参数(即要添加的后执行的函数)添加到数组fns
中。
为了判断先执行的那个函数(即包裹fs.stat
的那个函数)什么时候执行完成,我们创建了一个函数resolve
,它会遍历执行fns
中所有的函数,即所有的后执行的函数,当先执行的函数执行完后,我们调用resolve
函数就行了,但是如何将resolve函数传递到先执行的函数中呢?很简单,我们将先执行的函数作为构造函数的参数传入,然后在执行这个函数时把resolve
作为参数传入,而在先执行的函数里,我们可以灵活的放置resolve
函数的位置。
事实上上面的方法只实现了一些简单的功能,还有不少地方没考虑,比如先执行的函数异常怎么办?上面的方法不能实现函数串联执行,即函数a执行完后执行函数b,函数b执行完后执行函数c。而Promise能解决这些的问题。Promise与上面的方法原理类似,但是功能更强大,逻辑更严谨,Promise是实现JavaScript异步编程的重要工具。
Promise
ECMAScript 6新增的引用类型Promise来处理异步,Promise是一个构造函数,可以通过new操作符来实例化。创建Promise实例时需要传入执行器函数作为参数。
Promise有三种状态:pending
、fulfilled(也称resolve)
、reject
,三者分别表示三种不同的状态:待定(pending)、兑现(fulfilled)、拒绝(reject)。
待定(pending)是期约的最初始状态。在待定状态下,期约可以落定(settled)为代表成功的兑现(fulfilled)状态,或者代表失败的拒绝(rejected)状态。无论落定为哪种状态都是不可逆的。只要从待定转换为兑现或拒绝,期约的状态就不再改变。
Promise有以下两个特点:
对象的状态不受外界影响。Promise
对象代表一个异步操作,有三种状态:pending
(进行中)、fulfilled
(已成功)和rejected
(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是Promise
这个名字的由来,它的英语意思就是“承诺”,表示其他手段无法改变。
一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise
对象的状态改变,只有两种可能:从pending
变为fulfilled
和从pending
变为rejected
。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为 resolved(已定型)。
这两个特点很好理解,Promise三个状态分别表示异步操作是正在进行中、已经完成、已经失败,如果是正在进行中那么应该让它继续执行,如果是已完成则调用执行成功的处理程序resolve
,如果是已失败则执行失败的处理程序reject
。而这种状态的改变是不可逆的,一个异步操作执行成功就是成功,不可能一会成功一会又失败,这样很容易导致混乱。
复制 let pr = new Promise ((resolve , reject) => {
// 创建promise时需要传入一个函数,函数第一个参数是resolve,第二个参数的reject
// 模拟异步
setTimeout (() => {
if (操作成功){
// 将状态从pending改成fulfilled
resolve ( '成功了' )
} else {
// 将状态从pending改为reject
reject ( '失败了' )
}
} , 1000 )
})
pr .then ((res) => {
// fulfilled
console .log (res) // 成功了
}) .catch (err => {
// reject
console .log (err) // 失败了
})
Promise接收一个执行器函数,这个函数有两个参数resolve
和reject
,这两个参数都是函数,可以被调用和传递参数,一旦调用resolve()
就表示Promise对象由pending
转变为resolve
,会触发Promise.then
第一个函数,而一旦调用reject()
就表示Promise对象由pending
转变为reject
,会触发Promise.then
第二个函数或Promise.catch
函数(两者等价)。而转变后是不可再变的。
Promise也使用到了回调函数,例如Promise.then
就接收回调函数作为参数,但是Promise却会不产生回调地狱,因为Promise是链式调用,这种方式让代码便于理解和维护,不会出现回调地狱。
复制 // 回调函数产生的回调地狱
request ( 'https://example.com/a' , function (err){
if (err){ return ;}
request ( 'https://example.com/b' , function (err){
if (err){ return ;}
request ( 'https://example.com/c' , function (err){
if (err){ return ;}
// 进行处理
})
})
})
// Promise的链式调用,这里假设pro是一个Promise对象
pro .then ((res) => {
// 某些操作
return 1 ;
}) .then ((res => {
// 某些操作
// 上个Promise对象的返回值是此函数的参数
console .log (res) // 1
})) .then (res => {
// 某些操作
}) .catch ((err) => {
// Promise错误的处理程序
}) .finally (() => {
// 无论Promise转变为resolve还是reject,都会触发此函数
// finally函数无法判断Promise是resolve状态还是reject状态
})
Promise的链式调用不仅能够使代码更易维护,还有其他的优点。比如说在回调函数里需要对每个 回调函数判断是否出现错误,而在Promise中如果出现错误,会顺着调用链一直传递,直到遇到catch()
才会被捕获。另外需要注意try...catch
无法捕捉Promise中的错误。
Promise能够实现链式调用的秘诀在于,Promise.then()
和Promise.catch()
的返回值仍然是一个Promise对象。
复制 let pro = new Promise ((resolve , reject) => {
setTimeout (() => {
resolve ();
} , 100 );
});
let result = pro .then (() => {});
console .log (result); // Promise { <pending> }
Promise更多语法这里不再赘述,详情可以看阮一峰老师的文章: 点我
除此之外,Promise还提供多种静态方法。例如之前将的回调地狱,在Promise可以用更优雅的方式解决。
复制 // 这里为了方便,默认getA是封装了异步请求的promise的实例
Promise .all ([getA , getB , getC]) .then (res => {
// 所有请求都成功时才执行此函数
}) .catch (err => {
// 有请求失败时
}) .finnaly (() => {
// fulfilled和reject都会触发此函数
// finally函数无法判断Promise是resolve状态还是reject状态
})
除了all
方法外,Promise还有另外五种方法: race
、resolve
、reject
、allSettled
、any
。
异步函数
ES2017 标准引入了 async
函数,使得异步操作变得更加方便。
实现一个异步函数非常简单,只需要在函数声明前加上关键字async
就可以了
复制 async function asyncFn (){
//
}
单是这样的话,异步函数与普通函数没什么不同,异步函数里最重要的还是await
关键字,它是实现异步函数的关键,而await
关键字必须是在async
函数中。
await
关键字后面跟着的只能是Promise对象或者是原始值,当异步函数执行的时候,一旦遇到await
就会先跳出函数,执行后面的语句,等到异步操作完成,再接着执行函数体内后面的语句。
复制 let pro = new Promise ((resolve , reject) => {
setTimeout (() => {
resolve ();
} , 100 );
});
async function asyncFn () {
await pro;
console .log ( 1 );
}
asyncFn ();
console .log ( 2 );
// 2 1
当执行到await pro;
语句时,会跳出异步函数,执行后面的语言打印2,等异步操作执行完成后在执行await
后面的语句,打印1.
await
关键字会返回Promise对象resolve
的第一个参数。
复制 let pro = new Promise ((resolve , reject) => {
setTimeout (() => {
resolve ( "promise" );
} , 100 );
});
async function asyncFn () {
let a = await pro;
console .log (a);
}
asyncFn ();
// promise
async
函数中使用try...catch
捕获异常,这点与Promise不同。需要注意的是,如果await
后面的Promise对象进入的是reject
状态,则异步函数执行会被中断。解决的方法是将await
语句没有被包裹在try..catch
中或者在Promise对象后添加catch
捕获异常。
复制 let pro = new Promise ((resolve , reject) => {
setTimeout (() => {
reject ();
} , 100 );
});
async function asyncFn () {
await pro;
}
asyncFn ();
// 报错
// 正确的做法应该是将await语句包裹在try中
let pro = new Promise ((resolve , reject) => {
setTimeout (() => {
reject ( "promise" );
} , 100 );
});
async function asyncFn () {
try {
await pro;
} catch (e) {
}
}
asyncFn ();
// 或者是
let pro = new Promise ((resolve , reject) => {
setTimeout (() => {
reject ( "promise" );
} , 100 );
});
async function asyncFn () {
await pro .catch (() => {});
}
asyncFn ();
async
函数返回一个 Promise 对象,可以使用then
方法添加回调函数。
复制 console .log ( asyncFn ());
// Promise { <pending> }
使用async函数需要注意的是,如果使用await,那么await语句所在的函数必须是async函数。也就是说嵌套函数中,最外层是async函数,内层函数是普通函数,那么就不能在内层函数中使用await关键字。
复制 // 错误写法
async function asyncFn (){
await 1 ; // 正确
setTimeout (() => {
await 1 ; // 错误,这个函数不是async函数
} , 10 )
}
// 正确写法,在setTimeout的回调函数前加上async
async function asyncFn (){
await 1 ; // 正确
setTimeout ( async () => {
await 1 ; // 正确
} , 10 )
}
async
函数本质上是 Generator
函数的语法糖。async函数将Generator
函数的星号和yield
改成了语义更明确的async
和await
,另外async函数内置执行器,可以自动执行,还有就是async函数的返回值是Promise对象。
异步函数处理异步操作的方式比Promise更优雅,例如在之前的Promise的链式调用,在异步函数中可以这样写。
复制 let getA = new Promise ((resolve , reject) => {
setTimeout (() => {
console .log ( "getA" );
resolve ();
} , 1000 );
});
let getB = new Promise ((resolve , reject) => {
setTimeout (() => {
console .log ( "getB" );
resolve ();
} , 500 );
});
let getC = new Promise ((resolve , reject) => {
setTimeout (() => {
console .log ( "getC" );
resolve ();
} , 100 );
});
async function asyncFn () {
await getA;
await getB;
await getC;
}
asyncFn ();
// getA getB getC