浏览器专题系列 - 事件循环机制

简介: 浏览器专题系列 - 事件循环机制

前言


先简单聊聊JavaScript与此主题有关的其它内容,加深读者对内容的吸收与理解


解释性语言


脚本语言是为了缩短传统的编写-编译-链接-运行(edit-compile-link-run)过程而创建的计算机编程语言


脚本语言编写的代码通常是逐行解释执行而非编译(c/c++,java),所以通常又叫做解释性语言


所以javascriptpython,shell一样,也是一门优秀的解释性语言

制约解释性语言的性能瓶颈之一就是解释器,好在javascript有著名的V8(Android,Chrome)与JSCore(IOS,Safari)等优秀的解释器引擎,js得以大范围推广,它们是必不可少的功臣


单线程模型


javascript语言的一大特点就是单线程,即同一时间只能做一件事

为什么是单线程?


作为浏览器脚本语言,javascript的主要用途是与用户互动,以及操作DOM,这决定了它只能是单线程,否则会带来很复杂的同步问题


例如:假定javascript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器不知道应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,javascript就是单线程的


单线程的优势


  • 不会出现因线程之间争夺资源导致的死锁现象
  • 所有代码都是同步执行的
  • 没有线程切换的资源开销


单线程的缺点


  • 单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着


任务队列


浏览器中存在有很多耗时的任务的场景,网路请求(ajax),监听事件的传递,定时器等等

javascript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去


于是对所有任务进行了划分,分为 同步任务异步任务


同步任务


在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务

即顺序执行


异步任务


不进入主线程、而进入任务队列的任务


只有当任务队列通知主线程,某个异步任务可以执行了的时候,该任务才会进入主线程执行


当解释器引擎遇到异步任务时,会将其挂起,在时机成熟时,将其回调函数放入到任务队列中


打个比方,就像炒菜一样,锅里的东西一直在翻炒着(主线程),各种调料(不同的异步任务)只会在需要的时候被加入锅里,然后完成其使命


关于引擎的构成可参阅浏览器专题系列 - 浏览器内核


异步机制


  1. 所有同步任务都在主线程上执行,形成一个执行栈
  2. 主线程之外,还存在一个任务队列,只要异步任务有了运行结果,就在"任务队列"之中放置一个事件
  3. 一旦执行栈中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。哪些对应的异步任务,于是结束等待状态,进入执行栈,开始执行


只要主线程空了,就会去读取"任务队列",这就是javascript的运行机制。主线程不断重复上面的第3步


事件与回调


"任务队列"是一个事件的队列,IO设备(鼠标,键盘等)完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件


只要指定过回调函数,这些事件(鼠标点击,键盘按键,页面滚动等)发生时就会进入"任务队列",等待主线程读取


所谓"回调函数",就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数


队列有先进先出的特性,主线程会优先读取任务队列最前面的事件


主线程的读取过程基本上是自动的,只要”执行栈“一清空,"任务队列"上第1位的事件就会自动进入主线程


针对于定时器事件,主线程会先检查一下执行时间,只有到了规定的时间,才能返回给主线程,即到了一定时间后才把事件对应的回调函数放入执行栈中


Event loop


什么是Event Loop

也就是通常说的事件循环


Event Loop是一个执行模型,在不同的地方(不同的语言)有不同的实现


js的事件循环负责执行代码、收集和处理事件以及执行队列中的子任务,与其它语言的模型截然不同


js的事件循环模型与许多其他语言相比有一个非常有趣的特性是,它永不阻塞,处理 I/O 通常通过事件和回调来执行


因此当一个应用正等待一个 AJAX 请求返回时,它仍然可以处理其它事情,如用户输入,鼠标点击/滚动等


什么是执行栈


执行栈可以认为是一个存储函数调用的栈结构,遵循先进后出的原则


js开始执行代码的时候会首先创建一个main函数,然后根据执行的代码,根据先进后出的原则,后执行的函数先弹出栈


这里有一个可视化执行栈的在线工具 -> Loupe


示例


function a(v){
    return v*4
}
function b(v){
    return a(v*3)
}
console.log(b(2))


进栈顺序


1. main()
2. console.log(b(2))
3. b(2)
4. a(6)


出栈顺序


1. a(6)  // 24
2. b(2)  // 24
3. console.log(b(2)) // 24
4. main()


使用递归的时候,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈(如下图所示)


网络异常,图片无法展示
|


浏览器中的 Event Loop


通过上面的阐述,大概也是知道了js是如何执行的,了解了如何以单线程同步执行的方式处理异步任务的机制,下面开始详细描述一下执行的过程


执行 js 代码的时候其实就是往执行栈中放入函数/回调函数,当遇到异步的代码时,会被挂起并在需要执行的时候加入到 任务队列(有多种)中,一旦执行栈为空,时间循环机制 就会从 任务队列中拿出需要执行的代码并放入执行栈中执行


所以本质上来说 js 中的异步依旧还是同步的行为


任务源


不同任务源会被分到不同的任务队列中


微任务


微任务(microtask):jobs


  • promise
  • MutationObserver
  • ...


宏任务


宏任务(macrotask):tasks


  • script
  • xhr
  • setTimeout
  • setInterval
  • requestAnimationFrame
  • I/O
  • UI rendering
  • ...


Event Loop执行顺序


这里先抛出浏览器中js的事件循环的不同任务的执行顺序

在事件循环中,每进行一次循环操作称为 tick


  1. 执行所有的同步代码
  2. 执行完所有同步代码后,执行栈为空,查询是否有异步任务需要执行
  3. 执行 微任务,如果在执行 微任务 的过程中,又产生了 微任务,那么会加入到 微任务的队列 的末尾,也会在这个周期被调用执行
  4. 执行完所有微任务后,如有必要会渲染页面:
  • 判断document是否需要更新
  • 大部分显示设备还是 60Hz 的刷新率,所以 16.6ms 才会更新一次渲染
  • 判断是否有 resize 或者 scroll 事件,有的话会去触发事件
  • 所以 resize 和 scroll 事件也是至少 16.6ms 才会触发一次,即自带节流功能。
  • 判断是否触发了 media query(媒体查询)
  • 更新动画并且发送事件
  • 判断是否有全屏操作事件
  • 执行 requestAnimationFrame 回调
  • 执行 IntersectionObserver 回调,该方法用于判断元素是否可见,可以用于懒加载上
  • 更新界面
  1. 开始下一轮 Event Loop ,从宏任务中取出一个执行,再然后微任务...


小结归纳


  • 宏任务一次只从宏任务队列中取一个任务执行,执行完后就去执行微任务队列中的任务
  • 微任务队列中所有的任务都会被依次取出来执行,直到微任务队列为空;
  • 执行UI rendering,它的时间节点是在执行完所有的微任务之后,下一个宏任务之前
  • 定时器不是绝对准确的
  • SetTimeout/SetInterval只是在指定时间后将其回调函数放入到宏任务队列中


网络异常,图片无法展示
|


示例


下面通过一个示例来阐述一下代码的执行顺序


console.log('script start')
async function async1() {
  await async2()
  console.log('async1 end')
}
async function async2() {
  console.log('async2 end')
}
async1()
setTimeout(function() {
  console.log('setTimeout')
}, 0)
new Promise(resolve => {
  console.log('Promise')
  resolve()
})
  .then(function() {
    console.log('promise1')
  })
  .then(function() {
    console.log('promise2')
  })
console.log('script end')


  1. 执行同步代码 输出  script start
  • console.log('script start')
  1. 执行async1中的同步代码输出async2 end
  • async1()
  • async2()
  • 因为函数有async标志,所以返回的是Promise,我们记为P1
  • console.log('async2 end')
  • await
  • await为让出线程的标志,即执行await async2()后就回到 async1外部
  • 而await 就是 generator 加上 Promise 的语法糖,且内部实现了自动执行 generator,所以这里又套上了一层Promise,我们记为P2,其包裹着P1
  1. 遇到异步任务定时器,我们先给它挂起,记为 S1
  2. 执行new Promise 的构造函数,输出 Promise
  1. console.log('Promise')
  2. resolve()
  • 生成新的微任务记为 P3
  1. 执行同步代码输出 script end
  2. 此时所有的同步代码执行完成,微任务与宏任务队列情况分别是
  1. 微任务:[P2,P3]
  2. 宏任务:[S1]
  1. 执行所有的微任务
  1. 取出P2执行,产生新的微任务 P1加入到队列中 [P3,P1]
  2. 取出P3执行, 打印输出promise1,产生新的微任务 P4 加入队列中 [P1,P4]
  3. 取出P1执行, 无输出,产生新的微任务 P5 加入队列中 [P4,P5]
  4. 取出P4执行, 输出promise2,无新的任务产生 [P5]
  5. 取出P5执行, 输出async1 end,无新的任务产生 []


低版本浏览器的结果为


// script start --> async2 end --> Promise --> script end --> promise1 --> promise2
// async1 end --> setTimeout


因为 await 后面跟着 Promise 的话,async1 end 需要等待3个 microtick 才能执行到

async1 其等价的v8优化前的旧版代码为


function async1(){
  new Promise((resolve)=>{
    const p = new Promise(res=>res(async2()))
    p.then(()=>{
      console.log('async1 end')
      resolve()
    })
  })
}


新版浏览器的结果为


// script start --> async2 end --> Promise --> script end --> async1 end 
//  promise1 --> promise2 --> setTimeout


在本例中上述的 P2包裹P1 合并成了一个,即await后面如果是Promise将不会再进行一次Promise包装


async1 其等价的v8优化后的代码为


function async1(){
  new Promise((resolve)=>{
    const p = Promise.resolve(async2())
    p.then(()=>{
      console.log('async1 end')
      resolve()
    })
  })
}


小结

  1. 在新版浏览器中,await promiseFun,3个 microtick 被优化为了 2个 microtick
  • new Promise 替换为了 Promise.resolve
  • Promise.resolve的参数如果是Promise则直接返回这个Promise


补充


问题追溯可查看



自测


自测试1


console.log(1);
setTimeout(() => {
  console.log(2);
  Promise.resolve().then(() => {
    console.log(3)
  });
});
new Promise((resolve, reject) => {
  console.log(4)
  resolve(5)
}).then((data) => {
  console.log(data);
})
setTimeout(() => {
  console.log(6);
})
console.log(7)

点击查看答案


输出结果


// 1 4 7 5 2 3 


自测2


console.log(1);
setTimeout(() => {
  console.log(2);
  Promise.resolve().then(() => {
    console.log(3)
  });
});
new Promise((resolve, reject) => {
  console.log(4)
  resolve(5)
}).then((data) => {
  console.log(data);
  Promise.resolve().then(() => {
    console.log(6)
  }).then(() => {
    console.log(7)
    setTimeout(() => {
      console.log(8)
    }, 0);
  });
})
setTimeout(() => {
  console.log(9);
})
console.log(10);


点击查看答案


输出结果


// 1 4 10 5 6 7 2 3 9 8


参考



原文首发于个人博客,专题系列会整理多篇,与大家一起学习,共同进步,如有错误,还请斧正


浏览器专题系列文章


相关文章
|
11天前
|
存储 JavaScript 网络协议
浏览器与 Node 的事件循环
浏览器和Node.js的事件循环是异步操作的核心机制。它们通过管理任务队列和回调函数,确保程序在处理耗时任务时不会阻塞主线程,从而实现高效、响应式的应用开发。
|
6月前
|
Web App开发 JavaScript 前端开发
浏览器与Node.js事件循环:异同点及工作原理
浏览器与Node.js事件循环:异同点及工作原理
|
4月前
|
JavaScript 前端开发 开发者
浏览器事件机制详解
浏览器事件机制详解
44 1
|
5月前
|
监控 前端开发 JavaScript
记录浏览器节能机制导致Websocket断连问题
近期,在使用WebSocket(WS)连接时遇到了频繁断连的问题,这种情况在单个用户上每天发生数百次。尽管利用了socket.io的自动重连机制能够在断连后迅速恢复连接,但这并不保证每一次重连都能成功接收WS消息。因此,我们进行了一些的排查和测试工作。
452 1
记录浏览器节能机制导致Websocket断连问题
|
4月前
|
存储 JavaScript 前端开发
在?聊聊浏览器事件循环机制
在?聊聊浏览器事件循环机制
45 0
|
6月前
|
前端开发 JavaScript 数据可视化
探索浏览器的内心世界:渲染机制的奥秘
探索浏览器的内心世界:渲染机制的奥秘
探索浏览器的内心世界:渲染机制的奥秘
|
6月前
|
存储 前端开发 JavaScript
揭秘浏览器的事件循环:让网页动起来的幕后英雄
揭秘浏览器的事件循环:让网页动起来的幕后英雄
揭秘浏览器的事件循环:让网页动起来的幕后英雄
|
JavaScript 前端开发
浏览器中的事件循环和Node.js中事件循环的区别(经典面试题)
浏览器中的事件循环和Node.js中事件循环的区别(经典面试题)
762 0
|
移动开发 前端开发 HTML5
浏览器和 Node 中的事件循环有什么区别?
浏览器和 Node 中的事件循环有什么区别?
98 0
|
消息中间件 前端开发 JavaScript
浏览器中的事件循环
浏览器中的事件循环
89 1

热门文章

最新文章