博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
Javascript社区是时候接受async/await语法了
阅读量:4612 次
发布时间:2019-06-09

本文共 5174 字,大约阅读时间需要 17 分钟。

由于Javascript是一个单线程语言,大量的API都是异步实现的。异步代码有一个很讨厌的问题,会传染。当你在一个函数中使用一个异步API时,你需要通过回调执行后续的逻辑,而当外层逻辑使用这个函数并且依赖后续的逻辑时,你需要继续向外回调,外层函数也需要提供回调函数,于是异步逻辑将一直传染至发起调用者。

举一个例子:

function getDataSync(url) {    var req = new XMLHttpRequest();    req.open('GET', url, false);    req.send();    return req.responseText;}function getDataAsync(url, callback) {    var req = new XMLHttpRequest();    req.onreadystatechange = function cb () {        callback(req.responseText);    };    req.open('GET', url, true);    req.send();}// 同步调用console.log(getDataSync('data.json'));// 异步调用getDataAsync('data.json', function (data) {    console.log(data);});

第一个函数getDataSync同步地调用了req.open(),第二个函数getDataAsync则是异步调用,可以看到getDataAsync不仅要定义一个回调函数(就是那个cb)并且要提供一个参数接收外层调用者的回调函数。

异步传染将一直蔓延到最外层逻辑,如果发起调用者本身就是事件,噩梦就终止于此了,但是这意味着程序是靠不同的事件驱动的,这就是为什么说Javascript是事件驱动的模型。但是这样存在一个问题,多个事件的逻辑之间上下文是独立的,或者混杂的。

对于这个例子里的getDataAsync来说, 内部的回调函数可以访问getDataAsync内的上下文(也就是闭包变量req),并且通过回调函数callback把上下文的部分信息(req.responseText)通过参数继续传递给外层调用者。但是如上面所说,在层层调用的尽头,发起者将是一个事件回调。最终这个上下文将丢失在调用栈中,唯一的方法就是在这个过程中或者调用之前就把它保存起来,例如保存在一个全局变量或对象属性上,但是这么做可能把这个上下文信息暴露在一个公共的作用域中。
例如一个涉及前后端通信的某种功能的类,客户端向服务端发送一个消息,然后异步地等待服务端返回消息,通常是一个消息有一个监听事件的回调函数。当消息发送以后,一些上下文信息,或者说状态,保存在类的属性中,在返回消息事件的回调中取得这个属性继续执行后续的逻辑。这个类可能用到多个消息,不仅上下文信息的传递变得繁琐,而且多个消息的不相关逻辑对彼此之间保存的信息都可见,这就像大量使用全局变量一样糟糕。
当然也可以采用上面例子中的写法,在发送请求消息的方法中定义一个匿名函数来监听事件,通过闭包变量保持上下文。但是这种方法不仅写起来不方便,而且在外层调用栈可能仍然要面临这样的问题。还有很重要的一点,函数对象的创建是有开销的,函数越复杂开销越大。
在解决这一问题上,首先Promise被提了出来。先看这样一段代码:

function getDataPromise(url) {    var promise = new Promise(function(resolve, reject) {        var req = new XMLHttpRequest();        req.onreadystatechange = function cb () {            resolve(req.responseText); // 注意这里        };        req.open('GET', url, true);        req.send();    });    promise.then(function(value) {        console.log(value);     });    return promise;} getDataPromise('data.json').then(function (data) {    console.log(data);});  // 后面还可以串接更多的then

Promise通过构造函数的方式创建一个Promise对象,它接受一个函数并立即执行,并向其中传入两个函数resolve, inject的引用。之后在回调还未发生的这段时间里,可以通过Promise对象的then方法继续添加回调发生以后后续要执行的逻辑(通过匿名函数),当异步回调发生时,调用resolve会依次执行then方法添加的匿名回调函数,也可以把Promise对象返回给外层调用者,串接更多的回调。这里忽略更多的细节,因为本篇不是要讲这个。

直观地看,Promise仅仅是通过对象传递上下文解决了作用域混乱的问题,但它不仅没有解决需要回调的问题,反而把问题复杂化了。它增加了更多的匿名函数,加重了垃圾回收,显然不能大量用在有性能要求的场景。但是Promise的出现是很有意义的,它的意义在于将流程控制通过Promise对象的状态来实现。说句题外话,类似的思想还有一个应用就是行为树,程序无非就是代码和数据,代码或者说流程本身既是数据,用数据去描述流程是回归本源。Promise就先说到这里。

回到同步的问题上,之所以同步的代码可以保持上下文例如局部变量,是因为上下文保存在栈上。当一个调用过程返回以后,栈的上下文就丢失了,当然在C语言中完全是这样的。而之所以匿名回调函数可以保持上下文是因为Javascript函数也是一个对象,它本身也是有上下文的,这个封闭的上下文环境就是闭包,外层函数返回以后虽然栈丢失了,但是闭包变量被保存在了匿名函数对象的上下文中。只要还持有这个匿名函数对象,这个上下文就不会丢失,我们就可以在匿名函数中访问。
举一个这样的例子:

function gen(array) {    var i = 0;    return function () {        return array[i++];    };}var it = gen([1, 2, 3]);console.log(it()); // 1console.log(it()); // 2console.log(it()); // 3

函数gen接受一个参数,定义一个局部变量,然后返回一个匿名函数。返回的匿名函数it可以访问gen的参数和局部变量,只要我们持有这个函数对象,就可以多次调用,看起来就像重入了gen函数的栈上下文,实际上这是通过其中的匿名函数的闭包上下文实现的。

实际上这就是生成器,真正的生成器其实和这个原理一样,这里也不去讲它的语法了。它返回了一个保持上下文环境的函数对象,这样我们便可以多次调用这个函数重入生成器调用时的上下文环境。
那么这和一开始讲的异步调用有什么关系呢?前面提到过了,异步调用其实最大的问题不是代码风格,而是上下文的传递,通过全局或对象级的作用域传递上下文会干涉不相关的逻辑,而生成器既是函数,拥有函数级的词法作用域,又可以多次重入其中的上下文环境。那么我们在一个生成器中维护一个状态,控制生成的匿名函数在每次执行时执行不同的流程,在第一次调用中进行异步请求,在回调时进行第二次调用执行后续的逻辑,不就可以实现类似Promise的作用了吗?
这里就要说一下区别了,Promise是靠Promise对象去维护状态和上下文,而生成器则是靠闭包。Promise对于异步逻辑的延续是靠包装进匿名函数来实现的,越多的异步逻辑就有越多的函数对象,如前面所说存在性能问题。而生成器只在调用时创建一个匿名函数,异步逻辑都包含在这个匿名函数中,并通过一个状态(就像上面例子里的闭包变量i)来控制执行的流程。举例来说Babel和TypeScript转译出的ES5代码,可以看到它们是用switch语句来实现的,我们都知道大多数编程语言对于switch case结构都会进行一些诸如查表优化,因此生成器相比Promise效率更高一些。这里有一个测试(https://jsperf.com/v8-generators-vs-promises/11),我在chromium linux x64上测试生成器比Promise快了一倍,而这是这个例子仅使用且复用了一个匿名函数的结果(事实上v8已经可以自动优化这种情况了)。
但使用生成器其实并不比Promise容易,为了能方便地实现这一点,async方法诞生了(async方法其实在C#中早已有了,而且C#也是用了一个Task对象去维护状态,类似Promise)。再来看代码,这次我们要用到前面的getDataPromise方法:

function getDataPromise(url) {    return new Promise(function(resolve, reject) {        var req = new XMLHttpRequest();        req.onreadystatechange = function cb () {            resolve(req.responseText); // 注意这里        };        req.open('GET', url, true);        req.send();    });} async function getManyData() {    var data1 = await getDataPromise('data1.json');    var data2 = await getDataPromise('data2.json');    return [data1, data2];}getManyData(); // 或await getManyData();

可以看到在getManyData这个async方法中,用await方式调用getDataPromise这个异步方法,在语法上就像是同步的,虽然实际上还是异步的。如果使用Babel或者TypeScript,编译器会把这段代码编译成生成器实现的,async方法外层包装了一层调用,如果async方法返回了一个Promise,这个包装层会在Promise对象状态改变时重入生成器,执行相应的流程。外层调用必须也是一个async方法并用await调用才能利用这种可重入上下文的特性,所以await只能在async方法中使用。从条件上来看,async也是具有传染性的,这是异步代码的根源问题,但是async方法具有函数级词法作用域,相比Promise用对象和参数保持和传递上下文更加方便灵活,这才是使用async的根本目的(还看到有人说生成的代码太丑,做前端不知道sourcemap是什么的可以去面壁了)。

虽然包装函数和生成器创建了两个函数对象,而且最终还要使用Promise包装的异步方法,但异步逻辑比较复杂时相比单纯Promise性能上有优势。前面提过v8可以优化相同函数的对象创建,所以最好仅把Promise用于包装异步API,用async方法实现业务逻辑。

其实如果用Lua的话还有一个叫做coroutine也就是协程的东西,它虽然和生成器作用类似,但是原理不同。前面可以看出来,生成器是通过闭包保持上下文的,返回时丢掉了栈上下文。而协程拥有独立的栈,就像操作系统每个线程拥有独立的栈一样,但是协程是主动返回让出执行的,线程是被动调度,协程执行时会使用自己的栈,返回时栈不会丢失,也就不会产生闭包变量。总结来说,生成器和协程前者使用闭包上下文,后者使用栈上下文,前者返回时会清栈后者只是切换调用栈,结果上来看功能是一样的。
还有一个概念叫做continuation,其实这篇就是围绕这个来讲的,本篇仅限于从JavaScript的语法角度来理解,所以不再深入下去了。讲了这么多,其实就是想说下这些东西背后的思想。

转载于:https://www.cnblogs.com/fightingCat/p/6608910.html

你可能感兴趣的文章
DateTime数据类型保存问题(DateTime2)
查看>>
【算法学习】【洛谷】cdq分治 & P3810 三维偏序
查看>>
1025 反转链表 (25 分)
查看>>
基于Pojo的开发模式(day03)
查看>>
jQuery input -> file change事件bug
查看>>
前端开发 - CSS - 上
查看>>
基础数据结构
查看>>
关闭CENTOS不必要的默认服务
查看>>
showModalDialog改进版,包括Chrome下的特殊处理
查看>>
mysql学习
查看>>
对Jpa中Entity关系映射中mappedBy的理解
查看>>
获取注册表某键下的所有子键
查看>>
java类库
查看>>
spring boot中log4j冲突问题和解决办法
查看>>
python练手习题
查看>>
kmp算法的个人理解
查看>>
python 爬虫 加强记忆
查看>>
[USACO07JAN] Tallest Cow
查看>>
Selenium收藏官方网址
查看>>
[译]ABP vNext微服务演示,项目状态和路线图
查看>>