事件循环 - JS是怎么运行的?

348 阅读9分钟

事件循环 - JS是怎么运行的?

前言

目前社区中关于事件循环的文章非常之多,每篇文章讲述的侧重点都不尽相同。但目的都是一样的:让我们更深刻的认识和了解事件循环是什么?它是如何工作的?

本篇文章将从 浏览器进程的调度 + 事件循环伪代码 带你认识事件循环的工作流程。
希望看完本篇,能让你对事件循环有更深刻的认知。

原文链接:lei4519.github.io/blog/techno…

事件循环概述

  • JS引擎是单线程的,或者说浏览器调度JS引擎时是单线程的。
  • 事件循环是单线程执行异步(非阻塞)代码的一种实现方式。
  • JS引擎执行JS代码,是基于事件循环的。
  • 为什么是单线程?
    • 为了防止多个JS线程同时对DOM操作起冲突,比如一个更新了DOM属性,另一个删除了DOM。
  • 是否有别的解决方案呢?
    • 在别的语言中,多线程访问共享数据的场景很常见,典型的解决方案就是加锁。
  • 为什么JS不使用多线程 + 锁的方式呢?
    • 因为JS最初的设计方向就是做一个简单、易用,在浏览器中充当辅助位置的脚本语言。所以引入线程锁带来的复杂度,是不能被接受的。

浏览器进程与线程

想把事件循环讲明白,就绕不过浏览器的进程和线程。

先来思考一个问题:

	异步代码是什么?从哪里来的?

Chrome的多进程架构

Browser进程

浏览器的主进程(负责协调、主控)

  • 负责浏览器界面显示,与用户交互。如地址栏、书签栏、前进,后退等
  • 负责各个页面的管理,创建和销毁其他进程
  • 网络资源、本地存储、文件系统等

插件进程

  • 每种类型的插件对应一个进程,仅当使用该插件时才创建

GPU进程:用于3D绘制等

Renderer 进程(浏览器内核)

  • 主要作用为页面渲染,脚本执行,事件处理等

渲染进程(浏览器内核)中的线程

GUI渲染线程

负责渲染工作

  • 渲染线程的工作流程

  • GUI渲染线程与JS执行线程是互斥的,一个执行的时候另一个就会被挂起。

  • 常说的JS脚本加载和执行会阻塞DOM树的解析,指的就是互斥现象。

    • 在JS执行过程中,对GUI线程的写操作,并不会被立即执行,而是被保存到一个队列中,等到JS引擎空闲时(当前宏任务执行完,下面会详细讲)被执行。

      document.body.style.color = '#000'
      document.body.style.color = '#001'
      

document.body.style.color = '#002' ```

- 如果JS线程的当前宏任务执行时间过长,就会导致页面渲染不连贯,给用户的感觉就是页面卡顿。

- `1000毫秒 / 60帧 = 16.6毫秒`

JS引擎线程

负责执行Javascript代码,V8引擎指的就是这个。

  • JS引擎在执行代码时,会将需要执行的代码块当成一个个任务,放入任务队列中执行,JS引擎会不停的检查并运行任务队列中任务。

    // html
    <script>
      console.log(1)
      console.log(2)
      console.log(3)
    </script>
    
    // 将需要执行的代码包装成一个任务
    const task = () => {
    	console.log(1)
      console.log(2)
      console.log(3)
    }
    
    // 放入任务队列
    pushTask(task)
    
  • JS引擎执行逻辑:伪代码(所有的伪代码都是为了理解写的,并不是浏览器的真实实现):

    // 任务队列
    const queueTask = []
    // 将任务加入任务队列
    export const pushTask = task => queueTask.push(task)
    
    while(true) {
      // 不停的去检查队列中是否有任务
      if (queueTask.length) {
        // 队列:先进先出
        const task = queueTask.shift()
        task()
      }
    }
    

事件触发线程

事件监听触发

  • document.body.addEventListener('click', () => {})

  • 伪代码:

    // JS线程 -> 监听事件
    function addEventListener(eventName, callback) {
      sendMessage('eventTriggerThread', this, eventName, callback)
    }
    
    // 事件触发线程 -> 监听元素对应事件
    
    // 事件触发线程 -> 元素触发事件
    function trigger(callback) {
      pushTask(callback)
    }
    

定时触发器线程:

定时器setInterval与setTimeout所在线程

  • 伪代码:

    // JS线程 -> 开始计时
    function setTimeout(callback, timeout) {
      sendMessage('timerThread', callback, timeout)
    }
    
      // 定时器线程 -> 设定定时器开始计时
    
    // 定时器线程 -> 计时器结束
    function trigger(callback) {
      pushTask(callback)
    }
    

异步http请求线程

Ajax、fetch请求

  • 伪代码:

    // JS线程 -> 开始请求
    XMLHttpRequest.send()
    sendMessage('netWorkThread', options, callback)
    
    // 网络线程 -> 开始请求
    
    // 网络线程 -> 请求响应成功
    function trigger(callback) {
      pushTask(callback)
    }
    

异步任务是什么?从哪来的?

  • 异步任务就是由浏览器其他线程处理并执行的任务。
  • 由JS引擎调用浏览器API来通知其他线程开始工作,并将执行成功的回调函数传入,当工作结束后其他线程会将回调函数推入任务队列中,由JS引擎执行回调函数。

示例:任务队列的运行过程

  • 从输入URL到页面渲染都发生了什么?
    • 只详细讲任务队列相关的流程
  1. 在地址栏输入URL,请求HTML,浏览器接受到响应结果,将HTML文本交给渲染线程,渲染线程开始解析HTML文本。

    ...
      </div>
      <script>
        document.body.style.color = '#f40'
        document.body.addEventListener('click', () => {})
        setTimeout(() => {}, 100)
        ajax('/api/url', () => {})
      </script>
    </body>
    
  2. 渲染线程解析过程中遇到<script>标签时,会把<script>中的代码包装成一个任务,放入JS引擎中的任务队列中,并挂起当前线程,开始运行JS线程。

    pushTask(<script>)
    
  3. JS线程检查到任务队列中有任务,就开始执行任务。

    1. 将对DOM的写操作放入队列中
    2. 告诉事件触发线程,监听事件
    3. 告诉定时器线程,开始计时
    4. 告诉网络线程,开始请求
  4. 第一个宏任务执行完成,执行写操作队列(渲染页面)

    while(true) {
      if (queueTask.length) {
        const task = queueTask.shift()
        task()
    
        requestAnimationFrame()
        // 执行写操作队列后进行渲染
        render()
        // 检查空闲时间是否还够
        requestIdleCallback()
      }
    }
    
  5. 第一个任务就完全结束了,任务队列回到空的状态,第一个任务中注册了3个异步任务,但是这对JS引擎不会关心这些,它要做的就是接着不停的循环检查任务队列。

  6. 为了简化流程,假设三个异步任务同时完成了,此时任务队列中就有了3个任务

    // 任务队列
    const queueTask = [addEventListener, setTimeout, ajax]
    
  7. 但是不管有多少任务,都会按照上面的流程进行循环重复的执行,这整个流程被称为事件循环。

微任务队列

上面说的是ES6之前的事件循环,只有一个任务队列,很好理解。

在ES6标准中,ECMA要求JS引擎在事件循环中加入了一个新的队列:微任务队列

  • 为什么要加一个队列?要解决什么问题呢?

宏任务队列的问题

实际功能:Vue为了性能优化,对响应式数据的修改并不会立即触发视图渲染,而是会放到一个队列中统一异步执行。(JS引擎对GUI线程写操作的思想)

那怎么实现这个功能呢?想要异步执行,就需要创建一个异步任务,setTimeout是最合适的。

// 响应式数据修改
this.showModal = true

// 记录需要重新渲染的视图
const queue = []
const flag = false
// 触发setter
function setter() {
// 记录需要渲染的组件
queue.push(this.render)

 if (flag) return
flag = true
setTimeout(() => {
 queue.forEach(render => render())
 flag = false
})
}

这样实现有什么问题呢?

// 任务队列
const queueTask = [addEventListener, setTimeout, ajax]

用上面的例子,现在任务队列里有三个任务,在第一个任务addEventListener中进行了Vue响应式修改。

假设setTimeout立即就完成了,那么现在的任务队列如下:

// 任务队列
const queueTask = [addEventListener, setTimeout, ajax, vueRender]

这个结果符合任务队列的运行逻辑,但却不是我们想要的。

因为视图更新的代码太靠后了,要知道每次任务执行之后并不是立即执行下一个任务,而是会执行requestAnimationFrame、渲染视图、检查剩余时间执行requestIdleCallback等等一系列的事情。

按这个执行顺序,vueRender的代码会在页面渲染两次之后才执行。

我们想要实现的效果是这个异步代码最好是在当前任务执行完就执行,理想的任务队列是下面这样。

// 任务队列
const queueTask = [addEventListener, vueRender, setTimeout, ajax]

相当于要给宏任务队列加入插入队列的功能,但是如果这么改,那就整个乱套了。之前的异步任务还有个先来后到的顺序,先加入先执行,这么一改,异步任务的顺序就完全无法控制了。

上面的问题总结来说

  1. 现在的异步任务,执行颗粒度太大,两个任务间要做的事情太多,我们想要能够创建更快更高效的异步任务。
  2. 现在的任务队列逻辑不能动。
  3. JS引擎本身没有创建异步任务的能力。
    • 在这个例子中,需要执行的异步任务,跟别的线程是没有任何关系的,我们只是想通过异步任务来优化性能。

解决方案

既然之前的任务队列逻辑不能动,那不如就加个新队列:微任务队列

JS引擎自己创建的异步任务,就往这个微任务队列里放。通过别的线程创建的异步任务,还是按老样子放入之前的队列中(宏任务队列)。

微任务队列,会在宏任务执行之后被清空执行。

加入了微任务队列之后,JS引擎的代码实现:

// 宏任务队列
const macroTask = []
// 微任务队列
const microTask = []

while(true) {
  if (macroTask.length) {
    const task = macroTask.shift()
    task()

    // 宏任务执行之后,清空微任务队列
    while(microTask.length) {
      const micro = microTask.shift()
      micro()
    }

    requestAnimationFrame()
    render()
    requestIdleCallback()
  }
}

注意while循环的实现,只要微任务队列中有任务,就会一直执行直到队列为空。也就是说如果在微任务执行过程中又产生了微任务(向微任务队列中push了新值),这个新的微任务也会在这个while循环中被执行

// 微任务队列 = []

Promise.resolve()
	.then(() => {
  	console.log(1)
  	Promise.resolve()
      .then(() => {
        console.log(2)
      })
	})
// 微任务队列 = [log1函数体]

// log1函数体 = 微任务队列.shift()
// 微任务队列 = []

// log1函数体()
// 微任务队列 = [log2函数体]

// log2函数体 = 微任务队列.shift()
// 微任务队列 = []

// 渲染视图

以上就是为什么要有微任务队列,以及微任务队列的运行逻辑。

浏览器中可以产生微任务异步代码的API:Promise.prototype.thenMutationObserversetImmediate(IE、nodejs)MessagePort.onmessage


以上,就是事件循环的相关知识了,谢谢观看。