搞懂事件循环,面试不再害怕console输出问题。(自整)

事件循环

浏览器的进程模型

进程和线程

进程可以认为是浏览器运行占用的内存空间,线程则为每个内存空间中处理的多个任务之一

每个进程中都会默认开启一个主线程以及多个其他线程来处理不同的任务,且各个进程之间互不影响,

简单理解:浏览器为火车站,进程为通往各个地方的火车,线程则为各个火车中的车厢,主线程为火车头,火车头(主线程)停止,则该进程也相对应结束

浏览器是一个多进程多线程的应用程序

为了避免相互影响,减少崩溃的几率,启动浏览器后,浏览器会自动启动多个进程,例如网络进程(网络请求),浏览器进程(浏览器标签栏展示,浏览器内部返回,刷新等等...),渲染进程(单个浏览器tab渲染)等其他进程...,之后每个进程会开启一个主线程,开始运行内部中的代码

打开谷歌浏览器任务管理工具,可清楚看到,每个tab为一个进程,其中还包括网络进程等各种进程

渲染进程

在浏览器的进程中,渲染进程为最复杂且最重要的进程,并且也是主要运行我们前端代码的进程

渲染进程启动后,会开启一个渲染主线程,主线程负责运行HTML,CSS,Js代码。

默认情况下,浏览器会为每个tab栏标签页开启一个新的渲染进程,保证不同标签页之间不相互影响(如上图)

渲染线程工作

渲染主线程是前端中主要需要了解的线程,需要它处理的任务包括但不限于:

  • 解析 HTML

  • 解析 CSS

  • 计算样式

  • 布局

  • 处理图层

  • 每秒多次渲染页面(FPS)

  • 执行全局JS代码

  • 执行事件函数

  • 执行计时器回调函数

  • .....

主线程中如何调度任务?

例如:

  • 主线程中正在执行 JS 函数,执行到一半用户点击了按钮,该继续执行 JS 函数还是执行点击按钮事件?

  • 主线程中正在执行 JS 函数。执行到一半计时器的回调函数该执行了,该继续执行JS 函数还是执行回调函数?

  • 用户点击按钮的同时,计时器回调函数正好该执行,是该执行点击按钮事件还是回调函数?

  • ...

渲染主线程调度:排队

上面的图后续会做解释,目前看个大概就可以

  1. 在最开始时,渲染主线程会执行主线程中的任务并且进入一个无限循环(相关代码可查阅谷歌浏览器源码message_loop/message_pump_default.cc文件)

  1. 每次循环中会判断消息队列中是否有任务存在,若有任务存在,则将取出消息队列第一个任务放到主线程中执行,执行完后进入下一次循环,倘若消息队列中没有任务可以执行,则进入休眠状态。

  1. 其他所有线程(包括其他进程的线程)可以随时向消息队列新增任务,新增任务会加到消息队列中的末尾。在添加新任务的时候,如果主线程为休眠状态,则会再次唤醒主线程,重新进入循环获取任务并且执行。

以上整个过程我们就称之为 事件循环(消息循环)

疑点解决

1.不是说JS为单线程语言吗,为什么渲染进程中还会有这么多的线程?

JS从诞生之初就是单线程,那为什么是单线程呢?为了让我们这些菜鸡更容易入门?当然不是。

JS主要的用途就是操作DOM,以及与用户的交互,这就决定了他只能是单线程, 比如你这个线程创建了一个DOM,那个线程给删除了,这时候浏览器应该以哪个为准, 所以这个应该永远不会变,JS肯定也是单线程的。

其实渲染进程中确实是有很多线程的,除了我们的渲染主线程外,还有计时线程,网络线程等等多种线程。

而JS主要运行在渲染主线程中,并且渲染主线程只有一个,所以JS确实是一门单线程语言。而像我们的计时器中的JS代码,其实在计时线程中只是启到了一个计时效果,计时结束后会将计时器需要执行的回调函数包装成为一个任务,放到消息队列中

2.如果在渲染主线程中遇到异步,该如何运行?

这里说到了异步,我们首先知道什么是异步,而顾名思义,有异步那必然就有同步

同步

正常我们的JS代码都应该是同步的

function second() {
    console.log("second");
};
function first() {
    console.log("first");
    second();
    console.log("first end");
};
first(); // 执行first函数
// 输出结果 first --- second --- first end

像上述这样的应该代码,就为一个同步的代码,执行first函数,则先输出first,然后按照顺序调用second函数输出second之后再去执行输出first end

异步

在我们代码的执行过程中,会遇到一些无法立即处理的任务,例如

  • 计时完成后才去执行的任务:setTimeout、setInterval

  • 网络请求完成后才去执行的任务:XHR、Fetch

  • 用户操作完才去执行的任务:addEventListener

那如果像我们在上述的说法中,渲染主线程也用了同步的操作,那会如何呢?

我们先定义一个同步的延时函数(假设它为setTimeout异步函数)

// 传入一个延长时间time,记录一下调用时间,如果当前时间 - 调用时间 小于 延长的时间,则一直循环。大于则退出
function delay(time) {
	var startTime = Date.now();
    while(Date.now() - startTime < time) {};
};

然后开始模拟主线程中的执行

<h1>第一次渲染</h1>
<button>点击</button>
<script>
	var h1 = document.querySelector("h1");
    var btn = document.querySelector("button");
	function delay(time) {
		var startTime = Date.now();
    	while(Date.now() - startTime < time) {};
	};
    btn.onclick = function() {
        console.log("二次渲染触发");
        delay(3000);
        h1.textContent = "第二次渲染";
        console.log("二次渲染结束");
    };
</script>

如果是同步进行,那么浏览器就需要等待3秒后,页面才会出现二次渲染。渲染过程如下图

如果让渲染主线程长时间的等待,就会导致主线程长期处于阻塞状态,从而导致浏览器的假死;渲染主线程承担着极其重要的工作,肯定是不能阻塞的。

那么渲染主线程是如何避免这种渲染阻塞的呢?异步

渲染主线程在遇到任务中有计时器,网络等异步处理的时候,会通知其他线程(计时线程/网络线程),若该任务后续还有其他代码,则运行,没有其他则结束该任务。随后其他线程(计时线程/网络线程)在计时结束/请求结束后,会将回调函数包装成一个新的任务放到消息队列的末尾。具体如下图

使用异步的方法,那么渲染主线程将永远不会堵塞

1和2的总结

JS是一门单线程的语言,这是因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个。

而渲染主线程承担着诸多的工作,渲染页面、执行 JS 都在其中运行。

如果使用同步的方式,就极有可能导致主线程产生阻塞,从而导致消息队列中的很多其他任务无法得到执行。这样一来,一方面会导致繁忙的主线程白白的消耗时间,另一方面导致页面无法及时更新,给用户造成卡死现象。

所以浏览器采用异步的方式来避免。具体做法是当某些任务发生时,比如计时器、网络、事件监听,主线程将任务交给其他线程去处理,自身立即结束任务的执行,转而执行后续代码。当其他线程完成时,将事先传递的回调函数包装成任务,加入到消息队列的末尾排队,等待主线程调度执行。

在这种异步模式下,浏览器永不阻塞,从而最大限度的保证了单线程的流畅运行。

3.JS为何会阻塞渲染?

我们这里先用代码来测试,JS是如何阻塞渲染的

<h1>第一次渲染</h1>
<button>点击</button>
<script>
  var h1 = document.querySelector("h1");
  var btn = document.querySelector("button");
  function delay(time) {
    var startTime = Date.now();
    while (Date.now() - startTime < time) { };
  };
  btn.onclick = function () {
    console.log("二次渲染触发");
    h1.textContent = "第二次渲染";
    console.log("二次渲染结束");
    delay(3000);
  };
</script>

由我们代码可见,我们现在是将按钮点击,然后输出“二次渲染触发",然后将H1标签修改为“第二次渲染”,之后再输出“二次渲染结束”,那么页面效果真的是这样吗?我们测试一下。

我们会发现,输出都输出完了,为什么页面还没渲染呢??

其实在渲染主线程中,我们将通过代码h1.textContent = "第二次渲染"的时候,确实是已经执行了该代码,但是在页面中真正看到的,是需要有绘制任务来执行后,才会呈现出来的,所以我们在执行完这句代码后,是发布了一个绘制任务,然后绘制任务进入消息队列排队,等待当前任务执行完(计时),才去开始执行消息队列中的任务,直到执行渲染任务,才会真正显示到浏览器上,这也是JS为何会阻塞渲染的原因,它和渲染线程都是需要到渲染主线程中执行的。

4.任务会有优先级吗?

任务是没有优先级的,在消息队列中先进先出

但在消息队列中是有优先级的(多个消息队列是有优先级的)

在之前的浏览器中,只有分为宏任务(宏队列)和微任务(微队列),随着浏览器的不断发展和场景的复杂程度逐渐变大,2个队列已经满足不了当前的需求。

因此根据 W3C的最新解释:

  • 每个任务都有一个任务类型,不同的任务可以属于不同的队列,但同一个任务类型必须在同一个队列;

  • 在一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行

  • 浏览器必须准备好一个微队列,在微队列中的任务优先执行

在目前 chrome的实现中,至少包含了下面的队列:

  • 延时队列:用于存放计时器到达后的回调函数任务,优先级【中】

  • 交互队列:用户存放用户操作后的事件处理回调函数任务,优先级【高】

  • 微队列:用户存放需要最快执行的任务,优先级【最高】

添加任务到微队列的主要方式:promise、process.nextTick(node环境)、Object.observe, MutationObserver等...
Promise.resolve().then(function a(){console.log("放入微队列")});
验证--延时队列和交互队列的优先级
<button id="test">开始测试</button>
<button id="ineteraction">添加交互任务</button>
<script>
  var startTest = document.querySelector("#test");
  var ineteraction = document.querySelector("#ineteraction");
  // 延时方法,保证任务一定进入到了对应的队列中
  function delay(time) {
    var startTime = Date.now();
    while (Date.now() - startTime < time) { };
  };
  function addDelay() {
    console.log("添加延时队列");
    setTimeout(() => {
      console.log("延时任务执行")
    }, 100);
    delay(2000);// 死循环,此时该函数一定进入延时队列中
  }
  function addIneteraction() {
    console.log("添加交互队列");
    ineteraction.onclick = function () {
      console.log("交互任务执行")
    }
    delay(2000); // 死循环,此时2秒内点击,交互任务一定进入交互队列中
  }
  startTest.onclick = function () {
    // 先延时再交互,在延时2秒的时候点击添加交互任务,测试
    addDelay();
    addIneteraction();
    console.log("结束")
  };
</script>

结果:

题目测试

function a() {
    console.log(1);
    Promise.resolve().then(()=>{
        console.log(2);
    });
};
setTimeout(function() {
    console.log(3);
    Promise.resolve().then(a);
}, 0);
Promise.resolve().then(()=>{
    console.log(4);
});
console.log(5);

很多人看到这些题目,就直接懵逼了,那么现在我们就运用上面所学的知识,来测试一下

第一步

首先我们需要知道,执行这段代码的时候,是需要在渲染主线程上的,这也就是我们说的主线任务,所以我们现在第一次走的任务应该是这样的

如上图所示,我们现在是代码应该是全部放在主线任务,既渲染主线程上执行,那么我们现在开始执行

1.先定义函数a

2.执行定时器,将定时器的回调函数放到延时队列中

3.执行Promise,将Promise的回调函数放到微队列中

4.输出5

具体如下图

所以按照我们现在的流程图,现在主线程上仅仅就只是输出5,没有做其他的输出操作,然后就主线程就执行完毕

第二步

微队列中的第一个任务放到主线程上,此时微队列的任务只有一个

Promise.resolve().then(()=>{
    console.log(4);
});

此时主线程中只有输出4就结束了;

第三步

此时任务中只剩下延时队列中的定时器回调函数任务,主线程上也没有其他任务,则将回调函数放到主线程上执行

setTimeout(function() {
    console.log(3);
    Promise.resolve().then(a);
}, 0);

所以现在主线程输出3,然后又遇到了Promise函数,则再次将回调函数a放到了微队列上,然后结束任务

第四步

由于第三步输出之后,微队列中又出现了一个任务(回调函数a),并且主线程上已经没有其他任务,则直接将微队列中的任务放到主线程上执行,那么此时主线程上的任务就只有一个了

console.log(1);
Promise.resolve().then(()=>{
  console.log(2);
);

上面函数就可以很清楚的看出,主线程直接输出1,然后再次又将回调函数(输出2)放到了微队列后就结束任务

第五步

只剩下第四步的回调函数(输出2),则将该回调函数放到主线程上,控制台输出2

所以最后的输出结果就是:5,4,3,1,2啦

概况总结

概况

事件循环又叫做消息循环,是浏览器渲染主线程的工作方式。

在 Chrome 的源码中,它开启一个不会结束的 for 循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列末尾即可。

过去把消息队列简单分为宏队列和微队列,这种说法目前已无法满足复杂的浏览器环境,取而代之的是一种更加灵活多变的处理方式。

根据 W3C 官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的队列。不同任务队列有不同的优先级,在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行。

以上就是事件循环的全部过程啦,在此感谢袁进老师指点

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阿蔡还要努力

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值