事件循环深入理解

秦篆原创前端基础javaScript大约 7 分钟

有关事件循环的重新学习和一些新的理解

浏览器的进程模型

进程

众所周知,程序运行都需要开辟一块内存空间,分配至少一个进程,浏览器也不例外。一般来说,浏览器为了完成复杂的任务,会开辟多个进程:

  1. 浏览器进程 主要负责界⾯显示、⽤户交互、⼦进程管理等。浏览器进程内部会启动多个线程处理不同的任务。
  2. 网络进程 负责加载⽹络资源。⽹络进程内部会启动多个线程来处理不同的⽹络任务。
  3. 渲染进程 在渲染进程中,会优先开辟一个渲染主线程,该线程用于执行我们熟知的HTML,CSS,JS代码等。默认情况下,浏览器会为每一个tab页面开辟一个新的渲染进程。 我们接下来主要分析渲染进程中的内容。
浏览器任务管理器
浏览器任务管理器

线程

我们可以把一个进程看作是一个工厂,一个线程是一个工人,一个进程中至少会存在一个主线程,该线程结束则意味着进程也结束了, 浏览器也是基于这样的基本结构的。在渲染进程中,一般主线程为渲染主线程,该线程执行了非常多的任务,比如:

  • 解析html代码
  • 解析css
  • 计算样式
  • 计算布局
  • 处理图像
  • 执行全局的js代码
  • 执行事件处理的函数
  • 执行计时器的函数
  • ...等

为了解决这些问题,浏览器安排了一个模型来完成各种任务之间的调度-排队,也就是我们常说的EventLoop,在浏览器的实现中一般被称为MessageQueue。

事件循环基本解释
事件循环基本解释

事件循环做了几件事:

  1. 类似一个java中的main方法,渲染主线程中存在一个run方法,run方法开启了一个无限循环,类似如下:
    let messageQueue = []
     for(;;){
    //每次在这个循环中,将从各种队列中取出一个存在的任务,比如微队列、延时队列、交互队列等
    //根据w3c的最新标准,浏览器必须实现的队列仅存在微队列,其它的队列一般来说由浏览器决定是否实现
    }
    
  2. 当任务队列中存在一个任务时,将其按照先进先出的顺序,取出并放入到主线程执行。
  3. 如果当前所有的任务队列中都没有任务,则取出任务的动作将陷入沉睡,直到任务队列中新增了一个任务,从任务队列中重新唤醒该动作。
  4. 向任务队列中添加任务这个动作是任何时候都能够开启的,包括其它进程的线程也可以向本线程添加任务,比如浏览器进程监听的用户操作,点击,滚动等。
事件循环源代码
事件循环源代码

其它相关概念

浏览器的异步任务

浏览器在执行过程中,会遇到的一些无法立即处理的任务,浏览器无法立即执行,也无法持续等待,所以采取了异步的方式,将其回调延迟执行。

  • 定时任务 setTimeout setInterval 这类型的任务会被渲染主线程置入os模块,调用系统计时器,系统计时器完成计时后,就可以将回调函数置入到延时队列等待执行。
  • 网络任务 xhr fetch ajax 这类型的任务通常会以promise.resolve或者promise.reject的方式返回,他们自己的线程将回调函数置入到微队列,在主线程任务完成之后优先执行该回调函数。
  • 交互任务 addEventListener 通常,交互任务会被浏览器置为优先级仅次于微任务队列的队列中。

接下来看一些实际的例子,来帮助理解异步和线程之间的关系,我们从简单到难:

  1. 单纯的主线程任务,即初始的js代码
    function fun1() {
    console.log(1)
    }
    console.log(2)
    fun1()
    //out 2 1
    
    这段代码应该是最简单的,不需要任何的解释,它输出了 2 1
  2. 主线程任务中含有延时任务
    function log(val) {
        console.log(val)
    }
    
    setTimeout(function () {
        log(1)
    }, 0);
    
    setTimeout(function () {
        log(2)
    }, 1000);
    
    log(3)
    //out 3 1 sleep 1s 2
    
    上述代码,稍有编码经验的应该都没问题,执行顺序是3 1 2,虽然确实很简单,但是这里我想借助这个代码分析一下上述的线程问题: 首先我们可以确定的是,这段代码中总共需要一个延时队列,一个渲染主线程,那么分析结果就如下图:
事件循环分析
事件循环分析
  1. 主线程任务中有promise或者MutationObserver或者queueMicrotask
      function log(val) {
      console.log(val)
      }
      
      setTimeout(function () {
      log(1)
      }, 0);
      
      setTimeout(function () {
      log(2)
      }, 1000);
      
      let promise = new Promise(resolve => {
      console.log('init promise')
      setTimeout(()=>{
      resolve('promise resolve')
      }, 999)
      })
      
      promise.then(res=>{
      console.log(res)
      })
      
      log(3)
    
    //out 3 1 sleep 999ms promis resolve 1ms 2
    

在这段代码中,我们加入了一个promise,它将一个settimeout塞到了微任务中,这个微任务又将回调函数塞到了延时队列中。如果等待时间不是999而是1000,那么执行结果将是 3 1 2 promise resolve

注意

请注意,这段代码有部分人拿到编辑器或者node环境下执行可能会有不同的结果,在处理上有些许差异!

js为什么有时候会阻塞页面渲染

在开发过程中,我总会发现有些很奇怪的代码,明明点击了一个按钮或者是改变了一个文本,但是要隔很久才出现效果,不知道其它小伙伴有没有遇到过,我们结合一段代码来说一下这种情况。

<h1>我是一段文本</h1>
<button>change</button>
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);
      };

现在问题来了,当change按钮被点击后,文本会立马改变吗?
答案是文本内容实际上已经被改变了,但是视觉效果上要延迟三秒才改变。为什么会造成这种情况,引入我们上面说过的各种任务队列的知识:

  1. 一开始,主线程读取了js代码,定义了h1,btn,delay函数,并且向其它进程(浏览器进程)添加了一个监听。
  2. 随后点击事件发生,执行到h1.textContent = '这段文本现在被改变了',现在文本已经被改变了。但是文本改变了,不代表页面能改变,上面提到了,主线程负责各种各样的事情, 比如执行html,css,js,绘制等等,现在内容改变了,页面是需要重新绘制的,所以主线程向一个任务队列中添加了一个重新绘制的任务,该任务和其他的队列任务一样正在排队。
  3. 随后执行到delay(3000),在这个函数中,很容易看到,这是在堵塞主线程3000ms,所以主线程在这3s的时间里一直在执行delay函数的内容,并不认为当前函数已经结束了。
  4. 3s结束后,再去其它的队列中获取到重新渲染的任务,才将h1的内容改变。

任务有没有优先级

任务没有优先级,但是队列存在优先级

在w3c的标准中,微任务队列优先级最高,其它的队列w3c并没有进行强硬的要求,但是,从v8引擎的源代码来看,交互队列的优先级仅次于微队列。


好啦,本篇的内容就到这啦,下一次是浏览器的渲染原理。

上次编辑于:
贡献者: luolj