日期:2024年5月19日

异步编程

在计算机的世界中程序有同步和异步两种不同的运行方式。通常情况下在其他的语言中我们所编写的代码都是同步运行的,所谓的同步运行也就是代码一行一行按顺序执行,一行执行完才会执行下一行。像下边这样一个操作,就是非常典型的同步代码:

function sum(a, b){
    return a + b
}

console.log("第一行打印")
let result = sum(123, 456)
console.log(result)
console.log("第二行打印")

同步代码的逻辑清晰、结构简单、容易理解。同步就好像我们在按照顺序做事情:先刷牙,再洗脸,然后洗头发,接着抹擦脸油,最后吹头发。总之一件事干完才去干下一件事。同步看上去十分合理,但是真正操作起来却不总是那么的尽如人意。

阻塞

同步的最大问题是阻塞,所谓阻塞就是一段代码不执行完毕其后的所有代码也不会执行。比如上述的案例中,如果sum()函数执行速度比较慢,由于我们编写的是同步代码,所以在sum()执行完之前其后所有代码都不会执行,也就是它会阻塞后边代码的执行。像是这样:

function sum(a, b){
    let begin = Date.now()
    while(Date.now() - begin < 10000){

    }
    return a + b
}

console.log("第一行打印")
let result = sum(123, 456)
console.log(result)
console.log("第二行打印")

上例中sum()执行时会停顿10秒,10秒以后才会返回结果,由于是同步执行的代码,所以sum()会阻塞其后所有代码的执行,导致整个程序的执行速度极差。

想象这样一个场景:如果人是同步运行的会怎么样呢?假如一个人必须要做完一件事才能做下一件事情,会是什么样子的?

昨天我买了一管牙膏,快递还没到,可是我早上起来的第一件事就是刷牙,然后洗脸等一系列的事情依次进行。由于我是同步运行的,如果不刷牙其他的事情都无法进行,于是出现了这样一个场景,一大清早,我站在镜子前手里举着牙刷发呆,我在干嘛?等着牙膏来了,我好去刷牙……没有牙膏这件事导致我无法刷牙,而刷牙不能完成后续工作我也无法继续,换句话说,刷牙阻塞了我的其他操作。

异步

现实生活中我们都是异步的,没有牙膏不能刷牙时,我们会先去做其他的事情,洗脸、洗头发等等,等到牙膏送到了我们会回过头再去完成刷牙这个操作,换句话说如果两件事之间没有必然的先后联系,一件事不应该阻塞到另一件事情的运行,这样才足够的效率。

在程序中也是如此,程序中有些代码的执行速度很快,比如:打印一个内容、接收一个请求、发送一个响应等这些都是简单且快速的操作,但有些操作却很慢,比如:读写硬盘中的文件(I/O操作)。显然我们是不希望那些运行很慢的操作阻塞到那些运行快的操作的。那要如何处理呢?

对于其他的编程语言,如java,它的处理方式简单且粗暴,即多线程。线程是计算机中运算的执行者,我们代码需要线程来执行,它是一个干活的人。在java中,如果遇到阻塞的问题,我们可以多开几个线程。你不是不希望读取文件影响到其他操作吗?ok,我开一个线程读取文件,另一个线程去做其他操作,这样做是完全可行的,且显得非常高级。就好像我们没有牙膏刷牙了,我们怎么办呢?咔咔咔!影分身之术,一个分身等牙膏,一个分身去洗脸洗头。

但是对于Node.js来说,它本身就是单线程的,没有创建多个线程的能力(就像人不能影分身一样)。那它要怎么办呢?答案就是 —— 异步!在node中,当我们遇到这种比较慢的操作时,都会采用异步的操作。比如,当我们希望去读取一个较大的文件时,node可以先将指令发送给计算机,然后由计算机去读取文件,此时node的线程不是等待计算机数据返回,而是继续向下执行其他的操作,何时去获取计算机读取到的数据呢?不急,等数据返回了我们再去读取,这样既不影响其他操作也可以正常的读取到计算机返回的数据!上边的代码我们便可以这样修改:

function sum(a, b){
    setTimeout(()=>{
        return a + b
    }, 10000)
}

console.log("第一行打印")
let result = sum(123, 456)
console.log(result)
console.log("第二行打印")

上述代码,我们将计算操作放入到了setTimeout中,同样是等待10s,但是setTimeout不会阻塞其他代码的执行,而是在10秒后将函数放入到任务队列中,这样一来就可以很好的解决掉阻塞的问题。

但与此同时也产生了一个问题,函数确实不会阻塞后续代码的执行了,但是由于函数的返回值设置到了setTimeout的回调函数中调用sum时便无法获取到函数的计算结果了,此时我们得到的结果是undefined。这也是异步的一个特点,异步代码的执行结果无法通过返回值获得,返回值只能用来获取同步代码的执行结果。那么如何获取异步代码的执行结果呢?答案只有一个——回调函数,异步代码通常都需要一个回调函数作为参数,当异步代码执行完毕取得结果时便可以将结果作为回调函数的参数进行传递,这样我们便可以在回调函数中来读取结果,并完成后续操作了,像是这样:

function sum(a, b, cb){
    setTimeout(()=>{
        cb(a + b)
    }, 10000)
}

console.log("第一行打印")
sum(123, 456, result => {
    console.log(result)
})
console.log("第二行打印")

通过这样一个介绍,相信你对异步多少能够了解了一些。在java这种多线程的编程语言中,通常需要一个叫做线程池的东西,在程序中随时要有一些线程在线程池中待命,当有任务时需要调用线程去执行程序以完成功能,当线程池中线程不够使用时还必须要创建新的线程协助完成任务,当线程池中的闲置线程过多时也必须要清理掉一些多余的线程。无论是线程池还是线程的管理程序都需要耗费一定的系统的性能,这就要求像java这种多线程的编程语言,必须要运行在一些性能较好的服务器中。

而Node.js这种异步的编程语言,由于始终只有一个线程在干活,不需要线程池存储线程,也无需线程的管理调用程序去管理线程,所以它对服务器的要求就比较低,从而也就降低了服务器的使用成本。但是异步的编程的方式也提升了程序的复杂度,使代码变得难以理解,于是如何简化异步代码,让其更容易编写于维护就是我们下一步要面临的问题。

问题

异步代码最大的问题就是回调函数。由于是异步执行,异步函数无法直接通过返回值来返回执行结果,要想取得结果必须通过回调函数。这也就带来了一个问题,如果我们有两个异步操作需要先后执行,一个异步操作依赖于上一个异步操作的执行结果那要怎么办呢?简单,只需要在第一个异步操作的回调函数中调用第二个异步操作,像是这样:

function sum(a, b, cb){
    setTimeout(()=>{
        cb(a + b)
    }, 10000)
}

sum(123, 456, result => {
    sum(result, 777, result => {
        console.log(result)
    })
})

上例中,调用了两次sum,第一次调用计算了123和456的和,第二次调用是求第一次的运算结果和456的和。由于异步函数的结果只能在回调函数中访问,所以我们只能在回调函数中第二次调用sum。当然,如果事情仅仅是这样其实还好。

现在假设我需要连续调用4个异步函数,且后一个要依赖于前一个的运算结果,也许会是这样的:

function sum(a, b, cb){
    setTimeout(()=>{
        cb(a + b)
    }, 10000)
}

sum(123, 456, result => {
    sum(result, 777, result => {
        sum(result, 888, result => {
            sum(result, 999, result => {
                console.log(result)
            })
        })
    })
})

这样一来我们就见识到了传说中的“回调地狱”,又名“死亡金字塔”。这还只是4次,现实的代码可能比这个还要再复杂一些。比如,某个功能需要用到三个不同异步函数返回的结果。

总之,异步提高了代码运行的效率,同样也增加了代码的复杂程度。于是,我们便需要一个新的东西让我们可以更加优雅的编写异步的代码,Promise应运而生。

Promise

只要是通过回调函数来获取异步的结果,就一定会遇到回调地狱之类的问题。为了解决这个问题,JS为我们提供一个对象 —— Promise,Promise意为承诺,它可以用来存储一个值,并确保在你需要将这个值返回。这一点听上去似乎也并没有什么新奇的地方,任何一个对象都可以存储值,为什么非得用Promise呢?说到这就必须得看看Promise特别的存取数据的方式了!

创建Promise

Promise存储值的方式非常的特别,我们先来看看它的构造函数:

const promise = new Promise(executor)

创建Promise时需要一个executor(执行器)为参数,执行器是一个回调函数,进一步调用它大概长这个样子:

const promise = new Promise((resolve, reject) => {})

回调函数在执行时会收到两个参数,两个参数都是函数。第一个函数通常命名为resolve,第二个函数通常会命名为reject。向Promise中存储值的关键就在于这两个函数,可以将想要存储到Promise中的值作为函数的参数传递,像是这样:

const promise = new Promise((resolve, reject) => {
    resolve("哈哈")
})

这样我们就将”哈哈”这个字符串存储到了Promise中,那么问题又来了,为什么需要两个函数存储值呢?很简单,resolve用来存储运行正确时的数据,reject用来存储运行出错时的错误信息。我们在使用Promise时需要根据不同的情况,调用不同的函数来存储不同的数据。

Promise为什么整了如此复杂的一种方式来存储数据呢?如果仅仅是存储其他的数据,这么做确实有点脱了放。但是Promise是专门为了异步调用而生的,所以Promise中存储的主要是异步调用的数据,也就是那些本来需要通过回调函数来传递的数据。在Promise中,可以直接调用异步代码,在异步代码执行完毕后直接调用resolve或reject来将执行结果存储到Promise中,这就解决了异步代码无法设置返回值的问题。换句话说,异步代码的执行结果可以直接存储到Promise中,像是这样:

const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve("哈哈")
    }, 10000)
})

上例中通过setTimeout实现了一个异步调用,定时器会在10秒后执行,并调用resolve将”哈哈”存储到Promise中。现在你应该能够理解为什么Promise有这么一个奇怪的存储值的方式了吧?

获取Promise中的数据

Promise存储数据的方式奇特,读取方式同样特殊。我们需要通过Promise的实例方法来读取存储到Promise中的数据。现在我们有这样一个Promise,Promise中通过resolve存储了一个数据”哈哈”:

const promise = new Promise((resolve, reject)=>{
    setTimeout(() => {
        resolve("哈哈")
    }, 10000)
})

then

then是Promise的实例方法,通过该方法可以获取到Promise中存储的数据。它需要一个回调函数作为参数,Promise中存储的数据会作为回调函数的实参返回给我们:

const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve("哈哈")
    }, 10000)
})

promise.then((data) => {
    console.log(data) // "哈哈"
})

注意:这种方式只适合读取通过resolve存储的数据,如果存储数据时出现了错误,或者是通过reject存储的数据,这种方式是读取不到的:

const promise = new Promise((resolve, reject) => {
    throw new Error("出错了!")
    setTimeout(() => {
        resolve("哈哈")
    }, 10000)
})

promise.then((data) => {
    console.log(data)
})
const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        reject("哈哈")
    }, 10000)
})

promise.then((data) => {
    console.log(data)
})

上边的两块代码都是读取不到数据的,而且运行时会在控制台报出错误信息。这是因为,then的第一个参数只负责读取Promise中代码正常执行的结果,也就是只有Promise中数据正常时才会被调用。当Promise中的代码出错,或通过reject来添加数据时,我们还需要为其指定第二个参数来处理错误。

then的第二个参数同样是一个回调函数,两个回调的函数的结构相同,不同点在于第一个回调函数会在没有异常时被调用。而第二个函数会在出现错误(或通过reject存储数据)时调用。

const promise = new Promise((resolve, reject) => {
    throw new Error("主动抛出错误")
    setTimeout(() => {
        resolve("哈哈")
    }, 10000)
})

promise.then((data) => {
    console.log(data)
}, (err) => {
    console.log("出错了", err)
})
const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        reject("哈哈")
    }, 10000)
})

promise.then((data) => {
    console.log(data)
}, (err) => {
    console.log("出错了", err)
})

上边两个案例中,then的第二个回调函数会执行。执行时异常信息或时reject中返回的数据会作为参数传递。现实开发中,第二个回调函数通常会用来编写异常处理的代码。

原理

在Promise中维护着两个隐藏的值PromiseResult和PromiseState,PromiseResult是Promise中真正存储值的地方,在Promise中无论是通过resolve、reject还是报错时的异常信息都会存储到PromiseResult中。PromiseState用来表示Promise中值的状态,Promise一共有三种状态:pending、fulfilled、rejected。pending是Promise的初始化状态,此时Promise中没有任何值。fulfilled是Promise的完成状态,此时表示值已经正常存储到了Promise中(通过resolve)。rejected表示拒绝,此时表示值是通过reject存储的或是执行时出现了错误。

当我们调用Promise的then方法时,相当于为Promise设置了一个回调函数,换句话说,then中的回调函数不会立即执行,而是在Promise的PromiseState发生变化时才会执行。如果PromiseState从pending变成了fulfilled则then的第一个回调函数执行,且PromiseResult的值作为参数传递给回调函数。如果PromiseState从pending变成了rejected则then的第二个回调函数执行,且PromiseResult的值作为参数传递给回调函数。

then执行后每次总会返回一个新的Promise,并将then中回调函数的返回值存储到这个Promise中,如果没有指定返回值则新Promise中不会存储任何值。

const promise = new Promise((resolve, reject)=>{
    resolve("第一步执行结果")
})

const promise2 = promise.then(result => {
    console.log("收到结果:", result)
    return "第二步执行结果" // 会作为新的结果存储到新Promise中
})

const promise3 = promise2.then(result => {
    console.log("收到结果:", result)
    return "第三步执行结果" // 会作为新的结果存储到新Promise中
})

在简化一些可以这样写:

const promise = new Promise((resolve, reject)=>{
    resolve("第一步执行结果")
})

promise.then(result => {
    console.log("收到结果:", result)
    return "第二步执行结果" // 会作为新的结果存储到新Promise中
}).then(result => {
    console.log("收到结果:", result)
    return "第三步执行结果" // 会作为新的结果存储到新Promise中
})

第一个then用来读取上边我们创建的Promise中存储的结果,第二个then用来读取第一个then所返回的结果,依此类推我们就可以根据需要一直then下去,如此便解决了“回调地狱”的问题。

有了Promise后,在异步函数中我们便不再需要通过回调函数来返回结果,取而代之的是返回一个Promise,并将异步执行的结果存储到Promise中,像是这样:

function sum(a, b){
    return new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve(a + b)
        }, 10000)
    })
}

由于sum的返回值是一个Promise,所以我们不在需要通过回调函数来读取结果:

sum(123, 456).then(result => {
    console.log("结果为:", result) // 结果为: 579
})

如果需要连续多次调用,也不会在有“回调地狱的问题”:

sum(123, 456)
    .then(result => sum(result, 777))
    .then(result => sum(result, 888))
    .then(result => console.log(result))

catch

除了then以外,Promise中还有一个catch方法,catch和then使用方式类似,但是catch中只需要一个回调函数作为参数。catch中回调函数的作用等同于then中的第二个回调函数,会在执行出错时被调用。既然有了then的第二个参数,为什么还需要一个catch呢?两个回调函数都写到then中,会导致代码不够清晰,但是多了一个catch后立刻就变的不一样了,开发时通常会在then中编写正常运行时的代码,catch中编写出现异常后要执行的代码:

const promise = new Promise((resolve, reject) => {
    reject("出错了")
})


// 出现异常,then中只传了一个回调函数,无法读取数据
// promise.then((data) => {
//     console.log(data)
// }) 

// 出现异常,可以通过catch来读取数据
promise.catch(err => {
    console.log(err)
})

当Promise中代码执行出错时(或者reject执行时),如果我们调用的是catch来处理数据,则Promise会将错误信息传递给catch的回调函数,我们便可以在catch中处理异常,同时catch回调函数的返回值会作为下一步Promise中的数据向下传递。如果我们调用了then来处理数据,同时没有传递第二个参数,这时then是不会执行的,而是将错误信息直接添加到下一步返回的Promise中,由后续的方法处理。在后续调用中如果有catch或then的第二个参数,则正常处理。如果没有,则报错。

简言之,处理Promise时,如果没有对Promise中的异常进行处理(无论是then的二参数,还是catch),则异常信息总是会封装到下一步的Promise中进行传递,直到找到异常处理的代码位置,如果一直没有处理,则报错。

这种设计方式使得我们可以在任意的位置对Promise的异常进行处理,例如有如下代码:

function sum(a, b) {
    return new Promise((resolve, reject) => {
        if (Math.random() > 0.7) {
            throw new Error("出错了")
        }
        resolve(a + b)
    })
}

sum(123, 456)
    .then(result => sum(result, 777))
    .then(result => sum(result, 888))
    .then(result => console.log(result))

上例代码中,sum函数有一定的几率会出现异常,但是我们并不确定何时会出现异常,这时有了catch就变的非常的方便,因为在出现异常后所有的then在异常处理前都不会执行,所以我们可以将catch写在调用链的最后,这样无论哪一步出现异常,我们都可以在最后统一处理。像是这样:

sum(123, 456)
    .then(result => sum(result, 777))
    .then(result => sum(result, 888))
    .then(result => console.log(result))
    .catch(err => console.log("哎呀出错了,随便返回一个吧", 8888))

当然如果我们想在中间处理异常也是没有问题的,只是需要注意在链式调用中间处理异常时,由于后续还有then要执行,所以一定不要忘了考虑是否需要在catch中返回一个结果供后续的Promise使用:

sum(123, 456)
    .then(result => sum(result, 777))
    .catch(err => {
        // 也可以在调用链的中间处理异常
        console.log("出错了,我选择忽略这个错误,重新计算")
        return sum(123, 456)
    })
    .then(result => sum(result, 888))
    .then(result => console.log(result))
    .catch(err => console.log("哎呀出错了,随便返回一个吧", 8888))

还有一点要强调一下,在Promise正常执行的情况下如果遇到catch,catch是不会执行的,此时Promise中的结果会自动传递给下一个Promise供后续使用。

finally

finally也是Promise的实例方法之一,和then、catch不同,无论何种情况finally中的回调函数总会执行,通常我们在finally中定义一些无论Promise正确执行与否都需要处理的工作。注意,finally的回调函数不会接收任何参数,同时finally的返回值也不会成为下一步的Promise中的结果。简单说,finally只是编写一些必须要执行的代码,不会对Promise产生任何实质的影响。

静态方法

Promise的静态方法直接通过Promise类去调用,这些方法可以帮助我们完成一些更加复杂的异步操作。

Promise.all

当我们有多个Promise需要执行,且需要多个Promise都执行完毕,在将他们的结果进行统一处理时,我们便可以使用Promise.all来帮助我们完成这项工作。

Promise.all(iterable)

all需要一个数组(可迭代对象)作为参数,数组中可以存放多个Promise。调用后,all方法会返回一个新Promise,这个Promise会在数组中所有的Promise都执行后完成,并返回所有Promise的结果。比如:

function sum(a, b) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(a + b)
        }, 5000);
    })
}

Promise.all([sum(1, 1), sum(2, 2), sum(3, 3)])
    .then((result) => {
        console.log(result)
    })

上例中,调用三次sum,且将其添加到数组中传递给all。调用后all会返回一个新的Promise,当三次计算都完成后,新的Promise也会变为完成状态,并将三次执行的结果封装到数组中返回。

Promise.allSettled

all仅有当全部Promise都完成时才会返回有效数据,而allSettled用法和all一致,但是它里边无论Promise是否完成都会返回数据,只是他会根据不同的状态返回不同的数据。

成功:{status:”fulfilled”, value:result}

失败:{status:”rejected”, reason:error}

Promise.race

race会返回首先执行完的Promise,而忽略其他未执行完的Promise

Promise.any

any会race类似,但是它只会返回第一个成功的Promise,如果所有的Promise都失败才会返回一个错误信息。

Promise.resolve

Promise.resolve用来创建一个新的Promise实例,且直接通过resolve存入一个数据。

Promise.reject

Promise.reject用来创建一个新的Promise实例,且直接通过reject存入一个数据。

4.9 23 投票数
文章评分
订阅评论
提醒
guest

10 评论
最旧
最新 最多投票
内联反馈
查看所有评论
李伟鸿
1 年 前

大佬太细节了

断章*
断章*
1 年 前

老师,class里的 # 和 static 关键字有什么区别吗

最后由断章*编辑于1 年 前
zxw
zxw
1 年 前

老师,我运行了您的自定义promise程序,没做任何改动,但是程序报错,这是什么原因呢

uTools_1669186137075.png
chenjinpo
chenjinpo
1 年 前

深入浅出,赞。能出一版教程介绍下async和await么

bugFixed
bugFixed
1 年 前

看完老师的你问我答promise,再来看这篇文章,写得真的太好了,思维逻辑很清晰,一步一步往下捋,还讲了原理. 让我对promise的认识清晰很多. 花了两个小时仔细阅读完这篇文章,会多多回顾,值得收藏.

si*
si*
1 年 前

老师,promise的自写promise视频啥时候上传哪

si*
si*
1 年 前

超哥,咱们promise的自写promise视频什么时候会上传哪,感觉自己不太理解

鹏鹏*
鹏鹏*
1 年 前

超哥,有微信交流群么,目前学习完您的nodeJS了 准备开始学习ajxx了

热心网友
热心网友
11 月 前

李老师,你的promise视频下架了吗

10
0
希望看到您的想法,请您发表评论x