浅谈前端性能优化

466 阅读21分钟

写在最前

在掘金也白嫖了两年多...从来没有发过一篇文章,想着不能一直白嫖,于是就把去年记在博客一篇关于性能优化的文章传了上来也没改动过,单纯就想水本人的第一篇掘金hhhh。有问题可以交流。

前言

对于C端组,做出来的产品是直接给用户看的最直接与用户交互的。因此要给用户较为满意的体验,那么性能优化尤其重要。我曾看见一篇文章写,亚马逊减少了100ms的白屏时间,销量增加1% ! 由此可见性能优化的重要性了吧。

曾经有一天我有个好朋友拿着它的博客兴致冲冲拿给我看:"好兄弟,我博客上线了快来看看." 我点开 过了10s我终于进去了。我:"好兄弟你博客没做优化嘛?我等了10s"。 他:"我没怎么做优化也不知道如何做 ,你能教教我不?" 好了这就是为什么要写这篇文章的理由之一了。

还有就是,说起来我当初在面试的时候,我上去就跟主管吹牛说我的玩具项目,性能优化做了什么什么,什么图片懒加载,预加载乱七八糟的一顿输出,瞎讲我深深意识到我当初知识有多浅薄,现在想想就丢人。主管反问你性能优化看什么指标?你怎么判断的,怎么做。以及骨架屏原理有没有更好的实现方案等等。现在想想我当初的回答真实菜的不行啊。最后主管说:"如果有缘的话,来了再教你性能优化的东西"。现在学了点东西,分享给大家。

说说什么是RAIL模型

rail模型是谷歌提出来的一种针对性能优化的模型,他可以拆解成四个不同的词语:

1、r:response(响应)

目标:在 100 毫秒内完成由用户输入启动的转换,让用户感觉交互是即时的。

准则:

为确保 100 毫秒内的可见响应,请在 50 毫秒内处理用户输入事件。 这适用于大多数输入,例如单击按钮、切换表单控件或启动动画。 这不适用于触摸拖动或滚动。

2、a:animation(动画)

目标:

在 10 毫秒或更短的时间内生成动画中的每一帧。 从技术上讲,每帧的最大预算为 16 毫秒(1000 毫秒/每秒 60 帧≈16 毫秒),但浏览器需要大约 6 毫秒来渲染每帧,因此每帧 10 毫秒的准则。

准则:

在像动画这样的高压点中,关键是在你能做的地方什么都不做,在你不能做的地方绝对最少。 尽可能利用 100 毫秒响应预先计算昂贵的工作,以便最大限度地提高达到 60 fps 的机会。

3、i:idle(浏览器空闲时间)

目标:最大化空闲时间以增加页面在 50 毫秒内响应用户输入的几率。

准则:

利用空闲时间完成延期工作。 例如,对于初始页面加载,加载尽可能少的数据,然后使用空闲时间加载其余的数据。

4、l:load(加载)

目标:

优化与用户的设备和网络功能相关的快速加载性能。 目前,首次加载的一个很好的目标是加载页面并在 5 秒或更短的时间内在 3G 连接速度较慢的中端移动设备上进行交互。

对于后续加载,一个好的目标是在 2 秒内加载页面。

链路分析

在做性能优化前简单回顾一下,输入url到显示应该经历了什么过程(简述):

1.输入网址url

2.缓存解析(浏览器缓存-系统缓存-路由器缓存)等

3.域名解析(DNS解析,域名到ip地址的解析)

4.TCP连接,三次握手

5.服务器接收到浏览器发送的请求信息,返回响应头与响应体

6.页面渲染-浏览器接收到响应信息后,进行客户端渲染,生成DOM树、解析CSS样式对JS进行交互。

浏览器前端解析(简述):

1.根据HTML解析出DOM树

2.根据CSS解析成CSS规则树

3.结合DOM和CSS规则树,生成渲染树

4.根据渲染树计算每一个节点的信息

5.根据计算好的信息绘制页面。

当然这边写的其实还是很粗略的,还有各种合成线程,还有光栅化的各种过程这里就不写了。

为什么要在讲性能优化前,分析链路?因为从每个链路中,每一个步骤,每一个节点。都是可以有能让我们进行优化的地方的。

优化方向

将用户体验提升分为两个内容来提升:

性能优化

这个其实就是实打实的,真实的对项目进行优化,那我们要确定对应的指标,监控指标,分析优化,数据追踪等。这是要能看得见的。有数据比如首屏时间从6s->1s这样的优化需要数据真实存在的。

品质提升

这就是对用户体验感的提升了。比如你访问一些项目的时候,顶上有一些小进度条nprogress对就是这个我也很爱用的东西。或者说刚进入的时候有一些骨架屏或者loading.gif等 让用户体验起来更舒适,让用户知道这个东西在加载中... 但说实话其实这些东西本质上,对项目性能优化是没有提升的,其实也占用了额外的资源,但是我们的初衷是服务于用户呀,用户体验起来爽占据点资源又何妨?用户就是我们的上帝(bushi)。

性能指标

性能优化不是你感觉起来快他性能就好的。分析性能优化的首要基础是数据和指标。所以我们得知道一下市面上有什么样的指标?要怎么看。

Performance API

这个是HTML5新增的API,可供查询早期看见有些人在页面加载时,的head里面添加上一段获取时间戳的代码,然后在开始获取数据的时候在获取一次时间戳,相减来计算白屏时间这其实是一种非常麻烦的做法,并不友好。因此W3C后面推出了Performance这个API来帮助开发者查看这些性能时间点。

直接F12.控制台输入window.perforamce.timing

你可以看到一大堆这些属性,细心的你一定会发现这是成对出现的没错。下面把具体的都写在底下了。

const timingInfo = window.performance.timing;
// DNS解析,DNS查询耗时
timingInfo.domainLookupEnd - timingInfo.domainLookupStart;
// TCP连接耗时
timingInfo.connectEnd - timingInfo.connectStart;
// 获得首字节耗费时间,也叫TTFB
timingInfo.responseStart - timingInfo.navigationStart;
// domReady时间(与DomContentLoad事件对应)
timingInfo.domContentLoadedEventStart - timingInfo.navigationStart;
// DOM资源下载
timingInfo.responseEnd - timingInfo.responseStart;
// 准备新页面时间耗时
timingInfo.fetchStart - timingInfo.navigationStart;
// 重定向耗时
timingInfo.redirectEnd - timingInfo.redirectStart;
// Appcache 耗时
timingInfo.domainLookupStart - timingInfo.fetchStart;
// unload 前文档耗时
timingInfo.unloadEventEnd - timingInfo.unloadEventStart;
// request请求耗时
timingInfo.responseEnd - timingInfo.requestStart;
// 请求完毕至DOM加载
timingInfo.domInteractive - timingInfo.responseEnd;
// 解释dom树耗时
timingInfo.domComplete - timingInfo.domInteractive;
// 从开始至load总耗时
timingInfo.loadEventEnd - timingInfo.navigationStart;
// 白屏时间
timingInfo.responseStart - timingInfo.fetchStart;
// 首屏时间
timingInfo.domComplete - timingInfo.fetchStart;

感官性能优化指标

1.FP

FP(First Paint)翻译为首次绘制,表示浏览器第一次向屏幕传输像素的时间点,可以理解为浏览器首次开始绘制像素,页面首次在屏幕上发生了视觉变化 。听起来是不是很烦人?不过问题不大 你只要知道有这么个东西就行了,因为这个指标有虽然有但没啥子意义可言。

2.FCP

FCP(First Contentful Paint):简单来说就是浏览器首次绘制屏幕内容的时间,包括(任何文本,图像,SVG等等)。这个指标就是我们常说的白屏时间

3.FMP

FMP(First Meaningful Paint)首次进行有意义的绘制,这个指标反应就是主要内容出现在页面上要的时间,FMP的本质是一个主观认知的指标,是用一个算法来计算那个时间点是FMP,计算方式有点复杂...这算法我第一遍也看不懂,视情况后续会讲给大家听。我们网易这边目前看的指标是FMP,文档中我看见的是...但是这个指标不一定准确。

4.LCP

LCP(Largest Contentful Paint)翻译为最大内容绘制,用于记录首屏中最大元素渲染的时间,和 FCP 不同的是,FCP 更关注浏览器什么时候开始绘制内容,比如一个 loading 页面或者骨架屏,并没有实际价值,所以 LCP 相较于 FCP 更适合作为首屏指标。我有个好朋友在阿里那边,他们组的指标主要是看LCP,但我们组这边觉得LCP在设备兼容性上还不够完善,目前只在安卓8以上,以及pc上chrome支持。

5.TTI

TTI(Time to Interactive)翻译为整体链接耗时,可交互时间,等到服务器通过HTTP协议将响应全部返回之后,便开始DOM Tree 的构建,完成之后,网页变成可交互状态,到此为止便是网页的可交互时间。用户可以进行正常的事件输入交互操作。

性能优化的检测工具

1.DevTools

2.LightHouse

3.Webpagetest

性能优化部分实践

图片资源相关

适当调整图片的分辨率

对于图片而言,分辨率是影响图片大小的重要因素之一,较高的分辨率将影响页面的加载和解析速度。

图片分辨率与实际展示需要相对应,为用户提供"合适"的图片能够帮助用户获得较为流畅的预览体验,减少不必要的网络流量开销。

在此项得分的计算中,Lighthouse比较了页面中图片元素的渲染大小与加载图片资源的分辨率,同时与设备像素比DPR(opens new window)相关

提供响应式图片
由CDN提供适当大小图片

部分图片CDN支持通过配置图片资源地址的URL参数,实现图片的缩放和裁剪等变换。比如腾讯云的COS存储对象,可以在图片的末尾输入参数来实现选择图片的大小和清晰度。

使用canvas实现本地图片的压缩

对于需要从本地上传的图片,可以使用Canvas API(opens new window)对原始图片进行裁剪,减少网络传输成本。示例:

/**
 * 压缩原图分辨率
 * @params {Object} 承载原始图像的canvas对象
 * @return {Object} 承载压缩后图像的canvas对象
 */
function getCompressedCanvas(canvas) {
    const { width, height } = canvas;

    // 压缩比例
    const COMPRESSED_RATIO = 0.5;

    const cover = document.createElement("canvas");
    cover.width = width * COMPRESSED_RATIO;
    cover.height = height * COMPRESSED_RATIO;

    var coverCtx = cover.getContext("2d");
    coverCtx.drawImage(canvas, 0, 0, cover.width, cover.height);

    return cover;
}

图片懒加载

图片懒加载相信是大家都熟悉的懒加载方案之一了。简单来说对于无需立即在首屏展示的图片进行延迟加载,有利于提高页面载入的速度,帮助用户减少不必要的网络损耗。

图片懒加载的实现方案:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>手写图片懒加载</title>
  </head>
  <style>
    .container {
      width: 1000px;
      margin: 0 auto;
      background-color: pink;
    }
    .container > img {
      display: block;
      width: 400px;
      height: 400px;
      margin-bottom: 50px;
    }
  </style>
  <body>
    <div class="container">
      <img src="./img/loading.jpg" data-src="./img/pic.png" />
      <img src="./img/loading.jpg" data-src="./img/pic.png" />
      <img src="./img/loading.jpg" data-src="./img/pic.png" />
      <img src="./img/loading.jpg" data-src="./img/pic.png" />
      <img src="./img/loading.jpg" data-src="./img/pic.png" />
      <img src="./img/loading.jpg" data-src="./img/pic.png" />
    </div>
    <script>
      var imgs = document.querySelectorAll("img");
      function lazyLoad() {
        var scrollTop =
          document.body.scrollTop || document.documentElement.scrollTop;
        var winHeight =
          window.innerHeight || document.documentElement.clientHeight;
        for (var i = 0; i < imgs.length; i++) {
          //视口的高度-图片距离视口顶部的高度
          const viewPortHeight =
            winHeight - imgs[i].getBoundingClientRect().top;
          //如果是个正数的话证明当前视口已经到达可视区当中
          if (viewPortHeight > 0) {
            imgs[i].src = imgs[i].getAttribute("data-src");
          }
        }
      }
      function _debounce(fn, delay) {
        let timer = null;
        return function () {
          let arg = [...arguments];
          let context = this;
          if (timer) {
            clearTimeout(timer);
            timer = null;
          }
          timer = setTimeout(() => {
            fn.call(context, ...arg);
          }, delay);
        };
      }
      //在页面加载的时候先加载一次
      window.onload = lazyLoad;
      //监听页面的scroll
      window.addEventListener("scroll", _debounce(lazyLoad, 200));
      //浏览器窗口大小改变时重新计算
      window.addEventListener("resize", _debounce(lazyLoad, 200));
      //window.onscroll = lozyLoad();
    </script>
  </body>
</html>

当然现在也可以用:

这两个api来实现这里暂时就不多写了。

图片预加载:

图片预加载,适用于特定一些场景,比如我之前开发的一个win11的项目。我们本来就想模拟电脑开机那个loading动画,所以我们特意在访问我们的项目地址时,加上了loading动画,但那个win11其实是个切图项目用了一堆的图片资源,所以在开机loading的时候,顺带一起做图片的预加载是很合理的一个选择,简单地把图片预加载的实现方式我放在底下:

const imgs = [
    'https://yanxuan.nosdn.127.net/static-union/1658112868f03ef1.gif',
]

const loader = url => {
    return new Promise((resolve, reject) => {
        const image = new Image();
        image.onload = () => {
            console.log('@success');
            resolve();
        }
        image.onerror = () => {
            console.log('@error');
            reject();
        }
        image.src = url;
    })
}


export const preLoad = () => {
    const promiseArr = [];
    imgs.forEach(src => {
        promiseArr.push(loader(src));
    })
    return Promise.allSettled(promiseArr);
}

使用视频格式替代动画内容

目前有一些动画是直接用GIF形式的图片的:

  • GIF是一类流行的的图片格式,常用于承载动画类型的页面内容
  • 对于时长较大的动画内容,GIF的体积容易快速膨胀,造成较大的加载负担
  • 相同时长GIF文件和视频文件,视频文件在文件体积、图像素质和渲染性能上更具优势

比如一张20s时长的GIF,甚至会超过10M,这可太大了。

我有个兄弟在网易雷火游戏部门,他们那一些炫酷的背景动画也都是用视频格式。看了下游戏部门对这块的实战应该是更为丰富,我这就稍微班门弄斧了。

使用WebM格式视频,

相比老牌的MP4,WebM(opens new window)是一种相对较新的文件格式。得益于较为先进的编码方案,WebM格式的视频通常比MP4具备更小的文件体积。

对图片进行压缩:

  • 图片编码格式是影响图片体积的重要因素之一,合适的编码格式能够带来良好的用户体验
  • Lighthouse对于此项审计仅适用于JPEG和BMP格式的图片。算法原理是将原始版本与压缩版本(预设压缩等级85)进行比较,如果潜在的节省量为大于或等于4KB则该图片将被标记为可优化的。
使用SVG替换位图

采用新一代的图片格式

  • WebP(opens new window)是Google提供的一种较为现代化的图片格式,支持有损压缩和无损压缩,也支持alpha通道和表现动画内容
  • 相比于老牌的JPEG和PNG,WebP(opens new window)等图片格式在相同质量下拥有更小的文件体积(详见这里(opens new window)
  • 从使用范围来看,WebP已经被Chrome和Opera等浏览器原生支持,Google,Netflix, Amazon, Quora和Yahoo等公司均有采用WebP的图片格式。
  • webp好是挺好的缺点就是,兼容性不足,比如不支持IE全家桶,虽然IE已经嗝屁了,但还是有一部分人会用IE。而且这玩意对ios的兼容性还是差一些。

CSS资源的处理

使用CSS Minifiers

我们可以在webpack中使用 CSSNano(opens new window)csso来对我们的CSS代码来进行压缩。

//postcss.config.js
module.exports = {
    plugins: [
        require('cssnano')({
            preset: 'default',
        }),
    ],
};

CSSNano 提供了多方面的体积优化能力,例如移除不必要的厂商前缀、对属性进行排序以获得更高的 Gzip 压缩比等,您可以查看 完整优化列表(opens new window)进一步了解。

延迟加载非关健CSS

关键原理

根据浏览器构建渲染树的过程:

将关键CSS抽离出来并内联到标签中,这样页面请求到HTML文件后可以直接开始构建渲染树并进行布局、绘制,而不需要发起额外的CSS资源请求。

将非关键CSS延迟加载,可以在用户浏览首屏时再完成这些CSS的加载,用户不会感知到。

适用:缩小CSS 移除未使用的CSS

打开DevTools,ctrl+shift+p 搜索show coverage,对页面进行检测找出无用代码率

可以看到我们图中Unused Bytes这一栏,标注出了227570个字节,也就是73.6%的代码当前页面是没有立即用到的,图中标红的代码都是没有用到的,那么我们是不是有一个思路呢?

没错,聪明的你应该已经想到,我们可以先加载使用到的那部分CSS,再去网络空闲的时机,加载出剩余的CSS。思路我们已经有了,那么我们该怎么去做呢?我们下载出对应json文件

start和end代表有效代码的字节区间(可以编写一个js脚本提取),提取有效css后,在首屏中进行引入,取消掉全局css加载,将全局css防止合适的节点处进行加载(每个项目的情况不同,你需要判断项目中哪个时机最适合来进行这一步操作)最终成果

CSS的大小(gzip压缩后)从41.3kb降低到了1.6kb,加载时间从219ms降低80ms。

可以看到我们的首屏加载速度便提高了近140ms,在网络情况不佳的情况提升将更为显著!

使用网络相关优化

使用缓存

浏览器缓存虽然不能提高新用户的体验,但是对于老用户的体验是很有价值的。

此处不介绍该怎样给资源加入缓存,如果有兴趣可以查阅相关资料

我们可以将一些静态文件和长久稳定不变的JS包放入缓存(此处需要自己根据项目情况判断)

例如:

  1. 若页面是弱交互的,例如官网,可以直接将整个html和css放入缓存。
  2. 页面上的图片、字体等资源。
  3. Vue、React等稳定版本的框架。

设置尽量长的缓存时间

Lighthouse 推荐的缓存时效为 365.25 天

Service Worker

Service Worker 有本地管理注册域名下网页的能力,在网页数量可控的基础上可以自行控制网页的预加载和缓存策略,从而可以做到离线访问。 需要注意的是,除了兼容性问题之外,页面经过 Service Worker 代理会产成一点点性能开销,在未命中缓存时,加载时间会有轻微增加。

合并请求:

一些接口的数据查询比较快,考虑到chrome每次只能并发6个TCP,如果这个接口在使用的话,那么就会导致其他HTTP请求停滞,所以我们可以可以合并一切简单的get请求,让出一些TCP连接的空位来让一些请求提前开始。

使用HTTP2

使用服务端渲染

渲染优化

css文件上面已经单独说了这里就不再说了。

JS文件

事实上,大部分 JS 文件都不会影响页面首次渲染。它们需要被延迟加载。

使用 async 或 defer 属性

推荐使用 async 或 defer 属性标记非关键 JS 文件。

由于 async 或 defer 的执行时机不一致:

通常我们使用 defer 标记需要按照一定顺序执行的脚本,例如业务代码;使用 async 标记一些没有任何依赖的脚本,如埋点、性能统计脚本等

去除重复、无用的JS文件

首先,你需要有一个能够分析构建产物的工具,这里我采用了比较主流的webpack-bundle-analyzer来做为示例。

在运行插件后(具体使用在此不多做讲解),我们会得到当前项目的分析图:

有两个包完全重复了,为什么会发生这种情况呢?

例如当前有个项目A,他本身依赖D,但他引入了组件B和组件C,并且B和C都依赖了D,但是项目构建的时候并不知道,所以会将两个模块都引入进去,造成了重复依赖。

├── projectA 
│   └── node_modules 
│       │── packageD
│       │── pluginB 
│       │   └── node_modules 
│       │       └── packageD
│       │── pluginC 
│       │   └── nodule_modules 
│       │       └── packageD

那我们该怎么解决呢?

peerDependencies,想必大家对package.json这个文件并不陌生,他是管理我们依赖包的地方,每当我们npm install时,npm都会根据对应的模块版本安装依赖。那我们该怎么做呢?

我们只需要在pluginB/package.json和pluginC/package.json中申明即可,例如:

pluginB/package.json

// peerDependencies和dependencies同级哦
{ 
  "peerDependencies": { 
    "packageD": "1.0.1"
  }
}

然后只在最外层的dependencies引入模块就行了。

删除无用JavaScript

细心的朋友可以看到,我们的打包文件居然把mock.js也打包了进去,无论是处于什么原因,这都是没有用的,我们要干掉它!我们应该找出打包后的无用依赖包,努力把我们的体积优化到最小

如何降低包的大小:
  1. 使用路由懒加载,分包
  2. 第三方库按需加载
  3. 使用compressionWebpackPlugin使用gizp压缩
  4. 使用组件的异步加载
  5. 使用uglifyJS或者terserWebpackPlugin去压缩js代码
  6. 打包的时候取消.map文件

代码层面:

防抖节流:

这里就不单独介绍防抖节流了:想学的可以看这篇文章:

防抖节流及其应用实战

减少回流重绘

回流:当渲染树中部分或者全部元素的尺寸、结构或者属性发生变化时,浏览器会重新渲染部分或者全部文档的过程就称为回流。

可能会导致回流的操作:

  1. 浏览器的窗口大小发生变化
  2. 添加或者删除可见的DOM元素
  3. 元素的字体大小发生变化
  4. 元素的尺寸或者位置发生变化
  5. 元素的内容发生变化

重绘:当页面中某些元素的样式发生变化,但是不会影响其在文档流中的位置时,浏览器就会对元素进行重新绘制,这个过程就是重绘。

可能会导致重绘的操作:

  1. 字体颜色或背景发生变化
  2. border-radius、visibility、box-shadow等发生变化

注意:重绘不一定引起回流,回流一定引起重绘。

减少回流重绘的操作:

  1. 不要频繁操作元素的样式,对于静态页面,可以修改类名,而不是样式。
  2. 如果需要修改多个css属性,可以先将元素设置为display:none,然后操作完成之后再改变回来,这样就能大大降低回流次数。
  3. 使用absolute或者fixed,使元素脱离文档流,这样他们发生变化就不会影响其他元素
  4. 避免频繁的操作dom

其他一些优化小细节之前记的了:

  1. 使用图片懒加载
  2. 减少重排和重绘 使用transform去改变dom的位置
  3. 因为浏览器是16.66ms进行一次渲染,所以一些长时间的js操作可能导致掉帧,这个时候就可以使用时间分片的方式,使用setTimeout去切分这些长时间的操作,或者使用requestAnimationFrame这个api去操作
  4. 改变多个css的样式可以使用动态class一起进行改变。
  5. 比如在vue中获取值的时候,我们可以先把那些值都先保存下来,避免多次触发get,去判断是否是重复的依赖
  6. 对于一些死的数据,我们可以使用Object.freeze来冻结这个属性,不给他做响应式的处理。
  7. 善用图片格式 比如png质量较高,可以用来做logo,jp(e)g 质量较低(有从上到下和模糊到清晰的两种模式),webp虽然好,但是缺点是浏览器的支持率可能是在70%左右,要考虑到兼容性。
  8. 截流防抖
  9. 不要忘记移除一些绑定的事件,定时器等