js原理:
js引擎 运行上下文 调用栈 事件循环 回调
一. 执行栈
JS运行的环境称之为宿主环境。
执行栈:call stack,一个数据结构,用于存放各种函数的执行环境,每一个函数执行之前,它的相关信息会加入到执行栈。函数调用之前,创建执行环境,然后加入到执行栈;函数调用之后,销毁执行环境。(单线程不容易冲突,但是效率低)
浏览器执行线程
浏览器是多进程的,浏览器每一个 tab 标签都代表一个独立的进程,其中浏览器渲染进程(浏览器内核)属于浏览器多进程中的一种,主要负责页面渲染,脚本执行,事件处理等。
浏览器宿主环境中包含5个线程:
1.JS引擎:负责执行执行栈的最顶部代码(主线程)
2.GUI线程:负责渲染页面 (负责渲染页面,解析 HTML,CSS 构成 DOM 树;js线程与GUI线程会相互等待,一者工作一者等待)
3.事件监听线程:负责监听各种事件
4.计时线程:负责计时(定时器触发线程)
5.网络线程:负责网络通信(http 请求线程等主要线程)
当上面的线程发生了某些事情,如果该线程发现,这件事情有处理程序,它会将该处理程序加入一个叫做事件队列的内存。当JS引擎发现,执行栈中已经没有了任何内容后,会将事件队列中的第一个函数加入到执行栈中执行。
JS引擎对事件队列的取出执行方式,以及与宿主环境的配合,称之为事件循环。
二. 单线程
我们常说“JavaScript是单线程的”。
所谓单线程,是指在JS引擎中负责解释和执行JavaScript代码的线程只有一个。不妨叫它主线程。
但是实际上还存在其他的线程。例如:处理DOM事件的线程、定时器线程、处理AJAX请求的线程、读写文件的线程(例如在Node.js中)等等。这些线程可能存在于JS引擎之内,也可能存在于JS引擎之外,在此我们不做区分。不妨叫它们工作线程。
三. 同步和异步
假设存在一个函数A:
A(args...);
同步:如果在函数A返回的时候,调用者就能够得到预期结果(即拿到了预期的返回值或者看到了预期的效果),那么这个函数就是同步的。
同步任务都在主线程上执行,形成一个执行栈。按顺序执行
例如:
Math.sqrt(2);
console.log('Hi');
-
第一个函数返回时,就拿到了预期的返回值:2的平方根。
-
第二个函数返回时,就看到了预期的效果:在控制台打印了一个字符串。
所以这两个函数都是同步的。
异步:如果在函数A返回的时候,调用者还不能够得到预期结果,而是需要在将来通过一定的手段得到,那么这个函数就是异步的。
JS的异步是通过回调函数实现的。
例如:
fs.readFile('foo.txt', 'utf8', function(err, data) {
console.log(data);
});
在上面的代码中,我们希望通过fs.readFile
函数读取文件foo.txt中的内容,并打印出来。
但是在fs.readFile
函数返回时,我们期望的结果并不会发生,而是要等到文件全部读取完成之后。如果文件很大的话可能要很长时间。
下面以AJAX请求为例,来看一下同步和异步的区别:
-
异步AJAX:
主线程:“你好,AJAX线程。请你帮我发个HTTP请求吧,我把请求地址和参数都给你了。”
AJAX线程:“好的,主线程。我马上去发,但可能要花点儿时间呢,你可以先去忙别的。”
主线程::“谢谢,你拿到响应后告诉我一声啊。”
(接着,主线程做其他事情去了。一顿饭的时间后,它收到了响应到达的通知。)
-
同步AJAX:
主线程:“你好,AJAX线程。请你帮我发个HTTP请求吧,我把请求地址和参数都给你了。”
AJAX线程:“......”
主线程::“喂,AJAX线程,你怎么不说话?”
AJAX线程:“......”
主线程::“喂!喂喂喂!”
AJAX线程:“......”
(一炷香的时间后)
主线程::“喂!求你说句话吧!”
AJAX线程:“主线程,不好意思,我在工作的时候不能说话。你的请求已经发完了,拿到响应数据了,给你。”
正是由于JavaScript是单线程的,而异步容易实现非阻塞,所以在JavaScript中对于耗时的操作或者时间不确定的操作,使用异步就成了必然的选择。异步是这篇文章关注的重点。
一般而言,异步任务有以下三种类型:
1、普通事件,如 click、resize等
2、资源加载,如 load、error等
3、定时器,包括 setInterval、setTimeout等
四. 异步过程的构成要素
从上文可以看出,异步函数实际上很快就调用完成了。但是后面还有 工作线程执行异步任务、通知主线程、主线程调用回调函数等很多步骤。我们把整个过程叫做异步过程
。异步函数的调用在整个异步过程中,只是一小部分。
总结一下,一个异步过程通常是这样的:
主线程发起一个异步请求,相应的工作线程接收请求并告知主线程已收到(异步函数返回);主线程可以继续执行后面的代码,同时工作线程执行异步任务;工作线程完成工作后,通知主线程;主线程收到通知后,执行一定的动作(调用回调函数)。
异步函数通常具有以下的形式:
A(args..., callbackFn)
它可以叫做异步过程的发起函数,或者叫做异步任务注册函数。args
是这个函数需要的参数。callbackFn
也是这个函数的参数,但是它比较特殊所以单独列出来。
所以,从主线程的角度看,一个异步过程包括下面两个要素:
-
发起函数(或叫注册函数)
A
-
回调函数
callbackFn
它们都是在主线程上调用的,其中注册函数用来发起异步过程,回调函数用来处理结果。
举个具体的例子:
setTimeout(fn, 1000);
其中的setTimeout
就是异步过程的发起函数,fn
是回调函数。
注意:前面说的形式A(args..., callbackFn)
只是一种抽象的表示,并不代表回调函数一定要作为发起函数的参数,例如:
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = xxx; // 添加回调函数
xhr.open('GET', url);
xhr.send(); // 发起函数
发起函数和回调函数就是分离的。
五. 消息队列(任务队列)和事件循环
上文讲到,异步过程中,工作线程在异步操作完成后需要通知主线程。那么这个通知机制是怎样实现的呢?答案是利用消息队列和事件循环。
用一句话概括:
工作线程将消息放到消息队列,主线程通过事件循环过程去取消息。
-
消息队列:消息队列是一个先进先出的队列,它里面存放着各种消息。
-
事件循环:事件循环是指主线程重复从消息队列中取消息、执行的过程。
实际上,主线程上的同步任务执行完后只会做一件事情,就是不断的从消息队列里面取消息、执行消息,再取消息、再执行。当消息队列为空时,就会等待直到消息队列变成非空。而且主线程只有在将当前的消息执行完成后,才会去取下一个消息。这种机制就叫做事件循环机制,取一个消息并执行的过程叫做一次循环。
事件循环用代码表示大概是这样的:
while(true) {
var message = queue.get();
execute(message);
}
那么,消息队列中放的消息具体是什么东西?消息的具体结构当然跟具体的实现有关,但是为了简单起见,我们可以认为:
消息就是注册异步任务时添加的回调函数。
再次以异步AJAX为例,假设存在如下的代码:
$.ajax('http://segmentfault.com', function(resp) {
console.log('我是响应:', resp);
});
// 其他代码
...
...
...
主线程在发起AJAX请求后,会继续执行其他代码。AJAX线程负责请求segmentfault.com,拿到响应后,它会把响应封装成一个JavaScript对象,然后构造一条消息:
// 消息队列中的消息就长这个样子
var message = function () {
callbackFn(response);
}
其中的callbackFn
就是前面代码中得到成功响应时的回调函数。
主线程在执行完当前循环中的所有代码后,就会到消息队列取出这条消息(也就是message
函数),并执行它。到此为止,就完成了工作线程对主线程的通知
,回调函数也就得到了执行。如果一开始主线程就没有提供回调函数,AJAX线程在收到HTTP响应后,也就没必要通知主线程,从而也没必要往消息队列放消息。
同步和异步任务分别进入不同的执行环境,同步的进入主线程,即主执行栈,异步的进入消息队列。主线程内的任务执行完毕为空,会不断的去消息队列读取对应的任务,推入主线程执行。 上述过程的不断重复就是我们说的 Event Loop (事件循环)。
用图表示这个过程就是:
从上文中我们也可以得到这样一个明显的结论,就是:
异步过程的回调函数,一定不在当前这一轮事件循环中执行。
在事件循环中,每进行一次循环操作称为 tick ,通过阅读规范可知,每一次 tick 的任务处理模型是比较复杂的,其关键的步骤可以总结如下:
1.在此次 tick 中选择最先进入队列的任务( oldest task ),如果有则执行(一次)
2.检查是否存在 Microtasks ,如果存在则不停地执行,直至清空 Microtask Queue
3.更新 render
4.主线程重复执行上述步骤
可以用一张图来说明下流程:
这里相信有人会想问,什么是 microtasks ?规范中规定,task (异步)分为两大类, 分别是 Macro Task (宏任务)和 Micro Task(微任务), 并且每个宏任务结束时, 都要清空当前事件循环中所有的微任务,这里的 Macro Task也是我们常说的 task ,有些文章并没有对其做区分,后面文章中所提及的task皆看做宏任务( macro task)。
宏任务主要包含:
- <script>整体代码:整个JavaScript代码作为一个宏任务执行。
- setTimeout:用于在指定的时间间隔后执行一段代码。
- setInterval:用于每隔一定时间重复执行一段代码。
- setImmediate:在当前宏任务执行完毕后立即执行的任务,仅在Node.js环境中可用。
- Ajax:通过XMLHttpRequest或fetch发送的异步请求。
- DOM事件:例如click、mouseover等事件。
- requestAnimationFrame
- 跨窗口通信:在浏览器中,跨窗口通信(例如通过
postMessage
实现)会产生宏任务来处理通信消息。
微任务主要包含:
-
promise.then:会被添加到微任务队列中。
-
async/await
-
MutationObserver:一种可以观察
DOM
变化的API
,通过监听DOM
变化事件并注册回调函数,将回调函数加入微任务队列中。 -
process.nextTick:在当前微任务队列的末尾添加一个任务,仅在Node.js环境中可用。
-
object.observe
注意:new Promise是同步任务
setTimeout/Promise 等API便是任务源,而进入任务队列的是由他们指定的具体执行任务。来自不同任务源的任务会进入到不同的任务队列。其中 setTimeout 与 setInterval 是同源的。
为什么微任务先执行宏任务后执行(先微后宏)?
这是因为宏任务是下一个事件循环开始的宏任务,而微任务是这次事件循环就执行的微任务,他们之间隔了一个dom渲染,所以造成了微任务比宏任务更快执行的错觉。
微任务是异步的,并且在一次事件循环中只会执行一个微任务。因此,微任务执行时不会直接渲染DOM,但它可以在执行过程中更新DOM,这样的更新会在当前的事件循环结束后进行。
举例
掌握概念之后,我们来做一个例子强化一下:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
整体 script 作为第一个宏任务进入主线程,遇到 console.log,输出 script start
遇到 setTimeout,其回调函数被分发到宏任务 Event Queue 中
遇到 Promise,其 then函数被分到到微任务 Event Queue 中,记为 then1,之后又遇到了 then 函数,将其分到微任务 Event Queue 中,记为 then2
遇到 console.log,输出 script end
至此,Event Queue 中存在三个任务:宏任务:setTimeout 微任务:then1、then2
执行微任务,首先执行then1,输出 promise1, 然后执行 then2,输出 promise2,这样就清空了所有微任务
执行 setTimeout 任务,输出 setTimeout 至此,输出的顺序是:script start, script end, promise1, promise2, setTimeout
再来一个题目,来做个练习:
console.log('script start');
setTimeout(function() {
console.log('timeout1');
}, 10);
new Promise(resolve => {
console.log('promise1');
resolve();
setTimeout(() => console.log('timeout2'), 10);
}).then(function() {
console.log('then1')
})
console.log('script end');
这个题目就稍微有点复杂了,我们再分析下:
首先,事件循环从宏任务 (macrotask) 队列开始,最初始,宏任务队列中,只有一个 script(整体代码)任务;当遇到任务源 (task source) 时,则会先分发任务到对应的任务队列中去。所以,就和上面例子类似,首先遇到了console.log,输出 script start; 接着往下走,遇到 setTimeout 任务源,将其分发到任务队列中去,记为 timeout1; 接着遇到 promise,new promise 中的代码立即执行,输出 promise1, 然后执行 resolve ,遇到 setTimeout ,将其分发到任务队列中去,记为 timemout2, 将其 then 分发到微任务队列中去,记为 then1; 接着遇到 console.log 代码,直接输出 script end 接着检查微任务队列,发现有个 then1 微任务,执行,输出then1 再检查微任务队列,发现已经清空,则开始检查宏任务队列,执行 timeout1,输出 timeout1; 接着执行 timeout2,输出 timeout2 至此,所有的都队列都已清空,执行完毕。其输出的顺序依次是:script start, promise1, script end, then1, timeout1, timeout2
用流程图看更清晰:
总结
有个小 tip:从规范来看,microtask 优先于 task 执行,所以如果有需要优先执行的逻辑,放入microtask 队列会比 task 更早的被执行。
六. 异步与事件
上文中说的“事件循环”,为什么里面有个事件
呢?那是因为:
消息队列中的每条消息实际上都对应着一个事件(回调函数)。
上文中一直没有提到一类很重要的异步过程:DOM事件。
举例来说:
var button = document.getElement('#btn');
button.addEventListener('click', function(e) {
console.log();
});
从事件的角度来看,上述代码表示:在按钮上添加了一个鼠标单击事件的事件监听器;当用户点击按钮时,鼠标单击事件触发,事件监听器函数被调用。
从异步过程的角度看,addEventListener
函数就是异步过程的发起函数,事件监听器函数就是异步过程的回调函数。事件触发时,表示异步任务完成,会将事件监听器函数封装成一条消息放到消息队列中,等待主线程执行。
事件的概念实际上并不是必须的,事件机制实际上就是异步过程的通知机制。我觉得它的存在是为了编程接口对开发者更友好。
另一方面,所有的异步过程也都可以用事件来描述。例如:setTimeout
可以看成对应一个时间到了!
的事件。前文的setTimeout(fn, 1000);
可以看成:
timer.addEventListener('timeout', 1000, fn);
七. 生产者与消费者
从生产者与消费者的角度看,异步过程是这样的:
工作线程是生产者,主线程是消费者(只有一个消费者)。工作线程执行异步任务,执行完成后把对应的回调函数封装成一条消息放到消息队列中;主线程不断地从消息队列中取消息并执行,当消息队列空时主线程阻塞,直到消息队列再次非空。
八. 总结一下
最后再用一个生活中的例子总结一下同步和异步:在公路上,汽车一辆接一辆,有条不紊的运行。这时,有一辆车坏掉了。假如它停在原地进行修理,那么后面的车就会被堵住没法行驶,交通就乱套了。幸好旁边有应急车道,可以把故障车辆推到应急车道修理,而正常的车流不会受到任何影响。等车修好了,再从应急车道回到正常车道即可。唯一的影响就是,应急车道用多了,原来的车辆之间的顺序会有点乱。
这就是同步和异步的区别。同步可以保证顺序一致,但是容易导致阻塞;异步可以解决阻塞问题,但是会改变顺序性。改变顺序性其实也没有什么大不了的,只不过让程序变得稍微难理解了一些。
PS:ECMAScript 262规范中,并没有对异步、事件队列等概念及其实现的描述。这些都是具体的JavaScript运行时环境使用的机制。本文重点是描述异步过程的原理,为了便于理解做了很多简化。所以文中的某些术语的使用可能是不准确的,具体细节也未必是正确的,例如消息队列中消息的结构。请读者注意。