概述

众所周知,目前主流的javaScript环境,都是以单线程的模式去执行的javaScript代码,那javaScript采用单线程工作的原因与他最早的设计初衷有关。

最早javaScript这门语言就是一门运行在浏览器端的脚本语言,那他的目的是为了实现页面上的动态交互。

而实现页面交互的核心就是dom操作,那这也就决定了,他必须使用单线程模型,否则就会出现很复杂的线程同步问题。

我们可以设想一下,假定我们在javaScript中同时有多个线程一起工作,那其中一个线程修改了某一个dom元素,而另外一个线程同时又删除了这个元素,那此时我们的浏览器就无法明确,改以哪一个线程的工作结果为准。

所以说为了避免这种线程同步的问题,从一开始javaScript就被设计成了单线程模式工作,那这也就成为了这门语言最为核心的特性之一。

那这里所说的单线程指的就是,在js的执行环境当中,负责执行代码的线程只有一个。

那你可以想象成,在我们的内部只有一个人按照我们的代码去执行任务。那只有一个人,他同时也就只能执行一个任务,那如果说有多个任务的话就必须要排队,然后一个一个依次去完成。

那这种模式他最大的优点就是,更安全,更简单,那缺点也同样很明显,如果说我们遇到一个特别耗时的任务,那后面的这些任务呢,都必须要去排队,等待这个任务的结束。

console.log('foo');

for (let i = 0; i < 100000; i++) {
    console.log('耗时操作');
}

console.log('等待耗时操作结束');

那这也就会导致我们整个程序的执行会被拖延,出现假死的情况。

那为了解决耗时任务阻塞执行的这种问题,javaScript语言将任务的执行模式分成了两种。分别是同步模式(Synchronous)和异步模式(Asynchronous)。

那我们在这里重点要了解的就是在javaScript中与异步编程相关的一些内容,主要包括以下几点。

同步模式

那首先我们来看,同步执行模式。

同步模式指的就是我们代码当中任务依次执行,那后一个任务就必须要等待前一个任务结束才能够开始执行,那程序的执行顺序跟我们代码的编写顺序是完全一致的,也就是说这种方式会比较简单。

那在单线程的情况下我们大多数任务都会以同步模式去执行,那注意我们这里说的同步并不是指同时执行,而是排队执行。

那这里我们可以以一段同步模式的代码为例,来去分析一下他的具体执行过程。

console.log('global begin')

function bar() {
    console.log('bar task')
}

function foo() {
    console.log('foo task')
    bar()
}

foo()

console.log('global end')

那开始执行js引擎会把我们整体额代码全部加载进来,然后呢在我们的调用栈当中去压入一个匿名的调用,那这个匿名的调用就可以理解为把全部的代码放到了一个匿名函数当中去执行。

然后他就开始逐行执行我们这里每一行的代码,那首先是第一行,第一行遇到了console.log调用,他就会把这个console.log压入我们的调用栈去执行,那执行过程中我们的控制台打入了对应的消息global begin,然后呢我们这个console.log调用结束,他就弹出了我们这个调用栈。

然后我们的代码继续向下执行。

那紧接着往下是两个函数的声明,那不管是函数还是变量的声明他都不会产生任何的调用,所以说这里的执行会继续往下。

那在往下就是一个foo函数的调用,那对于函数调用它同样要压入调用栈,然后开始执行这个foo函数。

那foo函数一开始是先打印了一个消息,那打印完成过后他调用了bar函数,那这里的bar函数也会被放入到调用栈当中去执行。

那bar函数执行的过程当中又打印了一次,那打印完成我们的bar函数也就执行完成,从调用栈当中bar函数就会被弹出。

然后紧接着我们的foo函数执行也就结束了,他同样会从调用栈中弹出。

那最后再去打印了我们的global end, 然后我们整体的代码全部结束。我们的调用栈就会被清空掉。

那这里的调用栈只是一个更专业的说法,更通俗一点的解释就是js在执行引擎当中维护了一个正在工作的工作表,或者说正在执行的一个工作表。

那在这个里面会记录当前我们正在做的一些事情,那当这个工作表中所有的任务全部被清空过后,那这一轮的工作就算是结束了。

那这是一个纯同步模式下的执行情况。所以说特别容易理解,因为他整个执行过程非常符合我们正常的阅读逻辑或者说思考逻辑。

不过这种排队执行的机制,他也存在一个很严重的问题,就是如果说其中的某一个任务,或者更具体点说就是其中的某一行代码,他执行的时间过长,那他后面的任务就会被延迟。那我们把这种延迟称之为阻塞。

那这种阻塞对于用户而言,就意味着界面会有卡顿,或者说卡死,所以说就必须要有异步模式,来去解决我们程序当中那些无法避免的耗时操作。

例如我们在浏览器端的ajax操作,或者在nodejs当中的大文件读写。那都会需要使用到异步模式去执行,从而去避免我们的代码被卡死。

异步模式

接下来我们再来看异步执行模式,那不同于同步模式的执行方式,异步模式的API是不会等待这个任务的结束才开始执行下一个任务。

对于耗时操作他都是开启过后就立即往后执行下一个任务。

那耗时任务的后续逻辑呢我们会通过回调函数的方式去定义,那在内部呢,我们这个耗时任务完成过后呢就会自动执行我们这里传入的回调函数。

那异步模式对于javaScript非常重要,那如果没有这种模式的话,我们单线程的javaScript语言,他就无法同时处理大量的耗时任务。

而对于开发者而言,单线程模式下面的异步他最大的难点就是代码执行的顺序并不会像同步代码一样通俗易懂。

因为他的执行顺序相对会比较跳跃,那对于这个问题呢,更多的是需要理解和习惯,最好的办法呢就是多看,多练,多思考。

那这里我们同样以一段包含异步调用的代码,来去分析一下,在javaScript当中,异步执行的过程。

那这段代码最外层包含了两个setTimeout, 而在第二个setTimeout函数内部又去使用了一次timeout。

console.log('global begin')

setTimeout(function timer1() {
    console.log('timer1 invoke')
}, 1800)

setTimeout(function timer2() {
    console.log('timer2 invoke')

    setTimeout(function inner() {
        console.log('inner invoke')
    }, 1000)

}, 1000)

console.log('global end')

那因为有异步调用的过程相对会复杂一点,所以说我们这里要介绍到的东西也相对会多一些。

首先是内部API的环境,我们这里是以web平台举例,所以说就是web api,然后是事件循环和一个消息队列。也有人把消息队列称之为回调队列。那他的作用呢我们遇到的时候再说。

那这里整体的执行情况大致呢与我们前面所分析的同步模式情况相同,只不过在遇到一些异步调用时会有一些差异,我们具体来看。

首先他也是加载整体的代码,然后在我们的调用栈当中去压入一个匿名的全局调用,然后我们这里会依次执行每一行代码。

那对于console.log这样的同步api,还是一样的,先压栈然后再执行,执行过程当中打印,打印过后弹栈。

然后再往后就遇到了一个setTimeout调用,那同样也是先将这个setTimeout压入到我们的调用栈,但是这个函数的内部他是异步调用,所以我们需要关心内部API环境到底做了什么事情。

其实在内部的api也非常简单,他就是在内部为这个timer1函数开启了一个倒计时器,然后单独放到一边,那注意这里的倒计时器他是单独工作的,并不会受我们当前的js线程影响。

那从我们开始过后他就已经开始倒数了,只不过呢我们这里是分步骤去演示。那我们就让他在一旁默默的倒数,待会我们再来看倒数完了过后他干的事情。

那开启这个倒计时器过后,对于settimeout函数来讲,他的调用就已经而完成了,所以说代码会继续往下执行。

然后再往下又遇到了一个settimeout调用,那同理也是先压栈,然后开启另一个倒计时器。然后弹栈。

那最后又遇到了一个console.log调用,那打印了消息过后呢,对于整体的这个匿名调用就已经完成了。所以说我们这个调用栈就会被清空掉。

然后这时候Event loop 因为我们调用栈里面已经没有工作了,所以说我们Event loop他就会发挥作用。

那Event loop他其实只做一件事情,就是负责监听调用栈和消息队列,那一但我们调用栈当中所有的任务都结束了,那事件循环就会从消息队列当中取出第一个回调函数,然后压入到调用栈。

只不过此时我们的消息队列当中是空的,他什么都没有,所以说执行就相当于是暂停下来了。

那此时呢我们再来回过头来看一看我们这里的两个倒计时器,那自从前面开启了这两个倒计时过后我们的代码就再也没有管过他们。而是直接往后执行了。

那这里timer1函数所对应的倒计时他应该是倒计1.8s,timer2是1s。那很明显,timer2所对应的倒计时他应该先结束。

那结束过后呢,timer2函数就会被放入到我们消息队列的第一位,那在timer1对应的倒计时结束过后他就会放入到消息队列的第二位。

那一但消息队列中发生了变化,我们的事件循环就会监听到然后就会把消息队列当中的第一个也就是timer2函数取出来,压入到我们的调用栈。继续去执行这个timer2。

那此时对于调用栈来讲的话,相当于开启了新一轮的执行。那执行过程呢与我们刚刚分析的是一致的。

那如果说这个过程中又遇到了有异步调用,他也是相同的情况,先会把他放入到我们api环境里面单独去执行,然后在往后就是不断这样重复。

直到我们的调用栈和消息队列当中都没有需要继续执行的任务了,那整体的代码就结束了。

那如果说我们的调用栈是一个正在执行的工作表,那消息队列就可以理解成一个待办的工作表,而js执行引擎呢就是先去做完调用栈当中所有的任务,然后再通过事件循环从消息队列当中再取一个任务出来。继续去执行。以此类推。

那整个过程呢我们随时都可以往消息队列当中再去放入一些任务,那这些任务呢在消息队列当中会排队等待事件循环。

那以上就是异步调用在javaScript当中的实现过程以及他的一个基本的原理。

那整个过程呢都是通过内部的消息队列和事件循环去实现的,那因为我们这里是分开分析的,所以说你会认为这些步骤都会有一定的先后顺序,其实不是这样的。因为他们各自都有各自的time-line。

例如我们的倒计时器,他开始过后呢就会自动开始倒计时,根本不会管调用栈或者队列当中是什么情况。

只不过我们这分析时,我们如果同步去分析的话你就会觉得特别乱,所以说我们这里特别安排了这样几个时间点。尽量确保我们的执行顺序跟我们的分析顺序是一致的。那这一点呢,你需要额外注意一下。

可能我们接下来的这张图可以更清楚地表述出这一点。

例如我们在js当中。js线程某一时刻他发起了一个异步调用。然后他紧接着往后执行其他的任务。那此时呢,异步线程会单独去执行这个异步任务,然后在执行完这个任务过后会将这个任务的回调放入到消息队列,那js主线程他完成所有的任务过后会再依次执行我们消息队列当中的任务。

那这里呢我们特别需要注意一点的是,javascript他确实是单线程的,而我们的浏览器他并不是单线程的。

那更具体一点来说就是我们通过javascript调用的某些内部的api,他并不是单线程的。例如我们这里所使用的的倒计时器,那他内部呢就会有一个单独的线程去负责倒数。在时间到了之后会将我们的回调放入到消息队列。

也就是说这样一个事情他是有单独的线程去做的,我们所说的单线程指的是执行我们代码的那个线程,他是一个线程。

也就是说这些内部的API呢他们会用单独的线程去执行这些等待的操作。

因为我们就拿生活角度来说他有些事情耗时他是必然需要等的,那等总得有一个人去等,那我们这呢,只不过是不会让js线程去等。

那除此以外呢,这里我们所说的,同步也好,异步也好,肯定不是指我们写代码的方式,而是说我们运行环境提供的API, 他到底是以同步模式还是以异步模式的方式去工作。

那对于同步模式的API, 他的特点呢就是这个任务执行完代码才会继续往下走,例如我们的console.log。

对于异步模式的API呢,他就是下达这个任务开启过后的指令就会继续往下执行,那代码是不会在这一行等待任务的结束的。例如我们的setTimeout。

回调函数

正如前面所说,异步模式对于单线程的javascript语言非常重要。同时也是javascript的核心特点。

也正是因为大量异步模式的API的关系,所以说我们写出来的js代码相对就没有那么容易读。执行顺序呢,相对来说就会复杂很多,特别是对于复杂的一些异步逻辑。

那从这样一个角度来讲的话,javascript他实际上是不适合初学者的,但是呢,一般我们可能会有一些传统的固化的逻辑思维,那一但我们打破这种传统的逻辑思维过后,其实也还好,不会有那么夸张。

那接下来我们重点要介绍的就是在js当中那些为异步而生的语法。特别是在ES 2015过后推出的一系列新语法,新特性。

那这些语法,特性呢他们慢慢弥补了javascript在异步编程这块的不足或者是不变。

那首先我们先来看一下javascript当中实现异步编程的根本方式。

其实所有的异步编程方案他的根本都是回调函数,那回调函数你就可以把他理解成一件你想要做的事情,你明确知道这件事情应该怎么做,怎么样一步一步的往下做。

但是你并不知道这件事情所依赖的任务什么时候才能完成,所以说最好的办法呢就是把你的这件事的步骤写到一个函数当中交给任务的执行者。

那这个异步任务的执行者他是知道这个任务什么时候结束的,那他就可以在结束过后去帮你执行你想要去做的事情。那这件想要做的事情呢我们就可以理解成回调函数。

那这么说呢可能会比较抽象,我们具体一点,比如说我现在想给我的桌子重新刷一遍漆,那我明确知道我想要怎么去刷,但是呢我没有油漆,那我得让你帮我去买一桶油漆,那你去买油漆实际上需要一定的时间的,而我又会有很多其他的事情要做,所以说我不能在这个地方干等着你,那我就会选择把我们这个桌子应该怎么刷的步骤写到一个纸条上面,然后一起交给你,完了过后我就去忙别的事情了,那你买完油漆回来过后就可以按照我纸条上的步骤,一步一步的去帮我把这个桌子刷好就可以了。

那我们在这样一个例子中,我呢实际上就是异步任务的调用者,而你就是具体的异步任务的执行者,那我给你的纸条也就是写着步骤的这个纸条。他就是我调用者所定义的回调函数。

那我们再以程序当中的ajax请求为例,那我们去调用ajax操作,目的呢就是为了拿到请求结果过后去做一些事情,例如我们把它显示到界面上。

但是呢这个请求他何时能够完成我们并不知道,所以说我们得把得到响应结果之后要去执行的任务定义到一个函数当中,然后内部的ajax在请求完成过后呢,他会自动执行这个任务。

那这种由调用者定义然后交给执行者去执行的函数就被称之为回调函数,具体的用法也非常简单,他就只是把函数作为参数去传递罢了。只不过这种方式的异步代码呢他相对来说特别不利于阅读。而且整个过程执行顺序呢会非常的混乱。

function foo (callback) {
    setTimeout(function () {
        callback()
    }, 3000)
}

foo(function() {
    console.log('这就是一个回调函数')
    console.log('调用者定义这个函数,执行者执行这个函数')
    console.log('其实就是调用者告诉执行者异步任务后应该做什么')
})

那其实除了传递回调函数参数这种方式以外,还有几种常见的实现异步方式,例如事件机制或者发布订阅。

不过我认为这些也都是基于回调函数基础之上的一些变体罢了,所以我们在这就不做具体的探讨了。