事件循环(Event Loop)和异步编程(Async)一直是热门话题,本文将针对这两个概念做详细的讲解。
为什么单线程是一个限制?
可以想象一下在浏览器中运行的复杂图像转换算法,渲染一个超级大图,虽然调用堆栈还有要执行的其他功能,但浏览器正在运行,不能做其他事情,所以被阻塞了。这意味着浏览器无法渲染和运行任何其他代码,只是卡住了。给用户带来了非常糟糕的体验。
在某些情况下,这可能不是一个关键问题。但是,这会带来更大的问题。一旦浏览器开始处理调用很多堆栈中的任务,它可能会在很长一段时间内停止响应。到那时,许多浏览器会通过引发错误来采取行动,询问他们是否应该终止页面:这很就尴尬了😓,并且完全破坏了用户体验。
JavaScript 程序的构建块
我们尽管在单个 .js 文件中编写 JavaScript 应用程序,但程序肯定由几个块组成,其中只有一个现在要执行,其余的在以后执行。最常见的块单元是函数。
例如下面的demo,大多数刚接触 JavaScript 的开发人员似乎都遇到的问题,标准 Ajax 请求不会同步完成,代码执行时 ajax(..) 函数还没有任何值可以返回赋值给变量response。
// ajax(..) is some arbitrary Ajax function given by a library
var response = ajax('https://example.com/api');
console.log(response);
// `response` won't have the response
所以需要在第二个参数添加回调函数,用于接收返回结果。
ajax('https://example.com/api', function(response) {
console.log(response); // `response` is now available
});
当然也可以发出同步 Ajax 请求,就像下面这段代码,但是最好不要这样做,如果发出一个同步的 Ajax 请求,JavaScript 应用程序的 UI 将被阻止——用户将无法单击、输入数据、导航或滚动。这将阻止任何用户交互。这是一种可怕的做法。
ajax({
url: 'https://api.example.com/endpoint',
success: function(response) {
// This is your callback.
},
async: false // 这样设置同步,并不建议
});
除了以上说的Ajax,想让代码块异步执行,这可以通过 setTimeout(callback, milliseconds) 函数来完成, setTimeout 函数的作用是设置一个事件(超时)稍后发生。看看下面的例子:
function first() {
console.log('first');
}
function second() {
console.log('second');
}
function third() {
console.log('third');
}
first();
setTimeout(second, 1000); // 延迟1000ms执行second
third();
输出结果:
first
third
second
什么是事件循环?
尽管允许异步 JavaScript 代码(如我们刚刚讨论的 setTimeout),但在 ES6 之前,JavaScript 本身实际上从未内置任何直接的异步概念。 JavaScript 引擎所做的只是在任何给定时刻执行程序的一个块。
有关 JavaScript 引擎如何工作的更多详细信息(特别是 Google 的 V8),可以参考文章https://blog.sessionstack.com/how-javascript-works-inside-the-v8-engine-5-tips-on-how-to-write-optimized-code-ac089e62b12e
那么问题来了,谁来告诉 JS 引擎执行你的程序块呢?实际上,JS 引擎并不是孤立运行的——它在托管环境中运行,对于大多数开发人员来说,典型的有 Web 浏览器或 Node.js。实际上,如今,JavaScript 被嵌入到各种设备中,从机器人到灯泡。每个设备都代表 JS 引擎的不同类型的托管环境。
所有环境中的共同点是一个称为事件循环的内置机制,它随着时间的推移处理程序的多个块的执行,每次调用 JS 引擎
这意味着 JS 引擎只是任意 JS 代码的按需执行环境。调度事件(JS 代码执行)的是周围的环境。
例如,当您的 JavaScript 程序发出 Ajax 请求以从服务器获取一些数据时,您在函数中设置“response代码(callback),JS 引擎会告诉托管环境:
JS 引擎:“嘿,我现在要暂停执行,但是当你完成了那个网络请求,并且你有一些数据时,请回调这个函数。”
托管环境:“收到,等我成功完成这个请求,我就调用你给我的函数”
然后浏览器被设置为监听来自网络的响应,当它有东西要返回时,它会通过将回调函数插入事件循环中来安排回调函数的执行。
有关内存堆和调用堆栈的更多信息,可以参考文章:https://blog.sessionstack.com/how-does-javascript-actually-work-part-1-b0bacc073cf
这些 Web API 是什么?从本质上讲,它们是您无法访问的线程,您只需调用它们即可。它们是并发启动的浏览器部分。如果您是 Node.js 开发人员,这些就是 C++ API。
那么事件循环到底是什么?
事件循环有一项简单的工作——监控调用堆栈和回调队列。如果调用堆栈为空,事件循环将从队列中取出第一个事件并将其推送到调用堆栈,调用堆栈有效地运行它
这样的迭代在事件循环中称为一个tick。请记住这个tick,后面会使用这个概念。每个事件只是一个函数回调。
console.log('Hi');
setTimeout(function cb1() {
console.log('cb1');
}, 5000);
console.log('Bye');
让我们来看看执行上面这段代码会发生什么:
1、浏览器控制台没有打印,调用堆栈为空
2、
console.log('Hi')
添加到调用堆栈3、
console.log('Hi')
被执行4、
console.log('Hi')
移除调用堆栈5、
setTimeout(function cb1() { ... })
添加到调用堆栈6、
setTimeout(function cb1() { ... })
被执行,浏览器创建一个计时器timer
添加到Web APIs ,它将用于处理倒计时7、
setTimeout(function cb1() { ... })
完成并移除调用堆栈8、console.log('Bye')
添加到调用堆栈
9、console.log('Bye')
被执行
10、console.log('Bye')
移除调用堆栈
11、在5000ms以后,timer
完成,将timer
的cb1
回调函数存入回调队列
12、事件循环从回调队列中取出 cb1
并将其推送到调用堆栈
13、cb1
执行并将console.log('cb1')
添加到调用堆栈
14、console.log('cb1')
被执行
15、console.log('cb1')
移除调用堆栈
16、cb1
移除调用堆栈
快速回顾:
https://miro.medium.com/max/1400/1*TozSrkk92l8ho6d8JxqF_w.gif
有趣的是,ES6 指定了事件循环应该如何工作,这意味着从技术上讲它在 JS 引擎的职责范围内,它不再仅仅扮演托管环境的角色。这种变化的一个主要原因是在 ES6 中引入了 Promises,因为后者需要访问对事件循环队列上的调度操作的直接、细粒度的控制(稍后我们将更详细地讨论它们)
setTimeout(…) 如何工作?
重要的是要注意 setTimeout(...) 不会自动将回调放入事件循环队列。它设置了一个计时器。当计时器到期时,环境会将您的回调放入事件循环中,以便将来的某个tick
将拾取并执行它。看看这段代码:
setTimeout(myCallback, 1000);
这并不意味着 myCallback 将在 1,000 毫秒内执行,而是在 1,000 毫秒内,myCallback 将被添加到事件循环队列中。然而,队列中可能有之前添加的其他事件——回调将不得不等待执行。
事件循环的作用以及 setTimeout 的工作原理现在了解了吧!即便是使用 0 作为第二个参数调用 setTimeout 只是将回调延迟到调用堆栈清除为止。
看看下面的代码:
console.log('Hi');
setTimeout(function() {
console.log('callback');
}, 0);
console.log('Bye');
虽然等待时间设置为 0 毫秒,但浏览器控制台中的结果将如下所示:
Hi
Bye
callback
事件循环在 ES6中 如何工作?
ES6 中引入了一个称为“作业队列(Job Queue)”的新概念。它是存在于事件循环队列之上的一层,比如处理 Promise 的异步的时候,后面我们会讨论
我们现在只涉及这个概念,以便稍后在讨论 Promises 的异步行为时,您会了解这些操作是如何被调度和处理的。
想象一下:Job Queue 是一个附加到 Event Loop 队列中每个 tick 末尾的队列。在事件循环的一个 tick期间可能发生的某些异步操作不会导致将一个全新的事件添加到事件循环队列中,而是会在当前 tick的Job Queue的末尾添加一个项目(也称为Job)。
这意味着您可以添加另一个稍后执行的功能,并且它会在其他任何事情之前立即执行。
一个Job还可以导致更多Job被添加到同一队列的末尾。理论上,Job“循环”(不断添加其他Job)可能会无限期地旋转,从而使程序缺乏移动到下一个事件循环tick所需的必要资源。从概念上讲,这类似于在代码中添加一个长时间运行或无限循环的逻辑(如 while (true) ..)。
Jobs有点像 setTimeout(callback, 0) “hack”,但以这样一种方式实现,它们引入了更明确和有保证的排序:稍后,但尽快。
回调
如您所知,回调是迄今为止在 JavaScript 程序中表达和管理异步性的最常用方法。事实上,回调是 JavaScript 语言中最基本的异步模式。无数的 JS 程序,甚至是非常复杂和复杂的程序,都是在除了回调之外没有其他异步基础之上编写的。
回调也有缺点。所以许多开发人员正在尝试寻找更好的异步模式。但是,如果您不了解引擎盖下的实际内容,就不可能有效地使用任何抽象。
在下一章中,我们将深入探讨为什么需要且推荐更复杂的异步模式(将在后续文章中讨论)。
嵌套回调
看下面的代码:
listen('click', function (e){
setTimeout(function(){
ajax('https://api.example.com/endpoint', function (text){
if (text == "hello") {
doSomething();
}
else if (text == "world") {
doSomethingElse();
}
});
}, 500);
});
有一个嵌套在一起的三个函数链,每个函数代表异步序列中的一个步骤。
这种代码通常被称为“回调地狱”。但“回调地狱”实际上与嵌套/缩进几乎无关。这是一个比这更深层次的问题。
首先,我们等待“click”事件,然后等待计时器触发,然后等待 Ajax 响应返回,此时可能会再次重复。
乍一看,这段代码似乎将其异步自然地映射到顺序步骤,例如:
listen('click', function (e) {
// ..
});
然后
setTimeout(function(){
// ..
}, 500);
接着
ajax('https://api.example.com/endpoint', function (text){
// ..
});
最后
if (text == "hello") {
doSomething();
}
else if (text == "world") {
doSomethingElse();
}
所以,这种表达异步代码的顺序方式似乎更自然,不是吗?
Promises
看下面的代码:
var x = 1;
var y = 2;
console.log(x + y);
非常简单:它将 x 和 y 的值相加并将其打印到控制台。但是,如果 x 或 y 的值丢失并且仍有待确定怎么办?比如说,我们需要从服务器检索 x 和 y 的值,然后才能在表达式中使用它们。假设我们有一个函数 loadX 和 loadY 分别从服务器加载 x 和 y 的值。然后,假设我们有一个 sum 函数,一旦加载了 x 和 y 的值,它们就会相加。
它可能看起来像这样(很丑,不是吗):
function sum(getX, getY, callback) {
var x, y;
getX(function(result) {
x = result;
if (y !== undefined) {
callback(x + y);
}
});
getY(function(result) {
y = result;
if (x !== undefined) {
callback(x + y);
}
});
}
// A sync or async function that retrieves the value of `x`
function fetchX() {
// ..
}
// A sync or async function that retrieves the value of `y`
function fetchY() {
// ..
}
sum(fetchX, fetchY, function(result) {
console.log(result);
});
这里有一些非常重要的东西——在那个片段中,我们将 x 和 y 视为未来值,并且我们表达了一个操作 sum(...) (从外部)不关心 x 或 y 或两者是否马上可用。
当然,这种粗略的基于回调的方法还有很多不足之处。这只是了解推理未来价值的好处的第一步,而不必担心它们何时可用的时间方面。
Promise Value
让我们简单地看一下如何用 Promises 表达 x + y 示例:
function sum(xPromise, yPromise) {
// `Promise.all([ .. ])`同时发多个请求,返回一个新的Promise
return Promise.all([xPromise, yPromise])
//当这个promise得到结束时,让接收到 `X` 和 `Y` 值相加
.then(function(values){
// `values` 是前面两个请求返回的数组
return values[0] + values[1];
} );
}
sum(fetchX(), fetchY())
//等两个值都返回以后执行接下来的操作
.then(function(sum){
console.log(sum);
});
这个片段中有两层 Promises。
第一层是fetchX() 和 fetchY() 被直接调用,第二层sum函数返回的值promises,这些promises可能是在现在或者未来被使用。
第二层是 sum(...) 创建的promise(通过 Promise.all([ ... ])) 并返回,通过调用 then(...) 来等待。当 sum(...) 操作完成时, sum 未来值就准备好了,将其打印出来。在 sum(...) 中隐藏了等待 x 和 y 未来值的逻辑。
注意:在 sum(…) 中,Promise.all([ … ]) 调用创建了一个 Promise(它正在等待 PromiseX 和 PromiseY 解决)。对 .then(...) 的链式调用创建了另一个Promise,返回values[0] + values[1] 立即解析。因此,我们在 sum(...) 调用的末尾使用 then(...) 调用,实际上是在返回的第二个 promise 上运行,而不是第一个 Promise.all([ ... ])。此外,虽然我们没有执行 then(...),但如果我们选择使用它,它也创造了另一个Promise。这个 Promise 链的东西将在本章后面更详细地解释。
是否使用Promise?
关于 Promise 的一个重要细节是确定某个值是否是真正的 Promise。换句话说,它是一个表现得像 Promise 的值吗?
我们知道 Promise 是由 new Promise(...) 语法构造的,你可能认为 p instanceof Promise 就足够了。但不完全是。主要是因为可以从另一个浏览器窗口(例如 iframe)接收 Promise 值,该值将具有自己的 Promise,与当前窗口或框架中的不同,并且该检查将无法识别 Promise 实例。
此外,库或框架可以选择出售自己的 Promise,而不是使用本机 ES6 Promise 实现来这样做。事实上,您很可能在完全没有 Promise 的旧浏览器中将 Promises 与库一起使用。
捕获异常
如果在创建 Promise 或观察其解决方案的任何时候,发生 JavaScript 异常错误,例如 TypeError 或 ReferenceError,则该异常将被捕获,并且将强制相关 Promise 被拒绝。