JavaScript工做原理(四):事件循环,异步编程的兴起以及5招async/await实践

咱们将经过回顾第一篇文章中单线程编程的缺点,而后在讨论如何克服它们来构建使人惊叹的JavaScript UI。在文章结尾处,咱们将分享5个关于如何使用async / await编写更简洁的代码的技巧。javascript

单线程的局限性

第一篇文章中,咱们分析了若是在Call Stack中调用耗时长的函数,会产生不少问题。java

想象一下,一个复杂图像转换算法在浏览器中运行。jquery

当Call Stack有函数须要执行的时候,浏览器是没法执行其余任何操做的 - 没错它被阻塞了。这意味着浏览器没法渲染页面,也不能运行任何其余代码,它只是卡住了。问题来了 - 您的应用用户界面再也不高效和使人满意。ajax

在某些状况下,这可能不是相当重要的问题。可是,它可能引发一个更大的问题。一旦您的浏览器开始处理Call Stack中的太多任务,它可能会中止响应很长时间。不少浏览器会弹出错误处理窗口,询问他们是否应该终止该页面,这很丑陋,它彻底毁了你的用户体验:
no_response算法

JavaScript程序的构建块

您可能会把全部JavaScript代码写入一个.js文件,可是你的代码几乎确定由几个块组成,其中只有一个将当即执行,其他的将在稍后执行。最多见的块单位是函数。编程

大多数新的JavaScript的开发者彷佛都有这样的理解,即之后不必定会严格地当即发生。换句话说,根据定义,如今没法完成的任务将异步完成,这意味着您不会出现上述阻止行为,由于您可能已经潜意识地预期或指望。segmentfault

咱们来看看下面的例子:api

// ajax(..) 是由其它工具库提供的函数
var response = ajax('https://example.com/api');

console.log(response);
// `response` 不会有结果

您可能知道标准的Ajax请求是不会同步完成的,这意味着在代码执行时,ajax(..)函数尚未任何值返回以分配给response变量。数组

一个简单实现“等待”异步函数返回结果的方法就是callback的函数:promise

ajax('https://example.com/api', function(response) {
    console.log(response); // `response` 有值了
});

请注意:您实际上能够建立同步的Ajax请求。可是永远不要这样作,若是您发出同步Ajax请求,您的JavaScript应用的用户界面将被阻塞 - 用户将没法点击,或输入数据,导航或滚动。这将阻止任何用户交互。没错这是一个可怕的作法。

同步ajax请求代码以下,但请不要这样作:

// 假设你是用jquery库
jQuery.ajax({
    url: 'https://api.example.com/endpoint',
    success: function(response) {
        // 你的回调函数
    },
    async: false // 坏主意
});

Ajax请求只是其中一个例子。你可让任何代码块异步执行。

这个能够经过setTimeout(回调,毫秒)函数来完成。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 Engine的不一样类型的托管环境。

全部环境中的共同点是一种称为事件循环的内置机制,它随着时间的推移处理程序中多个代码块的执行,每次调用JS引擎。

这意味着JS引擎只是JS代码的按需执行环境。它是调度事件(JS代码执行)的周围环境。

例如,当您的JavaScript程序发出Ajax请求,想要从服务器获取一些数据时,您能够在函数中设置“响应”代码(“回调”),而且JS引擎会告诉主机环境:
“嘿,我如今暂停执行,但每当你完成这个网络请求,而且你有一些数据,请执行这个函数。”

而后设置浏览器来侦听来自网络的响应,当它返回给您时,它将经过将回调函数插入到事件循环中来安排执行回调函数。

咱们来看下面的图表:
event_loop_callstack

您能够在本系列第一篇文章中阅读关于内存堆和调用堆栈的更多信息。

这些Web API是什么?从本质上讲,它们是你没法访问的线程,你能够对它们进行调用。它们是浏览器并发功能的一部分。若是您是Node.js开发人员,那么这些是C++ API。

那么到底是什么事件循环呢?
event_loop_callback_queue

事件循环只有一个简单的工做 - 监视Call Stack(调用堆栈)和Callback Queue(回调队列)。若是调用堆栈为空,它将从回调队列中取出第一个事件并将其推送到调用堆栈,该调用堆栈能够有效地运行它。

这种迭代在事件循环中称为tick。每一个事件只是一个函数回调。

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

让咱们“执行”这段代码,看看会发生什么:

  1. 状态很清楚。浏览器控制台已清除,而且调用堆栈为空
    step1
  2. console.log('Hi')被添加到Call Stack
    step2
  3. 执行console.log('Hi')
    step3
  4. 从Call Stack中移除console.log('Hi')
    step4
  5. setTimeout(function cb1() { ... })被添加到Call Stack
    step5
  6. 执行setTimeout(function cb1() { ... }),浏览器将建立一个计时器做为Web API的一部分。 它将为您处理倒计时
    step6
  7. setTimeout(function cb1() { ... })自身执行结束,而后从Call Stack中移除
    step7
  8. console.log('Bye')被添加到Call Stack
    step8
  9. 执行console.log('Bye')
    step9
  10. 从Call Stack中移除执行console.log('Bye')
    step10
  11. 至少5000毫秒后,定时器完成并将cb1回调函数推送到Callback队列中。
    step11
  12. 事件循环从回调队列中获取cb1并将其推送到调用堆栈。
    step12
  13. cb1执行,添加console.log('cb1')到调用堆栈
    step13
  14. console.log('cb1')执行
    step14
  15. console.log('cb1')从调用堆栈中移除
    step15
  16. cb1从调用堆栈中移除
    step16

扼要重述:
recap

有趣的是,ES6指定了事件循环应该如何工做,这意味着它在JS引擎的职责范围内,而再也不只是属于一个托管环境。这种变化的一个主要缘由是在ES6中引入了Promises,由于后者须要对事件循环队列上的调度操做进行直接,细粒度的控制(咱们稍后会更详细地讨论它们)。

setTimeout(…)如何工做

请注意,setTimeout(...)不会自动将您的回调函数放到事件循环队列中。它设置了一个计时器,当计时器到期时,环境将您的回调函数放入事件循环中,以便未来的某个tick事件会将其选中并执行它。查看此代码:

setTimeout(myCallback, 1000);

这并不意味着myCallback将在1000ms以后立刻执行,而是在1000ms以后,myCallback将被添加到队列中。可是队列中可能还有其余事件先前已添加 - 您的回调将不得不等待。

有不少关于开始使用JavaScript中的异步代码的文章和教程,其中提到了setTimeout(callback,0)。 那么,如今你知道Event Loop的做用了,以及setTimeout如何工做:使用0做为第二个参数调用setTimeout只是推迟回调函数执行,直到调用堆栈清空才执行。

看看下面的代码:

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

虽然等待时间设置为0 ms,但浏览器控制台中的结果以下所示:

Hi
Bye
callback

ES6中的Jobs?

ES6中引入了一个名为“Job队列”的新概念。它是Event Loop队列顶部的一个层。在处理Promises的异步行为时,您最有可能接触到它(咱们也将讨论它们)。

如今咱们将简单介绍这个概念,以便在咱们稍后讨论Promise的异步行为时,您将了解如何安排和处理这些操做。

想象一下:Job队列是一个链接到事件循环队列中每一个tick的末尾的队列。在事件循环的tick期间可能发生的某些异步操做不会致使将全新的事件添加到事件循环队列中,而是会将一个项(又名Job)添加到当前tick的Job队列的末尾。

这意味着您能够添加其余功能以便稍后执行,您能够放心,它将在执行任何其余操做以前当即执行。

Job还可使更多做业添加到同一队列的末尾。从理论上讲,做业“循环”(一个不停地添加其余做业等的做业)可能会无限地旋转,从而致使须要进入下一个事件循环节点所需的必要资源的程序不足。从概念上讲,这与在代码中仅表示长时间运行或无限循环(如while(true)..)相似。

做业有点像setTimeout(回调,0)“破解”,但实现的方式是它们引入了一个更加明确和有保证的排序:稍后,但尽快。

回调

如您所知,回调是迄今为止在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);

最后:

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);
        }
    });
}
// 一个同步或异步函数返回x的值
function fetchX() {
    // ..
}


// 一个同步或异步函数返回y的值
function fetchY() {
    // ..
}
sum(fetchX, fetchY, function(result) {
    console.log(result);
});

这里有一些很是重要的东西 - 在这个代码中,咱们将x和y做为将来值,而且咱们表达了一个操做和(...)(从外部)不关心x或y或者二者是否都不可用 立刻。

固然,这种基于简单回调的方法还有不少不足之处。这只是为了解feature values的好处的第一步,而没必要担忧它们什么时候可用。

Promises的值

让咱们简要地看一下咱们如何用Promises来表达x + y示例:

function sum(xPromise, yPromise) {
    // `Promise.all([ .. ])` 接受 promises 数组,
    // 返回一个新的promise,这个promise会等待全部promise数组完成
    return Promise.all([xPromise, yPromise])

    // Promise.all被resolved以后, 咱们将返回的X和Y相加
    .then(function(values){
        // `values` 是以前promises数组中每一个promise解决以后的信息组成的数组
        return values[0] + values[1];
    } );
}

// `fetchX()` and `fetchY()` 返回promise,promise包含各自的值
// 这个值可能可用也可能不可用
sum(fetchX(), fetchY())

// 咱们最终获得一个promise,它返回了两个数字的和
// 调用 `then(...)` 获得最终值
.then(function(sum){
    console.log(sum);
});

在这个片断中有两层Promise。

直接调用fetchX()和fetchY(),并将它们返回的值(promise!)传递给sum(...)。这些承诺所表明的基础价值可能如今已经准备就绪,可是每一个承诺都将其行为规范化为不管如何都是相同的。咱们以时间无关的方式推测x和y值。他们是将来的价值观,期限。

第二层是sum(...)建立的承诺
(经过Promise.all([...]))和返回,咱们经过调用而后等待(...)。总和(...)操做完成后,咱们的总和将来值已准备好,咱们能够将其打印出来。咱们隐藏了等待sum(...)中x和y将来值的逻辑。

注意:Inside sum(...)中,Promise.all([...])调用建立一个承诺(等待promiseX并promiseY解析)。而后(...)的连接调用建立了另外一个承诺,即返回
值[0] +值[1]行当即解决(与加法的结果)。所以,咱们链接sum(...)调用结束时的then(...)调用 - 在片断结尾处 - 其实是在返回的第二个promise上运行,而不是由Promise建立的第一个promise。所有([...])。另外,虽然咱们并无把时间的尾端链接起来(...),可是若是咱们选择观察/使用它,它也创造了另外一个承诺。本章后面将详细解释这个Promise连接的东西。

有了Promises,那么(...)调用实际上能够采用两个函数,第一个用于履行(如前所示),第二个用于拒绝:

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

若是在获取x或y时出现问题,或者在添加期间某种方式失败了,那么sum(...)返回的promise将被拒绝,而且传递给then(...)的第二个回调错误处理程序将收到拒绝 来自诺言的价值。

因为Promises封装了时间依赖状态 - 等待基础价值的实现或拒绝 - 从外部看,Promise自己是时间无关的,所以Promises能够以可预测的方式组合(组合),而无论时间或结果如何 下。

并且,一旦一个承诺解决了,它就会永远保持这种状态 - 它在那个时候成为一个不变的价值 - 而后能够根据须要屡次观察。

确实能够连接承诺是很是有用的:

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);
})
// ...

呼叫延迟(2000)建立了一个将在2000ms完成的承诺,而后咱们从第一个(...)履行回调中返回,这致使第二个(...)的承诺等待2000ms的承诺。

注意:由于Promise一旦解决就是外部不可变的,如今能够安全地将该值传递给任何一方,并知道它不能被意外或恶意修改。 关于观察解决诺言的多方,这一点尤为如此。 一方不可能影响另外一方遵照Promise解决方案的能力。 不变性可能听起来像是一个学术话题,但它其实是Promise设计的最基本和最重要的方面之一,不该该随便传递。

使用仍是不使用Promise

关于Promises的一个重要细节是确切地知道某个值是不是实际的Promises。 换句话说,这是一种会表现得像一个Promise?

咱们知道Promise是由new Promise(...)语法构造的,您可能认为Promise的instanceof将是一个有效的检查。好吧,不是。

主要是由于您能够从另外一个浏览器窗口(例如iframe)接收Promise值,该窗口具备与当前窗口或框架中的承诺不一样的Promise,而且该检查没法识别Promise实例。

此外,库或框架可能会选择出售本身的Promises,而不是使用原生ES6的Promise实施来实现。 事实上,你可能会在早期的浏览器中使用Promises和Promise来实现Promise。

异常

若是在建立Promise或观察其解决方案的任什么时候候发生JavaScript异常错误(例如TypeError或ReferenceError),该异常将被捕获,而且它将强制有问题的Promise被拒绝。

例如:

var p = new Promise(function(resolve, reject){
    foo.bar();      // `foo` 没有被定义, 因此会发出异常或错误
    resolve(374); // 不会运行到这里 :(
});

p.then(
    function fulfilled(){
        // 不会运行到这里 :(
    },
    function rejected(err){
        // `err` 是一个 `TypeError` 异常对象
    // 异常发生在 `foo.bar()` 这一行.
    }
);

可是若是一个Promise被实现时,在observation期间(在一个then(...)注册的回调中)有一个JS异常错误会发生什么? 即便它不会丢失,你可能会发现它们的处理方式有点使人惊讶。直到你深刻一点:

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

p.then(function fulfilled(message){
    foo.bar();
    console.log(message);   // 没有运行到这里
},
    function rejected(err){
        // 没有运行到这里
    }
);

它看起来像来自foo.bar()的异常真的被吞噬了。不过事实上并不是如此。然而,有些更深层的事情发生了错误,但咱们没有监听到。p.then(...)调用自己会返回另外一个promise,这就是那个将被TypeError异常拒绝的promise

处理未捕获的异常

还有其余的方法,不少人会说更好。

一个常见的建议是Promise应该使用done(...),它们基本上将Promise链标记为“已完成”。done(...)不会建立并返回Promise,因此回调函数传递给done(..)显然没有链接到向不存在的链式承诺报告问题。

它的处理方式与您在未捕获的错误状况中一般所期待的同样:done(..)里面的异常或错误,将做为全局未捕获错误引起(基本上在开发人员控制台中):

var p = Promise.resolve(374);

p.then(function fulfilled(msg){
    // 数字是不会有字符串的处理函数
    // 因此会抛出异常
    console.log(msg.toLowerCase());
})
.done(null, function() {
    // 若是异常在这里发生,它会全局抛出
});

ES8(ES2017)async/await

JavaScript ES8(ES2017)引入了async/await,这使得使用Promises的工做更容易。咱们将简要介绍async/await提供的可能性以及如何利用它们来编写异步代码。

那么,让咱们看看async/await如何工做。

您可使用async关键字声明定义一个异步函数。这样的函数返回一个AsyncFunction对象。 AsyncFunction对象表示执行该函数中包含的代码的异步函数。

当一个异步函数被调用时,它返回一个Promise。当异步函数返回一个值时,这不是一个Promise,Promise将会自动建立,而且会使用函数返回的值来解析。当异步函数抛出异常时,Promise将被抛出的值拒绝。

异步函数能够包含await表达式,暂停执行该函数并等待传递的Promise的解析,而后恢复异步函数的执行并返回解析后的值。

您能够将JavaScript中的Promise等同于Java的Future或C#的Task。

async/await的目的是为了简化使用promises。
咱们来看看下面的例子:

// 标准的javascript函数
function getNumber1() {
    return Promise.resolve('374');
}
// 功能和getNumber相同
async function getNumber2() {
    return 374;
}

一样,抛出异常的函数等价于返回已被reject的promise的函数:

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

await关键字只能用于异步功能,并容许您同步等待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'));

还可使用“异步函数表达式”来定义异步函数。 异步函数表达式与异步函数语句很是类似,语法几乎相同。异步函数表达式和异步函数语句之间的主要区别在于函数名称,在异步函数表达式中能够省略这些名称以建立匿名函数。异步函数表达式能够用做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:
support

工做一天结束时,重要的是不要盲目选择“最新”方法编写异步代码。理解异步JavaScript的内部特性相当重要,并深刻了解所选方法的内部原理。与编程中的其余全部方法同样,每种方法都有优势和缺点。

编写高度可维护,强壮的异步代码的5个技巧

  • 代码整洁:使用async/await能够减小你的代码体积,由于它能够略过一些没必要要的步骤:.then链,处理结果的匿名函数和回调函数中定义结果变量
// `rp` is a request-promise function.
rp(‘https://api.example.com/endpoint1').then(function(data) {
 // …
});

使用async/await以后:

// `rp` is a request-promise function.
var response = await rp(‘https://api.example.com/endpoint1');
  • 错误处理:

async/await使相同的代码结构来处理同步或异步的错误(或异常)称为可能,好比熟悉的try/catch语句,下面的例子使用Promises:

function loadData() {
    try { // 捕获同步错误
        getJSON().then(function(response) {
            var parsed = JSON.parse(response);
            console.log(parsed);
        }).catch(function(e) { // 捕获异步错误
            console.log(e); 
        });
    } catch(e) {
        console.log(e);
    }
}

使用async/await以后:

async function loadData() {
    try {
        var data = JSON.parse(await getJSON());
        console.log(data);
    } catch(e) {
        console.log(e);
    }
}
  • 使用条件:使用条件式代码结合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/await以后:

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;    
  }
}
  • 堆栈帧:

使用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/await以后:

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)
});
  • 调试:若是你使用过promises,你知道调试它们是一场噩梦。例如,若是您在.then块内设置断点并使用调试快捷方式(如“step over”),则调试器将不会移动到如下位置,由于它仅经过同步代码“执行”。

经过异步/等待,您能够彻底按照正常的同步功能一步一步地调试。

相关文章
相关标签/搜索