首屏加载优化实测——从带宽计算到各种优化实操

538 阅读17分钟

背百遍八股不如一次实操。

今天中午12点34分上线了一个h5活动页,2核、4G、5M的服务器在160个同时连接数时造成了静态资源加载极慢按钮点击无效的问题。因为很紧急,所以看到是带宽限制之后果断选择提高上限至100Mbps,包年付费改为流量计费。

但要彻底解决问题还需要就此问题进行复盘,并结合大家耳熟能详的八股进行实操


一、服务器配置(2核4G 5M)分别决定了什么?

最开始,我们必须了解一个服务器的配置中,CPU核心数、运行内存、带宽分别决定了什么,这样才能对症下药。

1.1 CPU核心数

  • 影响并发处理能力:核心数越多,可同时处理的任务越多,适合 Web 服务、高并发接口等。
  • 影响系统性能与稳定性:核心数不足会导致任务排队、系统卡顿,甚至宕机。

1.2 运行内存

  • 决定可运行的服务数量与类型:内存不足会导致服务无法启动或崩溃,影响如 Redis、Java 等内存依赖型应用。
  • 影响并发请求处理能力与大任务支持:内存不足时难以应对高并发或大文件处理,系统易卡顿或报错

1.3 带宽

都知道带宽决定了网速,但是有几个细节必须注意:

1.3.1 上行带宽决定了上传速度,下行带宽决定了下载速度。

  • 家庭带宽

网络运营商考虑到一般家庭下载的需求大于上传。所以300 Mbps的带宽,通常,下行带宽 可能会在 200-300 Mbps 之间,而 上行带宽 可能只有 50-100 Mbps,具体的分配比例取决于你所在的宽带服务提供商。(贴一篇中国电信的小科普:宽带网速(下载网速/上传网速)相关知识)

  • ECS带宽

从文章开头的带宽监控图就可以看出, 5Mbps 的ECS带宽,最终下载的网速就是 5Mbps,同时上传网速能达到10Mbps(虽然这个h5中没有上传功能)。也就是说,ECS带宽和家庭带宽不一样,ECS带宽不是上行下行总和,而是指出网带宽(下载)。那ECS的入网带宽(上传)怎么计算?我直接贴2张图:

image.png

image.png

1.3.2 整个网络传输的速度是源端带宽目的端带宽中间网络路径带宽最小值

  • 下载时,源端是服务器,目的端是本机电脑;上传时,源端是本机电脑,目的端是服务器。中间的网络硬件设备也都会有带宽限制。最终的网速是网卡、路由器、家庭宽带、服务带宽等等的最小值。

(几年前我家宽带升级300Mbps之后一直没体会到该有的速度,后来才发现路由器还是100Mbps的...)

1.3.3 多设备访问会均摊带宽。

  • 默认情况下,多个设备会均摊带宽,家庭路由器管理界面可以对指定设备限制网速。

二、复盘

2.1 用户直观感受

造成的后果是:首页加载很慢、背景图加载很慢、首页上按钮点击多点几次才生效、背景音乐卡顿。

2.2 查看监控

2.2.1 埋点统计

通过预先设置的埋点可以看到,在12点34分上线后的15分钟之内访问次数有216次

image.png

当然这只是一个时间段内访问次数,直接影响服务器的还是看下面的同时连接数

2.2.2 查看ECS监控

刚上线后的几分钟最高同时连接数160个(服务器在单位时间内维持的TCP/UDP并发连接总数):

f54d29983e0a6fd8b91add90b89a84f7.png

5Mbps服务器的带宽直接拉满

05507ceebc9367b4543cdc9acf76d3f0.png

2.3 计算和分析

2.3.1 要注意,多少兆带宽是指兆比特,而多少兆文件是指兆字节!!

带宽单位Mbps中的bbit(比特)。我们平时说的多少兆带宽单位是Mbps,而不是MB/s。而文件大小都是用 bype 为单位

因为

1byte=8bit1 byte = 8 bit

所以 5Mbps 其实是:

5 Mbps÷8=0.625 MB/s5 Mbps÷8=0.625 MB/s

也就是说1MB大小的文件,就算一个设备独享5Mbps的带宽也得下载 1.6s

2.3.2 我的h5首屏要加载5.3MB静态资源

F12打开控制台,禁用缓存后重新加载h5可以看到 5.3MB 静态资源传输过来,一共 5.4MB 大小 (浏览器自动解压后)。

image.png

结合上文的ECS监控也可以看到5Mbps带宽这段时间内被用满了所以毋庸置疑,一定是资源太大、带宽太小导致的。


三、解决

下面记录一下我们平时背的八股文的实操过程。

大部分是我上线之前就做了的,只是服务带宽没有提高,最重要的是我音频忘记压缩了,虽然音视频会自动分段加载,一次加载5MB左右,但是和总计有17.6MB...

3.1 提高ECS带宽限制,包年付费改为按流量计费

最直接最快最有效的方案当然是提高带宽限制:

image.png

这种h5活动页的服务器只是短时间暴增流量,所以适合按流量计费。我设置的是100Mbps(2核4G的规格最高配100Mbps的带宽),但没有具体的数据支撑。这里我稍微估算一下:

5.3MB的资源,在100人同时访问的情况下,想让每个人都能在1s内加载完成,就需要:

(5.31008)/1=4240Mbps=4.24Gbps(5.3 * 100 * 8) / 1 = 4240 Mbps = 4.24Gbps

这么算我设置100Mbps也远远不够。但考虑到活动也就刚刚发布的时候人很多,后面同时连接数也就不到50,而且每个连接加载的很多都是几十几百KB的其他小资源,所以先这样。

!!! 注意!!! 对于我这种场景其实应该设置包年包月下的临时带宽升级,因为按量计费每小时都会有 0.4元的服务配置费用,一年下来得 3504 元。当时太急了没看到这个功能,这一步是我踩了坑了。

image.png

后面我会尝试通过自定义镜像的方式来备份文件数据(保存快照),更换系统后使用该镜像替换系统。成功的话我再记录下来。

3.2 图片、音频等静态资源压缩

3.2.1 图片压缩

推荐 iloveimg

这个h5很多图片,设计师给到我的每张都是2MB以上,需要在保证合适清晰度的情况下尽可能将空间压缩到最小。最后所有2MB的图片都控制在200KB左右:

image.png

3.2.2 音频压缩

这个h5 有个 17.6MB 的背景音乐。这个是导致带宽不足的罪魁祸首,必须压缩:www.youcompress.com/zh-cn/mp3/

image.png

背景音乐对音质要求不是很高,放心压缩就好,况且经过这么大幅度的压缩,我真没听出来有什么变化。

3.2.3 代码压缩

代码本身不占太多体积,使用vite、webpack等构建工具在生产环境下会自动进行代码压缩,主要有:

类型压缩方式
JavaScript移除空格、注释、简化变量名(minify)
CSS移除冗余样式、空格、注释
HTML(可选)需要插件支持,例如 html-webpack-plugin 配合 minify 选项

相对图片、音频等大体积的静态资源,代码压缩效果相对不会很明显,这里不做详细介绍。

3.3 开启gzip压缩

很多前端面试八股文中都会提到gzip压缩,但其实前端开发不需要做任何事情!!

3.3.1 gzip是什么?

gzip是一种压缩算法,当然还有其他算法,而nginx默认支持gzip,兼容性也是最好的:

特性GzipBrotli (br)Zstandard (zstd)Deflate
📦 压缩率(文件变小程度)高(优于 gzip)高(≈ Brotli)
🚀 解压速度中等快(优于 gzip)
⚙️ 压缩速度慢(尤其压缩等级较高)非常快
🧠 CPU 占用(压缩阶段)中高
🌐 浏览器支持度(客户端)✅ 全部支持✅ 现代浏览器支持🔶 Chrome/Firefox ≥118✅ 老浏览器也支持
🔧 Nginx 原生支持✅ 支持(默认)❌ 不支持❌ 不支持✅ 支持(内置于 gzip 模块)
🔌 Nginx 扩展支持方式无需额外模块✅ 静态 .br 文件配合 gzip_static✅ 第三方模块,如 ngx_zstd内置
📁 静态压缩文件支持(推荐).gz.br.zst(需自定义配置)❌ 很少使用
🧰 构建工具支持(Vite/Webpack)✅ 普遍支持✅ 插件良好✅ 插件可用几乎没人用
🏗️ 适合动态页面压缩✅ 性能佳⚠️ CPU 开销高⚠️ Nginx需模块,不推荐动态用✅ 简单页面可用
📦 Nginx 安装难度简单中等(需配置静态)高(需打补丁或 OpenResty)已集成

3.3.2 过程

gzip压缩的过程是下面这样的。

  1. 前端请求时,浏览器会自动带上请求头accept-encoding,以告诉服务器哪些压缩算法:

image.png

  1. 服务器端(如 Nginx)检测到这个头,就会:
  • 检查 nginx.confgzip on 是否启用;

  • 检查资源 MIME 类型是否在 gzip_types 中;

  • 检查是否符合gzip_proxied策略;

都符合就会对响应内容进行gzip压缩,并在响应头加上Content-Encoding: gzip

注意:以上过程是纯后端对每个静态资源的请求的实时压缩,这样在高并发的场景下会大量消耗CPU资源,所以常常需要前端打包时就接触构建工具插件来构建一个.gz的文件交给后端,后端在nginx中开启gzip_static选项,这样nginx会优先查找是否有现成的 .gz 文件。

gzip_static 模块需要 nginx 另外安装,默认的 nginx 没有安装这个模块。

3.3.3 配置前后数据对比

注意,仔细分析每一条网络请求最好使用浏览器的隐私窗口打开,这可以排除谷歌扩展工具等发出的和网页无关的请求

3.3.3.1 开启gzip压缩之前

F12 打开网路面板中的“大请求行”可以看到每个资源的原始空间大小(下)以及传输的空间大小(上)。正常来说传输的空间大小比资源原始的空间大小多200B~300B左右HTTP头部占用的空间:

image.png

3.3.3.2 开启gzip压缩之后

以下是nginx对gzip的默认配置:

gzip on;

# gzip_vary on;
# gzip_proxied any;
# gzip_comp_level 6;
# gzip_buffers 16 8k;
# gzip_http_version 1.1;
# gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

默认只对 text/html; 的类型应用gzip压缩。gzip_type配置允许你对其他类型启用gzip压缩:

image.png

解开上面3-8行的注释后记得重启nginx再查看效果,其他配置这里不做赘述,可以查看官方文档

image.png

从上图可以看到,开启gzip压缩之后,符合gzip_type配置的资源请求的响应头中会有Content-Encoding: gzip。这表示已经生效。再查看空间大小

image.png

3.3.3.3 压缩前后大小对比,压缩率计算

考虑到 HTTP 请求头的空间占用开销,以下是严谨的压缩率计算公式:

压缩率=1gzip压缩后传输大小(gzip压缩前传输大小原始大小)原始大小×100%\text{压缩率} = 1 - \frac{\text{gzip压缩后传输大小} - (\text{gzip压缩前传输大小} - \text{原始大小})}{\text{原始大小}} \times 100\%

对比表格如下:

文件名原始大小(未压缩)Gzip压缩前的传输大小Gzip压缩后的传输大小压缩率
%E9%80%89%E9%xxx.js354 B614 B494 B40.68%
home-BmvMRGK4.css525 B771 B572 B43.62%
navigation-CByOFk4A.js1.2 kB (1229 B)1.4 kB (1434 B)989 B54.67%
home-CPoM8DtW.js1.3 kB (1331 B)1.6 kB (1638 B)1.1 kB (1126 B)59.66%
wxshare.js3.2 kB (3277 B)3.5 kB (3584 B)1.6 kB (1638 B)92.10%
audio.js4.6 kB (4710 B)4.8 kB (4915 B)2.1 kB (2150 B)92.59%
index-CJAT1vev.css75.4 kB (77210 B)75.6 kB (77414 B)5.9 kB (6041 B)99.59%
index-BV-EnN-h.js87.4 kB (89498 B)87.6 kB (89698 B)35.1 kB (35942 B)98.30%

可以看到,gzip的压缩确实能节省一定的带宽,尤其是相对于较大的文件,效果更为明显。

3.3.3.4 为什么不对图片和音频开启gzip?

这里要分清什么是无损压缩(可逆压缩)、什么是有损压缩(不可逆压缩)。

gzip 是无损压缩,压缩后可通过某种算法操作得到原本的数据(这个过程就是所谓的解压缩)。

而我在这之前已经对图片和音频进行了有损压缩,以牺牲图片质量和色彩信息为代价,过程是不可逆的,也就是我无法通过某种算法再得到压缩前的数据。

对一个已经经过有损压缩的文件二次做无损压缩,通常不会有任何效果、甚至体积反而会增大。

参考

3.4 预加载

这个h5每页都会有几张几百KB的图片(压缩之后),如果到那个页面再去加载图片有个缓慢加载的过程,弱网环境下体验不好。下面是几种预加载方案的实操。

3.4.1 给new Image()的实例赋src,进行js预加载

/**
 * 预加载单张图片
 * @param {string} src - 图片路径
 * @returns {Promise} - 返回Promise,加载成功时resolve,失败时reject
 */
export const preloadImage = (src) => {
  return new Promise((resolve, reject) => {
    if (!src) {
      reject(new Error('图片路径不能为空'));
      return;
    }

    const img = new Image();
    
    img.onload = () => {
      resolve({
        src: src,
        status: 'loaded',
        width: img.width,
        height: img.height
      });
    };
    
    img.onerror = () => {
      reject(new Error(`图片加载失败: ${src}`));
    };
    
    img.src = src;
  });
};

使用这种方式在当前页预加载下一页的图片后,再到下一页时就不会再次加载,你可以通过浏览器NetWork面板验证,但是要记得关掉Disable cache,否则它仍然会加载2次。

3.4.2 使用link标签的rel="preload"预加载图片

那么你可能会问第一页的图片如何预加载?答案是使用rel="preload"link标签(附上MDN文档)。

vue这样的spa框架中,直接在根目录的index.htmlhead标签中添加:

<!-- 预加载首页背景图 -->
<link rel="preload" href="/src/assets/P01-封面/背景3.png" as="image" type="image/png" />
<!-- 预加载首页其他关键图片 -->
<link rel="preload" href="/src/assets/P01-封面/你是.png" as="image" type="image/png" />
<link rel="preload" href="/src/assets/P01-封面/按钮.png" as="image" type="image/png" />

注意,当使用rel="preload"时,as属性必填,它指定了 <link> 正在加载的内容类型,这对于匹配请求、应用正确的内容安全策略和设置正确的 Accept 请求标头都是必要的(附上MDN的文档)。

type属性告诉浏览器预加载资源的 MIME 类型,浏览器以此来确定是否支持该资源,如果不支持,则会忽略它,仅在支持时才会下载它。

通过Performance面板可以看到,通过link ref="preload"预加载的图片在DCL之前即可完成加载:

image.png

3.4.3 音效预加载

如果需要有交互音效,那一定得预加载,否则点击之后再加载就会反馈不同步。废话不多说,这个直接贴代码:

// 使用AudioContext优化点击音效
let audioContext;
let clickSoundBuffer = null;
let isAudioInitialized = false;
const CLICK_SOUND_PATH = './mixkit-arcade-game-jump-coin-216.wav';

// 初始化音频上下文和加载音效
function initAudio() {
  try {
    // 创建音频上下文
    window.AudioContext = window.AudioContext || window.webkitAudioContext;
    audioContext = new AudioContext();
    
    // 加载点击音效
    const request = new XMLHttpRequest();
    request.open('GET', CLICK_SOUND_PATH, true);
    request.responseType = 'arraybuffer';
    
    request.onload = function() {
      audioContext.decodeAudioData(request.response, function(buffer) {
        clickSoundBuffer = buffer;
        isAudioInitialized = true;
      });
    };
    request.send();
  } catch (e) {
    console.error('Web Audio API不受支持,回退到传统方式');
    // 回退到传统方式
    useTraditionalAudio();
  }
}

// 回退到传统Audio方式
let clickAudio;
function useTraditionalAudio() {
  clickAudio = new Audio(CLICK_SOUND_PATH);
  clickAudio.volume = 0.3;
  isAudioInitialized = true;
}

// 播放点击音效和振动
function playClickSound() {
  // 首次调用时初始化音频
  if (!isAudioInitialized) {
    initAudio();
    // 由于初始化需要时间,第一次点击直接返回,只设置振动
    if (navigator.vibrate) {
      navigator.vibrate(50);
    }
    return;
  }
  
  // 使用低延迟的AudioContext播放
  if (clickSoundBuffer && audioContext) {
    // 创建音源
    const source = audioContext.createBufferSource();
    source.buffer = clickSoundBuffer;
    
    // 创建音量控制
    const gainNode = audioContext.createGain();
    gainNode.gain.value = 0.3; // 音量设置
    
    // 连接音源到音量节点再到输出
    source.connect(gainNode);
    gainNode.connect(audioContext.destination);
    
    // 立即播放
    source.start(0);
  } else if (clickAudio) {
    // 回退方案
    clickAudio.currentTime = 0;
    clickAudio.play();
  }
  
  // 添加手机振动
  if (navigator.vibrate) {
    navigator.vibrate(50); // 振动50毫秒
  }
}

// 页面加载时预初始化音频
document.addEventListener('DOMContentLoaded', function() {
  // 用户首次交互时初始化音频
  const initOnFirstInteraction = function() {
    initAudio();
    // 移除事件监听器
    document.removeEventListener('click', initOnFirstInteraction);
    document.removeEventListener('touchstart', initOnFirstInteraction);
  };
  
  document.addEventListener('click', initOnFirstInteraction);
  document.addEventListener('touchstart', initOnFirstInteraction);
});

这段代码使用 两种音频播放方案,用最低延迟的 Web Audio API 实现音效播放,并为不支持 Web Audio 的旧浏览器做了 降级处理,改个 CLICK_SOUND_PATH 就可以拿去直接用。

3.5 使用CDN将源站资源缓存到遍布全球的边缘节点

因为阿里云CDN要单独计费,得17块/月,而且我这场景太小了没必要上CDN,我这里就不实操了,简单介绍一下阿里云CDN

阿里云内容分发网络CDN(Content Delivery Network)是建立并覆盖在承载网之上,由遍布全球的边缘节点服务器群组成的分布式网络。阿里云CDN能分担源站压力,避免网络拥塞,确保在不同区域、不同场景下加速网站内容的分发,提高资源访问速度。

阿里云CDN在全球拥有3200+节点。中国内地拥有2300+节点,覆盖31个省级区域,大量节点位于省会等一线城市;海外、中国香港、中国澳门和中国台湾拥有900+节点,覆盖70多个国家和地区。全网带宽输出能力达180 Tbps。

注意,他这里说全网带宽输出能力达180Tbps,这个带宽不是给你一个人用的,是全网共享的。

阿里云CDN单节点存储容量达40 TB ~ 1.5 PB,带宽负载达到40 Gbps ~ 200 Gbps ,具备180 Tbps带宽储备能力。

注意,他这里说的又是“单节点”,也不是给一个人用的,是单节点服务器的所有用户共享的。

总之,具体下行速度得实操才知道,但肯定的几个优点是,能让全球各地的用户更快得获取静态资源,而且这些不走源站的带宽,可以给源站减轻很多并发压力


四、总结

以上介绍了本次的分析和实操过程,总结一下就是:

  1. 首先务必对静态资源手动压缩,千万不要让用户加载1MB甚至10MB单个静态资源
  2. ToC的页面一定要评估一下流量,尤其是活动页会在某个时间点迎来一次暴增流量,上线之前要做压力测试,上线时刻做好升级服务器配置的准备
  3. 对于大部分格式的文件可以开启gzip压缩。经过实测,对较大的文件(几十KB)gzip的压缩率能到90%以上,最主要的是它兼容性好,配置简单
  4. 代码中可以对静态资源预加载,并使用loading骨架屏等提高用户体验;
  5. 如果用户分布较广,用户量很大,服务器带宽不够,那么建议使用CDN将源站资源缓存到遍布全球的边缘节点;

此外,我之前的一篇《首屏加载优化实测——按需加载、分包》中已经介绍了按需加载分包的方式来优化组件库的静态资源,但本次h5的静态资源优化实操不涉及这两个方面,就不重复介绍。