Skip to content

8.1 认识性能优化

性能优化是面向性能问题的优化手段,也就是说任何可能出现性能问题的地方,都有性能优化的用武之地。那么性能问题从何而来?我们先从项目的角度来看。

随着项目规模变大,模块的数量和体积都在不断增加,而浏览器加载和渲染资源的能力有限,如果超出了这个限度,用户会很明显地感觉到页面加载缓慢,操作不流畅。面对这个问题,大家首先会想到精简代码,优化打包配置。从另外一个角度讲,如果项目本身代码就很大,是不是也可以让加载更快一些?比如从网络、服务器方面综合优化,想法设法提升下载速度。

性能优化是多角度的,不应该只停留在“前端”层面。我们应该把目光放大至“从用户访问到看到结果”的整个流程,这首先要求我们了解浏览器的工作原理。

8.1.1 从渲染原理开始

本书第 7 章介绍了浏览器渲染引擎的工作原理,我们知道了页面如何被渲染成网页。其实换一个角度看渲染过程,这不就是我们要做性能优化的切入点吗?出现性能问题一定是在渲染过程中的某些个环节发生了异常,那么了解这个渲染过程,我们也就更接近了性能问题的根源。

渲染引擎工作原理解释了浏览器如何将拿到的资源包渲染成网页,该部分属于渲染层面的优化,环节比较多,也是前端优化的主战场。这里面包含多个步骤,其大致流程如下:

HTML 解析 -> CSS 解析 -> 渲染树构建 -> 布局 -> 绘制

在浏览器执行渲染前,还需要从网络中获取资源,资源获取的快慢也决定着网页打开的速度,这部分属于网络层面的优化。网络层面的优化范围比较广,可能会在打包环节处理,也可能在服务端处理,这部分往往会被前端开发者忽略,认为与前端无关。实际上很多构建方面的优化就是为网络服务的。

网络层面+渲染层面组成了性能优化的骨架。对于要做的性能优化的读者来说,了解这两个层面的执行流程就好比在心中绘制了一份性能优化地图。接下来所有的工作,都是围绕着这张地图逐步展开。

8.1.2 网络层面优化

从输入 URL 到页面加载完成,最先执行的一定是网络请求(本地资源除外,不在性能优化的考虑范围内)。网络请求是用户发起的第一道操作命令,网络请求堵塞,页面渲染的再快也没有意义。

网络请求是一个非常复杂的过程,当然我们不会展开细说,我们只介绍其中几个关键的可优化性能的环节。

DNS 解析

用户以输入域名的方式访问网页,然而服务器是以 IP 地址作为唯一标识,那么如何根据域名找到 IP 呢?这就是 DNS 解析的作用。DNS 映射了域名与 IP 的关系,当用户访问域名时,DNS 会查找对应的服务器 IP 并返给用户,这样浏览器就能找到目标服务器,从而获取目标服务器的资源。

DNS 有将用户请求导向某个服务端地址的能力,利用这个特性,我们可以实现分解流量的目的,从而缓解服务器压力。这其中最具有代表性的就是 CDN 加速。

CDN 表示资源分发网络,CDN 服务商将源站的资源缓存到遍布全国的高性能加速节点上。当用户访问源站的资源时,CDN 系统能够实时地根据网络流量和各节点的连接、负载状况以及到用户的距离等综合信息将用户的请求重新导向离用户最近的服务节点上,从而提高用户访问资源的响应速度。

HTTP 请求

通过 DNS 找到服务器地址之后,客户端就可以发起 HTTP 请求了。HTTP 请求由客户端发起,操作权在我们手里,因此这部分可以做的优化比较多。总的来说主要有两个方向:

  • 减少请求体积
  • 减少请求次数

减少请求体积,是指让请求发起时携带的数据更少,这样会使请求发送的更快。请求携带的数据包括请求头、请求体、Cookie 等,这些数据都可以不同程度的精简。比如 Cookie 会在每次请求时自动携带,那么就可以将不必要的 Cookie 删除;请求头也同理,常见情况是在请求头中添加 token 作为用户凭证,但有的 token 体积很大,每次携带会加重请求负担,因此要尽可能地生成短 token。

减少请求次数,目的是为了避免频繁请求带来的问题。请求是一个复杂的过程,需要 TCP 握手、数据传输、数据解析等多个环节,每一次请求都会有时间和性能上的消耗,同时也会对服务器造成压力。因此尽量用更少的请求去实现功能,比如利用浏览器缓存减少对服务器的直接请求,或者避免无效的多次请求等,这些都属于性能优化。

但也不是绝对。如果一次请求需要获取的资源太大,造成了加载阻塞,那么必然要拆分请求。一般浏览器支持同时最多下载 6 个资源,因此模块拆分粒度最好不超过 6 个,这样可保证下载速度。

HTTP 响应

服务端收到 HTTP 请求之后,就可以处理资源并发送响应了。响应阶段主要考验的是服务器的处理能力,能否尽可能快地处理用户请求并返回响应数据,是 HTTP 响应性能考量的关键。优化响应速度有以下几个方向:

(1)服务器配置不能成为访问速度的限制。服务器的配置越高,处理能力越强,自然处理资源就更快。我见过很多代码非常糟糕的网站,本地运行极其卡顿,但服务器配置拉升以后,响应速度依然能接受,这一点很重要。至少当我们遇到性能问题时,可以想到是不是服务器配置太低了,以及将“服务器升配”作为一个备选优化方案。

(2)压缩资源减少响应时间。如果服务端收到资源请求时,能够将目标资源压缩后返回给客户端,响应的时间就会大大减少。客户端收到压缩包后先解压再解析,虽然解压也会耗时,但和传输资源的耗时比起来微不足道。

(3)设置浏览器缓存。对于某些不变的资源,服务端可以在响应头里面添加字段,告诉浏览器这部分资源需要缓存,并指定有效时间。浏览器根据这些标识将资源缓存,下次请求时便可直接读取缓存,这就是我们说的强缓存和协商缓存。

(4)保证带宽足够。一个 10M 的资源在不同站点的加载速度会有好几倍的差异,其主要的原因就是带宽不同。服务器带宽决定了资源传输的最大量,如果带宽不够,即便网速再快下载速度依然受限。

8.1.3 渲染层面优化

获取到网络资源后,浏览器就要开始解析并渲染页面了。渲染层面的优化其实就是浏览器端的性能优化,可以完全按照渲染引擎的工作步骤逐步查找优化点,我们把关键渲染步骤分为下面几类:

HTML 解析

渲染流程的第一步,是浏览器解析 HTML 并将其转换为 DOM 树。解析过程中如果遇到资源链接(如:样式、脚本、图片等),浏览器会立刻发起请求并下载资源,因此当 HTML 解析完毕时,所有链接的请求会全部发送。

发送请求不重要,重要的是这些资源下载和执行时会不会阻塞 DOM 树构建。也就是说,如果资源下载时 DOM 构建暂停,等资源下载完成并解析/执行后,DOM 树才会继续构建,这就会严重拖慢页面渲染的时间。

在浏览器中,CSS 不会阻塞 DOM 树构建,但默认情况下 JavaScript 则会。这里有个非常重要的优化点:在一个 HTML 页面中,脚本文件在何处插入,对页面渲染是有影响的。具体的 JavaScript 部分我们稍后介绍。

CSS 解析

当解析 HTML 遇到样式标签时,CSS 开始加载并开始构建 CSSOM 树。CSS 是阻塞渲染的资源,当 DOM 树构建之后,必须要等到 CSSOM 树构建完毕,才会开始构建渲染树。也就是说,如果 CSSOM 树没有构建完成,就不会执行下一步的渲染,这就是所谓的“阻塞渲染”。

阻塞渲染并不是阻塞 DOM 树构建,而是阻塞渲染树构建。渲染树需要 DOM 和 CSSOM 合并生成。大多时候,DOM 不得不等待 CSSOM,因此让 CSS 尽可能早地加载有利于缩短渲染时间。

具体的优化方案其实很简单,就是将 CSS 资源尽量往前放,放到 head 标签里,这样在解析 HTML 时就能更早地解析 CSS,这就是为什么多数项目要把样式放在 head 标签下的原因。

从这个特性也可以看出,行内样式要比外部资源的样式性能更好,因为减少了资源下载的时间,CSSOM 构建得更快,渲染的也更快。

JavaScript 解析

在 HTML 文件中,script 标签可以当作一个普通标签在任意位置插入(尽管通常只写在 body 下),它的特点是在何处插入,其中间的 JavaScript 脚本就在何处执行,并会阻塞后面的构建。

JavaScript 代码不仅会阻塞 DOM 树构建,也会阻塞 CSSOM 树构建。当 HTML 解析到某处的 script 标签时,该处的 JavaScript 代码会下载并执行,这期间 HTML 和 CSS 解析都会暂停,等待 JavaScript 代码执行完毕后才会继续解析。

为什么会这样呢?本质原因是 HTML 和 CSS 由渲染引擎解析,而 JavaScript 由 JS 引擎执行,他们是两个独立的引擎。当 HTML 解析器遇到 script 标签时,浏览器会将控制权移交给 JS 引擎;当 JavaScript 代码执行完毕后,浏览器又把控制权还给渲染引擎。同一时间内只能有一个引擎工作,因此当 JavaScript 代码执行时,渲染工作就会被阻塞。

既然 JavaScript 代码会阻塞渲染,那我们该如何优化呢?其实很简单,只要把 script 标签尽可能地往后放,让渲染引擎完成大部分工作后再交予 JS 引擎,这样阻塞的影响就会大大减少,最佳实践就是:将 script 标签放在 body 标签的最后一个子元素后面。

除此之外,ES6 为 script 标签添加了新属性表示异步加载,可直接避免渲染阻塞,这样更方便。新属性分别是 async 和 defer。

(1)async 模式

在 script 标签上添加 async 属性,表示该脚本异步加载,加载之后会立即执行。

js
<script async src="app.js"></script>

(2)defer 模式

在 script 标签上添加 defer 属性,表示该脚本异步加载,加载之后会等整个文档解析完成、DOMContentLoaded 事件被触发前执行,比 async 模式更晚。

js
<script defer src="app.js"></script>

注意:async 模式和 defer 模式都不会阻塞渲染,但它们仅对单独引用的 JS 文件有效,直接写在 script 标签内的代码无效。