从实用性出发理解 JavaScript 的 promise 和 async/await

时间:2021-1-8 作者:admin

前言

其实网上关于Promise和asycn、await的文章很多,但是鉴于本菜鸟看了很多之后依然懵逼,后来看了《JavaScript 高级程序设计第四版》才茅塞顿开的情况下,决定自己写个算是读书笔记的文章吧。

其实看完书后发现,我之前是对 Promise.resolve() 这玩意的作用没理解,看懂了之后基本就打通“任督二脉”了,所以有同样情况的同学们可以选择直接看书,或者…接着看!

写完发现还挺多代码,但其实下面的代码都是超简单的。

那么故事就从为啥要有 Promise 说起

其实就前端来说,是个异步函数而且需要拿返回值来搞事情的基本上就是 Ajax 请求了,当然按照国际惯例文章都是用 setTimeout 来代替。

好,那么事情是这样的,现在有个异步操作,我们要用它执行后的数据:

// 要拿到一秒后 x 加 4 的结果
let x = 3
setTimeout(() => x += 4,1000) 
console.log(x) // 3 (想在这拿到那肯定是不可能的啦) 

那怎么办呢,给这个异步操作加一个回调函数,把函数结果作为回调的入参,就可以拿去为所欲为了:

let x = 3
setTimeout(() => callback(x += 4), 1000)

function callback(result) {
    console.log(result)  // 7 

    // 接下来就可以在这对数据进行操作了
}

ok,直入正题,这种方法的缺点主要有两个:

  1. 需要事先定义好回调函数,要是突然想把数据拿去干点别的,还得在同一个回调添加功能,要是数据在回调内被改了,就很难受。
  2. 如果异步返回值又依赖另一个异步返回值,那就得嵌套回调了,这就是传说中的 回调地狱 ,随着代码越来越复杂,维护起来就是噩梦,简单演示一下:
// 假设还是得拿到 7 这个值,x等于3需要等一个异步操作
let x
setTimeout(() => callback1(x = 3), 1000)

// 回调内嵌套回调
function callback2(result) {
    setTimeout(() => callback1(result += 4), 1000) 
}

function callback2(result) {
    console.log(result)  // 7 
    // 接下来才可以在这对数据进行操作了
}

既然事已至此,那只能让 Promise 来解决这些问题。

Promise的三个状态

Promise 是一个构造函数,通过 new 来实例化,而且实例化的时候要传入一个函数,否则会报错。

let p1 = new Promise()  // 报错 TypeError
let p2 = new Promise(()=>{}) 
console.log(p2) // Promise {<pending>}

书里管 Promise 叫期约,那么我们接下来也叫期约吧,我觉得其实更好理解一点。

实例化后的期约会有三个状态:

  • 待定(pengding)
  • 解决(resolved || fullfilled)
  • 拒绝(rejected)

它就像一颗鸡蛋,你只需要知道它可能会有3个状态:等待被吃(最初形态),然后或者孵化成小鸡,或者变成煎蛋,而且状态是不可能逆的,就像变成小鸡后就不能变成煎蛋了。最后,你只需要知道怎么变成这3种状态就可以了。演示一下:

  1. 解决状态:
// 函数内接受两个参数,当执行了resolve,状态就会变成解决
let p2 = new Promise((resolve, reject) => { resolve() }) 
console.log(p2) // Promise {<resolved>} (其实浏览器显示的是fullfilled,但是resolved大家可能熟一点,所以就写resolved)
  1. 拒绝状态
// 同理,执行 reject 就是拒绝
let p2 = new Promise((resolve, reject) => { reject() })
console.log(p2) // Promise {<rejected>}
  1. 待定状态
// 所以,两个都不执行,就是待定
let p2 = new Promise((resolve, reject) => {})
console.log(p2) // Promise {<pending>}

上面的解决期约和拒绝期约都可以接收一个值,也很容易理解,就是 resolve 和 reject 里面放什么值,这个期约就是什么值,上面没有放,所以值其实是 undefined

// 获得一个值是123的解决期约
let p1 = new Promise((resolve, reject) => { resolve(123) }) 
console.log(p1) // Promise {<resolved>} :123

// 获得一个值是321的拒绝期约
let p1 = new Promise((resolve, reject) => { reject(321) }) 
console.log(p1) // Promise {<rejected>} :321

其实到这里就可以简单说一下期约的工作原理了:
它就是把异步操作放在函数内,等异步操作完之后执行一下 resolve(“想要的值”),就可以把值拿到,你会发现它跟开头说的回调函数很像:

let x = 3
let p1 = new Promise((resolve, reject) => {
    setTimeout(() => resolve(x += 4), 1000)
})
console.log(p1) // Promise { <pending> } (此时p1还处于待定状态,因为上面代码是1秒后才执行resolve)
setTimeout(console.log, 1000, p1) // Promise {<resolved>} :7 (1秒后才可以拿到值为7的解决期约)

// 开头介绍的回调方法 
setTimeout(() => callback(x += 4), 1000)

虽然说我们现在只是拿到了一个有值的期约,后面还要知道怎么使用,但是在那之前,其实可以想到它已经解决了用回调函数的第一个缺点:需要事先定义函数;明显用 Promise 的话,并不需要额外的定义什么,反正我们拿着这个包含我们想要的值的期约 p1,想拿去哪里就拿去哪里。

接着,我们回归初心,我们的目标其实是拿到值,而不是一个期约,所以期约就为我们提供了一些方法,用来操作期约里的值,就是传说中的then 和 catch 了。

// 省点代码,已知p1 是一个值为7的解决期约
p1.then((result)=>{
    console.log(result) // 7 (1秒后输出)

    //  接下来可以在这对数据进行操作了
})

then 里面接收两个函数,第一个函数可以拿到解决期约的值,第二个可以拿到拒绝期约的值,就像这样:

// 1秒后 p2 是一个值为7的拒绝期约
let p2 = new Promise((resolve, reject) => {
    setTimeout(() => reject(7), 1000)
})

// 两个只会执行一个
p2.then((result) => {
    console.log(result) // 不会执行
}, (result) => {
    console.log(result) // 7 (1秒后输出)
})

// 或者用catch
p2.catch((result) => {
    console.log(result) // 7 (1秒后输出)
})

// 因为是拒绝期约,就是报错用的,所以一般会封装个错误类型
p2.catch((result) => {
    console.log(new Error(result)) // Error: 7 (1秒后输出)
})

可以看到,then 和 catch 里面的代码都是1秒后才会执行,证明只有期约里面执行了 resolve 或者 reject ,他们才会执行;而值的传递可以理解成一个管道:resolve 里的值会传输到 then 的第一个函数,reject 里的值会传到 then 的第二个函数或者 catch 里面;至于怎么实现我觉得可以忽略;

上面是期约的使用方法,会发现其实跟使用回调函数的复杂度是一样的,所以接下来说一下期约怎么解决第二个缺点:回调地狱,在此之前得说一下 Promise.resolve() 我觉得这东西重要且实用

Promise.resolve()

基本用法

其实很好理解,它就是把其他类型的值转化成一个期约,返回一个解决期约,下面两个期约实例实际上是一样的,它们都是返回一个值为 undefined 的解决期约。

let p1 = new Promise((resolve) => { resolve() })  // 
let p2 = Promise.resolve()

引用一下书里的话:

使用这个静态方法可以把任何值转换成一个期约:
console.log(Promise.resolve(3))
// Promise { resolved } :3

// 多余的参数会忽略
console.log(Promise.resolve(4, 5, 6))
// Promise { resolved } :4

注意:当包的是抛出的一个错误,会变成拒绝期约:

特殊之处

上面是它的基本用法,它还有一个很重要的作用就是:
当传入的是一个期约的时候,它就类似于一个空包装。
意思就是Promise.resolve(另一个期约)的时候,啥也不干,可以直接忽略 Promise.resolve(),是空包装 空包装 空包装:

let p1 = Promise.resolve(7)  // 一个值为7的解决期约
let p2 = Promise.resolve(p1)
console.log(p1 === p2) // true

// 或者层层套娃
let p3 = Promise.resolve(Promise.resolve(Promise.resolve(p2)))
console.log(p3 === p2) // true

接下来其实就可以讲期约的链式调用了,不过稍微带一下Promise.reject()

Promise.reject()

基本用法是一样的:返回一个拒绝期约。不同之处就是当里面是期约时,它不像Promise.resolve()是一个空包装,而是把传入的期约作为返回值:

let p1 = Promise.resolve(7)
let p2 = Promise.reject(p1)
console.log(p2) // Promise <rejected>:{Promise :<resolve>:7}

期约的链式调用,解决回调地狱

其实理解了一个点,就懂了:

Promise.prototype.then() 返回一个新的期约实例,怎么返回,就是通过 Promise.resolve() 包装来生成新期约。

就跟 jQuery 一样,怎样链式调用,每个方法都返回一个 jQuery 对象不就可以了。

而 Promise 骚就骚在 then 方法会默认用 Promise.resolve() 去包装返回值,我以前一直困惑在它里面明明没有返回值或者返回了一个字符串啥的,怎么就变成了一个期约!原来偷偷转换了,上点书里的代码加深理解吧:

let p1 = Promise.resolve("foo")

// 这些都是一样的
let p2 = p1.then(() => { })
let p3 = p1.then(() => undefined)
let p4 = p1.then(() => Promise.resolve())

setTimeout(console.log, 0, p2) // Promise <resolved>:undefined
setTimeout(console.log, 0, p3) // Promise <resolved>:undefined
setTimeout(console.log, 0, p4) // Promise <resolved>:undefined

// 这些都是一样的
let p5 = p1.then(() => 'bar')
let p6 = p1.then(() => Promise.resolve('bar'))

setTimeout(console.log, 0, p5) // Promise <resolved>:bar
setTimeout(console.log, 0, p6) // Promise <resolved>:bar

// 返回拒绝期约的情况
let p5 = p1.then(() => Promise.reject('bar'))
let p6 = p1.then(() => throw 'bar')

setTimeout(console.log, 0, p5) // Promise <rejected>:bar
setTimeout(console.log, 0, p6) // Promise <rejected>:bar

那么我们再一次回归初心,解决回调地狱,刚开始的例子改装一下就会变成这样:

let x
let p1 = new Promise((resolve) => {
    setTimeout(() => resolve(3), 1000)
})

// 等待 p1 状态变化才会执行
p1.then((x) => x + 4)  // 返回一个值为7的解决期约
   .then((x) => { console.log(x + 5) })  // 12 (没有return,所以返回的是值为undefined的解决期约)
   .then((data) => {
        console.log(data) // undefined
        return "突然又想返回个东西" 
    })
   .then(console.log) // "突然又想返回个东西"

这样就解决了回调地狱了,而且还挺优雅。

其实关于Promise的用法基本就是这些,不过上面基本都是说解决期约,下面补充一点拒绝期约的知识点吧:

关于拒绝期约的零零碎碎

  • 处理拒绝期约的规范写法
// 一般会用内置的错误类型包装错误信息,用来模拟浏览器自身触发的报错
let p1 = Promise.reject(Error('bar'))

// 不传 resolved 处理程序的写法
p1.then(null, (result) => console.log(result)) // Error: bar

// 或者直接用 catch
p1.catch((result) => console.log(result)) // Error: bar
  • 错误处理完还是会用 Promise.resolve() 包装从而变成解决的期约。意思是你都处理完错误了,那我就返回一个解决的期约呗。
// p1 跟上面一样
let p2 = p1.then(null, (result) => console.log(result)) // Error: bar

let p3 = p1.catch((result) => {
    console.log(result)
    return 123
})
setTimeout(console.log, 0, p2) //  Promise <resolved>:undefined
setTimeout(console.log, 0, p3) //  Promise <resolved>:123
  • 拒绝期约不会被 try/catch 捕捉到,得用上面的方法处理

异步函数 async/await

这两个关键字还是从用法入手,以最终会用为目的来写一下。
还有关于它们是生成器的语法糖,这个点我觉得可以不用管,那个生成器感觉很少会用到,直接理解这个先进的吧

async

它的作用是声明一个异步函数,但总得来说还是同步执行,书里有句话就简单明了:

async/await 真正起作用的是await。如果异步函数不包含 await 关键字,其执行基本跟普通函数没有什么区别。

然后我觉得它唯一特别的地方就在于,它的返回值也是会被 Promise.resolve()包装从而返回解决的期约。

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

let p = foo()
console.log(p) // Promise <resolved>:3

就是这么简单,并没有很复杂。

await

好,真正起作用的来了

该关键字可以暂停异步函数代码的执行,等待的是一个表达式,这个表达式的返回值可以是一个期约也可以是其他值。

这句话的重点就是 暂停。其实你会发现处理异步操作的中心思想就是等,等到有数据再处理。下面就从执行顺序上来理解一下这个关键字。

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

// 1
// 2
// 3
// 4

上面主要涉及到三个点:

  • await 同行的代码会马上执行。
  • await 后面的代码会被推到微任务队列,然后退出整个 async 函数执行其他代码后再恢复执行函数剩下的代码。(就是个暂停的概念)
  • await 一定要写在 async 定义的函数内,否则会报错的哦。

关于Promise 和 async/await 的执行顺序问题

推荐大家看这篇文章。可以说非常的强,看完之后这方面的面试题基本难不倒你。

知道了上面执行顺序的问题之后,再补充下面一个知识点基本就会用了。

await 的返回值

还记得上面说期约的时候说过,then 方法里面的代码是等到等到期约执行了 resolve 或者 reject 方法后才会执行。

执行 then 的目的就是为了拿到异步操作的返回值嘛,所以 await 的作用:就是直接拿到异步操作的返回值,它不再是返回一个期约什么的,而是直接把值返回,也可以理解为跳过了 then 的这一步直接拿到值,而且跟 then 一样,会等到期约执行了resolve 或者 reject 方法后才会有返回值。这样写法上就更加简洁,还显得很厉害的样子。

演示一下await 右边代码的各种情况:

// 跟一个没什么特别的值,可以简单粗暴的当作 await 不存在。当然 await 后面的代码还是会异步执行。
async function foo() {
    let result = await 123
    console.log(result)  // 123
}
foo()


// 跟一个期约,其实就跟我上面说的一样,写法上跳过 then 直接拿到值
async function foo() {
    let result = await Promise.resolve(555)
    console.log(result)  // 555
}
foo()

写个工作中可能常用的写法吧:

// 发送请求并返回一个期约
function request() {
    let p1 = new Promise((resolve, reject) => {
        // 此处是个随便写的ajax请求方法
        ajax({
            url: XXXX,
            success: function (data) {
                resolve(data)  // 请求成功了很开心,把值封装起来,期约变成解决状态
            },
            error(err) {
                reject(err)   // 请求失败就把值封装给reject,期约变成拒绝状态
            }
        })
    })
    return p1
}

// 这种情况其实也是 await 后面跟一个期约,因为函数返回的就是个期约。
async function foo() {
    let data = await request()

    // 拿到请求回来的数据做点事情
    console.log(data)
}
foo()

就上面这个做个小总结:

  • 其实不管发送请求的代码有多复杂,你只需要知道整个函数返回的是一个期约,这个期约的最终状态是什么,带出来的值是什么,就可以了。然后就可以通过 await 或者 then 两种方法去使用它。
  • await 也不会管你后面的请求要多久,反正期约有值之后它才会继续执行后面的代码,这也就是一个暂停的概念。

最后

新手上路,有错误的地方热烈欢迎指正。
推荐大家直接看《JavaScript 高级程序设计第四版》,里面还是有挺多细节的。虽然我记得的重点就上面那些了

参考

《JavaScript 高级程序设计第四版》
从一道题浅说 JavaScript 的事件循环(解决面试题的,记得看)

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。