前端性能优化原理与实践

2022-08-19 14:16:01 浏览数 (1)

webpack打包

在资源请求的过程中,涉及到网络请求的,包括:HTTP、TCP、DNS。其中TCP、DNS前端能做的工作非常有限,因此「优化HTTP」就成为了首要任务。

HTTP的优化可以从三个点出发:

  1. 减少请求次数;
  2. 减少每次请求的资源大小;
  3. 减少请求所花费的时间;

从前端的角度看,其实就是资源的压缩和合并,还有资源的缓存。这些事情webpack十分适合。

webpack性能瓶颈

webpack打包有两个瓶颈:

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

webpack优化方案

不要让loader做太多事情。比如说当使用「babel-loader」时,用 include 或 exclude 来帮我们避免不必要的转译。

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

除此之外,我们可以选择「开启缓存」将转译结果缓存至文件系统。只需要这么写:loader: 'babel-loader?cacheDirectory=true'

优化第三方库

为了防止每次重复打包不会变动的第三方库,可以使用「DllPlugin」

「DllPlugin」 是基于 Windows 动态链接库(dll)的思想被创作出来的。这个插件会把第三方库单独打包到一个文件中,这个文件就是一个单纯的依赖库。「这个依赖库不会跟着你的业务代码一起被重新打包,只有当依赖自身发生版本变化时才会重新打包」

Happypack

「webpack」是单线程的,「Happypack」会充分释放 CPU 在多核并发方面的优势,帮我们把任务分解给多个子进程去并发执行,大大提升打包效率。

下面是用法:

代码语言:javascript复制
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-bundle-analyzer[1]来通过可视化的方式查看各个模块的大小以及依赖关系。

使用方式如下:

代码语言:javascript复制
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}

其次,我们可以通过「tree-shaking」来删除多余的代码,该功能新版webpack默认使用。

最后,我们可以通过Gzip压缩来达到减少资源体积的目的。

按需加载

最经典的优化方式就是路由懒加载,只有当需要加载某个页面的时候,再去动态获取js文件。

vue-router中路由懒加载的写法如下:

代码语言:javascript复制
{
    path: '/ruleResult',
    component: () => import('@/views/rule/index'),
},

图片优化

时下应用较为广泛的 Web 图片格式有「JPEG/JPG、PNG、WebP、Base64、SVG」 等。

计算机中,像素用二进制数表示。一个二进制位表示两种颜色(0|1 对应黑|白),如果一种图片格式对应的二进制位数有 n 个,那么它就可以呈现 2^n 种颜色。

JPEG/JPG

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

「JPG」 适用于呈现色彩丰富的图片,在我们日常开发中,JPG 图片经常作为大的「背景图、轮播图或 Banner 图」出现。

JPG呈现大图,保证质量,体积不大。

JPG的缺点是:当它处理「矢量图形」「Logo」 等线条感较强、颜色对比强烈的图像时,人为压缩导致的图片模糊会相当明显。JPEG 图像「不支持透明度处理。」

PNG

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

「PNG」(可移植网络图形格式)是一种「无损压缩」的高保真的图片格式。8 和 24,这里都是二进制数的位数。按照我们前置知识里提到的对应关系,8 位的 PNG 最多支持 256 种颜色,而 24 位的可以呈现约 1600 万种颜色。

缺点就是体积太大。主要用它来呈现小的 「Logo」、颜色简单且对比强烈的「图片或背景」等。

SVG

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

SVG(可缩放矢量图形)是一种「基于 XML 语法的图像格式」。它和本文提及的其它图片种类有着本质的不同:SVG 对图像的处理不是基于像素点,而是「基于对图像的形状描述」

SVG的优点:文件体积更小,可压缩性更强。「图片可以无限放大不失真」。SVG是文本文件,比较灵活。SVG的局限性:渲染成本比较高,存在学习成本。

用法:可以采用svg 标签进行编程,也可以通过.svg 文件进行引入。

代码语言:javascript复制
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
    <circle cx="50" cy="50" r="50" />
</svg>
<img src="文件名.svg" alt="">

Base64

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

Base64 并非一种图片格式,而是一种编码方式。作为小图标或者小尺寸图片的解决方案。

base64的优点:「Base64 是一种用于传输 8Bit 字节码的编码方式,通过对图片进行 Base64 编码,我们可以直接将编码结果写入 HTML 或者写入 CSS,从而减少 HTTP 请求的次数。」

满足以下条件,可以使用base64:

  • 图片的「实际尺寸很小」
  • 图片无法以雪碧图的形式与其它小图结合(合成雪碧图仍是主要的减少 HTTP 请求的途径,Base64 是「雪碧图的补充」
  • 图片的「更新频率非常低」(不需我们重复编码和修改文件内容,维护成本较低)

WebP

全能型选手,支持「有损压缩和无损压缩」

WebP的优点:支持「有损压缩和无损压缩」。支持透明、可以显示动图、压缩后尺寸更小。

WebP的缺点:兼容性问题、增加服务器的负担。

浏览器缓存

「浏览器缓存机制有四个方面」,它们按照获取资源时请求的优先级依次排列如下:

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

HTTP缓存

HTTP缓存分为「强缓存」「协商缓存」。优先级较高的是强缓存,在命中强缓存失败的情况下,才会走协商缓存。

强缓存是利用 http 头中的 「Expires」「Cache-Control」 两个字段来控制的。若命中强缓存,则不再请求服务器。命中强缓存的情况下,返回的 HTTP 状态码为 200。

强缓存

强缓存的头部包括「expires」「cache-control。」

「expires」 是一个时间戳,如果我们试图再次向服务器请求资源,浏览器就会先对比本地时间和 expires 的时间戳,如果本地时间小于 expires 设定的过期时间,那么就直接去缓存中取这个资源。若服务器与客户端存在时差,将带来意料之外的结果。

代码语言:javascript复制
expires: Wed, 11 Sep 2019 16:12:18 GMT

Cache-Control中的max-age字段也允许我们通过设定「相对的时间长度」来达到同样的目的。「max-age」的单位是秒。相对时间可以规避掉时差问题。客户端会记录下请求到资源的时间点。

代码语言:javascript复制
cache-control: max-age=31536000

Cache-Controlmax-age 配置项相对于 expires 的优先级更高。当 Cache-Controlexpires 同时出现时,我们以 Cache-Control 为准。

代码语言:javascript复制
cache-control: max-age=3600, s-maxage=31536000

s-maxage 用于表示 cache 服务器上(比如 cache CDN)的缓存的有效时间的,并只对 public 缓存有效。s-maxage仅在代理服务器中生效,客户端中我们只考虑max-age

协商缓存

协商缓存机制下,「浏览器需要向服务器去询问」缓存的相关信息,进而判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源。如果服务器判断资源没有改动,会返回304。

协商缓存的实现包括「Last-Modified和Etag」

「Last-Modified」 是一个时间戳,如果我们启用了协商缓存,它会在首次请求时随着 「Response Headers」 返回:

代码语言:javascript复制
Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT

随后我们每次请求时,会带上一个叫 「If-Modified-Since」 的时间戳字段,它的值正是上一次 response 返回给它的 last-modified 值:

代码语言:javascript复制
If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT

服务器接受到该请求头时,会和资源最后修改时间进行对比。如果资源有变动,就会返回一个完整的响应内容,并在 「Response Headers」 中添加新的「Last-Modified」 值;否则,返回如上图的 304 响应,「Response Headers」 不会再添加「Last-Modified」 字段。

使用 Last-Modified 存在一些弊端:

  • 编辑了文件,但是文件内容没有变化。但是服务器以为有变动。
  • 修改文件速度过快,「If-Modified-Since」 只能检查到以秒为最小计量单位的时间差,因此服务器以为没有变动。

此时,需要使用Etag进行补充。

「Etag」是由服务器为每个资源生成的唯一的「标识字符串」,这个标识字符串是「基于文件内容编码」的,只要文件内容不同,它们对应的 Etag 就是不同的。当首次请求时,我们会在响应头里获取到一个最初的标识符字符串。

代码语言:javascript复制
ETag: W/"2a3b-1602480f459"

当再次请求资源时,请求头里会携带一个值相同的、名为「If-None-Match」 的字符串:

代码语言:javascript复制
If-None-Match: W/"2a3b-1602480f459"

Etag 的生成过程需要服务器额外付出开销,会影响服务端的性能,这是它的弊端。

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

MemoryCache

「MemoryCache」,是指存在内存中的缓存。优先级高,效率也高。

当tab页关闭时,内存里的缓存也消失。哪些文件被放入缓存取决于浏览器,一般较小的文件会放入内存的缓存,大文件则不会。

Service Worker Cache

借助 「Service worker」实现的离线缓存就称为「Service Worker Cache」

Push Cache

「Push Cache」 是指 HTTP2 在 server push 阶段存在的缓存。

  • Push Cache 是缓存的最后一道防线。浏览器只有在 Memory Cache、HTTP Cache 和 Service Worker Cache 均未命中的情况下才会去询问 Push Cache。
  • Push Cache 是一种存在于会话阶段的缓存,当 session 终止时,缓存也随之释放。
  • 不同的页面只要共享了同一个 HTTP2 连接,那么它们就可以共享同一个 Push Cache。

本地存储

Cookie

「Cookie」可以携带用户信息,当服务器检查 Cookie 的时候,便可以获取到客户端的状态。

Cookie以键值对的形式存在。Cookie最大存储「4KB」的内容。「同一个域名下的所有请求,都会携带 Cookie」

Web Storage

「Web Storage」分为 「Local Storage」「Session Storage」

两者的区别在于「生命周期」「作用域」的不同。

  • 生命周期:Local Storage 是持久化的本地存储,存储在其中的数据是永远不会过期的,使其消失的唯一办法是手动删除;而 Session Storage 是临时性的本地存储,它是「会话级别的存储」,当会话结束(页面被关闭)时,存储内容也随之被释放。
  • 作用域:Local StorageSession StorageCookie 都遵循同源策略。但 Session Storage 特别的一点在于,即便是相同域名下的两个页面,只要它们「不在同一个浏览器窗口中」打开,那么它们的 Session Storage 内容便无法共享。

「Web Storage」的特点是存储容量大,可以达到「5-10M」之间。不与服务端发生通信。

两者只能用于存储少量的简单数据。当遇到大规模的、结构复杂的数据时,就不适用了。

IndexedDB

「IndexedDB」 是一个「运行在浏览器上的非关系型数据库」。理论上来说,IndexedDB 是没有存储上限的(一般来说不会小于 250M)。它不仅可以存储字符串,还可以存储二进制数据。

浏览器渲染

「解析 HTML」

在这一步浏览器执行了所有的加载解析逻辑,在解析 HTML 的过程中发出了页面渲染所需的各种外部资源请求。

「计算样式」

浏览器将识别并加载所有的 CSS 样式信息与 DOM 树合并,最终生成页面 render 树(:after :before 这样的伪元素会在这个环节被构建到 DOM 树中)。

「计算图层布局」

页面中所有元素的相对位置信息,大小等信息均在这一步得到计算。

「绘制图层」

在这一步中浏览器会根据我们的 DOM 代码结果,把每一个页面图层转换为像素,并对所有的媒体文件进行解码。

「整合图层,得到页面」

最后一步浏览器会合并合各个图层,将数据由 CPU 输出给 GPU 最终绘制在屏幕上。(复杂的视图层会给这个阶段的 GPU 计算带来一些压力,在实际应用中为了优化动画性能,我们有时会手动区分不同的图层)。

css优化

浏览器解析css是从右往左匹配规则。我们要做到:

  • 避免使用通配符。
  • 关注可以通过继承实现的属性,避免重复匹配重复定义。
  • 少用标签选择器。
  • 减少嵌套。

CSS和JS加载顺序优化

默认情况下,CSS 是阻塞的资源。浏览器在构建 CSSOM 的过程中,「不会渲染任何已处理的内容」。因此:需要将它尽早、尽快地下载到客户端,以便缩短首次渲染的时间。

解决方案:将CSS下载链接放到head标签内、使用CDN加载静态资源、合理使用「preload」「prefetch」

「JS 引擎是独立于渲染引擎存在的」。当 HTML 解析器遇到一个 script 标签时,它会暂停渲染过程,将控制权交给 JS 引擎。也就是说,JS 引擎抢走了渲染引擎的控制权。可以通过对它使用 「defer」「async」 来避免不必要的阻塞。

JS有三种加载模式:

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

DOM优化

回流和重绘

「回流」:当对DOM的修改引发了DOM尺寸的变化时,浏览器需要重新计算元素的几何属性,然后将结果进行绘制。该过程为回流。

「重绘」:当对DOM的修改引发了样式的变化,但是没有尺寸变化时,浏览器不需要重新计算元素的几何属性,直接绘制新的样式。该过程为重绘。

结论:「回流一定导致重绘,重绘不一定导致回流。」

导致回流的操作有:

  • 改变 DOM 元素的几何属性:修改诸如「width、height、padding、margin、left、top、border」等。
  • 改变 DOM 树的结构:节点的「增删和移动」
  • 获取特定属性的值:诸如「offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight」以及「getComputedStyle」 方法。因为浏览器需要实时计算最新的值,会进行回流。

优化方案有:

  • 缓存特定属性的值,防止频繁获取导致频繁回流。
  • 避免逐条改变样式,使用类名去合并样式。
  • 将DOM离线。也就是display: none

减少DOM操作

使用document.fragment来减少DOM操作。

代码语言:javascript复制
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)

Flush队列

当频繁回流或者重绘的时候,浏览器缓存一个flush队列,把我们触发的回流与重绘任务都塞进去,待到队列里的任务多起来、或者达到了一定的时间间隔,或者“不得已”的时候,再将这些任务一口气出队。

而获取特定属性的值就是不得已的时候,因此要避免频繁获取。

懒加载

懒加载:它是针对图片加载时机的优化。当页面上图片较多时,如果不做额外的处理,浏览器会将所有资源进行加载。如此会造成白屏、卡顿的不良影响。

懒加载的核心思路是:当元素出现在可视区域内,style 内联样式中的背景图片属性从 none 变成了一个在线图片的 URL。

在懒加载的实现中,有两个关键的数值:一个是「当前可视区域的高度」,另一个是「元素距离可视区域顶部的高度」

「当前可视区域的高度」, 在和现代浏览器及 IE9 以上的浏览器中,可以用 window.innerHeight 属性获取。

「元素距离可视区域顶部的高度」,选用 getBoundingClientRect() 方法来获取返回元素的大小及其相对于视口的位置。

代码语言:javascript复制
// 获取所有图片标签
const imgs = document.getElementsByTagName('img');
const viewHeight = window.innerHeight;
// 元素距离顶部的距离比可是区域高度小,就意味着元素可以被用户看见,需要加载
const isView = (e) => (viewHeight - e.getBoundingClientRect().top) >= 0

// 记录检查到哪一张图片
let num = 0;
const lazyload = () => {
  for (let i = num; i < imgs.length; i  ) {
    let currentEl = imgs[i];
    if (isView(currentEl)) {
      currentEl.src = currentEl.dataset.src;
      num = i   1;
    }
  }
}

// 监听Scroll事件
window.addEventListener('scroll', lazyload, false);

需要注意的是,上面监听的滚动事件会频繁触发,因此需要进行防抖处理。

防抖与节流

原生事件中,有许多事件容易频繁触发。比如scroll 事件、resize事件、鼠标事件、键盘事件等等。频繁触发回调导致的大量计算会引发页面的抖动甚至卡顿。因此需要防抖和节流来「限制触发的频率」

节流

所谓的“节流”,是通过在一段时间内「无视后来产生的回调请求」来实现的。也就是说,一段时间内,以「第一个请求」为准。这段时间所有的其他请求都被忽略。

代码语言:javascript复制
function throttle(fn, interval) {
  // 通过闭包保存上一次触发回调的时间
  let last = 0;

  return function() {
    // 保留调用时的this上下文
    let context = this;
    // 保留调用时传入的参数
    let args = arguments;
    // 记录本次触发回调的时间
    let now =  new Date();

    // 时间间隔大于指定时间阈值,则触发回调
    if (now - last >= interval) {
      last = now;
      fn.apply(context, args);
    }
  }
}

const better_scroll = throttle(() => console.log('done'), 1000);

document.addEventListener('scroll', better_scroll);

防抖

所谓“防抖”,就是在某段时间内,不管你触发了多少次回调,都只认最后一次。也就是说,一段时间内,以「最后一个请求」为准。

代码语言:javascript复制
function debounce(fn, delay) {
  let timer = null

  return function() {
    let context = this;
    let args = arguments;

    if (timer) {
      clearTimeout(timer)
    }
    timer = setTimeout(() => {
      fn.apply(context, args)
    }, delay)
  }
}

const better_scroll = debounce(() => console.log('done'), 1000)

document.addEventListener('scroll', better_scroll);

节流和防抖合体

代码语言:javascript复制
// 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)

总结

节流是技能需要冷却,再次使用需要等待;防抖是打断技能释放,以最后一次释放为准。

总结

上面是阅读《前端性能优化原理与实践》小册后所总结的所有性能优化的知识点,希望对看到本文的你有所帮助。

Reference

[1]

webpack-bundle-analyzer: https://www.npmjs.com/package/webpack-bundle-analyzer

0 人点赞