阅读 1389

一次性搞懂JavaScript 执行机制

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


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


看了很多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运行环境,主要提供一些对外调用的接口 。比如浏览器环境:windowDOM。还有Node.js环境:require 、export

Event Loop Queue (事件循环队列)中存放的都是消息,每个消息关联着一个函数,JavaScript Engine (以下简称JS引擎)就按照队列中的消息顺序执行它们,也就是执行 chunk

例如

setTimeout( function() {
    console.log('timeout')
}, 1000)复制代码

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

  1. setTimeout 启动定时器(1000毫秒)执行
  2. 执行完毕后,得到机会将 callback 放入 Event Loop Queue
  3. 此 callback 执行

每一步都是一个chunk,可以发现,第2步,得到机会很重要,所以说即使延迟1000ms也不一定准的原因。因为如果有其他任务在前面,它至少要等其他消息对应的程序都完成后才能将callback推入队列,后面我们会举个🌰


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

按照阮老师的说法:

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

执行的机制流程

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

我们都知道,JS引擎 对 JavaScript 程序的执行是单线程的,为了防止同时去操作一个数据造成冲突或者是无法判断,但是 JavaScript Runtime(整个运行环境)并不是单线程的;而且几乎所有的异步任务都是并发的,例如多个 Job QueueAjaxTimerI/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队列里的任务。除非主线任务执行完毕。所有我们通常说的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身上

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

分析流程:

  1. 遇到同步任务,进入主线程直接执行,打印出"执行开始"
  2. 遇到setTimeout异步任务放入Event Table执行,满足条件后放入Event Queue的宏任务队列等待主线程执行
  3. 执行Promise,放入Job Queue优先执行,执行同步任务打印出"进入"
  4. 返回resolve()触发then回调函数,放入Event Queue微任务队列等待主线程执行
  5. 执行同步任务打印出"执行结束"
  6. 主线程清空,到Event Queue微任务队列取出任务开始执行。打印出"Promise执行完毕"
  7. 微任务队列清空,到宏任务队列取出任务执行,打印出"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是要等待下一次事件轮询,也就是本次结束后执行,所以需要画???

没有把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执行

第一遍完毕  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.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方法

如果意犹未尽可以尝试去深入Promise另一篇文章——一次性让你懂async/await,解决回调地狱


关注下面的标签,发现更多相似文章
评论