从一道面试题,构建性能优化知识体系【网络篇】

网站性能优化贯穿在开发的各个阶段,涉及的知识点也是比较分散的,怎样把这些分散的知识整理、连贯起来,在脑海中有一个图谱,在实战的时候,根据图谱上的优化点各个击破,这就需要构建自己的知识体系。

一道面试题

一道很经典的面试题: 从浏览器地址栏输入URL后,到页面渲染出来,整个过程都发生了什么?

简单来说,主要有以下几个过程:

1. 查找是否有缓存资源。如果有,那么直接将资源返回给浏览器渲染进程(第5步),否则进入网络请求流程。
2. DNS解析,以获取请求域名的服务器IP地址。
3. 根据IP地址和服务器建立TCP连接。
4. 发送HTTP请求,服务端处理请求,HTTP响应返回。
5. 浏览器拿到响应数据,解析响应内容,进行渲染。
复制代码

image.png
下面我们将对以上各阶段的优化点进行介绍,逐步形成性能优化的知识体系。从整体来看,上述过程主要分为网络和渲染这两大块。其中网络的优化重点在减少网络请求、减小资源体积、首屏资源加载优化等方面,渲染过程的优化主要包括JS执行优化、页面布局与重绘优化、CSS计算样式优化等等。学习这些知识,不仅可以帮助我们提升网站性能,更能加深我们对前端开发流程的理解。

由于篇幅太长,我们将分成两篇文章,这一篇是网络过程的优化,下一篇将会讲渲染过程的优化。

下面是网络篇的思维导图。

image.png

减少网络请求

网络请求相对而言是比较耗时的,减少网络请求可以加快用户加载页面的速度,提升网站性能和用户体验。

浏览器缓存

缓存的原理是首次请求后存储一份请求资源的响应副本,当用户再次发起相同请求后,如果缓存命中,则直接将之前存储的副本返回给浏览器,从而避免重新向服务器发起资源请求。

image.png
HTTP缓存是我们日常开发中最常用的一种缓存技术。根据判断缓存命中时,浏览器是否需要向服务器发起请求以询问缓存的相关信息来划分,HTTP缓存又可以分为强缓存和协商缓存。

强缓存

缓存策略都是通过设置HTTP Header来实现的,与强缓存相关的Header字段是 Expires 和 cache-control。

Expires是 HTTP1.0 协议中控制缓存失效日期时间戳的字段,是绝对时间,当客户端本地时间超过 Expires 的时间,表示缓存过期。

Expires: Mon, 21 Apr 2031 01:13:16 GMT
复制代码

Cache-Control是HTTP1.1协议中控制缓存的字段,Cache-Control 的 max-age 属性控制资源缓存的有效期,是一个以秒为单位的时间长度,如下,表示该缓存在资源被请求到后的315360000秒内有效。

Cache-Control: max-age=315360000
复制代码

Cache-Control 还可以配置一些其他属性值来控制缓存:

  • no-store:禁止使用任何缓存策略,每次请求都去服务器去获取最新资源。
  • no-cache:每次发起请求不会直接进入强缓存的过期校验,而是直接与服务器协商来验证本地缓存的有效性,也就是走下面要讲的协商缓存路线。
  • private:资源只能被浏览器缓存,Cache-Control的默认取值。
  • must-revalidate:可缓存但必须再向服务器进确认。

(以下是对代理服务器缓存的设置)

  • public:资源既可被浏览器缓存,又可被代理服务器缓存。
  • s-maxage: 缓存在代理服务器中的过期时长。
  • proxy-revalidate: 要求代理服务器对缓存的响应有效性再进行确认。

在HTTP1.1中,Cache-Control是Expires的完全替代方案,通常来说,在项目实践中使用它就足够了,继续使用 Expires 是为了兼容 HTTP1.0。

协商缓存

当 Cache-Control 校验缓存过期,或者设置了no-cache时,浏览器就会与服务器进行协商,判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源。

跟协商缓存相关的 Header 字段有Last-Modified / If-Modified-SinceETag / If-None-Match (分别成对出现)

Last-Modified / If-Modified-Since

在每次服务器返回资源的同时,会在响应头中同时返回Last-Modified字段,它是一个时间戳,表示资源最近一次的修改时间。

last-modified: Mon, 11 Jan 2021 08:15:02 GMT
复制代码

随后我们每次需要发送请求时,会带上一个叫 If-Modified-Since 的时间戳字段,它的值就是上一次 response 返回的 Last-Modified 值。

if-modified-since: Mon, 11 Jan 2021 08:15:02 GMT
复制代码

服务器接收到这个时间戳后,会比对该时间戳和资源在服务器上的最后修改时间:

  • 如果两者不同,返回200和完整的响应内容。
  • 如果两者相同,返回304和空的响应体,直接从缓存读取资源。

需要注意的是,强缓存命中对应的状态码是200,协商缓存命中对应的状态码是304。

ETag / If-None-Match

Etag 是由服务器为每个资源生成的唯一的标识字符串,类似于文件指纹。和 Last-Modified 类似,当首次请求时,会在响应头返回Etag字段。

etag: "88158AFCFF1E7F4B8B00A1BA81171B61"
复制代码

下一次请求时,会将之前响应头中Etag的值作为此次请求头中If-None-Match字段,提供给服务器进行校验。
若资源没有更新,就返回304和空的响应体,直接从缓存读取资源。否则,就会返回返回更新后的资源并且将新的缓存信息一起返回。

Etag 在感知文件变化上比 Last-Modified 更加准确,优先级也更高。当 Etag 和 Last-Modified 同时存在时,以 Etag 为准。

应用场景

html文档一般会设置 no-cache。在meta中设置:

<meta http-equiv="cache-control" content="no-cache">
<meta http-equiv="cache-Control" content="max-age=0">
复制代码

CSS,JS,图片等静态文件一般使用强缓存,设置较长的过期时间,并且文件名添加hash。比如掘金的文章详情页,js文件的缓存过期时间为max-age=31536000(一年)。

image.png
同时文件名必须添加hash,这样当文件名hash发生变化,则视为新的资源,从服务端直接获取。这样可以减少大量的静态资源的网络请求,由新hash标志的文件也不会因为缓存的原因滞后。

通过webpack,我们可以方便地为文件名添加hash。具体参考webpack中的文件指纹

image.png

本地存储

localStorage

localStorage是html5新增的本地存储API,在一般浏览器支持的存储容量是5M大小,它仅在浏览器中保存,不参与和服务器的通信。

存储在localStorage中的数据没有过期时间,只有手动清除。因为数据是保存在浏览器本地硬件设备中的,所以即使关闭浏览器,这部分数据依旧存在,下次打开浏览器访问网站时数据可继续使用。

localStorage使用起来非常简单,常用API包含setItem、getItem、removeItem、clear四个方法。

//向localStorage添加或设置数据项
localStorage.setItem("username", "John")

//以键名的方式从localStorage获取数据
localStorage.getItem('username')

//从localStorage移除指定键名的数据项
localStorage.removeItem('username')

//清空localStorage中的所有数据
localStorage.clear()
复制代码

值得注意的是,localStorage无法跨域,同源下才可读写;其次,localStorage中仅能存储字符串内容,所以当要存储对象、数组等数据时,可使用JSON.stringify先将其转化为字符串,取用时JSON.parse将其数据类型还原。

sessioStorage

sessioStorage和localStorage在API使用上基本一致,它们的区别在于拥有不同的数据持久性。sessioStorage中存储的数据,只在当前会话可用,当关闭标签页结束会话时,数据也将被清除。

应用场景

相比于sessioStorage,localStorage的应用更加广泛,由于localStorage中数据的持久性,所以可以用它来存储一些网站日常需要,又内容稳定的资源。

比如,淘宝的 localStorage 中存储了不少 Base64 格式的图片字符串(首屏各种小图标)。

image.png
也可以用它来保存搜索历史记录,比如掘金。

image.png
还可以保存一些网站的本地设置,比如阅读网站的白天/黑夜模式,字体大小、背景色等等。

文件合并

文件的合并与拆分

文件的合并可以减少http请求,利用构建工具,很容易实现文件合并,比如webpack,默认会把所有依赖模块打包成一个bundle.js

这里我们需要注意的是,如果合并后的文件体积很大,加载时间就需要很久,那么首屏渲染的延迟就会很长;另一方面就是缓存失效的问题,在实际项目,我们会给js文件名加上hash,来标志文件是否更新,合并成一个文件的话,源文件中一处小修改,就会导致整个文件的hash变化,缓存也会失效。所以文件合并需要考虑合理的策略,其实也是文件拆分的策略。此外,文件拆分还能充分利用同一域名的并行下载。

使用webpack进行构建,默认会把所有依赖模块打包成一个js文件,我们可以借助各种插件,对文件进行拆分。例如,使用mini-css-extract-plugin将css分离到单独的文件:

const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module:{
  rules:[
     {
        test: /\.css$/,
        use:  [MiniCssExtractPlugin.loader,'css-loader']
        //不要使用style-loader,换成MiniCssExtractPlugin.loader
     },
  ]
},
plugins:[
   new MiniCssExtractPlugin({
      filename: 'style/index.css' //默认为dist目录下的main.css
   })
]
复制代码

分离第三方库,业务代码中的基础库,常见操作有:

  1. Externals
  2. SplitChunks
  3. DllPlugin

雪碧图

雪碧图将多张小图标拼接成一张大图,在HTTP1.x环境下,雪碧图可以减少HTTP请求,加速网页的显示速度。

用于合成雪碧图的图标体积要小,较大的图片不建议拼接成雪碧图;同时要是网站静态图标,不是通过ajax请求动态获取的图标。所以通常是作为网站logo、icon之类的图片。

使用webpack构建时,可以借助postcss-sprites来自动合成雪碧图。具体步骤:

首先,在webpack.base.js中配置postcss-loader

//webpack.base.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['vue-style-loader','css-loader', 'postcss-loader']  //配置postcss-loader
      },
      {
        test: /\.less$/,
        use: [
          'vue-style-loader','css-loader', 'postcss-loader', 'less-loader']  //配置postcss-loader
      }
    ]
  }
};
复制代码

然后在项目根目录下新建.postcssrc.js,配置postcss-sprites

module.exports = {
  "plugins": [
    require('postcss-sprites')({
      // 默认会合并css中用到的所有静态图片
      // 使用filterBy指定需要合并的图片,比如这里这里只合并images/icon文件夹下的图片
      filterBy: function (image) {
        if (image.url.indexOf('/images/icon/') > -1) {
            return Promise.resolve();
        }
        return Promise.reject();
      }
    })
  ]
}
复制代码

默认会把图片合并到名为sprite.png的雪碧图中。 postcss-sprites合成雪碧图

Base64

Base64不是一种图像格式,是一种将任意二进制转换成文本字符串的编码方法,我们用Base64编码图像时,就是将图像的二进制数据转换成字符串。

data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADwAAAA8CAYAAAA6/NlyAAAAAXNSR0IArs4c6QAA...
复制代码

将Base64编码直接作为<img>标签的src属性值,浏览器会自动解析Base64编码,不会再一次发起图片请求。

每次建立http请求都会消耗一定的时间,对于网站频繁使用的各种小图标来说,建立http请求的时间都可能超过下载图片本身的时间,这种情况下,就可以用Base64编码小图来减少http请求。

由于Base64编码的原理,Base64编码后比原图要大3/4左右,如果把大图片编码到 html/css 中,会造成后者体积明显增加,明显影响网页的打开速度。因此,通常建议只对小图作Base64编码转换。

webpack中的 url-loader 可以自动根据文件大小决定要不要进行Base64编码:

{
   test: /\.(png|jpe?g|gif)$/i,
   use: [
      {
        loader: 'url-loader',
        options: {
          limit: 8192       //小于8M的图片都会被转换成Base64
        },
      },
   ],
},
复制代码

减小资源体积

除了减少网络请求之外,减小资源体积是另一个重要的优化点,也是我们开发过程中必不可少的环节。

构建工具优化

使用构建工具进行资源的合并压缩,是工程实践中最普遍的操作。时下最主流的构建工具无疑是webpack,我们也主要探讨利用webpack进行资源体积的优化。

具体的操作可以参考上一篇文章一文搞定webapck构建优化策略,里面对webapck构建的分析工具、速度优化、体积优化,都结合实例进行了讲解,这里不再赘述。

选择合适的图片格式

如果可以,尽量使用CSS效果(渐变,阴影,圆角等)代替图像。

当必须使用图像资源时,选择合适的图片格式非常重要,因为同样的视觉效果,不同格式的图片大小不同。

  • jpg:有损压缩,体积较小。适用于呈现色彩丰富的大图,如背景图、轮播图、Banner图等。

  • png:无损压缩,体积较大,支持透明度。适用于小的 Logo、颜色简单且对比强烈的图片等。

  • webp:2010由Google推出,使用更优的图像数据压缩算法,图片体积更小,而且拥有肉眼识别无差异的图像质量;同时具备了无损和有损的压缩模式、支持透明度以及动画的特性。但在兼容性方面,IE和Safari浏览器不支持。

  • svg:矢量图,图片可无限放大而不失真,体积小。适用于各种icon、logo。

选择合适的图片大小

使用尺寸满足需求的图片,不要使用原本尺寸很大的图片,然后在html中通过元素的 height / width 设置图片大小。

类似微博、朋友圈这种网站,如果需要展示大量的缩略图,点击显示完整图,那么应该分别使用缩略图和完整图。不能缩小完整图的尺寸放在缩略图的位置,图片尺寸过大会导致加载过慢而影响渲染;也不能放大缩略图当成完整图,会导致不清晰。

首屏资源加载优化

对于首屏之外的内容,如果一次性全部加载完,会影响首屏的渲染速度;而且,如果页面很长,就算整个页面全部加载完成,用户也不一定会滚动屏幕浏览全部内容。

所以,一个很重要的优化点就是,打开网站时,尽量只加载首屏内容所包含的资源,而首屏之外的资源,等需要用到的时候再去加载。从而使页面首屏内容更快地渲染出来。

懒加载

路由懒加载

单页面应用中,如果所有组件都打包到一起,js包会变得非常大,在首次加载时影响页面的加载速度。使用路由懒加载,我们就能把不同路由的组件分割成不同的代码块,当路由被访问时才会加载对应的组件。

使用动态import,很容易实现路由懒加载。具体参考上一篇中讲的 动态import

图片懒加载

浏览器是否发起请求图片根据的是<img>的src属性,懒加载的原理就是,不直接给<img>的src属性赋值,而是在<img>进入可视区域时,再给src赋于图片URL。

对于每一个需要懒加载的<img>标签,设置一个data-src属性,用于存储图片URL,当元素进入可视区域时,获取到这个data-src的值,然后赋值给<img>的src属性。

那么,怎样判断<img>是否进入可视区域呢?这里使用 getBoundingClientRect 来获取元素顶部与浏览器视口顶部的距离top, 通过window.innerHeight获取浏览器视口的高度viewHeight, 当viewHeight - top的值大于等于0,表示元素已进入可视区域。

下面我们就来实现一个简单的懒加载。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .lazy{
      height: 150px;
      width: 200px;
      background-color: #eee;
    }
  </style>
</head>
<body>
  <div>
    <img class="lazy" src="https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=3176859023,1719957347&fm=26&gp=0.jpg">
  </div>
  <div>
    <img class="lazy" src="https://ss1.bdstatic.com/70cFvXSh_Q1YnxGkpoWK1HF6hhy/it/u=2413205756,2857128339&fm=26&gp=0.jpg">
  </div>
  <div>
    <img class="lazy" src="https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=2356708534,2525790159&fm=26&gp=0.jpg">
  </div>
  <div>
    <img class="lazy" src="https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=1332858679,869019665&fm=26&gp=0.jpg">
  </div>
  <div>
    <img class="lazy" src="https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=2450079006,3171044776&fm=26&gp=0.jpg">
  </div>
  <div>
    <img class="lazy" src="https://ss2.bdstatic.com/70cFvnSh_Q1YnxGkpoWK1HF6hhy/it/u=1181825476,2174805292&fm=26&gp=0.jpg">
  </div>
</body>
<script>
  const imgs = document.querySelectorAll('.lazy')   // 需要懒加载的图片标签
  const viewHeight = window.innerHeight         // 可视区域高度
  // num用于统计当前显示到了哪一张图片,避免每次都从第一张图片开始检查是否进入可视区域
  let num = 0
  function lazyload(){
    for(let i=num; i<imgs.length; i++) {
      // 用浏览器视口高度 减去 元素顶部与浏览器视口顶部的距离
      let distance = viewHeight - imgs[i].getBoundingClientRect().top
      // 若distance大于等于0,说明元素进入可视区域
      if(distance >= 0 ){
        // 获取到data-src属性中存储的图片URL,赋值给src属性
        imgs[i].src = imgs[i].getAttribute('data-src')
        // 前i张图片已经加载完毕,下次从第i+1张开始检查是否进入可视区域
        num = i + 1
      }
    }
  }
  lazyload()   //加载首屏需要显示的图片
  window.addEventListener('scroll', lazyload, false)   // 监听scroll事件
</script>
</html>
复制代码

最终的效果如下图所示:

lazyimg.gif

服务端渲染

Vue、React等框架让开发变得简单高效,但也让页面需要加载的资源变多,除了业务代码,还要等框架代码加载完成才能进行页面的渲染。对于首屏的呈现速度有较高要求的网站,可以考虑服务端渲染。它不仅能提高首屏的呈现速度,还有助于SEO。服务端渲染的实践往往与前端框架(如 Vue,React等)紧密结合,更多实现原理和细节可以参考笔者之前写的文章理解Vue SSR原理,搭建项目框架

基础的网络优化

DNS

DNS 负责将域名转换为 IP 地址。对 DNS 的优化主要有以下两点:

  1. 减少 DNS 查询。

DNS 的查询结果有客户端本地缓存,所以首次查询从域名服务器获取对应IP后,下一次再访问就直接从本地拿到IP地址。因此,避免将静态资源分散在多个域名下,减少需要借助域名服务器的 DNS 查询。

  1. 预加载。

当浏览网页时,浏览器会在加载网页时,在后台对网页中的域名进行解析缓存,这样在访问当前网页中的连接时就无需再进行DNS的解析。

可以控制对当前页面中所有连接进行预解析,或对特定连接进行预解析,在head标签中进行配置:

//对当前页面中所有连接进行预解析
<meta http-equiv="x-dns-prefetch-control" content="on">
复制代码
//对特定连接进行预解析
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
<link rel="dns-prefetch" href="https://baidu.com">
复制代码

CDN

使用CDN存放 JS、CSS、图片等静态资源,静态资源本身具有访问频率高、承接流量大的特点,CDN 是静态资源提速的重要手段。CDN原理和细节参考:【前端词典】CDN 带来这些性能优化

网络部分的性能优化总结就到这里了,下一篇文章会专门写渲染部分的性能优化,下次见~

参考资料

前端性能优化原理与实践

浏览器工作原理与实践

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享