JS高阶三(JS中的异步编程)

Posted 稻香Snowy

tags:

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

事实上,程序运行的部分和将来运行的部分之间的关系就是异步编程的核心。
任何时候,只有把一段代码包装成一个函数,并指定它在响应某个事件(定时器,鼠标点击,Ajax响应等)时执行,你就是在代码中创建了一个将来的执行的程序块,也由此在这个程序中引入异步机制。
本文主要讲述异步编程之前需要了解的其他相关知识。

目录

  1. 同步与异步

  2. 多线程

  3. JavaScript单线程

  4. 并行于并发

  5. javascript异步机制

  6. 并发模型

  7. 事件循环流程

  8. 再谈setTimeout(...0)

  9. Web Works

  10. javascript异步实现方法

一、同步与异步

所谓的同步编程,就是计算机一行一行按顺序依次执行代码,当前代码任务耗时执行会阻塞代码的执行。

同步编程,即是一种典型的请求——响应模型,当请求调用一个函数或方法后,需要等待其响应返回,然后执行后续代码。

一般情况下,同步编程,代码依次执行,能很好的保证程序的执行,但是在某些场景下,比如读取文件的内容,或者请求服务器接口数据,需要根据返回内容执行后续操作,读取文件和请求接口直到数据返回这一过程是需要时间的,javascript是不能处理其他任务的,此时页面的交互,滚动等任何操作也都会被阻塞,这显然是及其不友好的,不能接受的,而这正是需要异步编程大显身手的场景。就比如耗时任务A会阻塞任务B的执行,等到任务A执行完才会继续执行B。
而当使用异步编程时,在等待当前任务响应返回之前,可以继续执行后续代码,即当前执行任务不会阻塞后续执行。

异步编程,不同于同步编程的请求——响应模式,其实就是一种事件驱动,请求调用函数或方法后,无需立即等待响应,可以继续执行其他任务,而异步任务响应返回后可以通过状态,通知调用者。

二、多线程

前面说明了异步编程能很好的解决同步阻塞的问题,那么实现异步编程的方式有哪些呢?通常实现异步方式是多线程,如C#,即同时开启多个线程,不同操作能并行执行。耗时任务A执行的同时,在线程二中任务B也可以执行。

三、javascript单线程

javascript语言执行环境是单线程的,单线程在程序执行时,所走的程序路径按照连续顺序排下来,前面的必须处理好,后面的才会执行,而使用异步实现时,多个任务可以并发执行。那么javascript的异步编程如何实现呢?

四、并行于并发

前面提到多线程的任务可以并行执行,而javascript单线程异步编程可以是实现多任务并发执行,这里有必要说明一些并行于并发的区别。

  • 并行,指同一时刻内多任务同时进行

  • 并发,指同一时间段内,多任务同时进行着,但是某一时刻,只有一个任务执行。

通常所说的并发链接数,是指浏览器向服务器发送请求,建立TCP连接,每秒钟服务器建立的总连接数,假如10ms能处理一个连接,那么其并发连接数就是100。

五、javascript异步机制

本节介绍javascript异步机制,首先看一个例子:

 
   
   
 
  1. for(var i=0;i<5;i++){

  2.  setTimeout(function(){

  3.    console.log(i);

  4.  },0);

  5. }

  6. console.log(i);

  7. //结果是5,5,5,5,5

应该明白最后输出的全是5:

  1. i在此处是for循环所在上下文的变量,有且只有一个i。

  2. 循环结束时i=5

  3. javascript单线程事件处理器在线程空间不会执行下一个事件。 如果要真正理解以上例子中的setTimeout()以及javascript异步机制,需要理解javascript的事件循环和并发模型。

六、并发模型(concurrency model)

目前,我们已经知道,javascript执行异步任务时,不需要等待响应返回,可以继续执行其他任务,而在响应返回时,会得到通知,执行回调或事件处理程序。那么这一切具体是如何完成的,又以什么规则或顺序运作的呢?接下来我们需要解答这个问题。
注:回调和事件处理程序本质上并无区别,只是在不同情况下,不同的叫法
前文已经提到,javascript异步编程是的多个人恶化可以并发执行,而实现这一功能的基础是javascript拥有一个基于事件循环的并发模型。

1.堆栈与队列

介绍javascript并发模型之前,先简单介绍堆栈与队列的区别:
堆和栈都是程序运行时内存中分配的一个数据区域,因此也称为堆区和栈区,但二者存储的数据类型和处理速度不同。堆(heep)用于复杂数据类型(引用类型)分配空间,例如数组对象,object对象;它是运行时动态分配内存的,因此存取速度较慢。栈(stack)中主要存放一些基本类型的变量和对象的引用,其优势是存取速度比堆更要快,并且栈内的数据可以共享,但是缺点是存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。

(1)、栈的使用规则

栈有一个很重要的特性,就是在栈中的数据可以共享。例如下面的代码定义两个变量,变量的值是数字类。

 
   
   
 
  1. var a=3;

  2. var b=3;

(2)、堆的使用规则

下面通过Array来看一下堆的行为,例如存在下面的代码:

 
   
   
 
  1. var fruit1='apple';

  2. var fruit2='orange';

  3. var fruit3='banana';

  4. var oArray=[fruit_1,fruit_2,fruit_3];

  5. var newArray=oArray;

当创建数组时,就会在堆内创建一个数组对象,并且在栈中创建一个这个数组的引用。变量fruit1,fruit2,fruit3为基本数据类型,它们的值直接存放在栈中;newArray,oArray为符号数据类型(引用类型),它们的引用变量存放在栈中,指向于存放在堆中的实际对象。

注意,newArray的只等于变量引用oArray,所以它是复合数据类(引用类类型)。此时如果改变变量fruit1和fruit2,fruit3的值,那么其实是更改栈中的值;如果更改newArray或oArray的值,那么其实是更改堆中的实际对象,因此,对两个变量的引用都会发生作用。例如,首先更改newArray的值,然后看oArray的值。代码如下:

 
   
   
 
  1. alert(oArray[1]);//返回orange

  2. newArray[1]='berry';

  3. alert(oArray[1]);//返回berry

同样,首先更改oArray的值,然后看newArray的值,代码如下:

 
   
   
 
  1. alert(newArray[1]);//返回orange

  2. oArray[1]='tomato';

  3. alert(newArray[1]);//返回tomato

javascript堆不需要程序来显示地释放,因为堆是自动的垃圾回收来负责的,每种浏览器中的javascript解释引擎有不同的自动回收方式,但一个最基本的原则是:如果栈中不存在对堆中的某个对象的引用,那么就认为该对象已经不需要了,在垃圾回收的时候就会清除该对象占用的内存空间。因此,在不需要时应该对对象的引用释放掉,以利于垃圾回收,这样就可以提高程序的性能。释放对象的引用最常用的方法就是将其赋值为null,例如下面的代码将newArray赋值为null

 
   
   
 
  1. newArray=null

(3)、易犯的错误

在堆和栈的使用问题上,最容易犯的错误就是String的使用,例如下面的代码:

 
   
   
 
  1. var str=new String('abc');

  2. var str='abc';

同样是创建两个字符串,第一种是用new关键字新建String对象,对象会存放在堆中,没调用一次就创建一个新的对象;而第二种是栈中,栈中存放“abc”和对值的引用。推荐使用第二种方式创建多个“abc”字符串,这种写法在内存中存放在一个值,有利于节省空间。同时它可以在一定程度上提高程序的运行速度,因为存储栈中,其值可以共享,并且由于栈的访问速度更快,所以对于性能的提高大有益处。 而第一种方式每次都在堆中创建一个新的String对象,而不管其字符串值是否相等以及是否有必要创建新的对象,从而加重了程序的负担。 堆的访问速度慢,对程序的性能影响也大。另外,处于逻辑运算的考虑,当两个变量进行比较时,使用堆和栈存储就会有差异。下面来看一下逻辑等于和逻辑全等于运算,深入理解一下堆和栈:

  • (1)、例如下面的代码,实际上只比较栈中的值:

 
   
   
 
  1. var str1='abc';

  2. var str2='abc';

  3. alert(str1==str2);//true

  4. alert(str1===str2);//true

不管逻辑等于还是逻辑全等于,都会返回true,可以看出str1和str2指向同一个值。

  • (2)、例如下面的代码,实际上比较堆中的值:

 
   
   
 
  1. var str1=new String('abc');

  2. var str2=new String('abc');

  3. alert(str1==str2);//false

  4. alert(str1===str2);//false

不管是逻辑等于还是逻辑全等于都会返回false,可以看出str1和str2指向的不是同一个对象。

  • (3)、例如下面的代码,比较堆和栈中的值

 
   
   
 
  1. var str1=new String('abc');

  2. var sstr2='abc';

  3. alert(str1==str2);//true

  4. alert(str1===str2);//false

在进行逻辑等于和逻辑全等于运算时,会首先将变量转成数据类型,然后进行对比。变量str1和str2的数据类型虽然不同,但比较运算还是返回true。但是逻辑全等于运算符与逻辑等于运算不同,它会对数据进行比较,看是否是引用的同一个数据。
以上仅仅是简单地对堆和栈的知识做一个补充,有关堆栈和队列的更多知识在这里就不做详细介绍了。
栈和队列是不同的数据结构,栈是后进先出的顺序存储数据结构,队列是先进先出的顺序存储数据结构。

2.事件循环(Event Loop)

javascript引擎负责解析,执行javascript代码,但是它不能单独运行,通常都得有一个宿主环境,一般如浏览器和node服务器,前文说到单线程在这些宿主环境中创建单一线程,提供一种机制,调用javascript引擎完成多个javascript代码块的调度,执行(是的,javascript代码都是按快执行的),这种机制称为事件循环(Event Loop)javascript执行环境中存在两个结构需要了解:

  1. 消息队列(message queue)也叫任务队列(task queue):存储待处理消息以及对应的回调函数或事件处理程序。

  2. 执行栈(execution queue),也叫执行上下文:javascript执行,顾名思义,是由执行上下文组成,当函数调用时,创建并插入一个执行上下文,通常称为执行帧栈(frame),存储着函数参数和局部变量,当函数执行结束时,弹出该执行帧。

注:关于全局代码,由于所有的代码都是全局上下文执行,所有执行栈顶部总是全局上下文就很容易理解,直到所有的代码执行完毕,全局上下文退出执行栈,栈清空了;也即是全局上下文第一个进栈,最后一个出栈的

3.任务

分析事件循环流程前,先阐述两个概念,有助于理解事件循环:同步任务和异步任务。
任务很好理解,javascript代码执行就是在完成任务,所谓的任务就是一个函数或一个代码块,比如完成一次加法计算,完成一次ajax请求;很自然就分为同步任务和异步任务。同步任务是连续的,阻塞的;而异步任务是不连续的,非阻塞的,包含异步事件及其回调。

七、事件循环

  1. 宿主环境为javascript创建线程时,会创建堆和栈,堆内存储javascript对象,栈内存储的是执行上下文;

  2. 站内执行上下文的同步任务按序执行,执行完即退栈,而当异步任务执行时,该异步任务进入等待状态(不入栈),同时通知线程:出发该事件时(或该异步操作响应返回时),向消息队列(任务队列)插入一个事件消息(异步任务)

  3. 当事件触发(异步操作响应返回)时,线程会向消息队列(任务队列)插入该事件(包括事件及其回调)

  4. 当栈内同步任务执行完毕之后,线程从消息队列(任务队列)中取出一个事件消息,将其对应的异步任务(函数)放入栈中,执行回调函数,如果未绑定回调,这个消息会被丢弃,执行完任务之后退栈。

  5. 当线程空闲(即执行栈清空)时继续拉取消息队列下一轮消息(next tick,事件循环流转一次称为一次tick)

 
   
   
 
  1. var eventLoop=[];

  2. var event;

  3. var i=eventLoop.length-1;//后进先出

  4. while(eventLoop[i]){

  5.  event=eventLoop[i--];

  6.    if(event){//如果事件回调存在

  7.        event();

  8.    }

  9.    //否则事件消息被丢弃

  10. }

这里需要注意的一点是等待下一个事件消息的过程是同步的。
并发模式与事件循环:

 
   
   
 
  1. var ele=document.querySelector('body');

  2. function clickcb(event){

  3.  console.log('clicked');

  4. }

  5. function bindEvent(callback){

  6.  ele.addEventListener(''click,callback)

  7. }

  8. bindEvent(clickcb);

在javascript代码运行中,遇到同步任务时,立即入栈;遇到异步任务时,则进入等待状态,并通知线程,相应的异步事件触发(或异步操作响应返回)时,往消息队列中插入一条事件消息;而执行栈后续的同步代码执行完后,读取消息队列,得到一条消息,然后将该消息对应的异步任务放入栈中,执行相应的回调函数;一次事件循环完成了,也即处理了一个异步任务。

八、再谈setTimeout(...,0)

了解了javascript事件循环后我们再看前文关于setTimeout(...0)的例子就比较清晰了:
setTimeout(...,0)所表达的意思是:等待0秒后,往消息队列中插入一条定时器事件消息,并将第一个参数作为回调函数;当执行栈中的同步任务执行完毕时,线程会从消息队列中读取消息,将该异步任务入栈,执行;线程空闲时再从消消息队列中读取消息。

 
   
   
 
  1. var start=new Date();

  2. var arr=[];

  3. setTimeout(function(){

  4.  console.log('time'+new Date().getTime()-start);

  5. },10)

  6. for(var i=0;i<1000000;i++){

  7.    arr.push(i);

  8. }

试着运行上面的代码,你会发现执行多次,每次执行的结果不同。
在setTimeout异步回调函数里我们输出了异步任务注册到执行的时间间隔,发现并不等于我们指定的时间,而且每次的时间间隔也都不同,出现这种原因应该考虑到以下两点:

  • 在读取消息队列时,得等同步任务完成,这个是需要消耗时间的;

  • 消息队列是先进先出的,读取此异步事件消息之前,可能还存在其他消息执行也需要耗时。

九、web works

每个Web Works或一个跨域的iframe都有各自的堆栈和消息队列,这些不同的文档只能通过postMessage方法进行通信,当一方监听了message事件后,另一方才能通过该方法向其发送消息,这message事件也是异步的,当一方接受到另一方通过postMessage方法发送过来的消息之后,会想自己的消息队列插入一条消息,而后续的并发流程按照上文所述。

十、javascript异步实现

关于javascript异步编程的实现,以前有回调函数,发布订阅模式,Promise三类(这里指的是原生书写实现promise),而ES6中提出了Promise,生成器(Generator),ES7中有出现了async/await。
这里我们仅仅是简单的介绍一下这些方式的用法,详细介绍会在后面说到。

1. 回调函数的方式

通过把一个函数(callback)作为参数传入另一个函数,当满足一定条件的时候,就执行callback函数。

 
   
   
 
  1. //这里只是一个简单的条件

  2. function fn1(a,fn){

  3.    if(a>10 && fn instanceof Function){

  4.        fn();

  5.    }

  6. }

  7. function fn2(){

  8.    console.log('-------fn2------')

  9. }

  10. //异步调用

  11. function fn3(fn){

  12.    setTimeout(()=>{

  13.        fn()

  14.    },1000)

  15. }

  16. //执行

  17. fn1(12,fn2);

  18. fn3(fn2);

2.发布订阅方式

pub/sub模式js设计模式中的一种,本身是借鉴于java的模式,但是在异步处理的时候非常有用。通过一个信息中心“EventCenter”来处理监听(on)和触发(triggle)。

 
   
   
 
  1. function fn1(){

  2.  setTimeout(()=>{

  3.    //异步调用操作后得到数据

  4.    let data=fetch(...);

  5.    Event.triggle('waterFull',data);

  6.  },2000);

  7. }

  8. fn1();

  9. Event.on('waterFull',(data)=>{

  10.  //对得到的数据进行处理

  11.  console.log(data);

  12. })

通过pub/sub模式,可以在信息中心清楚的看到有多少信号来源,方便集中管理。更加方便模块化管理,但是如果整个项目都使用pub/sub模式的话,流程就变得不清晰了,数据的得到和对数据的处理时分开的,对于后期的维护也是一个很大的问题。

3.Promise

下面主要是通过具体的要求来实现Promise,不会仔细讲解。 Promise构造函数(承诺),它分为三种状态,“resolve”,“reject”,“pending”,一旦状态pending改变为其他两个状态之后,就不能再修改了,就像一个承诺一样。Promise接受两个参数resolve和reject,分别表示成功后执行和失败后执行,可以通过实例的“then()”方法传递相应的函数。

 
   
   
 
  1. const promise=new Promise((resolve,reject)=>{

  2.  //some code这里的函数会立即执行

  3.  if(resolve) resolve(value);

  4.  else reject(err);

  5. })

  6. promise.then((data)=>{

  7.  console.log(data)

  8. }).catch((err)=>{

  9.  console.log(err);

  10. })

有关promise的详细内容,后面文章会讲到。

4.generator构造器

generator是一个构造器,generator函数执行并不会执行函数内部部分,而是返回一个构造器对象,通过构造器对象的next()函数调用函数主体,并且每当遇到“yield”都会暂停执行,并返回一个对象。

 
   
   
 
  1. function *gen(){

  2.  console.log('....');

  3.  yield 1

  4.  yield 2

  5.  return 3

  6. }

  7. let g=gen();//这里执行了generator函数但是并没有执行下面

  8. g.next();

有关generator和promise的结合使用实现异步操作,后面会有文章详细讲到

5.async/await异步处理

ES7中出现了async/await进行异步处理,使得异步操作像同步代码一样简单,方便了使用,由于'async/await'内部封装了'generator'处理,所以就很少人使用'generator'来处理了,但是在异步的推动中'generator'起到了很大的作用。
await:后面接受一个promise实例
async:返回一个promise对象
下面是一个简单的异步请求:

 
   
   
 
  1. async function f(){

  2.  //直接得到接口返回数据,在这里会等待接口返回数据

  3.  let data=await fetch('').then(res=>res.json());

  4.  console.log(data);//接口数据

  5.  return data;

  6. }

  7. async function h(){

  8.    let data=await Promise.resolve(22);

  9.    console.log(data);//22

  10.    return data;//Promsie {v}

  11. }


以上是关于JS高阶三(JS中的异步编程)的主要内容,如果未能解决你的问题,请参考以下文章

JS高阶四(JS中的异步编程中)

VSCode自定义代码片段——JS中的面向对象编程

VSCode自定义代码片段9——JS中的面向对象编程

理解js中的异步编程

深入浅出nodejs学习笔记——异步编程

JS高阶编程技巧--柯理化函数