简单聊聊JavaScript中的事件循环
作者:mick
为什么js是单线程的
我们首先要考虑下js作为浏览器脚本语言,主要用途就是和用户互动和操作DOM。比如js同时有两个线程,两个线程同时操作同一个DOM,比如一个给DOM添加内容,一个移除DOM,那到底该听谁的呢?所以这就决定了它只能是单线程,否则就会出现一些奇怪的问题。
浏览器
我们每打开一个tab页就会产生一个进程
浏览器都包含哪些进程呢
浏览器进程
- 浏览器的主进程(负责协调、主控),该进程只有一个
- 负责浏览器界面显示,用户交互。如前进,后退等
- 负责各个页面的管理,创建和销毁其他进程
- 将渲染(renderer)进程得到的内存中的Bitmap(位图),绘制到用户界面上
- 网络资源的管理,下载等
第三方插件进程
每种类型的插件对应一个进程,当使用该插件时才创建。因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响
GPU进程
该进程只有一个,用于3D绘制等
渲染进程
- 通常所说的浏览器内核(Renderer进程,内部是多线程)
- 每个Tab页面都有一个渲染进程,互不影响
- 主要作用为页面渲染,脚本执行,事件处理等
网络进程
主要负责页面的网络资源加载。
如果浏览器是单进程,那么当一个tab页面崩溃了,就会影响到整个浏览器。同时如果插件崩溃了也会影响整个浏览器。浏览器进程有很多,每个进程又有很多的线程,都会占用内存。进程之间的内容相互隔离,这是为了保护操作系统中进行互不干扰的技术,每一个进程只能访问自己占有的数据,也就避免了进程A写入数据到进程B的情况。因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。
渲染进程
页面的渲染、js的执行、事件的循环、都在渲染进程中执行,所以重点看下渲染进程。渲染进程是多线程的,下面看下比较常用的几个线程
GUI线程
负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。
当修改了一些元素的颜色或者背景色,页面就会重绘(Repaint)
当修改元素的尺寸,页面就是重排也叫回流(Reflow)
当页面需要重绘和重排的时候GUI线程执行,绘制页面
重绘和重排的成本比较高,尽量避免重绘和重排
GUI线程和JS引擎线程是互斥的
- 当JS引擎执行时,GUI线程会被挂起
- GUI更新会被保存在一个队列中,等JS引擎空闲的时候立即被执行。
JS引擎线程
JS引擎线程就是JS内核,负责处理JavaScript脚本程序(例如V8引擎)
JS引擎线程负责解析JavaScript脚本,运行代码
JS引擎一直等待任务队列的到来,然后加以处理
- 浏览器同时只能有一个JS引擎线程在运行JS程序,所以JS是单线程运行的
- 一个Tab页在Renderer进程中无论什么时候都只有一个JS线程在运行JS程序
GUI线程和JS引擎是互斥的,JS引擎线程会阻塞GUI渲染线程
如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
事件触发线程
- 属于浏览器而不是JS引擎,用来控制事件循环,并且管理着一个事件队列(task queue)
- 当JS引擎执行事件绑定和一些异步操作如SetTimeOut时,也可能是浏览器内核的其他线程,如鼠标点击、Ajax异步请求等,会走事件触发线程将对应的事件添加到对应的线程中(比如定时器操作,便把定时器事件添加到定时器线程),等异步事件有了结果,便把他们的回调操作添加到事件队列,等待js引擎线程空闲时来处理。
- 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
- JS是单线程,所以这些待处理队列中的事件都会排队等待JS引擎处理
定时触发器线程
- setInterval与setTimeout所在线程
- 浏览器定时计数器并不是由JS引擎计数的(因为JS引擎是单线程的,如果处于阻塞线程状态就会影响计时的准确性)
- 通过单独线程来计时并触发定时(计时完毕后,添加到事件触发线程的事件队列中,等待JS引擎空闲后执行)
- 注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。
异步HTTP请求线程
- 在XMLHttpRequest在连接后是通过浏览器新开的一个线程请求
- 将检测到状态变更时,如果设置有回调函数,异步线程就会产生状态变更事件,将这个回调再放入事件队列中再由JS引擎执行
- 简单说就是当执行到一个http异步请求时,就把异步请求事件添加到异步请求线程,等收到响应(准确来说应该是http状态变化),再把回调函数添加到事件队列,等待js引擎线程来执行
下面就来谈谈我们的重头戏
事件循环
- JS被分为同步任务和异步任务。
- 同步任务在主线程(JS引擎线程)上执行,形成一个执行栈。
- 除了主线程之外,事件触发线程管理这一个任务队列,只要异步任务有了结果,就会在任务队列中放入异步任务的回调。
- 当执行栈中所有的同步任务执行完毕后,就会读取任务队列,将可运行的异步任务(任务队列中的事件回调,只要任务队列中有事件回调,就说明可以执行)添加到执行栈中,开始执行。 我们画个图来表示一下
let setTimeoutCallBack = function() { console.log('我是定时器回调'); }; let httpCallback = function() { console.log('我是http请求回调'); } // 同步任务 console.log('我是同步任务1'); // 异步定时任务 setTimeout(setTimeoutCallBack,1000); // 异步http请求任务 ajax.get('/info',httpCallback); // 同步任务 console.log('我是同步任务2');
我们来看下这段代码。解析一下执行过程
- js会从上到下依次执行,可以先理解为这段代码的执行环境就是主线程,也就是当前执行栈
- 首先 执行
console.log('我是同步任务1');
- 然后执行到
setTimeout
时候,会交给定时器线程,并告诉定时器线程在1s后将setTimeoutCallBack
回调交给事件触发线程,1s后事件触发线程把这个回调添加到了任务队列中等待执行 - 接着执行
ajax
,会交给异步HTTP请求线程发送网络请求,请求成功后,将回调httpCallback
交给事件触发线程并放入任务队列中等待执行。 - 接着执行
console.log('我是同步任务2');
- 此时主线程执行栈执行完毕,js引擎线程已经空闲,开始询问事件触发线程的任务队列中是否有需要执行的回调,如果有则将任务队列中的回调事件加入执行栈中,开始执行,如果任务队列中没有需要执行的回调,js引擎会不断的发起询问,直到有为止。
浏览器上的所有线程是的行为都很单一。
- 定时触发线程只管理定时器并只关心定时不关注结果,定时结束后就把回调交给事件触发线程
- 异步HTTP请求线程只关心http请求不关心结果,请求结束后就把回调交给时间触发线程
- 事件触发线程只将异步回调加入事件队列
- JS引擎线程则执行执行栈中的事件,执行栈中的代码执行完毕后,就会询问事件触发线程的事件队列是否有回调需要执行,然后把事件队列中的事件添加到执行栈中执行,这样的反反复复的行为我们称为事件循环。
了解了事件循环下面看下宏任务和微任务
宏任务 微任务
宏任务
- 渲染事件(如解析 DOM、计算布局、绘制)
- 用户交互事件(如鼠标点击、滚动页面、放大缩小等)
- JavaScript 脚本执行事件
- 网络请求完成、文件读写完成事件
为了协调这些任务能够有序的在主线程上执行,页面进行引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,主线程不断的从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务
常见的宏任务:
- 主代码块
- setTimeOut
- setInterval
- setImmediate -- node
- requestAnimationFrame -- 浏览器 JS引擎线程和GUI渲染线程是互斥的,浏览器为了能够使宏任务和DOM任务有序的进行,会在一个宏任务结束后,在一个宏任务执行前,GUI渲染线程开始工作,对页面进行渲染
微任务
微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束后之前。
异步回调有两种方式
- 把异步回调函数封装成一个宏任务,添加到消息队列中,当循环系统执行到该任务的时候执行回调函数
- 执行时机是在主函数执行结束之后、当前宏任务结束之前执行回调函数,这通常都是以微任务的形式体现的
我们知道宏任务结束后,会执行渲染,然后执行下一次宏任务,微任务可以理解为当前宏任务执行后立即执行的任务。
常见的微任务:
- process.nextTick()--node
- Promise.then()
- catch
- finally
- Object.observe
- MutationObserver
当执行完一个宏任务之后,会立即执行期间所产生的所有微任务,然后执行渲染
宏任务微任务的执行流程
浏览器首先会执行一个宏任务,然后执行当前执行栈所产生的微任务,然后再渲染页面,然后再执行下一个宏任务
面试题
function test() { console.log(1) setTimeout(function () { // timer1 console.log(2) }, 1000) } test(); setTimeout(function () { // timer2 console.log(3) }) new Promise(function (resolve) { console.log(4) setTimeout(function () { // timer3 console.log(5) }, 100) resolve() }).then(function () { setTimeout(function () { // timer4 console.log(6) }, 0) console.log(7) }) console.log(8)
下面我们来分析一下整体的流程
首先应该找到同步任务先执行
- 当test()调用的时候
console.log(1)
会先执行,打印1。而setTimeout(我们记作timer1)作为宏任务加入宏任务队列 - test下面的setTimtout(我们记作timer2)作为宏任务加入宏任务队列
- new Promise()的executer中中也会当做同步任务执行 所以
console.log(4)
打印4。而setTimeout(我们记作timer3)作为宏任务加入宏任务队列 - 接着promise.then()作为微任务加入微任务队列
- 最后
console.log(8)
作为同步任务执行,打印8
我们再看异步任务
- 我们当前的执行栈本身就是宏任务,宏任务执行完了之后应该立即执行微任务,这里的微任务只有Promise.then(),而setTimeout(我们记作timer4)作为宏任务加入宏任务队列,然后执行
console.log(7)
打印7 - 微任务执行完毕之后,要执行GUI渲染,我们代码中没有
- 执行宏任务队列,此时宏任务队列里面有 timer1、timer2、timer3、timer4
- 按照定时时间,可以排列为:timer2、timer4、timer3、timer1依次拿出放入执行栈末尾执行
- 执行timer2,
console.log(3)
作为同步任务打印3,然后检查有没有微任务和GUI渲染 - 执行timer4,
console.log(6)
作为同步任务打印6,然后检查有没有微任务和GUI渲染 - 执行timer3,
console.log(5)
作为同步任务打印5,然后检查有没有微任务和GUI渲染 - 执行timer1,
console.log(2)
作为同步任务打印2,然后检查有没有微任务和GUI渲染 所以最终结果为:1、4、8、7、3、6、5、2
到此这篇关于简单聊聊JavaScript中的事件循环的文章就介绍到这了,更多相关JavaScript事件循环内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!