从浏览器运行机制切入,理解事件循环机制

1,118 阅读6分钟

a4beda5e1005faa952a6b62ced88bd6.jpg

序言

前几天自己在网上看到了一些"前端进阶资料",里面讲解了js在浏览器中运行的事件循环机制。觉得有一点小小的收获,于是写一篇东西作为一个总结。(本人水平有限,欢迎各位指正)

进程与线程

首先先明确两个概念:进程与线程。进程是指程序运行在我们的计算机上所需要占据的计算机资源(比如一块内存空间),而线程是指对进程上的任务进行执行的一个个"劳动力"(这些劳动力都是由电脑的cpu进行提供)。可以将进程理解为一份悬赏令,上面有很多任务,而每一个完成悬赏令上任务的赏金猎人就是一个个线程。

浏览器上的进程与线程

浏览器是一个多进程多线程的应用程序,进程包括(浏览器进程,网络进程,渲染进程,前端开发能接触到的大概就是这三个进程)。而浏览器进程主要负责浏览器与用户之间的交互行为,比如标签页上的页面前进回退,用户对页面中内容的点击滚动事件的监听,url的改变,各种浏览器插件的注册使用都在这里。网络进程主要就是负责浏览器中的网络请求的发起与接收,以及请求响应的解析等。而javascript的事件循环机制就运行在渲染进程之中,我们接下来着重讲渲染进程。

关于渲染进程

当我们在浏览器中每次打开一个新的标签页,都会创建起一个独立的渲染进程,并一同生成与之对应的渲染主线程(后面我统一将他称为主线程)。比如我打开百度的页面:

tttt.png

这里的渲染主线程很重要,咱们前端的代码都是在靠它才运行起来的🙈。

渲染主线程

web前端开发中的几乎所有操作都是在靠这个线程运行,包括对html文件的解析生成相应的dom树,解析css样式代码生成css规则树,将他们结合起来生成渲染树最终渲染整个网页。当然,咱们前端的javascript代码的执行也是在这个进程之上,而这也是javascript这门编程语言是单线程语言的原因。但是这么多事都由主线程一个人来运行,那这些任务只能排队被主线程一个一个的运行,这也就导致了js代码是有可能会导致页面样式渲染阻塞的。我们看下面一个例子。

    <h1>我很帅</h1>
    <button>换一种帅法</button>
    <script>
        var h1 = document.querySelector('h1')
        var btn = document.querySelector('button')
        // 定义一个函数 让他在规定的事件内 始终占据主线程的运行资源
        function delay(duration) {
            var start = Date.now()
            while(Date.now() - start < duration) {}
        }
        // 给我们上面声明的按钮添加点击事件
        btn.onclick = function () { h1.textContent = '我是一个靓仔!'; delay(3000); }
        
    </script>

当我们把上面的代码放进浏览器进行执行的时候,会导致这样一个结果: 当我们点击页面上的按钮,触发回调函数之后在3秒钟之后,h1中的文本才会改变。这是因为在主线程执行上面的js代码之后。一旦我们点击了按钮,这段代码会被执行 h1.textContent = '我是一个靓仔!'; delay(3000); h1中的文本被更改,并且将重新对这个h1进行渲染的任务添加到了一个等待队列中,当执行完 delay函数之后,这个渲染h1的任务才会被重新添加进主线程中进行执行。但是 delay函数需要执行3秒,所以 渲染函数也等待了3秒。

队列,同步任务,异步任务

刚刚我们提到了队列,那我们现在就来讲一下js执行过程中是如何使用队列的。

首先,js的执行,分为同步任务与异步任务两种

  • 同步任务:js执行过程中能按顺序依次执行,直到主线程中没有任务可执行的任务。

  • 异步任务:执行过程中,出现的无法立刻执行完成的任务被称为异步任务,比如延时任务(settimeout,setInterval),比如用户触发某些交互事件而需要执行的回调函数(addEventListener),比如发起网络请求(使用xmlHttpRequest对象,fetch发起网络请求),比如创建promise对象,MutationObserver,来自定义你的异步任务。异步任务会存放在于独立的队列中等待主线程执行。

下面用一段代码加图示进行解释事件循环的过程:

    console.log('1')
    setTimeout(function() {
        console.log('2')
      },0)
    new Promise((resolve,reject) => {resolve()}).then(() => {console.log('3')})

这段代码从上到下依次执行完成后,主线程与等待队列中会呈现出下图的状态:

1680929052834.png 在这里说下微任务与宏任务的概念(在最新的w3c的标准中已经取消了宏任务的队列划分将他替代为了更多类型的队列,稍等会讲这个,这个地方我先沿用之前的概念)。 在等待队列中,微任务的执行优先级是最高的,在主线程中没有任务时,微任务队列中的任务会被最优先放到主线程中进行执行。而宏任务次之。那么在这里,会先运行主线程中的 log('1'), 然后浏览器发现主线程中没有任务了,就会先去找微任务并添加到主线程中,如果里面没有就会去找有没有宏任务。如果都没有任务,主线程就会休眠。当有新的任务被添加进来时,主线程就会被重新唤醒。 所以最后运行出的结果就是,控制台打印出 1,3,2。

1680929866963.png

注意:这里的setTimeout(),执行的时候,倒数时间的任务会被提交到浏览器线程中的计时线程中去运行而不是在咱们的渲染主线程中去计时,当计时线程完成计时任务的时候,回调函数才会被添加到咱们的宏任务队列中。

我们在代码中在多嵌套几层,增加一下难度:

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

在代码从上到下执行一遍之后,线程与队列间的关系如下图:

1680930923424.png

接下来 ,会依次执行 log('1'),log('3'), log('2');new Promise('里面的resolve是执行 log('4')')。 然后,控制台中依次输出 1, 3, 2。 然后此时主线程与队列的状态变为:

1680931257042.png

这时主线程 发现没有任务,就去微任务队列中拿到 log('4'),并执行。主线程会反复的去等待队列中找是否还有任务待执行。知道主线程和队列中都没有任务时,主线程才会进入休眠。

当前的w3c对宏任务做的区分

随着浏览器的发展,单纯的微任务,宏任务队列已经不太够用了,于是针对宏任务队列做了更精细的划分,一共有几十个划分出的队列。在前端接触多点的大概就是咱们常用的计时任务的延时队列和用户响应行为的交互队列,对其他队列比较好奇的话就需要去看看w3c的官方文档了(反正我不知道:狗头保命一下😄)

Suggestion.gif

总结

我自己写这篇文章一边是出于总结的目的写出的,一边是对当前互联网大环境的焦虑,希望通过做些什么别的事情能让自己暂且忘却一下焦虑吧。差不多就这样吧😄