1

前言

javascript是一门单线程非阻塞的脚本语言。

单线程意味着,javascript代码在执行的任何时候,都只有一个主线程来处理所有的任务。这个由最初的用途来决定的:与浏览器交互,需要进行各种各样的dom操作。如果javascript是多线程的,那么当两个线程同时对dom进行一项操作,例如一个添加事件,另一个删除这个dom,此时该如何处理呢?因此,为了保证不会发生类似于这种情景,javascript选择只用一个主线程来执行代码,这样就保证了程序执行的一致性。

非阻塞是当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如I/O事件)的时候,主线程会挂起(pending)这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调。

javascript引擎到底是如何实现非阻塞呢?答案就是 event loop(事件循环)。

事件循环机制【浏览器环境】

执行栈与事件队列

当javascript代码执行的时候会将不同的变量存于内存中的不同位置:堆(heap)和栈(stack)中来加以区分。其中,堆里存放着一些对象,而栈中则存放着一些基础类型变量以及对象的指针。

堆(Heap)
堆表示一大块非结构化的内存区域,对象,数据被存放在堆中。

执行栈(Stack)

当我们调用一个方法的时候,js会生成一个与这个方法对应的执行环境(context),又叫执行上下文。这个执行环境中存在着这个方法的私有作用域,上层作用域的指向,方法的参数,这个作用域中定义的变量以及这个作用域的this对象。 而当一系列方法被依次调用的时候,因为js是单线程的,同一时间只能执行一个方法,于是这些方法被排队在一个单独的地方。这个地方被称为执行栈。

执行栈是一种后进先出的数组结构。

js执行栈如何执行?

当一个脚本第一次执行的时候,js引擎会解析这段代码,并将其中的同步代码按照执行顺序加入执行栈中,然后从头开始执行。如果当前执行的是一个方法,那么js会向执行栈中添加这个方法的执行环境,然后进入这个执行环境继续执行其中的代码。当这个执行环境中的代码 执行完毕并返回结果后,js会退出这个执行环境并把这个执行环境销毁,回到上一个方法的执行环境。这个过程反复进行,直到执行栈中的代码全部执行完毕。

举个例子:

function foo(b) {
  var a = 10;
  return a + b + 11;
}

function bar(x) {
  var y = 3;
  return foo(x * y);
}

console.log(bar(7)); // 返回 42

当调用 bar 时,创建了第一个帧 ,帧中包含了 bar 的参数和局部变量。当 bar 调用 foo 时,第二个帧就被创建,并被压到第一个帧之上,帧中包含了 foo 的参数和局部变量。当 foo 返回时,最上层的帧就被弹出栈(剩下 bar 函数的调用帧 )。当 bar 返回的时候,栈就空了。这里的堆栈,是数据结构的堆栈,不是内存中的堆栈(内存中的堆栈,堆存放引用类型的数据,栈存放基本类型的数据)

事件队列

js引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当一个异步事件返回结果后,js会将这个事件加入与当前执行栈不同的另一个队列,我们称之为事件队列。

事件循环

被放入事件队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码,如此反复,这样就形成了一个无限的循环。整个的这种运行机制又称为Event Loop(事件循环)。

同步任务和异步任务

所有任务分为两种:同步任务,异步任务

同步任务

是调用立即得到结果的任务,同步任务在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;

异步任务

是调用无法立即得到结果,需要额外的操作才能预期结果的任务,异步任务不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

JS引擎遇到异步任务(DOM事件监听、网络请求、setTimeout计时器等),会交给相应的线程单独去维护异步任务,等待某个时机(计时器结束、网络请求成功、用户点击DOM),然后由事件触发线程将异步对应的回调函数加入到消息队列中,消息队列中的回调函数等待被执行。

异步运行机制如下:

  1. 所有同步任务都在主线程上执行,形成一个[执行栈]
  2. 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
  3. 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  4. 主线程不断重复上面的第三步。

举个例子

console.log('script start')

setTimeout(() => {
  console.log('timer 1 over')
}, 1000)

setTimeout(() => {
  console.log('timer 2 over')
}, 0)

console.log('script end')

// script start
// script end
// timer 2 over
// timer 1 over

timer 2 over0毫秒后添加到任务队列队尾,timer 1 over1秒添加到任务队列队尾,等待主线程任务执行完,从队头依次执行任务队列中的任务。

宏任务和微任务

异步事件如何处理?

在一个事件循环中,异步事件返回结果后会被放到一个任务队列中。然而,根据这个异步事件的类型,这个事件实际上会被对应的宏任务队列或者微任务队列中去。在当前执行栈为空的时候,主线程会查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈...如此反复,进入循环。

不同的异步任务被分为两类:微任务(micro task)和宏任务(macro task)。

MacroTask(宏任务):script全部代码、setTimeout、setInterval、setImmediate(浏览器暂时不支持,只有IE10支持,具体可见MDN)、I/O、UI Rendering。
MicroTask(微任务): Process.nextTick(Node独有)、Promise、Object.observe(废弃)、MutationObserver(具体使用方式查看这里)

当前执行栈执行完毕时会立刻先处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行
Promise同样是用来处理异步的:

console.log('script start')

setTimeout(function() {
    console.log('timer over')
}, 0)

Promise.resolve().then(function() {
    console.log('promise1')
}).then(function() {
    console.log('promise2')
})

console.log('script end')

// script start
// script end
// promise1
// promise2
// timer over

异步编程的几种方法

1、回调函数
这是异步编程最基本的方法。假定有两个函数f1和f2,后者等待前者的执行结果。
如果f1是一个很耗时的任务,可以把f2写成f1的回调函数。

function f1(callback) {
  setTimeout(function () {
    callback();
  }, 1000);
}
f1(f2);

采用这种方式,我们把同步操作变成了异步操作,f1不会堵塞程序运行,相当于先执行程序的主要逻辑,将耗时的操作推迟执行。
回调函数的优点是简单、容易理解和部署,缺点是不利于代码的阅读和维护,各个部分之间高度耦合(Coupling),流程会很混乱,而回调函数有一个致命的弱点,就是容易写出回调地狱

2、事件监听
另一种思路是采用事件驱动模式。任务的执行不取决于代码的顺序,而取决于某个事件是否发生。
还是以f1和f2为例。首先,为f1绑定一个事件(这里采用的jQuery的写法)。

function f1(){
    setTimeout(function () {
        f1.trigger('done');  // 执行完成后,立即触发done事件,从而开始执行f2
    }, 1000);
}
f1.on('done', f2);    // 当f1发生done事件,就执行f2

这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以"去耦合"(Decoupling),有利于实现模块化。缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。

3、发布/订阅
假定,存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做"发布/订阅模式"(publish-subscribe pattern),又称"观察者模式"(observer pattern)。
这个模式有多种实现,下面采用的是Ben Alman的Tiny Pub/Sub,这是jQuery的一个插件。

jQuery.subscribe("done", f2);

function f1(){
  setTimeout(function () {
    jQuery.publish("done") ;   
       // f1执行完成后,向"信号中心"jQuery发布"done"信号,从而引发f2的执行。
  }, 1000);
}

jQuery.unsubscribe("done", f2);  // f2完成执行后,也可以取消订阅(unsubscribe)

这种方法的性质与"事件监听"类似,但是明显优于后者。因为我们可以通过查看"消息中心",了解存在多少信号、每个信号有多少订阅者,从而监控程序的运行

4、Promise对象
Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由社区最早提出和实现,ES6 将其写进了语言标准,统一了用法,原生提供了Promise对象。

Promise对象有以下两个特点:

  • 对象的状态不受外界影响。
    Promise对象代表一个异步操作,有三种状态:Pending(进行中)、Resolved(已完成,又称Fulfilled)和Rejected(已失败)。

只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态

  • 一旦状态改变,就不会再变,任何时候都可以得到这个结果

Promise对象的状态改变,只有两种可能:从Pending变为Resolved和从Pending变为Rejected。
只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果。就算改变已经发生了,你再对Promise对象添加回调函数,也会立即得到这个结果

优点:将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise对象提供统一的接口,使得控制异步操作更加容易。
缺点:首先,无法取消Promise,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。第三,当处于Pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。

Promise.prototype.then()方法的作用是为 Promise 实例添加状态改变时的回调函数。第一个参数是Resolved状态的回调函数,第二个参数(可选)是Rejected状态的回调函数。
Promise.prototype.catch方法是.then(null, rejection)或.then(undefined, rejection)的别名,用于指定发生错误时的回调函数

var promise = new Promise(function(resolve, reject) {
  // ... some code

  if (/* 异步操作成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});
// Promise实例生成以后,可以用then方法分别指定Resolved状态和Reject状态的回调函数。
promise.then(function(value) {
  // success
}, function(error) {
  // failure
});

5、Generator 函数
Generator函数是ES6提供的一种异步编程解决方案,语法行为与传统函数完全不同
Generator函数有多种理解角度:
语法上,Generator函数是一个状态机,封装了多个内部状态。执行Generator函数会返回一个遍历器对象,可以依次遍历Generator函数内部的每一个状态。
形式上,Generator函数是一个普通函数,但是有两个特征。一是,function关键字与函数名之间有一个星号;二是,函数体内部使用yield表达式,定义不同的内部状态。

yield语句
Generator函数返回的遍历器对象,yield语句暂停,调用next方法恢复执行,如果没遇到新的yeild,一直运行到return语句为止,return 后面表达式的值作为返回对象的value值,如果没有return语句,一直运行到结束,返回对象的value为undefined。

function* helloWorldGenerator() {  // Generator 函数,该函数有三个状态:hello,world 和 return 语句
  yield 'hello'; 
  yield 'world'; 
  return 'ending';
}

var hw = helloWorldGenerator();  
//Generator 函数的调用,调用后并不执行,而是返回一个指向内容状态的指针对象(即遍历器对象Iterator Object)

// Generator 函数是分段执行的,yield表达式是暂停执行的标记,而next方法可以恢复执行,

hw.next() 
// { value: 'hello', done: false }  done为false表示遍历未结束

hw.next()
// { value: 'world', done: false }

hw.next() 
// { value: 'ending', done: true }  done为true表示遍历结束

hw.next()
// { value: undefined, done: true }  Generator 函数已经运行完毕,以后再调用next方法,返回的都是这个结果

6、async与await
ES2017提供了async函数,使得异步操作变得更加方便。async函数就是Generator函数的语法糖。
async函数就是将Generator函数的星号(*)替换成async,将yield替换成await,仅此而已。
进一步说,async函数完全可以看作多个异步操作,包装成的一个Promise对象,而await命令就是内部then命令的语法糖。
async函数返回一个 Promise对象,可以使用then方法添加回调函数。当函数执行的时候,一旦遇到await就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。

async function timeout(ms) {
  await new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function asyncPrint(value, ms) {
  await timeout(ms);
  console.log(value);
}

asyncPrint('hello world', 50);

上面代码指定 50 毫秒以后,输出hello world。


时倾
794 声望2.4k 粉丝

把梦想放在心中