浏览器缓存与Service Worker:打造卓越Web体验

如何进一步提升用户体验,始终是一个热议且不断探索的问题。对于web应用而言,提升用户体验主要集中在两大关键方面:一方面,需要注重应用加载时的性能优化,确保用户可以快速、流畅地访问应用;另一方面,则要在运行时持续优化性能,以保障应用在运行过程中的稳定性与流畅度。这两方面的优化共同构成了提升web应用用户体验的重要策略。

因此,本文主要探讨浏览器的缓存策略,加载策略,并实现一个应用。同时开源所有代码供大家参考。若在阅读过程中有所受益,恳请点赞和GitHub上点个Star。若有任何疑问或建议,欢迎在评论区畅所欲言,共同探讨进步。

页面地址:chaxus.github.io/ran/

完整源码:github.com/chaxus/ran/...

1.页面的加载

可以从输入URL到页面完整展示来分析

整个过程分为:

  1. 输入 URL 地址
  2. 浏览器查找对应域名的IP地址(本地hostsDNS解析)
  3. 浏览器向对应的web服务器发起一个http请求(TCP链接,SSL链接)
  4. 服务器处理请求
  5. 服务器响应
  6. 浏览器加载htmlDOM树构建,CSSOM树构建,渲染树的构建)
  7. 浏览器发送请求获取 HTML 中的资源(如图片、音频、视频等)
  8. 页面布局,页面绘制,最终呈现

由此我们可以发现,耗时的核心在于网络传输的时间和资源加载时间。网络传输速度越快,所需加载的资源体积越小,用户从输入网址到页面加载完成、可供操作的时间就越短。因此,优化网络传输效率和减小资源大小是提升用户体验的关键所在。

对于网络传输的优化

  1. 优化 HTTP 首部数据,减少不必要的字段,降低请求开销。
  2. 利用 HTTP/1.1 的多个连接实现并行下载,通过并行处理请求和响应,减少排队等待时间
  3. 升级到 HTTP/2.0,利用该协议的多路复用、头部压缩等特性,进一步提升页面加载性能。
  4. 减少 HTTP 重定向,HTTP 重定向涉及额外的 DNS 查询、TCP 握手等耗时操作。最佳实践是尽量避免重定向,或将重定向次数降至最低。
  5. 通过合并域名或优化资源引用,我们可以减少页面中需要解析的域名数量,进而降低DNS查找的次数,减少响应时间。
  6. 进行DNS预获取。在实际发起资源请求之前,预先解析域名,确保DNS解析在用户需要资源时已经完成,从而避免额外的等待时间。
html 复制代码
<link rel='dns-prefetch' href='https://chaxus.github.io/ran/'></link>

对于资源的优化

检查页面资源,删除不必要的请求,减少网络负担,提升页面加载速度。利用 Gzip、图片压缩等技术,减少传输的数据量,降低网络带宽消耗。

另一种高效的方式是采用缓存策略,它不仅能减少从服务器获取资源的次数,还能降低所需资源的大小,从而大幅减少网络传输耗时,显著提升用户体验。通过有效利用缓存,我们可以显著优化网络性能,使用户更快地看到并操作页面。

浏览器的缓存策略通常存在一定的顺序:

  • page
  • preload
  • Service Worker
  • 强缓存
  • push cache
  • 协商缓存
  • Server

2.pre-load

preload机制在浏览器加载页面的过程中处于较早的阶段。通过在 HTML<head>标签中指定资源,可以告诉浏览器,这些资源是页面后续操作中很快就要用到,希望浏览器能提前开始加载它们,甚至早于浏览器的主要渲染机制启动。这样做可以确保这些资源更早地变得可用,从而降低了它们阻塞页面渲染的风险,进而提升了整体性能。

需要注意的是,preload并不加载和执行脚本,它的作用是提升脚本的下载和缓存的优先级。

使用起来非常简单

html 复制代码
<link rel="preload" href="style.css" as="style" />
<link rel="preload" href="main.js" as="script" />

以上三个属性的具体含义如下:

属性 作用
rel preload作为属性,可以将link标签转化为预加载器
href 资源路径
as 资源类型

以下是可以预加载的类型,同时也是as属性的值

类型 描述
audio 音频文件,一般用于<audio>
document 用于嵌入<frame>的文档
embed 用于 <embed>标签中的资源。
fetch 通过 fetchXHR 请求访问的资源,例如 ArrayBufferWebAssembly 二进制文件或 JSON 文件
font 字体文件
image 图像文件
object 用于 <object> 标签中的资源。
script JavaScript 文件
style CSS 文件|
track WebVTT 文件(使用 <track>元素显示定时文本轨道(例如字幕或者标题)的格式)
worker JavaScript web workershared worker
video 视频文件

3.Service Worker

Service Worker旨在解决用户在无网络环境下无法正常使用网页的痛点,确保用户在各种网络条件下都能获得流畅、无缝的应用体验。

为实现这一目标,Service Worker被赋予了强大的功能,可以管理和控制资源的加载与网络请求。它独立于主线程运行,能够拦截和处理请求,缓存资源,以及在后台进行数据的预取和推送等操作。

想要开启Service Worker,首先要进行注册。由于也是Worker的一种,所以我们也要指定一个JavaScript文件进行开启Worker,同时还可以指定Service Worker控制的子作用域。

ts 复制代码
// 注册 Service worker
const registerServiceWorker = async () => {
  if ('serviceWorker' in window.navigator) {
    try {
      const registration = await navigator.serviceWorker.register('/ran/sw.js', {
        scope: '/ran/', // 指定`Service Worker`控制的子作用域,可选
      });
      if (registration.installing) {
        console.log('installing Service worker');
      } else if (registration.waiting) {
        console.log('Service worker installed');
      } else if (registration.active) {
        console.log('Service worker active');
      }
    } catch (error) {
      console.error('service worker register error:', error);
    }
  }
};

registerServiceWorker();

在项目中,我会把这段代码直接注入到htmlscript标签中,让浏览器加载html的时候立即执行script

进行Service Worker的注册,同时控制台输出当前Service Worker的状态:

  • installing: 正在安装中
  • waiting: 中间状态
  • active: 这个阶段表示Service Worker生效了

我们可以打开控制台,查看网络(NetWork):

第一个inpage.js是浏览器插件

后面几个gif是埋点数据上报,不建议缓存。

为了测试Service Worker的缓存能力,我甚至加载了几个视频文件(ts)。

由此可见,除了浏览器插件js,埋点上报的gif,其他所有文件都可以进行缓存,并且加载时间只有毫秒级。能够显著提升用户体验,减少服务器的访问。

需要注意的是,Service Worker是需要二次生效,也就是第一次访问的时候,必然处于:

js 复制代码
installing Service worker

请求资源,并将指定的资源缓存到本地。需要等到

Service worker active

才能完全生效

那么我们是如何指定缓存的文件呢?在webpack/vite打包构建后,文件往往都会带一串hash,以表示文件更新,如何去更新需要缓存的文件呢?

(1).指定缓存文件

我们需要用到Service Worker提供的存储API: CacheStorage,去告诉我们应该缓存哪些文件。缓存后如何取出,删除和更新。因此CacheStorage被设计成俩部分,并且类似于Map结构

其中一部分是caches,可以在Service Workerjs中直接使用,是一个全局的API,具有以下方法:

方法 例子 作用
open caches.open(cacheName).then(cache=>cache) 创建并返回一个Promise<cache>,后面会讲到cache
has caches.has(cacheName).then(boolean=>boolean); 判断是否有cacheName这个缓存
keys caches.keys().then(keyList=>keyList) 类似于Object.keys,返回一个创建顺序的cacheName数组
delete caches.delete(cacheName).then((boolearn=>boolearn)) 如果找到cacheName并删除成功,则返回true,否则false
match caches.match(key).then((value=>value)); 这个方法非常特殊,它不是在caches这个对象上找符合key的值,而是在cache,也就是caches.open(cacheName)返回的对象上,进行查找

接下来是cache对象,这是由caches.open(cacheName)创建并返回的。可以理解成CacheStorage创建并管理cache对象,cache对象才是真正管理缓存的,因此具有以下的方法:

方法 例子 作用
put cache.put(key, value) key/value添加到cache对象中,通常用法是cache.put(request, reponse)。指定缓存某一request,将request作为keyresponse作为value,进行保存。下次再遇到同样的request可以直接在cache中取出而不用发起真实的请求。取出的方式可以用match方法
add cache.add(key) cache.put的简化语法糖,不用设置value,如果keyrequest会自动发起请求,然后缓存reponse。等同于:fetch(url).then((response)=> cache.put(url, response));
match caches.match(key) 匹配并返回之前已经缓存过的request
delete cache.delete(key).then((boolearn=>boolearn)) 如果找到key并删除成功,则返回true,否则false
keys cache.keys().then(keyList=>keyList) 类似于Object.keys,返回一个创建顺序的cache的键值数组

无论是cache还是caches,所有方法均是异步,返回都是Promise

cache中除了put,add,match,delete,keys这五个基本方法,还有addAllmatchAll,类似于给addmatch方法进行了Promise.all封装。

因此,我们首先需要用CacheStorage创建一个cache,然后去添加缓存:

js 复制代码
this.addEventListener(SERVICE_WORK.INSTALL, function (event) {
    // 确保 Service Worker 不会在 waitUntil() 里面的代码执行完毕之前安装完成
    event.waitUntil(
        // 创建了叫做 chaxus_ran 的新缓存
        caches.open(CACHE_NAME).then(function (cache) {
            // SERVICE_WORK_CACHE_FILE_PATHS 从 bin/build.sh 中生成注入,会去缓存所有的资源
            // 不用 cache.addAll 避免一个请求失败,全部缓存失败,类似 Promise.all
            // 可以使用 cache.add 但 Cache.add/Cache.addAll 不会缓存 Response.status 值不在 200 范围内的响应,
            // 而 cache.put 允许你存储任何请求/响应对。因此,Cache.add/Cache.addAll 不能用于不透明的响应,而 Cache.put 可以。
            return SERVICE_WORK_CACHE_FILE_PATHS.map(url =>
                fetch(url).then(response => {
                    // 检查响应是否成功
                    if (!response.ok) {
                        console.log('service worker fetch response error:', url)
                    }
                    // 将响应添加到缓存
                    return cache.put(url, response);
                }).catch(error => {
                    console.log('service worker self installed error:', url, error);
                })
            )
        })
    );
});

如果上述代码执行无误,那么页面一加载进来,就会进行Service Worker的安装,同时创建一个名字叫CACHE_NAMECacheStorage,将SERVICE_WORK_CACHE_FILE_PATHS里面的资源进行fetch请求,并将请求的结果进行put方法进行缓存。

这里会存在两个问题:

  1. 缓存后,下次如何使用?
  2. 当前页面存在tab切换,有些资源不会一进入页面就发起请求。

我们可以在Service Worker安装阶段,指定需要缓存的文件,我的做法是,缓存vite打包构建出来的dist目录下的 所有文件

为了方便集成到CI/CD环节,无需手动更新打包生成的dist文件。我写一个shell脚本,去读取dist目录下文件的路径,写入sw.js,进行缓存。

在执行pnpm run build,优先进行打包构建:

bash 复制代码
# 执行 ssg 构建命令
bin=./node_modules/.bin
$bin/vitepress build

构建完成后,读取dist目录文件路径,追加到sw.js文件中

sh 复制代码
# 指定输出的目录
dir="./.vitepress/dist"
# 生成的目标文件
target="./.vitepress/dist/sw.js"
# 创建一个临时文件
tmpfile=$(mktemp)
# 将目录 dir 下的文件名追加到临时文件中
find "$dir" -type f > "$tmpfile"
# 临时文件
SERVICE_WORK_VARABLE="./.vitepress/dist/sw-file.js"
# 拼接字符串
echo "const SERVICE_WORK_CACHE_FILE_PATHS = [" > "$SERVICE_WORK_VARABLE"
# 根路径
ran="/ran"
while read -r file; do
  # if [[ $file != *".DS_Store"* ]]; then
  str="${file##./.vitepress/dist}"
  echo "\"$ran$str\"," >> "$SERVICE_WORK_VARABLE"
  # fi
done < "$tmpfile"
# 拼接字符串
echo "];" >> "$SERVICE_WORK_VARABLE"
# 删除临时文件
rm "$tmpfile"

tmpfile=$(mktemp)

cat "$SERVICE_WORK_VARABLE" >> "$tmpfile"

cat "$target" >> "$tmpfile"

mv "$tmpfile" "$target"

echo "service work file paths have been generate for $target"

这时候,我们再去看sw.js文件:

js 复制代码
const SERVICE_WORK_CACHE_FILE_PATHS = [
"/ran/icon.png",
"/ran/icon_168.png",
"/ran/favicon.ico",
"/ran/home.svg",
"/ran/icon_96.png",
"/ran/icon_144.png",
"/ran/icon_192.png",
...
]
// service worker 可监听的事件
const SERVICE_WORK = {
    INSTALL: 'install',
    FETCH: 'fetch',
    ACTIVATE: 'activate',
    MESSAGE: 'message',
    SYNC: 'sync',
    PUSH: 'push'
}

const CACHE_NAME = 'chaxus_ran'

this.addEventListener(SERVICE_WORK.INSTALL, function (event) {
    // 确保 Service Worker 不会在 waitUntil() 里面的代码执行完毕之前安装完成
    event.waitUntil(
        // 创建了叫做 chaxus_ran 的新缓存
        caches.open(CACHE_NAME).then(function (cache) {
            // SERVICE_WORK_CACHE_FILE_PATHS 从 bin/build.sh 中生成注入,会去缓存所有的资源
            // 不用 cache.addAll 避免一个请求失败,全部缓存失败,类似Promise.all
            // 可以使用 cache.add 但 Cache.add/Cache.addAll 不会缓存 Response.status 值不在 200 范围内的响应,
            // 而 cache.put 允许你存储任何请求/响应对。因此,Cache.add/Cache.addAll 不能用于不透明的响应,而 Cache.put 可以。
            return SERVICE_WORK_CACHE_FILE_PATHS.map(url =>
                fetch(url).then(response => {
                    // 检查响应是否成功
                    if (!response.ok) {
                        console.log('service worker fetch response error:', url)
                    }
                    // 将响应添加到缓存
                    return cache.put(url, response);
                }).catch(error => {
                    console.log('service worker self installed error:', url, error);
                })
            )
        })
    );
});

这时候我们就可以配置ci.yml,在push或者merge request后,进行CI/CD

yml 复制代码
      - name: Build docs
        run: pnpm -F docs build

      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.RAN_ACTIONS_TOKEN }}
          exclude_assets: ""
          publish_dir: packages/docs/.vitepress/dist

在页面访问的时候,通过Service Worker中拦截请求的能力。我们在Service Worker中监听fetch事件:

js 复制代码
this.addEventListener(SERVICE_WORK.FETCH, (event) => {
    // 拦截请求
    try {
        const { request } = event
        // 忽略 IGNORE_REQUEST_LIST 中的请求,不进行拦截
        if (filterRequest(request)) {
            const responseFromServer = cacheFirst(request)
            if (responseFromServer?.clone) {
                event.respondWith(responseFromServer);
            }
        }
    } catch (error) {
        console.log('service worker self fetch error:', error, event)
    }
});

// 优先查找缓存中是否存在
const cacheFirst = async (request) => {
    // 从缓存中读取 respondWith 表示拦截请求并返回自定义的响应
    try {
        const { url } = request
        const responseFromCache = await caches.match(url);
        // 如果缓存中有,返回已经缓存的资源
        if (responseFromCache) return responseFromCache
        // 如果缓存中没有,就从网络中请求,并更新到缓存中
        const responseFromServer = await fetch(request);
        updateCache(responseFromServer, request)
        return responseFromServer
    } catch (error) {
        // 当缓存中也没有,请求也不可用的时候
        // 始终需要一个一个响应
        // 甚至可以设置回落的请求,在catch中继续发起请求
        console.log('service worker cacheFirst error:', error, request)
        return new Response("Network error happened", {
            status: 408,
            headers: { "Content-Type": "text/plain" },
        });
    }
}

拦截所有的请求,优先通过caches.match(url);进行查找,如果有缓存,取缓存的结果,不发起请求。如果没有缓存,则发起请求,并将请求响应添加到缓存cache中,返回请求的响应。

需要注意的是,有些资源或者请求比如chrome插件,埋点等等,这些请求是不应该拦截的,所以我们应该增加判断。

同时,应当只有GET请求是幂等和可缓存的,因此也需要过滤下GET

javascript 复制代码
const IGNORE_REQUEST_LIST = [
    // google 上报不需要缓存
    'google',
    // 插件请求不用缓存
    'chrome-extension',
    // 百度的请求不用缓存
    'baidu.com',
    'blob:',
    'www.google-analytics.com'
]
/**
 * @description: 忽略 IGNORE_REQUEST_LIST 列表中的请求和非GET方法的请求
 * @param {*} request
 * @return {*}
 */
const filterRequest = (request) => {
    const { url, method } = request
    return !IGNORE_REQUEST_LIST.some(item => url.includes(item)) && method === REQUEST_METHOD.GET
}

至此,我们已经成功安装了Service Worker并将指定的资源添加到了CacheStorge中,并且也知道了什么情况下使用缓存。那么接下来就剩下一个问题:如何更新和删除缓存,确保用户能看到最新的内容。

(2).Service Worker缓存与更新:

由于我们是在htmlscript中进行注册的,所以我们首先要保证,html没有被缓存:

比如这种情况下,要么就等缓存过期,要么就手动清除缓存,我们可以选择控制台上的应用,选择存储,清除网站数据即可:

或者是查看当前是哪个Service Worker资源生效,进行手动卸载:

在应用,service worker,来源 可显示当前生效的是哪个Service Workerjs,可以进行手动取消注册,用于获取最新的Service Workerjs

以上两种手动的方式主要是方便我们调试,正常情况下,我们都不会去强缓存html,一旦入口被强缓存了就只能等缓存过期,或者强刷。

我们更常见的做法是把入口做一个协商缓存,如果文件没有变化,则走缓存,如果文件发生变化,则重新获取新的文件。

因此,当html更新后,页面会去拉取最新的html,由于Service Worker的注册是页面的一段script,因此会去重新执行。

我们需要去更新Service Workerjs的文件名字或内容,确保名字或内容有变化。否则依然会访问原来的sw.js

但如果每次都去手动修改sw.js的名字和sw.js的内容,会很繁琐,而且修改后的sw.js名字,要和注册时的名字一致:

js 复制代码
navigator.serviceWorker.register('/ran/sw.js')

对于这种情况下,我们也可以进行自动化CI处理。

  1. 对于文件名字,我们可以用shell脚本生成一个时间戳,追加到文件名后。
  2. 对于注册时的文件名需要保持一致,我们将时间戳生成后,注入到注册代码中
  3. 对于文件内容的变化,我们在创建Cache Storage时,名字追加一个版本号,每次部署都清空之前的Cache Storage,减少用户内存占用,同时生成新的Cache Storage

最终shell脚本如下:

sh 复制代码
#!/bin/bash
# 更新 service work的版本号
version=$(date +%s)
# 将版本号写入 variable 目录下 SERVICE_WORK_VERSION.ts
SERVICE_WORK_VERSION="./variable/SERVICE_WORK_VERSION.ts"

echo "export const SERVICE_WORK_VERSION = \"$version\"" > $SERVICE_WORK_VERSION
# 执行 ssg 构建命令
bin=./node_modules/.bin
$bin/vitepress build
# 开启调试模式
# set -x
# 指定输出的目录
dir="./.vitepress/dist"
# 生成的目标文件
target="./.vitepress/dist/sw.js"
# 改名
mv "$target" "./.vitepress/dist/sw$version.js"

target="./.vitepress/dist/sw$version.js"
# 创建一个临时文件
tmpfile=$(mktemp)
# 将目录 dir 下的文件名追加到临时文件中
find "$dir" -type f > "$tmpfile"
# service worker中生成
# SERVICE_WORK_CACHE_FILE_PATHS(根据打包后生成的文件来生成)
# VERSION (时间戳)
# 的临时文件
SERVICE_WORK_VARABLE="./.vitepress/dist/sw-file.js"

# 拼接字符串
echo "const SERVICE_WORK_CACHE_FILE_PATHS = [" > "$SERVICE_WORK_VARABLE"
# 根路径
ran="/ran"
while read -r file; do
  # if [[ $file != *".DS_Store"* ]]; then
  str="${file##./.vitepress/dist}"
  echo "\"$ran$str\"," >> "$SERVICE_WORK_VARABLE"
  # fi
done < "$tmpfile"
# 拼接字符串
echo "];" >> "$SERVICE_WORK_VARABLE"
# 更新 sw 的版本号
echo "const VERSION = \"$version\";" >> "$SERVICE_WORK_VARABLE"
# 删除临时文件
rm "$tmpfile"

tmpfile=$(mktemp)

cat "$SERVICE_WORK_VARABLE" >> "$tmpfile"

cat "$target" >> "$tmpfile"

mv "$tmpfile" "$target"

rm "$SERVICE_WORK_VARABLE"

## 打印完成消息
echo "service work file paths have been generate for $target"
# 关闭调试模式
# set +x

当以上缓存都没有生效的时候,就会继续判断,是否有强缓存

4.强缓存

强缓存(Strong Caching),也称为本地缓存,是指浏览器或缓存代理服务器在一定时间内不向服务器发送请求,而是直接从本地缓存中读取资源。强缓存通过 HTTP 头部字段来控制。主要可以分为三种情况:

  • Expires

  • Cache-Control: max-age=100

  • Cache-Control: public,s-maxage=100

(1).Expires

yaml 复制代码
Expires: Wed, 21 Oct 2015 07:28:00 GMT

设置的是一个http日期,表示在这个日期之前,如果浏览器有缓存的话,直接使用浏览器的缓存,无需要向服务器发起请求。

光说概念可不行,我们可以造一个例子来验证一下:

ts 复制代码
import http from 'node:http'

const server = http.createServer((req, res) => {
  if (req.url === '/') {
    const expires = new Date(Date.now() + 100000).toUTCString()
    res.setHeader('Content-Type', 'text/html');
    res.setHeader('Expires', expires)
    res.write('Welcome to the homepage\n')
    console.log('expires:', expires)
    res.end();
  }
});

server.listen(3000, () => {
  console.log('Server is running on http://localhost:3000');
});

这里简单的启动了一个 node 服务,我们去访问根路径,页面上就会展示Welcome to the homepage

然后就会发现,

Expires完全没用。每次刷新仍然会请求服务器,打印出console.log

不是说好强缓存在没过期之前,是不会请求服务器的吗?

这是因为chrome浏览器会默认请求主路径的时候,请求头会带上max-age=0。意思就是不缓存。比如上面的例子:

并且max-age=0的优先级还高于Expires,导致缓存html完全没有效果。

所以在这种情况下,需要额外造一个派生资源。

ts 复制代码
import http from 'node:http'

const server = http.createServer((req, res) => {
  if (req.url === '/') {
    res.setHeader('Content-Type', 'text/html');
    res.write('Welcome to the homepage:\n')
    res.write('<script src="/expires.js"></script>');
    res.end();
  }

  if (req.url === '/expires.js') {
    const expires = new Date(Date.now() + 100000).toUTCString()
    res.setHeader('Content-Type', 'text/javascript')
    res.setHeader('Expires', expires)
    console.log('expires.js:', expires)
    res.end(`(()=>document.body.append(\n 'expires.js: ${expires}'))()`);
  }
});

server.listen(3000, () => {
  console.log('Server is running on http://localhost:3000');
});

启动服务,检查js文件的缓存情况

然后不断的刷新页面,再检查服务的控制台输出

发现只输出一条,说明强缓存确实不会请求到服务器。但前提是:非主资源,请求头没有max-age=0

(2).Cache-Control: max-age=100

由于不请求服务端,采用客户端时间,存在和服务器时间不一致的问题,因此在HTTP/1.1,扩展了新的字段Cache-Control去控制缓存的行为。就是max-age

而且max-age被设置成了一个时间段,表示请求到过期之间时间。在这个时间内,都会返回本地的资源,不会去请求服务器。

这个例子也非常简单:

ts 复制代码
import http from 'node:http'

const server = http.createServer((req, res) => {
  if (req.url === '/') {
    res.setHeader('Content-Type', 'text/html');
    res.write('Welcome to the homepage:\n')
    res.write('<script src="/expires.js"></script>');
    res.write('<script src="/maxage.js"></script>');
    res.end();
  }

  if (req.url === '/expires.js') {
    const expires = new Date(Date.now() + 100000).toUTCString()
    res.setHeader('Content-Type', 'text/javascript')
    res.setHeader('Expires', expires)
    console.log('expires.js:', expires)
    res.end(`(()=>document.body.append(\n 'expires.js: ${expires}'))()`);
  }
    if(req.url === '/maxage.js') {
    const maxAge = 100000
    res.setHeader('Content-Type', 'text/javascript')
    res.setHeader('Cache-Control', `max-age=${maxAge}`)
    console.log('maxage.js:', maxAge)
    res.end(`(()=>document.body.append(\n 'maxage.js: ${maxAge}'))()`);
  }
});

server.listen(3000, () => {
  console.log('Server is running on http://localhost:3000');
});

启动服务,访问页面,就发现maxage.js资源的第二次请求不会请求服务器了。

(3).Cache-Control: s-maxage=100

这个字段严格意义上讲不算是客户端的强缓存。属于代理服务器的强缓存。正常情况中,用户访问网站请求资源的时候,并不会直接的访问到真正的服务器。会有很多中间层做访问控制、内容过滤、加速访问等操作。比如CDN服务,可以让用户访问资源在地理位置上做到就近访问,以提高资源的访问速度。如果资源设置的是Cache-control: public,就表明资源是这些中间层服务器都可以缓存的。如果是Cache-control: private,说明这个资源非常重要,中间层服务器不能缓存,直接用户客户端或者访问到最终的服务器上获取资源。

这里会先遇到publicprivate属性

属性 作用 例子
public 表示当前资源可以被任意服务器缓存 Cache-control: public
private 当前资源只能被用户客户端或者当前服务器缓存,代理和中间层服务器不能缓存 Cache-control: private

因此我们需要造一个服务器和一个代理服务器。同时把资源设置成public:

反向代理的服务器:

conf 复制代码
# nginx.conf
    server {
        listen 3001;

        location /smaxage {
            proxy_pass http://localhost:3000;
            proxy_cache my_cache;
            proxy_cache_valid 200 30s;
        }
    }

    proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=my_cache:10m max_size=10g inactive=60m use_temp_path=off;

在启动前,可以通过 nginx -t -c nginx.conf来测试下是否配置正确

nginx -s reload -c nginx.conf来启动nginx服务

proxy_cache_path的作用如下:

  • /tmp/nginx_cache :指定缓存文件存储路径。在这个例子中,缓存文件将存储在 /tmp/nginx_cache 目录中。
  • levels=1:2 :指定缓存目录的层级结构。这意味着缓存将以两级子目录存储,第一层有1个字符,第二层有2个字符。例如,缓存文件可能存储在 /tmp/nginx_cache/a/1b/ 目录中。
  • keys_zone=my_cache:10m :定义一个名为 my_cache 的共享内存区域,用于存储缓存键及元数据,其大小为 10 兆字节。
  • max_size=10g:设置缓存的最大大小为 10 GB。当缓存达到此大小时,Nginx 将根据最久未使用(LRU)策略清理缓存。
  • inactive=60m:设置缓存项目的过期时间。如果一个缓存项目在 60 分钟内未被访问,它将被删除。
  • use_temp_path=off:禁用临时路径。这意味着缓存文件将直接写入最终缓存路径,而不是先写入临时路径再移动。

再给之前的node服务添加上smaxage的测试例子:

ts 复制代码
import http from 'node:http'

const server = http.createServer((req, res) => {
  if (req.url === '/') {
    res.setHeader('Content-Type', 'text/html');
    res.write('Welcome to the homepage:\n')
    res.write('<script src="/expires.js"></script>');
    res.write('<script src="/maxage.js"></script>');
    res.write('<script src="/smaxage.js"></script>');
    res.end();
  }

  if (req.url === '/expires.js') {
    const expires = new Date(Date.now() + 100000).toUTCString()
    res.setHeader('Content-Type', 'text/javascript')
    res.setHeader('Expires', expires)
    console.log('expires.js:', expires)
    res.end(`(()=>document.body.append(\n 'expires.js: ${expires}'))()`);
  }
  if(req.url === '/maxage.js') {
    const maxAge = 100000
    res.setHeader('Content-Type', 'text/javascript')
    res.setHeader('Cache-Control', `max-age=${maxAge}`)
    console.log('maxage.js:', maxAge)
    res.end(`(()=>document.body.append(\n 'maxage.js: ${maxAge}'))()`);
  }
  if(req.url === '/smaxage.js') {
    const sMaxAge = 100000
    res.setHeader('Content-Type', 'text/javascript')
    res.setHeader('Cache-Control', `public, s-maxage=${sMaxAge}`);
    console.log('smaxage.js:', sMaxAge)
    res.end(`(()=>document.body.append(\n 'smaxage.js: ${sMaxAge}'))()`);
  }
});

server.listen(3000, () => {
  console.log('Server is running on http://localhost:3000');
});

启动服务,访问http://localhost:3001;,多刷新几次,看看服务器最终打印了几次。

为什么这里要用nginx,可以用两个node服务吗?

也不是不可以,就是有点麻烦,还是nginx作为代理服务器更加简单好用。

s-maxage这个字段只在代理服务器生效,但通过http.createServer创建的只是普通的HTTP服务。如果要作为代理服务器,还需要手动实现代理服务器的功能。比如需要实现请求转发,通过memory-cache实现代理服务器的缓存。或者还可以使用http-proxy去手动实现。

5.协商缓存

协商缓存主要依靠两对字段,在HTTP/1.1协议中加入:

request response
If-None-Match ETag
If-Modified-Since Last-Modified
  1. If-None-Match: 客户端发送该字段以传达之前获取的资源的 ETag 值。服务器可以使用这个值来判断资源是否已经发生了变化。如果资源的 ETag 与客户端发送的匹配,则服务器返回 304 Not Modified
  2. If-Modified-Since: 客户端发送该字段以传达之前获取的资源的最后修改时间。服务器可以使用这个时间来判断资源是否已经更新。如果资源的最后修改时间与客户端发送的时间匹配,并且没有其他更新,则服务器返回 304 Not Modified
  3. ETag(实体标签): 服务器返回资源时,可以附加一个 ETag,它是一个资源的唯一标识符,通常是根据资源内容生成的哈希值。客户端在后续请求中可以将该 ETag 发送回服务器,服务器使用它来检查资源是否发生了变化。如果资源的 ETag 与客户端发送的匹配,则服务器返回 304 Not Modified 响应,表示资源未改变,可以使用缓存副本。
  4. Last-Modified(最后修改时间): 服务器在返回资源时会附加一个 Last-Modified 头部,表示资源的最后修改时间。客户端在后续请求中会将这个时间发送回服务器,服务器可以根据这个时间判断资源是否已经更新。如果资源的 Last-Modified 时间与客户端发送的时间匹配,并且没有其他更新,则服务器返回 304 Not Modified 响应。

我们同样可以造一个例子来看看:

ts 复制代码
const http = require('http');
const fs = require('fs');
const crypto = require('crypto');
const url = require('url');

const server = http.createServer((req, res) => {
  // 获取请求的资源路径
  const requestUrl = url.parse(req.url).pathname;
  const filePath = __dirname + requestUrl;

  // 检查请求头中的 If-None-Match 和 If-Modified-Since 字段
  const ifNoneMatch = req.headers['if-none-match'];
  const ifModifiedSince = req.headers['if-modified-since'];

  fs.readFile(filePath, (err, data) => {
    if (err) {
      res.writeHead(404);
      res.end('Not Found');
      return;
    }

    // 计算资源的 ETag(实体标签)和最后修改时间
    const fileStats = fs.statSync(filePath);
    const fileETag = crypto.createHash('md5').update(data).digest('hex');
    const fileLastModified = fileStats.mtime.toUTCString();

    // 检查缓存协商
    if (ifNoneMatch === fileETag || ifModifiedSince === fileLastModified) {
      res.writeHead(304); // 资源未改变
      res.end();
    } else {
      // 返回资源及相关头部信息
      res.setHeader('ETag', fileETag);
      res.setHeader('Last-Modified', fileLastModified);
      res.writeHead(200);
      res.end(data);
    }
  });
});

server.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

最后在介绍下cache-control的其他属性

属性 作用 例子
no-cache 协商缓存,需要要服务器发起请求,判断缓存是否过期,没过期直接用浏览器缓存,可以跳过http响应的下载,过期了就从服务器获取最新资源 Cache-control: no-cache
no-store 不缓存,每次从服务器获取最新资源 Cache-control: no-store
max-age=<seconds> 设置一个过期 seconds,相对于请求的时间,超过这个时间表示缓存过期 Cache-control: no-store
s-maxage=<seconds> max-age作用一致,设置一个过期 seconds,但只对代理服务器有效,private的缓存会忽略它。如果和max-age都存在,优先使用s-maxage Cache-control: public,s-maxage=600
max-stale[=<seconds>] 如果请求携带了max-stale,就算缓存过期了,仍然要返回和使用,但不能超过max-stale[=<seconds>]的时间 Cache-control: max-stale=600
min-fresh=<seconds> 最小更新时间,客户端不能一直请求最新资源,同时服务端也不能频繁更新资源,如果更新资源时间小于min-fresh,客户端不接受 Cache-control: min-fresh=600
only-if-cached 客户端只接受已经缓存的响应 Cache-control: only-if-cached
must-revalidate 不缓存,每次从服务器获取最新资源 Cache-control: must-revalidate
no-transform 不能对资源进行格式变更,比如代理服务器可能会对资源进行gzip压缩,或者对图片进行转webpno-transform不允许这样做 Cache-control: no-transform

当以上的缓存都失效的时候,还会有一个Push Cache缓存

6.Push Cache

Push Cache是由HTTP/2.0协议推出的功能。用于解决客户端可能需要多次请求服务器的问题。举一个例子:

小冉同学去图书馆借一本操作系统回宿舍装着。看到一半发现需要C语言知识,于是又去图书馆借了一半C语言精讲。看到一半又发现需要数据结构与算法知识,于是又去图书馆借了一本数据结构与算法。看到一半发现又需要编译原理,于是又去图书馆借了一本编译原理。

对于客户端请求htmlhtml就像一个资源清单,里面会有很多js,css甚至图片和视频资源的地址。

当客户端执行到了再去请求资源,就会来来回回请求很多次。

Push Cache就是这个作用,当小冉同学去图书馆借了一本操作系统,图书馆发现小冉同学水平一般,于是就把C语言精讲,数据结构与算法知识,编译原理全部一次性都塞给小冉同学。这时候小冉同学在学习操作系统的时候,发现有不会的地方,先检查Push CachePush Cache中有就可以直接取出,不用再去图书馆了。从而减少了客户端对服务器的请求。

因此push cache也不需要和服务端进行交互,优先级甚至在协商缓存之前,但需要HTTP/2.0协议的支持。

ts 复制代码
const http2 = require('http2');
const fs = require('fs');
const crypto = require('crypto');

const server = http2.createSecureServer({
  key: fs.readFileSync('server-key.pem'),
  cert: fs.readFileSync('server-cert.pem')
});

server.on('stream', (stream, headers) => {
  const requestPath = headers[':path'];
  const filePath = __dirname + requestPath;
  
  fs.readFile(filePath, (err, data) => {
    if (err) {
      stream.respond({ ':status': 404 });
      stream.end('Not Found');
      return;
    }

    const fileETag = crypto.createHash('md5').update(data).digest('hex');
    const fileStats = fs.statSync(filePath);
    const fileLastModified = fileStats.mtime.toUTCString();

    const ifNoneMatch = headers['if-none-match'];
    const ifModifiedSince = headers['if-modified-since'];

    if (ifNoneMatch === fileETag || ifModifiedSince === fileLastModified) {
      stream.respond({ ':status': 304 });
      stream.end();
    } else {
      stream.respond({
        ':status': 200,
        'content-type': 'text/html',
        'etag': fileETag,
        'last-modified': fileLastModified
      });
      stream.end(data);

      // 推送缓存示例
      const pushHeaders = { ':path': '/pushed-resource.html' };
      const pushData = '<html><body>Push Cache Content</body></html>';
      stream.pushStream(pushHeaders, (err, pushStream) => {
        if (err) throw err;
        pushStream.respond({
          ':status': 200,
          'content-type': 'text/html'
        });
        pushStream.end(pushData);
      });
    }
  });
});

server.listen(3000, () => {
  console.log('HTTP/2 server running on https://localhost:3000');
});

Push Cache 有几个特点和注意事项:

  1. 由服务器推送: Push Cache 中的资源是由服务器推送到客户端的,不是客户端请求的结果。这意味着服务器可以主动向客户端推送可能需要的资源,以减少请求的延迟。
  2. 独立于浏览器缓存: Push Cache 是独立于浏览器的普通缓存(例如 HTTP 缓存)的。即使客户端已经有了某些资源的缓存,服务器仍然可以将这些资源推送到客户端的 Push Cache 中。
  3. 依赖于服务器支持: Push Cache 功能需要服务器和客户端都支持 HTTP/2 协议,并且服务器能够正确地使用 Server Push 功能。如果服务器不支持或未正确配置 Server PushPush Cache 将无法发挥作用。

需要注意的是,Push Cache 并不是所有浏览器都实现和支持的功能。它主要是在 HTTP/2 中引入的,并且需要服务器和客户端的支持。因此,在使用 Push Cache 时需要确保环境和条件的支持和兼容性。

总结

至此,web所有缓存情况都讲完了,从优先级来说:

客户端会优先Service Worker,再到强缓存,再到Push Cache。以上三种缓存方式,都是生效后,不再向服务端发起请求。然后是协商缓存,最后是没有缓存,需要向服务端拉去资源。

有的浏览器会有自己的默认缓存策略,比如chrome在请求主资源,默认请求头携带max-age=0。但不是通用情况,就不讨论,这里提一下这种情况

基于以上的能力,我们可以将页面中大多数资源,在二次访问的时候,全部从缓存中获取

  • 减少服务器响应时间,提升用户体验。从内存中获取资源基本在毫秒级
  • 减少服务器的请求压力,降低服务器成本。首次访问才需要拉去资源,后续全部走缓存。

如果想检测在各个阶段的耗时情况,可以采用以下的代码:

ts 复制代码
interface BasicType {
  [x: string]: number | undefined;
  dnsSearch: number; // DNS 解析耗时
  tcpConnect: number; // TCP 连接耗时
  sslConnect: number; // SSL 安全连接耗时
  request: number; // TTFB 网络请求耗时
  response: number; // 数据传输耗时
  parseDomTree: number; // DOM 解析耗时
  resource: number; // 资源加载耗时
  domReady: number; // DOM Ready
  httpHead: number; // http 头部大小
  interactive: number; // 首次可交互时间
  complete: number; // 页面完全加载
  redirect: number; // 重定向次数
  redirectTime: number; // 重定向耗时
  duration: number; // 资源请求的总耗时 responseEnd-startTime
  fp: number | undefined; // 渲染出第一个像素点,白屏时间
  fcp: number | undefined; // 渲染出第一个内容,首屏结束时间
}

export function getPerformance(): BasicType | undefined {
  if (typeof window !== 'undefined') {
    const [performanceNavigationTiming] = performance.getEntriesByType('navigation');
    const [firstPaint = {}, firstContentfulPaint = {}] = performance.getEntriesByType('paint');
    const { startTime: fp } = firstPaint as PerformancePaintTiming;
    const { startTime: fcp } = firstContentfulPaint as PerformancePaintTiming;
    const {
      domainLookupEnd,
      domainLookupStart,
      connectEnd,
      connectStart,
      secureConnectionStart,
      loadEventStart,
      domInteractive,
      domContentLoadedEventEnd,
      duration,
      responseStart,
      requestStart,
      responseEnd,
      fetchStart,
      transferSize,
      encodedBodySize,
      redirectEnd,
      redirectStart,
      redirectCount,
    } = performanceNavigationTiming as PerformanceNavigationTiming;
    return {
      // DNS 
      dnsSearch: domainLookupEnd - domainLookupStart,
      // TCP
      tcpConnect: connectEnd - connectStart,
      sslConnect: connectEnd - secureConnectionStart,
      request: responseStart - requestStart,
      response: responseEnd - responseStart,
      parseDomTree: domInteractive - responseEnd,
      resource: loadEventStart - domContentLoadedEventEnd,
      domReady: domContentLoadedEventEnd - fetchStart,
      interactive: domInteractive - fetchStart,
      complete: loadEventStart - fetchStart,
      httpHead: transferSize - encodedBodySize,
      redirect: redirectCount,
      // redirect
      redirectTime: redirectEnd - redirectStart,
      duration,
      fp,
      fcp,
    };
  }
}

参考文档:

  1. Service Worker MDN
  2. 浏览器缓存、CacheStorage、Web Worker 与 Service Worker
  3. 通过通知推送让 PWA 可重用
  4. HTTP/2 push
  5. HTTP/2 Server Push and Cache-Digest
  6. Cache-Control MDN
  7. rel=preload MDN
  8. Push-Pull Caching

其他推荐

相关推荐
一条不想当淡水鱼的咸鱼5 分钟前
taro转H5端踩坑
前端·taro
傻小胖19 分钟前
React Context用法总结
前端·react.js·前端框架
xsh801442421 小时前
Java Spring Boot监听事件和处理事件
java·前端·数据库
JINGWHALE11 小时前
设计模式 行为型 状态模式(State Pattern)与 常见技术框架应用 解析
前端·人工智能·后端·设计模式·性能优化·系统架构·状态模式
摇光931 小时前
js状态模式
开发语言·javascript·状态模式
Smile_zxx1 小时前
windows 下npm 使用 n 切换node版本
前端·windows·npm
柠檬豆腐脑1 小时前
前端构建工具的发展和现状:Webpack、Vite和其他
前端·webpack·vite
灰色人生qwer2 小时前
React中的useMemo 和 useEffect 哪个先执行?
前端·react.js
GISer_Jing2 小时前
React进阶内容大纲Map
前端·react.js·前端框架
_未知_开摆2 小时前
css盒子水平垂直居中
前端·javascript·html