异步编程和事件循环
异步
通常来说,程序都是顺序执行,同一时刻只会发生一件事。如果一个函数依赖于另一个函数的结果,它只能等待那个函数结束才能继续执行,从用户的角度来说,整个程序才算运行完毕.
这是令人沮丧的体验,没有充分利用计算机的计算能力 — 尤其是在计算机普遍都有多核CPU的时代,坐在那里等待毫无意义,你完全可以在另一个处理器内核上干其他的工作,同时计算机完成耗时任务的时候通知你。这样你可以同时完成其他工作,这就是异步编程的出发点。你正在使用的编程环境(就web开发而言,编程环境就是web浏览器)负责为你提供异步运行此类任务的API。
产生阻塞的代码
异步技术非常有用,特别是在web编程。当浏览器里面的一个web应用进行密集运算还没有把控制权返回给浏览器的时候,整个浏览器就像冻僵了一样,这叫做阻塞;这时候浏览器无法继续处理用户的输入并执行其他任务,直到web应用交回处理器的控制。
为什么是这样? 答案是:JavaScript一般来说是单线程的(single threaded)。接着我们来介绍线程的概念。
线程
一个线程是一个基本的处理过程,程序用它来完成任务。每个线程一次只能执行一个任务:
1 | Task A --> Task B --> Task C |
每个任务顺序执行,只有前面的结束了,后面的才能开始。
正如我们之前所说,现在的计算机大都有多个内核(core),因此可以同时执行多个任务。支持多线程的编程语言可以使用计算机的多个内核,同时完成多个任务:
1 | Thread 1: Task A --> Task B |
JavaScript 是单线程的
JavaScript 传统上是单线程的。即使有多个内核,也只能在单一线程上运行多个任务,此线程称为主线程(main thread)。
经过一段时间,JavaScript获得了一些工具来帮助解决这种问题。通过 Web workers
可以把一些任务交给一个名为worker
的单独的线程,这样就可以同时运行多个JavaScript代码块。一般来说,用一个worker来运行一个耗时的任务,主线程就可以处理用户的交互(避免了阻塞)。
1 | Main thread: Task A --> Task C |
异步代码
web workers相当有用,但是他们确实也有局限。主要的一个问题是他们不能访问 DOM— 不能让一个worker直接更新UI。
我们不能在worker里面渲染1百万个蓝色圆圈,它基本上只能做算数的苦活。
其次,虽然在worker里面运行的代码不会产生阻塞,但是基本上还是同步的。
当一个函数依赖于几个在它之前运行的过程的结果,这就会成为问题。考虑下面的情况:
1 | Main thread: Task A --> Task B |
在这种情况下,比如说Task A 正在从服务器上获取一个图片之类的资源,Task B 准备在图片上加一个滤镜。
如果开始运行Task A 后立即尝试运行Task B,你将会得到一个错误,因为图像还没有获取到。
1 | Main thread: Task A --> Task B --> |Task D| |
在这种情况下,假设Task D 要同时使用 Task B 和Task C的结果,如果我们能保证这两个结果同时提供,程序可能正常运行,但是这不太可能。如果Task D 尝试在其中一个结果尚未可用的情况下就运行,程序就会抛出一个错误。
为了解决这些问题,浏览器允许我们异步运行某些操作。
像Promises
这样的功能就允许让一些操作运行 (比如:从服务器上获取图片),然后等待直到结果返回,再运行其他的操作:
1 | Main thread: Task A Task B |
由于操作发生在其他地方,因此在处理异步操作的时候,主线程不会被阻塞。
总结
围绕异步编程领域,现代软件设计正在加速旋转,就为了让程序在一个时间内做更多的事情。当你使用更新更强大的API时,你会发现在更多的情况下,使用异步编程是唯一的途径。以前写异步代码很困难,现在也需要你来适应,但是已经变容易了很多。在余下的部分,我们将进一步探讨异步代码的重要性,以及如何设计代码来防止前面已经提到过的问题。
同步JavaScript
要理解什么是异步 JavaScript ,我们应该从确切理解同步 JavaScript 开始。
前面学的很多知识基本上都是同步的 — 运行代码,然后浏览器尽快返回结果。先看一个简单的例子 :
1 | const btn = document.querySelector('button'); |
这段代码, 一行一行的顺序执行:
- 先取得一个在DOM里面的
<button>
引用。 - 点击按钮的时候,添加一个
click
事件监听器:alert()
消息出现。- 一旦alert 结束,创建一个
<p>
元素。 - 给它的文本内容赋值。
- 最后,把这个段落放进网页。
每一个操作在执行的时候,其他任何事情都没有发生 — 网页的渲染暂停。
任何时候只能做一件事情, 只有一个主线程,其他的事情都阻塞了,直到前面的操作完成。
所以上面的例子,点击了按钮以后,段落不会创建,直到在alert消息框中点击ok,段落才会出现。
Note: 这很重要请记住,alert()
在演示阻塞效果的时候非常有用,但是在正式代码里面,它就是一个噩梦。
异步JavaScript
就前面提到的种种原因(比如,和阻塞相关)很多网页API特性使用异步代码,特别是从外部的设备上获取资源,譬如,从网络获取文件,访问数据库,从网络摄像头获得视频流,或者向VR头罩广播图像。
为什么使用异步代码这么难?看一个例子,当你从服务器获取一个图像,通常你不可能立马就得到,这需要时间,虽然现在的网络很快。这意味着下面的伪代码可能不能正常工作:
1 | var response = fetch('myImage.png'); |
因为你不知道下载图片会多久,所以第二行代码执行的时候可能报错(可能间歇的,也可能每次)因为图像还没有就绪。取代的方法就是,代码必须等到 response
返回才能继续往下执行。
在JavaScript代码中,你经常会遇到两种异步编程风格:老派callbacks,新派promise。下面就来分别介绍。
异步callbacks
异步callbacks 其实就是函数,只不过是作为参数传递给那些在后台执行的其他函数。 当那些后台运行的代码结束,就调用callbacks函数,通知你工作已经完成,或者其他有趣的事情发生了。使用callbacks 有一点老套,在一些老派但经常使用的API里面,你会经常看到这种风格。
举个例子,异步callback 就是addEventListener()
第二个参数(前面的例子):
1 | btn.addEventListener('click', () => { |
第一个参数是侦听的事件类型,第二个就是事件发生时调用的回调函数。.
当我们把回调函数作为一个参数传递给另一个函数时,仅仅是把回调函数定义作为参数传递过去 — 回调函数并没有立刻执行,回调函数会在包含它的函数的某个地方异步执行,包含函数负责在合适的时候执行回调函数。
你可以自己写一个容易的,包含回调函数的函数。来看另外一个例子:
1 | function loadAsset(url, type, callback) { |
创建 displayImage()
函数,简单的把blob传递给它,生成objectURL,然后再生成一个image元素,把objectURL作为image的源地址,最后显示这张图片。 然后,我们创建 loadAsset()
函数,把URL,type,和回调函数同时都作为参数。函数用 XMLHttpRequest
(通常缩写 “XHR”) 获取给定URL的资源,在获得资源响应后再把响应作为参数传递给回调函数去处理。 (使用 onload
事件处理) ,有点烧脑,是不是?!
回调函数用途广泛 — 他们不仅仅可以用来控制函数的执行顺序和函数之间的数据传递,还可以根据环境的不同,将数据传递给不同的函数,所以对下载好的资源,你可以采用不同的操作来处理,譬如 processJSON()
, displayText()
, 等等。
请注意,不是所有的回调函数都是异步的 — 有一些是同步的。一个例子就是使用 Array.prototype.forEach()
来遍历数组:
1 | const gods = ['Apollo', 'Artemis', 'Ares', 'Zeus']; |
在这个例子中,我们遍历一个希腊神的数组,并在控制台中打印索引和值。forEach()
需要的参数是一个回调函数,回调函数本身带有两个参数,数组元素和索引值。它无需等待任何事情,立即运行。
Promises
Promises 是新派的异步代码,现代的web APIs经常用到。 fetch()
API就是一个很好的例子, 它基本上就是一个现代版的,更高效的 XMLHttpRequest
。
1 | fetch('products.json').then(function(response) { |
这里fetch()
只需要一个参数— 资源的网络 URL — 返回一个promise。promise 是表示异步操作完成或失败的对象。可以说,它代表了一种中间状态。 本质上,这是浏览器说“我保证尽快给您答复”的方式,因此得名“promise”。
这个概念需要练习来适应;它感觉有点像运行中的薛定谔猫。这两种可能的结果都还没有发生,因此fetch操作目前正在等待浏览器试图在将来某个时候完成该操作的结果。然后我们有三个代码块链接到fetch()的末尾:
- 两个
then()
块。两者都包含一个回调函数,如果前一个操作成功,该函数将运行,并且每个回调都接收前一个成功操作的结果作为输入,因此您可以继续对它执行其他操作。每个.then()
块返回另一个promise,这意味着可以将多个.then()
块链接到另一个块上,这样就可以依次执行多个异步操作。 - 如果其中任何一个
then()
块失败,则在末尾运行catch()
块——与同步try...catch
类似,catch()
提供了一个错误对象,可用来报告发生的错误类型。但是请注意,同步try...catch
不能与promise一起工作,尽管它可以与async/await
一起工作,稍后您将了解到这一点。
事件队列
像promise这样的异步操作被放入事件队列中,事件队列在主线程完成处理后运行,这样它们就不会阻止后续JavaScript代码的运行。排队操作将尽快完成,然后将结果返回到JavaScript环境。
Promises 对比 callbacks
promises与旧式callbacks有一些相似之处。它们本质上是一个返回的对象,您可以将回调函数附加到该对象上,而不必将回调作为参数传递给另一个函数。
然而,Promise
是专门为异步操作而设计的,与旧式回调相比具有许多优点:
- 您可以使用多个then()操作将多个异步操作链接在一起,并将其中一个操作的结果作为输入传递给下一个操作。这种链接方式对回调来说要难得多,会使回调以混乱的“末日金字塔”告终。 (也称为回调地狱)。
Promise
总是严格按照它们放置在事件队列中的顺序调用。- 错误处理要好得多——所有的错误都由块末尾的一个
.catch()
块处理,而不是在“金字塔”的每一层单独处理。
异步代码的本质
让我们研究一个示例,它进一步说明了异步代码的本质,展示了当我们不完全了解代码执行顺序以及将异步代码视为同步代码时可能发生的问题。
1 | console.log ('Starting'); |
浏览器将会执行代码,看见第一个console.log()
输出(Starting
) ,然后创建image
变量。
然后,它将移动到下一行并开始执行fetch()
块,但是,因为fetch()
是异步执行的,没有阻塞,所以在promise
相关代码之后程序继续执行,从而到达最后的console.log()
语句(All done
!)并将其输出到控制台。
只有当fetch()
块完成运行返回结果给.then()
,我们才最后看到第二个console.log()
消息 (It worked ;)
) . 所以 这些消息可能以和你预期不同的顺序出现:
Starting
All done!
It worked :)
如果你感到疑惑,考虑下面这个小例子:
1 | console.log("registering click handler"); |
这在行为上非常相似——第一个和第三个console.log()
消息将立即显示,但是第二个消息将被阻塞,直到有人单击鼠标按钮。前面的示例以相同的方式工作,只是在这种情况下,第二个消息在promise
链上被阻塞,直到获取资源后再显示在屏幕上,而不是单击。
将第三个console.log()
调用更改为以下命令:
1 | console.log ('All done! ' + image.src + 'displayed.'); |
此时控制台将会报错,而不会显示第三个 console.log
的信息:
1 | TypeError: image is undefined; can't access its "src" property |
这是因为:浏览器运行第三个console.log()
的时候,fetch()
语句块还没有完成,因此image
还没有赋值。
小结
在最基本的形式中,JavaScript是一种同步的、阻塞的、单线程的语言,在这种语言中,一次只能执行一个操作。但web浏览器定义了函数和API,允许我们当某些事件发生时不是按照同步方式,而是异步地调用函数(比如,时间的推移,用户通过鼠标的交互,或者获取网络数据)。这意味着您的代码可以同时做几件事情,而不需要停止或阻塞主线程。
异步还是同步执行代码,取决于我们要做什么。
有些时候,我们希望事情能够立即加载并发生。例如,当将一些用户定义的样式应用到一个页面时,您希望这些样式能够尽快被应用。
但是,如果我们正在运行一个需要时间的操作,比如查询数据库并使用结果填充模板,那么最好将该操作从主线程中移开使用异步完成任务。随着时间的推移,您将了解何时选择异步技术比选择同步技术更有意义。
setTimeout() & setInterval()
很长一段时间以来,web平台为JavaScript程序员提供了许多函数,这些函数允许您在一段时间间隔过后异步执行代码,或者重复异步执行代码块,直到您告诉它停止为止。这些都是:
setTimeout()
在指定的时间后执行一段代码.
setInterval()
以固定的时间间隔,重复运行一段代码.
requestAnimationFrame()
setInterval()的现代版本;在浏览器下一次重新绘制显示之前执行指定的代码块,从而允许动画在适当的帧率下运行,而不管它在什么环境中运行.
这些函数设置的异步代码实际上在主线程上运行(在其指定的计时器过去之后)。
在 setTimeout()
调用执行之前或 setInterval()
迭代之间可以(并且经常会)运行其他代码。根据这些操作的处理器密集程度,它们可以进一步延迟异步代码,因为任何异步代码仅在主线程可用后才执行(换句话说,当调用栈为空时)。
无论如何,这些函数用于在web站点或应用程序上运行不间断的动画和其他后台处理。
setTimeout()
正如前述, setTimeout()
在指定的时间后执行一段特定代码. 它需要如下参数:
- 要运行的函数,或者函数引用。
- 表示在执行代码之前等待的时间间隔(以毫秒为单位,所以1000等于1秒)的数字。如果指定值为0(或完全省略该值),函数将尽快运行。
- 更多的参数:在指定函数运行时,希望传递给函数的值。
Note: 指定的时间(或延迟)不能保证在指定的确切时间之后执行,而是最短的延迟执行时间。在主线程上的堆栈为空之前,传递给这些函数的回调将无法运行。
结果,像 setTimeout(fn, 0)
这样的代码将在堆栈为空时立即执行,而不是立即执行。如果执行类似 setTimeout(fn, 0)
之类的代码,之后立即运行从 1 到 100亿 的循环之后,回调将在几秒后执行。
在下面的示例中,浏览器将在执行匿名函数之前等待两秒钟,然后显示alert消息:
1 | let myGreeting = setTimeout(function() { |
我们指定的函数不必是匿名的。我们可以给函数一个名称,甚至可以在其他地方定义它,并将函数引用传递给 setTimeout()
。以下两个版本的代码片段相当于第一个版本:
1 | // With a named function |
例如,如果我们有一个函数既需要从超时调用,也需要响应某个事件,那么这将非常有用。此外它也可以帮助保持代码整洁,特别是当超时回调超过几行代码时。
setTimeout()
返回一个标志符变量用来引用这个间隔,可以稍后用来取消这个超时任务。
传递参数给setTimeout()
我们希望传递给setTimeout()
中运行的函数的任何参数,都必须作为列表末尾的附加参数传递给它。
例如,我们可以重构之前的函数,这样无论传递给它的人的名字是什么,它都会向它打招呼:
1 | function sayHi(who) { |
人名可以通过第三个参数传进 setTimeout()
:
1 | let myGreeting = setTimeout(sayHi, 2000, 'Mr. Universe'); |
清除超时
最后,如果创建了 timeout,您可以通过调用clearTimeout()
,将setTimeout()
调用的标识符作为参数传递给它,从而在超时运行之前取消。要取消上面的超时,你需要这样做:
1 | clearTimeout(myGreeting); |
setInterval()
当我们需要在一段时间之后运行一次代码时,setTimeout()
可以很好地工作。但是当我们需要反复运行代码时会发生什么,例如在动画的情况下?
这就是setInterval()
的作用所在。这与setTimeout()
的工作方式非常相似,只是作为第一个参数传递给它的函数,重复执行的时间不少于第二个参数给出的毫秒数,而不是一次执行。您还可以将正在执行的函数所需的任何参数作为 setInterval()
调用的后续参数传递。
让我们看一个例子。下面的函数创建一个新的Date()
对象,使用toLocaleTimeString()
从中提取一个时间字符串,然后在UI中显示它。然后,我们使用setInterval()
每秒运行该函数一次,创建一个每秒更新一次的数字时钟的效果。
1 | function displayTime() { |
像setTimeout()
一样, setInterval()
返回一个确定的值,稍后你可以用它来取消间隔任务。
清除intervals
setInterval()
永远保持运行任务,除非我们做点什么——我们可能会想阻止这样的任务,否则当浏览器无法完成任何进一步的任务时我们可能得到错误, 或者动画处理已经完成了。我们可以用与停止超时相同的方法来实现这一点——通过将setInterval()
调用返回的标识符传递给clearInterval()
函数:
1 | const myInterval = setInterval(myFunction, 2000); |
关于 setTimeout() 和 setInterval() 需要注意的几点
当使用 setTimeout()
和 setInterval()
的时候,有几点需要额外注意。 现在让我们回顾一下:
递归的timeouts
还有另一种方法可以使用setTimeout()
:我们可以递归调用它来重复运行相同的代码,而不是使用setInterval()
。
下面的示例使用递归setTimeout()
每100毫秒运行传递来的函数:
1 | let i = 1; |
将上面的示例与下面的示例进行比较 ––这使用 setInterval()
来实现相同的效果:
1 | let i = 1; |
递归setTimeout()和setInterval()有何不同?
上述代码的两个版本之间的差异是微妙的。
- 递归
setTimeout()
保证执行之间的延迟相同,例如在上述情况下为100ms。 代码将运行,然后在它再次运行之前等待100ms,因此无论代码运行多长时间,间隔都是相同的。 - 使用
setInterval()
的示例有些不同。 我们选择的间隔包括执行我们想要运行的代码所花费的时间。假设代码需要40毫秒才能运行 - 然后间隔最终只有60毫秒。 - 当递归使用
setTimeout()
时,每次迭代都可以在运行下一次迭代之前计算不同的延迟。 换句话说,第二个参数的值可以指定在再次运行代码之前等待的不同时间(以毫秒为单位)。
当你的代码有可能比你分配的时间间隔,花费更长时间运行时,最好使用递归的 setTimeout()
- 这将使执行之间的时间间隔保持不变,无论代码执行多长时间,你不会得到错误。
立即超时
使用0用作setTimeout()的回调函数会立刻执行,但是在主线程代码运行之后执行。
举个例子,下面的代码输出一个包含警报的”Hello”,然后在您点击第一个警报的OK之后立即弹出“world”。
1 | setTimeout(function() { |
如果您希望设置一个代码块以便在所有主线程完成运行后立即运行,这将很有用。将其放在异步事件循环中,这样它将随后直接运行。
使用 clearTimeout() or clearInterval()清除
clearTimeout()
和clearInterval()
都使用相同的条目列表进行清除。有趣的是,这意味着你可以使用任一一种方法来清除setTimeout()
和setInterval()
。
但为了保持一致性,你应该使用 clearTimeout()
来清除 setTimeout()
条目,使用 clearInterval()
来清除 setInterval()
条目。 这样有助于避免混乱。
requestAnimationFrame()
requestAnimationFrame()
是一个专门的循环函数,旨在浏览器中高效运行动画。它基本上是现代版本的setInterval()
—— 它在浏览器重新加载显示内容之前执行指定的代码块,从而允许动画以适当的帧速率运行,不管其运行的环境如何。
它是针对setInterval()
遇到的问题创建的,比如 setInterval()
并不是针对设备优化的帧率运行,有时会丢帧。还有即使该选项卡不是活动的选项卡或动画滚出页面等问题 。
该方法将重新加载页面之前要调用的回调函数作为参数。这是您将看到的常见表达:
1 | function draw() { |
这个想法是要定义一个函数,在其中更新动画 (例如,移动精灵,更新乐谱,刷新数据等),然后调用它来开始这个过程。在函数的末尾,以 requestAnimationFrame()
传递的函数作为参数进行调用,这指示浏览器在下一次显示重新绘制时再次调用该函数。然后这个操作连续运行, 因为requestAnimationFrame()
是递归调用的。
注意: 如果要执行某种简单的常规DOM动画, CSS 动画可能更快,因为它们是由浏览器的内部代码计算而不是JavaScript直接计算的。但是,如果您正在做一些更复杂的事情,并且涉及到在DOM中不能直接访问的对象, requestAnimationFrame()
在大多数情况下是更好的选择。
你的动画跑得有多快?
动画的平滑度直接取决于动画的帧速率,并以每秒帧数(fps)为单位进行测量。这个数字越高,动画看起来就越平滑。
由于大多数屏幕的刷新率为60Hz,因此在使用web浏览器时,可以达到的最快帧速率是每秒60帧(FPS)。然而,更多的帧意味着更多的处理,这通常会导致卡顿和跳跃-也称为丢帧或跳帧。
如果您有一个刷新率为60Hz的显示器,并且希望达到60fps,则大约有16.7毫秒(1000/60)来执行动画代码来渲染每个帧。这提醒我们,我们需要注意每次通过动画循环时要运行的代码量。
requestAnimationFrame()
总是试图尽可能接近60帧/秒的值,当然有时这是不可能的如果你有一个非常复杂的动画,你是在一个缓慢的计算机上运行它,你的帧速率将更少。requestAnimationFrame()
会尽其所能利用现有资源提升帧速率。
requestAnimationFrame() 与 setInterval() 和 setTimeout()有什么不同?
让我们进一步讨论一下 requestAnimationFrame()
方法与前面介绍的其他方法的区别. 下面让我们看一下代码:
1 | function draw() { |
现在让我们看看如何使用setInterval()
:
1 | function draw() { |
如前所述,我们没有为requestAnimationFrame()
;指定时间间隔;它只是在当前条件下尽可能快速平稳地运行它。如果动画由于某些原因而处于屏幕外浏览器也不会浪费时间运行它。
另一方面setInterval()
需要指定间隔。我们通过公式1000毫秒/60Hz得出17的最终值,然后将其四舍五入。四舍五入是一个好主意,浏览器可能会尝试运行动画的速度超过60fps,它不会对动画的平滑度有任何影响。如前所述,60Hz是标准刷新率。
包括时间戳
传递给 requestAnimationFrame()
函数的实际回调也可以被赋予一个参数(一个时间戳值),表示自 requestAnimationFrame()
开始运行以来的时间。这是很有用的,因为它允许您在特定的时间以恒定的速度运行,而不管您的设备有多快或多慢。您将使用的一般模式如下所示:
1 | let startTime = null; |
浏览器支持
与setInterval()
或setTimeout()
相比最近的浏览器支持requestAnimationFrame()
— requestAnimationFrame()
.在Internet Explorer 10及更高版本中可用。因此,除非您的代码需要支持旧版本的IE,否则没有什么理由不使用requestAnimationFrame()
。
撤销requestAnimationFrame()
requestAnimationFrame()
可用与之对应的cancelAnimationFrame()
方法“撤销”。
该方法以requestAnimationFrame()
的返回值为参数,此处我们将该返回值存在变量 rAF
中:
1 | cancelAnimationFrame(rAF); |
结论
上述这些方法在许多情况下都很有用,但请注意不要过度使用它们!因为它们仍然在主线程上运行,所以繁重的回调(尤其是那些操纵DOM的回调)会在不注意的情况下降低页面的速度。
Promise
本质上,Promise 是一个对象,代表操作的中间状态 —— 正如它的单词含义 ‘承诺’ ,它保证在未来可能返回某种结果。虽然 Promise 并不保证操作在何时完成并返回结果,但是它保证当结果可用时,你的代码能正确处理结果,当结果不可用时,你的代码同样会被执行,来优雅的处理错误。
通常你不会对一个异步操作从开始执行到返回结果所用的时间感兴趣(除非它耗时过长),你会更想在任何时候都能响应操作结果,当然它不会阻塞其余代码的执行就更好了。
如果某个业务,依赖于上层业务的数据,上层业务又依赖于更上一层的数据,我们还采用回调的方式来处理异步的话,就会出现回调地狱。
在传统的异步编程中,如果异步之间存在依赖关系,我们就需要通过层层嵌套回调来满足这种依赖,如果嵌套层数过多,可读性和可维护性都变得很差,产生所谓“回调地狱”,而Promise将回调嵌套改为链式调用,增加可读性和可维护性。
回调函数的麻烦
要完全理解为什么 promise 是一件好事,应该回想之前的回调函数,理解它们造成的困难。
我们来谈谈订购披萨作为类比。为了使你的订单成功,你必须按顺序执行,不按顺序执行或上一步没完成就执行下一步是不会成功的:
- 选择配料。如果你是优柔寡断,这可能需要一段时间,如果你无法下定决心或者决定换咖喱,可能会失败。
- 下订单。返回比萨饼可能需要一段时间,如果餐厅没有烹饪所需的配料,可能会失败。
- 然后你收集你的披萨吃。如果你忘记了自己的钱包,那么这可能会失败,所以无法支付比萨饼的费用!
对于旧式callbacks,上述功能的伪代码表示可能如下所示:
1 | chooseToppings(function(toppings) { |
这很麻烦且难以阅读(通常称为“回调地狱”),需要多次调用failureCallback()
(每个嵌套函数一次),还有其他问题。
使用promise改良
Promises使得上面的情况更容易编写,解析和运行。如果我们使用异步promises代表上面的伪代码,我们最终会得到这样的结果:
1 | chooseToppings() |
这要好得多 - 更容易看到发生了什么,我们只需要一个.catch()
块来处理所有错误,它不会阻塞主线程(所以我们可以在等待时继续玩视频游戏为了准备好收集披萨),并保证每个操作在运行之前等待先前的操作完成。我们能够以这种方式一个接一个地链接多个异步操作,因为每个.then()
块返回一个新的promise,当.then()
块运行完毕时它会解析。聪明,对吗?
使用箭头函数,你可以进一步简化代码:
1 | chooseToppings() |
甚至这样:
1 | chooseToppings() |
这是有效的,因为使用箭头函数 () => x
是 ()=> {return x;}
的有效简写; 。
你甚至可以这样做,因为函数只是直接传递它们的参数,所以不需要额外的函数层:
1 | chooseToppings().then(placeOrder).then(collectOrder).then(eatPizza).catch(failureCallback); |
但是,这并不容易阅读,如果你的块比我们在此处显示的更复杂,则此语法可能无法使用。
注意: 你可以使用 async/await
语法进行进一步的改进。
最基本的,promise与事件监听器类似,但有一些差异:
- 一个promise只能成功或失败一次。它不能成功或失败两次,并且一旦操作完成,它就无法从成功切换到失败,反之亦然。
- 如果promise成功或失败并且你稍后添加成功/失败回调,则将调用正确的回调,即使事件发生在较早的时间。
promise的基本流程
创建promise时,它既不是成功也不是失败状态。这个状态叫作pending(待定)。
当promise返回时,称为 resolved(已解决)。
- 一个成功resolved的promise称为fullfilled(实现)。它返回一个值,可以通过将
.then()
块链接到promise链的末尾来访问该值。.then()
块中的执行程序函数将包含promise的返回值。 - 一个不成功resolved的promise被称为rejected(拒绝)了。它返回一个原因(reason),一条错误消息,说明为什么拒绝promise。可以通过将
.catch()
块链接到promise链的末尾来访问此原因。
如果一个 promise 已经被兑现(fulfilled)或被拒绝(rejected),那么我们也可以说它处于已敲定(settled)状态。您还会听到一个经常跟 promise 一起使用的术语:已决议(resolved),它表示 promise 已经处于已敲定(settled)状态,或者为了匹配另一个 promise 的状态被”锁定”了。
Promise A+规范
其实 Promise 有多种规范,除了 Promise A、promise A+ 还有 Promise/B,Promise/D。目前我们使用的 Promise 是基于 Promise A+ 规范实现的。
异步处理
很多手写版本都是使用setTimeout
去做异步处理,但是 setTimeout
属于宏任务,这与Promise 是个微任务相矛盾,所以选择一种创建微任务的方式去实现我们的手写代码。
这里有几种选择,一种就是 Promise A+ 规范中也提到的,process.nextTick
( Node 端 )与MutationObserver
( 浏览器端 ),考虑到利用这两种方式需要做环境判断,所以在这里我们就推荐另外一种创建微任务的方式 queueMicrotask
。
手写实现
核心逻辑
原生Promise
1 | const promise = new Promise((resolve, reject) => { |
Promise 是一个类,在执行这个类的时候会传入一个执行器,这个执行器会立即执行
Promise 会有三种状态
- Pending 等待
- Fulfilled 完成
- Rejected 失败
状态只能由 Pending --> Fulfilled
或者 Pending --> Rejected
,且一旦发生改变不可二次修改;
Promise 中使用 resolve
和 reject
两个函数来更改状态;
then
方法内部做的事情就是状态判断
- 如果状态是成功,调用成功回调函数
- 如果状态是失败,调用失败回调函数
新建MyPromise类,传入执行器executor
1 | // 新建 MyPromise.js |
executor 传入 resolve 和 reject 方法
1 | // 新建 MyPromise.js |
状态与结果的管理
1 | // 新建 MyPromise.js |
then 的简单实现
1 | then(onFulfilled,onRejected){ |
使用export 对外暴露 MyPromise 类
1 | // MyPromise.js |
promise类中加入异步逻辑
上面还没有经过异步处理,如果有异步逻辑加如来会带来一些问题,例如:
1 | // test.js |
分析原因:
主线程代码立即执行,setTimeout 是异步代码,then 会马上执行,这个时候判断 Promise 状态,状态是 Pending,然而之前并没有判断等待这个状态
这里就需要我们处理一下 Pending 状态,我们改造一下之前的代码
缓存成功与失败回调
1 | // 存储成功回调函数 |
then 方法中的 Pending 的处理
1 | then(onFulfilled,onRejected){ |
resolve 与 reject 中调用回调函数
1 | // 更改成功后的状态 |
目前已经可以简单处理异步问题了
实现 then 方法多次调用添加多个处理函数
Promise 的 then 方法是可以被多次调用的。这里如果有三个 then 的调用,如果是同步回调,那么直接返回当前的值就行;如果是异步回调,那么保存的成功失败的回调,需要用不同的值保存,因为都互不相同。之前的代码需要改进。
1 | import MyPromise from "./MyPromise.js" |
目前的代码只能输出:3 resolve success
MyPromise 类中新增两个数组
这里实际是用数组模拟了两个队列
1 | // 存储成功回调函数 |
回调函数存入数组中
1 | then(onFulfilled, onRejected) { |
循环调用成功和失败回调
1 | // 更改成功后的状态 |
再来运行一下,看看结果
1 | 1 |
实现 then 方法的链式调用
then 方法要链式调用那么就需要返回一个 Promise 对象。
then 方法里面 return 一个返回值作为下一个 then 方法的参数,如果是 return 一个 Promise 对象,那么就需要判断它的状态。
1 | import MyPromise from "./MyPromise.js" |
之前的手写代码运行的时候会报错
1 | then(onFulfilled, onRejected) { |
执行一下,结果
1 | 1 |
then 方法链式调用识别 Promise 是否返回自己
如果 then 方法返回的是自己的 Promise 对象,则会发生循环调用,这个时候程序会报错
例如下面这种情况
1 | const promise = new Promise((resolve, reject) => { |
使用原生 Promise 执行这个代码,会报类型错误
1 | 100 |
修改代码
1 | then(onFulfilled, onRejected) { |
执行一下
1 | const promise = new MyPromise((resolve, reject) => { |
这里得到我们的结果
1 | 1 |
捕获错误及 then 链式调用其他状态代码补充
捕获执行器错误
捕获执行器中的代码,如果执行器中有代码错误,那么 Promise 的状态要变为失败
1 | constructor(executor) { |
验证一下:
1 | const promise = new MyPromise((resolve, reject) => { |
执行结果
1 | 2 |
then 执行的时错误捕获
1 | then(onFulfilled, onRejected) { |
验证一下:
1 | const promise = new MyPromise((resolve, reject) => { |
执行结果
1 | 1 |
这里成功打印了1中抛出的错误 then error
参考 fulfilled 状态下的处理方式,对 rejected 和 pending 状态进行改造
改造内容包括:
- 增加异步状态下的链式调用
- 增加回调函数执行结果的判断
- 增加识别 Promise 是否返回自己
- 增加错误捕获
1 | then(onFulfilled, onRejected) { |
then 中的参数变为可选
上面我们处理 then 方法的时候都是默认传入 onFulfilled、onRejected 两个回调函数,但是实际上原生 Promise 是可以选择参数的单传或者不传,都不会影响执行。
例如下面这种
1 | const promise = new Promise((resolve, reject) => { |
所以我们需要对 then 方法做一点小小的调整
1 | then(onFulfilled, onRejected) { |
改造完自然是需要验证一下的
先看情况一:resolve 之后
1 | const promise = new MyPromise((resolve, reject) => { |
先看情况一:reject 之后
1 | const promise = new MyPromise((resolve, reject) => { |
Promise.resolve() & Promise.reject()
就像开头挂的那道面试题使用 return Promise.resolve
来返回一个 Promise 对象,我们用现在的手写代码尝试一下
1 | MyPromise.resolve().then(() => { |
结果它报错了 😥
1 | MyPromise.resolve().then(() => { |
除了 Promise.resolve 还有 Promise.reject 的用法,我们都要去支持,接下来我们来实现一下
Promise.resolve(value)
方法返回一个以给定值解析后的Promise
]对象。
- 如果这个值是一个 promise ,那么将返回这个 promise ;
- 如果这个值是thenable(即带有
"then"
方法),返回的promise会“跟随”这个thenable的对象,采用它的最终状态;(尚未实现) - 否则返回的promise将以此值完成。
此函数将类promise对象的多层嵌套展平。
Promise.reject()
方法返回一个带有拒绝原因的Promise
对象。
1 | MyPromise { |
执行结果
1 | 0 |
Promise.prototype.catch()
catch()方法
返回一个Promise,并且处理拒绝的情况。它的行为与调用Promise.prototype.then(undefined, onRejected) 相同。
1 | //catch方法其实就是执行一下then的第二个回调 |
Promise.all()
Promise.all()
方法接收一个promise的iterable
类型(注:Array
,Map
,Set
都属于ES6的iterable类型)的输入,并且只返回一个Promise实例, 那个输入的所有promise的resolve回调的结果是一个数组。
这个Promise的resolve回调执行是在所有输入的promise的resolve回调都结束,或者输入的iterable里没有promise了的时候。
它的reject回调执行是,只要任何一个输入的promise的reject回调执行或者输入不合法的promise就会立即抛出错误,并且reject的是第一个抛出的错误信息。
1 | //all 静态方法 |
测试一下
1 | const promise1 = MyPromise.resolve(3); |
Promise.race()
Promise.race(iterable)
方法返回一个 promise,一旦迭代器中的某个promise解决或拒绝,返回的promise就会解决或拒绝。
状态只能由 Pending --> Fulfilled
或者 Pending --> Rejected
,且一旦发生改变不可二次修改。
1 | static race(promiseArr) { |
测试一下
1 | const promise1 = new Promise((resolve, reject) => { |
Promise.prototype.finally()
finally()
方法返回一个Promise
。在promise结束时,无论结果是fulfilled或者是rejected,都会执行指定的回调函数。
这为在Promise
是否成功完成后都需要执行的代码提供了一种方式。
这避免了同样的语句需要在then()
和catch()
中各写一次的情况。
如果你想在 promise 执行完毕后无论其结果怎样都做一些处理或清理时,finally()
方法可能是有用的。
finally()
虽然与 .then(onFinally, onFinally)
类似,它们不同的是:
- 调用内联函数时,不需要多次声明该函数或为该函数创建一个变量保存它。
- 由于无法知道
promise
的最终状态,所以finally
的回调函数中不接收任何参数,它仅用于无论最终结果如何都要执行的情况。 - 与
Promise.resolve(2).then(() => {}, () => {})
(resolved的结果为undefined
)不同,Promise.resolve(2).finally(() => {})
resolved的结果为2
。 - 同样,
Promise.reject(3).then(() => {}, () => {})
(rejected的结果为undefined
),Promise.reject(3).finally(() => {})
rejected 的结果为3
。
备注: 在finally
回调中 throw
(或返回被拒绝的promise)将以 throw()
指定的原因拒绝新的promise.
1 | //finally方法 |
Promise.allSettled()
该Promise.allSettled()
方法返回一个在所有给定的promise都已经fulfilled
或rejected
后的promise,并带有一个对象数组,每个对象表示对应的promise结果。
当您有多个彼此不依赖的异步任务成功完成时,或者您总是想知道每个promise
的结果时,通常使用它。
相比之下,Promise.all()
更适合彼此相互依赖或者在其中任何一个reject
时立即结束。
1 | //allSettled方法 |
测试一下
1 | const promise1 = Promise.resolve(3); |
完整代码
1 | // 新建 MyPromise.js |
一道面试题
1 | Promise.resolve().then(() => { |
打印结果:0、1、2、3、4、5、6
这里4怎么跑到3后面去了,不讲武德? Why……
Js引擎为了让microtask尽快的输出,做了一些优化,连续的多个then(3个)如果没有reject或者resolve会交替执行then而不至于让一个堵太久完成用户无响应,不单单v8这样其他引擎也是这样,因为其实promuse内部状态已经结束了。这块在v8源码里有完整的体现。
async + await
async functions
和 await
关键字是最近添加到JavaScript语言里面的。它们是ECMAScript 2017 JavaScript版的一部分。简单来说,它们是基于promises的语法糖,使异步代码更易于编写和阅读。通过使用它们,异步代码看起来更像是老式同步代码,因此它们非常值得学习。
async
首先,我们使用 async
关键字,把它放在函数声明之前,使其成为 async function
。异步函数是一个知道怎样使用 await
关键字调用异步代码的函数。
尝试在浏览器的JS控制台中键入以下行:
1 | function hello() { return "Hello" }; |
该函数返回“Hello” —— 没什么特别的,对吧?
如果我们将其变成异步函数呢?请尝试以下方法:
1 | async function hello() { return "Hello" }; |
哈。现在调用该函数会返回一个 promise
。这是异步函数的特征之一 —— 它保证函数的返回值为 promise。
你也可以创建一个异步函数表达式,如下所示:
1 | let hello = async function() { return "Hello" }; |
你可以使用箭头函数:
1 | let hello = async () => { return "Hello" }; |
这些都基本上是一样的。
要实际使用promise完成时返回的值,我们可以使用.then()
块,因为它返回的是 promise:
1 | hello().then((value) => console.log(value)) |
甚至只是简写如
1 | hello().then(console.log) |
这就像我们在上一篇文章中看到的那样。
将 async
关键字加到函数申明中,可以告诉它们返回的是 promise
,而不是直接返回值。此外,它避免了同步函数为支持使用 await 带来的任何潜在开销。在函数声明为 async
时,JavaScript引擎会添加必要的处理,以优化你的程序。爽!
await
当 await
关键字与异步函数一起使用时,它的真正优势就变得明显了 —— 事实上, await 只在异步函数里面才起作用。
它可以放在任何异步的,基于 promise 的函数之前。它会暂停代码在该行上,直到 promise 完成,然后返回结果值。
在暂停的同时,其他正在等待执行的代码就有机会执行了。
您可以在调用任何返回Promise的函数时使用 await,包括Web API函数。
这是一个简单的示例:
1 | async function hello() { |
使用 async/await 重写 promise 代码
让我们回顾一下我们在上一篇文章中简单的 fetch 示例:
1 | fetch('coffee.jpg') |
到现在为止,你应该对 promises 及其工作方式有一个较好的理解。让我们将其转换为使用async / await看看它使事情变得简单了多少:
1 | async function myFetch() { |
它使代码简单多了,更容易理解 —— 去除了到处都是 .then()
代码块!
由于 async
关键字将函数转换为 promise,您可以重构以上代码 —— 使用 promise 和 await 的混合方式,将函数的后半部分抽取到新代码块中。这样做可以更灵活:
1 | async function myFetch() { |
它到底是如何工作的?
您会注意到我们已经将代码封装在函数中,并且我们在 function
关键字之前包含了 async
关键字。这是必要的 –– 您必须创建一个异步函数来定义一个代码块,在其中运行异步代码; await 只能在异步函数内部工作。
在myFetch()
函数定义中,您可以看到代码与先前的 promise 版本非常相似,但存在一些差异。不需要附加 .then()
代码块到每个promise-based方法的结尾,你只需要在方法调用前添加 await 关键字,然后把结果赋给变量。await 关键字使JavaScript运行时暂停于此行,允许其他代码在此期间执行,直到异步函数调用返回其结果。一旦完成,您的代码将继续从下一行开始执行。例如:
1 | let response = await fetch('coffee.jpg'); |
解析器会在此行上暂停,直到当服务器返回的响应变得可用时。此时 fetch()
返回的 promise 将会完成(fullfilled),返回的 response 会被赋值给 response
变量。一旦服务器返回的响应可用,解析器就会移动到下一行,从而创建一个Blob
。Blob这行也调用基于异步promise的方法,因此我们也在此处使用await
。当操作结果返回时,我们将它从myFetch()
函数中返回。
这意味着当我们调用myFetch()
函数时,它会返回一个promise,因此我们可以将.then()
链接到它的末尾,在其中我们处理显示在屏幕上的blob
。
你可能已经觉得“这真的很酷!”,你是对的 —— 用更少的.then()
块来封装代码,同时它看起来很像同步代码,所以它非常直观。
添加错误处理
如果你想添加错误处理,你有几个选择。
您可以将同步的 try...catch
结构和 async/await
一起使用 。此示例扩展了我们上面展示的第一个版本代码:
1 | async function myFetch() { |
catch() {}
代码块会接收一个错误对象 e
; 我们现在可以将其记录到控制台,它将向我们提供详细的错误消息,显示错误被抛出的代码中的位置。
如果你想使用我们上面展示的第二个(重构)代码版本,你最好继续混合方式并将 .catch()
块链接到 .then()
调用的末尾,就像这样:
1 | async function myFetch() { |
这是因为 .catch()
块将捕获来自异步函数调用和promise链中的错误。如果您在此处使用了try/catch
代码块,则在调用 myFetch()
函数时,您仍可能会收到未处理的错误。
等待Promise.all()
async / await
建立在 promises之上,因此它与promises提供的所有功能兼容。这包括Promise.all()
–– 你完全可以通过调用 await
Promise.all()
将所有结果返回到变量中,就像同步代码一样。
将其转换为 async / await,现在看起来像这样:
1 | async function fetchAndDecode(url, type) { |
可以看到 fetchAndDecode()
函数只进行了一丁点的修改就转换成了异步函数。请看Promise.all()
行:
1 | let values = await Promise.all([coffee, tea, description]); |
在这里,通过使用await
,我们能够在三个promise的结果都可用的时候,放入values
数组中。这看起来非常像同步代码。我们需要将所有代码封装在一个新的异步函数displayContent()
中,尽管没有减少很多代码,但能够将大部分代码从 .then()
代码块移出,使代码得到了简化,更易读。
为了错误处理,我们在 displayContent()
调用中包含了一个 .catch()
代码块;这将处理两个函数中出现的错误。
async/await的缺陷
了解Async/await
是非常有用的,但还有一些缺点需要考虑。
Async/await
让你的代码看起来是同步的,在某种程度上,也使得它的行为更加地同步。 await
关键字会阻塞其后的代码,直到promise完成,就像执行同步操作一样。它确实可以允许其他任务在此期间继续运行,但您自己的代码被阻塞。
这意味着您的代码可能会因为大量await
的promises相继发生而变慢。每个await
都会等待前一个完成,而你实际想要的是所有的这些promises同时开始处理(就像我们没有使用async/await
时那样)。
有一种模式可以缓解这个问题——通过将 Promise
对象存储在变量中来同时开始它们,然后等待它们全部执行完毕。让我们看一些证明这个概念的例子。
我们有两个可用的例子 —— slow-async-await.html和fast-async-await.html。它们都以自定义promise函数开始,该函数使用setTimeout()
调用伪造异步进程:
1 | function timeoutPromise(interval) { |
然后每个包含一个 timeTest()
异步函数,等待三个 timeoutPromise()
调用:
1 | async function timeTest() { |
每一个都以记录开始时间结束,查看 timeTest()
promise 需要多长时间才能完成,然后记录结束时间并报告操作总共需要多长时间:
1 | let startTime = Date.now(); |
timeTest()
函数在每种情况下都不同。
在slow-async-await.html示例中,timeTest()
如下所示:
1 | async function timeTest() { |
在这里,我们直接等待所有三个timeoutPromise()调用,使每个调用3秒钟。后续的每一个都被迫等到最后一个完成 - 如果你运行第一个例子,你会看到弹出框报告的总运行时间大约为9秒。
在fast-async-await.html示例中,timeTest()
如下所示:
1 | async function timeTest() { |
在这里,我们将三个Promise对象存储在变量中,这样可以同时启动它们关联的进程。
接下来,我们等待他们的结果 - 因为promise都在基本上同时开始处理,promise将同时完成;当您运行第二个示例时,您将看到弹出框报告总运行时间仅超过3秒!
您必须仔细测试您的代码,并在性能开始受损时牢记这一点。
另一个小小的不便是你必须将等待执行的promise封装在异步函数中。
Async/await 的类方法
最后值得一提的是,我们可以在类/对象方法前面添加async
,以使它们返回promises,并await
它们内部的promises。
1 | class Person { |
第一个实例方法可以使用如下:
1 | han.greeting().then(console.log); |
选择正确的方法
异步回调
通常在旧式API中找到,涉及将函数作为参数传递给另一个函数,然后在异步操作完成时调用该函数,以便回调可以依次对结果执行某些操作。这是promise的前身;它不那么高效或灵活。仅在必要时使用。
通过XMLHttpRequest
API加载资源的示例:
1 | function loadAsset(url, type, callback) { |
缺陷
- 嵌套回调可能很麻烦且难以阅读(即“回调地狱”)
- 每层嵌套都需要故障回调,而使用promises,您只需使用一个
.catch()
代码块来处理整个链的错误。 - 异步回调不是很优雅。
- Promise回调总是按照它们放在事件队列中的严格顺序调用;异步回调不是。
- 当传入到一个第三方库时,异步回调对函数如何执行失去完全控制。
setTimeout()
setTimeout()
是一种允许您在经过任意时间后运行函数的方法
这里浏览器将在执行匿名函数之前等待两秒钟,然后将显示警报消息:
1 | let myGreeting = setTimeout(function() { |
缺陷
您可以使用递归的setTimeout()
调用以类似于setInterval()
的方式重复运行函数,使用如下代码:
1 | let i = 1; |
递归setTimeout()
和setInterval()
之间存在差异:
- 递归
setTimeout()
保证两次执行间经过指定的时间量(在本例中为100ms);代码将运行,然后等待100毫秒再次运行。无论代码运行多长时间,间隔都是相同的。 - 使用
setInterval()
,我们选择的时间间隔包含了运行代码所花费的时间。(还是100ms为例)假设代码需要40毫秒才能运行 –– 间隔最终只会有60毫秒。
当你的代码有可能比你分配的时间间隔更长时间运行时,最好使用递归的setTimeout()
––这将使执行之间的时间间隔保持不变,无论代码执行多长时间,你不会得到错误。
setInterval()
setInterval()
函数允许重复执行一个函数,并设置时间间隔。不如requestAnimationFrame()
有效率,但允许您选择运行速率/帧速率。
以下函数创建一个新的Date()
对象,使用toLocaleTimeString()
从中提取时间字符串,然后在UI中显示它。然后我们使用setInterval()
每秒运行一次,创建每秒更新一次的数字时钟的效果:
1 | function displayTime() { |
缺陷
- 帧速率未针对运行动画的系统进行优化,并且可能效率低下。除非您需要选择特定(较慢)的帧速率,否则通常最好使用
requestAnimationFrame()
.
requestAnimationFrame()
requestAnimationFrame()
是一种允许您以给定当前浏览器/系统的最佳帧速率重复且高效地运行函数的方法。除非您需要特定的速率帧,否则您应该尽可能使用它而不要去使用setInterval()/recursive setTimeout()
。
一个简单的动画旋转器:
1 | const spinner = document.querySelector('div'); |
缺陷
- 您无法使用
requestAnimationFrame()
选择特定的帧速率。如果需要以较慢的帧速率运行动画,则需要使用setInterval()
或递归的setTimeout()
。
Promises
Promises是一种JavaScript功能,允许您运行异步操作并等到它完全完成后再根据其结果运行另一个操作。
Promise是现代异步JavaScript的支柱。
以下代码从服务器获取图像并将其显示在<img>
元素中:
1 | fetch('coffee.jpg') |
缺陷
Promise链可能很复杂,难以解析。如果你嵌套了许多promises,你最终可能会遇到类似的麻烦来回调地狱。例如:
1 | remotedb.allDocs({ |
最好使用promises的链功能,这样使用更平顺,更易于解析的结构:
1 | remotedb.allDocs(...).then(function (resultOfAllDocs) { |
乃至:
1 | remotedb.allDocs(...) |
Promise.all()
一种JavaScript功能,允许您等待多个promises完成,然后根据所有其他promises的结果运行进一步的操作。
以下示例从服务器获取多个资源,并使用Promise.all()
等待所有资源可用,然后显示所有这些资源:
1 | function fetchAndDecode(url, type) { |
缺陷
- 如果
Promise.all()
拒绝,那么你在其数组参数中输入的一个或多个promise(s)就会被拒绝,或者可能根本不返回promises。你需要检查每一个,看看他们返回了什么。
Async/await
构造在promises之上的语法糖,允许您使用更像编写同步回调代码的语法来运行异步操作。
以下示例是我们之前看到的简单承诺示例的重构,该示例获取并显示图像,使用async / await编写:
1 | async function myFetch() { |
缺陷
- 您不能在非
async
函数内或代码的顶级上下文环境中使用await
运算符。这有时会导致需要创建额外的函数封包,这在某些情况下会略微令人沮丧。但大部分时间都值得。 - 浏览器对async / await的支持不如promises那样好。如果你想使用async / await但是担心旧的浏览器支持,你可以考虑使用BabelJS库 - 这允许你使用最新的JavaScript编写应用程序,让Babel找出用户浏览器需要的更改。
事件循环机制
我们都知道 Js 是单线程的,但是一些高耗时操作就带来了进程阻塞问题。
为了解决这个问题,Js 有两种任务的执行模式:同步模式(Synchronous)和异步模式(Asynchronous)。
宏任务与微任务
在异步模式下,创建异步任务主要分为宏任务与微任务两种。
ES6 规范中,宏任务(Macrotask) 称为 Task, 微任务(Microtask) 称为 Jobs。
宏任务是由宿主(浏览器、Node)发起的,而微任务由 JS 自身发起。
宏任务与微任务的几种创建方式
宏任务(Macrotask) | 微任务(Microtask) |
---|---|
setTimeout | requestAnimationFrame(有争议) |
setInterval | MutationObserver(浏览器环境) |
MessageChannel | Promise.[ then/catch/finally ] |
I/O,事件队列 | process.nextTick(Node环境) |
setImmediate(Node环境) | queueMicrotask |
script(整体代码块) |
EventLoop
- 判断宏任务队列是否为空
- 不空 –> 执行最早进入队列的任务 –> 执行下一步
- 空 –> 执行下一步
- 判断微任务队列是否为空
- 不空 –> 执行最早进入队列的任务 –> 继续检查微任务队列空不空
- 空 –> 执行下一步
如何理解 script(整体代码块)是个宏任务呢
实际上如果同时存在两个 script 代码块,会首先在执行第一个 script 代码块中的同步代码,如果这个过程中创建了微任务并进入了微任务队列,第一个 script 同步代码执行完之后,会首先去清空微任务队列,再去开启第二个 script 代码块的执行。所以这里应该就可以理解 script(整体代码块)为什么会是宏任务。
因为首次执行宏队列中会有 script(整体代码块)任务,所以实际上就是 Js 解析完成后,在异步任务中,会先执行完所有的微任务,这里也是很多面试题喜欢考察的。需要注意的是,新创建的微任务会立即进入微任务队列排队执行,不需要等待下一次轮回。
所谓任务,浅显来说就是代码块开始执行的入口(确切地说,是函数栈的入口,但是栈的概念较为复杂,不表)。而在 JS
里,除了“script
整体代码块”之外,所有代码块的入口都是“*回调函数*”,回调函数被注册到事件后不会马上被执行,而是保存在一个神秘的的地方,保存起来待执行的才能算“任务”,然后才有宏/微任务之分。
“script
整体代码块”的特殊之处,在于它的入口不是回调函数,但是我们可以想象它被装在一个隐形的函数里,作为回调函数被注册到某个事件里(大概是它解析完成之后会触发的一个事件),这时候这个隐形的函数就成为了一个任务。
总的来说就是,宏任务作为主导,它有支配微任务的能力,在一个宏任务任务消灭之前,它会让它创建的微任务任务都执行完,然后才进入下一个宏任务任务。
然后,入栈出栈,这个另一个概念,是每个任务执行它的代码的时候发生的,比如变量定义,函数调用,通过栈的入出,计算出结果。
补充
计算机中的同步是连续性的动作,上一步未完成前,下一步会发生堵塞,直至上一步完成后,下一步才可以继续执行。
JavaScript
的确是一门单线程语言,但是浏览器UI
是多线程的,异步任务借助浏览器的线程和JavaScript
的执行机制实现。 例如,setTimeout
就借助浏览器定时器触发线程的计时功能来实现。
JavaScript 执行上下文
当一段 JavaScript 代码在运行的时候,它实际上是运行在执行上下文中。下面3种类型的代码会创建一个新的执行上下文:
- 全局上下文是为运行代码主体而创建的执行上下文,也就是说它是为那些存在于JavaScript 函数之外的任何代码而创建的。
- 每个函数会在执行的时候创建自己的执行上下文。这个上下文就是通常说的 “本地/局部上下文(local context)”。
- 使用
eval()
函数也会创建一个新的执行上下文。
每一个上下文在本质上都是一种作用域层级。每个代码段开始执行的时候都会创建一个新的上下文来运行它,并且在代码退出的时候销毁掉。看看下面这段 JavaScript 程序:
1 | let outputElem = document.getElementById("output"); |
这段程序代码包含了三个执行上下文,其中有些会在程序运行的过程中多次创建和销毁。每个上下文创建的时候会被推入执行上下文栈。当退出的时候,它会从上下文栈中移除。
- 程序开始运行时,全局上下文就会被创建好。
- 当执行到
greetUser("Mike")
的时候会为greetUser()
函数创建一个它的上下文。这个执行上下文会被推入执行上下文栈中。- 当
greetUser()
调用localGreeting()
的时候会为该方法创建一个新的上下文。并且在localGreeting()
退出的时候它的上下文也会从执行栈中弹出并销毁。 程序会从栈中获取下一个上下文并恢复执行, 也就是从greetUser()
剩下的部分开始执行。 greetUser()
执行完毕并退出,其上下文也从栈中弹出并销毁。
- 当
- 当
greetUser("Teresa")
开始执行时,程序又会为它创建一个上下文并推入栈顶。- 当
greetUser()
调用localGreeting()
的时候另一个上下文被创建并用于运行该函数。 当localGreeting()
退出的时候它的上下文也从栈中弹出并销毁。greetUser()
得到恢复并继续执行剩下的部分。 greetUser()
执行完毕并退出,其上下文也从栈中弹出并销毁。
- 当
- 然后执行到
greetUser("Veronica")
又再为它创建一个上下文并推入栈顶。- 当
greetUser()
调用localGreeting()
的时候,另一个上下文被创建用于执行该函数。当localGreeting()
执行完毕,它的上下文也从栈中弹出并销毁。 greetUser()
执行完毕退出,其上下文也从栈中弹出并销毁。
- 当
- 当执行到
- 主程序退出,全局执行上下文从执行栈中弹出。此时栈中所有的上下文都已经弹出,程序执行完毕。
以这种方式来使用执行上下文,使得每个程序和函数都能够拥有自己的变量和其他对象。
每个上下文还能够额外的跟踪程序中下一行需要执行的代码以及一些对上下文非常重要的信息。
以这种方式来使用上下文和上下文栈,使得我们可以对程序运行的一些基础部分进行管理,包括局部和全局变量、函数的调用与返回等。
关于递归函数——即多次调用自身的函数,需要特别注意:每次递归调用自身都会创建一个新的上下文。
这使得 JavaScript 运行时能够追踪递归的层级以及从递归中得到的返回值,但这也意味着每次递归都会消耗内存来创建新的上下文。
JavaScript运行时
在执行 JavaScript 代码的时候,JavaScript 运行时实际上维护了一组用于执行 JavaScript 代码的代理。
每个代理由一组执行上下文的集合、执行上下文栈、主线程、一组可能创建用于执行 worker 的额外的线程集合、一个任务队列以及一个微任务队列构成。
除了主线程(某些浏览器在多个代理之间共享的主线程)之外,其它组成部分对该代理都是唯一的。
事件循环(Event loops)
每个代理都是由事件循环驱动的,事件循环负责收集事件(包括用户事件以及其他非用户事件等)、对任务进行排队以便在合适的时候执行回调。然后它执行所有处于等待中的 JavaScript 任务(宏任务),然后是微任务,然后在开始下一次循环之前执行一些必要的渲染和绘制操作。
网页或者 app 的代码和浏览器本身的用户界面程序运行在相同的线程中, 共享相同的 事件循环。 该线程就是主线程,它除了运行网页本身的代码之外,还负责收集和派发用户和其它事件,以及渲染和绘制网页内容等。
然后,事件循环会驱动发生在浏览器中与用户交互有关的一切,但在这里,对我们来说更重要的是需要了解它是如何负责调度和执行在其线程中执行的每段代码的。
有如下三种事件循环:
Window 事件循环
window 事件循环驱动所有同源的窗口。
Worker 事件循环
worker 事件循环顾名思义就是驱动 worker 的事件循环。这包括了所有种类的 worker:最基本的 web worker以及 shared worker 和 service worker。 Worker 被放在一个或多个独立于 “主代码” 的代理中。浏览器可能会用单个或多个事件循环来处理给定类型的所有 worker。
Worklet 事件循环
worklet事件循环用于驱动运行 worklet 的代理。这包含了
Worklet
、AudioWorklet
以及PaintWorklet
。
多个同源(译者注:此处同源的源应该不是指同源策略中的源,而是指由同一个窗口打开的多个子窗口或同一个窗口中的多个 iframe 等,意味着起源的意思,下一段内容就会对这里进行说明)窗口可能运行在相同的事件循环中,每个队列任务进入到事件循环中以便处理器能够轮流对它们进行处理。记住这里的网络术语 “window” 实际上指的用于运行网页内容的浏览器级容器,包括实际的 window,一个 tab 标签或者一个 frame。
在特定情况下,同源窗口之间共享事件循环,例如:
- 如果一个窗口打开了另一个窗口,它们可能会共享一个事件循环。
- 如果窗口是包含在
<iframe>
中,则它可能会和包含它的窗口共享一个事件循环。 - 在多进程浏览器中多个窗口碰巧共享了同一个进程。
这种特定情况依赖于浏览器的具体实现,各个浏览器可能并不一样。
任务 vs 微任务
一个任务就是指计划由标准机制来执行的任何 JavaScript,如程序的初始化、事件触发的回调等。 除了使用事件,你还可以使用 setTimeout()
或者 setInterval()
来添加任务。
任务队列和微任务队列的区别很简单,但却很重要:
- 当执行来自任务队列中的任务时,在每一次新的事件循环开始迭代的时候运行时都会执行队列中的每个任务。在每次迭代开始之后加入到队列中的任务需要在下一次迭代开始之后才会被执行.
- 每次当一个任务退出且执行上下文为空的时候,微任务队列中的每一个微任务会依次被执行。不同的是它会等到微任务队列为空才会停止执行——即使中途有微任务加入。换句话说,微任务可以添加新的微任务到队列中,并在下一个任务开始执行之前且当前事件循环结束之前执行完所有的微任务。
问题
由于你的代码和浏览器的用户界面运行在同一个线程中,共享同一个事件循环,假如你的代码阻塞了或者进入了无限循环,则浏览器将会卡死。无论是由于 bug 引起还是代码中进行复杂的运算导致的性能降低,都会降低用户的体验。
当来自多个程序的多个代码对象尝试同时运行的时候,一切都可能变得很慢甚至被阻塞,更不要说浏览器还需要时间来渲染和绘制网站和 UI、处理用户事件等。
解决方案
使用 web workers
可以让主线程另起新的线程来运行脚本,这能够缓解上面的情况。一个设计良好的网站或应用会把一些复杂的或者耗时的操作交给 worker 去做,这样可以让主线程除了更新、布局和渲染网页之外,尽可能少的去做其他事情。
通过使用像 promises
这样的异步JavaScript技术
可以使得主线程在等待请求返回结果的同时继续往下执行,这能够更进一步减轻上面提到的情况。然而,一些更接近于基础功能的代码——比如一些框架代码,可能更需要将代码安排在主线程上一个安全的时间来运行,它与任何请求的结果或者任务无关。
微任务是另一种解决该问题的方案,通过将代码安排在下一次事件循环开始之前运行而不是必须要等到下一次开始之后才执行,这样可以提供一个更好的访问级别。
微任务队列已经存在有一段时间了,但之前它仅仅被内部使用来驱动诸如 promise 这些。queueMicrotask()
的加入可以让开发者创建一个统一的微任务队列,它能够在任何时候即便是当 JavaScript 执行上下文栈中没有执行上下文剩余时也可以将代码安排在一个安全的时间运行。 在多个实例、所有浏览器以及运行时中,一个标准的微任务队列机制意味着这些微任务可以非常可靠的以相同的顺序执行,从而避免一些潜在的难以发现的错误。
queueMicrotask
可以安全的引入微任务而避免使用额外的技巧。
通过引入 queueMicrotask()
,由晦涩地使用 promise 去创建微任务而带来的风险就可以被避免了。举例来说,当使用 promise 创建微任务时,由回调抛出的异常被报告为 rejected promises 而不是标准异常。同时,创建和销毁 promise 带来了事件和内存方面的额外开销,这是正确入列微任务的函数应该避免的。
简单的传入一个 JavaScript Function
,以在 queueMicrotask()
方法中处理微任务时供其上下文调用即可;取决于当前执行上下文, queueMicrotask()
以定义的形式被暴露在 Window
或 Worker
接口上。
1 | queueMicrotask(() => { |
微任务函数本身没有参数,也不返回值。
何时使用微任务
我们来看看微任务特别有用的场景。通常,这些场景关乎捕捉或检查结果、执行清理等;其时机晚于一段 JavaScript 执行上下文主体的退出,但早于任何事件处理函数、timeouts 或 intervals 及其他回调被执行。
何时是那种有用的时候?
使用微任务的最主要原因简单归纳为:确保任务顺序的一致性,即便当结果或数据是同步可用的,也要同时减少操作中用户可感知到的延迟而带来的风险。
保证条件性使用 promises 时的顺序
微任务可被用来确保执行顺序总是一致的一种情形,是当 promise 被用在一个 if...else
语句(或其他条件性语句)中、但并不在其他子句中的时候。考虑如下代码:
1 | customElement.prototype.getData = url => { |
这段代码带来的问题是,通过在 if...else
语句的其中一个分支(此例中为缓存中的图片地址可用时)中使用一个任务而 promise 包含在 else
子句中,我们面临了操作顺序可能不同的局势;比方说,像下面看起来的这样:
1 | element.addEventListener("load", () => console.log("Loaded data")); |
连续执行两次这段代码会形成下表中的结果:
数据未缓存 | 数据已缓存 |
---|---|
Fetching data Data fetched Loaded data | Fetching data Loaded data Data fetched |
甚至更糟的是,有时元素的 data
属性会被设置,还有时当这段代码结束运行时却不会被设置。
我们可以通过在 if
子句里使用一个微任务来确保操作顺序的一致性,以达到平衡两个子句的目的:
1 | customElement.prototype.getData = url => { |
通过在两种情况下各自都通过一个微任务(if
中用的是 queueMicrotask()
而 else
子句中通过 fetch()
使用了 promise)处理了设置 data
和触发 load
事件,平衡了两个子句。
批量操作
也可以使用微任务从不同来源将多个请求收集到单一的批处理中,从而避免对处理同类工作的多次调用可能造成的开销。
下面的代码片段创建了一个函数,将多个消息放入一个数组中批处理,通过一个微任务在上下文退出时将这些消息作为单一的对象发送出去。
1 | const messageQueue = []; |
当 sendMessage()
被调用时,指定的消息首先被推入消息队列数组。接着事情就变得有趣了。
如果我们刚加入数组的消息是第一条,就入列一个将会发送一个批处理的微任务。照旧,当 JavaScript 执行路径到达顶层,恰在运行回调之前,那个微任务将会执行。这意味着之后的间歇期内造成的对 sendMessage()
的任何调用都会将其各自的消息推入消息队列,但囿于入列微任务逻辑之前的数组长度检查,不会有新的微任务入列。
当微任务运行之时,等待它处理的可能是一个有若干条消息的数组。微任务函数先是通过 JSON.stringify()
方法将消息数组编码为 JSON。其后,数组中的内容就不再需要了,所以清空 messageQueue
数组。最后,使用 fetch()
方法将编码后的 JSON 发往服务器。
这使得同一次事件循环迭代期间发生的每次 sendMessage()
调用将其消息添加到同一个 fetch()
操作中,而不会让诸如 timeouts 等其他可能的定时任务推迟传递。
服务器将接到 JSON 字符串,然后大概会将其解码并处理其从结果数组中找到的消息。