浏览器页面加载缓慢原因分析

2,049 阅读18分钟

这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战

从用户浏览器输入url开始

经常有面试官会问:从用户输入浏览器输入url到页面最后呈现有哪些过程?

答案大致如下:

  1. 用户输入URL地址
  2. 浏览器解析URL解析出主机名
  3. 浏览器将主机名转换成服务器ip地址(浏览器先查找本地DNS缓存列表 没有的话 再向浏览器默认的DNS服务器发送查询请求 同时缓存)
  4. 浏览器将端口号从URL中解析出来
  5. 浏览器建立一条与目标Web服务器的TCP连接(三次握手)
  6. 浏览器向服务器发送一条HTTP请求报文
  7. 服务器向浏览器返回一条HTTP响应报文
  8. 关闭连接 浏览器解析文档
  9. 如果文档中有资源 重复6 7 8 动作 直至资源全部加载完毕

浏览器

目前使用的主流浏览器有五个:Internet Explorer、Firefox、Safari、Chrome 浏览器和 Opera。本文中以开放源代码浏览器为例,即 Firefox、Chrome 浏览器和 Safari(部分开源)。根据 StatCounter 浏览器统计数据,目前(2021年 11 月)Firefox、Safari 和 Chrome 浏览器的总市场占有率 87.39%。由此可见,如今开放源代码浏览器在浏览器市场中占据了非常坚实的部分。

StatCounter.png

浏览器的主要作用

浏览器的主要功能就是向服务器发出请求,在浏览器窗口中展示您选择的网络资源。这些网络资源包括以下内容:

  • HTML
  • CSS
  • JavaScript的
  • 媒体(图片,视频等)

也可以分为HTML文档(HTML/CSS/JS)、PDF、图片、视频和其他类型。

浏览器解释并显示 HTML 文件的方式是在 HTML 和 CSS 规范中指定的。这些规范由网络标准化组织 W3C(万维网联盟)进行维护。 多年以来,各浏览器都没有完全遵从这些规范,同时还在开发自己独有的扩展程序,这给网络开发人员带来了严重的兼容性问题。如今,大多数的浏览器都是或多或少地遵从规范。

浏览器的用户界面有很多彼此相同的元素,其中包括:

  • 用来输入 URI 的地址栏
  • 前进和后退按钮
  • 书签设置选项
  • 用于刷新和停止加载当前文档的刷新和停止按钮
  • 用于返回主页的主页按钮

浏览器的组成结构

layers.png

  1. 用户界面(User Interface) - 包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗口显示的您请求的页面外,其他显示的各个部分都属于用户界面。

  2. 浏览器引擎(Browser engine) - 在用户界面和渲染引擎之间传送指令。

  3. 渲染引擎(Rendering engine) - 负责显示请求的内容。如果请求的内容是 HTML,它就负责解析 HTML 和 CSS 内容,并将解析后的内容显示在屏幕上。

    也可以叫呈现引擎(Rendering Engine)或者布局引擎(Layout Engine)

    默认情况下,渲染引擎可显示 HTML 和 XML 文档与图片。通过插件(或浏览器扩展程序),还可以显示其他类型的内容;例如,使用 PDF 查看器插件就能显示 PDF 文档。但是在本章中,我们将集中介绍其主要用途:显示使用 CSS 格式化的 HTML 内容和图片。

    浏览器(Firefox、Chrome 浏览器和 Safari)是基于两种渲染引擎构建的。Firefox 使用的是 Gecko,这是 Mozilla 公司“自制”的渲染引擎。而 Safari 和 Chrome 浏览器使用的都是 WebKit。

    浏览器渲染引擎(开发语言)脚本引擎(开发语言)
    ChromeBlink (c++)V8 (c++)
    OperaBlink (c++)V8 (c++)
    SafariWebkit (c++)JavaScript Core (nitro)
    FireFoxGecko (c++)SpiderMonkey (c/c++)
    EdgeEdgeHTML (c++)Chakra JavaScript Engine (c++)
    IETrident (c++)Chakra JScript Engine (c++)

    Firefox在2017年启用了新的web引擎Quantum,Quantum 以 Gecko 引擎为基础,同时利用了 Rust 的良好并发性和 Servo 的高性能组件,为 Firefox 带来了更多的并行化和 GPU 运算,让 Firefox 更快更可靠。

    2015 年 3 月,微软将放弃自家Edge:转而开发Chromium内核浏览器。

浏览器是多进程的

进程线程简单的理解:进程里面可以有多个线程,进程就是QQ,线程就是会话。

  • 浏览器是多进程的
  • 浏览器之所以能够运行,是因为系统给它的进程分配了资源(cpu、内存)
  • 简单点理解,每打开一个Tab页,就相当于创建了一个独立的浏览器进程。

浏览器里面的进程:

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

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

  3. GPU进程:最多一个,用于3D绘制等

  4. 渲染进程(浏览器内核)(Renderer进程,内部是多线程的)

    • 默认每个Tab页面一个进程,互不影响。
    • 主要作用为页面渲染,脚本执行,事件处理等

渲染进程是多线程的:

  1. GUI渲染线程

    • 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。
    • 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
    • 注意,GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。
  2. js引擎线程

    • 也称为JS内核,负责处理Javascript脚本程序。(例如V8引擎)
    • JS引擎线程负责解析Javascript脚本,运行代码。
    • JS引擎一直等待任务队列中任务的到来,然后加以处理,一个Tab页(renderer进程)中只有一个JS线程在运行
    • 同样注意,GUI渲染线程与JS引擎线程是互斥的。所以如果JS执行的时间过长,要放在body下面,否则就会导致页面渲染加载阻塞。
  3. 事件触发线程

    • 管理着事件队列
    • 监听事件,符合条件时把回调函数放入事件队列中
  4. 定时触发器线程

    • setInterval与setTimeout在此线程中计时完毕后,把回调函数放入事件队列中
    • 浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确),因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)
    • 注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms
  5. 异步http请求线程

    • 检测到XHR对象状态变化时,将回调函数放入事件队列中
    • 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行。

    了解 一下,执行线:

image-20190619165952240.png

渲染线程与JS引擎线程互斥

由于JavaScript是可操纵DOM的,如果在修改这些元素属性同时渲染界面(即JS线程和UI线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。

因此为了防止渲染出现不可预期的结果,浏览器设置GUI渲染线程与JS引擎为互斥的关系,当JS引擎执行时GUI线程会被挂起, GUI更新则会被保存在一个队列中等到JS引擎线程空闲时立即被执行。

简单例子:

<!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>Document</title>
    <script>
      // 页面上的hello world不会变成红色
      document.getElementById("app").style.color = "red";
    </script>
  </head>
  <body>
    <div id="app">
      hello world!
    </div>
  </body>
</html>

JS阻塞页面加载

从上述的互斥关系,可以推导出,JS如果执行时间过长就会阻塞页面。

譬如,假设JS引擎正在进行巨量的计算,此时就算GUI有更新,也会被保存到队列中,等待JS引擎空闲后执行。 然后,由于巨量计算,所以JS引擎很可能很久很久后才能空闲,自然会感觉到巨卡无比。

所以,要尽量避免JS执行时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。

总结一下:

  • css加载不会阻塞DOM树解析(异步加载时DOM照常构建),但会阻塞render树渲染(渲染时需等css加载完毕,因为render树需要css信息)
  • Javascript 阻塞 DOM 解析

浏览器的渲染机制

对于渲染,首先需要了解一个概念:设备刷新率。

设备刷新率是设备屏幕渲染的频率,通俗一点就是,把屏幕当作墙,设备刷新率就是多久重新粉刷一次墙面。如手机、电脑,它们的默认刷新频率都是60FPS,也就是屏幕在1s内渲染60次,约16.7ms渲染一次屏幕。

这就意味着,浏览器最佳的渲染性能就是所有的操作在一帧16.7ms内完成,能否做到一帧内完成直接决定着渲染性,影响用户交互。

渲染引擎一开始会从网络层获取请求文档的内容,内容的大小一般限制在 8000 个块以内。

然后进行如下所示的基本流程

flow.png

渲染引擎将开始解析 HTML 文档,并将各标记逐个转化成“内容树”上的 DOM 节点。同时也会解析外部 CSS 文件以及样式元素中的样式数据。HTML 中这些带有视觉指令的样式信息将用于创建另一个树结构:呈现树

呈现树包含多个带有视觉属性(如颜色和尺寸)的矩形。这些矩形的排列顺序就是它们将在屏幕上显示的顺序。

呈现树构建完毕之后,进入“布局”处理阶段,也就是为每个节点分配一个应出现在屏幕上的确切坐标。下一个阶段是绘制 - 渲染引擎会遍历呈现树,由用户界面后端层将每个节点绘制出来。

需要着重指出的是,这是一个渐进的过程。为达到更好的用户体验,渲染引擎会力求尽快将内容显示在屏幕上。它不必等到整个 HTML 文档解析完毕之后,就会开始构建呈现树和设置布局。在不断接收和处理来自网络的其余内容的同时,渲染引擎会将部分内容解析并显示出来。

CSSOM 树和 DOM 树合并成渲染树,然后用于计算每个可见元素的布局,并输出给绘制流程,将像素渲染到屏幕上。优化上述每一个步骤对实现最佳渲染性能至关重要。

具体的流程:

  • DOM 树与 CSSOM 树合并后形成渲染树。
  • 渲染树只包含渲染网页所需的节点。
  • 布局计算每个对象的精确位置和大小。
  • 最后一步是绘制,使用最终渲染树将像素渲染到屏幕上。

render-tree-construction.png

请注意 visibility: hiddendisplay: none 是不一样的。前者隐藏元素,但元素仍占据着布局空间(即将其渲染成一个空框),而后者 (display: none) 将元素从渲染树中完全移除,元素既不可见,也不是布局的组成部分。

以webkit的渲染作为示例:

webkitflow.png

可以使用调试工具来看一个真实的例子:

image-20190618182337662.png

script标签的处理

JS可以操作DOM来修改DOM结构,可以操作CSSOM来修改节点样式,这就导致了浏览器在解析HTML时,一旦碰到script,就会立即停止HTML的解析(而CSS不会),执行JS,再返还控制权。

事实上,JS执行前不仅仅是停止了HTML的解析,它还必须等待CSS的解析完成。当浏览器碰到script元素时,发现该元素前面的CSS还未解析完,就会等待CSS解析完成,再去执行JS。

JS阻塞了HTML的解析,也阻塞了其后的CSS解析,整个解析进程必须等待JS的执行完成才能够继续,这就是所谓的JS阻塞页面。一个script标签,推迟了DOM的生成、CSSOM的生成以及之后的所有渲染过程,从性能角度上讲,将script放在页面底部,也就合情合理了。

简单来说:渲染线程与JS引擎线程是互斥的,当JS引擎执行时渲染线程会被挂起(相当于被冻结了),渲染更新会被保存在一个队列中等到JS引擎空闲时立即被执行。

总结一下:

浏览器的渲染流程分为:

  • DOM树构建
  • CSSOM树构建
  • RenderObject树构建
  • 布局
  • 绘制

重排reflow与重绘repaint

重排reflow

reflow指的是重新计算页面布局。

某个节点reflow时会重新计算节点的尺寸和位置,而且还有可能触发其子节点、祖先节点和页面上的其他节点reflow。在这之后再触发一次repaint。

当render tree中的一部分(或全部)因为元素的规模尺寸,布局,隐藏等改变而需要重新构建。这就称为回流,每个页面至少需要一次回流,就是在页面第一次加载的时候。

导致reflow的操作

  • 调整窗口大小
  • 改变字体
  • 增加或者移除样式表
  • 内容变化,比如用户在input框中输入文字
  • 激活 CSS 伪类,比如 :hover (IE 中为兄弟结点伪类的激活)
  • 操作 class 属性
  • 脚本操作 DOM
  • 计算 offsetWidth 和 offsetHeight 属性
  • 设置 style 属性的值

触发页面重布局的一些css属性

  • 盒子模型相关属性会触发重布局
    • width
    • height
    • padding
    • margin
    • display
    • border-width
    • border
    • min-height
  • 定位属性及浮动也会触发重布局
    • top
    • bottom
    • left
    • right
    • position
    • float
    • clear
  • 改变节点内部文字结构也会触发重布局
    • text-align
    • overflow-y
    • font-weight
    • overflow
    • font-family
    • line-height
    • vertical-align
    • white-space
    • font-size

重绘repaint

repiant或者redraw遍历所有的节点检测各节点的可见性、颜色、轮廓等可见的样式属性,然后根据检测的结果更新页面的响应部分。

当render tree中的一些元素需要更新属性,而这些属性只是影响元素的外观,风格,而不会影响布局的,比如background-color。则就叫称为重绘

只触发重绘不触发重排的一些CSS属性:

  • color
  • border-styleborder-radius
  • visibility
  • text-decoration
  • backgroundbackground-imagebackground-positionbackground-repeatbackground-size
  • outlineoutline-coloroutline-styleoutline-width
  • box-shadow

例子:

var bstyle = document.body.style; // cache

bstyle.padding = "20px"; // reflow, repaint
bstyle.border = "10px solid red"; //  再一次的 reflow 和 repaint

bstyle.color = "blue"; // repaint
bstyle.backgroundColor = "#fad"; // repaint

bstyle.fontSize = "2em"; // reflow, repaint

// new DOM element - reflow, repaint
document.body.appendChild(document.createTextNode('dude!'));

当然,浏览器是聪明的,它不会像上面那样,每改一次样式,它就reflow或repaint一次。一般来说,浏览器会把这样的操作积攒一批,然后做一次reflow,这又叫异步reflow或增量异步reflow。但是有些情况浏览器是不会这么做的,比如:resize窗口,改变了页面默认的字体,等。对于这些操作,浏览器会马上进行reflow。

减少重绘与重排:

重绘和回流在实际开发中是很难避免的,能做的就是尽量减少这种行为的发生。

  • js尽量少访问dom节点和css 属性,尽量不要过多的频繁的去增加,修改,删除元素,因为这可能会频繁的导致页面reflow,可以先把该dom节点抽离到内存中进行复杂的操作然后再display到页面上。(虚拟DOM)

  • 减少不必要的 DOM 层级(DOM depth)。改变 DOM 树中的一级会导致所有层级的改变,上至根部,下至被改变节点的子节点。这导致大量时间耗费在执行 reflow 上面。

  • 不要通过父级来改变子元素样式,最好直接改变子元素样式,改变子元素样式尽可能不要影响父元素和兄弟元素的大小和尺寸

  • 尽量通过class来设计元素样式,切忌用style 多次操作单个属性

    // bad
    var left = 10,
    top = 10;
    el.style.left = left + "px";
    el.style.top  = top  + "px";
    
    // Good
    el.className += " theclassname";
    
    // Good
    el.style.cssText += "; left: " + left + "px; top: " + top + "px;";
    
  • 尽可能的为产生动画的 HTML 元素使用 fixedabsoluteposition ,那么修改的 CSS 是不会 Reflow 的。

  • img标签要设置高宽,以减少重绘重排

  • 把DOM离线后修改,如将一个dom脱离文档流,比如display:none ,再修改属性,这里只发生一次回流。

  • 尽量用 transform 来做形变和位移,不会造成回流

  • 权衡速度的平滑。比如实现一个动画,以1个像素为单位移动这样最平滑,但reflow就会过于频繁,CPU很快就会被完全占用。如果以3个像素为单位移动就会好很多。

  • 不要用tables布局的另一个原因就是tables中某个元素一旦触发reflow就会导致table里所有的其它元素reflow。在适合用table的场合,可以设置table-layout为auto或fixed,

  • 避免不必要的复杂的 CSS 选择器,尤其是后代选择器(descendant selectors),因为为了匹配选择器将耗费更多的 CPU。

PS: display:none会触发reflow,而visibility:hidden只会触发repaint,因为没有发现位置变化。

总结一下:

重排:元素的尺寸变了、位置变了

重绘:元素的颜色、背景、边框、轮廓变了,但是,元素的几何尺寸没有变。

Reflow的成本比Repaint的成本高得多的多。DOM Tree里的每个结点都会有reflow方法,一个结点的reflow很有可能导致子结点,甚至父点以及同级结点的reflow。在一些高性能的电脑上也许还没什么,但是如果reflow发生在手机上,那么这个过程是非常痛苦和耗电的。

页面加载缓慢的原因

browser-rendering.gif

backend-generation.gif

wpo-front-backend.png

浏览器部分

  • 网络层面

    1. 过多的HTTP请求

      打开一个网页的时候,后台程序的响应并不所需太多时间,等待的时间主要花费在下载网页元素上了,即HTML、CSS、JavaScript、Flash、图片等。据统计,每增加一个元素,网页载入的时间就会增加25-40毫秒(具体取决于用户的带宽情况)。

    2. 资源访问带宽小

      两方面,一方面是客户端的带宽,一方面是服务器端的带宽。

    3. 网页元素(图片、视频、样式)太大

  • 浏览器渲染层面

    1. 渲染阻塞:

      浏览器想要渲染一个页面就必须先构建出DOM树与CSSOM树,如果HTMLCSS文件结构非常庞大与复杂,这显然会给页面加载速度带来严重影响。

      所谓渲染阻塞资源,即是对该资源发送请求后还需要先构建对应的DOM树或CSSOM树,这种行为显然会延迟渲染操作的开始时间。

      JS阻塞与CSS阻塞:

      HTML、CSS、JavaScript都是会对渲染产生阻塞的资源,HTML是必需的(没有DOM还谈何渲染),但还可以从CSS与JavaScript着手优化,尽可能地减少阻塞的产生。

    2. 重复渲染

    3. DNS解析

  • 服务端层面

    1. 硬件配置低:这个是双向的
    2. 服务器软件,比如防火墙、内网策略等
    3. 未对Nginx这类web服务器进行配置优化
    4. CPU占满、数据库未优化
    5. 代码问题,代码效率,代码性能
    6. 包含了过多的分析类工具

代码部分

  • 构建层面

    未对代码进行打包、压缩、兼容性优化。

    未合并重复的请求、代码。

  • 编码层面

    没有良好的编码习惯,错误的编排JS与CSS

    for循环、迭代、同步、重定向、阻塞请求

    未删除重复、无用的代码

    未对逻辑业务复杂的代码进行重构,了解设计模式,对业务进行疏理

  • 机制(SSR,英文Server Side Render:服务器端渲染)

    未加入Async异步机制

    未思考页面加载、用户体验

  • 规范

    CSS规范

    HTML规范/HTML5规范

    Airbnb代码规范等。