铺垫

移动互联网时代,用户对于网页的打开速度要求越来越高。首屏作为直面用户的第一屏,其重要性不言而喻。优化用户体验更是我们前端开发非常需要 focus 的东西之一。

一个快速响应的网站可以更好地留住用户,试想一下如果你打开一个网站,过了几十秒网站仍然没有加载完成,你是否还会有耐心去等待?

那这样的话该怎么去提升我们网站的打开速度呢?首先我们要知道打开网站的过程中究竟是什么地方比较耗时,才能更好地去优化,这一节我们主要分析加载网页的过程,也就是前端经典面试题——从输入URL到页面渲染经历了什么。

问题根源

从输入URL到页面渲染经历了什么?这个问题在前端面试中已经被问“烂了”,可能还有部分小伙伴不熟悉这个问题,我们今天就来回顾一下这个问题

  1. 输入网址
  2. DNS查询
  3. 建立TCP连接
  4. 浏览器向服务器发送HTTP请求
  5. 服务器处理请求
  6. 关闭TCP连接
  7. 浏览器解析资源
  8. 页面渲染

一个网页加载的过程大致就是这些,其中需要我们前端工作者去优化的部分已经加粗标注了,下面我们来说详细或一下这几个过程

DNS查询

这个过程其实就是根据域名查询主机地址的过程,主机的标识地址都是通过IP地址来区分的,但是这个IP对于常人来说很难记忆,于是就有了域名这个东西,一般来说域名都是跟网站内容相关的方便记忆的英文单词或者单词组合,但是我们要访问主机还是通过IP来访问,这个过程浏览器会自动帮助我们完成。

这个查询的过程大致如下:

  • 首先浏览器解析输入的域名,先查找本地硬盘的host文件,看有没有和这个域名对应的ip,如果有,就直接使用这个ip。
  • 如果没有,浏览器会发出一个DNS请求到本地DNS(域名分布系统)服务器.本地DNS服务器一般都是你的网络接入服务器商提供,比如中国电信,中国移动。
  • 请求到达DNS服务器后,DNS服务器首先会查询他的缓存记录,如果有对应的ip地址,则返回,如果没有,本地DNS服务器向DNS根服务器发送查询请求。
  • 根服务器不会记录具体的域名和ip的对应关系,而是返回域服务器的地址.本地服务器会继续向域服务器发起请求。
  • 域服务器并没有记录域名和ip的对应关系,而是返回你的域名的解析服务器的地址.
  • 本地DNS服务器继续向域名解析服务器发出请求,这时会收到域名对应的ip,本地DNS服务器将ip返回给浏览器,并将ip存入缓存,方便下次访问,加快访问速度。

其实这个过程的有话不需要我们自己动手,浏览器或者运行商已经通过缓存手段帮助我们完成了优化

image-20210418163704292

HTTP请求

现在大多的网络请求都基于HTTP协议完成,他是一个基于TCP的应用层协议。

在 HTTP 请求阶段,最大的瓶颈点来源于请求阻塞。所谓请求阻塞,就是浏览器为保证访问速度,会默认对同一域下的资源保持一定的连接数,请求过多就会进行阻塞,一般是 6 个。所以减少HTTP请求或者使用内容分发网络(CDN)是优化方向。

其次,HTTP资源传输也需要消耗时间,时长和资源大小成正比,缩小资源体积也是一个优化方向。

再就是将不重要的且短时间不会改动的静态资源缓存下来也是一个方向

解析&渲染

最后一步发生在浏览器,浏览器将从服务器获取到的HTML、CSS、JS等文件进行解析然后渲染页面。

首先会并行解析HTML文档和CSS样式,分别生成产物DOM和CSSOM,然后将二者融合生成渲染树,最后绘制渲染树生成页面,这个过程中不会有JS脚本参与,只要有HTML和CSS就可以完成,但是遇到script标签就会被阻塞,因为JS可以改变HTML和CSS,因此非必要情况下不要在过程中添加script脚本,可以将script标签放到文档内容的最后或者使用 defer 和 async,告诉浏览器在等待脚本下载期间不阻止解析过程。

image-20210418170009826

页面渲染完成之后的界面布局如果发生了改变就会触发回流和重绘,回流的代价要高于重绘,当然这两个都不是善茬,尽量不要去招惹他们

本节内容我们大体讲解了可以进行优化的点,后面我们将从不同的维度去进行性能优化

网络

上一节介绍了网页加载过程中可优化的点,这一节我来说一下网络部分的优化。

网络部分包括了以下几个过程:

  • DNS查询
  • 建立TCP连接
  • 发送HTTP请求

这几点中DNS和TCP方面我们其实是无从下手的,所以我们只能咋HTTP上动刀子了。HTTP是一个往返的过程,浏览器发送请求和接收响应。

请求

在请求层面,上一节我们也说到了请求环节的优化,那就是减少HTTP请求,因为网络请求是整个过程中最不确定的因素,它受环境的影响最为直观。那么具体该从哪些方面减少,比如:

  • 每一个图片、样式……加载都会发送HTTP请求,所以可以将图片转为base64,现在前端开发一般都是使用打包工具来进行的工程化开发,打包工具一般会提供图片转base64的插件;合并小图标到css sprites (CSS精灵图)等,将小图标放置到一个文件,只需要一次加载,然后从这一张图片的位置去裁切,但是不要盲目合并导致文件过大,那样同样可能会影响体验
  • 过多的样式文件和脚本文件也会增加HTTP的压力,所以适当的将脚本和样式进行合并,但是不要盲目合并,盲目合并导致文件过大同样会影响加载速度
  • 少用location.reload()来刷新页面,每重新加载一次都会重新去请求资源

响应

在响应方面可以做的优化其实很简单,减小响应资源的体积——使用gzip压缩,启用gzip压缩也很简单,只需要在请求头中加入accept-encoding:gzipHTTP 压缩就是以缩小体积为目的,对 HTTP 内容进行重新编码的过程

gzip可以帮我们压缩大约70%体积,但是他并不是万能的,他也是有代价的,开启gzip需要浪费服务器的计算资源,服务器的 CPU 性能不是无限的,如果存在大量的压缩需求,服务器也扛不住的,这时候就会得不偿失

HTTP缓存

HTTP缓存是一个减少加载时间的最有效地方法,HTTP缓存有强缓存和协商缓存两种形式。

强缓存使用Expires 和 Cache-Control 两个字段来控制,当请求再次发出时,浏览器会根据其中的 expires 和 cache-control 判断目标资源是否“命中”强缓存,若命中则直接从缓存中获取资源,不会再与服务端发生通信。

来看一个使用了强缓存的响应,可以看到它使用了cache-control,最大有效时间31536000,换算过来借接近一年,也就是说在未来一年中都不需要再去请求这个资源

image-20210418191444199

协商缓存顾名思义就是浏览器和服务器共同协商的结果,当请求资源的时候浏览器会询问服务器资源是否过期,如果过期就重新加载资源,如果没有过期就直接使用缓存资源,协商缓存常用的字段有ETag/If-None-Match、Last-Modified/If-Modified-Since等

使用协商缓存之后HTTP状态码会变为304

image-20210418191300670

关于强缓存和协商缓存可以看我之前的这篇文章

内容分发网络CDN

现在CDN已经是一种很廉价的资源了,各大云服务商都提供了自己的CDN服务,价格也很便宜,CDN通过判断用户的地理位置来选择请求距离最近的一台服务器,来减少资源传输的距离,从而减少响应的时间

CDN的核心在于缓存和回源,CDN服务器会缓存根服务器的资源文件;回源是CDN服务器去向根服务器请求资源的过程,一般发生在资源缓存过期。

静态资源加载速度始终是前端性能的一个非常关键的指标,因为静态资源的请求量一般比较大,而且体积较大,放置到CDN上可以减轻应用服务器的压力,应用服务器就可以专心处理业务而不用分心来响应静态资源。一般放置在CDN上的都是些静态文件,比如图片、样式文件等

懒加载

懒加载可以将不重要的图片文件在需要时加载,优先将网页的整体框架加载出来,当用户需要浏览图片时再去请求。此外可以在用户等待页面加载的过程中使用骨架屏来优化用户的体验,比如掘金的文章详情页就使用了骨架屏

image-20210418191153998

存储

上一讲我们说到了HTTP缓存,这一讲的缓存并不单单指HTTP缓存

资源缓存

浏览器提供了多种缓存方式

  1. Memory Cache(内存)
  2. Service Worker Cache
  3. HTTP Cache
  4. Push Cache

Memory Cache,就是将资源缓存到内存中,等待下次访问时不需要重新下载资源,而直接从内存中获取。Webkit早已支持Memory Cache。在刷新页面时就会使用内存缓存,如图,第一次打开网页时先从磁盘缓存中读取了资源

image-20210418193354871

然后刷新页面,就会从内存中加载资源

image-20210418193331430

内存缓存是“最短命”的一种缓存,在页面关闭之后缓存就会从内存中释放

service worker 是独立于当前页面的一段运行在浏览器后台进程里的脚本。它的特性将包括推送消息,背景后台同步, geofencing(地理围栏定位),拦截和处理网络请求。它可以使你的应用先访问本地缓存资源,所以在离线状态时,在没有通过网络接收到更多的数据前,仍可以提供基本的功能(一般称之为 Offline First)。前提是必须是HTTPS协议才可以,而且必须注册以后才可用

Push Cache(推送缓存)是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂,在Chrome浏览器中只有5分钟左右,同时它也并非严格执行HTTP头中的缓存指令。

本地存储

打开开发者工具的应用程序选项就可以看到浏览器提供的本地存储方案

image-20210418194135199

现在的网页应用基本上不是原来的网页了,现在基本都是应该叫做WebApp,webApp的成型过程中,浏览器提供的存储功能提供了清理的支持

因为HTTP是无状态的请求,所以怎么保持状态是一个问题,cookie的出现就是为了解决这个问题。Cookie 说白了就是一个存储在浏览器里的一个小小的文本文件,它附着在 HTTP 请求上,在浏览器和服务器之间“飞来飞去”。它可以携带用户信息,当服务器检查 Cookie 的时候,便可以获取到客户端的状态。

但是cookie还是有缺陷的:

  • 不够大,cookie的体积上限是4kB,超过4kB将会被裁剪
  • 影响性能,cookie会被每次请求携带,无形之中增加了请求的体积,如果请求次数太多将会增加很多没有必要的传输

为了解决cookie的缺陷,web stroage出现了,web stroage有两种形式,localStroage和sessionStroage。它们二者的不同是sessionStroage生命周期是会话级,localStroage是持久化;作用域方面,localStroage只要是在同源下都可以访问,而sessionStroage只在当前窗口生效,再打开另一个窗口无法共享,通过a标签的target =“_blank”属性打开新窗口时可以共享sessionStroage

渲染

前面介绍多个前端性能优化的点,现在我们来说一下性能优化的最后一关——页面渲染

浏览器渲染的过程我们之前也稍微提过,这里再说一下

  1. 解析 HTML 文件,构建 DOM 树,同时浏览器主进程负责下载 CSS 文件
  2. CSS 文件下载完成,解析 CSS 文件成树形的数据结构,然后结合 DOM 树合并成 RenderObject 树
  3. 布局 RenderObject 树 (Layout/reflow),负责 RenderObject 树中的元素的尺寸,位置等计算
  4. 绘制 RenderObject 树 (paint),绘制页面的像素信息
  5. 浏览器主进程将默认的图层和复合图层交给 GPU 进程,GPU 进程再将各个图层合成(composite),最后显示出页面

重绘(Repaint)

当页面中元素样式的改变并不影响它在文档流中的位置时(例如:color、background-color、visibility 等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘。

回流(Reflow)

当 Render Tree 中部分或全部元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为回流。
会导致回流的操作:

  • 页面首次渲染
  • 浏览器窗口大小发生改变
  • 元素尺寸或位置发生改变元素内容变化(文字数量或图片大小等等)
  • 元素字体大小变化
  • 添加或者删除可见的 DOM 元素
  • 激活 CSS 伪类(例如:hover)
  • 查询某些属性或调用某些方法
  • 一些常用且会导致回流的属性和方法,因为这些API需要实时获取,所以会触发回流
    clientWidth、clientHeight、clientTop、clientLeftoffsetWidth、offsetHeight、offsetTop、offsetLeftscrollWidth、scrollHeight、scrollTop、scrollLeftscrollIntoView()、scrollIntoViewIfNeeded()、getComputedStyle()、getBoundingClientRect()、scrollTo()

回流比重绘的代价要更高。有时即使仅仅回流一个单一的元素,它的父元素以及任何跟随它的元素也会产生回流。现代浏览器会对频繁的回流或重绘操作进行优化:浏览器会维护一个队列,把所有引起回流和重绘的操作放入队列中,如果队列中的任务数量或者时间间隔达到一个阈值的,浏览器就会将队列清空,进行一次批处理,这样可以把多次回流和重绘变成一次。

或者我们可以通过“离线操作”的方式来操作DOM

let container = document.getElementById('container')
container.style.display = 'none'
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
...(省略了许多类似的后续操作)
container.style.display = 'block'

再有就是css选择器,css选择器每一次遍历都的过程是非常消耗性能的,应该减小那些没有必要的选择器堆叠,比如#container li{}虽然看起来搜索范围很小,从具体的id选择器中找li元素,但是实际上在选择元素时是从右向左寻找的,会先找所有的li,然后再找#container下面的,不如直接设置类选择器。

JS 引擎是独立于渲染引擎存在的,我们在第一节也说过,当HTML文档中引入script脚本时会阻塞浏览器解析DOM和CSSOM,脚本执行完成之后才会继续解析,对此我们可已使用异步defer或者async脚本

<script async src="index.js"></script>
<script defer src="index.js"></script>

async 模式下,JS 不会阻塞浏览器做任何其它的事情。它的加载是异步的,当它加载结束,JS 脚本会立即执行,defer 模式下,JS 的加载是异步的,执行是被推迟的。等整个文档解析完成、DOMContentLoaded 事件即将被触发时,被标记了 defer 的 JS 文件才会开始执行。

服务端渲染

服务端渲染是在服务端将首屏的HTML文档渲染好之后在发送给客户端,解决了客户端加载首屏过长的问题,我们现在绝大多数的webApp都是客户端渲染,HTML文档中只有一个根节点,往往是这个样子的

<body>
  <div id="app"></div>
</body>

只有一个根节点,根据后期的js运行来渲染页面内容,这就需要等待js全部加载完成才能开始渲染,这就导致了首屏时间过长,所以出现了服务端渲染的技术来解决首屏加载时间长的问题,服务端渲染并不是javaweb那种每切换一次网页都在服务端获取一次HTML,而是通过第一册请求的url将首屏的HTML文档直接填充内容,客户端在获取资源之后直接渲染就是了,之后切换路由也不需要再去请求HTML文件,只需要ajax交互就行了

具体的实现细节可以看我之前的SSR实现

服务端渲染固然能带来用户体验的提升,但是牺牲的同样是服务器的计算资源,服务端渲染本质上是本该浏览器做的事情,分担给服务器去做,当服务器顶不住压力时,这又是一波“反向优化”


前端小白