Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

EventLoop与浏览器渲染 #24

Open
lazyken opened this issue Jul 23, 2020 · 0 comments
Open

EventLoop与浏览器渲染 #24

lazyken opened this issue Jul 23, 2020 · 0 comments

Comments

@lazyken
Copy link
Owner

lazyken commented Jul 23, 2020

本文通过参考多篇文章和视频,根据自己理解的思路进行摘抄整理,补充和总结的关于js事件循环的原理和一些浏览器渲染相关的知识点,对自己来说是一次学习总结,记录自己从零开始一步步理解这些知识点的过程。文章末尾会列出参考文章和视频。
文中如有错误的地方,还请见谅,如果能指出错误,我会很感谢你!

1、进程和线程

1-1 概念

进程(process)和线程(thread)是操作系统的基本概念。
进程是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位)。
线程是 CPU 调度的最小单位(是建立在进程基础上的一次程序运行单位)。
现代操作系统都是可以同时运行多个任务的,比如:用浏览器上网的同时还可以听音乐。
对于操作系统来说,一个任务就是一个进程,比如打开一个浏览器就是启动了一个浏览器进程(准确的说,浏览器是多进程的,这里方便理解只是概括性的描述一下),打开一个 Word 就启动了一个 Word 进程。
有些进程同时不止做一件事,比如 Word,它同时可以进行打字、拼写检查、打印等事情。在一个进程内部,要同时做多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程。
由于每个进程至少要做一件事,所以一个进程至少有一个线程。系统会给每个进程分配独立的内存,因此进程有它独立的资源。同一进程内的各个线程之间共享该进程的内存空间(包括代码段,数据集,堆等)。

扩展阅读
从 8 道面试题看浏览器渲染过程与性能优化

1-2 浏览器的多进程架构

以chrome为例,它由多个进程组成,每个进程都有自己核心的职责,它们相互配合完成浏览器的整体功能。每个进程中又包含多个线程,一个进程内的多个线程也会协同工作,配合完成所在进程的职责。
浏览器的主要进程

  • 主进程 Browser Process:负责浏览器界面的显示与交互。各个页面的管理,创建和销毁其他进程。网络的资源管理、下载等。
  • 第三方插件进程 Plugin Process:每种类型的插件对应一个进程,仅当使用该插件时才创建。
  • GPU 进程 GPU Process:最多只有一个,用于 3D 绘制等。
  • 渲染进程 Renderer Process:称为浏览器渲染进程或浏览器内核,内部是多线程的。主要负责页面渲染,脚本执行,事件处理等。

我们通常说的js引擎(如v8),负责处理 Javascript 脚本,他所在的线程就是js引擎线程,属于渲染进程的一个线程。

1-3 js是单线程的

JavaScript是一门单线程的语言,因此,JavaScript在同一个时间只能做一件事,单线程意味着,如果在同个时间有多个任务的话,这些任务就需要进行排队,前一个任务执行完,才会执行下一个任务,比如说下面这段代码:

// 同步代码
function foo() {
  console.log(1);
}
function bar() {
  console.log(2);
}

foo(); // 1
bar(); // 2

代码会依次输出1,2,因为代码是从上到下依次执行,执行完foo(),才继续执行bar(),但是如果foo()中的代码执行的是读取文件或者ajax操作,文件的读取和数据的获取都需要一定时间,这样bar就需要等待foo执行结束才会执行。

JavaScript的单线程,与它的用途有很大关系。JavaScript作为浏览器的脚本语言,主要用来实现与用户的交互,利用JavaScript,可以实现对DOM的各种各样的操作,如果JavaScript是多线程的话,一个线程在一个DOM节点中增加内容,另一个线程要删除这个DOM节点,那么这个DOM节点究竟是要增加内容还是删除呢?这会带来很复杂的同步问题,因此,JavaScript被设计为单线程的。

扩展阅读
js中的同步和异步

2、同步与异步

2-1 同步任务

同步任务是指在主线程上排队执行的任务,只有前一个任务执行完毕,才能继续执行下一个任务,当我们打开网站时,网站的渲染过程,比如元素的渲染,其实就是一个同步任务。在1-2 js是单线程的中的示例代码就是一个同步代码。

2-2 异步任务

异步任务是指,当遇到耗时较长的任务时,把它挂起等待执行结束,而主线程不被阻塞继续执行后续代码。这样被挂起等待执行结束的任务称为异步任务。异步任务完成后会把回调函数推入一个执行队列,当主线程当前的同步任务执行完成时会检查这个执行队列来执行异步任务的回调函数。(具体js是如果管理和处理这些同步和异步的执行顺序呢?后面会在事件循环小节进行说明)。当我们打开网站时,像图片的加载,音乐的加载,其实就是一个异步任务。

2-3 为什么会有同步和异步

因为JavaScript是单线程的,因此同个时间只能处理一个任务,所有任务都需要排队,前一个任务执行完,才能继续执行下一个任务,但是,如果前一个任务的执行时间很长,比如文件的读取操作或ajax操作,后一个任务就不得不等着,造成阻塞。拿ajax来说,当用户向后台获取大量的数据时,不得不等到所有数据都获取完毕才能进行下一步操作,用户只能在那里干等着,严重影响用户体验。
JavaScript在设计的时候,就已经考虑到这个问题,主线程可以完全不用等待文件的读取完毕或ajax的加载成功,可以先挂起处于等待中的任务,先运行排在后面的任务,等到文件的读取或ajax有了结果后,再执行挂起的任务。这样便产生了同步和异步。

扩展阅读
nodejs中的异步、非阻塞I/O是如何实现的?

3、事件循环 EventLoop

3-1 Call Stack

3-1-1 Call Stack

Call Stack是一个记录当前代码执行到哪里到一个数据结构。js执行代码时,Call Stack会记录各个任务的进栈和出栈。
考虑如下代码:

function multiply(a, b) {
  return a * b;
}
function square(n) {
  return multiply(n, n);
}
function printSquare(n) {
  var squared = square(n);
  console.log(squared);
}
printSquare(4);

执行上面的代码,会有一种被称为main()的方法被执行(在浏览器中也会显示为(anonymous function),可以理解为这段代码本身);然后声明了三个函数,最后调用了printSquare(4)函数;在它内部又依次调用了square(n),multiply(n, n)。所以Call Stack看起来是这样的:

stack 顺序
multiply(n, n) 4
square(n) 3
printSquare(4) 2
main() 1

然后接下来的执行顺序是:multiply(n, n) return,multiply(n, n)出栈;square(n) return,square(n)出栈;printSquare(4)执行console.log(squared),console.log(squared)入栈;

stack 顺序
console.log(squared) 3
printSquare(4) 2
main() 1

console.log(squared)完成,出栈;printSquare(4)出栈;main()出栈;结束。

3-1-2 死循环

function foo() {
  return foo();
}
foo();

如果写了死循环,call stack将会一直被推入无限多个foo(),可能会造成堆栈溢出而卡死。最终当超出一定数量后会导致浏览器报错,杀掉这个进程,提示对应的错误信息。

stack 顺序
foo() Infinity
... ...
foo() 3
foo() 2
main() 1

3-2 阻塞 blocking

var foo = $.getSync('//foo.com')
var bar = $.getSync('//bar.com')
var qux = $.getSync('//qux.com')

console.log(foo)
console.log(bar)
console.log(qux)

上面的代码,当执行$.getSync('//foo.com')时,会等待同步的网络请求返回后再执行下一行代码。后面的代码需要等待前面的任务结束才能执行,这就发生了阻塞。
阻塞会造成什么问题呢?
我们的代码是跑在浏览器中的。当发生阻塞时,用户的任何操作都不会被立即响应执行,因为主线程被阻塞了(浏览器也不能render,后面浏览器渲染小节会讲到),用户在页面上的交互操作都需要等待之前在阻塞的任务执行完成才能继续往下执行任务。对用户而言就是页面卡住了,任何操作都没有效果。等阻塞结束后,用户之前的操作反而又会被再继续执行,这样就会比较诡异。
解决阻塞问题的方法是使用异步编程(包括回调函数callback function、promise等)。

3-3 异步回调 Async Callback

常见的setTimeout/setInterval、DOM监听事件、HTTP request等都是异步的。

异步编程扩展阅读
1、JS异步编程有哪些方案?为什么会出现这些方案?
2、js中的同步和异步——三、异步编程

考虑如下代码:

console.log('hi');
setTimeout(function () {
  console.log('there');
}, 5000);
console.log('JSConfEU');

逐步分析一下代码;call stack首先推入main();然后console.log('hi')进栈,console.log('hi')出栈;setTimeout是异步任务,跳过;console.log('JSConfEU')进栈,console.log('JSConfEU')出栈;main()出栈。5秒后call stack又被推入console.log('there'),然后console.log('there')出栈。
那么setTimeout(cb,5000)是怎么跳过的?它去了哪里?cb又是如何重新进入call stack的呢?看上去那么setTimeout转移到了其他地方等待执行结束,然后把回调函数又返回给js主线程了,但是js是单线程到,又是谁去处理setTimeout异步任务呢?接下来就开始讲一下重点——EVentLoop事件循环

3-4 事件循环 EventLoop

扩展阅读
如何理解EventLoop,共三篇

3-4-1 WebAPIs

setTimeout/setInterval;DOM(document)对象;XMLHttpRequest等不存在于js引擎V8之中,他们是浏览器提供的

浏览器不仅仅为js代码提供了运行时环境,还提供了可以进行异步操作的WebAPIs,如:setTimeout/setInterval;DOM(document)对象;XMLHttpRequest对象等。这些WebAPIs可以让js实现异步回调。

3-4-2 任务队列 Task queue (宏任务MacroTask与微任务 Microtask)

简单来说,js执行代码时,各种同步或异步任务会被存入任务队列顺序执行。但是实际情况会复杂很多。
任务队列分为2种:宏任务MacroTask与微任务 Microtask。
常见的宏任务有:同步代码的执行、大多数异步任务(setTimeout、网络请求等)。
常见等微任务有:MutationObserver、Promise.then(或.reject) 以及以 Promise 为基础开发的其他技术(比如fetch API), 还包括 V8 的垃圾回收过程。
js在执行每个宏任务结束之前会检查微任务队列内是否有待执行的任务,如果有,那么就会依次执行微任务队列的任务。当微任务队列没有待执行的任务,那么当前的宏任务就结束了,当前的宏任务从宏任务队列中删除,js继续执行宏任务队列里的下一个宏任务。js这样循环地检查宏任务事件队列和微任务事件队列来依次执行各个事件任务的机制,被称做事件循环EventLoop。当然事件循环涉及不止js事件的执行,还有很多其他非js事件的执行(如浏览器渲染等

用具体代码分析:

<div class="outer">
  <div class="inner"></div>
</div>
// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Let's listen for attribute changes on the
// outer element
new MutationObserver(function () {
  console.log('mutate');
}).observe(outer, {
  attributes: true,
});

// Here's a click listener…
function onClick() {
  console.log('click');

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

  Promise.resolve().then(function () {
    console.log('promise');
  });

  outer.setAttribute('data-random', Math.random());
}

// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);

在读ssh大佬的进阶指南时,他推荐了这篇文章,上面的示例代码就来自这篇文章,原文写的很详细,更有事件循环的step-by-step的动画演示过程便于理解,下面的分析也是基于对这片文章内容的摘抄和补充。建议先阅读原文以便下面的分析能容易的理解。

下面开始逐步分析代码:当点击inner div的时候
1、首先触发Click事件(会执行onClick这个回调函数),我们记做dispatch click,这是一个宏任务,MacroTasks queue存入dispatch click任务(注意,dispatch click任务包含执行onClick函数及其内部同步代码)。此时的任务队列大概是这个样子:

队列 任务
宏任务 MacroTask queue dispatch click
微任务 Microtask queue

2、js执行onClick,Call Stack推入onClick;Call Stack推入console.log('click'),console.log('click')出栈;
3、js执行setTimeoutAPI,Call Stack推入setTimeoutsetTimeoutAPI执行,浏览器提供一个timer开始计时,Call Stack推出setTimeoutAPI。同时因为setTimeout是一个宏任务,所以,0秒后(实际上是4ms左右,因为W3C在HTML中规定,setTimeout中低于4ms的时间间隔算为4ms)MacroTasks queue存入setTimeout的回调函数cb(setTimeout)

队列 任务
宏任务 MacroTask queue dispatch click,cb(setTimeout)
微任务 Microtask queue

4、js执行Promise.resolve.then(cb),Call Stack推入Promise.resolve.then(cb),Promise.resolve.then(cb)是一个微任务,因为是立即resolve了,所以紧接着,Microtask queue存入Promise.resolve.then(cb)的回调函数cb(Promise then),Call Stack推出Promise.resolve.then(cb)

队列 任务
宏任务 MacroTask queue dispatch click,cb(setTimeout)
微任务 Microtask queue cb(Promise then)

5、js执行outer.setAttribute(),Call Stack推入outer.setAttribute(),触发MutationObserver,它也是一个微任务,Microtask queue存入MutationObserver的回调函数cb(mutation observer)outer.setAttribute()出栈。

队列 任务
宏任务 MacroTask queue dispatch click,cb(setTimeout)
微任务 Microtask queue cb(Promise then),cb(mutation observer)

6、onClick内同步代码执行完了,宏任务dispatch click不会立即结束,此时会检查微任务队列是否有任务等待执行,此时微任务队列有2个任务,依次是cb(Promise then)cb(mutation observer),那么依次执行这2个微任务。
7、js执行cb(Promise then),即执行console.log('promise')。Call Stack推入console.log('promise'),Call Stack推出console.log('promise')。微任务队列删除cb(Promise then)

队列 任务
宏任务 MacroTask queue dispatch click,cb(setTimeout)
微任务 Microtask queue cb(mutation observer)

8、js执行cb(mutation observer),即执行console.log('mutate')。Call Stack推入console.log('mutate'),Call Stack推出console.log('mutate')。微任务队列删除cb(mutation observer)。此时微任务队列的任务也执行完了,Call Stack推出onClick

队列 任务
宏任务 MacroTask queue dispatch click,cb(setTimeout)
微任务 Microtask queue

9、那么宏任务dispatch click是不是结束了呢?答案是没有,因为dispatch click还有事件冒泡,会触发outer的Click事件,因此onClick函数再次被js执行。于是重复上面的2-8步骤。
10、2-8步骤重复完后,任务队列大概是这样的:

队列 任务
宏任务 MacroTask queue dispatch click,cb(setTimeout),cb(setTimeout)
微任务 Microtask queue

宏任务dispatch click结束。宏任务队列删除dispatch click,由于2-8步骤一共执行了2次,所以宏任务队列还有2个cb(setTimeout)宏任务。于是,继续执行下一个宏任务。
11、js执行第一个cb(setTimeout),即执行console.log('timeout'),Call stack推入console.log('timeout'),Call stack推出console.log('timeout')。此时微任务队列没有待执行的任务。第一个cb(setTimeout)宏任务结束了。宏任务队列删除第一个cb(setTimeout),继续执行第二个cb(setTimeout)
12、js执行第二个cb(setTimeout),即执行console.log('timeout'),Call stack推入console.log('timeout'),Call stack推出console.log('timeout')。此时微任务队列没有待执行的任务。第二个cb(setTimeout)宏任务结束了。宏任务队列删除第二个cb(setTimeout)
13、最终宏任务队列也空了,没有待执行的宏任务了。

再来看一下另一个例子

button.addEventListener('click', () => {
  promise.resolve().then(() => console.log('Microtask 1'));
  console.log('listener 1');
});

button.addEventListener('click', () => {
  promise.resolve().then(() => console.log('Microtask 2'));
  console.log('listener 2');
});

// js调用
// button.click();

button元素被添加了2个事件响应回调函数,根据不同的触发方式,各个事件响应的顺序也不同。

用户点击时,触发了2个click事件,他们是分别向主线程推入了回调函数,根据前面的介绍我们知道。DOM事件处理程序是宏任务,所以这2个都是宏任务。对应的log顺序是:listener 1Microtask 1listener 2Microtask 2。每个宏任务结束后都会执行相应的微任务。

而js调用时,又不太一样,它log的顺序是:listener 1listener 2Microtask 1Microtask 2。这是因为,整个script同步代码作为宏任务,当执行到button.click()时,会继续执行2个事件监听的回调函数,这2个回调函数也属性本次宏任务,只有2个回调函数都被执行完后,script才可以退出。这时才会检查微任务事件队列,去执行微任务。

4、浏览器渲染

除去网络资源获取的步骤,我们理解的 Web 页面的展示,一般可以分为 构建 DOM 树构建渲染树布局绘制渲染层合成 几个步骤。
关于这些步骤的细节,推荐阅读下面的扩展阅读,推荐的文章都介绍的比较好。

扩展阅读:
1、从浏览器多进程到js单线程,js运行机制最全面的一次梳理
2、从 8 道面试题看浏览器渲染过程与性能优化
3、浏览器层合成与页面渲染优化
4、神三元——浏览器渲染

下面来介绍一下和事件循环有关的一个动画API——requestAnimationFrame

4-1 requestAnimationFrame

通常浏览器渲染页面时,他的频率不会超过显示器刷新的频率,一般都是保持和显示器刷新频率一致(通常60次/秒,大约16.67ms一次)。也就是说,每次显示器刷新画面时,浏览器也更新一次页面。这样保证页面更新与显示器刷新保持同步,到达最流畅的显示效果。
在浏览器渲染进程中,周期性更新页面时,会调用js线程计算最新的页面,然后进行渲染流程。requestAnimationFrame就是在渲染前执行的动画。
定义:window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
用示例说明:

<div id="box"></div>
var box = document.getElementById('box');
var maxWidth = window.innerWidth;
var count = 0;
function moveBoxForwardOnePixel() {
  if (count >= maxWidth - box.offsetWidth) {
	count = 0;
  } else {
	++count;
  }
  box.style.left = count + 'px';
}
function callback() {
  moveBoxForwardOnePixel();
  requestAnimationFrame(callback);
  // setTimeout(callback, 0);
}
callback();

上面的代码是循环调用callback来让Box向前移动1个像素。
requestAnimationFrame是在每次渲染前移动一次,所以它看起来很流畅。显示器每刷新一次,浏览器更新一次页面,同时requestAnimationFrame在浏览器更新页面前让box向前移动1个像素。他们的频率都保持一致。
setTimeout虽然时间间隔是0,但是实际上是4ms左右,它的调用频率是大于浏览器和显示器刷新频率的(通常60次/秒,大约16.67ms一次)。每当浏览器想要更新页面时,setTimeout执行了好几次,计算box的位置时,box已经向前移动了好几个像素,这样box的移动动画就会不流畅,出现跳跃的现象。
requestAnimationFrame在每次渲染前进行计算,显示器每一帧都是先计算更新再进行渲染,即使requestAnimationFrame内的任务耗时较长推迟了渲染,他们的顺序是固定的,效果比setTimeout的不确定性更好,因为不同情况下setTimeout在每一帧出现的次数都不固定。因此,当我们在做动画时,推荐使用requestAnimationFrame。

参考文章:
1、 js中的同步和异步:https://www.cnblogs.com/Yellow-ice/p/10433423.html
2、 nodejs中的异步、非阻塞I/O是如何实现的?:http://47.98.159.95/my_blog/js-async/001.html#%E4%BB%80%E4%B9%88%E6%98%AFi-o%EF%BC%9F
3、JS异步编程有哪些方案?为什么会出现这些方案?:http://47.98.159.95/my_blog/js-async/002.html#%E5%9B%9E%E8%B0%83%E5%87%BD%E6%95%B0%E6%97%B6%E4%BB%A3
4、如何理解EventLoop,共三篇:http://47.98.159.95/my_blog/js-v8/004.html
5、前端高级进阶指南:sl1673495/blogs#37
6、Tasks, microtasks, queues and schedules:https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
7、从浏览器多进程到js单线程,js运行机制最全面的一次梳理:https://segmentfault.com/a/1190000012925872
8、从 8 道面试题看浏览器渲染过程与性能优化:https://juejin.im/post/5e143104e51d45414a4715f7#heading-18
9、浏览器层合成与页面渲染优化:https://juejin.im/post/5da52531518825094e373372#heading-1
10、浏览器渲染:http://47.98.159.95/my_blog/browser-render/001.html
参考视频:
1、https://www.bilibili.com/video/BV1K4411D7Jb?from=search&seid=10601633564738187138
2、https://www.bilibili.com/video/BV1ot411i7pD?from=search&seid=10636172763723335375

@lazyken lazyken changed the title EventLoop与浏览器渲染(未完成) EventLoop与浏览器渲染 Jul 28, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant