🧠🧠🧠由一个BUG引发的对JavaScript运行机制Event Loop的探索

问题背景

昨天我的同门**"导弹"** 哥遇到了一个问题,他的需求是需要将一个古老项目迁移并接入至Vue3。他采取的办法是利用jQuery EasyUI,将原项目进行转化。在利用jQuery配合EasyUIcombotree组件初始一个组合树形选择框 时出现了BUG。报错代码如下:

javascript 复制代码
onMounted(() => {
    getSymbol();
     nextTick(() => {
     const selectElement = biaohuifenleiSelect.value;
		if (selectElement) {
			$(selectElement).combotree('loadData', data);
			$(selectElement).combotree({
				onLoadSuccess: function () {
					// 默认加载一个符号集
					$(selectElement).combotree('setValue', "32_257");
					const tree = $(selectElement).combotree('tree');
					const node = tree.tree('getSelected');
					if (node) {
						tree.tree('expandTo', node.target);
						on_symbolset_selected(node);
					}
				},
			});
		}
     }
    
})

解决问题

报错显示我们并没拿到dom元素,但是这不可能啊,明明通过了if判断但是后续引入组件的时候还是报错了,属实不理解啊。经过不写努力的检索,我们找到了解决方案:

javascript 复制代码
	nextTick(()=>{
		setTimeout(()=>{
			// 相关函数逻辑
		},0)
	})

果然在代码中套用了一层setTimeout后,直接原地解决,但是这是为啥呢???

JavaScript运行机制的探索

Vue中,nextTick 是用来确保在DOM元素更新之后执行回调函数的工具。尽管在nextTick回调中我们能通过console.log打印出目标DOM元素,但仍然可能在后续操作第三方库时出现bug,这种BUG我们称之为由**"异步任务执行与浏览器渲染实际的差异"** 引起的浏览器渲染器错误。

即使 nextTickDOM 已更新到最新状态,它可能尚未被浏览器完全渲染。这种延迟可能导致后续操作依赖于渲染完成的情况下失败,例如:

  • 计算节点尺寸 (getBoundingClientRect) 返回错误值。
  • 操作未初始化完成的第三方插件或库。 解决办法:
javascript 复制代码
nextTick(() => {
  setTimeout(() => {
    // 确保此时 DOM 完全初始化
    const element = this.$refs.myElement;
    console.log(element.getBoundingClientRect());
  }, 0);
});

执行流程

假设我们的代码如下:

javascript 复制代码
nextTick(() => {
  console.log('nextTick callback');
  setTimeout(() => {
    console.log('setTimeout callback');
  }, 0);
});

代码流程:

  1. VueDOM更新被nextTick添加到微任务队列中;
  2. nextTick的回调(即console.log('nextTick callback'))会在DOM更新完成之后执行;
  3. setTimeout的回调会被添加到任务队列,需要等待微任务队列和同步代码全部完成;
  4. setTimeout的回调执行时,浏览器已完成全部DOM渲染。

注: 到我们setTimeout回调执行时,浏览器通常已经成了如下工作,如执行必要的布局计算、渲染更新后的DOM元素等,因此,像代码中getBoundingClienRect或其他查询更新样式操作,可以在setTimeout的回调中执行更加可靠。

上述执行流程设计到JavaScript中单线程、微任务队列、任务队列的相关知识,我们下边主要介绍一下缺失的基础:

JavaScript事件循环(Event Loop)

众所周知,JavaScript是一门单线程语言,但是却拥有着高性能和优秀的异步解决方案。这是由于JS引入了事件循环机制(Event Loop)。事件循环对于我们理解浏览器页面如何渲染,从何写出高性能的代码非常有帮助,对于前端工程师找工作也是必不可少的知识储备,话不多说直接开始~

浏览器多进程架构

我们常说什么多线程、多进程,那到底什么是进程和线程呢。同俗来讲:

复制代码
多进程:类似于一个工厂,每个工厂有每个工厂自己做的时间,互不影响
多线程:类似于每一个工厂里面的工人,一个工厂可以有很多个工人 

Chrome浏览器时使用多个进程来隔离不同的标签页,因此一个新的标签页就相当于一个一个新的进程,进程之间是不共享资源和地址空间的 ,故每个进行之间不会出现互相影响,而多线程每个线程共享着相同的资源和地址空间 ,所以线程之间会出现复杂问题。 最新Chrome浏览器架构如下:

从图中可以看出,最新的 Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。

Chrome浏览器多进程介绍:

  1. 浏览器进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
  2. 渲染进程:核心任务是将 HTMLCSSJavaScript 转换为用户可以与之交互的网页,排版引擎 BlinkJavaScript引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
  3. GPU 进程:其实,Chrome 刚开始发布的时候是没有GPU进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
  4. 网络进程:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
  5. 插件进程:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

浏览器渲染进程多线程

上边我们基本了解了浏览器渲染进程线程,下边我们主要针对其渲染进程 进行深入了解:

  1. GUI渲染线程: 负责渲染浏览器界面,解析HTMLCSS、构建DOM树和RenderObject树,布局和绘制等;当界面需要重绘(Repaint)或者需要重排(Reflow)时,就会执行该进程;而GUI渲染线程和JS引擎进程是互斥的,当JS引擎线程执行时GUI渲染线程被挂起 ,GUI更新任务会被保存在一个任务队列中等到JS引擎线程空闲时立即执行。
  2. JS引擎线程: 此部分也称之为JS内核,负责处理javascript脚本程序(V8引擎);JS引擎线程负责解析javascript脚本,运行代码;JS引擎一直在等待着任务的到来,然后加以处理,一个renderer进程中无论如何都只有一个JS线程在运行JS程序;由于GUI渲染线程和JS引擎线程是互斥的,所以如果JS执行时间如果过长,这样就会造成页面渲染不连贯,导致页面渲染加载阻塞。
  3. 事件触发线程: 该线程归属于浏览器而不是JS引擎,用于控制事件循环;当JS引擎执行代码,如setTimeout时(也可以是来自浏览器内核的其他线程,如鼠标点击,ajax请求等),会将对应的任务添加到事件线程当中,当对应的事件符合触发条件被触发时,事件线程会把事件添加到待处理事件队列的队尾,等待JS引擎的处理,由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)。
  4. 定时器触发线程: setIntervalsetTimeout所在的线程,浏览器定时计数器并不是由js引擎计数的(因为js是单线程的,如果处于阻塞状态就会影响计数的准确性),单独的线程来计时并触发定时(计时完毕后,添加到事件队列中,等待js引擎空闲后执行)。
  5. 异步HTTP请求线程: 在XMLHttpRequest在连接后通过浏览器开一个线程请求;将检测到状态变更时,如果有设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中,再有js引擎执行。

JS作为单线程语言怎么进行异步操作

众所周知大多数编程语言都是多线程,如JavaC++。那我们思考一个问题,为什么JS是门单线程语言?这主要是因为JS设计初衷 就是创造一门支持表单脚本的轻量化的浏览器脚本语言,而JS拥有操作DOM的功能,如果JS是多线程语言,那么当它们同时对页面同一个元素进行操作的话势必要建立一套复杂的优先级体系来区分究竟谁更有资格操作DOM。因此在早期对 JavaScript 定位本身就是一门简单的浏览器脚本语言的情况下单线程无疑是最简单且有效的解决方案。

那么JS作为一门单线程语言是如何执行异步操作的呢?比如setTimeoutsetInterval的实现原理是什么?要回答这些问题就需要引入 JavaScript 事件执行机制的核心------任务队列。

任务队列

JavaScript单线程语言的神奇操作下,我们的同步任务先予以执行,而我们的异步任务则会进入任务队列 之中,只有当"任务队列"通知主线程时,某个异步任务可以执行了,当前任务才会被加入主线程进行执行。用我们的大白话来说,任务队列就是一个编排函数执行顺序的一个队列。 在JavaScript中大概的事件执行步骤是什么呢?

  1. 先执行主线程,其实就是调用栈里的同步代码,系统会先予以执行;
  2. 等到主线程将调用栈里面的全部同步任务执行完毕后,事件循环此时开始执行
  3. 主线程发现调用栈为空后,会进行事件循环来观察要执行的事件回调。这个时候会进入任务队列当中,而事件循环检测到任务队列当中有事件,就进行我们最上面说到的操作,然后就取出相关事件任务放入调用栈中,由主线程执行。

事件循环

那怎么理解事件循环呢???我们都知道,当js代码在执行时,也就是往调用栈中放进去函数,然后再执行,但是,如果遇到异步函数,异步函数就会被挂起,进入所谓的任务队列中,并在调用栈为空的时候拿出来执行 :

流程如下:

step1:主线程读取JS代码,此时为同步环境,形成相应的执行上下文,也就是调用栈;

step2: 主线程遇到异步任务,将异步任务挂起;

step3: 将相应的异步任务推入任务队列;

step4: 主线程之中的任务执行完毕,查询任务队列,如果队列中存在任务,则取出一个任务推入主线程处理;

step5: 重复执行step2、3、4;称为事件循环。

微任务&宏任务

补充个知识点: 1、常见的宏任务: script(整体代码) setTimeout setInterval I/O`` UI交互事件 postMessage MessageChannel setImmediate(Node.js 环境)

2、常见的微任务: Promise.then Object.observe MutaionObserver process.nextTick(Node.js 环境)

Event-Loop 五部曲:

  1. 先执行同步代码,属于宏任务;
  2. 当执行完所有同步代码,执行栈为空,查询是否需要执行 异步代码;
  3. 执行完所有微任务;
  4. 微任务执行完毕后,有需要的话会渲染页面;
  5. 开始下一轮Event-Loop,执行宏任务的异步代码。

来段代码浅浅看一下:

javascript 复制代码
console.log(1)
setTimeout(() => {
  console.log(2)
  const promise1 = new Promise((resolve, reject) => {
    resolve()
  })
  promise1.then((res) => {
    console.log(3)
  })
})
const promise2 = new Promise((resolve, reject) => {
  resolve()
})
promise2.then((res) => {
  console.log(4)
})
console.log(5)

// ==========  执行结果如下 ==============
1
5
4
undefined
2
3

我们可以理解为,任务的队列就相当于事件循环。那么本示例中含有两轮事件循环。第一轮,系统看到setTimeout直接将其弄去第二轮执行,看到Promise后将它丢进微任务队列,自己接着执行 console.log(5)。执行后,执行微任务队列里的任务,打印出4。第一轮事件循环就此结束,执行setTimeout函数中的内容。

JavaScript事件循环(Event Loop)小结

综上所述,一个JS脚本本身对于浏览器而言就是一个宏任务 ,也是第一个宏任务,而处于其中的代码可能有3种:非异步代码、产生微任务的异步代码(promise等)、产生宏任务的异步代码(setTimeout、setInterval等)。

宏任务处于一个任务队列中,会先执行完上一个才会执行下一个宏任务,所以在JS脚本中,先执行非异步代码,再执行微任务代码,最后执行宏任务代码。然后开启下一个宏任务,继续按照这个顺序执行。 我们可以大概理解:一个宏任务执行的过程就相当于一个事件循环。

微任务是宏任务的一部分,每个宏任务中都包含一个微任务队列,在执行宏任务的过程中,如果有突发事件,那么就会将其加到微任务队列中,等待当前宏任务中的主函数执行完成后执行微任务,因此也就既解决了执行效率的问题又解决了实时性问题。

小结

所以从后续我们对于JavaScript事件循环、微任务、宏任务等诸多知识的理解,我们反过来看那个解决方案是不是迎刃而解了,当setTimeout回调执行时,浏览器通常已经完成了DOM渲染,解决了我们在将DOM传入给第三方库时,DOM元素为渲染完成的BUG

上述是小弟的一点点理解,希望能一次积累进步,若存在错误请指出,谢谢大家!!!


学习参考:

  1. 浏览器渲染进程多线程
  2. 详细浏览器的多进程架构
  3. JavaScript为什么是单线程的?
  4. 从JS的单线程特性说起,带你超快理解JavaScript中的事件循环机制(Event Loop)

文章荐读:

  1. 🔥🔥🔥Vite6 +TypeScript+Vue3+Tailwind+ESlint+Prettier+Husky搭建企业级前端项目
  2. 📸📸📸前端屏幕录制解决方案探索---------WebRTC,html2canvas和rrweb
  3. Vite6 +TypeScript+React18+Tailwind+ESlint+Prettier+Husky搭建企业级前端项目
相关推荐
无双_Joney13 分钟前
[更新迭代 - 1] Nestjs 在24年底更新了啥?(功能篇)
前端·后端·nestjs
在云端易逍遥15 分钟前
前端必学的 CSS Grid 布局体系
前端·css
EMT15 分钟前
在 Vue 项目中使用 URL Query 保存和恢复搜索条件
javascript·vue.js
ccnocare16 分钟前
选择文件夹路径
前端
艾小码16 分钟前
还在被超长列表卡到崩溃?3招搞定虚拟滚动,性能直接起飞!
前端·javascript·react.js
闰五月17 分钟前
JavaScript作用域与作用域链详解
前端·面试
泉城老铁21 分钟前
idea 优化卡顿
前端·后端·敏捷开发
前端康师傅21 分钟前
JavaScript 作用域常见问题及解决方案
前端·javascript
司宸22 分钟前
Prompt结构化输出:从入门到精通的系统指南
前端