前端性能优化(一)

2,100 阅读10分钟

在讨论具体如何优化之前,先思考一个经典问题,从输入 URL 到页面加载完成到底发生了什么?

URL 经过 DNS 解析为 IP 地址,然后与 IP 地址进行 TCP 连接,随后发出 HTTP 请求,服务器处理完请求之后将内容通过 HTTP 发送给客户端,拿到数据后浏览器就开始渲染流程。简单的说分为以下几个步骤:DNS 解析,TCP 连接,HTTP 请求,HTTP响应,浏览器解析并渲染。

这个问题解决之后,就可以从各个层面分析如何做性能优化了。

DNS 预解析

DNS 预解析是指浏览器视图在用户访问链接之前解析域名。那接下来用户如果确实访问了该域名,那 DNS 的解析时间将不会有延迟。浏览器对网站第一次的域名 DNS 查找流程为:浏览器缓存 => 系统缓存 => 路由器缓存 => ISP DNS 缓存 => DNS 服务器。所以我们不需要对和当前页面中同一个域的域名进行预获取(浏览器会缓存解析结果)。

使用起来也很简单:

// 如果需要禁用隐式的 DNS prefetch 设置 content = "off"
<meta http-equiv="x-dns-prefetch-control" content="on">
<link rel="dns-prefetch" href="//ha.aa.bb">

HTTP2 && HTTP1

先来一个例子大家直观感受下两者的差异,可以看到 HTTP/2 性能有大幅提高。这里就必须提到其引入的多路复用技术,在这个技术的支持下,同一域名下的所有请求都在一个通道内完成。这也是引入了帧和流的概念,帧是数据最小传输单位,且标记了属于哪个流,流就是多个数据帧组成的数据流。多路复用就是一个连接下存在多个流。而 HTTP/1 中每个请求都必须创建一个 TCP 连接,浏览器还限制了同一个域名下的请求数量,当请求资源较多的时候会出现队头阻塞。

且 HTTP/2 采用二进制传输代替了 HTTP/1 中的文本传输,解析更加高效。

浏览器缓存策略

缓存是性能优化中性价比很高的一种优化方式,显著的减少了网络传输带来的损耗。

浏览器的缓存机制有四种,按优先级排列如下:

  1. Service Worker
  2. Memory Cache
  3. Disk Cache
  4. Push Cache

当以上都没有命中资源的时候才去做网络请求。

Service Worker

Service Worker 是运行在浏览器背后的独立线程,且脱离浏览器窗口,因此无法直接访问 DOM。也正是独立的特点,我们往往可以通过它实现离线缓存,消息推送和网络代理等功能。但由于涉及到网络代理的,使用 Service Worker 时,传输协议必须为 HTTPS。

使用步骤分为三步:注册 Service Worker;监听 install 事件,并缓存需要的文件;在下次请求的时候通过拦截请求的方式查询是否存在缓存,存在的话直接读取缓存文件,否则请求资源。

有一点需要注意的是,当我们没有在 Service Worker 命中缓存,需要调用 fetch 函数获取数据时,浏览器会依次根据缓存优先级继续查找,但此时找到的数据依旧会显示是从 Service Worker 中获取的。

Memory Cache

Memory Cache 是指内存中的缓存,是速度非常快的一种缓存。但是虽然读取效率高,其生存时间较短,一旦 Tab 页关闭,内存中的缓存就被释放了。但是具体哪部分内容会被缓存并不确定,需要根据系统内存的具体情况判断。

Disk Cache

Disk Cache 是指存在硬盘中的缓存,读取速度较慢,但是时效性较高。即使在跨站点的情况下,相同地址的资源一旦被缓存下来就不会再次去请求数据。

Push Cache

HTTP/2 中的内容,不太了解~~有了解的同学可以一起交流下呀

缓存策略

前面提到了各种类型的缓存,但是究竟要不要缓存,怎么判断缓存过期时间,这些问题都要从缓存策略中找到答案。

浏览器的缓存策略分为两种:强缓存协商缓存

强缓存

强缓存的实现依赖 ExpiresCache-Control 两个字段来控制。强缓存表示缓存期间不需要再次请求,返回状态码 200。

强缓存的早期实现是靠 Expires 字段,这个字段是一个时间戳,表示在这个事件前的缓存都是有效的。可以看到这个字段十分依赖本地时间,如果修改客户端时间可能就会出问题。

HTTP/1.1 出现的 Cache-Control 可以完全替代 Expires 并提供更丰富的功能。它提供了很多指令:

指令 作用
public 表示响应可以被客户端和代理服务器缓存
private 表示响应只能被客户端缓存
max-age=30 缓存 30 s 后过期
s-maxage=30 覆盖 max-age ,但只在代理服务器中生效
no-store 不缓存任何响应
no-cache 资源被缓存但是立即失效,下次会发起请求验证资源是否过期
max-stale=30 30s 内及时缓存过期也使用该缓存
min-fresh=30 希望在 30s 内获取最新的响应
协商缓存

如果缓存过期了或是设置了 no-cache,则进入协商缓存阶段。协商缓存的实现依赖于两个字段::Etag 以及 Last-modified。当浏览器发起验证请求资源时,如果资源没有改动,就返回 304 状态码,并更新缓存有效期。

当浏览器发起请求时,会带上 If-Modified-Since 字段,它的值是上次请求资源时 Last-modified 提供的时间戳。服务器再判断在这个时间戳之后是否有改动。但是这个机制还是存在弊端的,因为时间戳是以秒为单位计算的,如果再 1s 内的改动是无法被感知到的。

Etag 就是为了解决上述问题出现的,浏览器会将上次请求资源返回结果携带的 Etag 作为 If-None-Match 的值发送给服务器,有变动的话就返回新的资源。Etag 的缺陷在于服务器需要有额外的开销,可能会影响性能。

那当没有设置缓存策略时,浏览器会怎么办?通常会取响应头中的 DATE 减去 Last-modified 值的 10% 作为缓存时间。

实际应用策略

大致了解了浏览器缓存机制之后,要怎么利用它们来提高性能呢?这才是我们真正要解决的问题。

对于频繁变动的资源,可以设置 Cache-Control: no-cache ,让浏览器每次都请求服务器验证资源是否有效。如果有效,可以有效减少响应数据大小。

对于一些打包过后的代码文件,比如 webpack ,通常我们都会对文件名做哈希处理,一般只要文件改动过了,文件名就会改动。所以对于这类文件,可以采用强缓存策略,设置较长时间的缓存时间,比如 Cache-Control: max-age=31536000

构建工具优化

减少打包时间

  • 优化 Loader

    就拿 Babel 举例,首先优化 Loader 的文件搜索范围,合理利用 testincludeexclude ;缓存编译过的文件,下次只需要编译更改过的代码即可:

    loader: 'babel-loader?cacheDirectory=true'
    
  • HappyPack

    HappyPack 将 loader 的同步执行转换为并行执行

    plugins: [
      new HappyPack({
        id: 'happybabel',
        loaders: ['babel-loader?cacheDirectory'],
        // 开启 4 个线程
        threads: 4
      })
    ]
    
  • DllPlugin

    DllPlugin 将制定的库提前打包后引入,下次打包时只有当库版本更新后才需要重新打包。

    // 单独配置在一个文件中
    // webpack.dll.conf.js
    const path = require('path')
    const webpack = require('webpack')
    module.exports = {
      entry: {
        // 想统一打包的类库
        vendor: ['react']
      },
      output: {
        path: path.join(__dirname, 'dist'),
        filename: '[name].dll.js',
        library: '[name]-[hash]'
      },
      plugins: [
        new webpack.DllPlugin({
          // name 必须和 output.library 一致
          name: '[name]-[hash]',
          // 该属性需要与 DllReferencePlugin 中一致
          context: __dirname,
          path: path.join(__dirname, 'dist', '[name]-manifest.json')
        })
      ]
    }
    // 先执行上面的配置文件生成依赖文件,再使用 DllReferencePlugin 将依赖文件引入项目中
    // webpack.conf.js
    module.exports = {
      plugins: [
        new webpack.DllReferencePlugin({
          context: __dirname,
          // manifest 就是之前打包出来的 json 文件
          manifest: require('./dist/vendor-manifest.json'),
        })
      ]
    }
    
    

压缩代码体积

  • 按需加载

    在开发 SPA 项目时,比较好的一个实践是,可以使用按需加载将每个路由页面单独打包为一个文件,避免首页加载文件过大,当然对于大型类库也同样适用。

  • Scope Hoisting

    // a.js
    export const a = 1
    // index.js
    export {a} from './a.js'
    

    有以上两个文件,使用 webpack打包后会变为:

    [
      /* 0 */
      function (module, exports, require) {
        //...
      },
      /* 1 */
      function (module, exports, require) {
        //...
      }
    ]
    

    使用了 Scope Hoisting 之后,代码会尽量合并到一个函数中,变为:

    [
      /* 0 */
      function (module, exports, require) {
        //...
      }
    ]
    

    可以看到代码量会减少很多,我们可以通过在 webpack 中配置 optimization.concatenateModules 来开启 Scope Hoisting。

  • Tree Shaking

    Tree Shaking 用于删除应用中未被引用的代码,webpack4 默认开启了这个功能。

图片资源优化

web 应用中图片几乎是必不可少的资源,也是十分损耗性能的一个点。图片优化的最好切入点在于根据业务场景做好图片选型方案。

  • JPG

    JPG 的特点是有损压缩,体积小,加载快,但不支持透明。所以通常可以用于大的背景图或是轮播图等色彩丰富的图片中。不适用于一些矢量图形或是对比比较鲜明的图片。

  • PNG

    PNG 的特点是无损压缩,质量高,体积大。通常用于透明图片,小 LOGO,或是颜色简单但对比强烈的图片。

  • SVG

    SVG 的特点是体积小,不失真,兼容性好。一般页面上的图标都可以用 SVG 制作,只是渲染成本较高,对性能可能略有影响。

  • Base64

    Base64 的特点是文本文件,可用于页面上的小图标。

  • WebP

    WebP 的特点是支持透明,支持动态图片,支持有损压缩和无损压缩。但是兼容性太差,但是对于兼容 WebP 的浏览器可以尽量多使用。

  • CSS

    很多时候一些效果可以直接通过 CSS 实现,这个时候就大胆的放弃使用图片吧。

预加载 & 预渲染

  • 预加载

    <link rel="preload" href="http://example.com">
    

    如果页面某些资源可能不会马上用到,但是希望尽早获取,可以使用预加载。它会强制浏览器请求资源,但不会阻塞 onload 事件。预加载可以降低首屏加载时间,因为可以将一些不影响首屏但很重要的文件延后加载。

  • 预渲染

    <link rel="prerender" href="http://example.com"> 
    

    预渲染是Chrome中的一项功能,可以改善用户可见的页面加载时间。预渲染由引用页面中的 <link rel =“prerender"> 元素触发。为预渲染的URL创建一个隐藏页面,该页面将完全加载所有相关资源,以及执行 JS 文件。如果用户进入该页面,则隐藏页面将被交换到当前选项卡中并使其可见。

懒加载 && 懒执行

  • 懒加载

    大多数人应该都接触过图片懒加载,实际上就是将不关键的资源延后加载。举个例子,当图片没有出现在可视区域内,我们可以先统一用一张占位图来显示,将真实的 src 存入自定义属性中,当进入到到可视区域后,再替换 src 属性。

  • 懒执行

    将某些比较耗时的且不需要在首屏中使用的逻辑延后到使用时再计算,一般用于首屏优化中。

CDN

CDN 是指一组分布在各个地区的服务器。这些服务器存储数据的副本,因此服务器可以根据那些服务器距离用户最近来满足数据的请求。CDN 适合被用于存放静态资源。