一次性搞懂JvaScript执行机制



你是否遭受到这样的恐吓?

你是否有过每个表达式前面都console一遍值去找执行顺序?

看了很多js执行机制的文章似乎都是似懂非懂,到技术面问的时候,理不清思绪。总结了众多文章的例子和精华,希望能帮到你们

JavaScript 怎么执行的?

执行机制——事件循环(Event Loop)

通常所说的 JavaScript Engine(JS引擎)负责执行一个个 chunk (可以理解为事件块)的程序,每个 chunk 通常是以 function 为单位,一个 chunk 执行完成后,才会执行下一个 chunk。下一个 chunk 是什么呢?取决于当前 Event Loop Queue (事件循环队列)中的队首。

通常听到的JavaScript EngineJavaScript runtime 是什么?

Javascript Engine :Js引擎,负责解释并编译代码,让它变成能交给机器运行的代码(runnable commands
Javascript runtime :Js运行环境,主要提供一些对外调用的接口 。比如浏览器环境:windowDOM。还有Node.js环境:requireexport
Event Loop Queue (事件循环队列)中存放的都是消息,每个消息关联着一个函数,JavaScript Engine (以下简称JS引擎)就按照队列中的消息顺序执行它们,也就是执行 chunk

例如

setTimeout( function() {
console.log('timeout')
}, 1000)

当JS引擎执行的时候,可以分为3步chunk

setTimeout 启动定时器(1000毫秒)执行
1.执行完毕后,得到机会将 callback 放入 Event Loop Queue
2.此 callback 执行
3.每一步都是一个chunk,可以发现,第2步,得到机会很重要,所以说即使延迟1000ms也不一定准的原因。因为如果有其他任务在前面,它至少要等其他消息对应的程序都完成后才能将callback推入队列,后面我们会举个🌰

像这个一个一个执行chunk的过程就叫做Event Loop(事件循环)。

按照阮老师的说法:

总体角度:主线程执行的时候产生栈(stack)和堆(heap),栈中的代码负责调用各种API,在任务队列中加入事件(click,load,done),只要栈中的代码执行完毕后,就会去读取任务队列,依次执行那些事件所对应的回调函数。
执行的机制流程

同步直接进入主线程执行,如果是异步的,不进入主线程、而进入”任务队列”(task queue)的任务,只有”任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。主线程从”任务队列”中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。

我们都知道,JS引擎 对 JavaScript 程序的执行是单线程的,为了防止同时去操作一个数据造成冲突或者是无法判断,但是 JavaScript Runtime(整个运行环境)并不是单线程的;而且几乎所有的异步任务都是并发的,例如多个 Job QueueAjax、Timer、I/O(Node)等等。

Node.js会略有不同,在node.js启动时,创建了一个类似while(true)的循环体,每次执行一次循环体称为一次tick,每个tick的过程就是查看是否有事件等待处理,如果有,则取出事件极其相关的回调函数并执行,然后执行下一次tick。nodeEvent Loop和浏览器有所不同。Event Loop每次轮询:先执行完主代码,期中遇到异步代码会交给对应的队列,然后先执行完所有nextTick(),然后在执行其它所有微任务。

任务队列

任务队列task queue中有微任务队列和宏任务队列

1.微任务队列只有一个
2.宏任务可以有若干个
根据目前,我们先大概画个草图

具体部分后面会讲,那先说说同步和异步

执行机制——同步任务(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里的异步任务,也就是执行对应的回调函数。
指定的事情可以是setTimeouttime🌰

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队列里的任务。除非主线任务执行完毕。所有我们通常说的setTimeouttime是不标准的,准确的说,应该是大于等于这个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身上

前面我们说过,异步任务必须有callbackajaxcallbacksuccess(),也就是只有当请求成功后,触发了对应的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 QueueES6新增的概念。

Job QueueEvent Loop Queue有什么区别?

JavaScript runtimeJS运行环境)可以有多个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()执行,这次没有回调
继续到微任务队首拿回调执行,重复轮询打印出B2B3
微任务执行完毕,到宏任务队首取出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慢执行,有一点——需要了解setImmediatenextTick的区别。nextTick虽然异步执行,但是不会给其他io事件执行的任何机会,而setImmediate是执行于下一个event loop。总之process.nextTick()的优先级高于setImmediate

setImmediate(callback)
MutationObserver
一定发生在setTimeout之前,你可以把它看成是setImmediateMutationObserver是一个构造器,接受一个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是要等待下一次事件轮询,也就是本次结束后执行,所以需要画???

没有把PromiseJob 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 QueueJS引擎当前无chunk,直接进入主线程执行,打印出7
触发resolve(),将then 8 放入微任务队列等待主线程执行,继续往下走
遇到setTimeout,执行完毕,将setTimeout 9callback 其放入宏任务队列
遇到setImmediate,将其callback放入Event Table,等待下一轮Event Loop执行
第一遍完毕 17

当前队列

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
第二遍轮询完毕,打印出了 68911101213

当前没有任务了,过了大概1000ms,之前的setTimeout 延迟执行完毕了,放入宏任务

setTimeout进入主线程开始执行。
遇到同步任务,直接执行,打印出2
遇到process.nextTickcallback放入Event Queue,等待同步任务执行完毕
遇到Promisecallback放入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方法

-------------本文结束感谢您的阅读-------------

本文标题:一次性搞懂JvaScript执行机制

文章作者:Seven

发布时间:2018年07月24日 - 22:07

最后更新:2018年11月06日 - 13:11

原始链接:http://www.yuanziwen.cn/2018/07/24/一次性搞懂JvaScript执行机制/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。