深入前端-彻底搞懂JS的运行机制

0

最近看了很多关于JS运行机制的文章,每篇都获益匪浅,但各有不同,所以在这里对这几篇文章里说的很精辟的地方做一个总结,参考文章链接见最后。本文博客地址

了解进程和线程

  • 进程是应用程序的执行实例,每一个进程都是由私有的虚拟地址空间、代码、数据和其它系统资源所组成;进程在运行过程中能够申请创建和使用系统资源(如- 独立的内存区域等),这些资源也会随着进程的终止而被销毁。
  • 而线程则是进程内的一个独立执行单元,在不同的线程之间是可以共享进程资源的,所以在多线程的情况下,需要特别注意对临界资源的访问控制。
  • 在系统创建进程之后就开始启动执行进程的主线程,而进程的生命周期和这个主线程的生命周期一致,主线程的退出也就意味着进程的终止和销毁。
  • 主线程是由系统进程所创建的,同时用户也可以自主创建其它线程,这一系列的线程都会并发地运行于同一个进程中。

浏览器是多进程的

详情看我上篇总结浏览器执行机制的文章-深入前端-彻底搞懂浏览器运行机制
  • 浏览器每打开一个标签页,就相当于创建了一个独立的浏览器进程。
  • Browser进程:浏览器的主进程(负责协调、主控),只有一个。作用有
  • 第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建
  • GPU进程:最多一个,用于3D绘制等
  • 浏览器渲染进程(浏览器内核)

javascript是一门单线程语言

  • jS运行在浏览器中,是单线程的,但每个tab标签页都是一个进程,都含有不同JS线程分别执行,,一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序
  • 既然是单线程的,在某个特定的时刻只有特定的代码能够被执行,并阻塞其它的代码。而浏览器是事件驱动的(Event driven),浏览器中很多行为是异步(Asynchronized)的,会创建事件并放入执行队列中。javascript引擎是单线程处理它的任务队列,你可以理解成就是普通函数和回调函数构成的队列。当异步事件发生时,如(鼠标点击事件发生、定时器触发事件发生、XMLHttpRequest完成回调触发等),将他们放入执行队列,等待当前代码执行完成。
  • javascript引擎是基于事件驱动单线程执行的,JS引擎一直等待着任务队列中任务的到来,然后加以处理,浏览器无论什么时候都只有一个JS线程在运行JS程序。所以一切javascript版的"多线程"都是用单线程模拟出来的
  • 为什么JavaScript是单线程?与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

任务队列

  • "任务队列"是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。
  • "任务队列"中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等),ajax请求等。只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。
  • 所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
  • "任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。但是,由于存在后文提到的"定时器"功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。

同步和异步任务

既然js是单线程,那么问题来了,某一些非常耗时间的任务就会导致阻塞,难道必须等前面的任务一步一步执行玩吗?
比如我再排队就餐,前面很长的队列,我一直在那里等岂不是很傻逼,说以就会有排号系统产生,我们订餐后给我们一个号码,叫到号码直接去就行了,没交我们之前我们可以去干其他的事情。
因此聪明的程序员将任务分为两类:

  • 同步任务:同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
  • 异步任务:异步任务指的是,不进入主线程、而进入"任务队列"(Event queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

任务有更精细的定义:

  • macro-task(宏任务):包括整体代码script(同步宏任务),setTimeout、setInterval(异步宏任务)
  • micro-task(微任务):Promise,process.nextTick,ajax请求(异步微任务)

macrotask(又称之为宏任务)

可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)
每一个task会从头到尾将这个任务执行完毕,不会执行其它
浏览器为了能够使得JS内部task与DOM任务能够有序的执行,会在一个task执行结束后,在下一个 task 执行开始前,对页面进行重新渲染
(task->渲染->task->...)

microtask(又称为微任务),可以理解是在当前 task 执行结束后立即执行的任务

也就是说,在当前task任务后,下一个task之前,在渲染之前
所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染
也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)

执行机制与事件循环

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

那怎么知道主线程执行栈为执行完毕?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。

第一轮事件循环:
主线程执行js整段代码(宏任务),将ajax、setTimeout、promise等回调函数注册到Event Queue,并区分宏任务和微任务。
主线程提取并执行Event Queue 中的ajax、promise等所有微任务,并注册微任务中的异步任务到Event Queue。
第二轮事件循环:
主线程提取Event Queue 中的第一个宏任务(通常是setTimeout)。
主线程执行setTimeout宏任务,并注册setTimeout代码中的异步任务到Event Queue(如果有)。
执行Event Queue中的所有微任务,并注册微任务中的异步任务到Event Queue(如果有)。
类似的循环:宏任务每执行完一个,就清空一次事件队列中的微任务。

注意:事件队列中分“宏任务队列”和“微任务队列”,每执行一次任务都可能注册新的宏任务或微任务到相应的任务队列中,只要遵循“每执行一个宏任务,就会清空一次事件队列中的所有微任务”这一循环规则,就不会弄乱。

说了那么多来点实例吧

ajax普通异步请求实例

let data = [];
$.ajax({
    url:www.javascript.com,
    data:data,
    success:() => {
        console.log('发送成功!');
    }
})
console.log('代码执行结束');

1.执行整个代码,遇到ajax异步操作
2.ajax进入Event Table,注册回调函数success。
3.执行console.log('代码执行结束')。
4.执行ajax异步操作
5.ajax事件完成,回调函数success进入Event Queue。
5.主线程从Event Queue读取回调函数success并执行。

普通微任务宏任务实例

setTimeout(function(){
    console.log('定时器开始啦')
});

new Promise(function(resolve){
    console.log('马上执行for循环啦');
    for(var i = 0; i < 10000; i++){
        i == 99 && resolve();
    }
}).then(function(){
    console.log('执行then函数啦')
});

console.log('代码执行结束');

1.整段代码作为宏任务执行,遇到setTimeout宏任务分配到宏任务Event Queue中
2.遇到promise内部为同步方法直接执行-“马上执行for循环啦”
3.注册then回调到Eventqueen
4.主代码宏任务执行完毕-“代码执行结束”
5.主代码宏任务结束被monitoring process进程监听到,主任务执行Event Queue的微任务
6.微任务执行完毕-“执行then函数啦”
7.执行宏任务console.log('定时器开始啦')

地狱模式:promise和settimeout事件循环实例

console.log('1');
// 1 6 7 2 4 5 9 10 11 8 3
// 记作 set1
setTimeout(function () {
    console.log('2');
    // set4
    setTimeout(function() {
        console.log('3');
    });
    // pro2
    new Promise(function (resolve) {
        console.log('4');
        resolve();
    }).then(function () {
        console.log('5')
    })
})

// 记作 pro1
new Promise(function (resolve) {
    console.log('6');
    resolve();
}).then(function () {
    console.log('7');
    // set3
    setTimeout(function() {
        console.log('8');
    });
})

// 记作 set2
setTimeout(function () {
    console.log('9');
    // 记作 pro3
    new Promise(function (resolve) {
        console.log('10');
        resolve();
    }).then(function () {
        console.log('11');
    })
})

第一轮事件循环

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

2.遇到set1,其回调函数被分发到宏任务Event Queue中。

3.遇到pro1,new Promise直接执行,输出6。then被分发到微任务Event Queue中。

4.遇到了set2,其回调函数被分发到宏任务Event Queue中。

  1. 主线程的整段js代码(宏任务)执行完,开始清空所有微任务;主线程执行微任务pro1,输出7;遇到set3,注册回调函数。

第二轮事件循环

1.主线程执行队列中第一个宏任务set1,输出2;代码中遇到了set4,注册回调;又遇到了pro2,new promise()直接执行输出4,并注册回调;

2.set1宏任务执行完毕,开始清空微任务,主线程执行微任务pro2,输出5。

第三轮事件循环

1.主线程执行队列中第一个宏任务set2,输出9;代码中遇到了pro3,new promise()直接输出10,并注册回调;

2.set2宏任务执行完毕,开始情况微任务,主线程执行微任务pro3,输出11。

类似循环...

所以最后输出结果为1、6、7、2、4、5、9、10、11、8、3。

参考文章

你可能感兴趣的

载入中...