看了很多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。
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<MutationRecord>)
- 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
5emmm...可能需要多看几遍消化一下。
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方法
如果意犹未尽可以尝试去深入Promise另一篇文章——一次性让你懂async/await,解决回调地狱