浏览器渲染过程及JS引擎浅析

Clloz · · 808次浏览 ·

前言

本文由于作者水平有限,肯定有错误之处,如果你看到,希望能够指出,感谢。

相信大家都听过一道经典的面试题:“在浏览器输入 URL 后回车之后发生了什么”,我一直想解答这个问题,不过这个题目涉及的知识面非常广,想要解答需要一定的知识储备。这篇文章我们讨论这个问题中的一部分,当浏览器拿到服务器传回的 html 文档后如何处理文档然后呈现在显示器上呢?

在进入正题之前我们来想几个问题,然后在跟着问题的脚步来分析:
1. 我们都知道 DOMCSS 的加载和渲染和 JS 的执行之间存在阻塞,阻塞发生的时候浏览器会加载其他页面资源吗
2. 都说 JS 是单线程的,那么 Event Loop 线程是什么呢,为什么 JS 要设计成单线程呢
3. setTimeout 是如何执行的,为什么 setTimeoutdelay 和执行时间不同
4. 浏览器的具体渲染流程是什么样的

希望我写完这篇文章和你看完这篇文章之后都能解答这几个问题。

浏览器

在解决问题之前,我们要先了解我们的对象:浏览器。浏览器的功能很简单,就是根据我们给出的 URI,替我们向服务器发出请求,获取服务器上的资源并在展示给我们。这里的资源主要是 HTML 文档也可以是图片,pdf 或其他类型的文件,取决于我们给出的 URI 采取的协议以及请求的文件类型。

浏览器的主要组件

  1. 用户界面( The userinterface ):包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗口显示的您请求的页面外,其他显示的各个部分都属于用户界面。
  2. 浏览器引擎( The browser engine ):用户页面和渲染引擎的中间层,负责在用户界面和渲染引擎之间传递信息。
  3. 渲染引擎( The rendering engine ):负责请求显示的内容。比如请求的是 HTML 文档,那么渲染引擎负责解析 HTMLCSS ,并将解析的结果显示到显示器上。
  4. 网络组件:用于网络调用,比如 http 请求。
  5. 用户界面后端( UI backend ):用于绘制一些小部件。
  6. JavaScript 解释器( JavaScript Interpreter ):解析并执行 JavaScript 代码。
  7. 数据存储( Data Storage ):浏览器需要保存各种类型的数据到本地,比如 cookieslocalStorage, IndexedDB, WebSQL and FileSystem.

browser-structure

这些组件之间是如何配合工作的呢,我们都知道 chrome 是内存杀手,那么我们打开的多个 tab 又是如何分配资源的,不同的 tab 之间会争夺资源吗?

进程与线程

很多前端开发人员都知道 JavaScript 是单线程语言,但具体是什么单线程呢,其实说的是 JS 引擎,也就是上面的 JavaScript 解释器是单线程的,那么这个单线程到底是什么意思呢,我们要来说说线程和进程的概念和关系,以及浏览器中的进程与线程。

进程和线程在大学的操作系统课程上讲的很多,不过可能很多同学忘记了,那么我们来说说进程和线程的概念。

我们的计算机中的所有计算都是在 cpu 中完成的,cpu 的计算速度非常快,大部分的设备是完全跟不上 cpu的速度的,那么怎么办呢。用金字塔式存储体系来根据信息处理的紧急程度分开存储,它们一般从下到上越来越小,越来越快,越来越贵。如下图:

memeory

大部分人都知道计算机有硬盘和内存,而我们的软件一般就安装在硬盘中,在硬盘中的数据是我们最不急需处理的,它们就静静地呆在那里。当我们想要运行一个程序,这些文件就被装载到内存中去了,而对于内存中最急需处理的文件会被传输到 CPU 的高速缓存中,也就是我们买 CPU 的时候会看到三级,二级,一级缓存,而在这些缓存中的文件最后会被依次传到 CPU 的寄存器中让 CPU 执行,只有寄存器的速度勉强能跟上 CPU

但是我们可以同时开很多软件,同时做很多工作,比如我可以一边听着音乐,一边修改着 Word,同时还在后台开着浏览器后台播放着直播,不仅如此,我们的操作系统还有很多程序需要运行。那么它们是按照什么顺序进 CPU 运行的呢。我们前面已经说过 CPU 非常快,所以当我们的程序进入 CPU 运行的时候,其他资源比如显卡都应该就位了,这些资源就构成了程序执行的上下文。这个程序执行上下文就是我们的进程,也就是操作系统分配系统资源的最小单位,说白了进程就是操作系统用来管理程序和计算机资源之间的分配关系的手段。当我们的程序终于等到能够进入CPU运行的机会,先装载执行上下文,然后执行程序,程序执行完成或系统分配的时间结束,就必须保存执行上下文,让排队的下一个进程进入计算。CPU 就不断重复着装载上下文,执行程序,保存上下文的过程。

那么线程是什么呢,当我们的程序终于获得 CPU 的临幸,我们当然希望我们的程序能尽快执行完成。如果我们的程序只有一个逻辑要执行,那么我们其实只需要一个线程就可以了,但如果有几个并行的任务需要执行,我们可以借助多核 CPU 同时开多个线程,在一个执行上下文中共享系统分配的资源,来协同更快地完成任务。(单核 CPU 也有多线程,操作系统在不同的线程之间快速切换,在进程间交替运行,减少 CPU 闲置的时间)

操作系统对进程的处理和资源的分配会复杂很多,我们只是了解一下大致的概念。阮一峰的博客有一篇更形象一点的比喻,大家可以借助比喻帮助理解:进程与线程的一个简单解释

浏览器的进程和线程

现在大家应该已经对进程和线程有一定的了解了,那么我们来说一说浏览器的进程和线程,首先我们要说,浏览器程序是多进程的,我们的每一个标签页( tab )都是一个独立的进程。我们可以打开 Chromemore tools 中的 task manager,就可以看到当前浏览器的进程。

chrome-process

从途中我们可以看到我们的每一个标签页都是一个进程,对应的 pidMac 的任务管理器中也都能看到,不过除了标签页的进程,我们还看到了比如 GPU ProcessBrowser 等进程,那么我们浏览器到底有哪些进程呢?我们结合上面浏览器的主要组件来看(以 Chrome 为例):
1. Browser 进程:控制 chrome 应用界面的一些组件,比如地址栏,书签栏,前进后退按钮等。还控制一些浏览器的不可见部分,比如网络请求和文件访问等。
2. Renderer 进程(浏览器内核):控制标签页内部网页要显示的一切,也就是我们访问的内容都是有渲染引擎控制,比如页面渲染,脚本执行,事件的处理。
3. Plugin 进程:控制网站应用的插件,比如 flash
4. GPU 进程:独立于其他进程处理 GPU 任务,它被独立出来是因为 GPU 处理来自不同程序的请求。

browserarch

browserui

第四点翻译的可能有点问题,原文Inside look at modern web browser,这个系列文章非常不错,推荐大家看看。

多进程什么好处呢,最简单的比方,我打开了三个 tab 浏览三个网页,每个页面都有单独的渲染进程,如果其中一个页面的代码非常糟糕崩溃了,那么你的另外两个页面不会受到影响,你依然可以继续浏览。而如果三个页面共享一个进程,那么你的两外两个页面也将崩溃。

不过正因为如此,chrome 简直是内存杀手,相信大家都有体会。

渲染引擎

对于我们前端来说,最重要的就是渲染引擎以及它工作的渲染进程,这是我们打交道最多的地方,学期其他的浏览器知识能让我们更清楚渲染引擎在浏览器中的定位和工作流程。渲染引擎也就是我们经常说的浏览器内核。渲染进程是多线程的,那到底有哪些线程,分别做什么工作呢?

  1. GUI 渲染线程
    • 负责渲染浏览器界面,解析HTMLCSS ,构建 DOM 树和 RenderObject 树,布局和绘制等。
    • 当界面需要重绘( Repaint )或由于某种操作引发回流( reflow )时,该线程就会执行。
    • 注意,GUI 渲染线程与 JS 引擎线程是互斥的,当 JS 引擎执行时 GUI 线程会被挂起(相当于被冻结了),GUI 更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行。
  2. JavaScript 引擎
    • 负责处理 Javascript 脚本程序。(例如 V8 引擎),JS 引擎是基于事件驱动单线程执行的, JavaScript 引擎一直等待着任务队列中任务的到来,然后加以处理,浏览器无论什么时候都只有一个 JavaScript 线程在运行 JavaScript 程序。
  3. 事件触发线程
    • 管理 Event LoopEvent Loop 的标准是在HTML5中,是渲染引擎的一个线程来处理,所以并不和 JS 单线程执行矛盾。
    • 当一个事件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JavaScript 引擎的处理。这些事件可来自 JavaScript 引擎当前执行的代码块如 setTimeout 、也可来自浏览器内核的其他线程如鼠标点击、Ajax 异步请求等,但由于 JavaScript 的单线程关系,所有这些事件都得排队等待 JavaScript 引擎处理(当线程中没有执行任何同步代码的前提下才会执行异步代码)。
  4. 定时器触发线程
    • 浏览器定时计数器并不是由 JavaScript 引擎计数的,(因为 JavaScript 引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确),因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行)。W3CHTML 标准中规定,规定要求 setTimeout 中低于 4ms 的时间间隔算为 4ms
  5. 异步请求线程
    • XMLHttpRequest 在连接后是通过浏览器新开一个线程请求
    • 检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由 JavaScript 引擎执行。

其中前三个是常驻线程,是所有浏览器内核必须实现的,后两个线程执行完就会终止。

关于JS引擎为什么是单线程的,因为大部分程序控制UI的都会是单一单线程,因为 JS 主要使用场景是与用户交互和操作 DOM,如果两个线程同时操作一个 DOM 会很复杂。而H5也提供了多线程方法 web worker,可以创建多个线程,但是子线程完全受控于主线程且不得操作 DOM

渲染过程

浏览器接收到服务器返回的 HTML 文档后就开始解析并渲染 HTML 文档,主要流程如下:
1. 解析 HTML 文档,将元素按层次转化成一棵 DOM树,根节点为 document
2. 解析 CSS 样式文件(包括外部 CSS文件和样式元素以及 js 生成的样式),获取样式数据。
3. 结合样式和 DOM树计算出节点的样式,生成渲染树( render tree ),渲染树包含多个带有样式属性的矩形,这些矩形的排列顺讯就是它们在屏幕上显示的顺序。
4. 进入布局阶段,从根节点递归调用,计算每一个元素的大小、位置等,给每个节点所应该出现在屏幕上的精确坐标。
5. 遍历渲染树,每个节点将使用 UI 后端层来绘制。

对于渲染引擎的渲染细节感兴趣的同学可以看看这篇文章,How browsers work,中文翻译的不是很好,推荐大家结合英文看。

browser-rendering

ReflowRepaint

  1. Reflow : 对于 DOM 结构中的各个元素都有自己的盒子(模型),这些都需要浏览器根据各种样式(浏览器的、开发人员定义的等)来计算并根据计算结果将元素放到它该出现的位置。
  2. Repaint : 当各种盒子的位置、大小以及其他属性,例如颜色、字体大小等都确定下来后,浏览器于是便把这些元素都按照各自的特性绘制了一遍,于是页面的内容出现了。

我们可以发现 Reflow 对应的是渲染过程中的第四步,而 Repaint 对应的是渲染过程的第五步。直白一点说就是当 DOM 被修改后需要重新计算渲染树 render tree 的一部分或者全部的时候,我们就需要 Reflow,而如果元素的修改不影响渲染树,那么只要 Repaint 就可以了。

显而易见,Reflow 的成本要比 Repaint 高得多,DOM Tree 里的每个结点都会有 reflow方法,一个结点的 reflow 很有可能导致子结点,甚至父点以及同级结点的 reflow。以下这些行为会触发 Reflowrepaint

  • 删除,增加,或者修改 DOM 元素节点。
  • 移动 DOM 的位置,开启动画的时候。
  • 修改 CSS 样式,改变元素的大小,位置时,或者将使用 display:none 时,会造成 reflow;修改 CSS 颜色或者 visibility:hidden 等等,会造成 repaint
  • 修改网页的默认字体时。
  • Resize 窗口的时候(移动端没有这个问题),或是滚动的时候。
  • 内容的改变,(用户在输入框中写入内容也会)。
  • 激活伪类,如 :hover
  • 计算 offsetWidthoffsetHeight

现在的浏览器已经对渲染的过程尽可能的优化,不过我们还是可以在编码的过程只能够注意一些细节:

  • 尽量避免 style 的使用,对于需要操作 DOM 元素节点,重新命名 className,更改 className 名称。
  • 如果增加元素或者 clone 元素,可以先把元素通过 documentFragment 放入内存中,等操作完毕后,再 appendChildDOM 元素中。
  • 不要经常获取同一个元素,可以第一次获取元素后,用变量保存下来,减少遍历时间。
  • 尽量少使用 dispaly:none,可以使用 visibility:hidden 代替,dispaly:none 会造成重排, visibility:hidden 会造成重绘。
  • 不要使用 Table 布局,因为一个小小的操作,可能就会造成整个表格的重排或重绘。
  • 使用 resize 事件时,做防抖和节流处理。
  • 对动画元素使用 absolute / fixed 属性。
  • 批量修改元素时,可以先让元素脱离文档流,等修改完毕后,再放入文档流。

DOM, CSS, JS的阻塞

渲染过程并不像上面描述的那么简单,特别是页面的绘制是一件开销非常大的事情,所以浏览器尽量最有效率的绘制,避免那些没必要的重绘和回流。要避免这个问题,DOM 加载和解析,CSS 加载和解析,和 JS 的加载执行之间的顺序和阻塞就非常重要,如果处理不当,页面很可能要重绘。因为渲染树是 DOMCSS 结合生成的,而 JS 可以操作 DOM 和样式,必须处理好三者之间的逻辑。

loadDOMContentLoaded

在分析具体情况之前我们先说两个事件 DOMContentLoadedload

  • load:当一个资源及其依赖资源已完成加载时,将触发 load 事件,也就是当页面的 htmlcssjs、图片等资源都已经加载完之后才会触发 load 事件。
  • DOMContentLoaded :当初始的 HTML 文档被完全加载和解析完成之后就会被触发,而无需等待样式表、图像和子框架的完成加载。

上面两个说法都是 MDN 给出的,但是其实具体结合加载和解析的阻塞情况会不一样(结合下面的阻塞情况来看这几点):
1. 因为 js 会阻塞 DOM 的解析,所以当文档解析完成触发 DOMContentLoaded 事件的时候,文档中所有的同步的 js 任务都已经执行完毕。
2. 因为 CSS 会阻塞 JS 而不会阻塞 DOM 解析,并且 JS 的执行会让该 JS 之前的以加载 DOMCSS 渲染,所有在 DOMContentLoaded 事件触发时,所有在 JS 之前的 CSS 已经加载并渲染完成。
3. 当 DOMContentLoaded 事件触发时,文档解析完毕,页面的 DOM 树已经构建完成,所有页面上的 JS 同步任务执行完成,所有在 JS 之前的 CSS 都已经加载并渲染完成。(页面每遇到一个 script 标签都会把当前已经构建的 DOM 树和 CSSOM树结合渲染一次)
4. 当 DOMContentLoaded 事件触发之后,浏览器会继续加载在 JS 之后的 CSS,以及在JS中同步添加的 img,css,js 文件,当这些资源全部加载完成,会进行 load 事件触发之前的最后一次渲染,之后load事件被触发。

这段代码可以验证,同步的 js 代码中的外部资源文件( jscssimg )都加载(由异步请求线程完成的异步加载)完毕后才会触发 load 事件,加载速度可以通过设置 network throttling 配置,把加载速度设置非常慢,可以看到资源文件加载完成以后load事件才触发。而异步事件 setTimeout 回调函数中的资源文件则会在 load 事件触发后在执行,不会阻塞 load 的触发。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Test</title>
    <script defer src="js/test.js"></script>
  </head>
  <body>
    <div id="test"></div>

    <script>
      let script = document.createElement("link");
      script.setAttribute("rel", "stylesheet");
      script.setAttribute(
        "href",
        'https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css'
      );

      var a = document.body.appendChild(script);

      // setTimeout(function() {
      //   let script = document.createElement("link");
      //   script.setAttribute("rel", "stylesheet");
      //   script.setAttribute(
      //     "href",
      //     'https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css'
      //   );
      //   var a = document.body.appendChild(script);
      //   console.log("setTimeout...");
      // }, 3000);

      window.onload = function() {
        console.log("window load...");
      };
    </script>
  </body>
</html>

下面有提到预加载监视器 preload scanner,浏览器对同一域名下的下载并发不超过 6 个。超过 6 个的话,剩余的将会在队列中等待,这就是为什么我们要将资源收敛到不同的域名下,也是为了充分利用该机制,最大程度的并发下载所需资源,尽快的完成页面的渲染。

下载和加载在浏览器渲染过程中应该是相同的意思。

阻塞关系

  1. 外部资源文件的加载不会被阻塞(js,css,image)
    我们的 HTML 文档一般都会包含外部链接,引入资源文件,包括 jscss,图片等,我们的主线程会一个一个的请求这些链接,不过现代浏览器一般会有一个预加载监视器 preload scanner 来加速这些链接的加载,因为我们的文档解析需要事件,所以提前发送请求获取资源文件会提高效率。这个预加载监视器会找到 <img><link> 之类的标签,发送给网络线程请求资源。

preload

  1. CSS 不会阻塞 DOM 解析
    这一点其实很好理解,CSS 不会引起 DOM的变化,它们两个只要尽早解析完成,然后生成渲染树就可以了,并行加载并不影响。我们可以设计一个场景模拟出这个状态,现在 chrome 中把 network throttling 设置一个较小的数值(我设置的 50kb/s ),然后加载一个稍大的 CSS,然后在 css 之前用一个 defer 属性的 js 获取页面上的 DOMdefer 属性表示这个 js 会在 DOMContentLoaded 事件发生后立即执行),如果能够在 css 加载好之前获取 dom 节点,说明 CSS 是不会阻塞 DOM 解析的。
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>css-dom-parse</title>
    <script defer src="css-dom-parse.js"></script>
    <link href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css" rel="stylesheet">
</head>
<body>
    <div>test</div>
</body>
</html>
//css-dom-parse.js
const div = document.querySelector('div');
console.log(div);

结果如下图

css-dom-parse

此时我们的 css 还没加载完成,但是我们可以看到 console 选项卡里面 div 标签已经被打印出来了,说明,此时 DOM 已经解析完成触发 DOMContentLoaded 事件。

  1. CSS 会阻塞 DOM 渲染
    因为 DOM 的渲染需要 DOM 树和 CSSOM 树共同来生成渲染树,所以在 CSS 加载完成之前, DOM 是不会进行渲染的。还是用上面那个例子,我们会发现 CSS 没有加载完成时,页面上是不显示 test 的,而当 CSS 加载完成的瞬间,标签就被渲染到页面上了。

  2. CSS 会阻塞 JS
    这一点可能大家有点疑惑,但是仔细想一想,我们的脚本可以在文档解析阶段请求样式信息,如果此时我们的样式还没有加载和解析,那么脚本必然会获得错误的结果,这样会产生很多问题,目前的浏览器做法就是在 CSS加载解析的过程中会阻塞 JS 的加载执行。虽然我们不常遇到这样的情况,但是也要清楚为什么会发生这样的情况。我们还用刚刚的方法,不过这次我们需要快速看到结果,可以把速度设置的快一点,network throttling 设置为 slow 3G,代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>css-dom-parse</title>
    <script>
        var starttime = new Date().getTime();
        console.log("page start" + starttime);
    </script>
    <link href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css" rel="stylesheet">
    <script src="css-dom-parse.js"></script>
</head>
<body>
    <div>test</div>
</body>
</html>
var endtime = new Date().getTime();
console.log("delay:" + (endtime - starttime));

我们在 CSS 加载之前记录事件并保存到全局变量 starttime 中,然后在 CSS 之后加载一个 JS 文件,当这个 JS 文件执行的时候,我们输出执行时间并计算时间差。结果如下图:
css-js-block
我们把时间线拖到页面加载的最开始,我们发现 htmljs都已经加载完毕,但是 css 的请求还没有完成,而我们在看看输出的时间,我们的 js 是在 2487ms 后才执行,正是 css 加载完成后才执行的js。

  1. JS 会阻塞 DOM解析
    当解析起遇到 script 标签时,文档会立即停止解析知道脚本执行完毕,如果脚本是外部的,那么解析过程会停止,直到从网络同步抓取资源完成后再继续。因为我们的脚本会操作 DOM,所以在脚本跑完之前浏览器不知道脚本会把 DOM 改成什么样,所以就等脚本执行完再进行解析。测试这个也很简单,我们用一个有 defer 属性的 jsDOMContentLoaded 的时候输出内容,然后再写一个一定时常的死循环,看看输出结果。代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>css-dom-parse</title>
    <script>
        var starttime = new Date().getTime();
        console.log('starttime' + starttime);
    </script>
    <script defer src="domcontentloaded.js"></script>
    <script src="css-dom-parse.js"></script>
</head>
<body>
    <div>test</div>
</body>
</html>
//domcontentloaded.js
console.log("DOMContentLoaded! " + (new Date().getTime() - starttime));

//css-dom-parse.js
var now = new Date().getTime();

while (new Date().getTime() - now < 3000) {
    continue;
}
console.log("time out" + new Date().getTime());

结果如下图:
js-dom-block
可以看出 DOMContentLoaded 事件一直等到 JS 执行完成以后才触发。

  1. 浏览器解析到 script 标签会立即触发一次渲染
    这个机制可能很多朋友不是很清楚,当我们的浏览器在解析文档的过程中遇到 script 标签( body 中的,因为 body 之前并没有需要渲染到页面上的元素),他会立即把已经解析的部分渲染了,这当然是个比较极端的情况,一般良好的代码不会遇到。大概是因为解析到一半的时候渲染树还没有,如果此时 JS 要操作的 DOM 的话,浏览器不知道如何处理,只能先把当前内容渲染了。测试代码如下:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Test</title>
    <script>
      console.log("page start" + new Date().getTime());
    </script>
    <style>
      div {
        background: lightblue;
        width: 500px;
        height: 500px;
      }
    </style>
  </head>
  <body>
    <div>test3</div>
    <script src="js/test.js"></script>
    <style>
      div {
        background: lightgray;
      }
    </style>
    <script src="js/test1.js"></script>
    <style>
      div {
        background: lightpink;
      }
    </style>
  </body>
</html>
//test.js 和 test1.js相同,都是执行三秒
var now = new Date().getTime();

while (new Date().getTime() - now < 3000) {
    continue;
}
console.log("js11 start" + new Date().getTime());

执行后我们会发现 div 先被渲染成 lightblue,三秒后被渲染成 lightgray,再过三秒被渲染成 lightpink

CSS 虽然不会阻塞 DOM,但是如果在 CSS 后有一个 JSCSS 会阻塞这个 JS,而这个 JS 会阻塞 DOM,所以有时候会造成 CSS 阻塞 DOM 的错觉。

白屏时间

阻塞的第六种情况说到每次 <script> 的执行渲染引擎都会将当前已经解析的 DOMCSSOM 立即合并成一棵 Render 树进行一次渲染,而第一个 body 中的 <script> 标签引发的渲染就是页面结束白屏的时间点。如果渲染引擎解析到第一个 script 标签时,对应的 js 文件还没有加载到本地,那么会立即进行渲染,如果已经加载好,那么会在 JS 引擎执行完这段脚本之后立即进行渲染。下面的代码可以测试这个机制(依然是把 network throttling 设置慢速,让css的加载时间较长以达到测试的效果:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Test</title>
    <script defer src="js/test.js"></script>
  </head>
  <body>
    <div id="test">test1</div>

    <script>
      var start = new Date().getTime();
      while (new Date().getTime() - start < 500) {
        continue;
      };
      console.log("time out");
    </script>
    <link rel="stylesheet" href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css">
    <script>
      console.log(123);
    </script>
  </body>
</html>
//test.js
console.log('domcontentloaded');

当timeout输出时,div正好被渲染出来,此时 bootstrapcss 依然在加载中,因为 css 的加载阻塞了下面的 console.log(123) 的执行,当css加载完毕后,console.log(123) 执行,然后触发 DOMContentLoaded 时间,最后 defer 属性的js执行。

documentperformance 属性可以帮助我们了解页面的加载时间。

defer和async的区别

上面有几段代码用了 defer 属性,其实还有个属性 async,这里说一下这两个属性。
1. <script src="script.js"></script>
– 没有 deferasync,浏览器会立即加载并执行指定的脚本,“立即”指的是在渲染该 script 标签之下的文档元素之前,也就是说不等待后续载入的文档元素,读到就加载并执行。

  1. <script async src="script.js"></script>
    • async,加载和渲染后续文档元素的过程将和 script.js 的加载与执行并行进行(异步)。
  2. <script defer src="myscript.js"></script>
    • defer,加载后续文档元素的过程将和 script.js 的加载并行进行(异步),但是 script.js 的执行要在所有元素解析完成之后,DOMContentLoaded 事件触发之前完成。

把所有脚本都丢到 </body> 之前是最佳实践,因为对于旧浏览器来说这是唯一的优化选择,可以保证非脚本的其他一切元素能够以最快的速度得到加载和解析。

defer-async
1. deferasync 在网络读取(加载)过程中是一样的,都是异步的(相较于 HTML 解析)
2. 两者差别在于脚本加载完之后何时执行,显然 defer 是最接近我们对于应用脚本加载和执行的要求的
3. defer 是按照加载顺序执行脚本的
4. async 则是乱序执行的,对它来说脚本的加载和执行是紧紧挨着的,所以不管你声明的顺序如何,只要它加载完了就会立刻执行
5. async 对于应用脚本的用处不大,因为它完全不考虑依赖(哪怕是最低级的顺序执行),不过它对于那些可以不依赖任何脚本或不被任何脚本依赖的脚本来说却是非常合适的,最典型的例子:Google Analytics

Event Loop

先了解三种数据结构
1. 栈( stack ):栈在计算机科学中是限定仅在表尾进行插入或删除操作的线性表。 栈是一种数据结构,它按照后进先出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据。栈是只能在某一端插入和删除的特殊线性表。
2. 堆( heap ):堆是一种数据结构,是利用完全二叉树维护的一组数据,堆分为两种,一种为最大堆,一种为最小堆,将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。堆是线性数据结构,相当于一维数组,有唯一后继。
3. 队列( queue ):特殊之处在于它只允许在表的前端( front )进行删除操作,而在表的后端( rear )进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。 队列中没有元素时,称为空队列。队列的数据元素又称为队列元素。在队列中插入一个队列元素称为入队,从队列中删除一个队列元素称为出队。因为队列只允许在一端插入,在另一端删除,所以只有最早进入队列的元素才能最先从队列中删除,故队列又称为先进先出( FIFO—first in first out )。

javaScript 是单线程,也就是说只有一个主线程,主线程有一个栈,每一个函数执行的时候,都会生成新的execution context (执行上下文),执行上下文会包含一些当前函数的参数、局部变量之类的信息,它会被推入栈中, running execution context(正在执行的上下文)始终处于栈的顶部。当函数执行完后,它的执行上下文会从栈弹出。把JS引擎再细分有三个部分:
1. Stack :主线程的函数执行都压在这个栈中。
2. Heap :存放对象,数据。没有引用的对象会被垃圾回收。
3. Task Queue :执行栈为空的时候从任务队列中取一个任务执行,再次为空时再次到任务队列中取任务执行,如此循环,所以称为 Event Loop

js-engine

具体过程如下图:
event-loop
Javascript 单线程任务被分为同步任务和异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务会在异步任务有了结果后,将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行。图中的异步处理模块就是我们之前在JS 引擎中提到的事件触发线程,当JS 引擎遇到异步任务的时候就把异步函数交给事件触发线程,当异步函数达到执行条件之后,事件触发线程会把异步任务根据类型压入指定任务队列。下图

任务队列

Js 中,有两类任务队列:宏任务队列( macro tasks )和微任务队列( micro tasks)。宏任务队列可以有多个,微任务队列只有一个。
– 宏任务:script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering.
– 微任务:process.nextTick, Promise, Object.observer, MutationObserver.

浏览器环境下,Event Loop 是按照 HTML5 的标准来实现,当执行栈空的时候 JS引擎会按如下规则取任务执行:
1. 取一个宏任务来执行。执行完毕后,下一步。
2. 取一个微任务来执行,执行完毕后,再取一个微任务来执行。直到微任务队列为空,执行下一步。
3. 更新UI渲染。

Event Loop 会无限循环执行上面3步,这就是 Event Loop 的主要控制逻辑。其中,第3步(更新UI渲染)会根据浏览器的逻辑,决定要不要马上执行更新。毕竟更新 UI 成本大,所以,一般都会比较长的时间间隔,执行一次更新。
从逻辑上来看,浏览器倾向于尽可能快地执行完微任务,当全局任务(其实是全局函数中的同步任务)执行完之后,会立即执行微任务队列,即使微任务队列执行完了,在每次执行完一个宏任务之后都会检查微任务队列,如果就微任务就一直执行到微任务队列为空才会执行宏任务。

console.log('script start');

// 微任务
Promise.resolve().then(() => {
    console.log('p 1');
});

// 宏任务
setTimeout(() => {
    console.log('setTimeout');
}, 0);

var s = new Date();
while(new Date() - s < 50); // 阻塞50ms

// 微任务
Promise.resolve().then(() => {
    console.log('p 2');
});

console.log('script ent');

/*** output ***/
// one macro task
script start
script ent

// all micro tasks
p 1
p 2

// one macro task again
setTimeout

NodeJsEvent Loop

Event Loop 之前会先做这些工作:
1. 初始化 Event Loop
2. 执行您的主代码。这里同样,遇到异步处理,就会分配给对应的队列。直到主代码执行完毕。
3. 执行主代码中出现的所有微任务:先执行完所有nextTick(),然后在执行其它所有微任务。
4. 开始 Event Loop

Event Loop分为6个阶段:
1. timers : 这个阶段执行 setTimeout()setInterval() 设定的回调。
2. pending callbacks : 上一轮循环中有少数的 I/O callback 会被延迟到这一轮的这一阶段执行。
3. idle , prepare : 仅内部使用。
4. poll : 执行 I/O callback,在适当的条件下会阻塞在这个阶段
5. check : 执行 setImmediate()设定的回调。
6. close callbacks : 执行比如 socket.on('close', ...) 的回调。

每个阶段执行完毕后,都会执行所有微任务(先 nextTick,后其它),然后再进入下一个阶段。

setTimeout

setTimeout 单独拿出来说是因为它有几点比较特别的地方。
1. setTimeout 是异步的,它会有主线程交给事件触发线程,然后放到宏队列中去。并且 HTML5 标准规定了delaysetTimeout )的第二个参数至少为 4ms,即使你写 0

setTimeout(function () {
    console.log(1);
}, 0);
console.log(2);

//output
2
1
  1. setTimeoutdelay 只能表示它被事件触发程序放到任务队列中的时间,如果此时任务队列前面没有任务,执行栈也为空,那么回调函数会被立即执行,但是如果任务队列前面还有未执行任务或者执行栈中不为空,则需要继续等待。
var starttime = new Date().getTime()
console.log("start " + starttime);

setTimeout(function () {
    var endtime = new Date().getTime()
    console.log("end " + endtime);
    console.log("timediff " + (endtime - starttime));
}, 1000);

while (new Date().getTime() - starttime < 3000) {
    continue;
}

//output
start 1556197854352
end 1556197857353
timediff 3001

总结

本文只是阅读的文章结合自己的一点见解,只是浏览器的渲染过程以及 JS 引擎的一点皮毛,而且在前端技术日新月异的今天,浏览器也在不断地进步和优化,想要更好的掌握其原理还需要不断学习。

参考文章

  1. 原来 CSS 与 JS 是这样阻塞 DOM 解析和渲染的
  2. css加载会造成阻塞吗?
  3. defer和async的区别
  4. JavaScript 异步、栈、事件循环、任务队列
  5. 一次弄懂Event Loop
  6. 从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理
  7. 浏览器的工作原理
  8. 浏览器渲染原理(性能优化之如何减少重排和重绘)
  9. 再谈 load 与 DOMContentLoaded

Clloz

人生をやり直す

发表评论

电子邮件地址不会被公开。 必填项已用*标注

我不是机器人*

 出现新的回复时用邮件提醒我。

EA PLAYER &

历史记录 [ 注意:部分数据仅限于当前浏览器 ]清空

      00:00/00:00