前端性能优化方案一览表

632 阅读13分钟

前言

这篇文章会从多个角度给出优化的策略,尽量保证给出的策略是可落地的、简单高效的。

优化请求

对于我们前端工程师来说,这一块我们可做的很少,一般都是交给公司的运维同学。但是,了解一下并没有坏处。

首先,我们可以使用一些 HTTP 的请求头来帮助我们缓存内容:Cache-ControlETagLast-Modified。(点击对应的条目可以跳转到 MDN 预览)

比如说,对于我们的静态资源,我们可以考虑在响应头里这样设置:

Cache-Control:public, max-age=31536000

值得注意的是,这里的 max-age 是距离请求发起过了多长时间后过期。具体这三者是什么意思,大家可以参考 MDN,我们这里来分享一下他们在实际中的应用。

image.png

在我们当前项目中,index.js 文件存放的就是下面这段代码,public/index.html 是任意一段 HTML,当我们在当前目录运行 node index.js ,并在浏览器访问 3000 端口的时候,我们发现,我们设置的Cache-Control 已经生效了。在截图的第二条,显示为 no-cache

const express = require('express')
const path = require('path')
const serveStatic = require('serve-static')

const app = express()

app.use(serveStatic(path.join(__dirname, 'public'), {
  setHeaders: setCustomCacheControl
}))

app.listen(3000)

function setCustomCacheControl (res, path) {
  if (serveStatic.mime.lookup(path) === 'text/html') {
    res.setHeader('Cache-Control', 'no-cache')
  }
}

image.png

对于一些不会变动的静态资源,如图片、下载的表格、基础的 CSS,我们可以通过 Cache-Control 设置长时间的缓存。

但某些经常变动的资源,就要慎重考虑了,一旦做了缓存,我们在发新版本的时候,就可能出现读取缓存不请求的情况。在我刚工作的时候,我们的解决方案就是每次重新发版的时候,更改链接形如 ?v=100 这样的后缀,那时候公司里还没有使用打包工具,每次都得徒手改有变动的文件,特别费劲。

现在我们一般就不用关心了,因为打包工具会帮我们做这件事。如果我们上一次打包生成的文件叫 app.2a34hd2fh3h.js,当这个文件某些内容改动了,打包工具打包出来的文件名就会发生改变,此时,就又会重新请求了。也就是说,设置长时间的缓存也没问题。

除此之外,还有像 index.html 这样的入口文件,它的文件名一般不会发生变化,我们怎么处理呢?

对于这种情况,我们一般会设置 Cache-Controlno-cache,这并不代表它不会使用缓存,而是在使用前,还要结合我们上面说的提过的两个属性 ETagLast-Modified,如果这两个属性都得出了文件没有变动的结论,那我们就没有必要请求了,直接使用缓存。如果想每次都不使用缓存,得使用 no-store 这个值。

使用 CDN 也算是优化 HTTP 请求的一种,我们可以选择使用 CDN 存放一些第三方库、图片资源。和存放在我们自己服务器对比的话,使用 CDN 可以动态选择离我们最近的节点,这样就可以让我们缩短 round-trip time (RTT) 。也就是说,本来请求我们服务器可能需要 1 S,但是使用 CDN 可能只需要 0.2 S。

不仅如此,有些 CDN 服务还能帮我们处理图片,我们可以通过在请求图片时加一些标志来请求压缩后的图片(我使用过过腾讯云的这种服务),具体的我们放在优化图片那一小节。

Cookie 这个属性我们再熟悉不过了,它会通过响应头的 Set-Cookie 来设置,设置了之后,我们之后的每个请求都会带上它,如果设置了过多的内容,那也会是一个不小的问题,所以,这个属性我们应当尽量保存只添加必要的值。

另外,我们还可以采用 Gzip 压缩,对于 Gzip 压缩这一种方案,你肯定每天都和它相遇,只不过,可能某些同学之前没有注意过。通过 Gzip 压缩,我们可以减少文件传输的体积,当浏览器接受到文件后,会自动帮我们解压,然后正常的解析。

如果想在 Express 使用 Gzip 压缩,需要引入一个中间件:

const compression = require('compression');

// 其他的中间代码省略了
// 压缩所有的请求
app.use(compression());

假设我们有一个请求,是返回一个很长的字符串:

app.get('/longText', (req, res) => {
  const text = 'a very long text';
  res.send(text.repeat(10_000));
})

不开启 Gzip 压缩的时候,传输的文件大小有 160 K,开启 Gzip 压缩的时候,传输的文件只有 644 B,只有当初体积的 0.04 %。这个对比只能用「恐怖」来形容了。

image.png

关于优化 HTTP 这一小节,我们还有最后一块内容,那就是简单对比一下 HTTP/1.X、HTTP/2、HTTP/3。

HTTP/2 主要就是为了解决 HTTP/1.X 的性能问题而出现的。我们上面讲述了可以使用 Gzip 压缩我们的传输内容,也看到了压缩后前后很夸张的对比,但是 HTTP/1.X 并不支持压缩请求头,这一点在 HTTP/2 上面得到了支持。

同时,HTTP/2 允许了在一次 TCP 连接中进行多次复用,在 HTTP/1.X 中,由于每个请求只能独立的使用一个连接,浏览器都有同时最多能发多少次请求的限制(Chrome 为 6 个),这一点在 HTTP/2 中也不存在了。

如果您想了解 HTTP/2 的更多内容,可以阅读 这篇文章

目前来说,主流浏览器都已经支持了 HTTP/2。

image.png

相较于 HTTP/1.X 来说,HTTP/2 有了很大的性能提升,不过它依然有一个问题。

我们上面说的,它有「多路复用」的特性,允许一次连接中同时发起、接受多个请求,不过,这个过程是线性的,假如有一个包丢了,整个过程就被阻塞住了。

image.png

HTTP/3 的出现解决了这个问题,在 HTTP/3 不再使用 TCP 了,而是转向使用了 UDP,这就使得,丢包了也没关系,也不会影响其他的流。(下图橙色的包就丢掉了)

image.png

不过,目前来说,HTTP/3 的支持性没有特别的好,我们就静静等待吧。

image.png

优化图片

如果说不改代码,怎么让网页的加载迅速变快,那选择优化图片是一个不错的选择。

目前我司的老项目的图片放在项目本地,并且图片直接从 UI 那里拿,没有经过任何处理,几百 K 的图片随处可见。这可能是最糟糕的方式了。

优化图片,我们要先从选择图片的格式入手。

图片主要分为两种格式:矢量图和位图。矢量图是使用点、线、多边形去构成图形,优点就是放大后也不失真,但是对于一些比较复杂、精美的图片显得力不从心。这时候我们得选择用位图,不过,位图放大后会失真,这时候我们可能得根据场景准备不同分辨率的图片。

矢量图的代表就是 SVG,位图的代表就是 PNG、JPEG。对于一些图标、logo,我们可以优选选 SVG ,IconFont 可以非常方便的托管 SVG,它的图标库也很丰富,日常使用可以说肯定够了。

使用 IconFont 的同学要注意一点,不要选择使用 Unicode 或者 Font class,那些配置繁琐,还不好用,要选择使用 Symbol。

image.png

对于不能选择 SVG 的场景,我们得看一下对清晰度要求高吗?高的话就选择 PNG,不高的话就选用更小的 JPEG。但是有时候 PNG 太大了,如果您的目标群体都是使用比较新的浏览器的用户,可以考虑使用 WebP 这个格式,它相较于老的格式,没有什么劣势,并且体积更小。

有 NPM 包帮我们做到这件事,我们可以使用 imagemin-webp 把老的图片格式转为 WebP。

如果您觉得 WebP 太棒了,想兼容老的浏览器,那可以考虑这种方案:

<picture>
  <source type="image/webp" srcset="flower.webp">
  <source type="image/jpeg" srcset="flower.jpg">
  <img src="flower.jpg" alt="">
</picture>

我在做小程序的时候,发现它报告里不允许我们使用 GIF 格式的图片,这个确实是很大的一个损耗,刚开始我没有留意,后来因为这一点被拒绝审核了,我才把它改掉了。毫不夸张的讲,几 M 的 GIF 图片都有......

优化 GIF 常见的方式就是使用 video 标签,我们可以使用 ffmpeg 把 GIF 格式的图片转为视频格式。说起视频格式,大家第一时间会想到 MP4,但是有了比 MP4 更好的视频格式,叫做 webm,它比 MP4 更小,但是不是所有的浏览器都支持。

为了兼容老浏览器,我们也要采取下面的策略:

<video autoplay loop muted playsinline>
  <source src="my-animation.webm" type="video/webm">
  <source src="my-animation.mp4" type="video/mp4">
</video>

选择完了图片格式,我们还可以考虑把图片懒加载加进去,可能有些同学觉得这个比较复杂,但是事实上,加起来特别的方便,只需要使用 img 标签的 loading="lazy" 就好了。加了这个属性,浏览器只会在图片快到达视口的时候才去请求图片。

不过,毒瘤 IE 不支持,但是写这个属性也不会有什么不好的影响,不支持浏览器会自动忽略。说句实话,我一点都不想兼容 IE,并且也不会给出兼容 IE 的策略。

还有一种优化图片的方式就是选择 CDN 了,一般来说,我们可以通过在图片链接后面添加查询参数来获取不同质量、大小的图片,这个具体要看服务商。比如:www.some-cdn.com/mysteryven.… 我只是和大家示意一下,具体语法还得看服务商的文档。

优化 JavaScript 文件

这一块我们主要从打包工具的角度去优化,和上面两小节一样,我们一样能压缩 JS,使用 Webpack 的同学可能都使用过 TerserWebpackPlugin

还有就是一个细小的写法的变化,比如我们在使用 lodash 的时候,我们想引用某个库,下面这样的写法在打包的时候可能要把整个库打包进来了:

import _ from  'lodash';

但是我们可能只用了一个方法,我们就可以这么写:

import cloneDeep from 'lodash/cloneDeep'

就这一点小小的改变,我们就能更好的利用打包工具的 tree-shaking 功能了。

另外,为了避免单 JS 文件过大,影响首屏渲染,我们可以考虑使用 code split

优化 CSS 文件

首先能想到的,也是压缩。除此之外,我还发现一个很好用的工具,叫做 tailwindcss,可能很多小伙伴都知道了。它帮我们定义了一些公共样式,使用它,我们就基本不用写 css 了:

<div class="w-32 ml-5"> </div>

为什么在这里提到它呢?它不仅仅能帮我们很方便的不离开 HTML 页面写样式,并且随着我们的使用,还能减少重复的 CSS 样式代码。也就意味着,我们 CSS 的文件会变小很多。可能有些同学刚开始不接受这种形式,其实刚开始我也算不接受的。毕竟这样就丧失了 CSS 的语义化,并且有的元素会被添加很多类名。但后来发现,这种方案很适合对样式要求不是特别高的场景,而对于一些需要特别精细控制的地方,可以单独写 CSS 样式。

使用 VS Code 还是 WebStorm 的同学都可以下载 TailWind 的插件,让它给我们提供自动补全。开发起来非常方便。

除此之外,还有基于它的 css-in-js 的解决方案,我没有采用它,但是有兴趣的同学可以前往阅读

写 JS 代码时可采取的方案

在这一小节,我只给大家介绍一种不那么常见的优化,就算在我知道了之后,我依然觉得非常的神奇。

大家觉得像下面这段代码还有可能优化吗?

let nums = [.....]
for (let i = 0; i < nums.length; i++) {
    process(nums[i])
}

不仅有,而且还能带来比较显著的提升。

在这段代码中,我们的循环体就只有一个 process 函数,事实上,就算是我们的循环体特别简单,如果迭代的次数非常多,也会变慢,循环体运行本身就是一次小的性能开销。而像上面那段代码可采取的优化策略就是减少循环的迭代次数,这种优化的名称叫做 达夫设备(duff device)。

本来,我们的代码可能是这样子的:

var nums = new Array(1_000_000_0).fill(0).map((i, index) => index);

function process(item) {
  JSON.stringify(item);
}

console.time('普通的循环')
for (let i = 0; i < nums.length; i++) { 
  process(nums[i]);
}
console.timeEnd('普通的循环')

使用 「达夫设备」后,我们的代码变得复杂了一点:

console.time('使用达夫设备')

var i = nums.length % 8;

while(i) {
  process(nums[i--])
}

i = Math.floor(nums.length / 8);

while(i > 0) {
  process(nums[i--])
  process(nums[i--])
  process(nums[i--])
  process(nums[i--])
  process(nums[i--])
  process(nums[i--])
  process(nums[i--])
  process(nums[i--])
}

console.timeEnd('使用达夫设备')

它尽量在一次循环中做了更多的事情,性能的对比也可能出乎一部分人的预料,它快了接近 90% :

image.png

不过在循环次数少的时候,可能没那么明显,我测试在 10 次的时候,普通循环更快。这个优化策略比较适合在循环次数很多的时候使用。

使用 React 时可采取的优化方案

关于常见的优化策略,就是虚拟列表、shouldComponentUpdateuseMemouseCallback

吐槽一下,长列表真的是 React 永远的痛。值得庆幸的是,虚拟列表实现起来并不复杂,并且在社区有很多比较好的实现方案。有时候不仅要使用虚拟列表,还要考虑把列表中某一项的值交给单个组件去维护,具体实现,可能要根据您的使用场景去决定了。

有些同学可能会借助 key 值来让一个组件重新渲染:

<Modal key={Math.random()}>
    ...
</Modal>

有时候不失为一种 Hack 技巧,但是据我的使用体验来说,潜在的问题往往更多。

一般来说,使用 React 了就比较难写出有很大性能问题的代码。关于这些内容的详细描述,官方文档的优化性能这一节讲述的比较详细,有兴趣可前往阅读。

后记

今天我着重和大家分享了优化请求、图片的内容,其他的只是比较简单的略过,希望我的这篇文章能和您在其他地方阅读到的文章有所不同,最大的希望还是能给您带来收获。

几个月前,我做过几个关于优化网页性能的任务,做得事情很简单,无非就是把某些值缓存起来,等到下次查询的时候从缓存读取。最终的效果就是:看起来某些高频操作确实变快了,大家都比较满意。

事实上,这种做法是一件比较「耍聪明」的方式,只优化突出的几个点,看起来却有特别的出效果,不过这种短时间内看得见的成果,在产品、领导那边都比较好交代。但是从长远来看,那种做法并不可持续,它并没有从根上解决性能的问题。只是相当于有了洞就去补洞,有了坑去填坑。在不远的未来,还是会有同样的问题。

就如同商鞅游说时,秦孝公做出的选择一样,作为皇帝,谁能忍受的了自己在位几十年却看不到立马的成效?谁不希望自己在位的时候名扬天下?谁又能在看不到任何未来时,继续坚持静等花开呢?站在他们的角度,这样的选择是非常可以理解的。尽管如此,作为工程师的我们,还是要有追求的,不然又和一条咸鱼有什么区别,可能某一天,我们碰到周王了呢?