彻底入门Promise

2,192 阅读35分钟

同步代码与异步代码

在学习 Promise 之前,我们需要理解同步代码和异步代码

  • 同步代码:逐行执行,需原地等待结果后,才继续向下执行

实际上浏览器是按照我们书写代码的顺序一行一行地执行,浏览器会等待代码的解析和工作,在上一行代码完成后才会执行下一行。这样做是很有必要的,因为每一行新的代码都是建立在前面代码的基础之上,这也使得它成为一个同步程序

同步执行模式简单直观,易于理解和调试,但是当涉及耗时较长的操作时,如果都采用同步方式,可能会导致程序响应变慢甚至出现卡顿现象,于是便产生了异步的概念

  • 异步代码:调用后耗时,不阻塞代码继续执行(不必原地等待),在将来完成后触发一个回调函数

异步编程技术使你的程序可以在执行一个可能长期运行的任务的同时继续对其他任务做出反应,而不必等待该耗时任务的完成。与此同时,你的程序也将在任务完成后显示结果

例如我们使用定时器延迟打印数据,对按钮点击事件进行事件监听,定时器内代码执行不会影响主线程(即执行JS代码的线程)内的后续代码的执行:

const result = 0 + 1
console.log(result)

setTimeout(() => {
  console.log(2)
}, 2000)

document.querySelector('.btn').addEventListener('click', () => {
  console.log(3)
})
console.log(4)
// 运行页面 2s 内点击: 1432
// 运行页面 2s 后点击: 1423

对于同步和异步代码,红宝书上有一个很有意思的现象:

for (var i = 0; i < 5; ++i) { 
  setTimeout(() => console.log(i), 0) // 5、5、5、5、5 
}
// 在退出循环时,迭代变量保存的是导致循环退出的值:5
// 在之后执行超时逻辑时,所有的 i 都是同一个变量,因而输出的都是同一个最终值

// 在使用 let 声明迭代变量时,JavaScript 引擎在后台会为每个迭代循环声明一个新的迭代变量
for (let i = 0; i < 5; ++i) { 
  setTimeout(() => console.log(i), 0) // 0、1、2、3、4
}
// 每个 setTimeout 引用的都是不同的变量实例,所以 console.log 输出的是我们期望的值
// 也就是循环执行过程中每个迭代变量的值

这里给出简单的解释,深层原因看完本文后续内容便豁然开朗:

  • var定义的变量会有变量提升问题,并且没有块级作用域。每次输出的i其实都是同一个变量,setTimeout()是异步执行的,于是每次setTimeout()输出时,输出的是循环后最终的i
  • let定义的变量没有变量提升问题,并且有块级作用域,每次传入setTimeout()回调函数中的i其实是不同的变量i ,因此最后能输出正确的结果

Promise

Promise定义

ECMAScript 6 增加了对 Promises/A+ 规范的完善支持,即 Promise (期约) 类型。一经推出,Promise 就大受欢迎,成为了主导性的异步编程机制所有现代浏览器都支持 ES6 期约,很多其他浏览器 API(如fetch()和 Battery Status API)也以期约为基础。此外 Promise 支持链式调用,可以解决回调函数地狱问题

Promise 对象是一个构造函数,用来生成 Promise 实例,可以包裹一个异步操作

const promise = new Promise(function(resolve, reject) {异步操作})

Promise(excutor){}
(1) excutor 函数:执行器 (resolve,reject) => {}
(2) resolve 函数:内部定义成功时我们调用的函数 value => {}
(3) reject 函数: 内部定义失败时我们调用的函数 reason => {}
// excutor 会在 Promise 内部立即同步调用,异步操作会在执行器中执行

Promise 构造函数接受一个函数作为参数,该函数的两个参数分别是resolvereject,称为函数类型的数据,它们是两个函数,由 JavaScript 引擎提供,不用自己部署。当然这里的 resolve 和 reject 是可以随意命名的(默认这么命名),要把这里的 resolve 和 reject 与Promise静态方法中的 resolve 和 reject 分开,它们并不相同!

  • 在异步任务成功时,调用resolve函数,将 Promise 对象的状态从 未完成(pending) 变为 成功(resolved),并将异步操作的结果作为参数传递出去

  • 在异步任务失败时,调用reject函数,将 Promise 对象的状态从 未完成(pending) 变为 失败(rejected),并将异步操作报出的错误作为参数传递出去

  • Promise 实例生成后,可以使用 then 方法分别指定 resolve 状态和 rejected 状态的回调函数,当然这里的错误接收可以使用 catch 方法 (本质上是 then 方法的语法糖)

    new Promise(function (resolve, reject) {
        //成功调用 resolve()
        //失败调用 reject()
    }).then(res => {
       //成功执行代码
    }).catch(res => {
       //失败执行代码
    })
    
    ## Promise 的状态
    * 实例对象中的一个属性 【PromiseState】
    ## Promise 对象的值
    * 实例对象中的另一个属性 【PromiseResult】
    * 保存着对象【成功/失败】的结果,只能由 resolve 和 reject 修改
    

    总的来说:当 resolvereject 修改 promise 对象状态之后,通过检测 promise 对象的状态,决定执行 then 还是 catch 回调方法。在这个过程中:resolvereject 起到的作用是修改对象状态、通知回调函数以及传递回调函数可能需要的参数。 这样做的好处就是:把逻辑判断和回调函数分开处理。通俗来讲,这俩函数就是个干苦力的中间人,任劳任怨,连名字都可以被随意更改!

Promise 只能用 resolve 、reject、throw 抛出信息,return 不能抛出信息(无法改变Promise对象状态) 不过在使用 async 声明的异步函数可以使用 return(后文会有详细说明)

const p = new Promise(function (resolve, reject) {
  return '666'
}).then(res => {
  console.log(res) // 不执行
}).catch(err => {
  console.log(err) // 不执行
})

setTimeout(() => {
  console.log(p) // Promise {<pending>}
}, 1000)

期约真正的异步特性:它们是同步对象(在同步执行模式中使用),但也是异步执行模式的媒介。这样看似乎不太好理解,下面引入红宝书中的例子:

try { 
  throw new Error('foo'); 
} catch(e) { 
  console.log(e); // Error: foo
} 

try { 
  Promise.reject(new Error('bar')); 
} catch(e) { 
  console.log(e); 
} 
// Uncaught (in promise) Error: bar

上述的例子中,拒绝期约的错误并没有抛到执行同步代码的线程里,而是通过浏览器异步消息队列来处理的。因此,主线程的try/catch块并不能捕获该错误。代码一旦开始以异步模式执行,则唯一与之交互的方式就是使用异步结构,更具体地说,就是 promise 的方法

Promise状态

一个 Promise 对象,必然处于以下三种状态之一:

  • 待定(pending):待定,初始状态,既没有被兑现也没有被拒绝
  • 已兑现(fulfilled):已兑现,表示操作成功完成
  • 已拒绝(rejected):已失败,表示操作失败

Promise 对象的状态不受外界影响,只有异步操作的结果可以决定当前是哪一种状态

Promise 对象状态一旦改变(从pending变为fulfilled或从pending变为rejected),就不能再变,这个过程是不可逆的

Promise 整体流程:

promises.png

Promise实例方法

期约实例的方法是连接外部同步代码与内部异步代码之间的桥梁。这些方法可以访问异步操作返回的数据、处理期约成功或失败的结果、连续对期约求值或者添加当期约进入终止状态时执行的代码

then

then()是实例状态发生改变时的回调函数,第一个参数是resolved状态的回调函数,第二个参数是rejected状态的回调函数

then()方法返回的是一个新的Promise实例(promise能链式调用的原因)

then(onResolved)
then(onResolved, onRejected)

替代 catch 写法:

new Promise(function (resolve, reject) {
  reject('出错了')
}).then(null, (res) => {
  console.log(res) // 出错了
})

两个处理程序参数都是可选的,而且传给then()的任何非函数类型的参数都会被默认忽略。如果想只提供onRejected参数,那就要在onResolved参数的位置上传入undefined / null,这样有助于避免在内存中创建多余的对象

onResolved 处理函数的返回值:当一个 Promise 完成(fulfilled)或者失败(rejected)时,返回函数将被异步调用。具体的返回值依据以下规则返回。如果 then 中的回调函数:

本节使用setTimeout输出的是执行完后的 Promise 对象的状态。直接打印会出现打印 pending 状态的 Promise 对象的情况(代码执行顺序)

  • 返回了一个值

    • then返回的 Promise 对象(p)将会成为fulfilled状态

    • p以该返回值作为其兑现值

    const p = new Promise((resolve, reject) => {
      // 模拟异步操作
      setTimeout(() => {
        const data = { message: "Hello, World!" }
        resolve(data)
      }, 1000)
    }).then((result) => {
      console.log(result) // {message: 'Hello, World!'}
      // 返回一个新的值
      return result.message.toUpperCase()
    })
    
    p.then((result) => {
      console.log(result) // HELLO, WORLD!
    }).catch((err) => {
      console.log(err)
    })
    
    setTimeout(() => {
      console.log(p) // Promise {<fulfilled>: 'HELLO, WORLD!'}
    }, 1000)
    
  • 没有返回任何值

    • then返回的 Promise 对象(p)将会成为fulfilled状态

    • pundefined作为其兑现值

    const p = new Promise((resolve, reject) => {
      // 模拟异步操作
      setTimeout(() => {
        const data = { message: "Hello, World!" };
        resolve(data)
      }, 1000)
    }).then((result) => {
      console.log(result) // {message: 'Hello, World!'}
      // 不返回值
    })
    
    p.then((result) => {
      console.log(result) // undefined
    }).catch((err) => {
      console.log(err)
    })
    
    setTimeout(() => {
      console.log(p) // Promise {<fulfilled>: undefined}
    }, 1000)
    
  • 抛出一个错误

    • then返回的 Promise 对象(p)将会成为rejected状态

    • p以抛出的错误作为其拒绝值

    const p = new Promise((resolve, reject) => {
      // 模拟异步操作
      setTimeout(() => {
        const data = { message: "Hello, World!" }
        resolve(data)
      }, 1000)
    }).then((result) => {
      throw new Error("Error in then")
    })
    
    p.then((result) => {
      console.log(result)
    }).catch((err) => {
      console.log(err) // Error: Error in then at xxx
    })
    
    setTimeout(() => {
      console.log(p) // Promise {<rejected>: Error: Error in then at file xxx... }
    }, 1000)
    
  • 返回一个fulfilled状态的 Promise对象(p1)

    • then返回的 Promise 对象(p2)也会成为fulfilled状态
    • p2p1的值作为其兑现值
    const p = new Promise(function (resolve, reject) {
      resolve('666')
    }).then(res => {
      return new Promise(function (resolve, reject) {
        resolve('777') // 返回成功状态的promise对象
      }) 
    })
    
    p.then(res => {
      console.log(res) // 777
    }).catch(err => {
      console.log(err) 
    })
    
    setTimeout(() => {
      console.log(p) // Promise {<fulfilled>: '777'}
    }, 0)
    
  • 返回一个rejected状态的 Promise(p1)

    • then返回的 Promise 对象(p2)也会成为rejected状态
    • p2p1的值作为其拒绝值
    const p = new Promise(function (resolve, reject) {
      resolve('成功')
    }).then(res => {
      return new Promise(function (resolve, reject) {
        reject('拒绝') // 返回拒绝状态的promise对象
      }) 
    })
    
    p.then(res => {
      console.log(res)
    }).catch(err => {
      console.log(err) // 拒绝
    })
    
    setTimeout(() => {
      console.log(p) // Promise {<rejected>: '拒绝'}
    })
    
  • 返回一个pending状态的 Promise(p1)

    • then返回 Promise 对象的状态(p2)也是pending状态
    • p2 保持待定状态,并在p1被兑现/拒绝后立即以p1值作为其兑现/拒绝值
    const p = new Promise(function (resolve, reject) {
      resolve('成功')
    }).then(res => {
      return new Promise(function (resolve, reject) {
        setTimeout(() => {
          resolve('已兑现')
        }, 1000)
      })
    })
    
    console.log(p) // Promise {<pending>}
    setTimeout(() => {
      console.log(p) // Promise {<fulfilled>: '已兑现'}
    }, 2000)
    

总的来说:

  • then中的onResolved函数返回的是值(或不返回任何东西),则then函数返回的是 fulfilled 状态的Promise对象
  • then中的onResolved函数返回的是Promise对象,则then函数返回的Promise对象状态与onResolved函数返回的Promise对象状态保持一致

catch

catch()方法是.then(null, onRejected).then(undefined, onRejected)的别名,用于指定发生错误时的回调函数,。这个方法只接收一个参数:onRejected 处理程序。事实上,这个方法就是一个语法糖

一般来说,使用catch()方法代替then()第二个参数,与then()方法一样

catch(onRejected)

catch()方法返回一个新的 Promise 实例,无论当前的 promise 状态如何,这个新的 promise 在返回时总是处于待定状态

如果 onRejected 方法抛出了一个错误或者返回了一个被拒绝的 promise,那么这个新的 promise 也会被拒绝(rejected),否则它最终会被兑现(fulfilled)

实际上就是两点:

  • catch 中的 onRejected 函数正常返回时,新 Promise 实例的状态为fullfilled
  • catch 中的 onRejected 函数报错时,新 Promise 实例的状态为rejected
// catch中的onRejected函数正常返回时, 新Promise实例的状态为fullfilled
const p = new Promise((resolve, reject) => {
  reject('Error!')
}).catch((error) => {
  console.log('onRejected function:', error) // onRejected function: Error!
})

setTimeout(() => {
  console.log(p) // Promise {<fulfilled>: undefined}
}, 0)

// catch中的onRejected函数报错时, 新Promise实例的状态为rejected
const p1 = new Promise((resolve, reject) => {
  reject('Error!')
}).catch((error) => {
  console.log('onRejected function:', error) // onRejected function: Error!
  throw new Error('Thrown Error')
})

setTimeout(() => {
  console.log(p1) // Promise {<rejected>: Error: Thrown Error
}, 0)

抛出错误时的陷阱

  • 大多数情况下,抛出错误会调用 catch() 方法:
const p1 = new Promise((resolve, reject) => {
  throw new Error("哦吼!")
})

p1.catch((e) => {
  console.error(e) // "b 哦吼!"
})
  • 在异步函数内部抛出的错误会像未捕获的错误一样:
const p2 = new Promise((resolve, reject) => {
  setTimeout(() => {
    throw new Error("未捕获的异常!")
  }, 1000)
})

p2.catch((e) => {
  console.error(e) // 永远不会被调用
})

可以在 Promise 的回调中使用 try-catch 来捕获错误,并在 catch 部分调用 reject 方法将错误传递给 Promise。这样就可以在 Promise 链的后续部分使用 catch 来捕获到错误

const p2 = new Promise((resolve, reject) => {
  setTimeout(() => {
    try {
      throw new Error("捕获异常!")
    } catch (error) {
      reject(error)
    }
  }, 1000)
})

p2.catch((e) => {
  console.log(e) // Error: 捕获异常! at xxx
})
  • 在调用 resolve 之后抛出的错误会被忽略:
const p3 = new Promise((resolve, reject) => {
  resolve()
  throw new Error("Silenced Exception!")
})

p3.catch((e) => {
  console.error(e) // 这里永远不会执行
})

finally

finally()方法用于给期约添加 onFinally 处理程序,这个处理程序在期约转换为解决或拒绝状态时都会执行。这个方法可以避免onResolvedonRejected处理程序中出现冗余代码。但 onFinally 处理程序没有办法知道期约的状态是解决还是拒绝,所以这个方法主要用于添加清理代码

finally()方法用于指定不管 Promise 对象最后状态如何,都会执行的操作finally()方法返回 Promise 实例对象

promise
.then(result => {···})
.catch(error => {···})
.finally(() => {···})

如果给期约添加了多个处理程序,当期约状态变化时,相关处理程序会按照添加它们的顺序依次执行。无论是 then()catch()还是 finally()添加的处理程序都是如此

let p1 = Promise.resolve(); 
let p2 = Promise.reject(); 
//setTimeout(functionRef, delay, param1, param2, /* … ,*/ paramN)
p1.then(() => setTimeout(console.log, 0, 1)); 
p1.then(() => setTimeout(console.log, 0, 2)); 
// 1 
// 2 
p2.then(null, () => setTimeout(console.log, 0, 3)); 
p2.then(null, () => setTimeout(console.log, 0, 4)); 
// 3 
// 4 
p2.catch(() => setTimeout(console.log, 0, 5)); 
p2.catch(() => setTimeout(console.log, 0, 6)); 
// 5 
// 6 
p1.finally(() => setTimeout(console.log, 0, 7)); 
p1.finally(() => setTimeout(console.log, 0, 8)); 
// 7 
// 8

Promise静态方法

resolve

Promise.resolve(value)

  • value:成功的数据或 promise 对象
  • 返回值:一个成功或失败的 promise 对象(不改变原 promise 对象状态)
//如果传入的参数为 非 Promise 类型的对象,返回结果为成功 promise 对象
Promise.resolve("成功").then(
  (value) => {
    console.log(value); // "成功"
  },
  (reason) => {
    console.log(reason)// 不会被调用
  },
);

//如果传入的参数为 Promise 对象,则参数的结果决定了 resolve 的结果
Promise.resolve(new Promise((resolve, reject) => {
  reject('error')
})).then(
  (value) => {
    console.log(value); // 不会被调用
  },
  (reason) => {
    console.log(reason) //error
  }
)

reject

Promise.reject(reason)

  • reason:失败的原因
  • 返回值:一个失败的 promise 对象(快速得到一个 promise 对象,如果为值也会被转换为 promise 对象,且永远是一个失败的 promise 对象
//如果传入的参数为 非 Promise 类型的对象,返回结果为失败的 promise 对象
Promise.reject("失败").then(
  (value) => {
    console.log(value); // 不会被调用
  },
  (reason) => {
    console.log(reason) // "失败"
  },
);

const p = Promise.reject(new Promise((resolve, reject) => {
    resolve('ok')
}))
console.log(p)//依旧为失败的 promise 对象
//Promise {<rejected>: Promise}
//  [[Prototype]]: Promise
//  [[PromiseState]]: "rejected"
//  [[PromiseResult]]: Promise

all

Promise.all()方法用于将多个 Promise实例,包装成一个新的 Promise实例

const p = Promise.all([p1, p2, p3])

接受一个数组(迭代对象)作为参数,数组成员都应为Promise实例

如果参数包含非 Promise 对象(即普通的值或非 Promise 对象),它们会被视为已经解决的 Promise,且会立即被视为成功

实例p的状态由p1p2p3决定,分为两种:

  • 只有p1p2p3的状态都变成fulfilledp的状态才会变成fulfilled,此时p1p2p3的返回值组成一个数组,传递给p的回调函数
  • 只要p1p2p3之中有一个被rejectedp的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数

注意,如果作为参数的 Promise 实例,自己定义了catch方法,那么它一旦被rejected,并不会触发Promise.all()catch方法

  • 示例一(正常返回)

    const p1 = Promise.resolve(3)
    const p2 = 1337
    const p3 = new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve("foo")
      }, 100)
    })
    
    Promise.all([p1, p2, p3]).then((values) => {
      console.log(values) // [3, 1337, "foo"]
    })
    
  • 示例二(包含非promise对象)

    const promise1 = Promise.resolve(1)
    const promise2 = new Promise((resolve) => setTimeout(() => resolve(2), 1000))
    const value = 3
    
    Promise.all([promise1, promise2, value])
      .then(results => {
        console.log(results) // [1, 2, 3]
      })
      .catch(error => {
        console.error(error)
      })
    
  • 示例三(返回值组成一个数组,传递给p的回调函数,参数promise对象自定义了catch方法)

    const p1 = new Promise((resolve, reject) => {
      resolve('hello')
    })
      .then((result) => result)
      .catch((e) => e)
    
    const p2 = new Promise((resolve, reject) => {
      throw new Error('报错了')
    })
      .then((result) => result)
      .catch((e) => e) 
    
    const p = Promise.all([p1, p2])
      .then((result) => console.log(result)) // ["hello", Error: 报错了 at file xxx]
      .catch((e) => console.log(e)) //不执行
    
  • 示例四(p2没有自己的catch方法,就会调用Promise.all()catch方法)

    const p1 = new Promise((resolve, reject) => {
      resolve('hello')
    }).then((result) => result)
    
    const p2 = new Promise((resolve, reject) => {
      throw new Error('报错了')
    }).then((result) => result)
    
    Promise.all([p1, p2])
      .then((result) => console.log(result))
      .catch((e) => console.log(e)) // Error: 报错了
    

race

Promise.race()方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例

const p = Promise.race([p1, p2, p3])

只要p1p2p3之中有一个实例率先改变状态,p的状态就跟着改变率先改变的 Promise 实例的返回值则传递给p的回调函数

示例:

const promise1 = new Promise((resolve, reject) => {
  setTimeout(resolve, 500, 'one');
});

const promise2 = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, 'two');
});

const p = Promise.race([promise1, promise2])
setTimeout(() => {
  console.log(p); //Promise {<fulfilled>: 'two'}
}, 1000);

allSettled

Promise.allSettled()方法将一个 Promise 可迭代对象作为输入,并返回一个新的 Promise 实例

当所有输入的 Promise 都已敲定时(包括传入空的可迭代对象时),返回的 Promise 将被兑现,并带有描述每个 Promise 结果的对象数组

Promise.allSettled(iterable) //promise组成的可迭代对象,如数组

示例:

Promise.allSettled([
  Promise.resolve(33),
  new Promise((resolve) => setTimeout(() => resolve(66), 0)),
  99,
  Promise.reject(new Error("一个错误")),
]).then((values) => console.log(values));

// [
//   { status: 'fulfilled', value: 33 },
//   { status: 'fulfilled', value: 66 },
//   { status: 'fulfilled', value: 99 },
//   { status: 'rejected', reason: Error: 一个错误 }
// ]

回调地狱与链式调用

回调地狱

异步行为是 JavaScript 的基础,但在早期 JavaScript 实现并不理想,只支持定义回调函数来表明异步操作的完成。串联多个异步操作是很常见的问题,通常需要深度嵌套回调函数,但嵌套深度比较大时,代码可读性会变得非常差,整体代码也会变得十分臃肿,这里就形成了回调函数地狱问题(俗称回调地狱)

我们可以对回调地狱进行总结:

  • 概念:在回调函数中嵌套回调函数,一直嵌套下去就形成了回调函数地狱
  • 缺点:可读性差,异常无法捕获,耦合性严重,牵一发而动全身

回调地狱问题,对于 es6 后的 JavaScript 来说,此种解决方案已经被遗弃,代替的有更好的实现方法,这里只是举例让大家了解 JavaScript 发展历史中的遗留问题

网上有一张图非常生动的展示了回调地狱的臃肿与可读性差:

JavaScript回调地狱.jpg

Promise链式调用

Promise 链式调用是解决回调地狱的一种方法。依靠then()方法会返回一个新生成的 Promise 对象特性,继续串联下一环任务,直到结束。then()回调函数中的返回值,会影响新生成的 Promise 对象最终状态和结果

image-20230610125635169.png

每个 Promise 对象中管理一个异步任务,用 then 返回 Promise 对象,串联起来

下面为大家举一个使用 Promise 链式调用的例子:

  • 使用 axios 实现省市区联动

  • 通过 then 返回的 Promise 对象实现 axios 请求同步

    let pname
    axios({ url: '省份信息接口地址' })
      .then(res => {
        pname = res.data.list[0]
        document.querySelector('.province').innerHTML = pname
        return axios({ url: '省份对应城市信息接口地址', params: { pname } })
      })
      .then(res => {
        const cname = res.data.list[0]
        document.querySelector('.city').innerHTML = cname
        return axios({ url: '省市对应地区信息接口地址', params: { pname, cname } })
      })
      .then(res => {
        document.querySelector('.area').innerHTML = res.data.list[0]
      })
    
  • 整个 axios 链式结构如下:

image-20230610130657164.png

Promise 链式调用在 JavaScript 中处理异步操作时十分常见,这种调用链的形式使得异步代码更加清晰和可读

拒绝期约与拒绝错误处理

注:本节内容为红宝书对应小节内容的提炼,有助于对后文中异步代码报错问题的理解

拒绝期约类似于 throw() 表达式,因为它们都代表一种程序状态,即需要中断或者特殊处理。在期约的执行函数或处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由。因此以下这些期约都会以一个错误对象为由被拒绝:

let p1 = new Promise((resolve, reject) => reject(Error('foo')))
let p2 = new Promise((resolve, reject) => { throw Error('foo') })
let p3 = Promise.resolve().then(() => { throw Error('foo') })
let p4 = Promise.reject(Error('foo'))

setTimeout(console.log, 0, p1) // Promise <rejected>: Error: foo 
setTimeout(console.log, 0, p2) // Promise <rejected>: Error: foo 
setTimeout(console.log, 0, p3) // Promise <rejected>: Error: foo 
setTimeout(console.log, 0, p4) // Promise <rejected>: Error: foo

期约可以以任何理由拒绝,包括undefined,但最好统一使用错误对象。这样做主要是因为创建错误对象可以让浏览器捕获错误对象中的栈追踪信息,而这些信息对调试是非常关键的

上述例子中的四个错误的栈追踪信息如下:

Uncaught (in promise) Error: foo 
 at Promise (test.html:5) 
 at new Promise (<anonymous>) 
 at test.html:5 

Uncaught (in promise) Error: foo 
 at Promise (test.html:6) 
 at new Promise (<anonymous>) 
 at test.html:6 

Uncaught (in promise) Error: foo 
 at test.html:8 

Uncaught (in promise) Error: foo 
 at Promise.resolve.then (test.html:7)

Promise.resolve().then()的错误最后才出现,这是因为它需要在运行时消息队列中添加处理程序;也就是说,在最终抛出未捕获错误之前它还会创建另一个期约 (微任务,后文有详解)

此例子引出了一个很重要的现象:

  • 正常情况下,在通过 throw() 关键字抛出错误时,JavaScript 运行时的错误处理机制会停止执行抛出错误之后的任何指令
throw Error('foo')
console.log('bar') // 这一行不会执行
// Uncaught Error: foo
  • 在 Promise 中抛出错误时,因为错误实际上是从消息队列中异步抛出的,所以并不会阻止运行时继续执行同步指令
Promise.reject(Error('foo'))
console.log('bar')
// bar
// Uncaught (in promise) Error: foo
//由此可知:异步错误只能通过异步的 onRejected 处理程序捕获

then()catch() 的 onRejected 处理程序在语义上相当于 try/catch。出发点都是捕获错误之后将其隔离,同时不影响正常逻辑执行。为此,onRejected 处理程序的任务应该是在捕获异步错误之后返回一个解决的期约。下面的例子中对比了同步错误处理与异步错误处理:

console.log('begin synchronous execution')
try {
  throw Error('foo')
} catch (e) {
  console.log('caught error', e)
}
console.log('continue synchronous execution')
// begin synchronous execution 
// caught error Error: foo 
// continue synchronous execution 

new Promise((resolve, reject) => {
  console.log('begin asynchronous execution')
  reject(Error('bar'))
}).catch((e) => {
  console.log('caught error', e)
}).then(() => {
  console.log('continue asynchronous execution')
})
// begin asynchronous execution 
// caught error Error: bar 
// continue asynchronous execution

Promise关键问题

(1)一个 promise 指定多个成功 / 失败回调函数,都会调用吗?

//当promise改变为对象状态时都会调用
let p = new Promise((resolve, reject) => {
  resolve('OK')
})
p.then(res => {
  console.log(res)
})
p.then(res => {
  console.log(res)
})

当 Promise 对象状态改变时,多个对应的成功/失败回调函数均会调用

(2)改变 Promise 状态和执行回调函数谁先谁后?

let p = new Promise((resolve, reject) => {
  //resolve('OK')
  setTimeout(() => {
    resolve('ok')
  },1000)
})
p.then(res => {
  console.log(res)
})

在 Promise 对象状态改变前,then()catch()均不会执行

(3)Promise 异常穿透

let p = new Promise((resolve, reject) => {
  reject('error')
}).then(res => {
  console.log(res)
}).then(res => {
  console.log(res)
}).catch(err => {
// 直接穿透前面的then函数,执行catch函数
  console.log(err) //error
})

(1)当使用 promise 的 then 链式调用时,可以在最后指定失败的回调

(2)前面任何操作出了异常,都会传到最后失败的回调中处理

(4)如何中断 Promise 链?

当使用 promise 的 then 链式调用时,在中间中断,不再调用后面的回调函数

  • 在回调函数中返回一个 pendding 状态的 promise 对象(可能会造成内存泄露
  • 直接通过throw抛出错误对象
  • 使用reject
  • 在回调函数中返回一个 pendding 状态的 promise 对象
let p = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('ok')
  }, 1000)
}).then(res => {
  console.log(res) //想在此处中断
  return new Promise(() => { }) //返回 pendding 状态的 promise 的对象,中断!
}).then(res => {
  console.log(res)
}).then(res => {
  console.log(res)
})
  • 直接通过throw抛出错误对象
let p = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('ok')
  }, 1000)
}).then(res => {
  console.log(res) //想在此处中断
  throw new Error('error here')
}).then(res => {
  console.log('ok1')
}).then(res => {
  console.log('ok2')
}).catch(error => {
  console.log('catch error:', error)
})
  • 使用reject
let p = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('ok')
  }, 1000)
}).then(res => {
  console.log(res) //想在此处中断
  return Promise.reject('error here')
}).then(res => {
  console.log('ok1')
}).then(res => {
  console.log('ok2')
}).catch(error => {
  console.log('catch error:', error)
})

async和await语法糖

async 和 await 本质上是官方推出的 Promise 链式调用的优化语法 (ES8 新规范)。在 async 函数内,使用 await 关键字取代 then 函数,等待获取 Promise 对象成功状态的结果值 ,此种用法本质上就是 Promise 链式调用,只不过代码逻辑上形似于同步代码,通过使用 async 和 await 解决回调地狱问题

async

async 关键字用于声明⼀个异步函数(如 async function asyncTask1() {...}

  • async 会自动将常规函数转换成 Promise,返回值也是⼀个 Promise 对象
  • Promise 对象的结果由 async 函数执行的返回值决定
  • async 函数内部可以使用 await

MDN对于 async 函数定义如下:

  • async 函数是使用async关键字声明的函数。async 函数是 AsyncFunction 构造函数的实例,并且其中允许使用 await 关键字。asyncawait 关键字让我们可以用一种更简洁的方式写出基于 Promise 的异步行为,而无需刻意地链式调用 promise

async 关键字可以用在函数声明、函数表达式、箭头函数和方法

// 使用 async 函数声明
async function asyncFunctionDeclaration() {...}

// 使用 async 函数表达式
const asyncFunctionExpression = async function() {...}

// 使用 async 箭头函数
const asyncArrowFunction = async () => {...}

// 对象方法中使用 async
const obj = {
  async asyncMethod() {...}
}
  • 使用 async 关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。 而在参数或闭包方面,异步函数仍然具有普通 JavaScript 函数的正常行为
//foo()函数仍然会在后面的指令之前执行
async function foo() { 
  console.log(1)
} 
foo() 
console.log(2)
// 1 
// 2
  • 与 Promise 不同的是,async 声明的函数可以使用 return 关键字。 异步函数如果使用return关键字返回了值(没有 return 默认返回 undefined),这个值会被Promise.resolve()包装成一个期约对象。异步函数始终返回期约对象。在函数外部调用这个函数可以得到它返回的期约。当然,直接返回一个期约对象也是一样的
async function foo () {
  console.log(1)
  return 3
}
// 给返回的期约添加一个解决处理程序
foo().then(console.log) //这里会自动将return的值传入console.log()函数中
console.log(2)
// 1 
// 2 
// 3

async function foo () {
  console.log(1)
  return Promise.resolve(3)
}
// 给返回的期约添加一个解决处理程序
foo().then(console.log)
console.log(2)
// 1 
// 2 
// 3
  • 与在期约处理程序中一样,在异步函数中抛出错误会返回拒绝的期约(被Promise.reject()包装成一个期约对象)不过,拒绝期约的错误不会被异步函数捕获(需要使用return
async function foo () {
  console.log(1)
  throw 3
}
// 给返回的期约添加一个拒绝处理程序
foo().catch(console.log)
console.log(2)
// 1 
// 2
// 3

async function foo () {
  console.log(1)
  Promise.reject(3)
  //改成 return Promise.reject(3),则可以正常抛出错误
}
// Attach a rejected handler to the returned promise
foo().catch(console.log)
console.log(2)
// 1
// 2
// Uncaught (in promise): 3

await

await 用于等待异步的功能执行完毕 const result = await someAsyncCall()

  • await 放置在 Promise 调用之前,会强制 async 函数中其他代码等待,直到 Promise 完成并返回结果,才会恢复异步函数的执行
  • await 只能在 async 函数内部使用
  • await 右侧表达式一般为 promise对象,但也可以是其他值
    • 如果表达式是 promise 对象,await 返回的是 promise 成功的值
    • 如果表达式是其他值,直接将此值作为 await 的返回值
  • 如果 awaitpromise失败了,就会抛出异常,需要通过 try/catch 捕获处理

async/await中真正起作用的是awaitasync关键字,无论从哪方面来看,都不过是一个标识符。毕竟异步函数如果不包含await关键字,其执行基本上跟普通函数没有什么区别

例如:

async function foo() { 
 console.log(2)
} 
console.log(1)
foo()
console.log(3)
// 1 
// 2 
// 3

JavaScript 运行时在碰到await关键字时,会记录在哪里暂停执行。等到await右边的值可用了,JavaScript 运行时会向消息队列中推送一个任务,这个任务会恢复异步函数的执行。因此,即使await后面跟着一个立即可用的值,函数的其余部分也被异步求值

红宝书中有一个很经典的例子:

async function foo() { 
 console.log(2); 
 console.log(await Promise.resolve(8)); //微任务
 console.log(9); 
} 
async function bar() {
 console.log(4); 
 console.log(await 6); //微任务
 console.log(7); 
} 
console.log(1); 
foo(); 
console.log(3); 
bar(); 
console.log(5); 
// 1 
// 2 
// 3 
// 4 
// 5 
// 8
// 9 
// 6 
// 7
// 同步任务先于异步任务(此处为微任务)执行
// 微任务根据进入微任务队列的先后顺序,当主队列同步任务执行完后,开始依次执行

相较于 Promise,async/await 有何优势?

  1. 同步化代码的阅读体验(Promise 虽然摆脱了回调地狱,但 then 链式调用的阅读负担还是存在)
  2. 和同步代码更一致的错误处理方式( async/await 可以用 try/catch 做处理,比 Promise 的错误捕获更简洁直观)
  3. 调试时的阅读性, 也相对更友好

await 后面的异步函数中发生了错误(即 Promise 被拒绝),可以通过使用 try...catch 块来捕获和处理这个错误

async function example () {
  try {
    const result = await someAsyncFunction()
    console.log(result) //此行不执行
  } catch (error) {
    console.error("error:", error.message) //error : Async operation failed
  }
}

async function someAsyncFunction () {
  return new Promise((resolve, reject) => {
    // 模拟异步操作
    setTimeout(() => {
      // 模拟异步操作中的错误
      reject(new Error("Async operation failed"))
    }, 1000)
  })
}

// 调用异步函数
example()

事件循环-EventLoop

事件循环定义

JavaScript 采用了一种基于事件循环的并发模型,该模型专门负责管理代码执行流程、监听和处理各类事件,并按序执行异步任务队列中的子任务。这一机制与诸如C、Java等传统多线程编程语言中的并发模型形成了显著区别

事件循环机制的设计初衷,正是针对 JavaScript 语言本身的单线程性质,旨在避免因长时间执行同步代码而导致的主线程阻塞现象,进而实现非阻塞式的异步编程体验。通过事件循环,JavaScript 能够高效地协调单线程上的异步操作,确保即便在无法同时执行多个任务的情况下,也能保持良好的响应性和执行效率

简单来说,事件循环是一种在 JavaScript 中用于调度和执行异步任务的机制

宏任务与微任务

宏任务

宏任务指的是那些在 JavaScript 运行环境中由宿主(比如浏览器或Node.js)安排在事件队列中的较大的异步任务单元。每当事件循环开始一个新的迭代时,它会从宏任务队列中取出一个任务来执行

常见的宏任务有:

  • script(整个脚本文件或 <script> 标签的内容)
  • setTimeout
  • setInterval
  • setImmediate(仅 Node.js 环境)
  • I/O 操作(例如网络请求、文件读写等)
  • UI 渲染(浏览器环境中,渲染是一个宏任务)
  • MessageChannel
  • postMessage(跨窗口通信)
  • UI 事件处理程序(如点击事件、键盘事件等)

微任务

微任务是相对于宏任务而言更小粒度的异步任务,它们在当前宏任务执行完成后立即执行,但在渲染下一帧之前。这意味着微任务会在同一次事件循环迭代的末尾插队执行,拥有比宏任务更高的执行优先级

常见的微任务有:

  • Promise.then.catch.finally 方法回调
  • Promise 构造函数执行时的内部处理
  • MutationObserver(DOM变动观察器)
  • process.nextTick(仅 Node.js 环境)
  • queueMicrotask API(直接调度微任务的方式)

事件循环流程

在 JavaScript 中,由于单线程执行模型的限制,为了实现异步操作,JavaScript 引擎通过与宿主环境通信,将某些任务(如定时器、I/O操作)委托给宿主环境的底层线程去处理。当这些异步任务完成后,宿主环境会将回调函数放入相应的任务队列(如宏任务队列或微任务队列)中,等待事件循环机制将其取回并安排在主线程上执行

JavaScript 会执行完宏任务里面所有同步代码,遇到 宏任务/微任务 交给宿主环境,有结果的回调函数进入对应队列,当执行栈空闲时,清空微任务队列,再执行下一个宏任务。这种机制确保了微任务总是在同一次事件循环的最后阶段执行,并且在任何新的宏任务之前完成

image-20230611114744827.png

总的来说,JavaScript 会先执行完所有的同步代码,然后清空微任务队列,再执行下一个宏任务

小试牛刀

学完了 JavaScript 异步编程机制相关的概念,下面我们将通过一些经典面试题加以巩固,先梳理一下相关概念:

JavaScript 是异步单线程语言,同步事件执行完才执行事件循环内容(宏任务和微任务)

  • 同步任务>微任务> 宏任务
  • 对于 async 函数,只有从 await 往下才是异步的开始

image-20230610223549066.png

await fn2() 之后的代码作为微任务执行,但这并不是因为它是 await 关键字下的代码,而是因为在等待 Promise 完成之后,该部分代码会被当作微任务执行

注:本节console.log输出均为换行输出,只是为了更直观,书写成同一行

script宏任务

<script>
  console.log(1)
  setTimeout(() => {
    console.log(2) // 先进入宏任务队列排队
  }, 0)
  console.log(3)
</script>
<script>
  console.log(4)
  setTimeout(() => {
    console.log(5) // 后进入宏任务队列排队
  }, 0)
  console.log(6)
</script>
<!--输出为:1 3 4 6 2 5 -->

先执行第一个script宏任务中的同步代码,异步代码放入宿主环境等待执行。当执行栈空闲时(执行完同步任务),清空微任务队列(执行微任务),再执行下一个宏任务,上述过程不断重复

DOM操作

document.addEventListener('click', () => {
  let p = new Promise(resolve => resolve(1))
  p.then(result => console.log(result)) //微任务
  console.log(2) 
})
document.addEventListener('click', () => {
  let p = new Promise(resolve => resolve(3))
  p.then(result => console.log(result))
  console.log(4)
})
// 输出为:2 1 4 3

给同一元素添加相同的事件监听,会按照添加的顺序执行,因此两个宏任务会依次执行

执行机制:同步任务>微任务> 宏任务,于是会输出 2 1 4 3 而不是 2 4 1 3

Promise、setTimeout

题一

new Promise((resolve, reject) => {
  console.log(1)
  new Promise((resolve, reject) => {
    console.log(2)
    setTimeout(() => {
      console.log(3)
    }, 0)
    console.log(4)
  })
  console.log(5)
})
setTimeout(() => {
  console.log(6)
}, 1000)
console.log(7)
// 输出为:1 2 4 5 7 3 6

Promise 本身是同步的,而 then 和 catch 回调函数是异步的;此外,对于setTimeout来说,延迟时间决定了执行的先后(setTimeout本身是宏任务,会按照进入宏任务队列次序来执行,但其本身有延迟时间,在宿主环境中倒计时结束后才会得到对应的结果,所以整体上看其延迟时间决定了执行的先后)

例如:将 promise 内部的 setTimeout 时间改为 1000,外部 setTimeout 改为 0,则输出变成 1 2 4 5 7 6 3

题二

setTimeout(() => {
  console.log(1)
  new Promise((resolve, reject) => {
    resolve(2)
  }).then(res => {
    console.log(res)
    setTimeout(() => {
      console.log(3)
    }, 1000)
  })
}, 0)
console.log(4)
setTimeout(() => {
  console.log(5)
}, 5000)
console.log(6)
// 输出为:4 6 1 2 3 5

同步代码 > 微任务 > 宏任务,setTimeout按照宏任务队列排队顺序执行,但得到结果的时间取决于其延迟时间

对于相同 setTimeout 按延迟时间排序执行:

  • 如果把 5000 改为 500,则执行顺序变成:4 6 1 2 5 3
  • 如果把 5000 改为 0,把 0 改成 1000,则执行顺序变成:4 6 5 1 2 3

题三

本题为 Node.js 环境(process 对象是 Node.js 环境中的一个全局对象)

console.log('1')

setTimeout(function () {
  console.log('2')
  process.nextTick(function () {
    console.log('3')
  })
  new Promise(function (resolve) {
    console.log('4')
    resolve()
  }).then(function () {
    console.log('5')
  })
})

process.nextTick(function () {
  console.log('6')
})

new Promise(function (resolve) {
  console.log('7')
  resolve()
}).then(function () {
  console.log('8')
})

setTimeout(function () {
  console.log('9')
  process.nextTick(function () {
    console.log('10')
  })
  new Promise(function (resolve) {
    console.log('11')
    resolve()
  }).then(function () {
    console.log('12')
  })
})
// 输出为:1 7 6 8 2 4 3 5 9 11 10 12

process.nextTick为微任务,当宏任务中的同步任务和微任务执行完毕之后,才会执行下一个宏任务

async、await

注意点:

  • await会阻塞其所在表达式中后续表达式的执行,在同一个作用域的await后面的代码相当于微任务,放到微任务队列

  • async 关键字用于声明⼀个异步函数,此外async 会自动将常规函数转换成 Promise,返回值也是⼀个 Promise 对象

在JavaScript中,async函数总是返回一个Promise对象,即使你不显式地返回一个Promise。当你在async函数中使用return 666这样的常规值时,JavaScript会自动将其包裹在一个已经resolve的Promise对象中返回。例如:

async function asyncFunction() {
  return 666
}

const result = asyncFunction() // 这里result实际上是一个Promise对象
result.then(value => console.log(value)) // 输出:666

相比之下,如果你显式地返回一个Promise对象,例如:

async function asyncFunction() {
  return new Promise((resolve, reject) => {
    resolve(666)
  })
}

const result = asyncFunction()
result.then(value => console.log(value)) // 输出:666

两者的差异在于:

  1. 封装行为:前者隐式地将返回值封装进Promise,后者显式地创建并返回一个Promise实例
  2. 控制流程:对于异步操作,显式地返回Promise可以让你控制何时resolve/reject这个Promise,可以根据异步操作的实际完成情况进行处理。而对于返回普通值的情况,由于已经是同步完成,Promise会立即resolve

显式与隐式在执行上会有些许区别:

  • 隐式返回,await 会立即得到这个值,不会引起异步等待(本身await后面代码也相当于是异步)
  • 显式返回,await 不会立即得到这个值,引起异步等待(时间上差异极小,但会影响代码执行顺序,可以理解成排两次队)

具体例子,看下面几道题

题一

async function async1() {
  console.log(1)
  await async2()
  console.log(2)
}
async function async2() {
  return new Promise((resolve, reject) => {
    reject(new Error(''))
  })
  .catch(err=>{
     console.log(err)
  })
}
console.log(3)
setTimeout(function () {
  console.log(4)
}, 0)
async1()
new Promise(function (resolve) {
  console.log(5)
  resolve()
}).then(function () {
  console.log(6)
})
console.log(7)
// 输出为: 3 1 5 7 (打印error) 6 2 4
  • 同步代码console.log(3)执行完毕,输出 3
  • 执行到setTimeout,进入宏任务队列
  • 执行到async1,执行其中的同步代码console.log(1),输出 1(3 1);执行await async2()时,遇到第一个微任务catch(err => console.log(err)),进入微任务队列排队。由于显式返回 promise 对象,引起异步等待(console.log(2)会继续等待第一个微任务完成,才进入微任务队列)
  • 执行new Promise中的同步代码console.log(5),输出 5(3 1 5) ,第二个微任务then(()=>console.log(6)),进入微任务队列排队
  • 执行console.log(7),输出 7(3 1 5 7)同步任务执行完毕,开始清空微任务队列
  • 执行第一个微任务catch(err => console.log(err)),打印 error(3 1 5 7 (打印error)),执行完成后,console.log(2)作为第三个微任务进入微任务队列
  • 执行第二个微任务then(()=>console.log(6)),输出 6(3 1 5 7 (打印error) 6)
  • 执行第三个微任务console.log(2),输出 2(3 1 5 7 (打印error) 6 2)微任务队列清空
  • 执行最后的宏任务setTimeout代码块,输出 4(3 1 5 7 (打印error) 6 2 4)
  • 得到最终结果 3 1 5 7 (打印error) 6 2 4

题二(题一变种)

async function async1() {
  console.log(1)
  await async2()
  console.log(2)
}
async function async2() {
  return 666
}
console.log(3)
setTimeout(function () {
  console.log(4)
}, 0)
async1()
new Promise(function (resolve) {
  console.log(5)
  resolve()
}).then(function () {
  console.log(6)
})
console.log(7)
// 输出为: 3 1 5 7 2 6 4

与题一的区别是,这里是隐式返回 promise 对象,await 会立即得到这个值,不会引起异步等待

因此:微任务console.log(2)会先于微任务console.log(6)进入微任务队列排队

题三(题一变种)

async function async1() {
  console.log(1)
  await async2()
  console.log(2)
}
async function async2() {
  return new Promise((resolve, reject) => {
    reject(new Error(''))
  })
}
console.log(3)
setTimeout(function () {
  console.log(4)
}, 0)
async1()
new Promise(function (resolve) {
  console.log(5)
  resolve()
}).then(function () {
  console.log(6)
})
console.log(7)
// 输出为: 3 1 5 7 6 4 (报错)

在异步代码中,如果一个任务执行失败(即Promise被拒绝),不会立即抛出异常,而是会被放到任务队列中最后抛出(该输出规则为浏览器环境,node环境输出不一样)

也就是说,该错误会在其他代码执行完毕后,才抛出错误。此外,由于console.log(2)处于await后,类似于处于then方法中,因此该行也不会执行(promise变成reject时,then方法不会执行)。由此,输出结果为:3 1 5 7 6 4 (抛错)

题四(题一变种)

async function async1 () {
  return new Promise((resolve, reject) => {
    resolve(1)
  })
}

async function foo () {
  console.log(2)
  console.log(await async1())
  console.log(3)
}

async function bar () {
  console.log(4)
  console.log(await new Promise((resolve, reject) => {
    resolve(5)
  }))
  console.log(6)
}

console.log(7)
foo()
console.log(8)
bar()

new Promise(function (resolve) {
  console.log(9)
  resolve()
}).then(function () {
  console.log(10)
})

console.log(11)
// 输出为:7 2 8 4 9 11 5 6 10 1 3

与 async 声明的函数不同,await 后接执行fulfilled状态的 promise 对象 ,会立即得到该值,这点与 async 函数显示返回的fulfilled状态的 promise 对象不同,代码执行逻辑同题一