乐趣区

前端阶段性总结(二):页面渲染机制与性能优化

引言:转前端一年了,期间工作较忙,也没时间整理一些知识体系,此系列文章是对前端基础的一些回顾与总结。本文主要介绍浏览器工作的原理以及一些优化手段。
一、浏览器渲染过程
1. 浏览器的主要结构:

2. 浏览器的多进程模型:
以 chorme 为例:

Browser 进程:浏览器的主进程,负责浏览器界面的显示,各个页面的管理,其他各种进程的管理;
Renderer 进程:页面的渲染进程,负责页面的渲染工作,Blink 的工作主要在这个进程中完成 (主要分成 render 主线程和合成器线程);
NPAPI 插件进程:每种类型的插件只会有一个进程,每个插件进程可以被多个 Render 进程共享;
GPU 进程:最多只有一个,当且仅当 GPU 硬件加速打开的时候才会被创建,主要用于对 3D 加速调用的实现;
Pepper 插件进程:同 NPAPI 插件进程,不同的是为 Pepper 插件而创建的进程

需要注意的是,NPAPI 是指浏览器对系统或外部的一些程序的调用接口,比如播放视频的 flash 插件,而 Pepper 其实是基于 NPAPI 改进的插件架构。
3. 网页请求过程:

4. 浏览器渲染过程
a. 主要流程:
主流的浏览器内核主要有 2 种,Webkit 和 Geoko,虽然 chorme 现在的内核更换为 blink,但其实 blink 是基于 webkit 的,差异不大。其渲染过程分别如下:
webkit

Geoko

这两个内核的渲染流程大同小异,主要的过程可以总结为下列 5 个:

DomTree: 解析 html 构建 DOM 树。
CssomTree : 解析 CSS 生成 CSSOM 规则树。
RenderObjectTree: 将 DOM 树与 CSSOM 规则树合并在一起生成渲染对象树。
Layout: 遍历渲染树开始布局 (layout),计算每个节点的位置大小信息。
Painting: 将渲染树每个节点绘制到屏幕。

使用 chorme 浏览器的开发者工具,我们很容易看到这 5 个过程的时间线,下面是 segmentfault 主页的渲染截图:

可以看到上述流程的耗时,甚至可以统计到每一帧的耗时分布,从而对影响渲染性能的代码精确定位。其中黄色为 JS,紫色为 Style 和 Layout,绿色为 Paint 和 Composite 部分,选中每个部分会显示各自的花费时间等信息,可以看出这个图片中 JS 运行的时间太长。目前的显示设备一般刷新率是 60FPS,所以理想中每帧的时间最好为 16 毫秒。
需要注意的一点是,这里的步骤执行并没有特定的顺序,为保证渲染的速度,浏览器一开始接收到 html 时就开始执行解析的过程,并且遇到需要重绘和重排的时候会重复执行这些步骤,下面我们详细介绍一下这 5 个过程。
b. 具体流程
DOM 树的构建:
浏览器在接收到 html 文件后即开始解析和构建 DOM 树,在碰到 js 代码段时,由于 js 代码可能会改变 dom 的结构,所以为避免重复操作,浏览器会停止 dom 树构建,先加载并解析 js 代码。而对于 css,图片,视频等资源,则交由资源加载器去加载,这个过程是异步的,并不会阻碍 dom 树的生成。这个过程需要注意的点是:

display:none 的元素、注释存在于 dom 树中
js 会阻塞 dom 树的构建从而阻塞其他资源的并发加载,因此好的做法是将 js 放在最后加载
对于可异步加载的 js 片段加上 async 或 defer

CSSOM 树的构建:
浏览器在碰到 <link> 和 <style> 标签时,会解析 css 生成 cssom,当然,link 标签需要先将 css 文件加载完成才能解析。需要注意的是:

js 代码会阻塞 cssom 的构建,在 webkit 内核中有所优化,只有 js 访问 css 才会阻塞
cssom 的构建与 dom 树的构建是并行的
减少 css 的嵌套层级和合理的定义 css 选择器可以加快解析速度,可参考如何提升 CSS 选择器性能

RenderObject 树的构建:
在 cssom 和 dom 树都构建完成后,浏览器会将他们结合,生成渲染对象树,渲染树的每一个节点,包含了可见的 dom 节点和节点的样式。需要注意的是:

renderObject 树 与 dom 树不是完全对应的,不可见的元素如 display:none 是不会放入渲染树的。

visibility: hidden 的元素在 Render Tree 中

布局:
这一步是浏览器遍历渲染对象树,并根据设备屏幕的信息,计算出节点的布局、位置,构建出渲染布局树(render layout)。渲染布局树输出的就是我们常说的盒子模型,需要注意的是:

float,absolute,fixed 的元素的位置会发生偏移
我们常说的脱离文档流,其实就是脱离布局树

绘制:
浏览器对生成的布局树进行绘制,由用户界面后端层将每个节点绘制出来。此时,Webkit 内核还需要将渲染结果从 Renderer 进程传递到 Browser 进程。
4. 重绘和回流
前面讲到,js 代码可以访问和修改 dom 节点和 css,所以在解析 js 的过程中会导致页面重新布局和渲染,这就是重绘(repaint)和回流 (reflow)。
a. 重绘:
概念:
重绘是指 css 样式的改变,但元素的大小和尺寸不变,而导致节点的重新绘制。
重绘的触发:
任何对元素样式,如 background-color、border-color、visibility 等属性的改变。css 和 js 都可能引起重绘。
b. 回流
概念
回流(reflow)是指元素的大小、位置发生了改变,而导致了布局的变化,从而导致了布局树的重新构建和渲染。
回流的触发

dom 元素的位置和尺寸大小的变化
dom 元素的增加和删除
伪类的激活
窗口大小的变化
增加和删除 class 样式
动态计算修改 css 样式

当然,我们的浏览器不会每一次 reflow 都立刻执行,而是会积攒一批,这个过程也被成为异步 reflow,或者增量异步 reflow。但是有些情况浏览器是不会这么做的,比如:resize 窗口,改变了页面默认的字体,等。对于这些操作,浏览器会马上进行 reflow。
二、页面性能分析与测速
优化并不是无目的的,而是通过分析页面各个维度,找到亟待优化的方向或者具体到某段代码。下面就讨论一下如何对页面做性能分析和测速监控。
1. 性能分析
Chorme Devtools
chorme 得 devtools 相信所有的前端开发者都用过,它不仅提供了日常开发中极强的调试能力,同时也具备着极强的页面分析能力。
全新 Chrome Devtool Performance 使用指南
第三方分析网站
google PageSpeed Insights
2. 测速上报
测速的关键指标
一般来说,我们打开一个页面,期望的是页面的响应和呈现速度和流畅的交互体验。所以,页面的测速指标可以大致概括为:白屏时间,首屏时间,可交互时间。

如何计算
window.performance 是 w3c 提供的用来测量网页和 Web 应用程序的性能 api。其中 performance timing 提供了延时相关的性能信息,可以高精度测量网站性能。timing 的整体结构如下图所示:

白屏时间 = 页面开始展示的时间点 (PerformanceTiming.domLoading)- 开始请求时间点 (PerformanceTiming.navigationStart)
首屏时间 = 首屏内容渲染结束时间点 (视业务具体情况而定)- 开始请求时间点 (PerformanceTiming.navigationStart)
可交互时间 = 用户可以正常进行事件输入时间点(PerformanceTIming.domInteractive)- 开始请求时间点 (PerformanceTiming.navigationStart)

三、性能优化
关于性能优化,涉及的方向太广了,从网络请求到数据库,整条链路都有其可优化的地方。这里我只总结一下前端比较需要关注的一些优化点。这里从两个个维度进行讨论:
(一). 网络请求的优化
从上文可知,浏览器渲染网页的前提是下载相关的资源,html 文档、css 文档、图片资源等。这些资源是客户端基于 HTTP 协议,通过网络请求从服务器端请求下载的,大家都知道,有网络,必定有延迟,而资源加载的网络延迟,是页面缓慢的一个重要因素。所以,如何使资源更快、更合理的加载,是性能优化的必修课。
1. 静态资源
1)拼接、合并、压缩、制作雪碧图:
由于 HTTP 的限制,在建立一个 tcp 请求时需要一些耗时,所以,我们对资源进行合并、压缩,其目的是减少 http 请求数和减小包体积,加快传输速度。

拼接、合并、压缩:在现代的前端工程化开发流程中,相信大家都有使用 webpack 或者 gulp 等打包工具对资源(js、css、图片等)进行打包、合并、去重、压缩。在这基础上,我们需要根据自身的业务,合理的对公共代码,公共库,和首屏代码进行单独的打包压缩,按需加载;
雪碧图:对于图片资源,我们可以制作雪碧图,即对一些页面上的 icon 和小图标,集成到一张图片上,css 使用背景图定位来使用不同的 icon,这样做可以有效的减少图片的请求数,降低网络延迟。而它的缺点也很明显,由于集成在同一张图片上,使用其中的一个图标,就需要将整张图片下载下来,所以,雪碧图不能盲目的使用。

segmentfault.com 的雪碧图图标
2)CDN 资源分发:
将一些静态资源文件托管在第三方 CDN 服务中,一方面可以减少服务器的压力,另一方面,CDN 的优势在于,CDN 系统能够实时地根据网络流量和各节点的连接、负载状况以及到用户的距离和响应时间等综合信息将用户的请求重新导向离用户最近的服务节点上,保证资源的加载速度和稳定性。
3)缓存:
缓存的范围很广,比如协议层的 DNS 解析缓存、代理服务器缓存,到客户端的浏览器本地缓存,再到服务端的缓存。一个网络链路的每个环节都有被缓存的空间。缓存的目的是简化资源的请求路径,比如某些静态资源在客户端已经缓存了,再次请求这个资源,只需要使用本地的缓存,而无需走网络请求去服务端获取。

segmentfault 的主页的一些静态资源使用了缓存,上面是一些控制缓存的 header 首部字段
4)分片:
分片指得是将资源分布到不同的主机,这是为了突破浏览器对同一台主机建立 tcp 连接的数量限制,一般为 6~8 个。现代网站的资源数量有 50~100 个很常见,所以将资源分布到不同的主机上,可以建立更多的 tcp 请求,降低请求耗时,从而提升网页速度。

从 segmentfault 的主页请求可以看出,网站将静态 js 文件和图片都放在了不同的子域名下。
5)升级协议:
可以升级我们的网络协议,比如使用 HTTP2,quic 之类的,代替之前的 http1.1,从协议层优化资源的加载。可以参考我之前的文章。
2. 业务数据
虽然做好了静态数据的加载优化,但是还是会出现一种情景,即静态数据已经加载完毕,但页面还是在转菊花,页面还没有进入可交互状态,这是因为现如今的网站开发模式,前后端分离已经成为主流,不再由 php 或 jsp 服务端渲染前端页面,而是前端先加载静态数据,再通过 ajax 异步获取服务器的数据,进而重新渲染页面。这就导致了异步从接口获取数据也是网页的一个性能瓶颈。响应缓慢,不稳定的接口,会导致用户交互体验极差,页面渲染速度也不理想。比如点击一个提交数据的按钮,接口速度慢,页面上菊花需要转好久才能交换完数据。
1)首屏直出
为了提升用户体验,我们认为首屏的渲染速度是极为重要的,用户进来页面,首页可见区域的加载可以由服务端渲染,保证了首屏加载速度,而不可见的部分则可以异步加载,甚至做到子路由页面的预加载。业界已经有很多同构直出的方案,比如 vue 的 nuxt,react 的 beidou 等。
2)接口合并
前端经常有这样的场景,完成一个功能需要先请求第一个接口获得数据,然后再根据数据请求第二个接口获取第二个数据,然后第三、第四 … 前端通常需要通过 promise 或者回调,一层一层的 then 下去,这样显然是很消耗性能的
通常后台接口都按一定的粒度存在的,不可能一个接口满足所有的场景。这是不可避免的,那么如何做到只发送一个请求就能实现功能呢?有一种不错的方案是,代理服务器实现请求合并,即后台的接口只需要保证健壮和分布式,而由 nodejs(当然也可以使用其他语言)建设一层代理中间层,流程如下图所示:

前端只需要按找约定的规则,向代理服务器发起一次请求,由代理服务器向接口服务器发起三次请求,再将目标数据返回给客户端。这样做的好处是:一方面是代理服务器代替前端做了接口合并,减少了前端的请求数量;另一方面代理服务器可以脱离 HTTP 的限制,使用更高效的通信协议与服务器通信;
(二). 页面渲染性能的优化
1. 防止阻塞渲染
页面中的 css 和 js 会阻塞 html 的解析,因为他们会影响 dom 树和 render 树。为了避免阻塞,我们可以做这些优化:

css 放在首部,提前加载,这样做的原因是:通常情况下 CSS 被认为是阻塞渲染的资源,在 CSSOM 构建完成之前,页面不会被渲染,放在顶部让样式表能够尽早开始加载。但如果把引入样式表的 link 放在文档底部,页面虽然能立刻呈现出来,但是页面加载出来的时候会是没有样式的,是混乱的。当后来样式表加载进来后,页面会立即进行重绘,这也就是通常所说的闪烁了。
js 文件放在底部,防止阻塞解析
一些不改变 dom 和 css 的 js 使用 defer 和 async 属性告诉浏览器可以异步加载,不阻塞解析

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

js 尽量少访问 dom 节点和 css 属性
尽可能的为产生动画的 HTML 元素使用 fixed 或 absolute 的 position,那么修改他们的 CSS 是不会 Reflow 的。
img 标签要设置高宽,以减少重绘重排
把 DOM 离线后修改,如将一个 dom 脱离文档流,比如 display:none,再修改属性,这里只发生一次回流。
尽量用 transform 来做形变和位移,不会造成回流

3. 提高代码质量
这最能体现一个前端工程师的水平了,高性能的代码能在实现功能的同时,还兼顾性能。下面是一些好的实践:
1)html:

dom 的层级尽量不要太深,否则会增加 dom 树构建的时间,js 访问深层的 dom 也会造成更大的负担。
meta 标签里需要定义文档的编码,便于浏览器解析

2)css:

减少 CSS 嵌套层级和选择适当的选择器,可参考如何提高 css 选择器性能

对于首屏的关键 css 可以使用 style 标签内联。可参考什么是关键 css

3)js:

减少通过 JavaScript 代码修改元素样式,尽量使用修改 class 名方式操作样式或动画
访问 dom 节点时需要对 dom 节点转存,防止循环中重复访问 dom 节点造成性能损耗。
慎用 定时器 和 计时器,使用完后需要销毁。
用于复杂计算的 js 代码可以放在 worker 进程中运行
对于一些高频的回调需要对其节流和消抖,就是 debounce 和 throttle 这两个函数。比如 scroll 和 touch 事件


优化没有正确答案,优化的手段也层出不穷,这里也无法概括全面,只列举了一些我了解过的。其实除了前端,后端也有许多可优化的地方,比如接口缓存啊,数据库缓存啊等等。这个本骚年就了解的不深了。
四、思考与总结
性能一直是前端开发很重要的一个课题。性能优化也是一条不见尽头的路,任重而道远啊~
参考文章:https://segmentfault.com/a/11…https://sylvanassun.github.io…https://www.html5rocks.com/zh…https://juejin.im/post/5a966b…https://juejin.im/post/59672f…https://tech.meituan.com/perf…https://segmentfault.com/a/11…https://www.jianshu.com/p/268…

退出移动版