JS事件循环
本文主要介绍了 JS 中的事件循环概念,详细讲解了 Browser 中的事件循环以及 Node.js 中的事件循环。
查看参考资料
参考方向 | 教程原帖 |
---|---|
面试必问之 JS 事件循环(Event Loop),看这一篇足够 | 面试必问之 JS 事件循环(Event Loop) |
Node.js 事件循环 | Event Loop |
通俗移动的 Node.js 事件循环 | node.js 事件循环 |
前言
JS 是一门单线程的语言,执行过程中每一步代码都有严格的先后顺序,但 JS 也能够实现异步编程,JS 本身并不具备异步编程的能力,其异步编程是依赖其宿主实现的,而实现异步编程的核心机制就是事件循环。
浏览器中 JS 异步执行的原理
例如发送ajax
请求以及设定setTimeout
定时器,实现这种异步,主要依赖于浏览器的定时触发线程以及 HTTP 请求线程,即浏览器才是执行发送请求以及定时功能的角色,JS 引擎只负责执行这些事件回调的代码。
浏览器的进程与线程
以 Chrome 为例,浏览器不仅有多个线程,还有多个进程,如渲染进程、GPU 进程和插件进程等。而每个 tab 标签页都是一个独立的渲染进程,所以一个 tab 异常崩溃后,其他 tab 基本不会被影响。作为前端开发者,主要重点关注其渲染进程,渲染进程下包含了 JS 引擎线程、HTTP 请求线程和定时器线程等,这些线程为 JS 在浏览器中完成异步任务提供了基础。
深入探讨事件循环
有关js
同步与异步问题的深入探讨。
- 进程是一个程序的执行实例,它拥有自己的内存空间和资源。一个程序可以同时运行多个进程,互不干扰。
- 线程是进程内部的一个执行单元,它共享进程的内存空间和资源。一个进程可以同时运行多个线程,提高效率和并发性。
浏览器是一个多进程的应用程序,它由以下几种进程组成:
- 浏览器主进程:负责浏览器的界面显示、用户交互、子进程管理等。
- 渲染进程:负责渲染网页,每个标签页对应一个渲染进程。
- 网络进程:负责网络请求的处理,如 HTTP、WebSocket 等。
- GPU 进程:负责 GPU 的使用,如 3D 绘制、视频解码等。
- 插件进程:负责插件的运行,如 Flash、PDF 等。
渲染进程是我们关注的重点,因为它涉及到前端开发中的同步异步以及线程相关的概念。渲染进程内部有以下几种线程:
- GUI 线程:负责渲染网页的界面,如 HTML、CSS、图片等。
- JS 引擎线程:负责执行 JS 代码,如 V8 引擎。
- 事件触发线程:负责监听和处理事件,如点击、滚动、定时器等。
- 定时器触发线程:负责处理定时器的回调函数,如 setTimeout、setInterval 等。
- 异步 HTTP 请求线程:负责处理异步的网络请求,如 XMLHttpRequest、fetch 等。
- Web Worker 线程:负责执行后台任务,不影响主线程。
JS 引擎线程是一个单线程的执行环境,也就是说,在同一时间只能执行一段 JS 代码。这是因为 JS 是一门设计用来与用户交互的脚本语言,如果允许多个 JS 代码同时运行,可能会导致 DOM 的操作冲突和数据不一致。因此,JS 的执行机制是基于事件循环(Event Loop)的。
事件循环是 JS 实现异步编程的核心机制,它可以简单地理解为以下几个步骤:
- JS 引擎线程从执行栈中取出一个同步任务并执行,直到执行栈为空。
- JS 引擎线程从任务队列中取出一个异步任务(也叫微任务)并执行,直到任务队列为空。
- JS 引擎线程从事件队列中取出一个异步任务(也叫宏任务)并执行,然后回到第二步。
同步任务是指那些不需要等待任何其他条件就可以立即执行的任务,如普通的赋值、计算、循环等。异步任务是指那些需要等待一定条件才能执行的任务,如网络请求、定时器、事件监听等。异步任务又分为微任务和宏任务,微任务是指那些在当前同步任务结束后立即执行的任务,如 Promise 的回调函数、MutationObserver 的回调函数等。宏任务是指那些在下一轮事件循环开始时才执行的任务,如 setTimeout 的回调函数、setInterval 的回调函数、DOM 事件的回调函数等。
JS 单线程存在的问题是,在执行一些耗时长或者阻塞的同步任务时,会导致后续的任务无法及时执行,影响用户体验和程序性能。解决方案是,尽量将这些任务转化为异步任务,或者使用 Web Worker 线程来执行这些任务,从而避免阻塞主线程。
JS 线程和 GUI 线程是互斥的,也就是说,在 JS 线程执行时,GUI 线程会暂停,反之亦然。这是为了保证 DOM 的渲染和操作的一致性。因此,如果 JS 代码执行时间过长,会导致页面的渲染和更新被延迟,影响用户体验。解决方案是,尽量优化 JS 代码的性能,或者使用 requestAnimationFrame 等 API 来实现动画效果,从而避免卡顿和闪烁。
JS 执行上下文、作用域以及作用域链
查看 JS 中执行上下文
在 JS 中,执行上下文是一个抽象的概念,它表示代码在运行时的环境。执行上下文可以分为三种类型:
- 全局执行上下文:它是为运行代码主体而创建的执行上下文,也就是说它是为那些存在于函数之外的任何代码而创建的。全局执行上下文只有一个,它在浏览器中对应于 window 对象,在 Node.js 中对应于 global 对象。
- 函数执行上下文:它是为每个函数调用而创建的执行上下文,也就是说它是为那些存在于函数内部的代码而创建的。函数执行上下文可以有多个,每次调用函数时都会创建一个新的执行上下文,并且在函数返回后销毁。
- eval 执行上下文:它是为 eval 函数内部的代码而创建的执行上下文,也就是说它是为那些通过 eval 函数执行的代码而创建的。eval 执行上下文很少使用,因为 eval 函数通常被认为是不安全和低效的。
每个执行上下文都有以下三个重要的组成部分:
- 变量对象(Variable Object):它是一个用于存储变量和函数声明的对象,它包含了该执行上下文中定义的所有变量和函数。
- 作用域链(Scope Chain):它是一个用于确定变量访问权限的链表,它包含了该执行上下文及其所有父级执行上下文的变量对象。
- this 值(This Value):它是一个用于指代当前对象的值,它根据函数调用方式的不同而有所不同。
当 JS 引擎解析到可执行代码片段(通常是函数调用阶段)的时候,就会先做一些执行前的准备工作,这个 “准备工作”,就叫做 “创建执行上下文”。创建执行上下文分为以下两个阶段:
- 创建阶段(Creation Phase):在这个阶段,JS 引擎会做以下三件事:
- 创建变量对象,并初始化其中的变量和函数声明。
- 创建作用域链,并将当前变量对象添加到作用域链的最前端。
- 确定 this 值,并将其赋值给当前执行上下文。
- 执行阶段(Execution Phase):在这个阶段,JS 引擎会逐行执行代码,并根据变量对象、作用域链和 this 值来访问和操作变量和函数。
查看 JS 中的作用域以及作用域链
JS 中的作用域是指代码中变量和函数的可访问范围,它决定了变量和函数的生命周期和可见性。JS 中有三种类型的作用域:
- 全局作用域:在代码中任何地方都能访问到的对象拥有全局作用域,例如最外层函数、最外层变量、未定义直接赋值的变量、window 对象的属性等。全局作用域有一个缺点,就是容易造成命名冲突和污染全局命名空间。
- 函数作用域:在函数内部定义的变量和函数拥有函数作用域,它们只能在函数内部被访问,而不能在函数外部被访问。函数作用域可以隔离变量,避免与外部发生冲突。
- 块级作用域:在 ES6 中,使用 let 和 const 关键字声明的变量拥有块级作用域,它们只能在当前代码块(由一对花括号包裹)内部被访问,而不能在代码块外部被访问。块级作用域可以更细粒度地控制变量的生命周期和可见性。
JS 中的作用域链是指在查找变量时沿着作用域层级所形成的链条,它由当前执行上下文及其所有父级执行上下文的变量对象组成。当一个变量在当前执行上下文中没有找到时,就会沿着作用域链向上一级查找,直到找到为止或者到达全局作用域。
作用域链的创建过程如下:
- 当执行全局代码时,会创建一个全局执行上下文,并将其压入执行栈。
- 当执行全局代码中的一个函数时,会创建一个函数执行上下文,并将其压入执行栈。
- 在函数执行上下文中,会先创建一个变量对象,并初始化其中的变量和函数声明。
- 然后会创建一个作用域链,并将当前变量对象添加到作用域链的最前端。
- 接着会确定 this 值,并将其赋值给当前执行上下文。
- 最后会逐行执行函数内部的代码,并根据作用域链来访问和操作变量和函数。
浏览器中的事件循环
执行栈与任务队列
执行栈:JS 按照顺序执行同步代码的时候,代码的执行依赖于一个栈结构,我们把这个栈叫做执行栈。
任务队列:
宏任务:有明确的异步任务需要执行和回调;需要其他异步线程支持。如
script(整体代码)
、setTimeout
、setInterval
、setImmediate
等注意 html 中的
script
标签,以及浏览器中的Dev Tools
中控制台中的执行代码都是包装在宏任务中执行的。微任务:没有明确的异步任务需要执行,只有回调;不需要其他异步线程支持。如
promise.then()
、MutationObserver
、process.nextTick
。requestAnimationFrame
在宏任务之后,在每次浏览器视图重绘前进行,所以其既不属于宏任务也不属于微任务。查看requestAnimationFrame的使用
JS 执行过程
执行顺序:同步代码->微任务队列->宏任务队列,注意对嵌套层级深的代码一定需要结合执行栈进行分析。
NodeJS 中的事件循环
循环周期
在 NodeJS
中 JS
的执行,我们主要需要关心的过程分为以下几个阶段,下面每个阶段都有自己单独的任务队列,当执行到对应阶段时,就判断当前阶段的任务队列是否有需要处理的任务。
timers
阶段:执行所有setTimeout()
和setInterval()
的回调,事件循环首先进入此阶段。pending callbacks
阶段:某些系统操作相关的回调函数,如TCP
链接错误。idle, prepare
:系统内部使用,程序员无需关心。poll
阶段:轮询等待新的链接和请求等事件,执行 I/O 回调等。优先执行poll
阶段的任务队列。如果此阶段任务队列已经执行完了,则进入check
阶段执行setImmediate
回调(如果有setImmediate
),或等待新的任务进来(如果没有setImmediate
)。在等待新的任务时,如果有timers
计时到期,则会直接进入timers
阶段。此阶段可能会阻塞等待。check
阶段:setImmediate
回调函数执行。close callbacks
阶段:关闭回调执行,如socket.on('close', ...)
。
上面每个阶段都会去执行完当前阶段的任务队列,然后继续执行当前阶段的微任务队列,只有当前阶段所有微任务都执行完了,才会进入下个阶段。
nextTick 和 setImmediate
NodeJS
中的 process.nextTick()
和 setImmediate()
也有类似效果。其中 setImmediate()
我们前面已经讲了是在 check
阶段执行的,而 process.nextTick()
的执行时机不太一样,它比 promise.then()
的执行还早,在同步任务之后,其他所有异步任务之前,会优先执行 nextTick
。可以想象是把 nextTick
的任务放到了当前循环的后面,与 promise.then()
类似,但比 promise.then()
更前面。意思就是在当前同步代码执行完成后,不管其他异步任务,先尽快执行 nextTick
。如下面的代码,因此这里的 nextTick
其实应该更符合setImmediate
这个命名才对。