从web浏览器的渲染到性能优化

来源:互联网 发布:大逃杀网络延迟检测 编辑:程序博客网 时间:2024/05/29 09:12

本文主要讲谈及web浏览器的渲染原理、流程以及相关的性能问题

最近在复习时遇到一个问题,关于async和defer,发现自己还能记住一点,然而再往深一想,浏览器的渲染顺序?怎么防止阻塞DOM渲染?如何保证首屏优化、关键渲染路径优化?如何从浏览器渲染、网络请求、js引擎机制优化性能?好像找不到让自己满意的答案,所以查阅资料写个博客总结一下(本文主要基于当前(2017-7)和chrome浏览器来讲的)。

一.缘起async defer

async和defer是script标签的属性,其有先决条件必须src属性存在才能生效,这有两个最基本的讨论点

  1. 下载是否会阻塞DOM渲染
  2. 执行是否会阻塞DOM渲染

async是赋予脚本异步属性,其特性如下:

  1. 不等待其他脚本 js一旦下载好了就会执行
  2. 与文档同时呈现 不能按序进行

defer是赋予脚本延迟属性,其特性如下:

  1. 在文档完全呈现后执行 不会阻塞页面的渲染和资源的加载
  2. 自身按序进行 如果前后有依赖关系的js可以放心使用

扒一下html规范里面的图,简单说一下规范的内容,首先有两种情况的script,一种是经典script(classic script),可以理解为我们最常用的、普通的script标签;另一种是模块script(module script),是一种支持度不高的规范,其代码会被当作JavaScript模块处理,不太清楚的读者可以点击链接详细看一下,这里不展开了。

async defer执行详情图

先讲一下规范里经典script的处理方法,总共三种情况:

  1. 不使用async和defer,根据所在位置阻塞解析,被下载紧接着执行直到完成。

  2. 使用async,不阻塞解析并行下载脚本,当下载完成后阻塞解析立即执行,在解析完成前后都可能被执行。

  3. 使用defer,并行下载,并在页面完成解析时进行执行,不会阻塞解析

另外还补充一点,如果浏览器不支持async,那么浏览器会将其作为defer处理,也算是优雅降级,至少不会出现你把async放在head失效直接阻塞后面body的情况。

简单总结一下,两者有一些共通点

  1. 二者下载都不会阻塞DOM解析,这点还是很重要的,首屏时间就是一切

  2. 都是给外链脚本使用,不考虑请求数量而使用内联情况下等因素的首选方式

  3. 都可以使用onload事件进行一系列处理

二者最主要的不同点就是async其实只注重异步下载时不阻塞html解析,下载完毕后会立刻执行,而defer会完全保证异步下载和执行都不会阻塞html解析,这点在不同场景使用还是比较重要的。

比如async就适合基本没DOM操作,不在乎依赖的模块,而且要尽可能小或者说执行时间短,否则对首屏可能还是影响很大的;defer相对而言就规矩的多了,按序加载,不会影响html解析。

基本async和defer就是这样了,不得不感叹一下,在规范里就几行文字,很多相关解析文章都讲错讲少了,走了不少弯路,以后还是认准文档大法。


二.浏览器关键渲染路径

上面我们知道了async的script执行可能会阻塞解析,可是css呢?另外其他的资源如何下载?会不会阻塞解析?我们说的解析,DOM parse到底是什么?另外domContentLoaded和load分别是基于那个节点触发?这些问题我们可能要深思一下。

浏览器渲染原理

上图展示了浏览器渲染的过程,我们可以先忽略JavaScript的部分,然后剩下的就是一条直线了。下面从头开始说起

1. 文档对象模型 (DOM)

DOM构建过程

上图展示了从html的字节码被浏览器处理为DOM的过程

  1. 转换:根据字节的编码规则将其转化为特定字符,也就是characters

  2. 生成tokens:将character转化为w3c定义的各种特定标签 ,生成tokens(令牌)

  3. 词法解析:匹配字符串,将tokens按照规则转换为包含特定属性和规则的节点对象(nodes)

  4. DOM构建:根据每个节点的层次关系和规则转换为直观的树形结构,具有明确的父子关系。

值得一提的是,HTML都是增量构建的,在HTML文件还在传输时html parse就可以开始了。

最终我们的到了页面完整的文档对象模型(DOM),在以后的页面渲染包括布局、绘制等都会用到它。它代表了页面的结构,决定了整个页面的初始格局,而下面的CSS对象模型(CSSOM)决定了页面的五彩斑斓。

2.CSS 对象模型 (CSSOM)

CSSOM构建过程

老规矩先放图,上图是CSSOM构建流程图,跟DOM构建差不多的套路,将CSS文件的字节码转换为符合浏览器特定规则的字符,然后浏览器对其进行解析和构成树。

与DOM有所不同的是,其整个的计算过程略有复杂,包括一套复杂的特异度计算规则(CSS属性来源 -> 特异度大小 -> 书写顺序前后覆盖),最终确定每个节点的样式值形成下图的不完整CSSOM。

CSS一直被认为是一种渲染阻塞资源(所谓CSS白屏),因为渲染树是依赖CSSOM才能生成,进而走浏览器的布局渲染流程,所以我们才有了CSS放在head的最佳实践。

CSSOM

3.渲染树(render-tree)

render-tree construction
渲染树生成大概经过以下过程:

  1. 从DOM根节点根节点开始遍历每个在HTML和CSS意义上的可见节点。
  2. 对于每个可见节点,为其找到适配的CSSOM并且组合他们
  3. 将每个节点(包括内容和样式)组建成render-tree

可见节点:渲染树包含了渲染网页所需的所有节点,不需要渲染的节点是不会合并到渲染树中的,比如元数据元素meta,base等,还有设置了display:none的节点。

4.布局(layout)–计算渲染树节点大小

布局的最终结果是一个“盒模型”,它需要精确的计算出每个元素所占据的位置坐标,将如rem、vw、em等相对测量值(计算值)转换为屏幕上的绝对像素。

将相对转换为绝对,这就需要首先明确或定义好相对的一个标准,是相对谁的相对值,如rem是相对根元素的font-size值,vw是相对视口的width等。

简单介绍涉及到的viewport和html的font-size值

  1. device-width为浏览器的理想视口
    在移动端,如果不设置viewport宽度为理想视口,viewport宽度通常为980px,这会导致文字很小,我们需要手动放大阅读。

  2. rem是 font size of the root element,简单一点可以设置html的字体大小为固定值(一般默认为16px),则width直接使用5rem(换算为80px),也可以使用js根据viewport大小动态设置rem大小。

同时也要注意,我们经常会在js或者是一些media query的设置不同断电,总之如果CSS元素的位置或大小等影响布局的因素发生变化,这是可能会触发回流,进行重新布局和渲染,这是我们在开发过程中要尽量避免和减小性能损耗的。

5.绘制(paint)

根据background, border, box-shadow等样式和HTML内容,将Layout生成的区域填充为最终将显示在屏幕上的像素。

整个大概的流程就是这样,下面用个谷歌开发者的小实例把添加事件响应流程讲一下:

<html>  <head>    <meta name="viewport" content="width=device-width,initial-scale=1">    <title>Critical Path: No Style</title>  </head>  <body>    <p>Hello <span>web performance</span> students!</p>    <div><img src="awesome-photo.jpg"></div>  </body></html>

waterfall

主要就两个资源–HTML和jpg

  1. 蓝色条有两部分,分别代表HTML的下载(分为HTTP链接建立和HTTP传输资源两段)和DOM parse的时间

  2. 紫色竖线表示domContentLoaded事件触发

  3. 紫色条代表图片下载和图片渲染

  4. 红色竖线则代表onload事件触发。

仔细注意一下,DOMContentLoaded是发生在img请求之后一点,而综合前面的内容,我们知道DOM是增量构建的,DOMContentLoaded实际上是DOM树构建完成的时候,具体说是DOM解析完</html> 那一刻。load事件则是在所有资源都加载渲染后才能触发。


三.关键渲染路径优化

优化关键渲染路径是指优先显示与当前用户操作有关的内容。

当前快速的网络体验,无数先贤在无数次尝试中不断改进才有了现代浏览器比较流畅的体验,这其中硬件软件支持不计其数,对web开发者来说,可以将浏览器看做一个黑盒,根据浏览器开发人员的API文档即可创建完整的应用程序,但追求极致的用户体验需要充分了解浏览器的运行机制和加速利器。

1.CSS阻塞渲染

CSSOM形成前,浏览器不会渲染任何已处理内容,所以CSS被视为阻塞渲染的资源。(主要指chrome浏览器,因为标准没有特别提及渲染顺序问题,所以各大浏览器实现有所差异,这个后面会说)

我们要解决CSS阻塞的问题有几个维度:

  1. 网速;
  2. 大小;
  3. 尽早并行下载;
  4. 尽早开始构建CSSOM;
  5. 构建CSSOM的速度

所以有如下几点优化:

  1. 媒体查询
    进入移动互联网时代有一段时间了,前端的使用场景可谓是处处开花,CSS的使用场景也越来越多样化,所以适配多端的代码必不可少,虽然媒体查询也下载全部CSS代码,但是只会解析符合媒体查询条件的代码,这就做到了尽量少的阻塞渲染。

  2. preload

    <link rel="preload" href="index_print.css" as="style" onload="this.rel='stylesheet'">

    preload是resoure hint规范中定义的一个功能,顾名思义预加载,将rel改为preload后,相当于加了一个标志位,浏览器解析的时候会提前建立连接或加载资源,做到尽早并行下载,然后在onload事件响应后将link的rel属性改为stylesheet即可进行解析。

  3. 动态添加link

    var style = document.createElement('link');style.rel = 'stylesheet';style.href = 'index.css';document.head.appendChild(style);

    js动态添加DOM元素link,不会阻塞渲染。
    loadCSS.js,CSS preload polyfill第三方库,原理同上

  4. 代码简练,不使用CSS计算,避免使用通配、高级选择器,不详讲了,有兴趣可以自己去找相关文章,深入的话可以多研究规范。

另外还有四点注意事项

  1. 将CSS放在head

    将CSS放在head,不管内联还是外联都尽早开始下载或者构建CSSOM(前提是这个CSS是首屏必须的)
  2. 避免使用CSS import

    在CSS中可以用import将另一个样式表引入,不过这样显然在构建CSSOM时会增加一次网络来回时间。
  3. 适度内联CSS,衡量其他因素,若外联网络来回影响多大,HTML大小,CSS大小

  4. 全面考虑渲染情况,网速差、问价下载失败等,防止白屏时间太长。

  5. 讨论一下IE chrome firefox三者的差异

1. IE 只要看到HTML 标签就会进行绘制2. chrome 不管css放在前面还是后面,都要等到CSSOM构建形成后才会绘制到页面上3. firefox 放在head则会阻塞绘制,放在body末尾会先绘制前面的标签

2.JavaScript阻塞渲染

长久以来script标签的最佳实践是放在</body>的前面,全是JavaScript功能太强大的锅 ~_~ 逃:)

JavaScript赋予我们操作页面交互、界面呈现、请求资源等一系列权限,几乎能让我们对网页的每一个行为进行操作,而且随着硬件设备的强大、网络的进一步提升和用户的需求不断提高,未来web的权限、功能、可操作性、交互、体验会越来越强。

扯远了,由于js可能会操作DOM和CSSOM,为了减少不必要的冲突和低效,浏览器都会做最坏的打算(js会操作DOM和CSSOM),所以正常情况下脚本执行会阻塞DOM构建,等待CSSOM的构建完毕再执行。

  1. 在HTML解析器解析到script标签后,会停止DOM构建,将控制权移交给javascript引擎,当js执行完毕后,浏览器会继续DOM构建。

  2. javascript可能会操作CSSOM,所以如果浏览器未先将CSSOM构建完毕,那么js将会暂停执行,同时DOM构建也会暂停

小结一下:

  • 脚本在文档中的位置很重要,因为其跟另外两者有很强的依赖关系
  • 在HTML解析器解析到script标签后,会停止DOM构建
  • javascript可以操作DOM和CSSOM,但进行这些行为时要确保相应DOM和CSSOM已经存在
  • JavaScript 执行将暂停,直至 CSSOM 就绪

所以总体来说,javascript在DOM、CSSOM和javascript之间加入了大量依赖关系,根本目的是为了有序高效渲染页面。若我们想要达到此根本目的,则需要明确依赖关系,其实可以类比模块化的思想处理这些关系。

优化方法也就是处理依赖关系的方法:

  1. 脚本放在body底部
  2. defer(详情见第一部分)
  3. async(详情见第一部分)
  4. 避免运行长时间的JavaScript,若初始化必须则考虑适当分割或ssr等措施

“优化关键渲染路径”在很大程度上是指了解和优化 HTML、CSS 和 JavaScript 之间的依赖关系谱。

3.font阻塞渲染

浏览器为了避免FOUT(Flash Of Unstyled Text),会尽量等待字体加载完成后,再显示应用了该字体的内容。

只有当字体超过一段时间仍未加载成功时,浏览器才会降级使用系统字体。每个浏览器都规定了自己的超时时间。

但这也带来了FOIT(Flash Of Invisible Text)问题。内容无法尽快地被展示,导致空白。

可以参考异步加载字体库 Webfontloader.js

资源的相互依赖是比较复杂的,首先要明确资源加载、解析的依赖关系,然后利用异步等方式将非关键资源阻塞,优化关键路径资源。

4.关键资源路径

三大指标目标:

  1. 关键资源大小 — 优化请求时间 解析渲染时间
  2. 网络请求来回数目 — 优化请求时间
  3. 关键资源数目 — 优化解析渲染时间

prefetch 利用缓存

减少资源大小:

  1. 避免返回无用内容
  2. 针对特定语言的源码压缩
  3. 通用文本压缩
  4. 图片压缩

减少请求来回时间:

  1. 服务器优化:

    • chunked encoding
    • 尽早返回数据
    • 服务端渲染
  2. 合理利用缓存

    • CacheControl
    • ETag
    • localstorage
    • service worker
  3. 优化网络

    • HTTP 2
    • CDN
    • 域名分割
    • 减少重定向
    • resource-hint

总结一下关键渲染路径优化的一般步骤:

  1. 分析关键渲染路径中的资源大小、来回、渲染顺序

  2. 最大限度删减关键资源数目,也就是尽量只渲染首屏必须资源,其他的异步或延迟(async defer ssr ajax)

  3. 合并请求数目,减少请求往返次数,减少资源字节数(内联js、 css)

  4. 优化加载渲染顺序,最大化利用浏览器渲染引擎和js引擎。(调整资源DOM顺序)


四.性能优化

这个话题也是老生长谈了,列出几点自己的积累:

代码层面:避免使用css表达式,避免使用高级选择器,通配选择器等。
缓存利用:缓存Ajax,使用CDN,使用外部js和css文件以便缓存,添加Expires头,服务端配置Etag,减少DNS查找等。
请求数量:合并样式和脚本,使用css图片精灵,初始首屏之外的图片资源按需加载,静态资源延迟加载。
请求带宽:压缩文件,开启GZIP,

代码层面的优化

  • 用hash-table来优化查找
  • 少用全局变量
  • 用innerHTML代替DOM操作,减少DOM操作次数,优化javascript性能
  • 用setTimeout来避免页面失去响应
  • 缓存DOM节点查找的结果
  • 避免使用CSS Expression
  • 避免全局查询
  • 避免使用with(with会创建自己的作用域,会增加作用域链长度)
  • 多个变量声明合并
  • 避免图片和iFrame等的空Src。空Src会重新加载当前页面,影响速度和效率
  • 尽量避免写在HTML标签中写Style属性

移动端性能优化

  • 尽量使用css3动画,开启硬件加速。
  • 适当使用touch事件代替click事件。
  • 避免使用css3渐变阴影效果。
  • 可以用transform: translateZ(0)来开启硬件加速。
  • 不滥用Float。Float在渲染时计算量比较大,尽量减少使用
  • 不滥用Web字体。Web字体需要下载,解析,重绘当前页面,尽量减少使用。
  • 合理使用requestAnimationFrame动画代替setTimeout
  • CSS中的属性(CSS3 transitions、CSS3 3D transforms、Opacity、Canvas、WebGL、Video)会触发GPU渲染,请合理使用。过渡使用会引发手机过耗电增加
  • PC端的在移动端同样适用

什么是Etag?

当发送一个服务器请求时,浏览器首先会进行缓存过期判断。浏览器根据缓存过期时间判断缓存文件是否过期。
情景一:若没有过期,则不向服务器发送请求,直接使用缓存中的结果,此时我们在浏览器控制台中可以看到 200 OK(from cache) ,此时的情况就是完全使用缓存,浏览器和服务器没有任何交互的。
情景二:若已过期,则向服务器发送请求,此时请求中会带上①中设置的文件修改时间,和Etag
然后,进行资源更新判断。服务器根据浏览器传过来的文件修改时间,判断自浏览器上一次请求之后,文件是不是没有被修改过;根据Etag,判断文件内容自上一次请求之后,有没有发生变化
情形一:若两种判断的结论都是文件没有被修改过,则服务器就不给浏览器发index.html的内容了,直接告诉它,文件没有被修改过,你用你那边的缓存吧—— 304 Not Modified,此时浏览器就会从本地缓存中获取index.html的内容。此时的情况叫协议缓存,浏览器和服务器之间有一次请求交互。
情形二:若修改时间和文件内容判断有任意一个没有通过,则服务器会受理此次请求,之后的操作同①
① 只有get请求会被缓存,post请求不会

ETag应用:

Etag由服务器端生成,客户端通过If-Match或者说If-None-Match这个条件判断请求来验证资源是否修改。常见的是使用If-None-Match。请求一个文件的流程可能如下:
====第一次请求===
1. 客户端发起 HTTP GET 请求一个文件;
2. 服务器处理请求,返回文件内容和一堆Header,当然包括Etag(例如”2e681a-6-5d044840”)(假设服务器支持Etag生成和已经开启了Etag).状态码200
====第二次请求===
客户端发起 HTTP GET 请求一个文件,注意这个时候客户端同时发送一个If-None-Match头,这个头的内容就是第一次请求时服务器返回的Etag:2e681a-6-5d0448402.服务器判断发送过来的Etag和计算出来的Etag匹配,因此If-None-Match为False,不返回200,返回304,客户端继续使用本地缓存;流程很简单,问题是,如果服务器又设置了Cache-Control:max-age和Expires呢,怎么办

答案是同时使用,也就是说在完全匹配If-Modified-Since和If-None-Match即检查完修改时间和Etag之后,服务器才能返回304.(不要陷入到底使用谁的问题怪圈)
为什么使用Etag请求头?
Etag 主要为了解决 Last-Modified 无法解决的一些问题。

Expires和Cache-Control

Expires要求客户端和服务端的时钟严格同步。HTTP1.1引入Cache-Control来克服Expires头的限制。如果max-age和Expires同时出现,则max-age有更高的优先级。

    Cache-Control: no-cache, private, max-age=0    ETag: abcde    Expires: Thu, 15 Apr 2014 20:00:00 GMT    Pragma: private    Last-Modified: $now // RFC1123 format

五.感想

其实整个浏览器渲染过程是相对有序的,根据整体对浏览器渲染的感觉,个人总结了三点体会:

  1. 总体遵循着从下往上的大原则,细节上尽量并行下载,不阻塞主线程。具体来说,其实整个DOM是主线,所以遵循尽量快、并行解析DOM,尽快触发load事件,只要并行的两者不会相互影响就好,如DOM和CSSOM

  2. 在尽量不进行重复操作的前提下,尽量优先渲染关键路径,同时尽量加载非关键路径资源,尽量保证不被网速或者说未下载资源阻塞渲染。

  3. 浏览器或者说厂商的最终目跟开发者一样,都是为了用户体验,总体来说浏览器实现甚至包括规范都是遵循用户>开发者的原则。

参考:

  1. 谷歌开发者文档 web基础 关键渲染路径部分
  2. 瓜瓜老师的 浏览器关键渲染路径 ppt
  3. alloyteam的 JS、CSS以及img对DOMContentLoaded事件的影响
原创粉丝点击