[译] JavaScript的工作原理:事件循环和异步编程的兴起 + 5 种 async/await 更好的编码方式

2,580 阅读20分钟

JavaScript的工作原理:事件循环和异步编程的兴起 + 5 种 async/await 更好的编码方式

欢迎阅读该系列文章的第4部分,该文章专门探讨JavaScript及其构建组件。 在识别和描述核心元素的过程中,我们还共享一些在构建 SessionStack 时使用的经验法则,SessionStack 是一个 JavaScript 应用程序,必须强大且高性能确保竞争力。

您是否错过了前三章? 您可以在这里找到它们:

这次,我们来扩展我们第一章,通过回顾单线程环境中编程的缺点以及如何克服这些缺点来构建出色的 JavaScript UIs 。 照旧,在本文的最后,我们将分享5条技巧,介绍如何使用 async / await 编写更简洁的代码。

为什么单线程会是一个限制?

第一章中,我们思考了以下问题:当在 Call Stack 中进行 function 的 调用需要大量时间来处理时,会发生什么情况。

例如,假设浏览器中正在运行一种复杂的图像转换算法。

当 Call Stack 中有 function 执行 ,此时浏览器无法执行其他任何操 作- 已被阻塞。这意味着浏览器无法渲染,无法运行任何其他代码,卡住了。 这是,你的 App UI 不再高效,页面交互不再流畅。

你的 app 卡住了

在某些情况下,这可能不是一个致命问题。 但是在某些情况下这会是一个更大的问题。 一旦浏览器开始处理 Call Stack 中的太多任务,它可能会长时间停止响应。 那时,许多浏览器会通过引发错误来询问是否应该终止该页面:

这很丑陋,并且完全破坏了用户体验:

JavaScript程序的组成部分

您可能会在单个.js文件中编写 JavaScript 应用程序,但是您的程序几乎可以肯定是由几个块组成的,其中只有一个要立即执行,其余的要稍后执行。 最常见的块单位是 function。

大多数刚接触 JavaScript 的开发人员似乎的问题在于什么是 "以后并不一定要立即发生" 。 换句话说,根据定义,现在无法完成的任务将异步完成,这意味着您将不会像在潜意识中期望或希望的那样具有上述阻止行为。

让我们看一下以下示例:

// 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请求 不会同步完成,这意味着在代码执行时,ajax(...) 函数还没有任何值可以返回以分配给响应变量。 一种 等待 异步函数返回其结果的简单方法是使用一个称为回调的函数:

ajax('https://example.com/api', function(response) {
    console.log(response); // `response` is now available
});

note:您实际上可以发出同步 Ajax 请求。 永远不要那样做。 如果您发出同步 Ajax 请求,则您的 JavaScript 应用的用户界面将被阻止。

用户将无法单击,输入数据,导航或滚动。 这将防止任何用户交互。 这是一个可怕的操作。

看起来是这样,但是请不要这样做 – 不要破坏网页体验:

// This is assuming that you're using jQuery
jQuery.ajax({
    url: 'https://api.example.com/endpoint',
    success: function(response) {
        // This is your callback.
    },
    async: false // And this is a terrible idea
});

我们仅以 Ajax 请求为例。 您可以让任何代码块异步执行。

可以使用 setTimeout(callback,msiseconds)函数完成此操作。 setTimeout 函数的作用是设置一个事件(超时)以在以后发生。 让我们来看看:

function first() {
    console.log('first');
}
function second() {
    console.log('second');
}
function third() {
    console.log('third');
}
first();
setTimeout(second, 1000); // Invoke `second` after 1000ms
third();

控制台会输出如下:

first
third
second

剖析事件循环

尽管允许异步JavaScript代码(例如我们刚刚讨论的setTimeout),但我们还是从一个奇怪的说法开始,直到ES6为止,JavaScript 本身实际上从未内置任何直接的异步概念。

除了在任何给定的时刻执行程序的单个块之外,JavaScript引擎从未做过其他任何事情。

要详细了解JavaScript引擎的工作方式(特别是 Google 的 V8 ),请查看我们之前关于该主题的文章之一

那么,谁告诉JS引擎去执行你的程序块呢? 实际上,JS引擎不是孤立运行的,而是在托管环境中运行的,对于大多数开发人员来说,托管环境是典型的Web浏览器或Node.js。 实际上,如今,JavaScript已嵌入从机器人到灯泡的各种设备中。 每个单个设备代表JS引擎的不同类型的托管环境。

所有环境中的共同点是一种内置机制,称为 event loop , 该机制每次调用 JS Engine 时都会处理程序中多个块的执行。

这意味着对于任意 JS 代码 来说,JS Engine 只是一个按需执行的环境。 安排 event(JS代码执行)的执行时间是处决于周围的环境。

因此,例如,当您的JavaScript程序发出 Ajax 请求以从服务器获取某些数据时,您在函数中设置了“响应”代码(“回调”),然后JS Engin e告知托管环境: “嘿,我现在暂时暂停执行,但是只要您完成该网络请求,并且有一些数据,请回叫此函数。”

然后将浏览器设置为侦听来自网络的响应,并在有需要返回的内容时,将通过将其插入 event loop 来安排要执行的回调函数。

如图:

您可以在上一篇文章中了解有关内存堆 和 call stack 的更多信息。

这些 Web API 是什么? 本质上,它们是您无法访问的线程,您可以对其进行调用。 它们是并发性所在的浏览器组件。如果您是Node.js开发人员,则这些都是 C ++ API。

那么,什么是 event loop 呢?

事件循环有一个简单的工作:监视 call stack 和回调队列。 如果 call stack 为空,它将从队列中获取第一个事件,并将其推入 call stack,后者将有效地运行它。

在 Event loop 中这样一个迭代的动作叫做一个 tick ,每个 event 只是一个回调函数。

console.log('Hi');
setTimeout(function cb1() { 
    console.log('cb1');
}, 5000);
console.log('Bye');

让我们执行以下上面的代码,看看发生了什么:

  1. 浏览器状态已经清除,控制台清空,call stack 为空

  1. console.log('Hi') 被添加到了 call stack 中

3. console.log('Hi') 被执行

  1. console.log('Hi') 从 call stack 移除

5. setTimeout(function cb1() { ... }) 被添加到 Call Stack.

6.setTimeout(function cb1() { ... })被执行,浏览器创建了一个定时器作为 web apis 的一部分,它会处理倒计时。

7. setTimeout(function cb1() { ... }) 执行后,从 call stack 中移除。

  1. console.log('Bye') 被添加到 Call Stack。
  2. console.log('Bye') 被执行。
  3. console.log('Bye') 从 Call Stack 中移除。

  1. 经过 5000ms 后,计时器完成,并将回调 callback push 到回调的队列中。

  1. 事件循环将 cb1 从回调队列中推入到 call stack。

  1. cb1 被执行,并且将 console.log('cb1') 推入 call stack。

  1. console.log('cb1') 被执行。

  1. console.log('cb1') 从调用堆栈移除。
  2. cb1 从调用堆栈移除。

我们来快速回顾一下:

有趣的是,ES6指定了事件循环的工作方式,这意味着从技术上讲,它在JS引擎职责范围之内,不再仅充当托管环境角色。

进行此更改的一个主要原因是在ES6中引入了Promises,因为后者需要访问对事件循环队列上的调度操作进行直接,细粒度的控制(我们将在后面详细讨论)。

setTimeout(...) 怎么工作的

请务必注意,setTimeout(...) 不会自动将回调置于事件循环队列中。 它设置一个计时器。 当计时器到期时,环境会将您的回调放入事件循环中,以便将来的 tick 将其拾取并执行,请看下面的代码:

setTimeout(myCallback, 1000);

这并不意味着 myCallback 将在1000毫秒时刚好执行,而是在1000毫秒内将 myCallback 添加到队列中。 但是,队列中可能还包含其他较早添加的事件 - 您的回调将不得不等待。

有很多关于 JavaScript async 入门的文章和教程,建议做 setTimeout(callback,0)。 好了,现在知道了事件循环的功能以及 setTimeout 的工作方式:以0作为第二个参数调用 setTimeout 只会推迟回调,直到清除 call stack。

看一下下面的代码:

console.log('Hi');
setTimeout(function() {
    console.log('callback');
}, 0);
console.log('Bye');

尽管等待时间设置为 0 ms,但在浏览器控制台中的结果将是以下内容:

Hi
Bye
callback

在 ES6 中 Jobs 是什么?

ES6 中引入了一个称为 "Jobs Queue" 的新概念。 它是事件循环队列顶部的一层。 在处理Promises的异步行为时,您最有可能碰到它(我们也将讨论它们)。

我们暂时在用 Promises 讨论异步行为时,只讨论概念,稍后,你会明白这些动作是怎么被执行和处理的。

想象一下:"Jobs Queue" 是一个附加到 Event Loop 中每个刻度末尾的队列。 在事件循环的一个 tick 中可能发生的某些异步操作不会导致将一个新事件添加到事件循环队列中,而是将一个 job 添加到当前 tick 的Job队列的末尾。

这意味着您可以添加其他功能以便以后执行,并且可以放心,它会在之后立即执行。

job 还可以导致将更多 jobs 添加到同一队列的末尾。 从理论上讲,job "循环"(一个job 一直在添加其他的 jbs )可能无限期旋转,从而使程序缺少进入下一事件循环刻度的必要资源。 从概念上讲,这类似于在代码中表达长时间运行或无限循环(例如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响应返回,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的值。 然后,假设我们有一个函数求和,将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([ .. ])` takes an array of promises,
	// and returns a new promise that waits on them
	// all to finish
	return Promise.all([xPromise, yPromise])

	// when that promise is resolved, let's take the
	// received `X` and `Y` values and add them together.
	.then(function(values){
		// `values` is an array of the messages from the
		// previously resolved promises
		return values[0] + values[1];
	} );
}

// `fetchX()` and `fetchY()` return promises for
// their respective values, which may be ready
// *now* or *later*.
sum(fetchX(), fetchY())

// we get a promise back for the sum of those
// two numbers.
// now we chain-call `then(...)` to wait for the
// resolution of that returned promise.
.then(function(sum){
    console.log(sum);
});

此段代码包含了两个 Promise。

直接调用 fetchX()fetchY() ,并将它们返回的 Promise 传递给 sum(...)。 这些 Promise 表示返回可能是马上或者是以后,但是每个承诺都将其行为规范化为相同。 我们以与时间无关的方式推理 x 和 y 值。 它们是以后的值。

第二块代码是使用 .then 处理 sum(...) 返回的 promise(通过Promise.all([ ... ]) 中的 return 返回),当 sum(...) 的操作完成,我们的值已经被计算完毕,并打印出来,我们隐藏了等待 x 和 y 的返回的细节

注意:

sum(…) 内,Promise.all([…]) 调用创建一个 promise(正在等待 promiseXpromiseY 解析)。 链接到 .then(...) 的调用会创建另一个 promise,即返回 values [0] + values [1] 执行的结果。

因此,我们将 sum(...).then(...) 是执行的 values [0] + values [1] 所产生的 promise

使用Promises时,then(...) 调用实际上可以具有两个 回调函数,第一个用于实现(如前所示),第二个用于拒绝:

sum(fetchX(), fetchY())
.then(
    // fullfillment handler
    function(sum) {
        console.log( sum );
    },
    // rejection handler
    function(err) {
    	console.error( err ); // bummer!
    }
);

如果在获取 x 或 y 时出现问题,或者在加法过程中因某种原因失败,则 sum(...) 返回的 promise 将被 reject,传递给 then(...) 的第二个回调错误处理的回调。

因为 Promise 从外部封装了时间相关的状态(等待 value 的 resolve 或者 reject),所以 Promise 本身是时间独立的,因此 Promise 可以以可预测的方式组合。

而且,一旦 Promise 被 resolve ,它就会永远保持这种状态 - 它会成为一个不变的值

对 promise 的链式调用真的很有用:

function delay(time) {
    return new Promise(function(resolve, reject){
        setTimeout(resolve, time);
    });
}

delay(1000)
.then(function(){
    console.log("after 1000ms");
    return delay(2000);
})
.then(function(){
    console.log("after another 2000ms");
})
.then(function(){
    console.log("step 4 (next Job)");
    return delay(5000);
})
// ...

.then 的执行必须要等到前一个 promise 的状态变成 resolve/onFulfilled ,所以 after another 2000ms ,在等待了 2000ms 之后被执行。

是否使用 Promise ?

关于 Promise 有一个很重要的细节是,需要了解清楚这个值是否需要被 promise,简单地说就是,这个获取这个值得行为是否像一个 promise。

我们知道 Promise 的创建是由 new Promise(…) 所得,那么,你可能会认为 p instanceof Promise 能够检查一个 promise 实例,但是并不是这样的 。

主要原因是因为你可以从另外一个环境(比如 iframe 拥有独立的 Promise)接受到 Promise,该窗口具有自己的Promise,与当前窗口或框架中的Promise不同,并且该检查将无法识别Promise实例。

此外,库或框架可能选择使用其自己的 Promises,而不使用 ES6 Promise 实现。 实际上,您很可能将 Promises 与完全没有 Promise 的旧版浏览器中的库一起使用。

异常被吞噬 ?

如果在创建 Promise 或在执行 promise 的任何时候发生 JavaScript 异常错误(例如 TypeError 或 ReferenceError ),则会捕获该异常,这将迫使 Promise 被 rejected。

举例说明:

var p = new Promise(function(resolve, reject){
    foo.bar();	  // `foo` is not defined, so error!
    resolve(374); // never gets here :(
});

p.then(
    function fulfilled(){
        // never gets here :(
    },
    function rejected(err){
        // `err` will be a `TypeError` exception object
	// from the `foo.bar()` line.
    }
);

如果某个 promise 已经被 resolved ,但在 resolved 的回调中抛出了异常, 在当前 .then() 的二个回调中不会捕获到错误。


var p = new Promise( function(resolve,reject){
	resolve(374);
});

p.then(function fulfilled(message){
    foo.bar();
    console.log(message);   // never reached
},
    function rejected(err){
        // never reached
    }
);

从上面看出好像异常被吞噬了,但是实际上这是一个更深层级的错误,promise 抛出了另外一个 rejected 的 promise ,需要在第二个 .then() 捕获。

处理未捕获的异常

其实还有更多更好的方法

一个常见的做法是 Promises 末尾添加 done(...),这实际上将 Promise 链标记为“完成”。done(...) 不会创建并返回 Promise ,因此将回调传递给 done(..) ,不会再抛出多余的 promise 了 ( promise:undefined)。

done(...) 会全局处理未捕获到的错误:

var p = Promise.resolve(374);

p.then(function fulfilled(msg){
    // numbers don't have string functions,
    // so will throw an error
    console.log(msg.toLowerCase());
})
.done(null, function() {
    // If an exception is caused here, it will be thrown globally 
});

在 ES8 中发生了什么 ? Async/await

JavaScript ES8 引进了 Async/await 让 promise 变得更加容易,我们将简要介绍一下 Async/await 提供的可能性以及如何利用它们编写异步代码。

让我们来看一下 Async/await 怎么工作的。

您可以使用异步函数声明来定义 async function。 此类函数返回 AsyncFunction 对象。 AsyncFunction 对象表示执行包含在该函数中的代码的异步函数。

调用 async function ,它将返回Promise。 当异步函数返回的值不是 Promise 时,将自动创建 Promise 并将其与函数返回的值一起解析。 当异步函数抛出异常时,Promise 将被抛出的值 rejected。

异步函数可以包含 await 表达式,该表达式会暂停函数的执行并等待所传递的 Promise 的结果,然后恢复异步函数的执行并返回解析后的值。

您可以将 JavaScript 中的 Promise 视为 Java 的 Future 或 C#的 Task。

Async/await 的目的是简化使用 promise 的行为。

让我们看一下下面的例子:

// Just a standard JavaScript function
function getNumber1() {
    return Promise.resolve('374');
}
// This function does the same as getNumber1
async function getNumber2() {
    return 374;
}

其实两个函数的作用是等价的,对于 promise 被 rejected 的情况是这样:

function f1() {
    return Promise.reject('Some error');
}
async function f2() {
    throw 'Some error';
}

await 关键字只能在 async function 中使用,并允许您在 Promise 上同步等待。 如果我们在异步函数之外使用 Promise ,则仍然必须使用然后回调:

async function loadData() {
    // `rp` is a request-promise function.
    var promise1 = rp('https://api.example.com/endpoint1');
    var promise2 = rp('https://api.example.com/endpoint2');
   
    // Currently, both requests are fired, concurrently and
    // now we'll have to wait for them to finish
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ' ' + response2;
}
// Since, we're not in an `async function` anymore
// we have to use `then`.
loadData().then(() => console.log('Done'));

您也可以使用“ async 函数表达式”定义 async function。 async 函数表达式与async function 语句非常相似,并且语法几乎相同。 他们之间主要区别是函数名称,可以在异步函数表达式中省略该名称以创建匿名函数。 async 函数表达式可用作IIFE(立即调用函数表达式),该函数一经定义便立即运行。

就像这样

var loadData = async function() {
    // `rp` is a request-promise function.
    var promise1 = rp('https://api.example.com/endpoint1');
    var promise2 = rp('https://api.example.com/endpoint2');
   
    // Currently, both requests are fired, concurrently and
    // now we'll have to wait for them to finish
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ' ' + response2;
}

更重要的是 Async/await 已经被所有的主流浏览器支持:

归根结底,重要的是不要盲目地选择“最新”方法来编写异步代码。 必须了解异步 JavaScript 的内部结构,了解为什么它如此重要并深入理解所选方法的内部结构。 每种方法在编程中都有其优点和缺点。

编写高度可维护的,健壮的 async 代码的5条提示

  1. 干净的代码:使用 async / await 可使您编写的代码更少。 每次使用 async / await 时,您都会跳过一些不必要的步骤。比如,需要创建一个匿名函数来处理响应,从该回调中命名响应,例如:
// `rp` is a request-promise function.
rp(‘https://api.example.com/endpoint1').then(function(data) {
 // …
});

与:

// `rp` is a request-promise function.
var response = await rp(‘https://api.example.com/endpoint1');
  1. 错误处理: async / await 使使用相同的代码结构(众所周知的try / catch语句)同时处理同步和异步错误成为可能。 让我们看看Promises怎么处理:
function loadData() {
    try { // Catches synchronous errors.
        getJSON().then(function(response) {
            var parsed = JSON.parse(response);
            console.log(parsed);
        }).catch(function(e) { // Catches asynchronous errors
            console.log(e); 
        });
    } catch(e) {
        console.log(e);
    }
}

与:

async function loadData() {
    try {
        var data = JSON.parse(await getJSON());
        console.log(data);
    } catch(e) {
        console.log(e);
    }
}
  1. 条件选择: 使用 async/await 编写条件表达语句会更加直观:
function loadData() {
  return getJSON()
    .then(function(response) {
      if (response.needsAnotherRequest) {
        return makeAnotherRequest(response)
          .then(function(anotherResponse) {
            console.log(anotherResponse)
            return anotherResponse
          })
      } else {
        console.log(response)
        return response
      }
    })
}

与:

async function loadData() {
  var response = await getJSON();
  if (response.needsAnotherRequest) {
    var anotherResponse = await makeAnotherRequest(response);
    console.log(anotherResponse)
    return anotherResponse
  } else {
    console.log(response);
    return response;    
  }
}
  1. 错误堆栈信息,从 promise 链返回的错误不会提供任何信息:
function loadData() {
  return callAPromise()
    .then(callback1)
    .then(callback2)
    .then(callback3)
    .then(() => {
      throw new Error("boom");
    })
}
loadData()
  .catch(function(e) {
    console.log(err);
// Error: boom at callAPromise.then.then.then.then (index.js:8:13)
});

与:


async function loadData() {
  await callAPromise1()
  await callAPromise2()
  await callAPromise3()
  await callAPromise4()
  await callAPromise5()
  throw new Error("boom");
}
loadData()
  .catch(function(e) {
    console.log(err);
    // output
    // Error: boom at loadData (index.js:7:9)
});
  1. 调试:如果您使用了 Promise,则知道调试它们是一场噩梦。 例如,如果您在 .then 块内设置断点并使用调试快捷方式(如 “ stop-over” ),则调试器将不会移至以下.then,因为它仅 “step” 通过同步代码。 使用 async / await,您可以完全像正常的同步函数一样逐步执行 await 调用。

    编写异步JavaScript代码不仅对应用程序本身很重要,对库也很重要

    例如,SessionStack 库记录您的 Web 应用程序/网站中的所有内容:所有 DOM 更改,用户交互,JavaScript 异常,堆栈跟踪,失败的网络请求和调试消息。

    这一切都必须在您的生产环境中进行,而不会影响任何用户体验。 我们需要大量优化代码,并使其尽可能地异步,以便我们可以增加 event loop 正在处理的事件的数量。

    不仅是 librar! 当您在 SessionStack 中重播用户会话时,我们必须在问题发生时渲染用户浏览器中发生的所有事情,并且我们必须重构整个状态,以允许您在会话时间轴中来回跳转。 为了实现这一点,我们大量利用了 JavaScript async。

    这里有个免费的计划让你 开始 sessionStatck

资料: