javascript 事件循环是如何工作的以及使用async/await提升异步编程的5种方式
这次我们将通过回顾在单线程环境中编程的缺点来扩展先前的第一篇文章,以及如何克服它来建立一个令人惊叹的 javascript UIs。像以往一样,在文章的最后,我会分享使用 async/await 写简洁代码的 5 个小贴士。
为什么单线程是一个限制?
在我们推出的第一篇文章, 我们仔细思考了这个问题,当我们调用了一个非常耗时的函数时发生了什么。
想象一下,例如浏览器中运行着一个复杂的图像变换算法。
当调用栈执行函数时,浏览器不能做任何其他事——它被阻塞了。这意味着浏览器不能渲染,不能执行其他代码,暂时卡住了。那问题也来了————你的UI界面也不再顺畅和高效。
你的应用被卡住了。
它很丑陋,完全毁了你的用户体验:
javascript 程序的组成部分
你可能会把你的 javascript 应用写在单个 .js
文件里,但你的程序肯定包含着好几块,只有其只一块正在执行,其他的会稍后执行。最常见的块就是函数。
问题是,大多数新开发者似乎都觉得,这个 稍后执行 并不是严格地在 当前执行 之后立即执行。换句话说,任务当前不能立即完成,通过定义,将会异步完成。意味着不会出现上面所说的阻塞行为,就像你所期望或希望发生的那样。
让我们看看下面的例子:
你可能已经意识到,标准的 Ajax
事件不会同步完成。意味着当执行代码 ajax(...)
的时候,并不会立即返回值并赋值给变量 response
。
一个简单的方法就是使用一个叫 回调函数(callback)的函数去等待异步函数返回的结果。
提醒一下:你实际可以发起一个 同步 的 Ajax
请求。但永远不要这样做。如果你发送一个同步 Ajax 请求, 你 javascript 应用的 UI 就会被阻塞————用户将不能点击,输入数据,导航或滚动。这会阻碍用户交互,非常糟糕的体验。
就像下面这样,但请永远不要这样做,不要毁了网页:
我们只是用 Ajax 请求做一个例子,你可以异步执行任意一段代码。
可以通过 setTimeout(callback, milliseconds)
函数来做到。setTimeout
函数建立一个延后发生的事件。就像这样:
将会输出
first
third
second
剖析事件循环
我们会从一个奇怪的要求开始————尽管允许异步编码(就像刚刚讨论的setTimeout
),直到 ES6, javascript从未有过真正直接的异步的概念。javascript 引擎在执行单一代码块的时刻不会再做任何事情。
关于javascript 引擎(google专用的V8引擎)的更多细节,请查看我们之前的文章.
那么,谁让 javascript 引擎执行程序块呢?现实中,javascript 引擎并不是孤立运行的————它运行在一个 宿主 环境中,对于大多数开发者来说,典型的是网页浏览器或者 Node.js。事实上,javascript 被嵌入到各种设备中,从机器人到小灯泡。每个设备代表 javascript 引擎的不同的宿主环境类型。
在所有环境中,不变的是拥有共同的称为 事件循环 的内置机制,控制处理多个程序块的执行时机。
这意味着 JS 引擎只是在执行环境中按需调用任意代码。它(执行JS代码的)是一个事件调度的环境。
那,举个例子,当你的 JS 程序发起一个 Ajax 请求去从服务器中获取一些数据,你在一个函数里设置了“响应”的代码(称为“回调函数”),JS 引擎告诉宿主环境:“嘿,我现在会暂停执行,但当网络请求完成时,并返回一些数据,请回来调用这个函数。”
浏览器会监听网络响应,并在拿到数据时返回给你,它会通过插入到 事件循环 中来调度回调函数。
让我们看看下面的图表:
你可以在以前的文章阅读到更多关于堆内存和调用栈的讨论。
这些 web APIs 是什么?本质上来讲,它们是无法访问的线程,你只能利用它。它们只是浏览器的并发访问的一部分。如果你是 Node.js 开发者,它们是 C++ APIs。
那么,事件循环 到底是什么呢?
事件循环有一个简单的工作————监视调用栈和回调队列。如果调用栈清空了,它会获取队列里的第一个事件放入调用栈,高效地运行。
在事件循环中,像这样的一个迭代被称为 tick。每个事件都只是一个回调函数。
让我们一起“执行”这段代码,然后看看发生了什么:
1、状态是清空的。浏览器控制台是清空的,调用栈也是空的。
2、console.log('Hi')
被添加进调用栈。
3、console.log('Hi')
被执行。
4、 console.log('Hi')
被移出调用栈。
5、setTimeout(function cb1() { ... })
被添加进调用栈。
6、 setTimeout(function cb1() { ... })
被执行。浏览器通过 web api 创建了一个定时器。它将为你处理倒计时。
7、setTimeout(function cb1() { ... })
被执行完毕并移出调用栈。
8、console.log('Bye')
被添加进调用栈。
9、console.log('Bye')
被执行。
10、console.log('Bye')
被移出调用栈。
11、在 至少 5000毫秒之后,定时器完成,并将 cb1
回调推入回调队列。
12、事件循环从回调队列里取出 cb1
并推入调用栈。
13、cb1
被执行,并添加 console.log('cb1')
到调用栈。
14、console.log('cb1')
被执行。
15、console.log('cb1')
被移出调用栈。
16、cb1
被移出调用栈。
快速回顾一下:
有趣的是 ES6 指定了 事件循环如何工作,意味着技术上来讲,事件循环是在 JS 引擎的职责范围内的,它将不再扮演宿主环境的角色。这一变化的一个主要原因是 ES6 Promise 的介绍说:因为后者需要直接访问和细粒度地控制事件队列循环的调度操作。(稍后我们会更详细地讨论它们)
setTimeout(…) 如何工作
值得注意地的, setTimeout(…)
并不会自动将回调函数添加进事件循环队列。它只是设定一个定时器。当定时器到期时,运行环境将回调事件推入事件循环。所以未来 tick
会取出回调并执行它。看一眼这个代码:
它并不意味着 myCallback
将会在1000毫秒的时候立即执行,而是在1000毫秒后,myCallback
被添加进事件循环队列。但是,队列里可能已经有其他事件被更早地添加进来————你的回调必须等待。
有相当多的文章和教程在开始使用异步js时建议使用 setTimeout(callback, 0)
。现在你知道事件循环是怎么回事以及 setTimeout 如何工作:调用第二个参数为 0 的 setTimeout 只是推迟执行回调函数直至调用栈清空。
看一下下面的代码:
尽管定时器设置为 0ms, 但浏览器控制台的结果是:
Hi
Bye
callback
ES6 里的 Jobs 是什么?
在 ES6 介绍中,出现了一个新概念 “Jobs Queue”。它是事件循环之上的一层。当你处理 Promise 异步操作的时候,你几乎经常碰到它(我们也会谈谈它)。
我们先来谈谈这个概念,以便稍后讨论异步 Promise 表现的时候,你能理解那些行为是如何被调度和处理的。
想像一下,Job Queue 是一个附属于事件队列中每一个 tick 结尾的队列。某些异步行为可能会发生在事件循环的某一刻,却不会引起一个全新的事件添加到事件循环队列中。但会添加一个项目(也叫Job)到当前 tick 的 Job Queue。
这意味着你可以添加一个功能,让它稍后执行。并且你可以放心,它刚好会在其他事件之前,在那之后执行。
一个 Job 也能引起其他的 Job 添加到相同的队列后面。理论上,Job “循环”也是有可能产生的(一个不断添加其他 Jobs的Job之类)。这样需要资源的程序就必须移动到事件循环的下一个 tick。概念上看,这就类似于需要长时候运行或死循环(就像 while (true) ..
)。
Job 就像 setTimeout(callback, 0)
的 hack
,但通过引入一个更加明确的概念来实现:稍后执行,但尽可能快。
回调函数
正如你所知道的,回调函数是迄今为止 javascript 程序中最常见的表达和管理异步的方式。的确,回调是 javascript 语言中最基本的异步模式。无数的JS程序,甚至是即使是非常复杂的一个,也不会少了最基础的回调异步模式。
除了回调,其他的不会没有缺点。许多开发者尝试寻找更好的异步模式。但是,如果不能很好地理解引擎下的工作原理,就不可能有效地使用任何抽象概念。
在接下来的章节,我们将探索这些抽象概念,深入地展示为什么更复杂的异步模式(这将在随后的帖子中讨论)是必须的,甚至是推荐的。
嵌套回调
看下面的代码:
我们有三个函数嵌套在一起,每一个就代表异步系列的一步。
这种代码经常被称为“回调地狱(callback hell)”。但“回调地狱”实际上与嵌套、缩进没什么关系。这是一个更深入的问题。
首先,我们等待“点击”事件,然后等待定时器被触发,然后等待 Ajax 请求响应数据,在这一点,它可能会再次重复。
粗略地看,这段代码似乎自然而然地将异步映射为连续的步骤:
然后我们有:
再然后有:
最后我们有:
这样用连续的方式表达异步看起来更自然,不是吗?这种方式一定行得通,对吧?
Promise
看一下这个代码:
这非常简单:将 x
和 y
的值相加然后输出在控制台。但是,如果 x
和 y
的值在用于表达式之前丢失了,或还不确定怎么办呢?让我们想象一下,我们有两个函数 loadx
和 loady
分别从服务端加载 x
和 y
的值。然后,有一个函数 sum
会在 x
和 y
都获取到值的时候相加。
代码看起来可能是这样的(非常丑陋,不是吗):
这里有一些非常值得注意的事情——在上面的代码段中,我们假设 x
和 y
都是未来值,并且我们表达了一个操作函数 sum
并不关心 x
或y
或两者是否都可用。
当然,这种基于回调的方式不尽人意。这只是首先的一小步,为了更好地理解为什么不需要关心 未来值 何时可用这方面。
Promise Value
让我们粗略地看一下,如何使用 Promise 来表达 x + y
:
这个代码段中有两层 Promise.
fetchX()
和 fetchY()
被直接调用,并且他们的返回值被传递到 sum(...)
。潜在的值可能在现在或稍后准备好,但每个 Promise 规定了它们的表现是相同的。我们以独立的方式分析 x
和 y
的值。它们是未来的值,有一段期限。
第二层是sum(...)
创建的 Promise(来源于 Promise.all([ ... ])
)。我们等待着调用 then(...)
.当 sum(...)
操作符完成时,我们相加的未来的值已经准备好,并且可以输出。我们为了等待 x
和y
值,在 sum(...)
中隐藏了逻辑。
注意: 在sum(…)
之中,调用 Promise.all([ … ])
创建一个 Promise (等待 promiseX
和 promiseY
完成)。调用链 .then(...)
创建另一个 Promise,立即返回 values[0] + values[1]
(相加的结果)。sum(...)
后面调用的 then(...)
实际上是第二个Promise 返回的,而不是第一个 Promise.all([ ... ])
返回的。同样,我们没有链接第二个 then(...)
, 它也同样已经创建了另一个 Promise。这一 Promise 链的内容将在本章结尾详细解释。
使用 Promise, then(...)
实际上可以传入两个函数,第一个对应完成状态,第二个是对应失败状态。
如果在获取 x
或 y
的时候发生错误,或者在相加的时候出错,sum(...)
会返回失败,并且传入 then(...)
的第二个错误处理函数会收到 Promise 返回的值。
因为 Promise 封装了依赖时间的状态——等待底层值的完成或失败——从外部看,Promise 本身是依赖时间的,因此 Promise 可以以可预测的方式组合起来,而不用考虑时间和结果。
此外,一旦 Promise 解决了,它将永入停留在那个状态——它这时变成一个 不可变的 值——并且根据需要可以观察多次。
链式调用 Promise 真的非常有用:
调用 delay(2000)
创建一个 Promise 将会在 2000ms 后达到条件,然后从第一个 then(...)
成功回调返回,这会造成第二个 then(...)
会等待 2000ms 的 Promise.
注意: 因为 Promise 从外部看来如果被解决了是无法再改变的,现在把值传到任何一部分都是安全的,它不能被恶意或意外地修改。尤其是关系到多个 Promise 遵守协议。一方不可能影响另一方遵守协议的能力。不变性听起来关系到学术性的话题。但它实际上是 Promise 设计的最基本也是最重要的部分,不应该随便错过。
用还是不用 Promise
关于 Promise 有一个很重要的细节就是要知道某个值是否是 Promise。换句话说,某个值表现得像 Promise 吗?
我们知道,Promise 通过 new Promise(…)
语法构造,然后你认为 p instanceof Promise
足以检查它是否是一个 Promise。但,并不完全是。
主要是因为你可以有通过其他的浏览窗口接收一个 Promise 值(比如:iframe),它有它所属的 Promise, 不同于当前窗口的,并且检测是否是 Promise 实例会返回失败。
此外,一个类库或框架可能会选择自己使用的 Promise 实现版本,而不是使用 ES6 原生实现的 Promise。通过类库使用 Promise 可能在不支持 Promise 的浏览器上工作得很好。
被吞掉的异常
如果任何时候创建一个 Promise ,或正在监听它决策的时候,发生了意外错误,比如 TypeError
或者 ReferenceError
, 异常将会被捕获,并且这个 Promise 将会被强制变成失败状态。
举个例子:
但如果一个 Promise 还没有变成成功状态,却在观察期间发生了异常会怎样呢(在 then(...)
注册了回调)?尽管这个异常可以捕获,但它的处理方式可能让你有点吃惊。让我们挖得更深一点。
这看起来发生在 foo.bar()
的异常确实被吞掉了。虽然事实并没有。有一些更深的东西发生了错误,但是,我们并没有监听到。p.then(…)
返回另一个 Promise,而且这个 Promise 会因为 TypeError
变成失败状态。
处理未捕获的异常
还有其他一些许多人认为 更好 的解决办法。
一个普遍的建议就是 Promise 应该添加一个 done(…)
,意味着 Promise 链式调用已“完成”。done(…)
不会再创建并返回一个 Promise,所以传递回调函数到 done(..)
显然不能与不存在的 Promise 连接上报异常。
你可能期望发现任何未捕获的错误的情况:任何 done(..)
中的异常都会抛出全局的未捕获的错误(基本都会出现在控制台):
ES8 有什么变化?Async/await
javascript ES8 推出了 async/await
让 Promise 的工作更容易更简单。我们简要地看看 async/await
提供了什么并利用它们写一些异步代码。
那么,让我们来看看 async/await
是如何工作的。
你可以使用 async
声明定义一个异步函数。这样的函数返回一个异步函数对象。AsyncFunction
对象代表异步函数执行代码,并包含在那个宿主函数中。
当异步函数被调用时,它返回一个 Promise
。当异步函数返回一个值时,那就不是一个 Promise
。Promise
会被自动创建,并且会通过返回值来完成状态。当 async
抛出异常,Promise
将抛出值并变成失败状态。
一个 async
函数可以包含一个 await
表达式,它会暂停执行函数内的代码并等待 Promise 返回结果,然后继续执行异步函数并返回结果。
你可以认为 Promise
与 Java中的Future
或者 C#
的任务。
使用
async/await
的目的是为了简化使用 Promise 的行为。
看下面的代码:
同样的,异步函数内抛出异常相当于普通函数抛出一个被拒绝的 Promise:
await
语句只有用在 async
函数内,并允许你在 Promise 进行同步等待。如果我们要在 async
函数外使用 Promise, 我们仍要使用 then
来执行回调:
你也可以使用“异步函数表达式”来定义异步函数。一个异步函数表达式和一个异步函数声明很类似,几乎有相同的语法。他们之前主要的不同就是函数名。异步函数表达式可以省略函数名,创建一个匿名函数。异步函数表达式可以被当成 IIFE(自执行函数)来使用,一经定义就执行。
看下这个:
更重要的地,async/await
在主流浏览器中的支持情况:
最后,最重要的是不要盲目地选择“最新”的写法去实现异步函数。本质是要理解异步 javascript 的内部原理。了解它为什么如此关键,并深入理解你的选择的内部原理。每种方法都有优缺点,就像编程中的其他部分一样。
编写高可维护性,健壮的代码的5个提示
1、 简洁的代码
使用 async/await 允许你写很少的代码。每次你使用 async/await 跳过一些不必须要步骤:写 .then
时,创建一个匿名函数去处理回应,给返回值命名。例如:
与:
2、错误处理
Async/await 让处理同步和异步错误使用相同的方法——广为人知的 try/catch
。
与:
3、条件语句
使用 async/await
写条件句更简单:
与:
4、堆栈帧
与 async/await
不同,Promise 抛出的错误栈并没有包含错误发生在哪里。
与:
5、调试
如果你使用过 Promise, 你一定知道调试它们是一个噩梦。例如,如果你在一个 .then
打了个断点,并且使用调试快捷键操作,像“stop-over”,调试器并不会移动到下一个 .then
,因为它只是同步代码的“一步”。使用 async/await
你可以精确地定位到每一步 await
,就好像他们是正常的同步函数一样。
书写异步函数不仅对应用程序很重要,对类库也同样如此。
举个例子: Sessionstak 类记录了网页上发生的所有事情。所有 DOM 操作,用户交互,javascript 异常,堆栈跟踪,失败的网络请求,以及调试信息。
这一切都发生在生产环境下却不影响用户体验。我们必须高度优化我们的代码,让它们尽可能地异步,这样就可以增加事件循环处理事件的数量。
也不止是类库,当你在 SessionStack 重播用户的会话时,我们必须在发生问题时,将用户浏览器的状态重现出来,我们必须重建整个状态,允许你在时间轴上向后或向前跳跃。为了让这成为可能,我们大量使用了异步javascript提供的机会。
这里有一个免费的计划让你来体验一下。
参考: