如何进一步提升用户体验,始终是一个热议且不断探索的问题。对于web
应用而言,提升用户体验主要集中在两大关键方面:一方面,需要注重应用加载时的性能优化,确保用户可以快速、流畅地访问应用;另一方面,则要在运行时持续优化性能,以保障应用在运行过程中的稳定性与流畅度。这两方面的优化共同构成了提升web
应用用户体验的重要策略。
因此,本文主要探讨浏览器的缓存策略,加载策略,并实现一个应用。同时开源所有代码供大家参考。若在阅读过程中有所受益,恳请点赞和GitHub
上点个Star
。若有任何疑问或建议,欢迎在评论区畅所欲言,共同探讨进步。
完整源码:github.com/chaxus/ran/...
1.页面的加载
可以从输入URL
到页面完整展示来分析
整个过程分为:
- 输入
URL
地址 - 浏览器查找对应域名的
IP
地址(本地hosts
,DNS
解析) - 浏览器向对应的
web
服务器发起一个http
请求(TCP
链接,SSL
链接) - 服务器处理请求
- 服务器响应
- 浏览器加载
html
(DOM
树构建,CSSOM
树构建,渲染树的构建) - 浏览器发送请求获取
HTML
中的资源(如图片、音频、视频等) - 页面布局,页面绘制,最终呈现
由此我们可以发现,耗时的核心在于网络传输的时间和资源加载时间。网络传输速度越快,所需加载的资源体积越小,用户从输入网址到页面加载完成、可供操作的时间就越短。因此,优化网络传输效率和减小资源大小是提升用户体验的关键所在。
对于网络传输的优化:
- 优化
HTTP
首部数据,减少不必要的字段,降低请求开销。 - 利用
HTTP/1.1
的多个连接实现并行下载,通过并行处理请求和响应,减少排队等待时间 - 升级到
HTTP/2.0
,利用该协议的多路复用、头部压缩等特性,进一步提升页面加载性能。 - 减少
HTTP
重定向,HTTP
重定向涉及额外的DNS
查询、TCP
握手等耗时操作。最佳实践是尽量避免重定向,或将重定向次数降至最低。 - 通过合并域名或优化资源引用,我们可以减少页面中需要解析的域名数量,进而降低
DNS
查找的次数,减少响应时间。 - 进行
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 | 通过 fetch 或 XHR 请求访问的资源,例如 ArrayBuffer 、WebAssembly 二进制文件或 JSON 文件 |
font | 字体文件 |
image | 图像文件 |
object | 用于 <object> 标签中的资源。 |
script | JavaScript 文件 |
style | CSS 文件| |
track | WebVTT 文件(使用 <track> 元素显示定时文本轨道(例如字幕或者标题)的格式) |
worker | JavaScript web worker 或 shared 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();
在项目中,我会把这段代码直接注入到html
的script
标签中,让浏览器加载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 Worker
的js
中直接使用,是一个全局的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 作为key ,response 作为value ,进行保存。下次再遇到同样的request 可以直接在cache 中取出而不用发起真实的请求。取出的方式可以用match 方法 |
add |
cache.add(key) |
cache.put 的简化语法糖,不用设置value ,如果key 是request 会自动发起请求,然后缓存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
这五个基本方法,还有addAll
和matchAll
,类似于给add
和match
方法进行了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_NAME
的CacheStorage
,将SERVICE_WORK_CACHE_FILE_PATHS
里面的资源进行fetch
请求,并将请求的结果进行put
方法进行缓存。
这里会存在两个问题:
- 缓存后,下次如何使用?
- 当前页面存在
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
缓存与更新:
由于我们是在html
的script
中进行注册的,所以我们首先要保证,html
没有被缓存:
比如这种情况下,要么就等缓存过期,要么就手动清除缓存,我们可以选择控制台上的应用,选择存储,清除网站数据即可:
或者是查看当前是哪个Service Worker
资源生效,进行手动卸载:
在应用,service worker
,来源 可显示当前生效的是哪个Service Worker
的js
,可以进行手动取消注册,用于获取最新的Service Worker
的js
。
以上两种手动的方式主要是方便我们调试,正常情况下,我们都不会去强缓存html
,一旦入口被强缓存了就只能等缓存过期,或者强刷。
我们更常见的做法是把入口做一个协商缓存,如果文件没有变化,则走缓存,如果文件发生变化,则重新获取新的文件。
因此,当html
更新后,页面会去拉取最新的html
,由于Service Worker
的注册是页面的一段script
,因此会去重新执行。
我们需要去更新Service Worker
的js
的文件名字或内容,确保名字或内容有变化。否则依然会访问原来的sw.js
。
但如果每次都去手动修改sw.js
的名字和sw.js
的内容,会很繁琐,而且修改后的sw.js
名字,要和注册时的名字一致:
js
navigator.serviceWorker.register('/ran/sw.js')
对于这种情况下,我们也可以进行自动化CI
处理。
- 对于文件名字,我们可以用
shell
脚本生成一个时间戳,追加到文件名后。 - 对于注册时的文件名需要保持一致,我们将时间戳生成后,注入到注册代码中
- 对于文件内容的变化,我们在创建
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
,说明这个资源非常重要,中间层服务器不能缓存,直接用户客户端或者访问到最终的服务器上获取资源。
这里会先遇到public
和private
属性
属性 | 作用 | 例子 |
---|---|---|
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:禁用临时路径。这意味着缓存文件将直接写入最终缓存路径,而不是先写入临时路径再移动。
再给之前的nod
e服务添加上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 |
- If-None-Match: 客户端发送该字段以传达之前获取的资源的
ETag
值。服务器可以使用这个值来判断资源是否已经发生了变化。如果资源的ETag
与客户端发送的匹配,则服务器返回304 Not Modified
。 - If-Modified-Since: 客户端发送该字段以传达之前获取的资源的最后修改时间。服务器可以使用这个时间来判断资源是否已经更新。如果资源的最后修改时间与客户端发送的时间匹配,并且没有其他更新,则服务器返回
304 Not Modified
。 - ETag(实体标签): 服务器返回资源时,可以附加一个
ETag
,它是一个资源的唯一标识符,通常是根据资源内容生成的哈希值。客户端在后续请求中可以将该ETag
发送回服务器,服务器使用它来检查资源是否发生了变化。如果资源的ETag
与客户端发送的匹配,则服务器返回304 Not Modified
响应,表示资源未改变,可以使用缓存副本。 - 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 压缩,或者对图片进行转webp ,no-transform 不允许这样做 |
Cache-control: no-transform |
当以上的缓存都失效的时候,还会有一个Push Cache
缓存
6.Push Cache
Push Cache
是由HTTP/2.0
协议推出的功能。用于解决客户端可能需要多次请求服务器的问题。举一个例子:
小冉同学去图书馆借一本操作系统回宿舍装着。看到一半发现需要C语言知识,于是又去图书馆借了一半C语言精讲。看到一半又发现需要数据结构与算法知识,于是又去图书馆借了一本数据结构与算法。看到一半发现又需要编译原理,于是又去图书馆借了一本编译原理。
对于客户端请求html
,html
就像一个资源清单,里面会有很多js
,css
甚至图片和视频资源的地址。
当客户端执行到了再去请求资源,就会来来回回请求很多次。
Push Cache
就是这个作用,当小冉同学去图书馆借了一本操作系统,图书馆发现小冉同学水平一般,于是就把C语言精讲,数据结构与算法知识,编译原理全部一次性都塞给小冉同学。这时候小冉同学在学习操作系统的时候,发现有不会的地方,先检查Push Cache
,Push 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
有几个特点和注意事项:
- 由服务器推送:
Push Cache
中的资源是由服务器推送到客户端的,不是客户端请求的结果。这意味着服务器可以主动向客户端推送可能需要的资源,以减少请求的延迟。 - 独立于浏览器缓存:
Push Cache
是独立于浏览器的普通缓存(例如HTTP
缓存)的。即使客户端已经有了某些资源的缓存,服务器仍然可以将这些资源推送到客户端的Push Cache
中。 - 依赖于服务器支持:
Push Cache
功能需要服务器和客户端都支持 HTTP/2 协议,并且服务器能够正确地使用Server Push
功能。如果服务器不支持或未正确配置Server Push
,Push 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,
};
}
}
参考文档:
- Service Worker MDN
- 浏览器缓存、CacheStorage、Web Worker 与 Service Worker
- 通过通知推送让 PWA 可重用
- HTTP/2 push
- HTTP/2 Server Push and Cache-Digest
- Cache-Control MDN
- rel=preload MDN
- Push-Pull Caching