问题背景
昨天我的同门**"导弹"** 哥遇到了一个问题,他的需求是需要将一个古老项目迁移并接入至Vue3
。他采取的办法是利用jQuery EasyUI
,将原项目进行转化。在利用jQuery
配合EasyUI
的combotree
组件初始一个组合树形选择框 时出现了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
我们称之为由**"异步任务执行与浏览器渲染实际的差异"** 引起的浏览器渲染器错误。
即使 nextTick
中 DOM
已更新到最新状态,它可能尚未被浏览器完全渲染。这种延迟可能导致后续操作依赖于渲染完成的情况下失败,例如:
- 计算节点尺寸 (
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);
});
代码流程:
Vue
的DOM
更新被nextTick
添加到微任务队列中;nextTick
的回调(即console.log('nextTick callback')
)会在DOM
更新完成之后执行;setTimeout
的回调会被添加到任务队列,需要等待微任务队列和同步代码全部完成;- 当
setTimeout
的回调执行时,浏览器已完成全部DOM
渲染。
注: 到我们setTimeout
回调执行时,浏览器通常已经成了如下工作,如执行必要的布局计算、渲染更新后的DOM
元素等,因此,像代码中getBoundingClienRect
或其他查询更新样式操作,可以在setTimeout
的回调中执行更加可靠。
上述执行流程设计到JavaScript
中单线程、微任务队列、任务队列的相关知识,我们下边主要介绍一下缺失的基础:
JavaScript事件循环(Event Loop)
众所周知,JavaScript
是一门单线程语言,但是却拥有着高性能和优秀的异步解决方案。这是由于JS
引入了事件循环机制(Event Loop
)。事件循环对于我们理解浏览器页面如何渲染,从何写出高性能的代码非常有帮助,对于前端工程师找工作也是必不可少的知识储备,话不多说直接开始~
浏览器多进程架构
我们常说什么多线程、多进程,那到底什么是进程和线程呢。同俗来讲:
多进程:类似于一个工厂,每个工厂有每个工厂自己做的时间,互不影响
多线程:类似于每一个工厂里面的工人,一个工厂可以有很多个工人
Chrome
浏览器时使用多个进程来隔离不同的标签页,因此一个新的标签页就相当于一个一个新的进程,进程之间是不共享资源和地址空间的 ,故每个进行之间不会出现互相影响,而多线程每个线程共享着相同的资源和地址空间 ,所以线程之间会出现复杂问题。 最新Chrome
浏览器架构如下:
从图中可以看出,最新的 Chrome
浏览器包括:1 个浏览器(Browser
)主进程、1 个 GPU
进程、1 个网络(NetWork
)进程、多个渲染进程和多个插件进程。
Chrome浏览器多进程介绍:
- 浏览器进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
- 渲染进程:核心任务是将
HTML
、CSS
和JavaScript
转换为用户可以与之交互的网页,排版引擎Blink
和JavaScript
引擎V8
都是运行在该进程中,默认情况下,Chrome
会为每个Tab
标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。GPU
进程:其实,Chrome
刚开始发布的时候是没有GPU
进程的。而GPU
的使用初衷是为了实现3D CSS
的效果,只是随后网页、Chrome
的UI
界面都选择采用GPU
来绘制,这使得GPU
成为浏览器普遍的需求。最后,Chrome
在其多进程架构上也引入了GPU
进程。- 网络进程:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
- 插件进程:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。
浏览器渲染进程多线程
上边我们基本了解了浏览器渲染进程线程,下边我们主要针对其渲染进程 进行深入了解:
GUI
渲染线程: 负责渲染浏览器界面,解析HTML
、CSS
、构建DOM
树和RenderObject
树,布局和绘制等;当界面需要重绘(Repaint
)或者需要重排(Reflow
)时,就会执行该进程;而GUI
渲染线程和JS
引擎进程是互斥的,当JS
引擎线程执行时GUI
渲染线程被挂起 ,GUI更新任务会被保存在一个任务队列中等到JS
引擎线程空闲时立即执行。JS
引擎线程: 此部分也称之为JS
内核,负责处理javascript
脚本程序(V8
引擎);JS
引擎线程负责解析javascript
脚本,运行代码;JS引擎一直在等待着任务的到来,然后加以处理,一个renderer
进程中无论如何都只有一个JS
线程在运行JS
程序;由于GUI
渲染线程和JS
引擎线程是互斥的,所以如果JS
执行时间如果过长,这样就会造成页面渲染不连贯,导致页面渲染加载阻塞。- 事件触发线程: 该线程归属于浏览器而不是
JS
引擎,用于控制事件循环;当JS
引擎执行代码,如setTimeout
时(也可以是来自浏览器内核的其他线程,如鼠标点击,ajax
请求等),会将对应的任务添加到事件线程当中,当对应的事件符合触发条件被触发时,事件线程会把事件添加到待处理事件队列的队尾,等待JS
引擎的处理,由于JS
的单线程关系,所以这些待处理队列中的事件都得排队等待JS
引擎处理(当JS
引擎空闲时才会去执行)。 - 定时器触发线程:
setInterval
和setTimeout
所在的线程,浏览器定时计数器并不是由js
引擎计数的(因为js
是单线程的,如果处于阻塞状态就会影响计数的准确性),单独的线程来计时并触发定时(计时完毕后,添加到事件队列中,等待js
引擎空闲后执行)。 - 异步
HTTP
请求线程: 在XMLHttpRequest
在连接后通过浏览器开一个线程请求;将检测到状态变更时,如果有设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中,再有js引擎执行。
JS作为单线程语言怎么进行异步操作
众所周知大多数编程语言都是多线程,如Java
,C++
。那我们思考一个问题,为什么JS是门单线程语言?这主要是因为JS
的设计初衷 就是创造一门支持表单脚本的轻量化的浏览器脚本语言,而JS
拥有操作DOM
的功能,如果JS
是多线程语言,那么当它们同时对页面同一个元素进行操作的话势必要建立一套复杂的优先级体系来区分究竟谁更有资格操作DOM
。因此在早期对 JavaScript
定位本身就是一门简单的浏览器脚本语言的情况下单线程无疑是最简单且有效的解决方案。
那么JS
作为一门单线程语言是如何执行异步操作的呢?比如setTimeout
和setInterval
的实现原理是什么?要回答这些问题就需要引入 JavaScript
事件执行机制的核心------任务队列。
任务队列
JavaScript
单线程语言的神奇操作下,我们的同步任务先予以执行,而我们的异步任务则会进入任务队列 之中,只有当"任务队列"通知主线程时,某个异步任务可以执行了,当前任务才会被加入主线程进行执行。用我们的大白话来说,任务队列就是一个编排函数执行顺序的一个队列。 在JavaScript
中大概的事件执行步骤是什么呢?
- 先执行主线程,其实就是调用栈里的同步代码,系统会先予以执行;
- 等到主线程将调用栈里面的全部同步任务执行完毕后,事件循环此时开始执行
- 主线程发现调用栈为空后,会进行事件循环来观察要执行的事件回调。这个时候会进入任务队列当中,而事件循环检测到任务队列当中有事件,就进行我们最上面说到的操作,然后就取出相关事件任务放入调用栈中,由主线程执行。
事件循环
那怎么理解事件循环呢???我们都知道,当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
五部曲:
- 先执行同步代码,属于宏任务;
- 当执行完所有同步代码,执行栈为空,查询是否需要执行 异步代码;
- 执行完所有微任务;
- 微任务执行完毕后,有需要的话会渲染页面;
- 开始下一轮
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
。
上述是小弟的一点点理解,希望能一次积累进步,若存在错误请指出,谢谢大家!!!
学习参考:
文章荐读: