前端性能优化盘点

206 阅读9分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第6天,点击查看活动详情 >>

本篇博文源于阅读前端性能优化原理与实践 - 修言 - 掘金小册 (juejin.cn)的总结

从输入URL到页面加载完成,发生了什么?

  1. DNS解析
  2. TCP连接
  3. HTTP请求抛出
  4. 服务端处理请求,HTTP响应返回
  5. 浏览器拿到响应数据,解析响应内容,把解析的结果展示给用户

任何一个用户端的产品,都需要把这5个过程滴水不漏地考虑到自己的性能优化方案内。


第一步:Webpack性能调优

webpack的优化瓶颈:

  • webpack的构建过程太花时间
  • webpack打包的结果体积太大

构建过程提速策略

不要让loader做太多事情

最常见的优化方式是用includeexclude来避免不必要的转译

module: {
  rules: [
    {
      test: /.js$/,
      exclude: /(node_modules|bower_components)/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env']
        }
      }
    }
  ]
}

开启缓存将转译结果缓存至文件系统,至少可以将babel-loader的工作效率提升两倍

loader: 'babel-loader?cacheDirectory=true'

Happypack-将loader由单进程转为多进程

webpack是单线程的,但是CPU是多核的,可以利用Happypack构建多进程去并发执行打包

const HappyPack = require('happypack')
// 手动创建进程池
const happyThreadPool =  HappyPack.ThreadPool({ size: os.cpus().length })

module.exports = {
  module: {
    rules: [
      ...
      {
        test: /.js$/,
        // 问号后面的查询参数指定了处理这类文件的HappyPack实例的名字
        loader: 'happypack/loader?id=happyBabel',
        ...
      },
    ],
  },
  plugins: [
    ...
    new HappyPack({
      // 这个HappyPack的“名字”就叫做happyBabel,和楼上的查询参数遥相呼应
      id: 'happyBabel',
      // 指定进程池
      threadPool: happyThreadPool,
      loaders: ['babel-loader?cacheDirectory']
    })
  ],
}

按需加载

webpack.js.org/api/module-…

output: {
    path: path.join(__dirname, '/../dist'),
    filename: 'app.js',
    publicPath: defaultSettings.publicPath,
    // 指定 chunkFilename
    chunkFilename: '[name].[chunkhash:5].chunk.js',
},

路由处的代码也要做一下配合

const getComponent => (location, cb) {
  require.ensure([], (require) => {
    cb(null, require('../pages/BugComponent').default)
  }, 'bug')
},
...
<Route path="/bug" getComponent={getComponent}>

核心方法:

require.ensure(dependencies, callback, chunkName)

第二步:图片优化

用对图片很重要

JPEG/JPG

关键字:有损压缩、体积小、加载快、不支持透明

适用场景:作为大的背景图、轮播图或Banner图片出现

PNG

关键字:无损压缩、质量高、体积大、支持透明

适用场景:呈现小的Logo、颜色简单且对比强烈的图片或背景等

SVG

关键字:文本文件、体积小、不失真、兼容性好

特性:渲染成本比较高,对性能来说很不利;SVG存在着其他图片格式所没有的学习成本(可编程)

Base64

关键字:文本文件、依赖编码、小图标解决方案

适用场景:图片的实际尺寸很小、图片无法以雪碧图的形式与其他小图结合、图片的更新频率非常低

特性:Base64编译之后,图片大小膨胀为原来的4/3,减少HTTP请求,但是增加了内存开销,得不偿失

雪碧图

指:将小图标和背景图像合并到一张图片上,然后利用CSS的背景定位来显示其中的某一部分

特性:使用一个图像文件替代多个小文件,减少HTTP请求次数

WebP

关键字:年轻的全能型选手

特性:太年轻,兼容性不好

第三步:缓存优化

缓存分类:强缓存,协商缓存

强缓存

利用http头中的expiresCache-Control来控制,状态码200

expires

expires: Wed, 11 Sep 2019 16:12:18 GMT

绝对的时间戳,再次请求时,对比客户端现在时间,对服务器和客户端的时间一致性要求极高,若服务器与客户端存在时差,将带来意料之外的结果

Cache-Control

cache-control: max-age=3600, s-maxage=31536000

相对的时间戳,再次请求时客户端对比第一次请求时的时间,看是否过期,s-maxage优先级高,未过期向代理服务器请求其缓存内容

no-store和no-cache

no-cache绕过了浏览器,每次想服务器确认是否过期,走协商缓存的路

no-store不使用任何缓存策略

协商缓存

利用Last-ModifiedEtag来控制,状态码304

Last-Modified

首次请求,response headers返回:

Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT

之后请求,带上If-Modified-Since的时间戳字段,它的值是上一次response返回的last-modified

If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT

服务器收到这个时间戳后,会比对该时间戳和资源在服务器上的最后修改时间是否一致,从而判断资源是否发生了变化。如果发生了变化,就会返回一个完整的响应内容,并在Response Headers中添加新的Last-Modified值;否则,返回304Response Headers不再添加Last-Modified字段

弊端:

  1. 编辑了文件,但是文件的内容没有改变,还是会被当作新资源,重新请求
  2. 时间只能精确到秒,修改过快时,感知不到改动

Etag

由服务器为每个资源生成唯一的标识字符串

首次请求:

ETag: W/"2a3b-1602480f459"

之后请求,带上值相同的If-None-Match的字符串到服务端比对

If-None-Match: W/"2a3b-1602480f459"

Etag生成会对服务器额外造成开销,会影响服务端的性能,因此Etag并不能替代Last-Modified,只能作为Last-Modified的补充和强化存在

本地存储

cookie

特性:体积最大只有4KB,cookie紧跟域名,每次请求都会携带cookie,大量的cookie存在于一次HTTP请求中

Web Storage

特性:容量大5M-10M,仅位于浏览器端,不与服务器通信

应用场景:存储一些内容稳定的资源,比如Base64格式的图片字符串、不经常更新的CSS、JS等静态资源、微博利用Session Storage存储本次会话的浏览足迹

第四步:SSR

服务端渲染本质上是本该浏览器做的事情,分担给服务器去做。

服务端渲染并非万全之策,服务器稀少而宝贵,但首屏渲染体验和SEO的优化方案却很多,最好把能用的低成本方案用完再考虑

第五步:渲染优化

基于渲染流程的CSS优化建议

CSS引起查找样式表对每条规则都是按照从右到左的顺序去匹配的

#myList  li {}

上面的代码开销很大,浏览器必须遍历页面上每个li元素,并且每次都要去确认这个li元素的父元素id是不是myList

优化方案

  • 避免使用通配符,只对需要用到的元素进行选择
  • 关注可以通过继承实现的属性,避免重复匹配重复定义
  • 少用标签选择器,如果可以,用类选择器替代
  • 不要画蛇添足,idclass选择器不应该被多余的标签选择器拖后腿
  • 减少嵌套,后代选择器的开销是最高的,因此我们应该尽量将选择器的深度降到最低(最高不要超过三层),尽可能使用类来关联每一个标签元素

CSS与JS的加载顺序优化

  • 把CSS尽量往前放
  • 当脚本与DOM元素和其他脚本之间的依赖关系不强时,我们选用async加载JS
  • 当脚本依赖于DOM元素和其他脚本的执行结果时,我们选用defer加载JS

第六步:DOM优化

减少DOM操作次数并使用DOM Fragment

let container = document.getElementById('container')
// 创建一个DOM Fragment对象作为容器
let content = document.createDocumentFragment()
for(let count=0;count<10000;count++){
  // span此时可以通过DOM API去创建
  let oSpan = document.createElement("span")
  oSpan.innerHTML = '我是一个小测试'
  // 像操作真实DOM一样操作DOM Fragment对象
  content.appendChild(oSpan)
}
// 内容处理好了,最后再触发真实DOM的更改
container.appendChild(content)

回流与重绘

触发回流的方式

  1. 改变几何属性
  2. 获取特定属性的值:offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight
  3. getComputedStyle方法和currentStyle的调用

缓存敏感属性

// 缓存offsetLeft与offsetTop的值
const el = document.getElementById('el') 
let offLeft = el.offsetLeft, offTop = el.offsetTop

// 在JS层面进行计算
for(let i=0;i<10;i++) {
  offLeft += 10
  offTop  += 10
}

// 一次性将计算结果应用到DOM上
el.style.left = offLeft + "px"
el.style.top = offTop  + "px"

避免逐条改变样式,使用类名去合并样式

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
  <style>
    .basic_style {
      width: 100px;
      height: 200px;
      border: 10px solid red;
      color: red;
    }
  </style>
</head>
<body>
  <div id="container"></div>
  <script>
  const container = document.getElementById('container')
  container.classList.add('basic_style')
  </script>
</body>
</html>

将DOM“离线”

一旦我们给元素设置display:none,相当于将其从页面上拿掉了,之后的操作,不会触发回流重绘,然后再上线,只会触发一次回流

Flush队列:浏览器并没有那么简单

let container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'

理论上触发了三次回流,一次重绘,但是浏览器只触发了一次回流,一次重绘。

现代浏览器是非常重吗的,浏览器自己也清楚,如果每次DOM操作都即时地反馈一次回流或重绘,那么性能上来说是扛不住的,于是它自己缓存了一个flush队列,把我们触发的回流与重绘任务都塞进去,待到队列里的人物躲起来、或者达到了一定的时间间隔,或者“不得已”的时候,再将这些人物一口气出队。

不得已的时候即:请求敏感属性的时候。

第七步:Lazy-Load

针对图片加载时机的优化:在一些图片量比较大的网站(如电商,团购等),如果我们尝试在用户打开页面的时候,就把所有的图片资源加载完毕,那么很可能会造成白屏、卡顿等现象。

示例: 这scroll是个危险事件,太容易被触发,需要使用throttledebounce

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Lazy-Load</title>
  <style>
    .img {
      width: 200px;
      height:200px;
      background-color: gray;
    }
    .pic {
      // 必要的img样式
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="img">
      // 注意我们并没有为它引入真实的src
      <img class="pic" alt="加载中" data-src="./images/1.png">
    </div>
    <div class="img">
      <img class="pic" alt="加载中" data-src="./images/2.png">
    </div>
    <div class="img">
      <img class="pic" alt="加载中" data-src="./images/3.png">
    </div>
    <div class="img">
      <img class="pic" alt="加载中" data-src="./images/4.png">
    </div>
    <div class="img">
      <img class="pic" alt="加载中" data-src="./images/5.png">
    </div>
     <div class="img">
      <img class="pic" alt="加载中" data-src="./images/6.png">
    </div>
     <div class="img">
      <img class="pic" alt="加载中" data-src="./images/7.png">
    </div>
     <div class="img">
      <img class="pic" alt="加载中" data-src="./images/8.png">
    </div>
     <div class="img">
      <img class="pic" alt="加载中" data-src="./images/9.png">
    </div>
     <div class="img">
      <img class="pic" alt="加载中" data-src="./images/10.png">
    </div>
  </div>
  <script>
    // 获取所有的图片标签
    const imgs = document.getElementsByTagName('img')
    // 获取可视区域的高度
    const viewHeight = window.innerHeight || document.documentElement.clientHeight
    // num用于统计当前显示到了哪一张图片,避免每次都从第一张图片开始检查是否露出
    let num = 0
    function lazyload(){
        for(let i=num; i<imgs.length; i++) {
            // 用可视区域高度减去元素顶部距离可视区域顶部的高度
            let distance = viewHeight - imgs[i].getBoundingClientRect().top
            // 如果可视区域高度大于等于元素顶部距离可视区域顶部的高度,说明元素露出
            if(distance >= 0 ){
                // 给元素写入真实的src,展示图片
                imgs[i].src = imgs[i].getAttribute('data-src')
                // 前i张图片已经加载完毕,下次从第i+1张开始检查是否露出
                num = i + 1
            }
        }
    }
    // 监听Scroll事件
    window.addEventListener('scroll', lazyload, false);
</script>
</body>
</html>

第八步:节流与防抖

使用throttle优化后的debounce

debounce 的问题在于它“太有耐心了”。试想,如果用户的操作十分频繁——他每次都不等 debounce 设置的 delay 时间结束就进行下一次操作,于是每次 debounce 都为该用户重新生成定时器,回调函数被延迟了不计其数次。频繁的延迟会导致用户迟迟得不到响应,用户同样会产生“这个页面卡死了”的观感。

为了避免弄巧成拙,我们需要借力 throttle 的思想,打造一个“有底线”的 debounce——等你可以,但我有我的原则:delay 时间内,我可以为你重新生成定时器;但只要delay的时间到了,我必须要给用户一个响应。这个 throttle 与 debounce “合体”思路,已经被很多成熟的前端库应用到了它们的加强版 throttle 函数的实现中:

// fn是我们需要包装的事件回调, delay是时间间隔的阈值
function throttle(fn, delay) {
  // last为上一次触发回调的时间, timer是定时器
  let last = 0, timer = null
  // 将throttle处理结果当作函数返回
  
  return function () { 
    // 保留调用时的this上下文
    let context = this
    // 保留调用时传入的参数
    let args = arguments
    // 记录本次触发回调的时间
    let now = +new Date()
    
    // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
    if (now - last < delay) {
    // 如果时间间隔小于我们设定的时间间隔阈值,则为本次触发操作设立一个新的定时器
       clearTimeout(timer)
       timer = setTimeout(function () {
          last = now
          fn.apply(context, args)
        }, delay)
    } else {
        // 如果时间间隔超出了我们设定的时间间隔阈值,那就不等了,无论如何要反馈给用户一次响应
        last = now
        fn.apply(context, args)
    }
  }
}

// 用新的throttle包装scroll的回调
const better_scroll = throttle(() => console.log('触发了滚动事件'), 1000)

document.addEventListener('scroll', better_scroll)

性能优化指标

web.dev/metrics/