JS 执行机制 详解(附例题)

事件循环(Event Loop)初识

JS分为 同步任务异步任务

同步任务都在主线程(这里的主线程就是JS引擎线程)上执行,会形成一个执行栈

主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放一个事件回调

一旦执行栈中的所有同步任务执行完毕(也就是JS引擎线程空闲了),系统就会读取任务队列,将可运行的异步任务(任务队列中的事件回调,只要任务队列中有事件回调,就说明可以执行)添加到执行栈中,开始执行

有关线程问题,回顾:JS(单线程)与浏览器(多进程)

事件循环图解(初始):

在这里插入图片描述

  • 首先,执行栈开始顺序执行
  • 判断是否为同步,异步则进入异步线程,最终事件回调给事件触发线程的任务队列等待执行,同步继续执行
  • 执行栈为空,询问任务队列中是否有事件回调
  • 任务队列中有事件回调则把回调加入执行栈末尾继续从第一步开始执行
  • 任务队列中没有事件回调则不停发起询问

代码示例:

let setTimeoutCallBack = function() {
  console.log('我是定时器回调');
};
let httpCallback = function() {
  console.log('我是http请求回调');
}

// 同步任务
console.log('我是同步任务1');

// 异步定时任务
setTimeout(setTimeoutCallBack,1000);

// 异步http请求任务
ajax.get('/info',httpCallback);

// 同步任务
console.log('我是同步任务2');

上述代码执行过程:

JS是按照顺序从上往下依次执行的,可以先理解为这段代码时的执行环境就是主线程,也就是当前执行栈

  • 首先,执行console.log(‘我是同步任务1’)
  • 接着,执行到setTimeout时,会移交给定时器线程,通知定时器线程 1s 后将setTimeoutCallBack 这个回调交给事件触发线程处理,在 1s 后事件触发线程会收到 setTimeoutCallBack 这个回调并把它加入到事件触发线程所管理的事件队列中等待执行
  • 接着,执行http请求,会移交给异步http请求线程发送网络请求,请求成功后将 httpCallback 这个回调交由事件触发线程处理,事件触发线程收到 httpCallback 这个回调后把它加入到事件触发线程所管理的事件队列中等待执行
  • 再接着执行console.log(‘我是同步任务2’)
  • 至此主线程执行栈中执行完毕,JS引擎线程已经空闲,开始向事件触发线程发起询问,询问事件触发线程的事件队列中是否有需要执行的回调函数,如果有将事件队列中的回调事件加入执行栈中,开始执行回调,如果事件队列中没有回调,JS引擎线程会一直发起询问,直到有为止

由此可以看出,浏览器上的所有线程的工作都很单一且独立,非常符合单一原则

  • 定时触发线程 只管理定时器且只关注定时不关心结果,定时结束就把回调扔给事件触发线程
  • 异步http请求线程 只管理http请求同样不关心结果,请求结束把回调扔给事件触发线程
  • 事件触发线程 只关心异步回调入事件队列
  • JS引擎线程 只会执行执行栈中的事件,执行栈中的代码执行完毕,就会读取事件队列中的事件并添加到执行栈中继续执行,这样反反复复就是我们所谓的事件循环(Event Loop)

但是这样的事件循环是不够详细的,请接着向下看。


任务队列

它是一种数据结构,存放要执行的任务。然后事件循环系统再以先进先出原则按顺序执行队列中的任务。产生新任务时IO线程就将任务添加在队列尾部,要执行任务渲染主线程就会循环地从队列头部取出执行
在这里插入图片描述
可任务队列里的任务类型太多了,而且是多个线程操作同一个任务队列,比如鼠标滚动、点击、移动、输入、计时器、WebSocket、文件读写、解析DOM、计算样式、计算布局、JS执行…

所以为了处理高优先级的任务,和解决单任务执行过长的问题,所以需要将任务划分

浏览器页面是由 任务队列事件循环 系统来驱动的,但是队列要一个一个执行,如果某个任务(http请求)是个耗时任务,那浏览器总不能一直卡着,所以为了防止主线程阻塞,JavaScript 又分为同步任务和异步任务

  • 同步任务:就是任务一个一个执行,如果某个任务执行时间过长,后面的就只能一直等下去
  • 异步任务:就是进程在执行某个任务时,该任务需要等一段时间才能返回,这时候就把这个任务放到专门处理异步任务的模块,然后继续往下执行,不会因为这个任务而阻塞
  • 常见异步任务:定时器、ajax、事件绑定、回调函数、async await、promise

宏任务与微任务

1、宏任务(macrotask)

在ECMAScript中,macrotask也被称为task

每次 执行栈 执行的代码可以当做是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行), 每一个宏任务会从头到尾执行完毕,不会执行其他

由于JS引擎线程GUI渲染线程是互斥的关系,浏览器为了能够使 宏任务DOM任务 有序的进行,会在一个宏任务执行结果后,在下一个宏任务执行前,GUI渲染线程开始工作,对页面进行渲染。

宏任务 -> GUI渲染 -> 宏任务 -> ...

常见的宏任务:

  • 主代码块
  • setTimeout
  • setInterval
  • setImmediate ()-Node
  • requestAnimationFrame ()-浏览器
  • 渲染事件、请求、script、I/O等

2、微任务(microtask)

ES6新引入了Promise标准,同时浏览器实现上多了一个microtask微任务概念,在ECMAScript中,microtask也被称为jobs

知道宏任务结束后,会执行渲染,然后执行下一个宏任务, 而微任务可以理解成 在当前宏任务执行后立即执行的任务

当一个宏任务执行完,会在渲染前,将执行期间所产生的所有微任务都执行完。

宏任务 -> 微任务 -> GUI渲染 -> 宏任务 -> ...

常见微任务:

  • process.nextTick ()-Node
  • Promise.then()
  • catch finally
  • Object.observe
  • MutationObserver-监听DOM

代码示例:

document.body.style = 'background:blue'
console.log(1);
Promise.resolve().then(()=>{
    console.log(2);
    document.body.style = 'background:pink'
});
console.log(3);

最终结果为:控制台输出 1 3 2,浏览器页面背景直接变为粉色

  • 控制台输出 1 3 2 , 是因为 promise 对象的 then 方法的回调函数是异步执行,所以 2 最后输出
  • 而页面的背景色直接变成粉色,没有经过蓝色的阶段,是因为在宏任务中将背景设置为蓝色,但在进行渲染前执行了微任务,在微任务中将背景变成了粉色,然后才执行的渲染

图解:
在这里插入图片描述

  • 首先执行一个宏任务,执行结束后判断是否存在微任务
  • 有微任务先执行所有的微任务,再渲染,没有微任务则直接渲染
  • 然后再接着执行下一个宏任务

3、微任务宏任务注意点:

1)浏览器会先执行一个宏任务,紧接着执行当前执行栈产生的微任务,再进行渲染,然后再执行下一个宏任务

2)微任务和宏任务不在一个任务队列,不在一个任务队列

例如:setTimeout是一个宏任务,它的事件回调在宏任务队列,Promise.then()是一个微任务,它的事件回调在微任务队列,二者并不是一个任务队列

3)消息队列中的任务都是宏任务:以Chrome 为例,有关渲染的都是在渲染进程中执行,渲染进程中的任务(DOM树构建,JS解析…等)需要主线程执行的任务都会在主线程中执行,而浏览器维护了一套事件循环机制,主线程上的任务都会放到消息队列中执行,主线程会循环消息队列,并从头部取出任务进行执行,如果执行过程中产生其他任务需要主线程执行的,渲染进程中的其他线程会把该任务塞入到消息队列的尾部,消息队列中的任务都是宏任务

4)微任务的产生:当执行到script脚本的时候,JS引擎会为全局创建一个执行上下文,在该执行上下文中维护了一个微任务队列,当遇到微任务,就会把微任务回调放在微任务队列中,当所有的JS代码执行完毕,在退出全局上下文之前引擎会去检查该队列,有回调就执行,没有就退出执行上下文,这也就是为什么 微任务要早于宏任务,也是大家常说的,每个宏任务都有一个微任务队列(由于定时器是浏览器的API,所以定时器是宏任务,在JS中遇到定时器会也是放入到浏览器的队列中)

举例子:

你和一个大爷在银行办业务,大爷排在你前面,大爷是要存钱,存完钱之后,工作人员问大爷还要不要办理其他业务,大爷说那我再改个密码吧,这时候总不能让大爷到队伍最后去排队再来改密码吧

这里面大爷要办业务就是一个宏任务,而在钱存完了又想改密码,这就产生了一个微任务,大爷还想办其他业务就又产生新微任务,直到所有微任务执行完,队伍的下一个人再来

这个队伍就是任务队列,工作人员就是单线程的JS引擎,排队的人只能一个一个来让他给你办事

也就是说当前宏任务里的微任务全部执行完,才会执行下一个宏任务


事件循环(Event Loop)完整

1、事件循环图解(完整):
在这里插入图片描述

  • 首先,整体的script(作为第一个宏任务)开始执行的时候,会把所有代码分为同步任务、异步任务两部分
  • 同步任务会直接进入主线程依次执行
  • 异步任务会再分为宏任务和微任务
  • 宏任务进入到Event Table中,并在里面注册回调函数,每当指定的事件完成时,Event Table会将这个函数移到Event Queue中
  • 微任务也会进入到另一个Event Table中,并在里面注册回调函数,每当指定的事件完成时,Event Table会将这个函数移到Event Queue中
  • 当主线程内的任务执行完毕,主线程为空时,会检查微任务的Event Queue,如果有任务,就全部执行,如果没有就执行下一个宏任务
  • 上述过程会不断重复,这就是Event Loop,比较完整的事件循环

一句话概括就是入栈到出栈的循环。即:一个宏任务,所有微任务,渲染,一个宏任务,所有微任务,渲染…


2、Promise函数
new Promise(() => {}).then() ,来看这样一个Promise代码

前面的 new Promise() 这一部分是一个构造函数,这是一个同步任务

后面的 .then() 才是一个异步微任务,这一点是非常重要的

示例1:

new Promise((resolve) => {
	console.log(1)
    resolve()
}).then(()=>{
	console.log(2)
})
console.log(3)

打印结果:1 3 2


3、async/await 函数
async/await 是 ES7 引入的重大改进的地方,可以在不阻塞主线程的情况下,使用同步代码实现异步访问资源的能力,让我们的代码逻辑更清晰

async/await本质上还是基于Promise的一些封装,而Promise是属于微任务的一种

所以在使用await关键字与Promise.then效果类似。可以理解为,await 以前的代码,相当于与 new Promise 的同步代码,await 以后的代码相当于 Promise.then的异步

示例1:

setTimeout(() => console.log(4))

async function test() {
  console.log(1)
  await Promise.resolve()
  console.log(3)
}

test()
console.log(2)

执行结果:1 2 3 4

示例2:

async function fun() {
    console.log(1)
    let a = await 2
    console.log(a)
    console.log(3)
}
console.log(4)
fun()
console.log(5)

执行结果:4 1 5 2 3

用ES6的写法:(同上)

function fun(){
    return new Promise(() => {
        console.log(1)
        Promise.resolve(2).then( a => {
            console.log(a)
            console.log(3)
        })
    })
}
console.log(4)
fun()
console.log(5)

回调是微任务,所以直接扔到微任务队列等着,自然就是最后执行

示例3:

function bar () {
    console.log(2)
}
async function fun() {
    console.log(1)
    await bar()
    console.log(3)
}
console.log(4)
fun()
console.log(5)

执行结果:4 1 2 5 3

因为await的意思就是等,等await后面的执行完。所以"await bar()",是从右向左执行,执行完bar(),然后遇到await,返回一个微任务(哪怕这任务里没东西),放到微任务队列让出主线程。


4、常见面试题:

代码示例1:

async function async1 ()  {
    console.log('async1 start');
    await  async2();
    console.log('async1 end')
}

async function  async2 ()  {
    console.log('async2')
}

console.log('script start');

setTimeout(function ()  {
    console.log('setTimeout')
},  0);

async1();

new Promise(function (resolve)  {
    console.log('promise1');
    resolve()
}).then(function ()  {
    console.log('promise2')
});

console.log('script end')

执行结果:script start——async1 start——async2——promise1——script end——async1 end——promise2——setTimeout


代码示例2:

function test() {
  console.log(1)
  setTimeout(function () { 	// timer1
    console.log(2)
  }, 1000)
}

test();

setTimeout(function () { 		// timer2
  console.log(3)
})

new Promise(function (resolve) {
  console.log(4)
  setTimeout(function () { 	// timer3
    console.log(5)
  }, 100)
  resolve()
}).then(function () {
  setTimeout(function () { 	// timer4
    console.log(6)
  }, 0)
  console.log(7)
})

console.log(8)

执行结果:1,4,8,7,3,6,5,2

执行顺序:JS是顺序从上而下执行

  • 执行到test(),test方法为同步,直接执行,console.log(1)打印1
  • test方法中setTimeout为异步宏任务,回调把它记做timer1放入宏任务队列
  • 接着执行,setTimeout为异步宏任务,回调把它记做timer2放入宏任务队列
  • 接着执行promise,new Promise是同步任务,直接执行,console.log(4)打印4
  • new Promise里面的setTimeout是异步宏任务,回调记做timer3放到宏任务队列
  • Promise.then是微任务,放到微任务队列
  • console.log(8)是同步任务,直接执行,打印8
  • 主线程任务执行完毕,检查微任务队列中有Promise.then
  • 开始执行微任务,发现有setTimeout是异步宏任务,记做timer4放到宏任务队列
  • 微任务队列中的console.log(7)是同步任务,直接执行,打印7
  • 微任务执行完毕,第一次循环结束
  • 检查宏任务队列,里面有timer1、timer2、timer3、timer4,四个定时器宏任务,按照定时器延迟时间得到可以执行的顺序,即Event Queue:timer2、timer4、timer3、timer1,依次拿出放入执行栈末尾执行 (插播一条:浏览器 event loop 的 Macrotask queue,就是宏任务队列在每次循环中只会读取一个任务)
  • 执行timer2,console.log(3)为同步任务,直接执行,打印3
  • 检查没有微任务,第二次Event Loop结束
  • 执行timer4,console.log(6)为同步任务,直接执行,打印6
  • 检查没有微任务,第三次Event Loop结束
  • 执行timer3,console.log(5)同步任务,直接执行,打印5
  • 检查没有微任务,第四次Event Loop结束
  • 执行timer1,console.log(2)同步任务,直接执行,打印2
  • 检查没有微任务,也没有宏任务,第五次Event Loop结束

代码示例3:

console.log('1');

setTimeout(function() {                    //setTimeout1
    console.log('2'); 
    process.nextTick(function() {          //process2         
        console.log('3');
    })
    new Promise(function(resolve) {
        console.log('4');
        resolve();
    }).then(function() {                   //then2
        console.log('5')
    })
})
process.nextTick(function() {             //process1
    console.log('6');
})
new Promise(function(resolve) {
    console.log('7');
    resolve();
}).then(function() {                     //then1
    console.log('8')
})

setTimeout(function() {                //setTimeout2
    console.log('9');
    process.nextTick(function() {     
        console.log('10');
    })
    new Promise(function(resolve) {     //process3
        console.log('11');
        resolve();
    }).then(function() {               //then3   
        console.log('12')
    })
})

执行结果:1,7,6,8,2,4,3,5,9,11,10,12

第一轮事件循环流程分析如下:

  • 整体script作为第一个宏任务进入主线程,遇到console.log,输出1

  • 遇到setTimeout,其回调函数被分发到宏任务Event Queue中。暂且记为setTimeout1

  • 遇到process.nextTick(),其回调函数被分发到微任务Event Queue中。记为process1

  • 遇到Promise,new Promise直接执行,输出7。then被分发到微任务Event Queue中。记为then1

  • 又遇到了setTimeout,其回调函数被分发到宏任务Event Queue中,记为setTimeout2

  • 宏任务 微任务
    setTimeout1 process1
    setTimeout2 then1

    可知,process1和then1两个微任务

  • 执行process1,输出6

  • 执行then1,输出8

第二轮时间循环从setTimeout1宏任务开始:

  • 首先输出2
  • 接下来遇到了process.nextTick(),同样将其分发到微任务Event Queue中,记为process2
  • new Promise立即执行输出4,then也分发到微任务Event Queue中,记为then2
  • 宏任务 微任务
    setTimeout2 process2
    then2

    宏任务结束,发现有process2和then2两个微任务可以执行

  • 执行process2,输出3
  • 执行then2,输出5

第三轮事件循环开始从setTimeout2宏任务开始:

  • 直接输出9
  • 将process.nextTick()分发到微任务Event Queue中。记为process3
  • 直接执行new Promise,输出11
  • 将then分发到微任务Event Queue中,记为then3
  • 宏任务 微任务
    process3
    then3

    宏任务执行结束,执行两个微任务process3和then3

  • 执行process3,输出10
  • 执行then3,输出12

5、最终图解:

在这里插入图片描述

参考优秀文章:
「硬核JS」一次搞懂JS运行机制
这一次,彻底弄懂 JavaScript 执行机制
看完还不懂JavaScript执行机制(EventLoop),你来捶我

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>