浏览器缓存策略
Http缓存 & 本地缓存
缓存可以划分为资源缓存和数据缓存两大类
资源缓存:用于静态资源按照我们所期望的规则存储在本地,用户访问页面时如果相关资源未发生改变,可以直接从本地拿取资源渲染网页。缓存策略就是确定资源是否发生了更新。良好的缓存策略可以减少资源重复加载,进而提高网页的整体加载速度, 减少服务器负载。
数据缓存:用于将常用数据存储在本地,如:用户登录态信息、不常变动且不涉及数据安全问题的数据等。数据缓存的方案有:cookie、localstorage、indexedDB等。
资源缓存
浏览器缓存策略通常分为:强缓存和协商缓存,当然还包括service worker。
强缓存
-
强缓存的相关字段有
expires
,cache-control
。-
expires
是 HTTP1.0 提出的,它描述的是一个绝对时间,由服务端返回给客户端。会受到本地时间的影响。(通常表示为:Expires: Sun, 07 Apr 2024 08:22:17 GMT
) -
cache-control
是 HTTP1.1 提出的,它描述的是一个相对时间,单位(秒/s),由服务端返回。相对的是服务端的时间,所以不会受到本地时间的影响。(通常表示为:Cache-Control:max-age=86400
)指令 max-age
指定缓存的最大有效时间,单位为秒 no-cache
表示缓存需要重新验证,即每次都需要向服务器发送请求,与服务器进行新鲜度校验 no-store
不缓存资源到本地,每次都需要向服务器发送请求 pubilc
可被所有用户缓存,多用户进行共享,包括终端或CDN等中间代理服务器。 private
只能被浏览器客户端缓存,不允许CDN等中间代理服务器对其进行缓存。
-
-
如果
cache-control
和expires
同时存在的话,cache-control
的优先级高于expires
以下 index.js
文件会命中强缓存,10秒内刷新页面会从本地读取文件,不会发生资源请求:
const http = require("http");
const fs = require("fs");
const path = require("path");
// 定义路由表
const routes = {
"/": function html(req, res) {
res.end("hello");
},
"/index.html": function html(req, res) {
const data = fs.readFileSync(path.join(__dirname, "public", "/index.html"));
res.writeHead(200, { "Content-Type": "text/html" });
res.end(data);
},
"/index.css": function css(req, res) {
fs.readFile(path.join(__dirname, "public", "/index.css"), (err, data) => {
res.writeHead(200, { "Content-Type": "text/css" });
res.end(data);
});
},
"/index.js": function js(req, res) {
fs.readFile(path.join(__dirname, "public", "/index.js"), (err, data) => {
res.writeHead(200, {
"Content-Type": "text/javascript",
// 设置响应头 Cache-Control 字段,表示浏览器缓存文件 10 秒
"Cache-Control": "max-age=10",
// 设置响应头 Expires 字段,表示浏览器缓存文件 10 秒
Expires: new Date(Date.now() + 10 * 1000).toUTCString(),
});
res.end(data);
});
},
};
const server = http.createServer((req, res) => {
console.log(req.url);
return routes[req.url]?.(req, res);
});
server.listen(8080, () => {
console.log("server start !");
});
协商缓存
-
浏览器加载资源时,若强缓存未命中,将发送资源请求至服务器。若协商缓存命中,请求响应返回 304 状态码。
-
协商缓存主要使用到两对请求响应头字段:
- Last-Modified 和 If-Modified-Since
- Etag 和 If-None-Match
-
Last-Modified 和 If-Modified-Since : Last-Modified表示资源的最后修改时间,服务器在响应请求时会将该参数返回给客户端 。 客户端在下一次请求时会将该参数作为If-Modified-Since的值发送给服务器。
Last-Modified 由上一次请求的响应头返回,且该值会在本次请求中,通过请求头 If-Modified-Since 传递给服务端,服务端通过 If-Modified-Since 与资源的修改时间进行对比,若相同,则资源没有更新,请求响应返回 **304 ** 状态码,使用本地缓存资源。若不同,则在此日期后资源有更新,则将新的资源发送给客户端。
Last-Modified:Tue, 26 Mar 2024 03:58:58 GMT
不过,通过文件的修改时间来判断资源是否更新是不明智的,因为很多时候文件更新时间变了,但文件内容未发生更改。这样一来,就出现了 ETag 与 If-None-Match。
-
Etag 和 If-None-Match: ETag表示资源的唯一标识符,服务器在响应请求时会将该参数返回给客户端。 客户端在下一次请求时会将该参数作为If-None-Match的值发送给服务器。
不同于 Last-Modified,Etag 通过计算文件指纹,与请求传递过来的 If-None-Match 进行对比,若值不等,则将新的资源发送给客户端。
Etag:W/“105805c9f6fd45ca2f5065b90e067a0a”
ETag 的优先级比 Last-Modified 高
以下 index.css
为协商缓存策略:根据文件是否更新来判断是否使用缓存,Etag同理
const http = require("http");
const fs = require("fs");
const path = require("path");
const etag = require("etag");
const fresh = require("fresh");
// 定义路由表
const routes = {
"/": function html(req, res) {
res.end("hello");
},
"/index.html": function html(req, res) {
const data = fs.readFileSync(path.join(__dirname, "public", "/index.html"));
res.writeHead(200, { "Content-Type": "text/html" });
res.end(data);
},
"/index.css": function css(req, res) {
const cssFilePath = path.join(__dirname, "public", "/index.css");
const css = fs.readFileSync(cssFilePath);
// 读取文件描述信息,用于计算etag及设置Last-Modified
const stat = fs.statSync(cssFilePath);
console.log(stat);
// 获取文件最后修改时间
const Last_Modified = stat.mtime.toUTCString();
const fileEtag = etag(stat);
// 获取请求头中是否有 if-modufied-since 字段
const ifModifiedSince = req.headers["if-modified-since"];
// 协商判断
const isModified = !ifModifiedSince || ifModifiedSince !== Last_Modified;
res.setHeader("Last-Modified", Last_Modified);
res.setHeader("ETag",fileEtag);
// 根据请求头判断缓存是否是最新的
let isFresh = fresh(req.headers, {
etag: fileEtag,
"last-modified": lastModified,
});
// 判断文件是否更改,更改则返回200,重新请求数据; 未更改则返回304
res.writeHead(isFresh? 200 : 304, { "Content-Type": "text/css" });
res.end(css);
},
"/index.js": function js(req, res) {
fs.readFile(path.join(__dirname, "public", "/index.js"), (err, data) => {
res.writeHead(200, {
"Content-Type": "text/javascript",
// 设置响应头 Cache-Control 字段,表示浏览器缓存文件 10 秒
"Cache-Control": "max-age=10",
// 设置响应头 Expires 字段,表示浏览器缓存文件 10 秒
Expires: new Date(Date.now() + 10 * 1000).toUTCString(),
});
res.end(data);
});
},
};
const server = http.createServer((req, res) => {
console.log(req.url);
return routes[req.url]?.(req, res);
});
server.listen(8080, () => {
console.log("server start !");
});
缓存策略
- 浏览器第一次发起请求时,本地无缓存,浏览器会向服务器发送请求,浏览器响应请求,在请求头中设置
expires
或cache-control
。 - 刷新页面,浏览器会先获取资源缓存的响应头信息,根据
expires
和cache-control
判断资源是否过期。若没过期,则直接从缓存中获取资源信息, 所以此次请求不会和服务器进行通信。 - 如果已过期,浏览器会向服务器发送请求,会携带第一次请求返回的有关缓存的响应头字段信息,客户端会通过
If-None-Match
头将先前服务器端发送过来的Etag
数据发送给服务器,服务器会对比这个客户端发过来的Etag
是否与服务器相同,若相同,就将If-None-Match
的值设置为false,返回状态304,客户端继续使用本地缓存;若不相同就将If-None-Match
的值设为true,返回状态200,客户端重新接收服务器端返回的数据;
Service Worker(策略缓存)
Service Worker 是专门的 JavaScript 资源,充当网络浏览器和 Web 服务器之间的代理。它们旨在通过提供离线访问来提高可靠性,以及提升网页性能。
缓存策略: 仅限缓存、仅限网络、缓存优先,回退到网络、网络优先,回退到缓存、重新验证时过时
详情请查阅:ServiceWorker
- 仅限缓存
当 Service Worker 控制着页面时,匹配的请求将只会进入缓存。这意味着,任何缓存的资源都需要进行预缓存,以使模式正常工作,并且在 Service Worker 更新之前,绝不会在缓存中更新这些资源。
- 仅限网络
与“仅缓存”相反,“仅限网络”是指请求通过 Service Worker 传递到网络,而无需与 Service Worker 缓存进行任何交互。这是确保内容新鲜度的好策略(比如使用标记),但需要权衡的是,用户离线时自定义设置将永远不会起作用。
- 缓存优先,回退到网络
在此策略中,需要更深入地发挥作用。对于匹配请求,流程如下:
- 请求到达缓存。如果资源位于缓存中,请从缓存中提供。
- 如果请求不在缓存中,请转到网络。
- 网络请求完成后,将其添加到缓存中,然后从网络返回响应。
- 网络优先,回退到缓存
- 您先前往网络请求一个请求,然后将响应放入缓存中。
- 如果您日后处于离线状态,则会回退到缓存中该响应的最新版本。
此策略非常适合 HTML 或 API 请求,当您想在线获取资源的最新版本,同时希望离线访问最新的可用版本时。
- 重新验证时过时
- 在第一次请求获取资源时,从网络中提取资源,将其放入缓存中并返回网络响应。
- 对于后续请求,首先从缓存提供资源,然后“在后台”从网络重新请求该资源,并更新资源的缓存条目。
- 对于此后的请求,您将收到在上一步中从缓存中放置的最后一个网络提取的版本。
对于很重要、但并非很重要的事情,这是一种很好的策略。 可以将内容想象成社交媒体网站的头像。 它们会在用户执行相应操作时进行更新,但并不是每个请求都绝对有必要使用最新版本。
详情请查阅:缓存策略
状态码
- 200:强缓存 Expires / Cache-Control 失效时,返回新的资源文件
- 200(from disk cache) Expires / Cache-Control 两者都存在且有效,Cache-Control 优先 Expires ,浏览器从本地获取资源成功。
- 200(from memory cache)
- 304(Not Modified)协商缓存 Last-modified / Etag 有效,则服务端返回该状态码。
缓存技术方案实践
静态资源优化方案与思考
- 配置超长时间的本地缓存 —— 节省带宽,提高性能
- 采用内容摘要作为缓存更新依据 —— 精确的缓存控制
- 静态资源 CDN 部署 —— 优化网络请求
- 更资源发布路径实现非覆盖式发布 —— 平滑升级
充分利用浏览器缓存机制
- 对于某些不需要缓存的资源,可以使用 Cache-control: no-store ,表示该资源不需要缓存
- 对于频繁变动的资源(比如经常需要刷新的首页,资讯论坛新闻类),可以使用 Cache-Control: no-cache 并配合 ETag 使用,表示该资源已被缓存,但是每次都会发送请求询问资源是否更新。
- 对于代码文件来说,通常使用 Cache-Control: max-age=31536000 并配合策略缓存使用,然后对文件进行指纹处理,一旦文件名变动就会立刻下载新的文件。
- 静态资源文件通过 Service Worker 进行缓存控制和离线化加载
数据缓存
- cookie 4K,可手动设置失效实践
- localStorage 5M,需要手动清除,否则一直存在
- sessionStorage 5M,仅限同标签访问,页面关闭就会清理
- indexedDB 无限容量,浏览器端数据库,需手动清除,否则一直存在
cookie
cookie 实际是一小段文本信息。客户端请求服务端,如果服务器需要记录该用户的登录状态,就需要使用在响应时向客户端返回一个 cookie。客户端浏览器会将 cookie 保存。客户端再次请求该网站时,会携带 cookie 一同提交到服务端。此时服务端检查该 cookie 来确定用户登录状态。服务器还可以根据需要修改 cookie 内容。
cookie 包含以下属性:
- Expires :cookie 过期时间,绝对时间;
- Max-Age:cookie 失效时间,相对时间;
- Domain:指定 cookie 可以送达的主机名。
- Path:指定一个 URL 路径,这个路径必须出现在要请求的资源的路径中才可以发送 Cookie 首部
- Secure:一个带有安全属性的 cookie 只有在请求使用 SSL 和 HTTPS 协议的时候才会被发送到服务器。
- HttpOnly: 设置了 HttpOnly 属性的 cookie 不能使用 JavaScript 经由 Document.cookie 属性、XMLHttpRequest 和 Request APIs 进行访问,以防范跨站脚本攻击(XSS)。
localStorage、sessionStorage
容量通常不超过 5M,存储内容格式为字符串,可以格式化为字符串的资源均可存储在其中。localstorage 中数据在同域下可共享,而 sessionstorage 只在会话生命周期中有效。
// 保存数据
localStorage.setItem('key', 'value');
sessionStorage.setItem('key', 'value');
// 读取数据
localStorage.getItem('key');
sessionStorage.getItem('key');
// 删除单个数据
localStorage.removeItem('key');
sessionStorage.removeItem('key');
// 删除全部数据
localStorage.clear();
sessionStorage.clear();
// 获取索引的key
localStorage.key('index');
sessionStorage.key('index');
// 事件监听
window.addEventListener('storage', function(e) {
console.log(e.key, e.oldValue, e.newValue);
});
借助 localfroge 库做数据本地缓存
indexedDB
可用于存储非结构化数据,该数据库属于非关系型数据库,便于查询存储。
一个示例演示 indexedDB 的使用方式,如下:
const DB_NAME = "Netease";
const DB_VERSION = 1;
const OB_NAMES = {
UseKeyPath: "UseKeyPath",
UseKeyGenerator: "UseKeyGenerator",
};
// 1.打开数据库
function openIndexedDB() {
return new Promise((resolve, reject) => {
/**
* NOTE:
* 1. 第一次打开可能会提示用户获取 indexDB 的权限
* 2. 浏览器隐身模式不会存在本地,只会存储在内存中
*/
// 创建连接池
const pool = indexedDB.open(DB_NAME, DB_VERSION);
pool.onerror = function (event) {
console.error(event.target.error);
};
pool.onsuccess = function (event) {
let db = event.target.result;
db.onerror = function (e) {
console.error("Database error: ", e.target.error);
reject(e.target.error);
};
db.onclose = (e) => {
console.error("Database close:", e.target.error);
reject(e.target.error);
};
resolve(db);
};
pool.onupgradeneeded = function (event) {
/**
* NOTE:
* 1. 创建新的 objectStore
* 2. 删除旧的不需要的 objectStore
* 3. 如果需要更新已有 objectStore 的结构,需要先删除原有的 objectStore ,然后重新创建
*/
// The IDBDatabase interface
console.log("onupgradeneeded", event);
var db = event.target.result; // Create an objectStore for this database
obUseKeypath(db);
obUseKeyGenerator(db);
db.transaction.oncomplete = function (e) {
console.log("obj create success", e);
};
};
});
}
// 2.创建对象仓库
function obUseKeypath(db) {
const objectStore = db.createObjectStore(OB_NAMES.UseKeyPath, {
keyPath: "time",
});
objectStore.createIndex("name", "name", {
unique: false,
});
objectStore.createIndex("level", "level", {
unique: false,
});
}
function obUseKeyGenerator(db) {
const objectStore = db.createObjectStore(OB_NAMES.UseKeyGenerator, {
autoIncrement: true,
});
objectStore.createIndex("errorCode", "errorCode", {
unique: false,
});
objectStore.createIndex("time", "time", {
unique: true,
});
objectStore.createIndex("level", "level", {
unique: false,
});
}
// 3.添加数据
/**
* 添加数据
* @param {array} docs 要添加数据
* @param {string} objName 仓库名称
*/
function addData(docs, objName) {
if (!(docs && docs.length)) {
throw new Error("docs must be a array!");
}
return openIndexedDB().then((db) => {
// 事务
const tx = db.transaction([objName], "readwrite");
tx.oncomplete = function () {
return Promise.resolve(docs);
};
tx.onerror = function (event) {
e.stopPropagation();
return Promise.reject(event.target.error);
};
tx.onabort = (e) => {
console.warn("tx:addData abort", e.target);
return Promise.reject(e.target.error);
};
// 提交事务
const ob = tx.objectStore(objName);
docs.forEach((doc) => {
ob.add(doc);
});
});
}
const testData = [
{
event: "NE-TEST1",
level: "warning",
errorCode: 200,
url: "http://www.example.com",
time: "2017/11/8 下午4:53:039",
isUploaded: false,
},
];
addData(testData, OB_NAMES.UseKeyGenerator).then(() =>
addData(testData, OB_NAMES.UseKeyPath)
);
以上是我们完全使用 indexedDB 原生 api 实现,通常我们为了简化开发,会选用例如Dexie 实现。
<script src="https://unpkg.com/dexie@latest/dist/dexie.js"></script>
var db = new Dexie("FriendDatabase");
db.version(1).stores({
friends: "++id,name,age"
});
db.friends.add({name: "Josephine", age: 21}).then(function() {
return db.friends.where("age").below(25).toArray();
}).then(function (youngFriends) {
alert ("My young friends: " + JSON.stringify(youngFriends));
}).catch(function (e) {
alert ("Error: " + (e.stack || e));
});
indexedDB 的使用场景
- 游戏
- 前端数据缓存
- 离线应用数据存储
end