简单聊聊JavaScript中的事件循环
mick 人气:0为什么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
加载全部内容