前言:
这段时间在学习Vue的同时,又将JavaScript异步方面的知识又复习了一遍,前前后后也看了不少的文章,但感觉有点混乱,对异步也没有一个相对体系化的认识。因此准备写几篇文章来好好的来理清一下思路,如有错误疏漏请指出,不胜感激!
单线程的JavaScript
大家在初学JavaScript的时候应该或多或少都知道JavaScript是一门单线程的弱类型语言。而JavaScript之所以设计成单线程其实与它的用途有关:由于JavaScript的早期的主要用途是与用户交互以及操作DOM,因此如果设计成多线程并行就会带来很多复杂和不可控的同步问题,比如当两个不同的线程一个是要往一个节点添加内容,另一个则是要删除这个节点,这时浏览器就懵逼了。
由于JavaScript是单线程的,这就意味着,JavaScript中所有的任务都需要排队依次执行。这样说起来很简单,但很多时候我们在写程序的时候就可能会意识到一个问题,那就是程序中将来执行的代码并不一定是在现在运行的代码执行完之后就立即执行。比如这样:
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设计者就将所有任务分以下两种任务以解决这个问题:
- 同步任务(在主线程中,只有前面的代码执行完毕后,后面的才能执行)
- 异步任务(从主线程提出来,异步执行,当执行完毕后在任务队列中放入一个事件,等主线程的任务执行完毕后再从任务队列中读取该任务的事件,并执行该任务)
主线程与任务队列的示意图如下(转自阮一峰老师的JavaScript 运行机制详解:再谈Event Loop):
image事件循环(Event Loop)
前面我们提到,当JavaScript在异步任务完成后会通知主线程该任务可以执行了,那么又是如何 通知的呢?其实用一句话就可以去描述这个过程:
工作线程将信息放到任务队列中,主线程则通事件循环过程去读取完成的消息。
让我们再来看这段代码:
console.log(1);
setTimeout(function() {
console.log(2);
},1000);
console.log(3);
// 1 3 2
其实它会经历一下几个步骤:
- 打印 1
- 调用 setTimeout,发现是一个异步任务,从主线程中提出,进行异步运行。
- 打印 3
- 异步任务运行完毕,工作线程在任务队列中放置一个事件。
- 主线程所有同步事件执行完毕后,通过事件循环读取事件,然后将异步任务放入主线程最后端执行。
- 打印 2
总结起来,JavaScript的代码执行机制其实就三点:
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
- 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
- 一旦主线程的栈中的所有同步任务执行完毕,系统就会读取任务队列,选择需要首先执行的任务然后执行。
实际上,简单来说,主线程在第三步就是从任务队列里面取事件、执行事件,执行完毕;再取事件、再执行事件...这样不断取事件、执行事件的循环机制就叫做事件循环机制。(需要注意的是,当任务队列为空时,就会等待,直到任务队列变成非空。)其基本逻辑如下:
while (queue.waitForMessage()) {
queue.processNextMessage();
}
常见的异步事件
在我们的日常开发中,比较常见的异步事件主要是以下三种:
- DOM操作(在用户执行操作后进入任务队列)
- 网络请求(在网络响应后进入任务队列)
- 定时器(在规定时间到达后进入任务队列)
现在让我们在看看具体的实例吧:
DOM操作
console.log(1);
document.getElementById('btn').addEventListener('click',function() {
console.log(2);
});
console.log(3);
//1
//3
//点击后
//2
上面的代码很容易理解,先后打印1和3。当用户进行点击后才会执行异步任务,打印出2。
网络请求
而在网络请求方面,通常我们会遇到以下两种情况:
- 进行ajax请求
- 动态<img>加载
先让我们来看看ajax请求:
console.log('1');
$.get('./wozeishuai.json',function(data) {
console.log(data);
});
console.log(2);
// 1
// 2
// ?
首先两个同步任务会被依次执行,打印出1和2。而data的打印则会视情况而定,如果ajax请求成功,那么data就会在2后面被打印出来;但如果请求不成功,data就不会被打印出来。
还有就是动态的<img>的加载所产生的异步问题,在这方面我们可能会遇到这种情况:
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的加载问题,通常我们还有可能遇到这种情况:
document.getElementsByTagNames('img')[0].width
乍看起来这段代码并没有什么问题,好像并没有存在异步的问题,一切都应该是你期望的那样进行着。但当你执行时,却惊讶的发现,取得的竟然width是0!然后整个人都不好了,心态崩了,怎么也想不明白为什么会是这样(没错就是刚学编程的我)。其实这个问题也很好理解,因为<img>的加载是需要时间的,因此会被浏览器归入异步任务之中,而这条语句是同步语句,会被主线程依次执行,当这条语句执行完毕后,img才会被加载完成进入主线程,所以我们不能取得正确的width。遇到这种情况,我们可以改写代码,使其能够取到正确的width:
document.getElementsByTagNames('img')[0].onload = function(){
console.log(this.width); //打印width
};
定时器
定时器我们在上面的示例中已经有提到了,就是这个:
console.log(1);
setTimeout(function() {
console.log(2);
},1000);
console.log(3);
// 1 3 2
对于定时器,主要有以下三个用处:
- 让浏览器渲染当前的变化(很多浏览器UI渲染和JavaScript执行是放在一个线程中,当线程阻塞时会导致界面无法更新渲染)。
- 重新评估”script is running too long”的警告。
- 改变代码的执行顺序。
还有一点我们得要额外注意。那就是当在零延迟调用 setTimeout 时,它并不会是真正的零延迟,它的调用取决于队列里正在等待的消息数量。
(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是不会出现阻塞的。但凡事都有例外,比如这样:
console.log(1);
alert('hello,world');
console.log(2);
执行上面的代码的时候,它并不会依次执行下去,而是先打印1,然后跳出一个弹窗,只有当你点击确定之后,才会执行后面的代码打印2出来。具有这种阻塞效果的有alert之类的弹窗和同步XHR,这需要在实践时额外注意,以避免出现阻塞的问题。
网友评论