看了很多js执行机制的文章似乎都是似懂非懂,到技术面问的时候,理不清思绪。总结了众多文章的例子和精华,希望能帮到你们
JavaScript 怎么执行的?
执行机制——事件循环(Event Loop)
通常所说的JavaScript Engine
(JS引擎)负责执行一个个 chunk
(可以理解为事件块
)的程序,每个 chunk
通常是以 function
为单位,一个 chunk
执行完成后,才会执行下一个 chunk
。下一个 chunk
是什么呢?取决于当前 Event Loop Queue
(事件循环队列)中的队首。
通常听到的JavaScript Engine
和JavaScript runtime
是什么?
- Javascript Engine :Js引擎,负责解释并编译代码,让它变成能交给机器运行的代码(runnable commands)
- Javascript runtime :Js运行环境,主要提供一些对外调用的接口 。比如浏览器环境:
window
、DOM
。还有Node.js环境:require
、export
Event Loop Queue
(事件循环队列)中存放的都是消息,每个消息关联着一个函数,JavaScript Engine
(以下简称JS引擎)就按照队列中的消息顺序执行它们,也就是执行 chunk
。
例如
setTimeout( function() {
console.log('timeout')
}, 1000)
当JS引擎执行的时候,可以分为3步chunk
- 由
setTimeout
启动定时器(1000毫秒)执行 - 执行完毕后,得到机会将
callback
放入Event Loop Queue
- 此 callback 执行
每一步都是一个chunk
,可以发现,第2步,得到机会很重要,所以说即使延迟1000ms也不一定准的原因。因为如果有其他任务在前面,它至少要等其他消息对应的程序都完成后才能将callback
推入队列,后面我们会举个🌰
像这个一个一个执行chunk
的过程就叫做Event Loop(事件循环)
。
按照阮老师的说法:
总体角度:主线程执行的时候产生栈(stack)和堆(heap),栈中的代码负责调用各种API,在任务队列中加入事件(click,load,done),只要栈中的代码执行完毕后,就会去读取任务队列,依次执行那些事件所对应的回调函数。
执行的机制流程
同步直接进入主线程执行,如果是异步的,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。
我们都知道,JS引擎 对 JavaScript
程序的执行是单线程的,为了防止同时去操作一个数据造成冲突或者是无法判断,但是 JavaScript Runtime
(整个运行环境)并不是单线程的;而且几乎所有的异步任务都是并发的,例如多个 Job Queue
、Ajax
、Timer
、I/O(Node)
等等。
而Node.js会略有不同,在node.js
启动时,创建了一个类似while(true)
的循环体,每次执行一次循环体称为一次tick
,每个tick
的过程就是查看是否有事件等待处理,如果有,则取出事件极其相关的回调函数并执行,然后执行下一次tick
。node的Event Loop
和浏览器有所不同。Event Loop
每次轮询:先执行完主代码,期中遇到异步代码会交给对应的队列,然后先执行完所有nextTick(),然后在执行其它所有微任务。
任务队列
任务队列task queue
中有微任务队列
和宏任务队列
- 微任务队列只有一个
- 宏任务可以有若干个
根据目前,我们先大概画个草图
具体部分后面会讲,那先说说同步和异步
执行机制——同步任务(synchronous)和异步任务(asynchronous)
事件分为同步和异步
同步任务
同步任务直接进入主线程进行执行
console.log('1');
var sub = 0;
for(var i = 0;i < 1000000000; i++) {
sub++
}
console.log(sub);
console.log('2');
.....
会点编程的都知道,在打印出sub
的值之前,系统是不会打印出2
的。按照先进先出的顺序执行chunk。
如果是Execution Context Stack(执行上下文堆栈)
function log(str) {
console.log(str);
}
log('a');
从执行顺序上,首先log('a')
入栈,然后console.log('a')
再入栈,执行console.log('a')
出栈,log('a')
再出栈。
异步任务
异步任务必须指定回调函数,所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务进入
Event Table
后,当指定的事情完成了,就将异步任务加入Event Queue
,等待主线程上的任务完成后,就执行Event Queue里的异步任务,也就是执行对应的回调函数。
指定的事情可以是setTimeout的time🌰
var value = 1;
setTimeout(function(){
value = 2;
}, 0)
console.log(value); // 1
从这个例子很容易理解,即使设置时间再短,setTimeout
还是要等主线程执行完再执行,导致引用还是最初的value
值
🌰
console.log('task1');
setTimeout(()=>{ console.log('task2') },0);
var sub = 0;
for(var i = 0;i < 1000000000;i++) {
sub++
}
console.log(sub);
console.log('task3');
分析一下
- task1进入主线程立即执行
- task2进入
Event Table
,注册完事件setTimeout
后进入Event Queue
,等待主线程执行完毕 - sub赋值后进入for循环自增,主线程一直被占用
- 计算完毕后打印出sub,主线程继续chunk
- task3进入主线程立即执行
- 主线程队列已清空,到Event Queue中执行任务,打印task2
不管for循环计算多久,只要主线程一直被占用,就不会执行Event Queue
队列里的任务。除非主线任务执行完毕。所有我们通常说的setTimeout
的time
是不标准的,准确的说,应该是**大于等于这个time**
来个🌰体验一下结果
var sub = 0;
(function setTime(){
let start = (new Date()).valueOf();//开始时间
console.log('执行开始',start)
setTimeout(()=>{
console.log('定时器结束',sub,(new Date()).valueOf()-start);//计算差异
},0);
})();
for(var i = 0;i < 1000000000;i++) {
sub++
}
console.log('执行结束')
实际上,延迟会远远大于预期,达到了3004毫秒
最后的计算结果是根据浏览器的运行速度和电脑配置差异而定,这也是setTimeout
最容易被坑的一点。
AJAX怎么算
那ajax怎么算,作为日常使用最多的一种异步,我们必须搞清楚它的运行机制。
console.log('start');
$.ajax({
url:'xxx.com?user=123',
success:function(res){
console.log('success')
}
})
setTimeout(() => {
console.log('timeout')
},100);
console.log('end');
答案是不肯定的,可能是
start
end
timeout
success
也有可能是
start
end
success
timeout
前两步没有疑问,都是作为同步函数执行,问题原因出在ajax身上
前面我们说过,异步任务必须有callback
,ajax的callback
是success()
,也就是只有当请求成功后,触发了对应的callback success()
才会被放入任务队列(Event Queue)等待主线程执行。而在请求结果返回的期间,后者的setTimeout
很有可能已经达到了指定的条件(执行100毫秒延时完毕
)将它的回调函数放入了任务队列等主线程执行。这时候可能ajax结果仍未返回…
Promise的执行机制
再加点料
console.log('执行开始');
setTimeout(() => {
console.log('timeout')
}, 0);
new Promise(function(resolve) {
console.log('进入')
resolve();
}).then(res => console.log('Promise执行完毕') )
console.log('执行结束');
先别继续往下看,假设你是浏览器,你会怎么运行,自我思考十秒钟
这里要注意,严格的来说,Promise 属于 Job Queue,只有then
才是异步。
Job Queue是什么
Job Queue是ES6新增的概念。
Job Queue和Event Loop Queue有什么区别?
- JavaScript runtime(JS运行环境)可以有多个Job Queue,但是只能有一个Event Loop Queue。
- JS引擎将当前chunk执行完会优先执行所有Job Queue,再去执行Event Loop Queue。
Promise 中的一个个 then
就是一种 Job Queue
。
分析流程:
- 遇到同步任务,进入主线程直接执行,打印出
"执行开始"
- 遇到
setTimeout
异步任务放入Event Table执行,满足条件后放入Event Queue的宏任务队列等待主线程执行 - 执行
Promise
,放入Job Queue
优先执行,执行同步任务打印出"进入"
- 返回
resolve()
触发then回调函数,放入Event Queue微任务队列``等待主线程执行 - 执行同步任务打印出
"执行结束"
- 主线程清空,到
Event Queue
的微任务队列
取出任务开始执行。打印出"Promise执行完毕"
- 微任务队列清空,到宏任务队列取出任务执行,打印出
"timeout"
🌰 plus
console.log("start");
setTimeout(() => {
console.log("setTimeout");
}, 0);
new Promise((resolve) => {
resolve();
})
.then(() => {
return console.log("A1");
})
.then(() => {
return console.log("A2");
});
new Promise((resolve) => {
resolve();
})
.then(() => {
return console.log("B1");
})
.then(() => {
return console.log("B2");
})
.then(() => {
return console.log("B3");
});
console.log("end");
打印结果
运用刚刚说说的,分析一遍
- setTimeout异步任务,到Event Table执行完毕后将callback放入Event Queue宏任务队列等待主线程执行
- Promise 放入Job Queue优先进入主线程执行,返回
resolve()
,触发A1 then
回调函数放入微任务队列中等待主线程执行 - 到第二个Promise,同上,放入Job Queue执行,将
B1 then
回调函数放入微任务队列 - 执行同步函数,直接进入主线程执行,打印出
"end"
- 无同步任务,开始从task Queue 也就是 Event Queue里取出异步任务开始执行
- 首先取出队首的
A1 then()
回调函数开始执行,打印出"A1"
,返回promise
触发A2 then()
回调函数,添加到微任务队首。此时队首是B1 then()
- 从微任务队首取出
B1 then
回调函数,开始执行,返回promise触发B2 then()
回调函数,添加到微任务队首,此时队首是A2 then()
,再取出A2 then()
执行,这次没有回调 - 继续到微任务队首拿回调执行,重复轮询打印出
B2
和B3
。 - 微任务执行完毕,到宏任务队首取出
setTimeout
的回调函数放入主线程执行,打印出"setTimeout"
。
这样的话,Promise应该是搞懂了,但是微任务和宏任务?很多人对这个可能有点陌生,但是看完这个应该对这两者区别有所了解
异步任务分为宏任务和微任务
宏任务(macrotasks): setTimeout, setInterval, setImmediate(node.js), I/O, UI rendering
微任务(microtasks):process.nextTick(node.js), Promises, Object.observe, MutationObserver
先看一下具有特殊性的API:
process.nextTick
node方法,process.nextTick
可以把当前任务添加到执行栈的尾部,也就是在下一次Event Loop(主线程读取"任务队列")之前执行。也就是说,它指定的任务一定会发生在所有异步任务之前。和setTimeout(fn,0)
很像。
process.nextTick(callback)
setImmediate
Node.js0.8以前是没有setImmediate的,在当前"任务队列"的尾部添加事件,官方称setImmediate
指定的回调函数,类似于setTimeout(callback,0)
,会将事件放到下一个事件循环中,所以也会比nextTick
慢执行,有一点——需要了解setImmediate
和nextTick
的区别。nextTick
虽然异步执行,但是不会给其他io事件执行的任何机会,而setImmediate
是执行于下一个event loop
。总之process.nextTick()
的优先级高于setImmediate
setImmediate(callback)
MutationObserver
一定发生在setTimeout
之前,你可以把它看成是setImmediate
。MutationObserver
是一个构造器,接受一个callback
参数,用来处理节点变化的回调函数,返回两个参数
- mutations:节点变化记录列表(sequence)
- observer:构造MutationObserver对象。
var observe = new MutationObserver(function(mutations,observer){
// code...
})
在这不说过多,可以去了解下具体用法
Object.observe
Object.observe方法用于为对象指定监视到属性修改时调用的回调函数
Object.observe(obj, function(changes){
changes.forEach(function(change) {
console.log(change,change.oldValue);
});
});
什么情况下才会触发?
- 原始JavaScript对象中的变化
- 当属性被添加、改变、或者删除时的变化
- 当数组中的元素被添加或者删除时的变化
- 对象的原型发生的变化
来个大🌰
总结:
任务优先级
同步任务` >>> `process.nextTick` >>> `微任务(ajax/callback)` >>> `setTimeout = 宏任务` ??? `setImmediate
setImmediate
是要等待下一次事件轮询,也就是本次结束后执行,所以需要画???
没有把Promise的Job Queue放进去是因为可以当成同步任务来进行处理。要明确的一点是,它是严格按照这个顺序去执行的,每次执行都会把以上的流程走一遍,都会再次轮询走一遍,然后把处理对应的规则。
拿个别人的🌰加点料,略微做一下修改,给大家分析一下
console.log('1');
setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
}, 1000); //添加了1000ms
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})
setImmediate(function(){//添加setImmediate函数
console.log('13')
})
第一遍Event Loop
- 走到
1
的时候,同步任务直接打印 - 遇到
setTimeout
,进入task 执行1000ms
延迟,此时未达到,不管它,继续往下走。 - 遇到
process.nextTick
,放入执行栈队尾(将于异步任务执行前执行)。 - 遇到
Promise
放入 Job Queue,JS引擎当前无chunk,直接进入主线程执行,打印出7
- 触发
resolve()
,将then 8
放入微任务队列等待主线程执行,继续往下走 - 遇到
setTimeout
,执行完毕,将setTimeout 9
的 callback 其放入宏任务队列 - 遇到
setImmediate
,将其callback放入Event Table,等待下一轮Event Loop执行
第一遍完毕 1
、7
当前队列
Number two Ready Go!
- 无同步任务,准备执行异步任务,JS引擎一看:“嘿!好家伙,还有个process”,然后取出
process.nextTick
的回调函数执行,打印出6
- 再继续去微任务队首取出
then 8
,打印出8
。 - 微任务队列清空了,就到宏任务队列取出
setTimeout 9 callback
执行,打印出9
- 继续往下执行,又遇到
process.nextTick 10
,放入Event Queue等待执行 - 遇到
Promise
,将callback 放入 Job Queue,当前无chunk,执行打印出11
- 触发
resolve()
,添加回调函数then 12
,放入微任务队列
本次Event Loop还没有结束,同步任务执行完毕,目前任务队列
- 再取出
process.nextTick 10
,打印出10
- 去微任务队列,取出
then 12
执行,打印出12
- 本次Event Loop轮询结束 ,取出
setImmediate
打印出13
。
第二遍轮询完毕,打印出了 6
、8
、9
、11
、10
、12
、13
当前没有任务了,过了大概1000ms
,之前的setTimeout
延迟执行完毕了,放入宏任务
-
setTimeout
进入主线程开始执行。 - 遇到同步任务,直接执行,打印出
2
- 遇到
process.nextTick
,callback放入Event Queue,等待同步任务执行完毕 - 遇到
Promise
,callback放入Job Queue,当前无chunk,进入主线程执行,打印出4
- 触发
resolve()
, 将then 5
放入微任务队列
同步执行完毕,先看下目前的队列
剩下的就很轻松了
- 取出
process.nextTick 3 callback
执行,打印出3
- 取出微任务
then 5
,打印出5
- over
总体打印顺序
1
7
6
8
9
11
10
12
13
2
4
3
5
emmm…可能需要多看几遍消化一下。
Web Worker
现在有了Web Worker
,它是一个独立的线程,但是仍未改变原有的单线程,Web Worker
只是个额外的线程,有自己的内存空间(栈、堆)以及 Event Loop Queue
。要与这样的不同的线程通信,只能通过 postMessage
。一次 postMessage
就是在另一个线程的 Event Loop Queue
中加入一条消息。说到postMessage
可能有些人会联想到Service Work
,但是他们是两个截然不同
Web Worker和Service Worker的区别
Service Worker:
处理网络请求的后台服务。完美的离线情况下后台同步或推送通知的处理方案。不能直接与DOM交互。通信(页面和Service Worker之间)得通过postMessage
方法 ,有另一篇文章是关于本地储存,其中运用到页面离线访问Service Work of Google PWA,有兴趣的可以看下
Web Worker:
模仿多线程,允许复杂的脚本在后台运行,所以它们不会阻止其他脚本的运行。是保持您的UI响应的同时也执行处理器密集型功能的完美解决方案。不能直接与DOM交互。通信必须通过postMessage
方法