【前端进阶】从底层剖析JS之异步编程原理

前言:

在这里插入图片描述
异步:现在与未来的理解?
假设我们在运行一段for循环,开始到结束的过程,当然这也需要持续一段时间(几微秒或几毫秒)才能完成。它是指程序的一部分现在执行,而另一部分则将来运行。现在与将来之间有段间隙,在这段间隙中,程序没有活跃执行其它程序,而是等待for循环的完成。这将浪费了这段间隙的时间,给了用户很不好的体验。我们需要管理这段时间间隙,这段间隙可能是在等待用户输入、从数据库或文件系统中请求数据、通过网络发送数据并等待响应。或者是在以固定时间间隔执行重复任务等等。这些情况我们都需管理这段时间间隔的状态。在接下来这几篇博客,我们将学习新出现的JS异步编程技术。

这篇博客不会谈到异步编程技术,我们先深入理解异步的概念及在JS中的运作模式。将在接下来的博客深入讲解回调函数和Promise异步编程技术

1.1 分块程序

我们可以把JS的程序写在单个.js文件中,但是这个程序几乎是由多个块构成的。在这些块中只有一个是现在执行的,其余的则会在将来执行。最常见的块单位是函数。

我们通常会遇到这样的问题:我们会认为现在无法完成的任务将会异步完成,因此不会对程序造成阻塞行为。
看下面代码:

	//假设ajax()是某个库中提供的某个Ajax函数
    var data=ajax("http://url");
    console.log(data);//哦豁,data通常不会包含Ajax结果。

在打印data时没有Ajax的结果,这是因为Ajax请求不是同步完成的,这就意味着ajax()函数还没有返回任何值可以赋给变量data。如果ajax()能够阻塞到响应返回,那么data赋值就能正确成功。但是我们不能这么做(阻塞),这也不是Ajax的正确使用方法。

现在我们发出一个异步的Ajax请求,然后在将来才能得到返回的结果。

ajax("http://url",function myCallBack(data){
	console.log(data);
})

从现在到将来的“等待”,最简单的方法是使用回调函数,但不是唯一和最好的方法。上面的回调函数就是异步(将来)执行的块。

注意:我们可以发送同步的Ajax请求,但是,在任何情况下都不应该这样做,因为它会锁定浏览器的UI界面,使得按钮、菜单、滚动条等无法使用,阻塞了所有的用户交互。
再次举例说明“将来”函数:

function now(){
  return 20;
}
function later(){
  answer=answer*2;
  console.log(answer);
}
var answer=now();
setTimeout(later,1000);

这段程序有两个块(函数):现在执行的now(),将在执行的later()。对它们进行“现在”与“将来”进行划分。

现在:
function now(){
  return 20;
}
function later(){
  answer=answer*2;
  console.log(answer);
}
var answer=now();
setTimeout(later,1000);


将来:
answer=answer*2;
console.log(answer);

任何时候,只要把一段代码包装成函数,并指定它在响应某个事件(定时器,鼠标点击,Ajax响应等)时执行,你就是在代码中创建了一个将来执行的块,也由此引入了异步机制。

1.2 事件循环(重点)

JavaScript引擎是如何处理这些异步的函数的呢?
举例来说一下:如果我们的JS程序发出一个Ajax请求,从服务器获取一些数据,那你就在回调函数中写好响应代码,然后JavaScript引擎会通知宿主环境(浏览器),告知宿主环境,我现在要暂停执行,你如果完成了网络请求,拿到了数据,就请调用我这个回调函数。
好啦,现在可以讲解事件循环了。看下面的伪代码:

var eventLoop=[];//存储需要处理的事件
var event;
while(true){//一直循环
  if(eventLoop.length>0){
    event=eventLoop.shift();//删除并返回数组的第一个事件,即提取出来处理
    try{
      event();
    }
    catch(err){
      reportError(err);
    }
  }
}

这是一段简化的伪代码,可以用来理解概念。
你可以看到,有个while循环实现持续运行的循环,如果队列中有等待事件,那么就会从队列中摘下一个事件并执行。这些事件就是你的回调函数。

一定要清楚,setTimeout()并没有把你的回调函数挂在事件循环队列中。它所做的是设定一个定时器。当定时器到时后,环境就会把你的回调函数放在事件循环中,这就会在未来的某个时刻会摘下并执行这个回调。

如果这时事件循环有很多项目了呢?你的回调就会等待。它得排在其它项目后面,这也解释了为什么setTimeout()定时器的精度不够高,大体上说,只能确保你的回调函数不会在指定的时间间隔之前执行,但可能会在那个时刻运行,也可能在那之后运行,要根据事件队列的状态而定。

1.3 并发

异步是关于现在和将来的时间间隙,而并行是关于能够同时发生的事情。

现在让我们来设想一个展示状态更新列表的网站,其随着用户向下滚动列表而逐渐加载更多内容。实现这功能至少需要两个独立的“进程”同时运行。

第一个“进程”在用户滚动页面触发onscroll事件时响应这些事件(发起Ajax请求新的内容)。第二个“进程”接收Ajax响应(把内容展示到页面)。

当用户滚动页面足够快时,在等待第一个响应返回并处理的时候可能会看到两个或更多onscroll事件被触发,因此将得到快速触发彼此交替的onscroll事件和Ajax响应事件。两个或多个“进程”同时执行就出现并发。

假设"进程"如下:

“进程1”:
	onscroll,请求1
	onscroll,请求2
	onscroll,请求3
	onscroll,请求4
	

“进程2”:(Ajax响应事件)
	响应1
	响应2
	响应3
	响应4
	响应5

在前面的事件循环的概念,JavaScript一次只能处理一个事件,要么是onscroll,要么是响应。
假设事件循环队列中所有这些交替的事件:

onscroll,请求1     进程1启动
onscroll,请求2
响应1			   进程2启动
onscroll,请求3
响应2
响应3
onscroll,请求4	  进程1结束
响应4    		  进程2结束

"进程1"和"进程2"并发运行,但是它们的各个时间实在事件循环列表中依次进行。

1.3.1 非交互

两个或多个“进程”在同一程序内并发地交替运行他们的事件,如果这些任务不彼此相关,就不一定需要交互。且进程之间不相互影响。
举例:

var res={};
function foo(results){
  res.foo=results;
}
function bar(results){
  res.bar=results;
}
ajax("http://url1",foo);
ajax("httpL//url2",bar)

foo()和bar()是两个并发执行的“进程”,独立运行,不会相互影响。

1.3.2 交互

更常见的情况是,并发的“进程”需要相互交流,通过作用域或DOM间接交互。
看代码:

var res=[];
function response(data){
  res.push(data);
}
ajax("http://url1",response);
ajax("http://url2",response);

这里的并发“进程”是这两个用来处理Ajax()响应的response()调用。我们可能希望的是res[0]中存放调用"http://url1"的结果,res[1]存放"http://url2"的回调结果。有时候可能是这样,但有时却相反,这要取决于哪个调用先成功。这种不确定性就是一个竟态条件bug。
接下来处理一下这样的竞速条件,使之确定性稳定:

var res=[];
function response(data){
  if(data.url=="http://url1"){
    res[0].push(data);
  }else if(data.url=="http://url2"){
    res[1].push(data);
  }
}
ajax("http://url1",response);
ajax("http://url2",response);

这样,不管哪个Ajax响应先返回,我们通过data.url判断应该把响应数据存放在数组中的什么位置。通过这样的协调,就可以避免竞态条件引起的不确定性。
再想象一个场景,多个并发函数调用通过共享DOM彼此之间交互的情况,比如一个函数调用更新某个< div >的内同,另外一个更新< div >的风格或属性。在你未拿到DOM想显示的数据前,你不想把这个容器显示出来,所以这种协调必须保证正确的交互顺序。
有些并发场景如果不做协调,就总是会出错。
例如:

var a,b;
function foo(x){
  a=x*2;
  baz();
}
function bar(y){
  b=y*2;
  baz();
}
function baz(){
  console.log(a+b);
}
ajax("http://url1",foo);
ajax("http://url2",bar);

在这个例子中,无论哪个回调先响应总会使baz()运行过早(a或b未定义);但是对baz()的第二次调用就没问题了。因为这时候a、b都已定义了。

要解决这个问题有很多种方法:

var a,b;
function foo(x){
  a=x*2;
  if(a&&b){
  baz();
  }
}
function bar(y){
  b=y*2;
  if(a&&b){
  baz();
  }
}
function baz(){
  console.log(a+b);
}
ajax("http://url1",foo);
ajax("http://url2",bar);

包裹baz()调用的条件判断if(a&&b)传统上称为门,我们虽然不能确定a和b到达的顺序,但是会等到它们都准备好再进一步打开门(调用baz())。

1.4 任务(Promise的异步特性是基于任务的)

在ES6中,有一个新的概念建立在事件循环队列之上,叫做任务队列。这个概念给大家带来的最大影响可能是Promise的异步特性。
对于任务队列最好的理解方式就是,它是挂在事件循环队列的每个tick(任务)之后的一个队列。在事件循环的每个tick中可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而是会在当前tick的任务队列末尾添加一个任务。(即事件循环的每个任务中又有一个队列记录着该任务的异步事件)。

事件循环队列类似于一个游乐园游戏:玩过一个游戏之后,你需要重新到队尾排队才能再玩一次。而任务队列类似于玩过游戏之后,插队继续玩。
注意:一个任务可能引起更多任务被添加到同一个队列末尾。
假设有这么一个调度任务schedule():

	console.log("A");
	setTimeout(function(){
		console.log("B");
		},0);
	schedule(function(){
		console.log("C");
		schedule(function(){
	   	 	console.log("D");
	    });
	 });

可能你认为这里会打印A、B、C、D,但实际打印的结果是A、C、D、B。因为任务处理是在当前事件循环tick结尾处,且定时器触发是为了调度下一个事件循环tick。(setTimeout()调用时被排到异步事件队列末尾,等同步任务完成才执行console.log(“B”))。

由于异步编程(回调函数、Promise、生成器)技术内容较多,因此将会在新博客逐一讲解。

猜你喜欢

转载自blog.csdn.net/weixin_43334673/article/details/106638299
今日推荐