【JS】深入了解JS运行机制

前言

很多小伙伴在面试被问到JS运行机制时,脑海中浮现的就是JS单线程、事件循环机制、宏任务、微任务....,可是具体深入下去便支支吾吾了,所谓好记性不如烂笔头,于是我打算自己整理一篇详细的文章分享给各位。那么话不多说,开始今天的干货分享。

进程和线程

什么是进程

狭义上来讲,进程是正在运行程序的实例

广义上来讲,进程是一个可以独立运行且拥有自己的资源空间的任务程序,是CPU进行资源分配的最小单位

我们自己的计算机上就有很多进程,比如我此时边听网易云EMO歌曲时边敲着代码,网易云音乐和我的代码编辑器就是两个不同的进程。CPU会为这两个进程分配独立的资源空间

什么是线程

线程CPU调度的最小单位

线程包含在进程中,一个进程中可以有多个线程,这些线程共享进程的资源

进程相比,线程的创建和销毁开销更小。例如,我打开我的音乐播放器时,存在用户交互的线程,以及音乐播放的线程,多个线程分工明确,协同工作

二者的区别

  • 资源分配

进程作为操作系统分配资源的最小单位,拥有独立的资源空间,不同进程之间相互隔离,线程共享同一进程的所属资源,共同使用进程的空间、内存、打开的文件等

  • 创建与销毁

进程相比于线程的创建与销毁开销更大,创建进程需要分配独立的内存空间(包括地址空间、内存、文件描述符、PCB等),销毁时需要回收其占用的资源,创建线程只需要在其所属地址空间内分配空间资源,销毁同理,开销小

多进程和多线程

多进程:在同一时间,计算机系统允许两个或多个程序同时运行。比如我们可以边听音乐,边敲代码,两个进程独立运行,互不干扰

多线程:同一个程序中可以启用多个不同的线程,各司其职,也就是单个程序可以创建多个并行线程来完成各自的任务

JS为什么是单线程

JS单线程,首先是与它的用途有关,作为浏览器的脚本语言,主要用于与用户的交互以及DOM的操作有关。试想一下,如果JS是多线程,将面对什么复杂的问题

例如,若 JS 同时存在两个线程,一个线程在某个 DOM 节点上添加内容,而另一个线程删除了这个节点,此时浏览器将难以确定以哪个线程的操作结果为准

这时候可能有人会说了,JS还有Worker线程

没错,虽然 HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但子线程完全受主线程控制,并且不能操作 DOM。所以,这一标准并未改变 JavaScript 单线程的本质 。

浏览器

作为前端,每天低头不见抬头见的就是浏览器了,那么接下来就让我们来看看浏览器里包含哪些进程,为什么会有这么多进程

浏览器是多进程的

这个想必大家早就知道了,比如我们浏览器打开多个TAB页面,每一个TAB页面就是一个进程。例如 Chrome 浏览器,一个页面脚本陷入死循环导致崩溃,其他页面仍能正常浏览,不会受到牵连。

浏览器包含哪些进程

  • Browser 进程
    • 浏览器的主进程,负责协调、主控浏览器的整体运行,且只有一个
    • 负责览器界面的显示,处理与用户的交互,如前进、后退等操作
    • 管理各个页面,负责创建和销毁其他进程
    • 管理网络资源,进行资源下载
    • 负责将 渲染(Renderer) 进程得到的内存中的 位图(Bitmap) 绘制到用户界面上
  • 第三方插件进程
    • 每种类型的插件对应一个进程,仅在使用该插件时才会创建
  • GPU 进程
    • 最多一个,负责 3D 绘制等图形处理任务
  • 渲染进程(Renderer 进程)
    • 也被称为浏览器内核
    • 默认情况下每个 Tab 页面一个进程,互不影响
    • 负责页面渲染、脚本执行、事件处理等,包括浏览器渲染过程,以及执行 JavaScript 代码来实现页面的动态效果和交互功能等

浏览器为什么要有这么多进程

  • 稳定性

首先就是考虑稳定性,若浏览器是单进程,一个页面或者插件崩溃,就可能导致整个浏览器崩溃。多进程下,每个页面、插件都运行在自己独立进程中,提高用户多页面浏览的体验

  • 安全性

正是因为浏览器每个进程之间相互隔离,每个进程都有自己独立的内存空间和资源,这样可以防止恶意网站或插件通过漏洞攻击获取用户敏感信息或者攻击其他程序进行破坏

  • 充分利用多核CPU

现代计算机的CPU通常都有多个核心,多进程架构可以让浏览器将不同的任务分配到不同的进程中,并在不同CPU核心上并行执行。

渲染进程(Rendere)的主要线程

GUI渲染线程

  • 负责渲染页面的HTML、CSS、构建DOM树、CSSOM树、合并成渲染树(Render Tree)、页面布局构建和绘制(回流重绘)
  • 与JS引擎线程互斥。因为JS可以操作DOM,如果我们在修改这些DOM属性的同时去对其进行渲染,渲染结果就会出乎意料。所以到JS引擎执行时,GUI渲染线程会被挂起,GUI更新会被放进一个队列中,等待JS引擎执行完毕后再去进行GUI渲染

JS引擎线程

  • 主线程,负责解析和执行JavaScript代码。常见的引擎有Chrome的V8引擎,Safari的Webkit引擎
  • JavaScript是单线程的,这意味着同一时间只能执行一个任务。一个TAB页无论什么时候都只有一个JS线程在运行JS程序
  • GUI线程与JS引擎线程互斥,例如浏览器遇到<script>标签,就会停止GUI渲染,然后JS引擎线程开始工作,等JS执行完毕,JS引擎线程停止工作,GUI渲染线程再开始工作,继续渲染下面的内容

事件触发线程

  • 属于浏览器而不是JS引擎,用来控制事件循环,并且管理着一个事件队列
  • 当一个事件被触发时,该线程会将该事件添加到对应的线程中(比如遇到一个定时器,就将其添加到定时器触发线程),等该事件有了结果,便把相关事件的回调函数添加到事件队列,等待JS引擎空闲时执行

定时器触发线程

  • 处理setTimeout和setInterval等定时任务。属于浏览器而不是通过JavaScript计数的(因为JavaScript是单线程的,如果处于阻塞线程状态会影响计数结果)
  • 通过单独线程来计时并触发定时,当时间达到时,将相应回调函数加入到事件队列中,等待JS引擎空闲时执行。
  • 由于任务队列的存在,定时器的执行时间也可能会有一定误差,W3C标准规定要求setTimeout中低于4ms的时间间隔算为4ms

异步HTTP请求线程

  • 在发起异步HTTP请求时,浏览器会开启一个异步HTTP请求线程,在后台进行请求的处理。当请求完成(如收到服务器响应)时,该线程会将回调函数添加到任务队列,等待JS引擎处理
  • 他会持续监听XMLHttpRequest状态码的变化,根据不同的状态码来进行不同的操作

事件循环机制(Event Loop)

我们知道,JS中的Evnet Loop分为同步任务异步任务 (宏任务(Macrotask)和微任务(Microtask))

首先,同步任务在主线程上执行(这里也就是JS引擎线程),会形成一个执行栈

主线程之外,事件触发线程还存在一个任务队列(task queue),只要异步任务有了运行结果,就在"任务队列"之中放置一个事件

一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",询问里面是否还有需要执行的任务,如果有则将其放到执行栈中等待执行,如果没有则会一直询问,直到有为止

宏任务(macrotask)

macrotask也被称为task

执行栈中执行的代码就是一个宏任务(包括每次从事件队列中获取回调存放到执行栈中执行),此外遇到<script>标签,也代表着一次宏任务的开启。

由于,JS引擎线程和GUI渲染线程是互斥的关系,所以当JS引擎线程工作时,GUI渲染线程会被挂起,等待JS引擎线程执行完毕,GUI渲染线程开始工作,GUI渲染工作完成后JS引擎线程继续工作,反复轮询。

宏任务 => GUI渲染 => 宏任务 => ....

常见的宏任务有以下几类:

  • 定时器相关

    • setTimeout
    • setInterval
  • I/O 操作相关

    • 文件读取 :在 Node.js 中,使用 fs.readFile
    • 网络请求fetchXMLHttpRequestaxios
  • 浏览器相关

    • UI 渲染: 更新DOM节点、样式等
    • 用户交互事件 :鼠标点击、键盘输入、触摸屏幕等
  • Node.js 特定

    • setImmediate:在当前事件循环的末尾,下一次事件循环开始之前执行回调函数,
    • process.nextTick :在当前操作结束后、下一个事件循环开始前执行,并且它的回调函数执行优先级高于 Promise 微任务和其他宏任务。

微任务(microtask)

microtask也被称为jobs

再开启一轮宏任务时,首先会执行宏任务中的同步代码,然后在执行其中的微任务,如果产生新的微任务会继续执行微任务直到所有微任务执行完毕

上面我们说宏任务执行完毕后,会进行GUI渲染,所以宏任务执行完,会在GUI渲染前将所有的微任务执行完毕

宏任务 => 微任务 => GUI渲染 => 宏任务 => ....

常见的宏任务有以下几类:

  • Promise 的回调thencatchfinally 方法的回调函数都会作为微任务执行
  • MutationObserver 的回调: EventLoop的一种实现,将回调函数放入到DOM更新后的微任务队列,保证回调函数在DOM更新后触发
  • process.nextTick(Node.js 环境) :将回调函数添加到一个特殊的微任务队列中,这个队列的优先级高于 Promise 微任务队列
  • queueMicrotask:ES2020 引入的一个标准方法,用于将一个回调函数添加到微任务队列中

图解完整JS事件循环机制

首先,第一个<script>标签作为宏任务开启,开始执行的时候,会将所有代码分为同步任务异步任务

所有同步任务进入执行栈执行

所有异步任务被分为宏任务微任务,被放入到对应的Event Table中并注册回调函数,当对应事件有结果的时候,事件触发线程会将其放入到对应的宏任务队列或者微任务队列中,按照顺序执行宏任务中的微任务,所有微任务执行完毕后,GUI渲染线程开始工作,GUI渲染线程工作完成后将下一个宏任务推入到执行栈中执行,开启下一轮宏任务,反复轮询

接下来,我们来看一段Event Loop代码

js 复制代码
console.log('1. 整体 script 开始执行');

// 微观任务:Promise 的回调
Promise.resolve().then(() => {
    console.log('3. Promise 回调执行(微观任务)');
});

// 宏观任务:setTimeout
setTimeout(() => {
    console.log('5. setTimeout 回调执行(宏观任务)');
    // 在 setTimeout 回调中创建新的微观任务
    Promise.resolve().then(() => {
        console.log('6. 在 setTimeout 中创建的 Promise 回调执行(微观任务)');
    });
}, 0);

// 异步 http 请求任务
fetch('https://jsonplaceholder.typicode.com/todos/1')
  .then(response => response.json())
  .then(data => {
        console.log('7. 异步 HTTP 请求响应数据处理(宏观任务回调)');
    })
  .catch(error => {
        console.error('异步 HTTP 请求出错:', error);
    });

// 微观任务:Promise 的回调
Promise.resolve().then(() => {
    console.log('4. 另一个 Promise 回调执行(微观任务)');
});

console.log('2. 整体 script 执行结束');    

上述代码执行过程

首先,从上往下执行,执行console.log('1. 整体 script 开始执行')

遇到第一个 Promise.resolve().then() 时,将其回调函数放入微观任务队列

遇到setTimeout,定时器线程开始计时,计时结束后将setTimeout的回调函数传递给事件触发线程,事件触发线程将其放入宏观任务队列

遇到fetch 异步http请求,将请求任务交给 http 请求线程,http 请求线程发起一个异步 http 请求,当请求完成后,http 请求线程会将 fetchthen 回调函数传递给事件触发线程,由事件触发线程将其添加到宏观任务队列中

遇到第二个 Promise.resolve().then() 时,其回调函数被放入微观任务队列

继续执行代码,输出 2. 整体 script 执行结束,当前宏观任务执行完毕,开始检查微观任务队列

依次执行微观任务队列中的任务,输出 3. Promise 回调执行(微观任务)4. 另一个 Promise 回调执行(微观任务)

依次执行宏观任务队列中的任务,先执行 setTimeout 回调,输出 5. setTimeout 回调执行(宏观任务),其内部的微观任务随后执行,输出 6. 在 setTimeout 中创建的 Promise 回调执行(微观任务)

继续执行宏观任务队列中的任务,输出 7. 异步 HTTP 请求响应数据处理(宏观任务回调)

至此,主线程执行栈中的回调函数执行完毕,JS引擎线程已经空闲,开始向事件触发线程询问其事件队列中是否有需要执行的回调函数,如果有,将事件队列中的回调事件加入到执行栈中,开始执行回调,如果没有,则反复询问,直到有为止

总结

首先,JS作为浏览器的脚本语言被打造出来,为了避免多个线程操作DOM可能导致的冲突问题,其被设计成单线程。而浏览器本身的多进程且多线程的,进程是CPU分配资源和调度的最小单位,线程是CPU执行代码的最小单元,同一进程下的多个进程共享改进程的内存资源。浏览器包含多个进程,如 Browser 进程、第三方插件进程、GPU 进程、渲染进程等。其中渲染进程(Render 进程)最重要,它负责页面渲染、脚本执行、事件处理等工作。渲染进程中有多个线程,包括 JS 引擎线程(主线程)、GUI 渲染线程、事件触发线程、定时器线程、异步 HTTP 请求线程等,各线程分工明确。当运行代码时,代码会被分为同步任务和异步任务。同步任务会进入执行栈由 JS 引擎执行,而异步任务又分为宏任务和微任务。对于宏任务,不同类型由不同线程处理,例如定时器线程处理 setTimeoutsetInterval,异步 HTTP 请求线程处理网络请求,当这些任务有结果时,会将回调函数交给事件触发线程存入到宏任务队列中。微任务(如 Promise 的回调、MutationObserver 的回调等)在创建时会直接将回调函数添加到微任务队列。事件触发线程主要负责监听各种事件(如鼠标点击、键盘输入等),当事件触发时,将对应的事件处理函数添加到宏任务队列。当执行栈中的同步任务执行完毕后,JS 引擎线程会检查微任务队列,如果有微任务,会依次执行微任务队列中的所有任务,直到微任务队列为空。之后,若浏览器判断需要进行 UI 渲染(并非每次都渲染),GUI 渲染线程会开始工作进行页面渲染。由于 JS 引擎线程和 GUI 渲染线程互斥,JS 引擎线程工作时,GUI 渲染线程会被挂起。最后,JS 引擎线程会从宏任务队列中取出下一个宏任务执行,如此循环往复,这就是浏览器的事件循环(Event Loop)机制。

相关推荐
Json_3 分钟前
使用vue2技术写了一个纯前端的静态网站商城-鲜花销售商城
前端·vue.js·html
1024熙3 分钟前
【Qt】——理解信号与槽,学会使用connect
前端·数据库·c++·qt5
少糖研究所4 分钟前
ColorThief库是如何实现图片取色的?
前端
冴羽5 分钟前
SvelteKit 最新中文文档教程(22)—— 最佳实践之无障碍与 SEO
前端·javascript·svelte
ZYLAB7 分钟前
我写了一个简易的 SEO 教程,希望能让新手朋友看完以后, SEO 能做到 80 分
前端·seo
小桥风满袖13 分钟前
Three.js-硬要自学系列4 (阵列立方体和相机适配、常见几何体、高光材质、lil-gui库)
前端·css
深海丧鱼14 分钟前
什么!只靠前端实现视频分片?
前端·音视频开发
ohMyGod_12317 分钟前
Vue如何实现样式隔离
前端·javascript·vue.js
涵信21 分钟前
第二十节:项目经验-描述一个React性能优化案例
前端·react.js·性能优化
Danny_FD26 分钟前
前端中的浮动、定位与布局
前端·css