JavaScript异步编程基础

单线程的JavaScript

大佬们在初学JavaScript的时候应该或多或少都知道JavaScript是一门单线程的弱类型语言。而JavaScript之所以是单线程其实与它的用途有关:由于JavaScript的早期的主要用途是与用户互动,以及操作DOM,因此如果设计成多线程并行就会带来很多复杂和不可控的同步问题,比如当两个不同的线程一个是要往一个节点添加内容,另一个则是要删除这个节点,这时浏览器就懵逼了。

因为JavaScript是单线程的,因此这就意味着,JavaScript中所有的任务都需要排队依次完成,这样说起来很简单,但很多时候我们在写程序的时候就会意识到一个问题,那就是程序中将来执行的代码并不一定在现在运行的部分执行完之后就立即执行。比如这样:

1
2
3
4
5
6
7
console.log(1);
setTimeout(function() {
console.log(2);
},1000);
console.log(3);

// 1 3 2

看到上面的代码,很多只想着单线程的哥们可能会毫不犹豫的大喊:1 2 3!但现实总是骨感的,人生如此,爱情如此,代码也是如此。正确的打印顺序应该是1 3 2。这是由于JavaScript的设计者在设计之初就料想,单线程可能会由于运算量过大或执行耗时过长,而后面的任务却只能痴痴等待,这就会导致我们常说的IO操作(耗时但CPU处于闲置状态)。因此JavaScript设计者就将所有任务分以下两种以解决这种问题:

  1. 同步任务(在主线程中只有前面的代码执行完毕后,后面的才能执行)
  2. 异步任务(从主线程提出,进入任务队列执行,等执行完毕后通知主线程,这个异步任务可以执行了,才会进入主线程执行)

主线程与任务队列的示意图如下(转自阮一峰老师的JavaScript 运行机制详解:再谈Event Loop):

image

事件循环(Event Loop)

前面我们提到,当JavaScript在异步任务完成后会通知主线程该任务可以执行了,那么又是如何 通知的呢?其实用一句话就可以去描述这个过程了:

工作线程将信息放到任务队列中,主线程则通事件循环过程去取完成的消息。

让我们再来看这段代码:

1
2
3
4
5
6
7
console.log(1);
setTimeout(function() {
console.log(2);
},1000);
console.log(3);

// 1 3 2

其实它会经历一下几个步骤:

  1. 打印 1
  2. 调用 setTimeout,发现是一个异步任务,从主线程提出,进行异步运行。
  3. 打印 3
  4. 异步任务运行完毕,工作线程在任务队列中放置一个事件。
  5. 主线程所有同步事件执行完毕后,通过事件循环读取事件,将异步任务放入主线程最后端。
  6. 打印 2

总结起来,JavaScript的代码执行机制其实就三点:

  • 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  • 主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
  • 一旦主线程的栈中的所有同步任务执行完毕,系统就会读取任务队列,选择需要首先执行的任务然后执行。

实际上,主线程只会做一件事情,就是从任务队列里面取事件、执行事件,执行完毕;再取事件、再执行事件…这样不断取事件,执行事件的循环机制就叫做事件循环机制。(需要注意的的是当任务队列为空时,就会等待直到任务队列变成非空。)其基本逻辑如下:

1
2
3
while (queue.waitForMessage()) {
queue.processNextMessage();
}

常见的异步事件

在我们的日常开发中比较常见的异步事件主要是以下三种:

  • DOM操作(在用户执行操作后进入任务队列)
  • 网络请求(在网络响应后进入任务队列)
  • 定时器(在规定时间到达后进入任务队列)

现在让我们在看看具体的实例把:

DOM操作

1
2
3
4
5
6
7
8
9
10
console.log(1);
document.getElementById('btn').addEventListener('click',function() {
console.log(2);
});
console.log(3);

//1
//3
//点击后
//2

上面的代码很容易理解,先后打印1和3。当用户进行点击后·才会执行函数,打印2。

网络请求

网络请求方面,通常我们可以将其分为两种情况:

  • 进行ajax请求
  • 动态加载

先让我们来看看ajax请求:

1
2
3
4
5
6
7
8
console.log('1');
$.get(''./wozeishuai.json',function(data) {
console.log(data);
});
console.log(2);
// 1
// 2
// ?

首先两个同步任务会被执行,会依次打印1和2。而data则会视情况而定,如果ajax请求成功,那么就会在2后面被打印出来;但如果请求不成功,那么就不会被打印出来。

还有就是动态的的加载了,在这方面我们可能会遇到:

1
2
3
4
5
6
7
8
9
10
console.log(1);
let img = document.creatElement('img');
img.onload = function() {
console.log(2)
};
img.src = '/sky.png';
console.log(3);
//1
//3
//?

同理,当我们执行上面的代码的时候,我们首先依次打印出1,3。然后就需要等待img的加载了,这同样需要一个过程。如果加载成功就会打印出2,如果加载失败,那么2就不会被打印出来。对于img的加载问题,通常我们还有可能遇到这种情况:

1
document.getElementsByTagNames('img')[0].width

乍看起来这段代码并没有什么问题,好像并没有存在异步的问题,一切都应该是你期望的那样进行着。但当你执行时,却惊讶的发现,取得的竟然width是0!然后整个人都不好了,心态崩了,怎么也想不明白为什么会是这样(没错就是刚学编程的我)。其实这个问题也很好理解,因为的加载是需要时间的,因此会被浏览器归入异步事件之中,而这条语句是同步语句,会被主线程依次执行,而当这条语句执行完毕后,img才会被加载完成进入主线程。因此我们可以使用异步改写代码,使其能够取到正确的width:

1
2
3
document.getElementsByTagNames('img')[0].onload = function(){
console.log(this.width); //打印width
};

定时器

定时器我们在上面的示例中已经有提到了,就是这个:

1
2
3
4
5
6
7
console.log(1);
setTimeout(function() {
console.log(2);
},1000);
console.log(3);

// 1 3 2

对于定时器,主要有以下三个用处:

  • 让浏览器渲染当前的变化(很多浏览器UI渲染和JavaScript执行是放在一个线程中,当线程阻塞时会导致界面无法更新渲染)
  • 重新评估”script is running too long”警告
  • 改变代码的执行顺序

还有一点我们得要额外注意。那就是当在零延迟调用 setTimeout 时,它并不会是真正的零延迟,它的调用取决于队列里正在等待的消息数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(function() {

console.log(1);

setTimeout(function cb() {
console.log(2);
});

console.log(3);

setTimeout(function cb1() {
console.log(4);
}, 0);

console.log(5);

})();

//1 3 5 2 4

其他要点

浏览器不是单线程的

虽然JavaScript通常运行在浏览器中,且是单线程的,且每个window都有一个JavaScript线程。但浏览器并不是单线程的,例如Webkit或是Gecko引擎,都可能有如下线程:

  • javascript引擎线程
  • 浏览器UI渲染线程
  • 浏览器事件触发线程
  • HTTP请求线程

阻塞问题

因为JavaScript处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待IndexedDB查询返回或者一个 XHR 请求返回时,它仍然可以处理其它事情,所以通常来说JavaScript是不会出现阻塞的。但凡事都有例外,比如这样:

1
2
3
console.log(1);
alert('hello,world');
console.log(2)

执行上面的代码的时候,它并不会依次执行下去,而是先打印,然后跳出一个弹窗,只有当你点击确定之后,才会执行后面的代码打印2出来。具有这种阻塞效果的有alert之类的弹窗和同步XHR,这需要注意和避免。