前端性能优化

2021/2/25

# Heading

参考链接:

# 性能指标

# 加载性能

一个网站加载性能如何主要看白屏时间和首屏时间。

  • 白屏时间:指从输入网址,到页面开始显示内容的时间。
  • 首屏时间:指从输入网址,到页面完全渲染的时间。

将以下脚本放在 前面就能获取白屏时间。

<script>
  new Date() - performance.timing.navigationStart;
</script>
1
2
3

在 window.onload 事件里执行 new Date() - performance.timing.navigationStart 即可获取首屏时间。

# 运行性能

配合 chrome 的开发者工具,我们可以查看网站在运行时的性能。

打开网站,按 F12 选择 performance,点击左上角的灰色圆点,变成红色就代表开始记录了。这时可以模仿用户使用网站,在使用完毕后,点击 stop,然后你就能看到网站运行期间的性能报告。如果有红色的块,代表有掉帧的情况;如果是绿色,则代表 FPS 很好。

# 打包方式

//由以下整体打包
() => import(/* webpackMode: "lazy-once" */"@/views/page" + url)
//改为多入口分块打包
() => import(/* webpackChunkName: "chunkName1" */`@/views/page1`
() => import(/* webpackChunkName: "chunkName1" */`@/views/page2`
1
2
3
4
5

提升第一个菜单页的加载速度。

# 点击时加载

  1. 数据请求提前。对于 vue,一些必要数据的后端请求由mounted提前到created
  2. 弹框、下拉框等不可见组件可以通过v-if开关调整为第一次点击时加载,提升首屏速度,如:
<template>
  <div v-if="renderOnDoc">
    <!-- component -->
  </div>
</template>
<script>
export default {
  name: "PreviewPDF",
  model: {
    prop: "showPDF",
    event: "on-close",
  },
  props: {
    showPDF: {
      type: Boolean,
      default: false,
    },
  },
  data() {
    return {
      renderOnDoc: false,
      show: false,
    };
  },
  watch: {
    showPDF(value) {
      if (!!value) {
        this.renderOnDoc = true;
      }
      this.show = value;
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

# 打包压缩

  1. npm install --save-dev compression-webpack-plugin
  2. webpack 配置开启productionGzip
  3. nginx 打开 gzip 压缩开关
    1. gzip on;
    2. gzip_types text/plain application/x-javascript text/css application/xml application/javascript application/json text/xml 'application/json;charset=utf-8';
  4. webpack 配置关闭生产环境的 SourceMapproductionSourceMap

# 使用服务端渲染

  • 客户端渲染: 获取 HTML 文件,根据需要下载 JavaScript 文件,运行文件,生成 DOM,再渲染。

  • 服务端渲染:服务端返回 HTML 文件,客户端只需解析 HTML。

    • 优点:首屏渲染快,SEO 好;更快的内容到达时间 (time-to-content)。
    • 缺点:配置麻烦,增加了服务器的计算压力。

# 减少 HTTP 请求

一个完整的 HTTP 请求需要经历 DNS 查找,TCP 握手,浏览器发出 HTTP 请求,服务器接收请求,服务器处理请求并发回响应,浏览器接收响应等过程。 对于 HTTP1.1,可并发使用的 TCP 通道有限(chrome 为 6 个), 建议将多个小文件合并为一个大文件,从而减少 HTTP 请求次数。

# 使用 HTTP2

HTTP2 相比 HTTP1.1 有如下几个优点:

  1. 解析速度快

服务器解析 HTTP1.1 的请求时,必须不断地读入字节,直到遇到分隔符 CRLF 为止。而解析 HTTP2 的请求就不用这么麻烦,因为 HTTP2 是基于帧的协议,每个帧都有表示帧长度的字段。

  1. 多路复用

HTTP1.1 如果要同时发起多个请求,就得建立多个 TCP 连接,因为一个 TCP 连接同时只能处理一个 HTTP1.1 的请求。

在 HTTP2 上,多个请求可以共用一个 TCP 连接,这称为多路复用。同一个请求和响应用一个流来表示,并有唯一的流 ID 来标识。 多个请求和响应在 TCP 连接中可以乱序发送,到达目的地后再通过流 ID 重新组建。

  1. 首部压缩

HTTP2 提供了首部压缩功能。 HTTP2 在客户端和服务器端使用首部表来跟踪和存储之前发送的键-值对,对于相同的数据,不再通过每次请求和响应发送。

  1. 优先级

HTTP2 可以对比较紧急的请求设置一个较高的优先级,服务器在收到这样的请求后,可以优先处理。

  1. 流量控制

由于一个 TCP 连接流量带宽(根据客户端到服务器的网络带宽而定)是固定的,当有多个请求并发时,一个请求占的流量多,另一个请求占的流量就会少。流量控制可以对不同的流的流量进行精确控制。

  1. 服务器推送

HTTP2 新增的一个强大的新功能,就是服务器可以对一个客户端请求发送多个响应。换句话说,除了对最初请求的响应外,服务器还可以额外向客户端推送资源,而无需客户端明确地请求。

例如当浏览器请求一个网站时,除了返回 HTML 页面外,服务器还可以根据 HTML 页面中的资源的 URL,来提前推送资源。

# 静态资源使用 CDN

如果服务器是部署在客户内网的,就不存在这个问题。
内容分发网络(CDN)是一组分布在多个不同地理位置的 Web 服务器。我们都知道,当服务器离用户越远时,延迟越高。CDN 就是为了解决这一问题,在多个位置部署服务器,让用户离服务器更近,从而缩短请求时间。

# 将 CSS 放在文件头部,JavaScript 文件放在底部

所有放在 head 标签里的 CSS 和 JS 文件都会堵塞渲染。如果这些 CSS 和 JS 需要加载和解析很久的话,那么页面就空白了。所以 JS 文件要放在底部,等 HTML 解析完了再加载 JS 文件。

那为什么 CSS 文件还要放在头部呢?
因为先加载 HTML 再加载 CSS,会让用户第一时间看到的页面是没有样式的、“丑陋”的,为了避免这种情况发生,就要将 CSS 文件放在头部了。
另外,JS 文件也不是不可以放在头部,只要给 script 标签加上 defer 属性就可以了,异步下载,延迟执行。

# 使用字体图标 iconfont 代替图片图标

  1. 字体图标就是将图标制作成一个字体,使用时就跟字体一样,可以设置属性,例如 font-size、color 等等,非常方便。并且字体图标是 svg 矢量图,不会失真。还有一个优点是生成的文件特别小。
  2. 使用 fontmin-webpack (opens new window) 插件对字体文件进行压缩

# 善用缓存,不重复加载相同的资源

  1. nginx 对于静态文件设置缓存,添加 Expires 或 max-age(图片类型可以设置更长失效时间)
  2. 当文件更新了怎么办?怎么通知浏览器重新请求文件?
    1. @TODO 把资源地址 URL 的修改与文件内容关联起来

# 图片优化

  1. 图片延迟加载 在页面中,先不给图片设置路径,只有当图片出现在浏览器的可视区域时,才去加载真正的图片,这就是延迟加载。对于图片很多的网站来说,一次性加载全部图片,会对用户体验造成很大的影响,所以需要使用图片延迟加载。
  2. 响应式图片 响应式图片的优点是浏览器能够根据屏幕大小自动加载合适的图片。
<picture>
  <source srcset="banner_w1000.jpg" media="(min-width: 801px)" />
  <source srcset="banner_w800.jpg" media="(max-width: 800px)" />
  <img src="banner_w800.jpg" alt="" />
</picture>
1
2
3
4
5
@media (min-width: 769px) {
  .bg {
    background-image: url(bg1080.jpg);
  }
}
@media (max-width: 768px) {
  .bg {
    background-image: url(bg768.jpg);
  }
}
1
2
3
4
5
6
7
8
9
10
  1. 调整图片大小 可以用两张图片来实行优化。一开始,只加载缩略图,当用户悬停在图片上时,才加载大图进行替换。

  2. 降低图片质量 JPG 格式的图片,100% 的质量和 90% 质量的通常看不出来区别,尤其是用来当背景图的时候。我经常用 PS 切背景图时, 将图片切成 JPG 格式,并且将它压缩到 60% 的质量,基本上看不出来区别。

压缩方法有两种,一是通过 webpack 插件 image-webpack-loader,二是通过在线网站进行压缩。 webpack 插件 image-webpack-loader 的用法:

{
  test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
  use:[
    {
    loader: 'url-loader',
    options: {
      limit: 10000, /* 图片大小小于1000字节限制时会自动转成 base64 码引用*/
      name: utils.assetsPath('img/[name].[hash:7].[ext]')
      }
    },
    /*对图片进行压缩*/
    {
      loader: 'image-webpack-loader',
      options: {
        bypassOnDebug: true,
      }
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  1. 尽可能利用 CSS3 效果代替图片 有很多图片使用 CSS 效果(渐变、阴影等)就能画出来,这种情况选择 CSS3 效果更好。因为代码大小通常是图片大小的几分之一甚至几十分之一。

  2. 使用 webp 格式的图片 WebP 相对于 PNG、JPG 有什么优势? (opens new window)

    WebP 的优势体现在它具有更优的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量;同时具备了无损和有损的压缩模式、Alpha 透明以及动画的特性,在 JPEG 和 PNG 上的转化效果都相当优秀、稳定和统一。

# 提取第三方库

由于引入的第三方库一般都比较稳定,不会经常改变。所以将它们单独提取出来,作为长期缓存是一个更好的选择。 这里需要使用 webpack4 的 splitChunk 插件 cacheGroups 选项。

optimization: {
      runtimeChunk: {
        name: 'manifest' // 将 webpack 的 runtime 代码拆分为一个单独的 chunk。
    },
    splitChunks: {
        cacheGroups: {
            vendor: {
                name: 'chunk-vendors',
                test: /[\\/]node_modules[\\/]/,
                priority: -10,
                chunks: 'initial'
            },
            common: {
                name: 'chunk-common',
                minChunks: 2,//在分割之前,这个代码块最小应该被引用的次数
                priority: -20,//针对cacheGroups ,表示抽取权重,数字越大表示优先级越高。
                chunks: 'initial',
                reuseExistingChunk: true
            }
        },
    }
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 减少重绘重排

# 浏览器渲染过程

  1. 解析 HTML 生成 DOM 树。
  2. 解析 CSS 生成 CSSOM 规则树。
  3. 将 DOM 树与 CSSOM 规则树合并在一起生成渲染树。
  4. 遍历渲染树开始布局,计算每个节点的位置大小信息。
  5. 将渲染树每个节点绘制到屏幕。

DOMTree

# 重排&重绘

  • 重排:当改变 DOM 元素位置或大小时,会导致浏览器重新生成渲染树,这个过程叫重排。
  • 重绘:当重新生成渲染树后,就要将渲染树每个节点绘制到屏幕,这个过程叫重绘。不是所有的动作都会导致重排,例如改变字体颜色,只会导致重绘。记住,重排会导致重绘,重绘不会导致重排 。

重排和重绘这两个操作都是非常昂贵的,因为 JavaScript 引擎线程与 GUI 渲染线程是互斥,它们同时只能一个在工作

# 什么操作会导致重排?

  • 添加或删除可见的 DOM 元素
  • 元素位置改变
  • 元素尺寸改变
  • 内容改变
  • 浏览器窗口尺寸改变

# 如何减少重排重绘?

  • 用 JavaScript 修改样式时,最好不要直接写样式,而是替换 class 来改变样式
  • 如果要对 DOM 元素执行一系列操作,可以将 DOM 元素脱离文档流,修改完成后,再将它带回文档。推荐使用隐藏元素(display:none)文档碎片(DocumentFragement),都能很好的实现这个方案。

# 使用事件代理(事件委托)

事件代理即是利用事件冒泡的机制把里层所需要响应的事件绑定到外层,只指定一个事件处理程序,就可以管理某一类型的所有事件。所有用到按钮的事件(多数鼠标事件和键盘事件)都适合采用事件委托技术。 优点:

  1. 可以大量节省内存占用,减少事件注册
  2. 当新增或删除子对象时无需再次对其绑定(动态绑定事件)
<ul>
  <li>苹果</li>
  <li>香蕉</li>
  <li>凤梨</li>
</ul>
1
2
3
4
5
// good
document.querySelector("ul").onclick = (event) => {
  const target = event.target;
  if (target.nodeName === "LI") {
    console.log(target.innerHTML);
  }
};

// bad
document.querySelectorAll("li").forEach((e) => {
  e.onclick = function() {
    console.log(this.innerHTML);
  };
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14

使用“事件委托”时,并不是说把事件委托给的元素越靠近顶层就越好。事件冒泡的过程也需要耗时,越靠近顶层,事件的”事件传播链”越长,也就越耗时。如果 DOM 嵌套结构很深,事件冒泡通过大量祖先元素会导致性能损失。

# 程序的局部性

一个编写良好的计算机程序常常具有良好的局部性,它们倾向于引用最近引用过的数据项附近的数据项,或者最近引用过的数据项本身,这种倾向性,被称为局部性原理。有良好局部性的程序比局部性差的程序运行得更快。

# 局部性通常有两种不同的形式:

  • 时间局部性:在一个具有良好时间局部性的程序中,被引用过一次的内存位置很可能在不远的将来被多次引用。
  • 空间局部性 :在一个具有良好空间局部性的程序中,如果一个内存位置被引用了一次,那么程序很可能在不远的将来引用附近的一个内存位置。

# 时间局部性示例

function sum(arry) {
  let i,
    sum = 0;
  let len = arry.length;

  for (i = 0; i < len; i++) {
    sum += arry[i];
  }

  return sum;
}
1
2
3
4
5
6
7
8
9
10
11

在这个例子中,变量 sum 在每次循环迭代中被引用一次,因此,对于 sum 来说,具有良好的时间局部性

# 空间局部性示例

先生成一个 10000*10000 的数组

//引用lodash,可以在https://lodash.com/docs/4.17.15#range 测试
let a = [];
for (let i = 0; i <= 10000; i++) {
  a[i] = _.shuffle(_.range(0, 10000));
}
1
2
3
4
5
// 具有良好空间局部性的程序
function sum1(arry, rows, cols) {
  let i,
    j,
    sum = 0;

  for (i = 0; i < rows; i++) {
    for (j = 0; j < cols; j++) {
      sum += arry[i][j];
    }
  }
  return sum;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// 空间局部性差的程序
function sum2(arry, rows, cols) {
  let i,
    j,
    sum = 0;

  for (j = 0; j < cols; j++) {
    for (i = 0; i < rows; i++) {
      sum += arry[i][j];
    }
  }
  return sum;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

测试环境:

  • Google Chrome:88.0.4324.190
  • 操作系统 Windows 10 OS Version 1909 (Build 18363.1316)
  • JavaScript V8 8.8.278.17 测试结果如下: sum1 sum2 从以上测试结果来看,步长为 1 的数组执行时间比步长为 10000 的数组快了一倍。

# 总结:

  • 重复引用相同变量的程序具有良好的时间局部性
  • 对于具有步长为 k 的引用模式的程序,步长越小,空间局部性越好;而在内存中以大步长跳来跳去的程序空间局部性会很差

# 查找表

当条件语句特别多时,使用 switch 和 if-else 不是最佳的选择,这时不妨试一下查找表。查找表可以使用数组对象来构建。

switch (index) {
  case "0":
    return result0;
  case "1":
    return result1;
  case "2":
    return result2;
  case "3":
    return result3;
  case "4":
    return result4;
  case "5":
    return result5;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

可以将这个 switch 语句转换为查找表

const results = [result0, result1, result2, result3, result4, result5];

return results[index];
1
2
3

如果条件语句不是数值而是字符串,可以用对象来建立查找表

const map = {
  red: result0,
  green: result1,
};

return map[color];
1
2
3
4
5
6

# 避免页面卡顿

60fps 与设备刷新率

目前大多数设备的屏幕刷新率为 60 次/秒。因此,如果在页面中有一个动画或渐变效果,或者用户正在滚动页面,那么浏览器渲染动画或页面的每一帧的速率也需要跟设备屏幕的刷新率保持一致。 其中每个帧的预算时间仅比 16 毫秒多一点 (1 秒/ 60 = 16.66 毫秒)。但实际上,浏览器有整理工作要做,因此您的所有工作需要在 10 毫秒内完成。如果无法符合此预算,帧率将下降,并且内容会在屏幕上抖动。 此现象通常称为卡顿,会对用户体验产生负面影响。

假如你用 JavaScript 修改了 DOM,并触发样式修改,经历重排重绘最后画到屏幕上。如果这其中任意一项的执行时间过长,都会导致渲染这一帧的时间过长,平均帧率就会下降。假设这一帧花了 50 ms,那么此时的帧率为 1s / 50ms = 20fps,页面看起来就像卡顿了一样。 对于一些长时间运行的 JavaScript,我们可以使用定时器进行切分,延迟执行。

for (let i = 0, len = arry.length; i < len; i++) {
  process(arry[i]);
}
1
2
3

假设上面的循环结构由于 process() 复杂度过高或数组元素太多,甚至两者都有,可以尝试一下切分。

const todo = arry.concat();
setTimeout(function() {
  process(todo.shift());
  if (todo.length) {
    setTimeout(arguments.callee, 25);
  } else {
    callback(arry);
  }
}, 25);
1
2
3
4
5
6
7
8
9

# 使用 requestAnimationFrame 来实现视觉变化

从第 16 点我们可以知道,大多数设备屏幕刷新率为 60 次/秒,也就是说每一帧的平均时间为 16.66 毫秒。在使用 JavaScript 实现动画效果的时候,最好的情况就是每次代码都是在帧的开头开始执行。而保证 JavaScript 在帧开始时运行的唯一方式是使用 requestAnimationFrame

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行

/**
 * If run as a requestAnimationFrame callback, this
 * will be run at the start of the frame.
 */
function updateScreen(time) {
  // Make visual updates here.
}

requestAnimationFrame(updateScreen);
1
2
3
4
5
6
7
8
9

如果采取 setTimeout 或 setInterval 来实现动画的话,回调函数将在帧中的某个时点运行,可能刚好在末尾,而这可能经常会使我们丢失帧,导致卡顿。

requestAnimationFrame 详解 (opens new window) requestAnimationFrame 比起 setTimeout、setInterval 的优势主要有两点:

  1. requestAnimationFrame 会把每一帧中的所有 DOM 操作集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率,一般来说,这个频率为每秒 60 帧。
  2. 在隐藏或不可见的元素中,requestAnimationFrame 将不会进行重绘或回流,这当然就意味着更少的的 cpu,gpu 和内存使用量。

optimize-javascript-execution (opens new window)

# 使用 Web Workers

Web Worker 使用其他工作线程从而独立于主线程之外,它可以执行任务而不干扰用户界面。一个 worker 可以将消息发送到创建它的 JavaScript 代码, 通过将消息发送到该代码指定的事件处理程序(反之亦然)。

Web Worker 适用于那些处理纯数据,或者与浏览器 UI 无关的长时间运行脚本。

示例如下
<!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>
</head>

<body>
    <input id='main1' type="text">
    <input id='main2' type="text">
    <input id= 'worker' type="text">
    <script>
        const myWorker = new Worker('./worker.js');
        const main1 = document.getElementById('main1')
        const main2 = document.getElementById('main2')
        const worker = document.getElementById('worker')
        myWorker.onmessage = function (e) {
            console.log('Message received from worker', e);
            worker.value = e.data
        }
        main1.addEventListener('change',e => Post(e.target.value,main2.value))
        main2.addEventListener('change',e => Post(main1.value,e.target.value))

        function Post(a,b){
            myWorker.postMessage([+a,+b]);
            console.log('Main postMessage:',[+a,+b])
        }
    </script>
</body>

</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
onmessage = function (e) {
    console.log('Message received from main script', e);
    var workerResult = e.data[0] * e.data[1];
    postMessage(workerResult);
    console.log('Worker postMessage:', workerResult)
}
1
2
3
4
5
6

不过在 worker 内,不能直接操作 DOM 节点,也不能使用 window 对象的默认方法和属性。然而你可以使用大量 window 对象之下的东西,包括 WebSockets,IndexedDB 以及 FireFox OS 专用的 Data Store API 等数据存储机制。

# 使用位操作

JavaScript 中的数字都使用 IEEE-754 标准以 64 位格式存储。但是在位操作中,数字被转换为有符号的 32 位格式。即使需要转换,位操作也比其他数学运算和布尔操作快得多

JavaScript 位运算,也许并没有那么快 (opens new window) 尽量不要在 JS 中使用位运算 (opens new window)

# 不要覆盖原生方法

无论你的 JavaScript 代码如何优化,都比不上原生方法。因为原生方法是用低级语言写的(C/C++),并且被编译成机器码,成为浏览器的一部分。当原生方法可用时,尽量使用它们,特别是数学运算和 DOM 操作。

# 降低 CSS 选择器的复杂性

  1. 浏览器读取选择器,遵循的原则是从选择器的右边到左边读取
#block .text p {
  color: red;
}
1
2
3
  1. 1.查找所有 P 元素。

  2. 2.查找结果 1 中的元素是否有类名为 text 的父元素

  3. 3.查找结果 2 中的元素是否有 id 为 block 的父元素

  4. CSS 选择器优先级 内联 > ID 选择器 > 类选择器 > 标签选择器

根据以上两个信息可以得出结论:

  1. 1.选择器越短越好。
  2. 2.尽量使用高优先级的选择器,例如 ID 和类选择器。
  3. 3.避免使用通配符 *。

CSS 无需过度优化,因为本身耗时较小,选择器之间的性能差异也较小。 Appendix 1: CSS selector performance (opens new window)

# 使用 flexbox 而不是较早的布局模型

在早期的 CSS 布局方式中我们能对元素实行绝对定位、相对定位或浮动定位。而现在,我们有了新的布局方式 flexbox,它比起早期的布局方式来说有个优势,那就是性能比较好。 不过 flexbox 兼容性还是有点问题,不是所有浏览器都支持它,所以要谨慎使用。

避免大型、复杂的布局和布局抖动 (opens new window)

# 使用 transform 和 opacity 属性更改来实现动画

在 CSS 中,transforms 和 opacity 这两个属性更改不会触发重排与重绘,它们是可以由合成器(composite)单独处理的属性。

坚持仅合成器的属性和管理层计数 (opens new window)