我理解的JavaScript异步编程

Posted 58技术

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了我理解的JavaScript异步编程相关的知识,希望对你有一定的参考价值。


引言


最开始学习JS的时候就从知道了JS是单线程的,天生异步,适合IO密集型,不适合CPU密集型。但是,多数初学者从来没有认真思考过自己程序中的异步到底是怎么出现的,以及为什么会出现,也没有探索过处理异步的其他方法,甚至于一直在用callback来解决异步问题。


为什么会出现异步


浏览器内核的多线程

一个浏览器至少三个常驻线程:JavaScript引擎线程,浏览器GUI渲染线程,浏览器事件触发线程

  • JS引擎,是基于事件驱动单线程执行的,JS引擎一直等待着任务队列中任务的到来,然后加以处理,浏览器无论什么时候都只有一个JS线程在运行JS程序。

  • GUI线程,当界面需要重绘或由于某种操作引发回流时,该线程就会执行。而因为JS可以操作DOM元素,进而会影响到GUI的渲染结果,因此JS引擎线程与GUI渲染线程是互斥的,也就是说当JS引擎线程处于运行状态时,GUI渲染线程将处于冻结状态。

  • 浏览器事件触发线程,当一个事件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理,这些事件可来自javascript引擎当前执行的代码块,setTimeout, 也可以来自浏览器内核的其他线程如鼠标点击,AJAX异步请求等,但由于JS的单线程关系,所有这些事件都得排队等待JS引擎处理。

事件循环机制

任务队列,也就是事件队列,分为 宏任务(macro-task) 和 微任务(micro-task)

循环机制如下:

  1. 先顺序从上向下执行当前全局上下文

  2. 遇到异步事件就将其交给对应的浏览器模块

  3. 浏览器的模块处理完之后,宏任务放入宏任务队列,微任务放入微任务队列

  4. 当函数调用栈清空,开始执行任务队列,先执行微任务队列,执行完微任务队列再执行宏任务队列

  5. 当执行任务队列时,可以认为重新开了一个空的宏任务队列和一个空的微任务队列,将新产生的异步任务最终放入新的任务队列,当前任务队列执行完成后,当前宏队列和微队列就清除,然后再去执行新的微任务队列,执行新的宏任务队列,新开微队列,新开宏队列,一直循环下去,直到任务队列全部为空。


异步的处理方法


callback是最简单的,但不是最好的

//callback的一般使用形式$.ajax({ url:'www.pidanChen.com', success(data){ console.log(data) }})
//延时处理try{  setTimeout(function(){    console.log('timeout')  },500)}catch(e){  console.log('timerErr'+e)}

1. 如果某个业务,依赖于上层业务的数据,上层业务又依赖于更上一层的数据,还采用回调的方式来处理异步的话,就会出现回调地狱,也就是顺序问题。

2. 最严重的还不是编辑器中出现的倒三角形的代码,是控制反转。例如,我们调用了一个第三方组件的支付API,进行购买支付,正常情况发现一切运行良好,但是假如某一天,第三方组件出问题了,可能多次调用传入的回调,也可能传回错误的数据。说到底,这样的回调嵌套,控制权在第三方,对于回调函数的调用方式、时间、次数、顺序,回调函数参数都是不可控的,因为无论如何,并不总能保证第三方是可信任的。

解决信任问题--Promise

//创建第一个Promiselet p = new Promise(function(res,rej){ if(err){ rej() }else{ res() }})p.then()

实例化一个Promise对象组要一个函数作为参数,该函数接受两个参数:resolve函数和reject函数;当实例化Promise构造函数时,将立即调用该函数,随后返回一个Promise对象。通常,实例化时,会初始一个异步任务,在异步任务完成或失败时,调用resolve或reject函数来完成或拒绝返回的Promise对象。另外需要注意的是,若传入的函数执行抛出异常,那么这个Promsie将被拒绝。

解决控制反转的信任问题

  • Promise并没有取消控制反转,而是把反转出去的控制再反转一次,也就是反转了控制反转。

  • 它与普通的回调的方式的区别在于,普通的方式,回调成功之后的操作直接写在了回调函数里面,而这些操作的调用由第三方控制。在Promise的方式中,回调只负责成功之后的通知,而回调成功之后的操作放在了then的回调里面,由Promise精确控制。

  • Promise有这些特征:只能决议一次,决议值只能有一个,决议之后无法改变。任何then中的回调也只会被调用一次。

解决调用过早

  • Promise就根本不必担心这种问题即使是立即完成的Promise,也无法被同步观察到,即使这个Promise已经决议了,提供给then的回调总会是在js事件队列在当前完成后,再被调用,即异步调用。

解决回调过晚或没有调用

  • Promise本身不会回调过晚,只要决议了,它就会按照规定运行。至于服务器或者网络的问题,并不是Promise能解决的,一般这种情况会使用Promise的竞态API Promise.race加一个超时的时间

function timeoutPromise(delay){ return new Promise(function(res,rej){ setTimeout(function(){ rej('timeout') }, delay) })}
Promise.race([dosomeThing(), timeoutPromise(3000)]).then().catch()

解决回调次数太少或太多的问题

  • 由于Promise只能被决议一次,且决议之后无法改变,所以,即便是多次回调,也不会影响结果,决议之后的调用都会被忽略。

let fs = require('fs');var p1 = new Promise(function (resolve, reject{  fs.readFile('a.txt''utf8'function (err, data{    if (err) {      reject(err);    }   resolve(data);   resolve(data);   resolve(data);  })});p1.then(function(res){  <!--只打印一次res-->  console.log(res)})

解决吞掉可能出现的错误或异常

  • 如果在Promise的创建过程中或在查看其决议结果的过程中的任何时间点上,出现了一个JavaScript异常错误,比如一个TypeError或ReferenceError,这个异常都会被捕捉,并且会使这个Promise被拒绝。

var p = new Promise(function (resolve, reject{  foo.bar(); // foo未定义  resolve(2);})p.then(function (data{  console.log(data);// 永远也不会到达这里}, function (err{  console.log(err);    // err将会是一个TypeError异常对象来自foo.bar()这一行});

从以上几点可以明确,Promise可以解决一系列控制反转带来的回调信任问题,但是Promise并没有完全摆脱回调,而是把回调的位置放到了then中,换成了决议通知,这样其实说到底顺序问题还是没有解决


解决顺序问题--Generator

生成器 (Generator)

  • 生成器是一种返回迭代器的函数,通过 function 关键字后的 * 号来表示。

迭代器(Iterable)

  • 迭代器是一种对象,它具有一些专门为迭代过程设计的专有接口,所有迭代器对象都有一个 next 方法,每次调用都返回一个结果对象。结果对象有两个属性,一个是 value,表示下一个将要返回的值;另一个是 done,它是一个布尔类型的值,当没有更多可返回数据时返回 true。迭代器还会保存一个内部指针,用来指向当前集合中值的位置,每调用一次 next() 方法,都会返回下一个可用的值。

生成器一般使用形式

function *foo({  var x = yield 2;  var y = x * (yield x + 1)  console.log( x, y );  return x + y}var it = foo();it.next() // {value: 2, done: false}it.next(3// {value: 4, done: false}it.next(3) // 3 9, {value: 12, done: true}

yield.. 和 next(..) 这一对组合起来, 在生成器的执行过程中构成了一个双向消息传递系统。

  • 一般来说,需要的 next(..) 调用要比 yield 语句多一个,前面的代码片段有两yield 和三个 next(..) 调用;

  • 第一个 next(..)是用来启动一个生成器,并运行到第一个 yield 处;

  • 每个 yield.. 基本上是提出了一个问题:“这里我应该插入什么值?”,这个问题由下一个 next(..) 回答。 第二个 next(..) 回答第一个 yield.. 的问题,第三个 next(..) 回答第二个 yield 的问题,以此类推;

  • yield.. 作为一个表达式可以发出消息响应 next(..) 调用, next(..) 也可以向暂停的 yield 表达式发送值。

异步迭代生成器

来看一下下面这段代码,我们在生成器里 yeild 请求函数(暂停生成器继续执行,同时并执行请求函数),执行生成器产成可迭代对象后,又在请求函数里通过 next() 方法获取到请求结果、将结果传进生成器并恢复生成器的执行。

//setTimeout 模拟ajax请求var it=nullfunction foo(){    let firstData='firstData'    setTimeout(function(){        it.next(firstData)    },500)}function goo(data){    let secondData='secondData'    setTimeout(function(){        if(data=='firstData'){            it.next(`${secondData}+${data}`)        }    },500)}function *main(){    let data=yield foo()    let newData=yield goo(data)    console.log('经过两次异步之后输出结果:'+newData)}it=main();it.next() //启动<!--输出-->经过两次异步之后输出结果:secondData+firstData

我们在生成器内部有了看似完全同步的代码(除了 yield 关键字本身),但隐藏在背后的是,在 foo(..) goo(...)内的运行可以完全异步。在可读性和合理性方面也都是一个巨大的进步。 

从本质上而言,我们把异步作为实现细节抽象了出去,使得我们可以以同步顺序的形式追踪流程控制:“发出一个 Ajax 请求,等它完成之后打印出响应结果。”并且只在这个流程控制中表达了两个步骤,而这种表达能力是可以无限扩展的,以便我们无论需要多少步骤都可以表达。

由以上来看,顺序问题也得到了解决


es7的解决方案 Async/Await 


上面介绍的Promise和Generator,把这两者结合起来,就是Async/Await

Generator的缺点是还需要我们手动控制next()执行,使用Async/Await的时候,只要await后面跟着一个Promise,它会自动等到Promise决议以后的返回值,resolve(...)或者reject(...)都可以。

Async/Await是Generator的语法糖,和generator的写法很像,就是将 Generator 函数的星号(*)替换成 async,将 yield 替换成await。它有以下优点:

  • 内置执行器:Generator函数的执行必须靠执行器,所以才有了 co 函数库,而 async 函数自带执行器.也就是说,async 函数的执行,与普通函数一模一样。

  • 更好的语义:async 和 await,比起星号和 yield,语义更清楚了。async 表示函数里有异步操作,await 表示紧跟在后面的表达式需要等待结果。

async 的作用

async 函数负责返回一个 Promise 对象,如果在async函数中 return 一个直接量,async 会把这个直接量通过Promise.resolve()  封装成 Promise 对象;如果 async 函数没有返回值,它会返回 Promise.resolve(undefined)

await在等待什么

一般我们都用await去等带一个async函数完成,不过按语法说明,await 等待的是一个表达式,这个表达式的计算结果是 Promise 对象或者其它值,所以,await后面实际可以接收普通函数调用或者直接量。

如果await等到的不是一个promise对象,那跟着的表达式的运算结果就是它等到的东西。

如果是一个promise对象,await会阻塞后面的代码,等promise对象resolve,得到resolve的值作为await表达式的运算结果

虽然await阻塞了,但await在async中,async不会阻塞,它内部所有的阻塞都被封装在一个promise对象中异步执行

Async Await使用场景

当需要用到promise链式调用的时候,就体现出Async Await的优势;

function delayTime(n{  return new Promise(resolve => {    setTimeout(() => resolve(n + 200), n);  });}
function step1(n{  console.log(`step1 with ${n}`);  return delayTime(n);}
function step2(m, n{  console.log(`step2 with ${m} and ${n}`);  return delayTime(m + n);}
async function main({  console.time("doIt");  const time1 = 300;  const time2 = await step1(time1);  const time3 = await step2(time1, time2);}
main();
<!--输出结果-->// step1 with 300// step2 with 300 and 500

await 若等待的是 promise 就会停止下来

例如:业务是这样的,我有三个异步请求需要发送,相互没有关联,只是需要当请求都结束后将界面的 loading 清除掉即可。

之前用callback这么写

let lock=0ajax(function(){  lock++  if(lock===3){    clearLoading()//  清除loading  }})

现在用 Async/Await

// ajax方法返回的是promise对象async function doIt(){  await ajax(1)  await ajax(2)  await ajax(3)  clearLoading()}

loading 确实是等待请求都结束完才清除的。但是观察下浏览器的 timeline 请求是一个结束后再发另一个的

正常应该是利用Promise.all + async

async function doIt(){  let p1=ajax(1)  let p2=ajax(2)  let p3=ajax(3)  await Promise.all([p1,p2,p3]);  clearLoading()}

所以async-await并不能取代promise,两者应该是相互配合,才是目前较好的JavaScript异步解决方案。


以上是关于我理解的JavaScript异步编程的主要内容,如果未能解决你的问题,请参考以下文章

深扒深入理解 JavaScript 中的异步编程

我理解的JavaScript异步编程

JavaScript异步编程

你好,JavaScript异步编程---- 理解JavaScript异步的美妙

深入理解nodejs中的异步编程

理解Javascript的异步