Skip to content

8.4 网络资源优化

上一节介绍了首屏渲染优化方案,主要是减少首屏加载的内容,让页面更快的渲染出来。其实优化网络资源本来也是优化首屏渲染必不可少的部分,只不过这是一个通用方案,适用于整个应用。

浏览器中一切请求的资源都是网络资源,包括 JavaScript、图片、接口、字体等,这些资源的下载与页面的呈现息息相关。单从网络资源的角度来看,如何在尽可能少的下载资源的同时不影响页面的渲染,这是网络资源优化的关键方向。几个常见的优化方案如下。

8.4.1 图片异步加载

在一些图片量比较大的网站中,一个页面的的图片总体积可以达到几十兆。如果要一次性将所有图片加载完成,页面一定会有白屏和卡顿现象,如果在手机设备上浏览问题会更加突出。

但是图片和其他可以拆分的模块不同,图片需要在当前页面展示,因此加载是必须的。如果我们要实现懒加载,就只能从浏览器视口的可见区域下手。具体方案是:首次加载时只加载页面可见区域的图片,当用户滚动页面时,我们再计算并下载应该出现在可视区域的图片,这样用户在感受不到延迟加载的情况下,大大提升了首页性能。

当然为了更好的用户体验,防止页面抖动,更好的做法是为图片设置一个占位符。默认情况下我们为 img 标签设置一个骨架屏的样式,当图片加载完成后,再替换成真正的图片。

js
<img src="xxx.default.png" data-src="xxx.realsrc.png" />

上面的 img 标签我们将真实的图片地址放在 data-src 属性之下。使用 getBoundingClientRect() 方法来获取元素到可见区域的高度,最后通过与浏览器视口高度对比判断出元素是否可见,如下:

js
// 获取一张图片
let img = document.querySelector("img");
// 获取可视区域的高度
let viewHeight = window.innerHeight;
// 判断图片是否出现在可视区域
let distance = viewHeight - img.getBoundingClientRect().top;
if (distance >= 0) {
  img.src = img.getAttribute("data-src");
}

最后,将上述代码封装成一个方法,获取所有图片并在浏览器滚动时触发懒加载:

js
const imgs = document.querySelectorAll("img");
var img_index = 0;
const loadImg = () => {
  Array.from(imgs)
    .slice(img_index)
    .forEach((img) => {
      let distance = window.innerHeight - img.getBoundingClientRect().top;
      if (distance >= 0) {
        img.src = img.getAttribute("data-src");
        img_index++;
      }
    });
};
window.addEventListener("scroll", loadImg, false);

代码中通过 img_index 标记已加载图片的索引,这样可以避免重复加载。当所有图片加载完成后,函数内便不会再执行相关的加载逻辑。

8.4.2 接口按需请求

单页面应用中大部分的数据获取都需要通过接口请求,在加载和交互的过程中要频繁调用接口,很显然接口也是网络请求,也会产生耗时,过多的接口请求为浏览器带来压力,影响它的运行性能。

因此,请求接口这一块应该严格按照规范,不能盲目地随意请求。比如某一个配置接口,在多个页面中都会用到,很多人的做法是进一个页面请求一次,这样会带来大量多余的请求。正确做法是请求之后存储在状态管理中,在多个页面复用数据,从而减少请求。

以 Vue3 为例,假设要在某个页面中使用城市列表数据,多数情况下会直接在生命周期函数 onMounted 中获取:

js
import { onMounted } from "vue";
const cities = ref([]);
onMounted(() => {
  // 获取城市列表
  fetch("http://xxxx/api").then((res) => (cities.value = res.data));
});

如果要在多个页面中使用该数据,就要像上面代码一样重复写多次,这样显然是不合适的,造成了多余的请求。

一个建议的方式是:将所有可能会多次使用的接口请求全部写在状态管理中,数据和请求可以供所有页面使用,我们可以省去绝大部分的能力,并且状态管理中的数据可以同步更新,避免了组件中状态异步的麻烦。

以 Pinia 为例,将请求和状态全部定义到状态管理中,方法如下:

js
// store.js
import { defineStore } from "pinia";
const commonStore = defineStore("common", {
  state: () => ({
    cities: [],
  }),
  actions: {
    getCities() {
      fetch("http://xxxx/api").then((res) => (this.cities = res.data));
    },
  },
});
export default commonStore;

在页面中,加载这个状态并使用,我们可以判断无值时再获取,这样保证数据只会获取一次:

js
import commonStore from "./store.js";
const com_store = commonStore();
if (com_store.cities.length == 0) {
  com_store.getCities();
}

8.4.3 高效利用缓存

使用浏览器时你一定发现了这样的现象:某个网站第一次打开的时候很慢,但后面再打开就快多了 ——— 这都是缓存的功劳。随着浏览器的缓存功能越来越强大,它可以智能推算出哪些是不可变资源,尽可能地将数据缓存在本地,减少实际网络请求,加快渲染速度。

然而缓存的种类很多,如果使用不善,也会出现“过度缓存”的结果。比如服务端已经更新资源、但浏览器刷新多次依然是旧资源,这样直接影响了产品功能。

浏览器中的缓存机制有一部分是自动实现的,我们主要控制的缓存是 HTTP 缓存,分为强缓存和协商缓存。

  1. 强缓存

强缓存通过服务端在 HTTP 响应头中设置 “expires” 和 “cache-control” 两个字段来实现。当浏览器从响应头中读取到这两个字段时,会判断强缓存是否生效(是否在有效期内),生效则设置缓存;当下一次请求该资源时,浏览器就会直接从缓存中读取资源,不再向服务器发起请求。

那具体是怎么实现的呢?我们先以 expires 为例,响应头中的字段如下:

js
expires: Thu, 24 Feb 2033 02:04:51 GMT

代码中可见,expires 字段值是一个时间戳。当浏览器在响应头中接收到该字段后,会将字段值与当前时间比较,判断是否在有效期(字段值大于当前时间)。如果是则写入缓存,下次请求时从缓存读取,直到该时间戳过期。

由于 expires 字段对时间一致性的要求很高,而本地时间又无法保证准确性,因此浏览器决定用 cache-control 来代替 expires。cache-control 可设置相对时间长度,直接指定有效期的时长,通过 max-age 来设置最大有效时间,如下:

js
cache-control: max-age=49536000

这行代码表示该资源在 49536000 秒以内都是有效的。如果 cache-control 和 expires 同时出现,则 cache-control 的优先级更高。

  1. 协商缓存

如果强缓存没有生效,浏览器也不会让你的请求“裸奔”,它会自动启用协商缓存。协商缓存顾名思义,要浏览器和服务器一起根据情况协商:哪些资源需要缓存,哪些资源不需要缓存。

协商缓存通过服务端在响应头中设置 Last-Modified 实现,该字段的值也是一个时间戳,如下:

js
Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT

当浏览器接收到该字段时,表示协商缓存启动,并会在下一次请求时将字段值携带到 If-Modified-Since 字段上。如果服务端接收到这个请求,就会将上次的 Last-Modified 和这次的 If-Modified-Since 两个时间戳做对比,从而判断资源是否被修改。

如果时间戳对比一致,表示协商缓存命中,服务器会返回 304 状态码,并输出缓存资源。如果对比不一致,服务器就会重新获取资源,并在响应头中添加新的 Last-Modified 值,进入下一轮协商缓存判断。

从上述的流程可以看出:协商缓存命中与否是通过时间变化来判断的,而不是内容变化。这样就会有一个问题:当编辑文件但内容没变,或修改文件的速度过快,协商缓存就会失效。

为了解决该问题,浏览器推出新字段 Etag 来实现协商缓存。与 Last-Modified 不同的是,Etag 的值是文件的唯一标识符,由服务器生成,文件变化时会自动更新,这样便实现了精准的协商缓存。

Etag 值的生成会带来服务器开销,但好处是协商缓存更精准,因此是否使用请根据实际情况判断。如果 Last-Modified 和 Etag 同时存在时,Etag 优先级更高。