JavaScript异步学习笔记

JavaScript异步学习笔记

Last updated on


9271 字 约 47 分钟

1.前言

作为开启React学习前的最后一项知识点,我看了两天,感觉这一块东西确实是比较多,比较杂,所以决定写一篇笔记来梳理一下有关于异步,此处仅指JavaScript中的异步的有关知识。

其实最开始看到回调异步这两个概念,我是有点懵逼的。什么玩意异步?你是说这一段代码跑过去了等下还能再回来调用一遍?对于一个写得最多的是竞赛代码的人来说,无疑是很难理解的。

但是没关系,接下来我们会慢慢梳理出完整的脉络,看看异步这个东西到底是什么,以及它在JavaScript中起什么作用


2.基础概念

2.1.进程(Process)与线程(Thread)

2.1.1.进程

所谓进程,就是操作系统分配资源的最小单位。这样说可能有点抽象,可以把它理解为我们正在运行的一个程序实例

例如编译并运行一个.cpp文件,生成了一个.exe并运行,此时这个.exe就是一个进程。然后我们再回头去理解什么叫操作系统分配资源的最小单位。试想我们一个程序运行,首先肯定得有内存去放我们的变量,声明的对象等等,而这些内存就是操作系统分配给这个进程的。相应地还有栈区,堆区等等资源,都是操作系统接收到这个进程开始的信号之后,根据你的需求,大手一挥,给你运行的.exe分配的资源。

我们打开任务管理器,看到这些列出的一长串的任务,其实就可以理解为进程。不过现在的很多软件都是通过多进程运行的,像浏览器就有很多个网页,每个网页都相当于一个进程。

然后我们不难发现:进程之间基本是互不影响的。试想如果进程之间会互相影响,你写的.exe栈溢出爆掉之后,后台的网易云音乐,微信,WPS全都一命呜呼了,这能合理吗?显然不合理。因此每个进程都占据独立的一份资源,互不侵犯。

关于进程,另一个比较显著的特点就是切换开销大。从一个进程切换到另一个进程,会发生页表切换、寄存器状态的保存与恢复、内核态与用户态之间的相互切换等等事件,涉及很多计算机底层原理,此处不展开讲了 (其实是因为没学计组 。总之只要记住一件事:从一个进程切换到另一个进程费老劲了


2.1.2.线程

一句话总结:线程是进程内部的执行单元,也是CPU调度的最小单位

什么叫进程内部的执行单元?其实就是,线程是隶属于进程的,在一个进程内,可能有一个或者很多个线程,它们共享进程本身所持有的内存、全局变量以及文件描述符。就像一个健身房,这个卧推架你用我也用,大家一块用。但问题在于,卧推架只有一个,如果分配不好,就很容易引起竞争。这就是所谓的竞态条件:一堆线程你推我挤地去访问同一个变量,这个线程费劲巴拉地调了一大堆函数算了一个最终值赋给这个变量,转头另一个线程随手一个a = 0直接没了。难以调试,甚至有时要看计算机心情。

但相对于进程,线程有一个优点:它的切换开销很小。从一个线程到另一个线程,由于大家都在同一个进程里,切换不需要什么内存映射之类的东西,切换相对来说比较容易。


2.1.3.对比与关系

维度进程线程
定义资源分配的最小单位CPU调度的最小单位
内存相互独立,隔离性好共享进程空间,存在竞态
开销大(创建/切换成本高)小(轻量级)
健壮性一个进程崩了,其他的没事一个线程崩了,整个进程废了

2.2.并发(Concurrency)与并行(Parallelism)

2.2.1.并发

并发,本质上是 处理(dealing with) 很多事情,而不是 同时做(doing simutaneously) 很多事情。

  • 核心定义:在同一时间段内,系统可能需要处理很多个任务;但是保证在同一时刻,系统只会正在做一件事。

就比如说敲代码,你在几个地方标记了TODO,表示这些都是需要处理的东西。但是你绝对不可能在这个TODO完善原有逻辑的同时在另一个TODO那里写新功能,因为键盘只有一个,手只有一双,脑子也只有一个。除非你是天才八爪鱼而且有很多台电脑。


2.2.2.并行

不同于并发,这才是真正的同时执行

  • 核心定义同一时刻,物理上有多个任务在多个CPU核心上运行。

好比如一个分工有序的团队,这个人负责写后端API,这个人负责前端的WEB交互,那个人负责美术UI,还有一个负责策划产品,不用非得等某个人的事情做完了,其他人才能开始做事,而是任务分别派发下去之后,每个人根据自己的任务去做事,同时进行,准确高效。

2.2.3.对比总结

维度并发(Concurrency)并行(Parallelism)
本质结构/逻辑:处理多个任务的能力物理:同时执行多个任务的能力
硬件要求单核即可必须多核/多处理器
解决目标解决等待时间(IO密集型)计算密集型

2.3.上下文切换(Context Switching)

简单来说,上下文切换就是我们前面所提到的,CPU从执行一个进程(或线程)到执行另一个进程(或线程)的过程

为什么需要上下文切换?因为CPU的核心数是有限的,而你的电脑或者说服务器上可能同时运行的进程是没有上限的(如果扛得住),再者我们也不可能塞八百个核心进CPU,所以当我们需要进程或者线程之间的切换时,就需要上下文切换来实现。

这个过程,通俗一点来讲,其实就是游戏里的存档和读档。

假如你打游戏,不可能说你切到后台放个音乐,回来一看你前面打的那些装备什么的全没了,角色只剩一条新手裤衩。当我们发出切换指令时,CPU收到信息,把当前进程的状态存档,等到下次轮到它的时候再读档

具体来说,在进程或者线程发生切换时,主要有下面的四个状态需要存档

  • 程序计数器(Program Counter):记录当前代码执行到哪一行了。
  • 寄存器状态(Registers):保存着当前的计算数据、栈指针。
  • 内核栈(Kernal Stack):记录了系统调用的中间状态。
  • 内存映射信息(Virtual Memory/Page Tables):进程能访问的内存空间地址。

其中进程和线程的不同点在于,当进程和线程切换时,前三者的状态都需要发生改变。而当线程切换时,内存映射信息是不会发生改变的。

因为正如我们前面所说,多个线程共享同一个进程的内存以及资源,如果没有发生进程的改变,线程切换只是相当于从自家的客厅走到书房一样。

在这四者中,内存映射信息,也即我们所说的页表状态的变化,存档和读档是最费劲的。这也是为什么进程切换的内存开销要远大于线程切换。


2.4.同步(Syncchronous)与异步(Asyncchronous)

2.4.1.同步

所谓同步,就是我们最常写的顺序执行。必须要等上一步提前做完,才能进行下一步。

就好像你做饭,切菜和炒菜,这就是典型的两个前后执行的同步任务。你必须得切完菜之后才能开始炒菜 (把食材丢进锅里用锅铲切的不算

  • 特性:阻塞。前一步不干完,后面的任务就只能在旁边转圈圈干瞪眼。
  • 代价:效率低下,CPU大部分时间都在等待IO或者指令完成。如果是浏览器主线程发生了这样的情况,那么下一步将要发生的就是用户直接关掉了你的网页,或者拨打投诉电话。

2.4.2.异步

相比于同步,异步其实就是:我把当前的这个任务委托,或者说挂到一个地方去让它自己执行。等到时间到了,执行完了,我再回来拿结果(回调)。

还是以做饭举例,我们都知道其实煮饭要花的时间还是挺久的,不可能说得等煮好饭之后我们才开始做菜,那样等开始吃饭黄花菜都凉了。我们直接把煮饭的这个任务丢给电饭煲,让它自己执行煮饭这个任务,我们先去炒菜。等炒完菜之后,我们再回来看煮饭这个任务已经完成了,直接拿结果(煮熟的米饭)。

  • 特性:非阻塞。你发起请求,不需要在原地傻站着等结果,可以继续去干别的,等操作系统或者环境通知你结果好了,你再回来拿到结果并对其进行处理,就好像拿到一锅米饭执行”吃掉”函数或者执行”炒饭”函数。
  • 本质:这实际上是在利用有限的资源,处理并发任务。

2.4.3.同步 vs 异步

特性同步(Syncchronous)异步(Asyncchronous)
执行模式串行,一步紧接一步并行/并发,任务独立推进
等待机制阻塞,必须拿到结果才能往下走非阻塞,发起即转头,后续通知
执行效率低,CPU容易闲置等待高,充分利用资源处理并发
复杂度低,逻辑清晰,顺着写就完了高,涉及回调、状态管理、竞态条件
适用场景简单的计算、内存操作网络请求、文件读写、UI交互

3.为什么JS需要异步

我们常说要在JavaScript中应用异步,那么为什么?为什么在JavaScript中我们非得使用异步不可?实际上还是需要回到我们在前面所说的,如果只有同步,那么在任务执行的过程中,极其容易遭遇阻塞。

另外一个就是JavaScript这门语言的特性:它是单线程的(但它的宿主环境是多线程,我们后面会讲到)。也就是说,同一时间,JavaScript真正意义上地只能做一件事。如果没有异步,那么遇到耗时极高的网络请求,图形渲染任务,整个流程直接卡死,效率极低。

综上所述,JS不能没有异步,就像人类不能失去番茄


4.JavaScript异步发展史

时代在进步,相应地,技术也需要迭代。我们不可能在一开始就实现完美的,不需要任何改进的方案,因为人的需求,环境的需求都在变化,在过去看起来是颠扑不破的真理,从现在的角度来看,很可能也变为了谬误。

同样,对于JavaScript中异步的实现,也经历了几个时代的变化,最终变成了现在的容易使用,可读性高的版本。至于未来是否还会有进步,我觉得应该是会的。

4.1.时代的眼泪:回调函数(Callback)

话说鸿蒙伊始,混沌初开…总之在最早的时候,人们是通过一个叫做 回调函数(Callback) 的东西来实现异步的。

什么叫回调函数?其实非常简单粗暴,就是JavaScript通过设置一个定时器或者网络请求,然后把一个需要异步执行的函数丢到里面。等到操作完了,引擎通过调用你丢进去的那个函数来通知你这里的事情搞定了,快回来看看。

比如说:

console.log("今天好热");

setTimeout(() => {
    console.log("Abel真帅");
}, 1000);

console.log("晚上吃什么");

输出为:

今天好热
晚上吃什么
Abel真帅

这里就相当于设置了一个定时器,里面的箭头函数就是你需要设置的异步执行的操作函数,等待1000毫秒之后调用,初步实现了异步逻辑。

然而它有一个显著到无法忽视的缺点…当业务逻辑变得越来越复杂时,你的代码就会变成下面这样:

findUser("Abel", function(user) {
    findOrders(user.id, function(orders) {
        findOrderDetails(orders[0].id, function(details) {
            console.log("获取订单详情成功!", details);
        }, function(err) {
            console.log("获取订单详情失败!");
        })
    }, function(err) {
        console.log("获取订单失败!");
    })
}, function(err) {
    console.log("获取用户失败!");
})

这就是我们常说的回调地狱,可读性和可维护性都极差,虽然我没有在实际应用中遇到过这种代码,但看一眼就知道如果真的要去维护这样的东西有多让人头晕眼花。

另外,使用回调函数实现异步还有一个致命的缺陷:控制权反转

当你使用回调函数时,你就相当于把执行这个函数的时机,如何调用等控制权拱手让人,全部交给了第三方库,比如说这样:

fetchdata(function(data) => {
    // 具体逻辑

    process(data);
});
// 代码往下接着跑

在这里,你根本不知道第三方库会怎样调用你给出的回调函数,这就是信任危机的来源:这个异步库是否可靠?完全不知道。以及代码的逻辑被扯得东一块西一块,很快就会形成复杂的状态嵌套。

为了解决这个令人痛苦无比的问题,开发者们引入了一个新的工具:Promise。但是在讲Promise之前,我们需要先搞明白,JavaScript中的异步在底层到底是基于什么样的逻辑实现的。


4.2.事件循环(Event Loop)

4.2.1.概念总述

深究事件循环,它实际上可以被看做一个while(true),在这个循环内,当主线程空闲的时候,引擎根据开发者所规定的任务执行顺序,从执行栈或者任务队列之类的地方拿出要执行的任务进行处理,这一轮执行完了就到下一轮,如此往复,故有循环。

所谓执行栈,或者说调用栈,就包含了我们当前所需要处理的第一优先级的任务,每次从栈顶拿出来一个任务进行执行。

但是在不同的JavaScript运行环境中,事件循环的具体流程略有不同,在这里我们暂且介绍两种最常见的环境。


4.2.2.浏览器环境

4.2.2.1.概念详解
  • 微任务:所谓微任务,其实简单理解,就是JavaScript为了保证一些异步任务的优先级更高,让它在前面执行所搞出来的东西。它和宏任务一样,都是在先前所抛出来的异步任务。而区别在于,正如它的名字一样,微任务的体量是微小的,比如说一些简单的逻辑运算,函数调用。它的执行代价相对来说较小,当我们期望一个任务既需要实现异步回调,又想让它能够在执行栈清空之后尽快执行,就把它当做一个微任务处理。为什么我们需要它? 假如说我们有一个任务实现了渲染,但是对于Web来说更重要的是功能的实现,如果一个实现逻辑的任务被卡在这个任务后面,那么我们只能等到渲染完成之后才能继续执行这个逻辑。显然这对用户的使用体验有显著的负面影响。总之只要记住,微任务在宏任务结束后立即清空
  • 宏任务:在浏览器环境内,宏任务是指那些需要宿主环境(如浏览器和Node.js)调度的任务。
特性微任务(Microtask)宏任务(Macrotask)
本质引擎内部的”即时处理”宿主环境的”异步调度”
执行优先级
体量感轻量级、高频、爆发式重量级、低频、持续性
对性能的影响容易阻塞渲染(如果死循环)造成界面延迟、UI掉帧

4.2.2.2.浏览器环境事件循环流程概述

在浏览器环境中,事件循环大概呈现下面的流程:

1.执行同步代码(Script)

在这个阶段,浏览器会把当前所有的同步代码,也就是你的.js文件里的代码全部执行完毕。例如let a = 0;,for (let i = 0; i < n; i++)这样的部分。

需要注意的是,抛出异步的那些部分也算作同步,它们相当于一个触发器,同步代码执行到这里了,就抛出一个需要异步执行的任务留待后面处理。遇到微任务就丢到微任务队列,遇到宏任务就丢给浏览器线程去处理。

2.清空微任务队列(Microtask Checkpoint)

这是重要的检查点,它的核心逻辑是:每当执行栈清空时,主线程会先把微任务队列中的所有任务清空,再决定下一步要干嘛。

甚至于,如果清空微任务队列过程中产生了新的微任务,它同样会把这些新产生的微任务也一同执行清空。

3.渲染更新(Update the Rendering):当微任务队列被清空后,浏览器会根据当前DOM节点状态决定是否需要渲染更新浏览器页面。

4.执行宏任务(Macrotask):当渲染完成,或者不需要渲染时,引擎会从宏任务队列中取出一个执行。相当于把它也抛进执行栈,执行完之后也相当于执行栈清空了,此时又回到了第二步。

简单一句话总结:同步->清空微任务队列->尝试渲染更新->执行宏任务->清空微任务队列->尝试渲染更新…,如此循环。


4.2.3.Node.js环境

4.2.3.1.概念详解
  • I/O回调:实际上是宏任务的一种,用于指代那些较为耗时的任务,如网络请求、文件读取等。
  • Libuv:一个跨平台的异步I/O库,用于调度那些耗时的异步任务。可以理解为Node.js接收到文件读取或者网络请求之类的任务后,会把任务丢给Libuv,由它进行任务的分配。

4.2.3.2.Node.js事件循环流程概述

1.Timers(定时器阶段)

这里是循环的起始点

  • 任务:存放由 setTimeout()setInterval() 设置的回调。
  • 机制:Libuv内部维护一个最小堆,存储定时器的到期时间。每次循环进入这个阶段的时候,它会持续查询堆顶的时间是否已经过期了,如果过期就把这个任务拿出来,回调到当前任务队列中执行。

2.Pending Callbacks(待定回调)

  • 任务:处理上一轮循环中没有执行完的”系统错误回调”。
  • 细节:比如在TCP连接中出现了ECONNREFUSED之类的底层错误,操作系统通知Node.js,把这些错误的回调丢在这个阶段排队。

3.Idle,Prepare

  • 任务:内部阶段,无需理会,用于给Libuv内部逻辑作预处理,对业务逻辑透明。

4.Poll(轮询阶段)——整个系统的心脏

  • 核心逻辑

    1.如果观察者队列里有任务,就依次同步执行。 2.如果队列空了,循环检查有没有setImmediate。如果有,就调转到Check阶段;如果没有,它就会在这里阻塞(挂起),等待新的I/O事件到来。

  • 关键点:这就是Node.js高性能的来源。没事儿做的时候就躺在这里摸鱼,有活了就鲤鱼打挺瞬间被唤醒。

5.Check(检查阶段)

  • 任务:专门为setImmediate()准备
  • 来由:防止Poll阶段阻塞导致某些需要立即执行的任务被错过,所以setImmediate()作为一个补救措施在Poll后面开辟了一个专门的执行通道。

6.Close Callbacks(关闭回调)

  • 任务:执行socket.destroy()stream.destroy()之后的回调。
  • 作用:为了确保资源被正常释放,如果一个socket在此时被关闭,会触发这一步来清理堆内存,防止内存泄漏。

4.2.3.3.那些”插队特权”:NextTick和Promise

这些就对应了浏览器事件循环中我们曾提到的微任务。

  • process.nextTick:它属于同步执行的任务之后的任务。无论循环处于哪一个阶段,只要当前执行栈清空,Node.js会立即清空nextTickQueue。比任务事件循环阶段都快。
  • Promise.then:涉及我们接下来要提到的Promise,它属于微任务。跟nextTick很像,但是优先级略低。

4.2.4.一些概念上的澄清

那么看到这里可能有观众就要问了:啊主播主播,你不是说JavaScript只有一个线程吗,那那些异步任务,最终还不是要阻塞到主线程处理?

此言差矣,虽说JavaScript本身确实是只有一个线程没错,但我们早在高中课文里就已经学到了:

君子性非异也,善假于物也 好风凭借力,送我上青天

没错,就是你想的那样,它可以借助其他人的线程,来辅助它处理那些恶心且耗时的异步任务。

1.浏览器环境

  • Web APIs:当你调用setTimeout()fetch或者操作DOM时,JS直接把这些请求一股脑甩给浏览器内核。浏览器会开启底层的C++线程去处理这些任务。等任务跑完了,再把结果塞回JS的任务队列里。

2.Node.js环境

在服务器端,Node.js更是把这种”借力”发挥到了极致。

  • Libuv:正如我们前面所提到的,JS会把那些异步任务丢给它。而这些任务能用内核搞定的(如网络I/O),就直接丢给内核,忙完了再通知Libuv;不能用内核搞定的,就丢进它内置的线程池里,默认4个线程。

4.2.5.代码示例

我们来通过几段代码来理解事件循环的执行顺序:

console.log(1);

setTimeout(() => {
    console.log(2);
    Promise.resolve().then(() => console.log(3));
}, 0);

new Promise((resolve) => {
    console.log(4);
    resolve();
}).then(() => {
    console.log(5);
});

console.log(6);

我们来解析一下这个执行顺序:

  • 首先是同步代码,我们看当前的同步代码有什么:
    • 一个console.log(1),没问题直接输出1
    • 遇到了一个宏任务的发起,注意看这里虽然将延迟设为0,但本质上还是宏任务,把里面的回调函数丢到宏任务队列里先。
    • 遇到了一个Promise的构造,我们知道Promise构造中的executor和同步代码一样是立即执行的,所以看到console.log(4)直接输出4,然后这个Promise状态变成Fulfilled传给.then()抛出一个微任务,把它放在微任务队列里先不管。
    • 下面一个console.log(6)输出6
  • 此时执行栈清空,开始检查微任务队列
    • 微任务队列中一个.then(() => console.log(5)),直接输出5
  • 此时微任务队列清空,执行渲染(假装有)
  • 渲染完成,从宏任务队列中拿一个出来
    • 执行setTimeout回调的函数() => { console.log(2); Promise.resolve().then(() => console.log(3))}
    • console.log(2)输出2
    • Promise.resolve().then(() => console.log(3))创建一个微任务,塞到微任务队列
  • 宏任务执行完成,开启新一轮循环,检查微任务队列
    • 有一个.then(() => console.log(3)),执行输出3
  • 微任务队列清空,尝试渲染
  • 查看宏任务队列,为空,循环结束

所以最终的输出结果为:1, 4, 6, 5, 2, 3


4.3.Promise——劈开混沌之人

4.3.1.概述

话说那Callback引得开发者们民不聊生、哀鸿遍野…却见得在此时,一道人影忽将闪现而出,定睛一看:原是Promise!只见他一声冷喝:“今日之乱局,就由我Promise来扭转!”

那么,Promise到底实现了什么,改变了什么?大概总结来看,就是如下几点:

  • 从过程驱动到状态驱动
  • 从回调嵌套到链式调用
  • 从层层捕获到一键查错

下面我们就来详细说说这三点具体都改在哪里。


4.3.2.Promise基本介绍

首先,Promise本质上是一个持有异步操作结果的对象。

这是什么意思?首先我们知道,当我们需要异步操作时,我们此时发送出来一个异步请求,就期望在之后能够得到一个返回的结果,比如说文件里的内容,比如说网络里的信息,再不济失败了也要返回一个错误的详细原因和描述。

那么我们前面所说的Promise的概念就很明确了,既然它是对象,就可以new出来:

const promise = new Promise((resolve, reject) => {
    /* 这里的代码是同步执行!
    也就是说,当Promise对象被创建时,
    会随着构造函数一同立即执行这里的内容,
    但它本身的异步代码仍为异步,
    称其为executor*/

    // 异步任务(比如 setTimeout, fetch) 都放在这里

    if(/* 成功 */) {
        resolve(value); // value作为异步任务的返回值
    } else {
        reject(error);
    }
});

Promise构造函数的灵魂就在于resolvereject函数参数。其中resolve()如果被调用,就代表执行成功,往下传异步任务返回结果;如果reject()被调用,就代表执行失败,往下传错误。

当然你也可以把resolve()reject()改成你想要的名字,比如abel()或者tomato()什么的,但是我不建议你这样做,因为你的协作者可能会看着你的代码一脸茫然。


4.3.2.状态(State)

我们上面说了,调用resolve()代表执行成功,reject()代表执行失败。那么Promise具体是如何判断是成功还是失败呢?这就牵涉到Promise的状态机制。

具体来说,Promise对象具有三种可能的状态:

  • Pending(待定):初始状态,还没有拿到异步任务的结果。
  • Fulfileed(已兑现):成功了,拿到结果。
  • Rejected(已拒绝):失败了,抛出错误。

当我们在一个Promise中调用resolve()时,这个Promise的状态会从Pending变为Fulfilled;相应地,调用reject()时,会从Pending变为Rejected

那么你可能会想,如果我先调用resolve(),再调用reject()呢?这就涉及到了另一个特性:不可逆性

当一个Promise的状态已经由Pending变为Fulfilled或者Rejected时,它的状态就已经固定了。也就是说此时你无法再回退到Pending,也无法从Fulfilled切换为Rejected或者从Rejected切换为Fulfilled


4.3.3.链式调用(Chaining)

当一个Promise对象拿到了结果之后,它要传递给谁呢?这里就是最伟大的地方:它通过链式调用将结果一层一层处理下去。

具体地,像下面这样:

const getData = (id) => {
    return new Promise((resolve, reject) => {
        console.log(`正在请求id为${id}的数据...`);

        // 模拟异步操作
        setTimeout(() => {
            const success = true;       // 假设总是成功

            if(success) {
                resolve({id, nmae:"测试数据"});
            } else {
                reject(err);
            }
        }, 1000);
    });
};

// 链式调用
getData(1)
    .then((data) => {
        console.log("第一次拿到数据:", data);
        return data.name;
    })
    .then((name) => {
        console.log("第二次处理,只拿到了名字:", name);

        return getData(2);      // 甚至可以返回一个新的Promise,实现异步串行
    })
    .then((data) => {
        console.log("第三次处理,拿到了新的数据data(2),串行结束:", data);
    })
    .catch((err) => {
        console.log("出错了:", err);
    })
    .finally(() => {
        console.log("关闭文件");
    });

在这里,我们利用.then,.catch,.finally方法来实现Promise的链式调用,具体来说,它们三个都返回一个新的Promise对象。

我们来看代码,在第一个.then,我们拿到了前面getData(1)给我们返回的Promise,里面包含了异步任务的结果。然后在这个.then中,我们用了一个data作为参数,承接了传下来的Promise的返回结果(如果这个Promise的状态是Fullfilled的话)。然后将data.name作为返回值,又将这个结果传了下去。你可以理解为,在这个.then里,我们返回了一个状态为Fullfilled,返回值为data.namePromise。如此这样,第二个.then接受返回值,继续重复上面的操作。

在这个过程中,如果有哪一步执行出错,抛出异常了怎么办?比如说第一步getData(1)就抛出了异常,或者在某个.then中抛出了异常。在这个时候,它会一直往下找,透过中间的.then,一直到下面最近的.catch,并将异常丢给.catch,让它处理异常,决定是处理数据之后产生一个新的状态为FulfilledPromise还是直接终止程序。

对于.finally,它充当一个保洁员的角色,好比如说一群人做饭,不管最后是成功做出来一桌美味佳肴,还是说错误产生了黑暗料理,最后都需要一个保洁员去清理厨房。它通常用于关闭文件,终止请求这样的操作。但需要注意的是,它同样会返回Promise,但它具有”透传性”,如果在它的内部调用函数中没有返回新的Promise,就会把从上面传下来的Promise作为最终结果返回。一般来说,我们不应当在.finally中使用新的Promise覆盖原有结果。


4.3.4.缺陷

然而,Promise虽然解决了Callback的致命问题,但它本身的应用也有着重大的缺陷。

1.无法取消

一旦你启动了一个new Promise,那么这个链式调用就会如同脱缰的野狗,嘴里的炫迈,根本停不下来。

  • 后果:即使你的页面已经关了,或者说用户已经不再需要那个数据了,那个异步任务仍然会在后台运行完。

2.状态一旦改变,无法重来

正如我们前面所说的,Promise的状态设计是”一次性”的。

  • 后果:如果需要实现”重试机制”,比如说网络请求失败,想要自动重连三次。此时还不能直接令一个Promise的状态重置,必须把所有逻辑封装在一个函数里面,重新new一个。

3.代码风格的伪线性

虽然.then的链式调用看起来是线性的,但是一旦逻辑变得复杂,比如说串行,.then里面套if-else分支或者多层嵌套,代码将会变得可读性差且难以维护。


4.4.Generator & co

在真正解决了上面我们提到的问题的工具出现之前,我们先来看看这个用于和Promise配合,解决异步流程控制的可读性,把它们缝合在一起的方法。

4.4.1.Generator(生成器)

简单来说,Generator就是一个能够被手动控制的函数?如何手动控制?用function*定义,用yield暂停,用next()恢复。

  • 本质:它是一个迭代器(Iterator)生成器。每次你调用next(),代码就执行到下一个yield,然后吐出一个对象{ value: ..., done: ...}
  • 用途:它改变了函数”一口气执行完”的传统,让你可以控制函数进度。

用法大概像下面这样:

function* takeGenerator() {
    console.log("准备开始");
    yield 1;    // 暂停,吐出1
    console.log("恢复执行");
    yield 2;    // 暂停,吐出2
    return "结束";
}

const g = takeGenerator();
console.log(g.next());  // { value: 1, done: false}
console.log(g.next());  // { value: 2, done: false}
console.log(g.next());  // { value: "结束", done: true}

4.4.2.co:Generator的自动执行器

它实际上是一个库,核心逻辑就是利用Generator的暂停/恢复特性,自动执行异步任务

核心原理:递归式自动迭代

co的本质就是通过递归,把yield出来的Promise自动next()回去:

1.执行入口co(fn)接收一个Generator函数。 2.获取迭代器:执行fn()得到iterator 3.驱动循环:定义一个next函数,调用iterator.next() 4.自动延续:拿到yield出来的value(假设这里是个Promise),给它接上.then() 5.递归调用:在.then()的回调里,把结果再扔回iterator.next(result),完成下一次循环

用代码模拟一下流程,当然co内部比这复杂得多:

function run(generatorFunc) {
    const iterator = generatorFunc();

    function step(val) {
        const next = iterator.next(val);
        if(next.done) return next.value;

        // 假设yield的都是Promise
        next.value.then(result => {
            step(result);
        });
    }

    step();
}

// 使用
run(function* () {
    const data = yield fetch('https://api.example.com');
    console.log(data);
});

如此和Generator两者配合,就很大程度上解决了Promise的可读性问题。


4.5.async & await——化异步为同步

4.5.1.改变

在ES8标准中引入的async/await改变了游戏规则:

  • 写法同步化:它让异步代码看起来就像同步代码一样,逻辑是从上到下的结构,可读性大大提高。
  • 错误处理统一化:不再需要满屏的.catch(),直接用传统的try-catch就能捕获所有的异步异常。
  • 调试友好:可以在await那一行打断点,而不是迷失在Promise的回调函数堆里。

4.5.2.核心特性

简单来说,async/await就是JavaScript异步处理的语法糖,它的底层逻辑仍然是PromiseGenerator那一套。

  • async关键字:把它加在函数声明前面,这个函数就成了异步函数,它保证一件事:该函数一定会返回一个Promise对象。哪怕你函数里写的return 1,它也会包装成return new Promise.resolve(1)
  • await关键字:它只能在async函数中调用。它的作用是暂停当前函数的执行,等待它后面的Promise把结果吐出来。

4.5.3.用法展示

同样是一个调取用户数据的逻辑,在Callback时期,我们是这么写的:

function getUserData(usedId, callback) {
    setTimeout(() => {
        callback({ id: usedId, name: "Abel"});
    }, 1000);
}

function getOrders(userName, callback) {
    setTimeout(() => {
        callback(["订单1", "订单2"]);
    }, 1000);
}

// 回调地狱
getUserData(1, (user) => {
    getOrders(user.name, (orders) => {
        console.log(`用户${user.name}的订单:`, orders);
    });
});

而使用async/await是这么写的:

// 假设函数返回Promise
const getUserData = (userId) => new Promise(resolve => {
    setTimeout(() => resolve({ id: userId, name: "Abel" }), 1000);
});

const getOrders = (userName) => new Promise(resolve => {
    setTimeout(() => resolve(["订单1", "订单2"]), 1000);
});

// 使用async/await
async function showUserOrders() {
    try {
        console.log("开始获取数据");

        const user = await getUserData(1);
        const orders = await getOrders(user.name);

        console.log(`用户${user.name}的订单:`, orders);
    } catch(error) {
        console.error("出错了:", error);
    }
}

简直是令人感动的清晰度不是吗?至于为什么我们说它是Promise的语法糖?因为上面的这个函数其实就等价于:

function showUserData() {
    console.log("开始获取数据");

    getUserData(1)
        .then((user) => {
            return getOrders(user.name).then((orders) => {
                return { user, orders };
            });
        })
        .then(({ user, orders }) => {
            console.log(`用户:${user.name} 的订单:`, orders);
        })
        .catch((error) => {
            console.error("出错了:", error);
        });
}

两相比较,我们自然发现了async/await的好处,像我们前面所说的,我们可以像写同步代码一样写异步。但需要注意的是,它的底层机制还是没有变,本质上还是Promise

你可以发现,await本质上就是把它后面的Promise拆开,注册.then(),并把try块中在它之后的代码塞进.then()里。

5.异步进阶实战——当理想遇到现实

5.1.别让异步变成同步——并发控制

我们在前面讲解Promise时曾经提到一个词:异步串行。实际上在应用中,将两个之间完全独立的异步任务放在先后分别处理是一件非常浪费性能的事情——你完全可以借助宿主环境让它们同时去执行任务。

这正是我们所说的并发,但是JS的并发并不是真正的并发,因为我们知道它是单线程的。它的并发本质上是利用事件循环在等待I/O的空档,把CPU资源挪给别的任务。

5.1.1.核心武器:Promise.allPromise.allsettled

  • Promise.all:只要有一个任务挂了,那立刻跟着报错。适合那种需求数据缺一不可,宁为玉碎不为瓦全的情况。
  • Promise.allSettled:这个不管任务成功或者失败,它都会等你跑出来一个结果,然后将结果放到一个数组里面统一返回。

示例:

// Promise.all
async function loadGameData(playerId) {
    try {
        // 两者全部加载成功才行
        const [player, leaderboard] = await Promise.all([
            fetch(`/api/player/${playerId}`), fetch('/api/leaderboard')
            ]);

        console.log("加载完成");
    } catch(error) {
        console.error("加载失败", error);
    }
}

// Promise.allSettled
async function getAssets() {
    // 返回全部异步任务的执行结果
    const results = await Promise.allSettled([
        fetch('/music/bgm.mp3'),
        fetch('/map/forest.png'),
        fetch('/data/script.json')
    ]);

    results.forEach((res, index) => {
        if(res.status === 'fulfilled') {
            console.log(`资源${inddex}加载成功`);
        } else {
            console.warn(`资源${index}加载失败`);
        }
    })
}

5.1.2.Promise.race & Promise.any

  • Promise.race:返回一批异步任务中最快的那个的结果,无论成败。
  • Promise.any:只要有一个成功了就返回成功结果,除非全部挂了才报错。

这两个的用法和上面的类似,不再详述。

6.小结

关于JavaScript异步的讲解和介绍暂时就到这里了,我想在这里有一点需要强调的是:

  • 异步不是魔法:异步的实现本质上是基于它的宿主环境而成的,JavaScript本身只是一个合格的调度员,它指挥浏览器或者Node.js之类的环境来帮助它处理那些它自己难以独立高效解决的任务,最终通过高效的调度实现并发的效果,这就是异步的关键所在。
🐙