浏览器进程、JS事件循环机制、宏任务和微任务

3,109 阅读21分钟

该篇文章参考了下面链接的文章,加上自己的理解整合了一下,感谢下面原文内容给我的帮助,写这篇文章是为了加深对浏览器进程、JS事件循环机制、宏任务和微任务的理解

区分进程和线程

用个形象的比喻:

  • 进程是一个工厂,工厂有自己独立的资源

    • 工厂之间相互独立 -> 进程之间相互独立
    • 工厂内有一个或多个工人一>个进程由一个或多个线程组成
    • 工人之间共享空间-> 同一进程下各个线程之间共享程序的内存空间(如代码段、数据集等)
  • 线程是工厂中的工人,多个工人协作完成任务->多个线程在进程中相互协作完成任务

浏览器是多进程的

  • Browser进程,浏览器的主进程(负责协调,主控),只有一个,作用有:

    • 负责浏览器界面的显示,与用户交互。如前进、后退等
    • 负责各页面的管理,创建和销毁其他进程
    • 将Renderer进程得到的绘制到用户界面上
    • 网络资源的管理,下载等
  • 第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建

  • GPU进程:图形处理器进程,它负责对 UI 界面的展示,最多一个,用于3D绘制等

  • 浏览器渲染进程(Renderer进程,内部是多线程的):默认每个Tab页面一个进程,互不影响,主要用于页面渲染,脚本执行,事件处理等 **强调:**浏览器中打开一个网页相当于新起了一个进程(进程内有自己的多线程),也有可能多个合并成一个(重点);

浏览器多进程的优势

  • 避免单个page crash(崩溃)影响整个浏览器
  • 避免第三方插件crash影响整个浏览器
  • 多进程充分利用多核优势
  • 方便使用沙盒模型隔离插件等进程,提高浏览器稳定性
简单理解:如果浏览器是单进程,那么某个Tab页或者某个插件崩溃了,就影响整个浏览器

重点,浏览器内核(渲染进程)

对于前端来说,页面的渲染、JS的执行、事件的循环都在这个进程中进行。 浏览器的渲染进程是多线程的。 浏览器的渲染进程包括哪些线程:

  1. GUI渲染线程
    • 负责渲染浏览器界面,解析HTML、CSS,构建DOM Tree,css Tree和RenderObject树,布局和绘制
    • 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
    • GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中,等到JS引擎空闲时立即被执行。
  2. JS引擎线程,单线程
    • 也称JS内核,负责处理JS脚本程序。例如V8引擎
    • JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(Renderer进程)中无论什么时候都只有一个JS引擎线程在运行JS程序
    • GUI渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,页面渲染就不连贯。
  3. 事件触发线程
    • 归属于浏览器而不是JS引擎,用来控制事件循环(可以理解为:JS引擎自己都忙不过来,需要浏览器另开线程协助)
    • 当JS引擎执行代码块和setTimeout时(也可来自浏览器内核的其他线程,如鼠标点击、ajax异步请求等),会将对应事件任务添加到事件线程(事件队列)中
    • 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的尾部,等待JS引擎的处理
    • 由于JS是单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)
  4. 定时触发器线程
    • setIntervalsetTimeout所在的线程
    • 浏览器定时计数器并不是由JS引擎计数的,因为JS引擎是单线程的,如果处于阻塞线程状态就会影响计时的准确性
    • 因此通过定时触发器线程来计时并触发定时,计时完毕后,添加到事件队列中,等待JS引擎空闲后执行
    • W3C在HTML标准中规定,要求setTimeout中低于4ms的时间间隔算4ms
  5. 异步http请求线程
    • XMLHttpRequest在连接后是通过浏览器新开一个线程请求
    • 在检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中,再由JS引擎执行

NCyNRS.png

Browser进程和浏览器内核(Renderer进程)如何通信

打开一个浏览器,可以看到:任务管理器出现了2个进程(一个主进程,一个是打开Tab页的渲染进程)

  • Browser主进程收到用户请求,首先需要获取页面内容(如通过网络下载资源),随后将该任务通过RendererHost接口传递给Render渲染进程
  • Render渲染进程的Renderer接口收到消息,简单解释后,交给渲染线程GUI,然后开始渲染
  • GUI渲染线程接收请求,加载网页并渲染网页,这其中可能需要Browser主进程获取资源和需要GPU进程来帮助渲染
  • 当然可能会有JS线程操作DOM(注意:这可能会造成回流并重绘)
  • 最后Render渲染进程将结果传递给Browser主进程
  • Browser主进程接收到结果并将结果绘制出来

clipboard.png

流程:Browser进程收到用户请求----->浏览器Renderer进程通过RendererHost接口拿到页面资源--->浏览器Renderer进程渲染网页--->将渲染的结果传递回Browser进程

梳理浏览器渲染流程


浏览器输入url
-->浏览器Browser主进程接管,开一个下载线程,然后进行http请求(tcp3次握手)(略去DNS查询,IP寻址等等操作),然后等待响应,获取内容
-->将内容通过RendererHost接口转交给浏览器Renderer进程,浏览器渲染流程开始

浏览器内核拿到内容后,渲染大概可以划分成以下几个步骤:

  • 解析html创建dom树

  • 解析css构建css树

  • 运行JS脚本,等到JS文件下载完成后,通过DOM API 和CSS API 操作DOM Tree和CSS Rule Tree,然后结合Css 树和DOM合并成render树。

  • 布局render树(layout/reflow),负责各元素尺寸、位置的计算

  • 绘制render树(paint),绘制页面像素信息

  • 浏览器将各层的信息发送给GPU进程,GPU会将各层合成(composite)显示在页面上,渲染完毕后就是load事件了,之后就是自己的JS逻辑处理了

img

GUI渲染线程与JS引擎线程是互斥的,阻碍页面的渲染 .

解决方案:

建议把 <script> 标签放到 <body> 的最后面。如果不放在最后,也可使用defer或者async属性,异步加载js文件。

二者的区别是:async 会在下载完之后立即执行;而 defer 会等到DOM Tree解析完成之后再去执行。

img

从Event Loop谈JS的运行机制

首先我们要知道几点:

1.JavaScript 是单线程语言,决定于它的设计最初是用来处理浏览器网页的交互。浏览器负责解释和执行 JavaScript 的线程只有一个(所以说是单线程),即JS引擎线程,但是浏览器同样提供其他线程,如:事件触发线程、定时器触发线程等。

2.异步一般是指:

  • 网络请求
  • 计时器
  • DOM事件监听

3.事件循环机制:

  • JavaScript是单线程的语言

  • Event Loop是javascript的执行机制

  • JS分为同步任务和异步任务

  • JS引擎线程会维护一个执行栈,同步代码会依次加入到执行栈中依次执行并出栈。

  • JS引擎线程遇到异步函数,会将异步函数交给相应的Webapi,而继续执行后面的任务,主线程之外,事件触发线程管理着一个任务队列,即事件队列。

  • Webapi会在条件满足的时候,将异步对应的回调加入到消息队列中,等待执行。

  • 执行栈为空时,JS引擎线程会去取事件队列中的回调函数(如果有的话),并加入到执行栈中执行。

  • 完成后出栈(此时JS引擎空闲),执行栈再次为空,重复上面的操作,这就是事件循环(event loop)机制。

evenloop2

上图大致描述就是:

  • 主线程运行时会产生执行栈,

  • 栈中的代码调用某些api时,它们会在事件队列中添加各种事件(当满足触发条件后,如ajax请求,Dom的onClick,setTimeOut,setInterval)

  • 而栈中的代码执行完毕,就会读取事件队列中的事件,去执行那些回调

  • 如此循环

  • 注意,总是要等待栈中的代码执行完毕后,才会去读取事件队列中的事件

    先看一道面试题:

    console.log('script start');
    setTimeout(function() {
        console.log('setTimeout1');
    }, 10);
    Promise.resolve().then(function() {
        console.log('promise1');
    }).then(function() {
        console.log('promise2');
    });
    setTimeout(function() {
        console.log('setTimeout2');
    }, 0);
    console.log('script end');
    

    打印顺序:

    'script start'
    'script end'
    'promise1'
    'promise2'
    'setTimeout2'
    'setTimeout1'
    

    为什么呢?因为Promise里有了一个新的概念:microtask。 或者,进一步,JS中分为两种任务类型:macrotaskmicrotask,在ECMAScript中,microtask称为jobsmacrotask可称为task 它们的定义?区别?简单点可以按如下理解:

  • macrotask(又称之为宏任务),可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)

    • 每一个task会从头到尾将这个任务执行完毕
    • 浏览器为了能够使得JS内部task与DOM任务能够有序的执行,会在一个task执行结束后,在下一个 task 执行开始前,对页面进行重新渲染 (task->GUI渲染->task->...
  • microtask(又称为微任务),可以理解是在当前task执行结束后立即执行的任务

    • 也就是说,在当前task任务后,下一个task之前,在渲染之前
    • 所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染
    • 也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前) 分别是怎样的场景会形成macrotask和microtask呢?

划重点-规则:

macrotask:包括 主代码块scriptsetTimeoutsetIntervalrequestAnimationFrame等(可以看到,事件队列中的每一个事件都是一个macrotask)

microtask:Promise.then catch finally,process.nextTick(node环境)等

补充:在node环境下,process.nextTick的优先级高于Promise,也就是可以简单理解为:在宏任务结束后会先执行微任务队列中的nextTickQueue部分,然后才会执行微任务中的Promise部分。

再根据线程来理解下:

  • macrotask中的事件都是放在一个事件队列中的,而这个队列由事件触发线程维护
  • microtask中的所有微任务都是添加到微任务队列(Job Queues)中,等待当前macrotask执行完毕后执行,而这个队列由JS引擎线程维护 (因为它是在主线程下无缝执行的)
  • 总结下运行机制
    • 执行一个宏任务(栈中没有就从事件队列中获取)
    • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中(先进先出原则)
    • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
    • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
    • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

macmic 一帧执行流程 练习

console.log('1')
setTimeout(function() {
    console.log('2')
    Promise.resolve().then(()=>{
    	console.log('3')
    })
    new Promise(function(resolve) {
        console.log('4')
        resolve()
    }).then(function() {
        console.log('5')
    })
},10)
Promise.resolve().then(()=>{
    	console.log('6')
    })
new Promise(function(resolve) {
    console.log('7')
    resolve('lc')
}).then(function(res) {
    console.log('8')
		return res
})

打印顺序:

1
7
6
8
Promise {<resolved>: "lc"}
2
4
3
5

对于async await的理解

带 async 关键字的函数,它使得你的函数的返回值必定是 promise 对象

也就是

  • 如果async关键字函数返回的不是promise,会自动用Promise.resolve()包装
  • 如果async关键字函数显式地返回promise,那就以你返回的promise为准
  • 如果函数写了async关键字,没有 await关键字,也没有return返回值,会隐式的返回 Promise.resolve(undefiend)

这是一个简单的例子,可以看到 async 关键字函数和普通函数的返回值的区别:

async function fn1(){
    return 123
}
function fn2(){
    return 123
}
console.log(fn1())
console.log(fn2())
打印:
Promise {<resolved>: 123}
123

await 在等什么?

await等的是右侧「表达式」的结果

也就是说,

右侧如果是函数,那么函数的return值就是「表达式的结果」

右侧如果是一个 'hello' 或者什么值,那表达式的结果就是 'hello'

async function async1() {
    console.log('async1 start')
    await async2();
    console.log('async1 end');
    console.log('async22')
}
async function async2() {
    console.log( 'async2' )
}
async1()
console.log( 'script start' )

打印:
async1 start
async2
script start
async1 end
async22

这里注意一点,await 后面的async2函数执行完后,会让出线程,阻塞后面的代码,

对于await来说,分2个情况,都会让出线程,会阻塞后面的代码

  • 不是promise对象
  • 是promise对象

await 后面不管是不是 promise , await都会阻塞后面的代码,先执行完await后面的函数async2后(async2后面的console.log('async1 end')可以理解为进入到了微任务队列里),然后跳出当前async1函数,执行async1外面的同步代码,同步代码执行完,再回到async内部,最后在执行console.log( 'async1 end' );

经典面试题:

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

打印:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

宏任务和微任务的慨念:

image-20200607213646585

一段代码执行时,会先执行宏任务中的同步代码,

  • 如果执行中遇到setTimeout,setInterval之类宏任务,那么就把这个setTimeout内部的函数推入「宏任务的队列」中,下一轮宏任务执行时调用。
  • 如果执行中遇到promise.then()之类的微任务,就会推入到「当前宏任务的微任务队列」中,在本轮宏任务的同步代码执行都完成后,依次执行所有的微任务1、2、3
  • async和await函数,如果遇到 await关键字,先执行完 await后面的结果,然后会让出线程(当前的async函数),阻塞后面代码的执行;

分析流程:

1.这段代码作为宏任务1,进入主线程,           
	async1和async2是函数声明,不用管,
	执行console.log("script start"),   										
	--打印:script start 
	
2.setTimeout作为宏任务2放入事件队列等候,

3.执行async1(),先打印 console.log("async1 start"),在执行 async2(),打印console.log("async2"),并默认返回 Promise.resolve(undefiend),然后就跳出async1函数;  
	--打印:script start->async1 start-->async2
	
4.new Promise作为构造函数会立即执行,执行console.log("promise1"),then()函数放入到宏任务1的微任务队列里;																										
	--打印:script start->async1 start-->async2->promise1
	
5.执行主线程的 console.log("script end")									
	--打印:script start->async1 start-->async2->promise1->script end

6.进入到async1(),执行 console.log("async1 end"),在执行宏任务1的微任务,执console.log("promise2")
  --打印:script start->async1 start-->async2->promise1->script end->async1 end->promise2
  
 7.当前宏任务1及其里面的微任务都执行完毕,执行栈空闲了,GUI线程渲染,在事件队列里取 宏任务2(setTimeout行数),执行console.log("setTimeout")
  --打印:script start->async1 start-->async2->promise1->script end->async1 end->promise2->setTimeout
根据优化后的新规范,可以改写成
function async1(){
  console.log('2 async1 start')
  return async2().then(() => {
    console.log('6 async1 end')  //放入到微任务队列里,先入先出的顺序执行
  })
}
function async2(){
  console.log('3 async2')
  return Promise.resolve(undefined)
}
console.log('1 script start')
setTimeout(function(){
  console.log('8 setTimeout')
},0)
async1()
new Promise(function(resolve){
  console.log('4 promise1')
  resolve();
}).then(function(){
  console.log('7 promise2')
})
console.log('5 script end')

// 结果
1 script start
2 async1 start
3 async2
4 promise1
5 script end
6 async1 end
7 promise2
8 setTimeout

巩固练习:
async function async1() {
  console.log('2')
  await async2()
  console.log('6')
}

async function async2() {
  console.log('3')
}

setTimeout(function () {
  console.log('8')
}, 0)

console.log('1')
async1()

new Promise(function (resolve) {
  console.log('4')
  resolve()
}).then(function () {
  console.log('7')
})
console.log('5')

1.首先输出同步代码1,然后进入async1方法输出22.因为遇到await所以先进入async2方法,后面的微任务,6处于等待状态。
3.在async2中输出3,现在跳出async函数先执行外面的同步代码。
4.输出45。then回调里的7进入微任务栈。
5.现在宏任务执行完了,微任务,输出67
6.执行宏任务2setTimeout),输出8

补充点:

  • 一、requestAnimationFrame(执行时机是根据屏幕显示器的刷新率决定的,显示器屏幕刷新率越高动画越流畅)

    • 概念:HTML5新增的api,类似于setTimeout定时器。window对象的一个方法window.requestAnimationFrame 浏览器(所以只能在浏览器中使用)专门为动画提供API,让dom动画,canvas动画,svg动画,webGL动画等有一个 统一的刷新机制

    • 基本思想 : 让页面重绘的频率与这个刷新频率保持同步

      • 例如:显示器屏幕刷新率为 60Hz,使用 requestAnimationFrame API,那么回调函数就每 1000ms / 60 ≈ 16.7ms 执行一次;如果显示器屏幕的刷新率为 75Hz,那么回调函数就每 1000ms / 75 ≈ 13.3ms 执行次,显示器屏幕刷新率越高动画越流畅
      • 通过 requestAnimationFrame 调用回调函数引起的页面重绘或回流的时间间隔和显示器的刷新时间间隔相同。所以requestAnimationFrame 不需要像setTimeout 那样传递时间间隔,而是浏览器通过系统获取并使用显示器刷 新频率
    • 特点:

      • 按帧对网页进行重绘 。该方法告诉浏览器动画在下一次重绘之前调用函数更新动画。
      • 由系统来决定回调函数的执行机制。在运行时浏览器会自动优化方法的调用。显示器有固定的刷新频率(60Hz 或 75Hz),也就是说,每秒最多只能重绘60 次或 75 次。
    • 优势:

      • 从实现的功能和使用方法上, 提升性能,防止掉帧。
      • 浏览器 UI 线程:浏览器让执行 JavaScript 和更新用户界面(包括重绘和回流)共用同一个单线程,称为“浏览器 UI 线程”
      • 浏览器 UI 线程的工作基于一个简单的队列系统,任务会被保存到队列中直到进程空闲。一旦空闲,队列中的下一个任务就被重新提取出来并运行。这些任务要么是运行 JavaScript 代码,要么执行 UI 更新。
  • 二、setTimeout通过人为的设置一个间隔时间不断改变图像,达到动画效果,会丢帧,卡顿**)

    • 概念:通过设置一个间隔时间不断改变图像,达到动画效果。该方法在一些低端机上会出现卡顿、抖动现象。这种现象一般有两个原因:
    • setTimeout 的执行时间并不是确定的。
    • requestAnimationFrame刷新频率受屏幕分辨率和屏幕尺寸影响,不同设备的屏幕刷新率可能不同,setTimeout 只能设置固定的时间间隔,这个时间和屏幕刷新间隔可能不同。
    • 以上两种情况都会导致 setTimeout 的执行步调和屏幕的刷新步调不一致,从而引起丢帧现象。使用 requestAnimationFrame 执行动画,最大优势是能保证回调函数在屏幕每一次刷新间隔中只被执行一次,这样就不会引起丢帧,动画也就不会卡顿。 b. 节约资源,节省电源

总结: setTimeout 与 requestAnimationFrame 的区别:

  • 引擎层面:setTimeout 属于 JS 引擎,存在事件轮询,存在事件队列。requestAnimationFrame 属于 GUI 引擎,发生在渲染过程的重绘重排部分,与电脑分辨路保持一致。

  • 性能层面:当页面被隐藏或最小化时,定时器 setTimeout 仍在后台执行动画任务。当页面处于未激活的状态下,该页面的屏幕刷新任务会被系统暂停,requestAnimationFrame 也会停止。

  • 应用层面:利用 setTimeout,这种定时机制去做动画,模拟固定时间刷新页面。requestAnimationFrame 由浏览器专门为动画提供的 API,在运行时浏览器会自动优化方法的调用,在特定性环境下可以有效节省了CPU 开销。

  • 三、 ** 重绘和回流(重排)**

    • 回流:

      回流是布局或者几何属性需要改变就称为回流。回流是影响浏览器性能的关键因素,因为其变化涉及到部分页面(或是整个页面)的布局更新。一个元素的回流可能会导致了其所有子元素以及DOM中紧随其后的节点、祖先节点元素的随后的回流。

    • 重绘:

      重绘是由于节点的几何属性发生改变或者由于样式发生改变但不会影响布局。例如outline, visibility, color、background-color等,重绘的代价是高昂的,因为浏览器必须验证DOM树上其他节点元素的可见性。

    • 什么时候会发生回流呢?

      • 1、添加或删除可见的DOM元素

      • 2、元素的位置发生变化

      • 3、元素的尺寸发生变化(包括外边距、内边框、边框大小、高度和宽度等)

      • 4、内容发生变化,比如文本变化或图片被另一个不同尺寸的图片所替代。

      • 5、页面一开始渲染的时候(这肯定避免不了)

      • 6、浏览器的窗口尺寸变化(因为回流是根据视口的大小来计算元素的位置和大小的)

      而重绘是指在布局和几何大小都不变得情况下,比如次改一下background-color,或者改动一下字体颜色的color等。 注意:回流一定会触发重绘,而重绘不一定会回流

    • 如何减少回流和重绘

      1、CSS优化法

      • (1)使用 transform 替代 top

        • (2)使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流(改变了布局

        • (3)避免使用table布局,可能很小的一个小改动会造成整个 table 的重新布局。

        • (4)尽可能在DOM树的最末端改变class,回流是不可避免的,但可以减少其影响。尽可能在DOM树的最末端改变class,可以限制了回流的范围,使其影响尽可能少的节点。

        • (5)避免设置多层内联样式,CSS 选择符从右往左匹配查找,避免节点层级过多。

        • (6)将动画效果应用到position属性为absolute或fixed的元素上,避免影响其他元素的布局,这样只是一个重绘,而不是回流,同时,控制动画速度可以选择 requestAnimationFrame,详见探讨 requestAnimationFrame。

        • (7)避免使用CSS表达式,可能会引发回流。

        • (8)将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点,例如will-change、video、iframe等标签,浏览器会自动将该节点变为图层。

        • (9)CSS3 硬件加速(GPU加速),使用css3硬件加速,可以让transform、opacity、filters这些动画不会引起回流重绘 。但是对于动画的其它属性,比如background-color这些,还是会引起回流重绘的,不过它还是可以提升这些动画的性能。

        2、JavaScript优化法

        • (1)避免频繁操作样式,最好一次性重写style属性,或者将样式列表定义为class并一次性更改class属性。

        • (2)避免频繁操作DOM,创建一个documentFragment,在它上面应用所有DOM操作,最后再把它添加到文档中, Document.createDocumentFragment()创建文档碎片,不会引发页面回流。

        • (3)避免频繁读取会引发回流/重绘的属性,如果确实需要多次使用,就用一个变量缓存起来。

参考: async 函数的理解

阮一峰es6

8张图让你一步步看清 async/await 和 promise 的执行顺序

浏览器进程、JS事件循环机制、宏任务和微任务

Document.createDocumentFragment()创建文档碎片