1. 前言
浏览器缓存是前端性能优化至关重要的一环,也是在前端面试当中被经常问到的一个话题。浏览器缓存是web缓存方式之一,而web缓存大致可分为:数据库缓存、服务端缓存、客户端(浏览器)缓存3类。浏览器缓存是指将一个页面中所用到的资源如html、css样式脚本、js脚本、图片、字体、音视频等静态资源以合理的方式缓存在浏览器当中。这样在我们下次访问页面的时候,将会使用有效缓存数据而不用重新向后台服务器发起请求。
通过合理的配置静态资源缓存策略,可以节省带宽、减少网络阻塞、提高加载渲染速度、提高用户体验等方面起到明显的效果。
浏览器缓存主要分为两种:
- 强制缓存
- 协商缓存
本片文章,将结合图片和代码的形式,帮助大家更清晰的认识浏览器缓存以及具体的实践应用。
2. 强制缓存
与强制缓存相关的HTTP头部信息字段主要有:Expires和Cache-Control
2. 1 Expires
Expires是HTTP1.0的控制强制缓存表示符,其值为GMT格式的时间值,代表文件过期时间。格式如下:
Expires: Mon, 24 Jan 2022 02:38:05 GMT
2. 1 Cache-Control
Cache-Control是HTTP1.1用于控制缓存的标识符,在于Expires一起使用时,优先级高于Expires。Cache-Control在强缓存控制方面要比Expires更加细化,常用取值有以下:
public: 响应资源可在客户端和代理服务缓存。private: 响应资源仅可在客户端缓存。no-store: 响应资源在所有位置都不可缓存。no-cache: 响应资源可缓存但会立即失效,在下次使用时,需要依据协商缓存Etag向服务器验证资源是否过期。max-age=xx: 其值以秒为单位,代表响应资源将在多长时间之后过期。
2. 3 强制缓存请求流程
- 客户端第一次请求服务端,服务端返回资源的同时将强制缓存标识Cache-Control和Expires标识添加在响应头部,浏览器根据缓存标识保存缓存信息。
- 之后再次打开页面请求同一个文件时候,浏览器首先向浏览器缓存发起请求检查缓存中是否有对应文件缓存信息,检查缓存是否过期,未过期则直接返回缓存内容。
- 如果浏览器缓存检验强制缓存已过期,则继续检查是否存在协商缓存标识
- 带有协商缓存标识,则发起http请求向后端服务验证缓存是否过期。
- 不带缓存标识,则发起http请求重新获取服务端最新数据资源。
3. 协商缓存
协商缓存是在客户端强制缓存匹配失效的情况下,向后端服务发起请求连接并在请求头部带有协商标识信息,验证资源在这次请求和上次请求这一段之间是否发生过变化:
- 没有发生过变化,返回304状态码,客户端继续使用缓存数据。
- 数据发生变化,返回200状态码,服务端返回新数据以及新的缓存策略配置信息。
3. 1 Last Modified 与 If-Modified-Since
Last Modified 与 If-Modified-Since分别是HTTP1.0协商缓存标识的响应头和请求头信息。
- Last Modified:其值代表资源在服务端的最后一次修改时间,GMT时间格式。服务端在客户端第一次请求或者协商缓存校验失效的情况下,在响应头部添加的标识。
- If-Modified-Since:其值为上一次请求响应头信息Last Modified对应值,在客户端强缓存校验失效的情况下,浏览器在请求头部添加该协商标识,向服务端协商校验文件是否有修改。
注意:由于Last Modified返回时间值以秒为单位,如果在这次请求于文件被修改时间之间间隔小于1秒,则即使资源有修改,协商缓存依然会有效,返回304客户端继续使用旧缓存数据。
3. 2 Etag与If-None-Match
Etag与If-None-Match分别是HTTP1.1协商缓存标识的响应头和请求头信息。
- Etag: 其值为依据文件资源内容生成的对应内容hash值,服务端在客户端第一次请求或者协商缓存校验失效的情况下,对获取的资源内容通过计算获取内容hash值并添加在响应头部。
- If-None-Match:其值为上一次请求响应头返回Etag标识对应值,在客户端强缓存校验失效的情况下,浏览器在请求头部添加该协商标识,向服务端协商校验文件是否有修改。
注意:
- 在Last Modified和Etag一同使用的情况下,Etag标识优先级高于Last Modified。
- 使用Etag由于要依据资源内容经过计算生成对应内容hash值,所以在接收到客户端If-None-Match值的情况下要将服务端对应资源经过计算获取当前hash值于请求头所带hash值做比较以判断文件是否有变化,所以会占用一定的服务资源。
4. 服务端缓存配置举例
下面使用node作为后端服务并使用Koa框架,通过简单的demo示例来更加清晰的认识服务端如何进行缓存配置。
说明,下面的demo案例只是演示使用,实际项目中的缓存策略还需要以实际需求为准配置对应的缓存策略。
// util/index.js 工具方法
import { fileURLToPath } from 'url';
import path, { dirname as pathDirname } from 'path';
import fs from 'fs/promises';
import { createHash } from 'crypto';
export const filename = fileURLToPath(import.meta.url);
export const dirname = pathDirname(filename);
export function resolvePath(...args) {
const root = path.resolve(dirname, '../', ...args);
return root;
}
export function isHtml(str) {
return /\.html$/.test(str);
}
export function isCss(str) {
return /\.css$/.test(str);
}
export function isJs(str) {
return /\.js$/.test(str);
}
/**
*
* @export
* @param {number} num 毫秒数
* @returns {Date}
*/
export function LimtTimeAddNow(num) {
return new Date(new Date().getTime() + num).toUTCString();
}
/**
*
* @export
* @param {string} fileUrl 文件路径
* @returns {Date} 文件最后修改时间
*/
export async function getFileLastEditDate(fileUrl) {
try {
const stats = await fs.stat(fileUrl);
return stats.ctime.toUTCString();
} catch (err) {
return '';
}
}
// 协商缓存策略
export async function setConsultCache(ctx) {
// 获取请求文件资源路径
const url = ctx.url;
const urlArr = url.split('/');
urlArr.splice(0, 1);
const fileUrl = resolvePath('htmlFiles', ...urlArr);
// 获取请求信息if-none-match
const reqIfNoneMatch = ctx.request.header['if-none-match'];
// 获取请求信息if-modified-since
const reqIfModifiedSince = ctx.request.header['if-modified-since'];
if (reqIfNoneMatch) {
// 优先处理Etag
try {
return await fs.readFile(fileUrl, 'utf-8').then((res) => {
const md5 = createHash('md5');
md5.update(res);
const etag = md5.digest('hex');
if (reqIfNoneMatch === '"' +etag+'"') {
// 缓存有效
ctx.body = null;
ctx.response.etag = etag;
ctx.response.status = 304;
return true;
} else {
// 缓存过期
ctx.response.etag = etag;
ctx.response.status = 200;
// 新资源在静态服务中返回
return false;
}
});
} catch (err) {
console.log('set etag error');
return false;
}
} else if(reqIfModifiedSince) {
// 其次Last-Modified
const serverFileModified = await getFileLastEditDate(fileUrl);
if (serverFileModified && new Date(serverFileModified).getTime() <= new Date(reqIfModifiedSince).getTime()) {
// 缓存有效
ctx.body = null;
ctx.response.set('Last-Modified', reqIfModifiedSince);;
ctx.response.status = 304;
return true;
} else {
// 缓存过期
ctx.response.set('Last-Modified', serverFileModified);
ctx.response.status = 200;
// 新资源在静态服务中返回
return false;
}
} else {
// 首次请求--添加--Etag Last-Modified
try {
// Last-Modified
const serverFileModified = await getFileLastEditDate(fileUrl);
ctx.response.set('Last-Modified', serverFileModified);
// Etag
await fs.readFile(fileUrl, 'utf-8').then((res) => {
const md5 = createHash('md5');
md5.update(res);
const etag = md5.digest('hex');
ctx.response.etag = etag;
});
} catch (err) {
console.log('set etag error');
}
return false;
}
}
// server.js
import Koa from 'koa';
import staticServer from 'koa-static';
import koaMount from 'koa-mount';
import KoaRouter from '@koa/router';
import { resolvePath, isHtml, isCss, isJs, LimtTimeAddNow, setConsultCache } from './util/index.js';
const app = new Koa();
const router = new KoaRouter();
const serverPort = 3001;
app.use(async (ctx, next) => {
if (isHtml(ctx.url)) {
// 强缓存
ctx.response.set('Cache-Control', 'no-cache');
// 协商缓存
const isAllow = await setConsultCache(ctx);
if (!isAllow) {
// 将控制权交由静态服务中间件
await next();
}
} else if(isCss(ctx.url)) {
await next();
ctx.response.set('Cache-Control', 'max-age=3600, public') // 1h
ctx.response.set('Expires', LimtTimeAddNow(60*60*1000)) // 1h
} else if (isJs(ctx.url)) {
await next();
ctx.response.set('Cache-Control', 'max-age=3600, public') // 1h
ctx.response.set('Expires', LimtTimeAddNow(60*60*1000)) // 1h
} else {
// 其它资源
await next();
ctx.response.set('Cache-Control', 'max-age=2592000') // 30d
ctx.response.set('Expires', LimtTimeAddNow(30*24*60*60*1000)) // 30d
}
});
// 静态资源服务
app.use(
staticServer(resolvePath('htmlFiles'), {
index: 'index.html',
})
);
// 其他错误请求处理
app.use(async (ctx) => {
if (!ctx.body) {
ctx.body = '404 NotFound';
}
});
// listening
app.listen(serverPort, () => {
console.log(`server is runing in http://localhost:${serverPort}`);
});
上面的代码示例,将html配置Cache-Control=no-cache,即每次使用html文件都需要向后端协商文件是否有变化,而对于js,css和其它资源则使用强制缓存配置。在我们前端每次构建打包时候,我们可以在webpack输出配置使用contenthash作为文件名名部分,这样没有变化的文件在打包前后文件名不会发生变化,而只有真正有内容变化的文件打包前后生成的文件名发生变化。对于这部分资源如css,js,图片等资源就适合使用强制缓存策略。
而对于我们的html文件,在我们打包前后,文件中引用的js,css,图片可能已发生变化,需要我们需要在每次请求时候要验证文件是否有变化。
5. 缓存位置
先看张图更直观
可以看到对于强缓存有效的情况下,直接使用memory cache或者disk cache位置资源。
memory cache: 内存缓存 memory cache 是浏览器为了加快读取缓存速度而进行的自身的优化行为,不受开发者控制,一般将脚本,图片,字体等常常和页面产生交互的部分存放在内存中,原因也很简单,比较利于性能提升。
dick cache: 磁盘缓存 磁盘缓存读取较内存缓存慢但是可以提供更大的容量,其策略也不受开发者控制,一般将css等这些不经常变动的数据放在磁盘中进行缓存。
6. 用户不同行为对缓存的影响
用户访问一个页面的行为通常包括:点击链继跳转进入、地址栏输入地址进入、当前页面点击浏览器刷新按钮、ctrl+f5强制刷新等方式。
- 点击连接跳转:强制缓存全部使用disk cache,协商缓存可使用。
- 地址栏输入:强制缓存全部使用disk cache,协商缓存可使用。
- 点击浏览器左上角刷新按钮:强制缓存disk cache和memory cache 及协商缓存均可使用。
- ctrl+f5: 浏览器不使用缓存,所有请求头部浏览器全部添加cache-control:no-cache和Pragma:no-cache头信息。
说明:点击连接跳转和地址栏输入这两种情况下,由于浏览器不存在将要打开新页面的tab,所以不存在memory cache,在这两种情况下进入页面,所有强缓存资源全部来自disk cache(包括会被放入memory cache的资源),此时我们再刷新页面,就可看到部分强制缓存资源如js脚本将会显示memory cache。