浏览器页面渲染

633 阅读22分钟

渲染页面: 浏览器的工作原理

在开始之前, 关键渲染路径非常有必要去了解: developer.mozilla.org/zh-CN/docs/…

概述

等待资源加载时间和大部分情况下的浏览器单线程执行是影响Web性能的两大主要原因.

  1. 尽可能快的发送请求的信息, 网络等待时间是在链路上传送二进制到客户端所消耗的链路传输时间, web性能优化需要做的是尽可能快的使页面加载完成

  2. 大部分情况下, 浏览器是单线程执行的. 确保主线程可以完成所有给它的任务并且仍然一直可以处理用户的交互.

导航(url)

导航是加载web的第一步, 用户通过在地址栏输入一个URL、点击一个链接、提交表单等行为.

web性能优化的目标之一就是缩短导航完成所花费的时间.

DNS(域名系统)

当然你得先了解什么是DNS, DNS是如何工作的

第一步: DNS缓存

  1. 查找浏览器自身的DNS缓存
  2. 没有找到条目, 浏览器会继续搜索操作系统自身的DNS缓存
  3. 如果DNS缓存也没有到, 那么会尝试读取hosts文件
  4. 如果host文件也没有找到, 浏览器就会发起DNS查询解析

第二步: DNS查询

浏览器向名称服务器发起 DNS 查询请求,得到一个 IP 地址。第一次请求之后,这个 IP 地址可能会被缓存一段时间,这样可以通过从缓存里面检索 IP 地址而不是再通过名称服务器进行查询来加速后续的请求。

通过主机名加载一个页面通常仅需要一次 DNS 查询。但是,对于页面指向的不同的主机名,则需要多次 DNS 查询。如果字体(fonts)、图像(images)、脚本(scripts)、广告(ads)和网站统计(metrics)都有不同的主机名,则需要对每一个主机名进行 DNS 查询。

DNS 查询可能存在性能问题,特别是对于移动网络。当一个用户使用了移动网络,每一个 DNS 查询必须从手机发送到基站,然后到达一个认证的 DNS 服务器。手机、信号塔、名称服务器之间的距离可能是一个大的时间等待。

第三步: TCP握手

一旦获取到服务器 IP 地址,浏览器就会通过 TCP“三次握手” (en-US)与服务器建立连接。这个机制的是用来让两端尝试进行通信——在浏览器和服务器通过上层协议 HTTPS 发送数据之前,可以协商网络 TCP 套接字连接的一些参数。

TCP 的“三次握手”技术经常被称为“SYN-SYN-ACK”——更确切的说是 SYN、SYN-ACK、ACK——因为通过 TCP 首先发送了三个消息进行协商,然后在两台电脑之间开始一个 TCP 会话。是的,这意味着终端与每台服务器之间还要来回发送三条消息,而请求尚未发出。

第四步: TLS协商(https)

为了在 HTTPS 上建立安全连接,另一种握手是必须的。更确切的说是 TLS 协商,它决定了什么密码将会被用来加密通信,验证服务器,在进行真实的数据传输之前建立安全连接。在发送真正的请求内容之前还需要三次往返服务器。

第五步: HTTP请求

一旦建立了web服务器的连接, 浏览器就通过TCP连接向远程服务器发送HTTP请求

响应

对于网站来说, 请求通常是一个html文件. 服务器收到请求后, 会使用相关的响应头和html字符串内容进行回复.

初始请求的响应包含所接收数据的第一个字节。Time to First Byte(TTFB)是用户通过点击链接进行请求与收到第一个 HTML 数据包之间的时间。第一个内容分块通常是 14KB 的数据。

TCP慢启动/14kb规则

第一个响应数据包是14kb大小, 这是慢启动的一部分, 慢启动是一种均衡网络连接速度的算法. 慢启动逐渐增加发送数据的数量直到达到网络的最大带宽.

在 TCP 慢启动 中,在收到初始包之后,服务器会将下一个数据包的大小加倍到大约 28KB。后续的数据包依次是前一个包大小的二倍直到达到预定的阈值,或者遇到拥塞。

如果您听说过初始页面加载的 14KB 规则,TCP 慢启动就是初始响应为 14KB 的原因,也是为什么 web 性能优化需要将此初始 14KB 响应作为优化重点的原因。TCP 慢启动逐渐建立适合网络能力的传输速度,以避免拥塞。

### 拥塞控制

当服务器用 TCP 数据包来发送数据时,客户端通过返回确认帧来确认传输。由于硬件和网络条件,连接的容量是有限的。如果服务器太快地发送太多的包,它们可能会被丢弃。这意味着,将不会有确认帧的返回。服务器把它们当做确认帧丢失。拥塞控制算法使用这个发送包和确认帧流来确定发送速率。

解析

一旦浏览器收到数据的第一块,它就可以开始解析收到的信息。“解析”是浏览器将通过网络接收的数据转换为 DOM 和 CSSOM 的步骤,通过渲染器把 DOM 和 CSSOM 在屏幕上绘制成页面。

即使请求页面的 HTML 大于初始的 14KB 数据包,浏览器也将开始解析并尝试根据其拥有的数据进行渲染。这就是为什么在前 14KB 中包含浏览器开始渲染页面所需的所有内容,或者至少包含页面模板(第一次渲染所需的 CSS 和 HTML)对于 web 性能优化来说是重要的。但是在渲染到屏幕上面之前,HTML、CSS、JavaScript 必须被解析完成。

构建DOM树

第一步是处理 HTML 标记并构造 DOM 树。HTML 解析涉及到 tokenization 和树的构造。HTML 标记包括开始和结束标记,以及属性名和值。如果文档格式良好,则解析它会简单而快速。解析器将标记化的输入解析到文档中,构建文档树。

DOM 树描述了文档的内容。<html> 元素是第一个标签也是文档树的根节点。树反映了不同标记之间的关系和层次结构。嵌套在其他标记中的标记是子节点。DOM 节点的数量越多,构建 DOM 树所需的时间就越长。

当解析器发现非阻塞资源,例如一张图片,浏览器会请求这些资源并且继续解析。当遇到一个 CSS 文件时,解析也可以继续进行,但是对于 <script> 标签(特别是没有 async 或者 defer 属性的)会阻塞渲染并停止 HTML 的解析。尽管浏览器的预加载扫描器加速了这个过程,但过多的脚本仍然是一个重要的瓶颈。

预加载扫描器

浏览器构建 DOM 树时,这个过程占用了主线程。当这种情况发生时,预加载扫描仪将解析可用的内容并请求高优先级资源,如 CSS、JavaScript 和 web 字体。多亏了预加载扫描器,我们不必等到解析器找到对外部资源的引用来请求它。它将在后台检索资源,以便在主 HTML 解析器到达请求的资源时,它们可能已经在运行,或者已经被下载。预加载扫描仪提供的优化减少了阻塞。

在这个例子中,当主线程在解析 HTML 和 CSS 时,预加载扫描器将找到脚本和图像,并开始下载它们。为了确保脚本不会阻塞进程,当 JavaScript 解析和执行顺序不重要时,可以添加 async 属性或 defer 属性。

构建CSSOM树

第二步是处理 CSS 并构建 CSSOM 树。CSS 对象模型和 DOM 是相似的。DOM 和 CSSOM 是两棵树。它们是独立的数据结构。浏览器将 CSS 规则转换为可以理解和使用的样式映射。浏览器遍历 CSS 中的每个规则集,根据 CSS 选择器创建具有父、子和兄弟关系的节点树。

构建 CSSOM 非常非常快,并且在当前的开发工具中没有以独特的颜色显示。相反,开发人员工具中的“重新计算样式”显示解析 CSS、构建 CSSOM 树和递归计算计算样式所需的总时间。在 web 性能优化方面,它是可轻易实现的,因为创建 CSSOM 的总时间通常小于一次 DNS 查询所需的时间

JS编译

当 CSS 被解析并创建 CSSOM 时,其他资源,包括 JavaScript 文件正在下载(借助预加载扫描器)。JavaScript 被解释、编译、解析和执行。脚本被解析为抽象语法树。一些浏览器引擎使用抽象语法树并将其传递到解释器中,输出在主线程上执行的字节码。这就是所谓的 JavaScript 编译。

构建辅助功能树(accessibility)

浏览器还构建辅助设备用于分析和解释内容的辅助功能(accessibility)树。无障碍对象模型(AOM)类似于 DOM 的语义版本。当 DOM 更新时,浏览器会更新辅助功能树。辅助技术本身无法修改无障碍树。

在构建 AOM 之前,屏幕阅读器(screen readers (en-US))无法访问内容。

渲染

渲染步骤包括样式、布局、绘制,在某些情况下还包括合成。在解析步骤中创建的 CSSOM 树和 DOM 树组合成一个 Render 树,然后用于计算每个可见元素的布局,然后将其绘制到屏幕上。在某些情况下,可以将内容提升到它们自己的层并进行合成,通过在 GPU 而不是 CPU 上绘制屏幕的一部分来提高性能,从而释放主线程。

Style

第三步是将 DOM 和 CSSOM 组合成一个 Render 树,计算样式树或渲染树从 DOM 树的根开始构建,遍历每个可见节点。

像 <head> 和它的子节点以及任何具有 display: none 样式的结点,例如 script { display: none; }(在 user agent stylesheets 可以看到这个样式)这些标签将不会显示,也就是它们不会出现在 Render 树上。具有 visibility: hidden 的节点会出现在 Render 树上,因为它们会占用空间。由于我们没有给出任何指令来覆盖用户代理的默认值,因此上面代码示例中的 script 节点将不会包含在 Render 树中。

每个可见节点都应用了其 CSSOM 规则。Render 树保存所有具有内容和计算样式的可见节点——将所有相关样式匹配到 DOM 树中的每个可见节点,并根据 CSS 级联确定每个节点的计算样式。

Layout

第四步是在渲染树上运行布局以计算每个节点的几何体。布局是确定呈现树中所有节点的宽度、高度和位置,以及确定页面上每个对象的大小和位置的过程。回流是对页面的任何部分或整个文档的任何后续大小和位置的确定。

构建渲染树后,开始布局。渲染树标识显示哪些节点(即使不可见)及其计算样式,但不标识每个节点的尺寸或位置。为了确定每个对象的确切大小和位置,浏览器从渲染树的根开始遍历它。

第一次确定节点的大小和位置称为布局。随后对节点大小和位置的重新计算称为回流。在我们的示例中,假设初始布局发生在返回图像之前。由于我们没有声明图像的大小,因此一旦知道图像大小,就会有回流。

绘制

最后一步是将各个节点绘制到屏幕上,第一次出现的节点称为 first meaningful paint (en-US)。在绘制或光栅化阶段,浏览器将在布局阶段计算的每个框转换为屏幕上的实际像素。绘画包括将元素的每个可视部分绘制到屏幕上,包括文本、颜色、边框、阴影和替换的元素(如按钮和图像)。浏览器需要非常快地完成这项工作。

为了确保平滑滚动和动画,占据主线程的所有内容,包括计算样式,以及回流和绘制,必须让浏览器在 16.67 毫秒内完成。在 2048x1536 分辨率的 iPad 上,有超过 314.5 万像素将被绘制到屏幕上。那是很多像素需要快速绘制。为了确保重绘的速度比初始绘制的速度更快,屏幕上的绘图通常被分解成数层。如果发生这种情况,则需要进行合成。

绘制可以将布局树中的元素分解为多个层。将内容提升到 GPU 上的层(而不是 CPU 上的主线程)可以提高绘制和重新绘制性能。有一些特定的属性和元素可以实例化一个层,包括 <video> 和 <canvas>,任何 CSS 属性为 opacity 、3D transformwill-change 的元素,还有一些其他元素。这些节点将与子节点一起绘制到它们自己的层上,除非子节点由于上述一个(或多个)原因需要自己的层。

分层确实可以提高性能,但是它以内存管理为代价,因此不应作为 web 性能优化策略的一部分过度使用。

Compositing

当文档的各个部分以不同的层绘制,相互重叠时,必须进行合成,以确保它们以正确的顺序绘制到屏幕上,并正确显示内容。

当页面继续加载资源时,可能会发生回流(回想一下我们迟到的示例图像),回流会触发重新绘制和重新组合。如果我们定义了图像的大小,就不需要重新绘制,只需要重新绘制需要重新绘制的层,并在必要时进行合成。但我们没有包括图像大小!从服务器获取图像后,渲染过程将返回到布局步骤并从那里重新开始。

交互

一旦主线程绘制页面完成,你会认为我们已经“准备好了”,但事实并非如此。如果加载包含 JavaScript(并且延迟到 onload 事件激发后执行),则主线程可能很忙,无法用于滚动、触摸和其他交互。

Time to Interactive (en-US)(TTI)是测量从第一个请求导致 DNS 查询和 SSL 连接到页面可交互时所用的时间——可交互是 First Contentful Paint (en-US) 之后的时间点,页面在 50ms 内响应用户的交互。如果主线程正在解析、编译和执行 JavaScript,则它不可用,因此无法及时(小于 50ms)响应用户交互

developer.mozilla.org/zh-CN/docs/…

资源合并与压缩

  1. 合并:减少HTTP请求的数量

  2. 压缩:减少请求资源的大小

    html压缩:压缩在文本文件中有意义,但是在html中不显示的字符,包括空格,制表符,换行符等, 还有压缩一些其他意义的字符,如HTML注释

     方式: 
         1. 在线网站压缩
         2. nodejs提供了html-minifier工具
    

    css压缩: 无效代码删除 css语义合并

     方式: 
         1. 使用clean-css对cssj进行压缩
         2. 使用html-minifier对html中的css进行压缩
     
    

    js压缩与混乱: 无效字符的剔除: 比如注释回车空格,比如无效的变量 剔除注释 代码语义的缩减和优化: 比如变量名称特别长sommmmesuisan, 压缩为s变量 代码保护: 代码的混乱对前端代码的保护,将代码的风格变的混乱降低可读性

     方式: 
         1. 使用uglifyjs2对jsj进行压缩
         2. 使用html-minifier对html中的js代码进行压缩
     
    

    文件合并:

     文件合并的带来的问题:
         1. 首屏渲染问题: 首屏js文件加载过长,比如vue就会出现这样的情况
         2. 缓存失效问题: a17abdh12.js 和b128hsg12h.js两个文件合并后,如果
             a17abdh12.js发生了变化,那么这两个文件合并后的js文件肯定也会变化
             。导致用户在访问网站的时候之前缓存的js文件失效。
     
     真实场景:
         1. 一般公共库会合并成一个文件,业务代码会合并为一个文件。公共库修改
             非常少,而业务代码变动的可能性非常高,所以业务代码改动不会影响
             到公共库代码的缓存的失效。
     
     方式: 
         1. 利用nodejs实现文件合并,比如webpack来进行合并
     
    

    开启gzip:

     服务端开启gzip压缩,缩小文件大小,浏览器获取到gzip文件后在解压缩
    

图片相关

    png8/png24/png32之间的区别
    
    1. png8 --- 256色 + 支持透明  + 文件小很多
    2. png24 --- 2^24色 + 不支持透明 + 文件大很多
    3. png32 --- 2^24色 + 支持透明 + 文件最大
    
    备注:每种图片格式都有自己的特点,针对不同的业务场景选择不同的图片格式非常重要
    
    不同格式图片常用的业务场景: 
    
    1. jpg有损压缩,文件体积小,不支持透明   ---大部分不需要使用透明图片的场景
    2. png支持透明,浏览器兼容性好     --- 大部分需要使用透明图片的场景
    3. webp压缩体积最小,但是兼容性存在问题   --- ios支持存在问题,安卓尝试使用
    4. svg矢量图,代码内嵌,相对较小,图片样式相对简单的场景  --- 图片样式相对简单的场景
    

图片压缩

  1. css雪碧图,整合到一张图片上,减少网站的HTTP请求数量。带来的缺点: 整合图片比较大的时候,一次加载就比较慢了。所以雪碧图也要控制大小

  2. Image inline,将图片的内容内嵌到html当中,减少网站HTTP请求数量, 也就是base64格式的图片,直接写在html中,就不会发送HTTP请求了。

  3. 使用矢量图, 使用SVG进行矢量图的绘制,使用iconfont解决icon问题。

  4. 在安卓下使用webp,图片体积非常非常小,同时具有无损和有损压缩。 在使用webp应该采用降级方式,如果支持使用webp,否则默认还是使用jpg。

html页面加载渲染的过程

浏览器解析html标签渲染成DOM树,当加载到link标签,会并发的向服务器发送css的资源请求。 当加载到script标签,会向服务器请求js资源,对css进行解析后结合DOM树生成渲染树,接着进行布局,然后绘制。

html渲染过程的特点

  1. 顺序执行, 并发加载

    • 词法分析: 浏览器对html文档解析的方式,从最开始的部分对标签进行从上到下的解析,解析过程从上到下,顺序执行!
    • 并发加载: html引入的外部资源是并发加载的
    • 并发上限: 某个域名下的请求数量是存在上限的,所以对某个域名下所请求资源的数量从而避免达到并发上限
  2. 是否阻塞

    • css阻塞
        1. head标签中阻塞页面渲染
            推荐使用link标签的方式在head中在html中引入
        2. css不阻塞外部脚本js的并发加载,但是会阻塞外部脚本js的执行
            因为js在操作DOM的时候,有可能涉及到css样式的修改,而这个css样式的修改
            是基于之前css样式的渲染。所以在css代码渲染完毕,才会进而执行js代码,
            也就是css代码阻塞了外部js的执行
    
    • js阻塞
        1. 直接引入的js阻塞页面的渲染,比如<script src='a.js'></script>
            js代码中很有可能去调用document.write方法来修改DOM文档的结构,
            所以需要等待js代码执行完毕,进而去继续执行html标签的分析,
            所以js脚本代码中不要出现document.write这样的代码
        2. js不阻塞外部资源的加载
        3. js顺序执行,会阻塞后续js逻辑的执行
            举个例子:<script src='a.js'></script>
                      <script src='b.js'></script>
            按照这样的顺序引入,会在a.js里面的代码执行完毕接着执行b.js里面的代码
            因为js的执行是单线程的,也就是b.js里面执行的代码可以依赖a.js中的代码
        
    
  3. 引入方式

  • js引入方式
    1. 直接引入
    2. defer: 确保所有的DOM树都生成
    3. async:不能确保脚本js文件之间的依赖关系,所以async引入的js脚本不能存在依赖关系
    4. 异步动态引入js: 在需要某个js文件的时候,通过生成script标签来引入

css和js文件加载过程的优化点

  1. css样式表存放在head中,确保css和html一起生成渲染树,而不至于从没有样式,跳转到有样式的情况,出现页面闪动。
  2. 用link标签代替@import这样引入css的方式,@import的方式无法并发加载,另外@import这样的方式会在DOM加载结束后才会加载
  3. js脚本文件放在body底部
  4. 合理使用js的异步加载能力,就是采用动态生成script标签,动态加载所需要的js文件

async 和 defer

  1. async 确保在所有html页面dom渲染完之后才会加载
  2. async 加载的js脚本文件,不是按照顺序加载的,所以只有几个js脚本没有依赖关系,才可以通过async的方式来加载脚本文件
  3. defer 不阻塞页面渲染,是按照顺序执行的,会按照script的顺序依次执行。在DOM渲染完成之后才会执行。

动态引入js脚本

    function loadScript(src) {
        let ele = document.createElement('script');
        ele.type = 'text/javascript';
        ele.src = src
        document.body.appendChild(ele)
    }
    loadScript('demo.js')

懒加载和预加载

懒加载

  1. 图片进入可视区域之后请求图片资源
  2. 对于电商等图片很多,页面很长的业务场景非常适用
  3. 减少无效资源的加载,因为可能用户只访问100张图片,剩下的100张是没有必要加载出来,这些资源就是属于无效资源。

预加载

  1. 图片等静态资源在使用之前提前请求图片资源
  2. 资源在使用到的时候从缓存中加载,提升用户体验

重绘和回流

回流(reflow): 当render tree中的一部分因为元素尺寸,布局,隐藏等改变而需要重新构建,称为回流。 当页面布局和几何属性改变时就会回流。

触发回流的属性

  1. 盒子模型相关属性
    width, height, padding, margin,display,border-width, border,min-height

2. 定位属性

    top, bottom, left, right, position, float, clear

3. 改变节点内部文字结构像相关属性

    text-align, overflow-y, font-weight, overflow, font-family, line-height
    vertical-align, white-space, font-size

重绘:当render tree中一些元素需要更新属性,而这些属性只是影响元素颜色风格,而不影响布局的,比如background-color,color等 称为重绘。

只会触发重绘的属性

    color, border-style, border-radius, visibility, text-decoration, background,
    background-size, background-image,background-position, background-repeat,
    outline-color, outline, outline-style, box-shadow

重绘不一定会触发回流,但是回流一定会绘制重绘

重绘回流优化方式:

  1. 避免使用触发重绘,回流的CSS属性
  2. 将重绘和回流的范围限制在单独的图层之内(暂时不理解.....)

实际工作中CSS关于回流和重绘的总结

  1. translate 替换top的变化
    translate不会触发回流,top会触发回流

  2. opacity 替换visiblity
    visiblity只会触发重绘的过程,opacity不会触发重绘的过程 // bad setTimeout(() => { document.getElementById('box').style.visiblity = 'hidden' }, 2000)

        // good
        #box {
            width: 100px;
            height: 100px;
            background: red;
            transform: translateZ(0);  // 在浏览器的层面建立一个新图层
            opacity: 1;
        }
        setTimeout(() => {
            document.getElementById('box').style.opacity = 0
        })
    
  3. 不要一条一条的地修改DOM的样式,预先定义好class,然后修改DOM的ClassName

    // bad
    var box = document.getElementId('box')
    setTimeout(() => {
        box.style.width = '200px';  
        box.style.height = '200px';
        box.style.background = 'red'
    })
    
    // good
    .box {
        width: 200px;
        height: 200px;
        background: red;
    }
    setTimeout(() => {
        box.classList.add('box');
    })

4. 把DOM离线后修改(比如,需要频繁操作DOM并将DOM的样式进行修改,那么可以先将DOM的display:none(有一次回流),然后你修改DOM样式100次,在display:block显示出来) 5. 不要使用table布局,可能很小的小改动会造成整个table的重新布局 6. 合理选择动画的速度 7. 当使用实现动画的时候,启用GPU硬件渲染加速,当然如果数据量很大,需要从cpu到gpu的过程也是非常耗费时间的,这个也是需要考量的

    .box {
        transform: translateZ(0);
        transform: translate3d(0,0,0);  // 启用GPU硬件加速
    }
```