浏览器缓存与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

其他推荐

相关推荐
安冬的码畜日常1 分钟前
【CSS in Depth 2精译】2.5 无单位的数值与行高
前端·css
ilisi_2 分钟前
导航栏样式,盒子模型
前端·javascript·css
吉吉安11 分钟前
grid布局下的展开/收缩过渡效果【vue/已验证可正常运行】
前端·javascript·vue.js
梦凡尘16 分钟前
Vue3 对跳转 同一路由传入不同参数的页面分别进行缓存
前端·javascript·vue.js
攒了一袋星辰16 分钟前
Webpack安装以及快速入门
前端·webpack·node.js
吃饱很舒服31 分钟前
kotlin distinctBy 使用
android·java·开发语言·前端·kotlin
柳晓黑胡椒40 分钟前
vue3实现多表头列表el-table,拖拽,鼠标滑轮滚动条优化
javascript·vue.js·elementui·el-table
勤劳兔码农1 小时前
从IE到Edge:微软浏览器的演变与未来展望
前端·microsoft·edge
limit for me1 小时前
在uni-app使用vue3使用vuex
javascript·vue.js·uni-app
web守墓人1 小时前
【前端】解决element-ui两层dialog嵌套,遮罩层消失的问题。
前端·ui