浏览器是怎么执行JS的?——消息队列与事件循环
看完渡一的课后,感觉这块内容确实非常重要,写 JS 的连 JS 的执行原理都不知道可不行。
事件循环
在写 JS 的时候,你有没有想过 JS 是按照什么顺序执行的?浏览器是怎么执行 JS 代码的?为什么有时候代码没有按照我们认为的顺序执行?JS 作为解释型脚本语言,怎么能用上定时器、回调函数之类的操作?
其实浏览器背后隐藏着一个精密而复杂的机制,那就是事件循环。这个机制使得网页能够响应用户的操作,同时保持了界面的流畅性和高效性。事件循环是现代前端开发中至关重要的概念之一,它负责管理各种异步操作,例如用户输入、网络请求和定时器等。这是浏览器层面的,做前端必须知道的东西。
深入了解浏览器中的事件循环,将使我们能够更好地理解JavaScript在前端开发中的工作原理。本文会详细解析事件循环的内部机制,同时提供实用的示例来帮助你更好地利用这个机制来构建出色的交互体验。
一、进程模型
为了理解事件循环的作用域,不得不提到一些操作系统的底层概念——进程、线程,理解不好这些概念,肯定理解不了事件循环。我尽量简略地解释,这里理解得多,事件循环理解得也越快越好。
当然,进程、线程的概念也是极为重要的底层知识,如果你完全不了解这些,最好可以先看看详细解释。
何为进程
- 程序运行需要有自己专属的内存空间,可以把这块内存空间简单理解成进程
- 每个应用至少有一个进程,进程之间相互独立,即使要通信,也要双方同意
在操作系统中,进程指的是正在运行的程序的实例,它包括了程序的代码、数据以及程序执行时所需要的资源。每个进程都有它自己独立的内存空间,可以同时执行不同的任务。并且,一个程序可以占用多个进程,其中有一个主进程是在程序运行之初就被操作系统启动的,而另外的进程都是这个进程启动,来为他分担别的任务的。它负责管理系统资源、调度任务的执行顺序、以及为程序提供必要的环境。
每个进程都拥有独立的内存空间,它们之间不会直接共享内存,因此彼此之间互相隔离。这也是为什么在一个进程中的变量不能直接被另一个进程所访问的原因;但是他们如果达成了一定约定,双发都同意消息的传递,那么是可以互相通信传送数据的。
何为线程
- 有了进程之后,就可以运行代码了,运行的代码可以成为线程,他在进程环境中运行
- 一个进程至少有一个线程,进程在开启之后会自动创建一个线程来运行代码,该线程被成为主线程
- 如果需要同时执行多个代码,即并行去执行多个操作,主线程就会启动更多的线程去执行另外的代码
线程是程序执行的最小单位,它是进程中的一个独立执行流程。一个进程可以包含多个线程,可以同时执行不同的任务,这些线程共享相同的内存空间和其他资源,所以可以很容易互相通信、相互协调。
浏览器的进程和线程
- 浏览器是一个多进程、多线程的应用程序
- 为了避免相互影响,启动浏览器之后,会运行多个进程(同时也因为浏览器是一个异常复杂的软件,只用几个进程很难协调好工作)
- 浏览器有三个重要进程:
- 浏览器进程:界面显示、用户交互、子进程管理等等,其中会启动多个线程处理不同的任务
- 网络进程:负责加载网络资源,同样在该进程中会启动多个线程来处理不同的网络任务
- 渲染进程:1个标签页一个渲染进程
- 渲染进程:
- 启动后会开启一个渲染主线程,主线程执行HTML、CSS、JS代码
- 默认情况下,浏览器会每个标签页开启一个全新的渲染进程,保证不同的标签页之间不会相互影响
- 后面可能会改变这种模式(有太多进程的时候占用大量资源)
为什么要一个页面一个进程?
因为用户是很容易多开很多页面的,如果这么多个页面公用一块内存空间,也就是公用同一个进程,很容易出现,一个页面出 Bug 把内存卡崩了,一整个进程都卡死,整个浏览器都得重启,因为所有页面都用不了了。
但是一个页面一个进程,可以让一个页面的异常不会影响到其他页面。比如你平时使用浏览器,不会因为知乎的网站崩了,把旁边的CSDN也卡死,你照样可以用CSDN,并且只用重新打开知乎。
关于进程和线程就说这么多,因为这个并不是本文的重点。
二、渲染主线程
如何工作的?
渲染主线程是工作量最大的线程,需要处理的程序包括但不限于:
- 解析HTML
- 解析CSS
- 计算样式
- 布局
- 处理图层
- 每秒刷新页面60次(渲染帧)
- 执行全局 JS 代码
- 执行事件处理函数
- 执行计时器的回调函数
- …
思考:为什么浏览器不用多个线程来处理上述任务
这么多的任务,如何调度?
例如:
- 正在执行一个JS函数,执行到一半的时候用户点击了按钮,我该立即去执行点击事件的处理函数吗?
- 我正在执行一个JS函数,执行到一半的时候某个计时器到达了时间节点,我该立即去执行这个计时器的回调吗?
- 浏览器进程监听到用户点击了某个按钮,但同时某个计时器也到达了时间节点,我应该处理哪儿一个呢?
- …
为了解决上面的一些调度问题,渲染主线程采用了“排队”的方式。
我们把所有要做的事,一件一件统称为任务,渲染主线程的动作可以看做是对一个接一个的任务的响应和执行。当渲染主线程正在执行一个任务的时候,到来的所有需要执行的任务都会进入一个消息队列(事件队列),每到来一个任务,该任务在前一个任务没有执行完的时间中,都会在这个队列中排队等候。
下面是渲染主线程的主要工作:
- 在渲染主线程启动的时候都会进入一个无限的循环
- 在每一循环中,都会去查询上面提到的消息队列中是否有未执行的任务在等待,如果有,就取出第一个任务,也就是最早到来的任务开始执行,执行完后进入下一个循环,如果没有,则进入休眠状态
- 其他所有的线程都能随时往消息队列中添加任务。新任务会添加到消息队列的末尾,如果这个时候主线程是休眠状态,则主线程会被唤醒,开始循环,并在循环中获取任务去执行
事件循环(消息循环)的主要步骤就是上面三点,保证了页面能够正常执行事件完成功能。
更深理解
上面讲述了事件循环的大致概念和步骤,下面解释一些更细节的东西,可以让我们理解得更深入。
什么是异步
前端写 JS ,总是绕不开同步和异步,同步很好理解,就是一步一步,从上到下,一行一行执行 js 代码,那什么是异步?
代码在执行过程中,可能遇到一些没有办法立即执行的任务,例如:
- 计时器结束后触发回调任务(
setTimeout
、setInterval
…) - 网络通信完成后需要执行的任务(向后端发送请求后的操作…)
- 用户操作后需要执行的任务(addEventListener…)
如果让渲染主线程等待每个任务执行完,再执行下一个任务,那么可能会浪费大量时间,影响页面正常运行。例如,假如设置了一个一分钟的定时器,在消息队列中取到这个任务的时候,不可能一直等待,直到一分钟后执行完该定时器任务后,才执行下一个任务,这样等待的这一分钟内什么事儿都不干,完全浪费掉了,甚至导致页面卡死。
简单提一下计时器的工作原理:
在计时器开始被调用的时候,计时器会通知计时线程,让计时线程开始计时。主线程和计时线程是并行执行的,同属与页面进程。
如果采用一个接一个,上一个任务全部执行完再执行下一个任务,这种思路就是同步,虽然这样可以保证时间线单一,不混乱,但是如上面的例子所说,问题十分严重。所以渲染主线程并不是这么工作的。
setTimeout(() => {console.log("计时器结束")
}, 3000)
console.log(1)
如果浏览器是同步执行的,那么 JS 代码是从上到下,上一个代码块执行完,才会执行下一行代码块,那么上面的代码会先输出“计时器结束“,再输出1。如果是异步的,那么打印顺序应该是反过来的:先打印1,再打印“计时器结束”。可以自己试一试上面的测试代码。
实际上,上面的测试代码的运行原理是这样的:主线程触发计时器之后,会立刻获取下一个任务,当计时线程计时结束后,计时线程是不会通知主线程的,而是直接将回调函数加入到消息队列。所以主线程虽然不能直接知道计时器已经结束,但是任然可以从消息队列中知道该何时执行计时器的回调函数。
对上面的知识来个总结概述:
JS 是一门单线程的语言,这是因为他运行在浏览器的渲染主线程中,而渲染主线程只有一个。
主线程承担着许多工作,例如:渲染页面、执行 JS 等等
如果采用同步的方式,极可能会导致主线程产生阻塞,从而导致消息队列中很多任务无法执行,浪费大量时间,甚至导致页面卡顿(无法刷新)、崩溃。
所以浏览器采用异步的方式避免阻塞问题。当某些需要等待的任务发生时,比如计时器、网络、事件监听,主线程将任务交给其他线程去处理,自身立即结束当前任务,进入下一个循环,从消息队列中获取并执行下一个任务。当其他线程完成任务后,将事先传递的回调函数包装成任务(任务是一个对象,不能直接把回调函数加入消息队列)添加到消息队列的队尾,等待主线程执行。
这样,最大限度的保证了单线程的流畅运行。
JS为何会阻碍渲染
假如你有一个页面,其中的主要内容如下:
/*** html:* <h1>hello</h1>* <button>click</button>*/const h1 = document.querySelector('h1')
const btn = document.querySelector('button')const delay = (duration) => {const start = Date.now()while(Date.now() - start < duration) {}
}btn.onclick = () => {h1.textContent = 'hello world'delay(3000)
}
在打开这个页面后,我们通过“事件循环”的角度来分析一下:
- 首先通过
docuemnt.querySelector
获取了两个元素实例 - 设置了一个延时函数
- 在按钮上绑定了一个事件:渲染主线程将监听的工作交给交互线程去执行,交互线程等待按钮被点击
在页面加载完毕后,我们点击了按钮,会发现一个神奇的现象:标题中的文字内容并没有直接从 hello 变成 helloworld,而是在等待了三秒后才变化。这是为什么?
- 点击按钮,交互线程监听到了,马上将点击事件的回调包装成任务加入消息队列
- 主线程执行到了交互线程添加的点击回调任务,开始执行,并执行到
h1.textContent = 'hello world'
- 上面异步执行的代码成功将元素的文本内容修改了,但是修改了不是马上就能被同步到页面的,而是在执行完这一步后,马上生成一个“绘制”任务,添加到消息队列,只有等“绘制”任务执行,页面重绘后才能看见。(但是这个时候html和页面是统一的,并不是html内容改了,但是页面显示有问题)
- 点击的回调继续执行,调用
delay
函数延迟三秒。这个delay
并没有生成新任务,而是在主线程当前执行的点击回调任务中执行的,所以得等他执行完之后,才能获取下一个任务,也就是第三步生成的“重绘”任务
虽然这个问题浏览器还不能很好解决,但是前端的一些框架已经做出了一定优化,例如 React 会监听一段 JS 的运行时间,不会让某些无用的 JS 持续太长时间。
任务的优先级
任务有没有优先级?有没有加急的任务?
很可惜,任务是不区分优先级的,所有任务都是一视同仁,该排队就得排队。
但是消息队列是有优先级的,队列不是只有一个。最新W3C标准,优化了之前宏任务微任务的架构:
-
每个任务都有一个任务类型,同一个类型的任务必须都在同一个队列,不同类型的任务可以分属于不同的队列(例如,网络任务和交互任务可以都放在A队列,但是有新的网络任务或者新的交互任务,那么必须放在A队列,而不能放在B队列,注意区分“一个队列只能放同一种任务”这种说法,这种是错误的理解),在一次时间循环中,可以根据实际情况从不同的队列中取出任务(这个就看不同的浏览的不同实现和策略了)
-
浏览器必须准备好一个微队列,微队列中的任务具有最高的优先级
-
不再只使用宏队列和微队列,两个队列无法应对当前浏览器的复杂度了
-
目前 chrome 的实现中,至少包含了下面的队列:
- 延时队列:用于存放计时器到达后的回调任务,优先级:中
- 交互队列:用于存放用户操作后产生的事件处理任务,优先级:高
- 微队列:用户存放需要最快执行的任务,优先级:最高
添加任务到微队列的主要方式: Promise、MutationObserver
例如:
// 将一个函数立即添加到微队列 Promise.resolve().then(() => {console.log(1) })
一个小题目,输出顺序是什么:
const a = () => {console.log(1)Promise.resolve().then(() => {console.log(2)}) }setTimeout (() => {console.log(3)Promise.resolve().then(a) }, 0)Promise.resolve().then(() => {console.log(4) })console.log(5)
- 浏览器还有很多队列,但是和开发关系弱一些,就不说了
三、总结
主要总结两部分
JS 的事件循环
事件循环又叫消息循环,是浏览器渲染主线程的工作方式。
在Chrome的源码中,主线程开启一个死循环for(;;)
,每次循环都会从消息队列中取出第一个任务并执行,而且他线程不需要和主线程通信,只需要将任务添加到消息队列即可让主线程执行对应 JS。
过去把消息队列简单分为宏队列和微队列,但现在已经无法满足复杂的浏览器环境,现在的消息队列有更多的分类。
JS 的计时器能精确计时吗
不能:
- 计算器硬件限制
- 操作系统本身有时间上的偏差,而 JS 计时器本质上是调用操作系统的时间系统
- 按照 W3C 的标准,浏览器实现的计时器,如果嵌套层级超过5层,则会带有 4 毫秒的最少时间,导致在计时时间少于 4 毫秒时有一定偏差
- 事件循环决定了计时器的回调只能在主线程空闲的时候运行,而不能直接打断主线程当前运行的任务
相关文章:

浏览器是怎么执行JS的?——消息队列与事件循环
看完渡一的课后,感觉这块内容确实非常重要,写 JS 的连 JS 的执行原理都不知道可不行。 事件循环 在写 JS 的时候,你有没有想过 JS 是按照什么顺序执行的?浏览器是怎么执行 JS 代码的?为什么有时候代码没有按照我们认为…...

IMU预积分的过程详解
一、IMU和相机数据融合保证位姿的有效性: 当运动过快时,相机会出现运动模糊,或者两帧之间重叠区域太少以至于无法进行特征匹配,所以纯视觉SLAM对快速的运动很敏感。而有了IMU,即使在相机数据无效的那段时间内ÿ…...

TypeScript中的类型运算符
类型运算符 1. keyof运算符 1. 简介 是一个单目运算符,接受一个对象类型作为参数,返回该对象的所有键名组成的联合类型。 type MyObj {foo: number,bar: string, };type Keys keyof MyObj; // foo|bar这个例子keyof MyObj返回MyObj的所有键名组成的…...

【蓝桥杯选拔赛真题03】C++输出字母Y 青少年组蓝桥杯C++选拔赛真题 STEMA比赛真题解析
目录 C/C++输出字母Y 一、题目要求 1、编程实现 2、输入输出 二、算法分析...

redis搭建集群-多实例快速搭建
1.基础的redis.conf的配置 # Redis configuration file example. # # Note that in order to read the configuration file, Redis must be # started with the file path as first argument: # # ./redis-server /path/to/redis.conf# Note on units: when memory size is ne…...

为什么进行压力测试? 有哪些方法?
在信息技术飞速发展的今天,软件系统的性能已经成为了用户满意度的决定性因素之一。而要确保一个系统在实际使用中能够稳定可靠地运行,压力测试就显得尤为关键。本文将深入探讨什么是压力测试,为什么它是如此重要,以及一些常见的压…...

Java开发者必备:支付宝沙箱环境支付远程调试指南
🔥博客主页: 小羊失眠啦. 🔖系列专栏: C语言、Linux、Cpolar ❤️感谢大家点赞👍收藏⭐评论✍️ 文章目录 前言1. 下载当面付demo2. 修改配置文件3. 打包成web服务4. 局域网测试5. 内网穿透6. 测试公网访问7. 配置二级…...

基于STM32温湿度传感器采集报警系统设计
**单片机设计介绍,1648【毕设课设】基于STM32温湿度传感器采集报警系统设计 文章目录 一 概要二、功能设计设计思路 三、 软件设计原理图 五、 程序程序 六、 文章目录 一 概要 这次的设计主要是通过读取DHT11和HCSR04的数值,(Proteus的传感器…...

檢測項目簡體字
某些項目可能要求代碼中不允許使用簡體字 安裝stcheck檢查 yarn add stcheck --dev在項目根目錄創建 st.config.json 文件 {"patterns": ["./**/*.(ts|js|tsx|jsx|vue|html)","!**/node_modules/**","!.git/**"],"gitignore&q…...

适用于嵌入式arm的ffmpeg编解码
在嵌入式arm应用开发中,经常会遇到需要处理视频的情况,这时候就需要强大的开源工具ffmpeg出马了。 这里可以下载到各个版本的ffmpeg。 ffmpeg各版本https://www.videohelp.com/software/ffmpeg/old-versions 现在ffmpeg更新较频繁,如…...

nlp与知识图谱代码解读_词嵌入
目录 词嵌入简单原理代码案例解读专业原理介绍场景 词嵌入 简单原理 可以使用一些比喻和生活中的例子: 老师: 你们还记得玩乐高积木的时候,每个积木块代表了一个特定的事物或形状吗?现在,想象一下,每个词…...

HarmonyOS 音频通话开发指导
常用的音频通话模式包括 VOIP 通话和蜂窝通话。 ● VOIP 通话:VOIP(Voice over Internet Protocol)通话是指基于互联网协议(IP)进行通讯的一种语音通话技术。VOIP 通话会将通话信息打包成数据包,通过网络进…...

LeetCode讲解篇之面试题 01.08. 零矩阵
文章目录 题目描述题解思路题解代码 题目描述 题解思路 遍历矩阵,若当前元素为零,则将该行和该列的第一个元素置零 遍历第一行,若当前元素为零,则将当前列置零 遍历第一列,若当前元素为零,则将当前行置零 …...

安装python虚拟环境
什么是虚拟环境: 虚拟环境的意义,就如同 虚拟机 一样,它可以实现不同环境中Python依赖包相互独立,互不干扰。 环境准备 安装python (到官网下载Download Python配置环境变量,cmd进入命令行输入 python…...

【App 抓包提示网络异常怎么破?】
背景 当你测试App的时候,想要通过Fiddler/Charles等工具抓包看下https请求的数据情况,发现大部分的App都提示网络异常/无数据等等信息。以“贝壳找房”为例: 455 x 705 Fiddler中看到的请求是这样的: 619 x 215 你可能开始找证书的问题:是不是Fiddler/Charles的证书没有…...

【开发篇】一、处理函数:定时器与定时服务
文章目录 1、基本处理函数2、定时器和定时服务3、KeyedProcessFunction下演示定时器4、process重获取当前watermark 前面API篇完结,对数据的转换、聚合、窗口等,都是基于DataStream的,称DataStreamAPI,如图: 在Flink…...

重入漏洞EtherStore
重入漏洞 // SPDX-License-Identifier: MIT pragma solidity ^0.8.13;contract EtherStore {mapping(address > uint) public balances;function deposit() public payable {balances[msg.sender] msg.value;}function withdraw() public {uint bal balances[msg.sender]…...

账号运营的底层逻辑---获客思维
什么是运营? 运营是做什么的? 什么是内容运营? 什么是活动运营? 一篇带你搞清楚所有的底层逻辑!...

Pinia中如何实现数据持久化操作
使用vue3中的pinia,我们可以在多个页面间共享数据,但是一旦我们关闭或刷新页面,这些数据就会丢失,因此,我们需要有一种数据持久化的解决方案。在记录vue3 使用vue3中的pinia,我们可以在多个页面间共享数据&…...

【owt-server】RTC视频接收调用流程学习笔记1: Call::CreateVideoReceiveStream 前后
WebRTC源码分析——Call模块 大神提到,call模块是在worker线程创建的。主要创建接收、发送流Call模块是WebRTC会话中不可缺少的一个模块,一个Call对象可以包含多个发送/接收流,且这些流对应同一个远端端点,并共享码率估计。 call中通过webrtc::VideoReceiveStream::Config …...

淘宝商品链接获取淘宝商品评论数据(用 Python实现淘宝商品评论信息抓取)
在网页抓取方面,可以使用 Python、Java 等编程语言编写程序,通过模拟 HTTP 请求,获取淘宝多网站上的商品详情页面评论内容。在数据提取方面,可以使用正则表达式、XPath 等方式从 HTML 代码中提取出有用的信息。值得注意的是&#…...

十九、类型信息(1)
本章概要 为什么需要 RTTI RTTI(RunTime Type Information,运行时类型信息)能够在程序运行时发现和使用类型信息 RTTI 把我们从只能在编译期进行面向类型操作的禁锢中解脱了出来,并且让我们可以使用某些非常强大的程序。对 RTTI …...

十八、字符串(3)
本章概要 正则表达式 基础创建正则表达式量词CharSequencePattern 和 Matcherfinde()组(Groups)start() 和 end()Pattern 标记split()替换操作reset()正则表达式与 Java I/0 正则表达式 很久之前,_正则表达式_就已经整合到标准 Unix 工具…...

基于SSM的酒店预约及管理系统设计与实现
末尾获取源码 开发语言:Java Java开发工具:JDK1.8 后端框架:SSM 前端:Vue 数据库:MySQL5.7和Navicat管理工具结合 服务器:Tomcat8.5 开发软件:IDEA / Eclipse 是否Maven项目:是 目录…...

MIxformerV2的onnx和tensorrt加速
MIxformerV2的onnx和tensorrt加速 注意事项 地址:github地址 注意事项 转换成onnx模型之前,最好现简化算法的源代码,使其结构干净。因为在进行onnx转换后,可能在进行onnx→trt时算子不匹配,这时就需要去查看模型的源…...

Kotlin 中let 、run 、with、apply、also的用法与区别
实例代码 User(val userName:String,val age:Int){fun printName(){println(userName)}fun getUserName():String{return userName}} let 函数 let 函数常用来与对象的空判断一起用,起到作用于的限定效果。let 函数最后一行返回值。(比如实例需要let函…...

PHP函数的定义与最简单后门原理
PHP函数的定义与最简单后门原理 文章目录 PHP函数的定义与最简单后门原理函数的定义函数调用的过程变量的范围局部变量全局变量 可变函数动态函数 PHP 最简单后门原理分析 函数的定义 使用function关键字来定义一个函数定义函数的函数名避开关键字形式参数是传递映射的实际参数…...

PlantSimulation访问本地Excel文件的方法
PlantSimulation访问本地Excel文件的方法 PlantSimulation访问本地Excel文件的方法PlantSimulation访问本地Excel文件的方法 //Param StatusTable,T_DataTable:object var T_DataTable:object:=DataTable IF NOT isComputerAccessPermittedMESSageBox("计算机访问被阻止,…...

使用微PE工具箱制作winU盘启动盘~重装系统
1.准备一个大于8G的U盘,为了保证传输和安装速度请确保U盘的质量。 2.鼠标右键点击U盘,进行格式化: 3.下载微PE工具箱: 微PE工具箱 - 下载 4.安装微PE工具箱:选择安装到U盘 5.选择U盘后,开始安装…...

漏洞复现-jquery-picture-cut 任意文件上传_(CVE-2018-9208)
jquery-picture-cut 任意文件上传_(CVE-2018-9208) 漏洞信息 jQuery Picture Cut v1.1以下版本中存在安全漏洞CVE-2018-9208文件上传漏洞 描述 picture cut是一个jquery插件,以友好和简单的方式处理图像,具有基于bootstrap…...