浏览器缓存与前端工程化知识整理
浏览器缓存相关知识整理
浏览器请求资源的过程
- 浏览器在加载资源时,先根据这个资源的一些http header判断它是否命中强缓存,强缓存如果命中,浏览器直接从自己的缓存中读取资源,不会发请求到服务器。比如某个css文件,如果浏览器在加载它所在的网页时,这个css文件的缓存配置命中了强缓存,浏览器就直接从缓存中加载这个css,连请求都不会发送到网页所在服务器;
- 当强缓存没有命中的时候,浏览器一定会发送一个请求到服务器,通过服务器端依据资源的另外一些http header验证这个资源是否命中协商缓存,如果协商缓存命中,服务器会将这个请求返回(304),但是不会返回这个资源的数据,而是告诉客户端可以直接从缓存中加载这个资源,于是浏览器就又会从自己的缓存中去加载这个资源;
- 强缓存与协商缓存的共同点是:如果命中,都是从客户端缓存中加载资源,而不是从服务器加载资源数据;区别是:强缓存不发请求到服务器,协商缓存会发请求到服务器
- 当协商缓存也没有命中的时候,浏览器直接从服务器加载资源数据
强缓存
客户端第一次访问服务端资源时,服务端会返回请求的对应资源,告诉客户端将这个资源保存在本地,并且告诉客户端如果在未来的某个时间点之前还访问这个资源,则直接从本地获取就好,不需要再向服务端请求。
服务端在返回对应资源时,同时也返回了Expires/Cache-Control两个字段
- Expires
- 为一个绝对时间的GMT格式的时间字符串,代表缓存资源的过期时间,在这个时间点之前访问,则命中缓存
- 缺点是该字段是根据客户端本地的时间进行对比的,若本地的时间与服务端的时间不一致,则会造成资源混乱
- 具体的请求过程为:
- 浏览器第一次发起请求后,response响应头会携带Expires,并将资源和response header保存在本地
- 当浏览器再次发起请求获取这个资源时,浏览器会先从缓存中找到这个资源,并对比Expires时间和本地当前请求的时间比较,若还没有超过Expires时间,则说明缓存未过期,即命中缓存;反之则重新发起请求获取资源,并重复以上操作
- Cache-Control有很多属性
- private:客户端可以缓存
- public:客户端和代理服务器都可以缓存
- max-age=t:缓存将在t秒后失效(与Expires的时间格式不一样,这里主要为秒,表示第一次获取该资源后的t秒内该资源都被认为命中缓存)
- no-cache:需要使用协商缓存来验证缓存数据
- no-store:所有内容都不会缓存
- 其描述的是相对时间,采用本地时间计算资源的有效期,即
本地时间 + max-age,在这段时间内即命中缓存,所以比Expires更可靠 - 并且Cache-Control比Expires优先级更高
协商缓存
客户端第一次访问服务端资源时,服务端会返回请求的对应资源和资源的一些信息(文件摘要/最后的修改时间),告诉客户端将这个资源保存在本地,当客户端再次请求这个资源时,会将文件摘要/最后的修改时间一并发给服务端,由服务端判断客户端资源是否需要更新,若不需要更新,则直接告诉客户端从本地读取资源(返回状态码304),若需要更新,则将最新的资源(包括最新的文件摘要/最后的修改时间)返回给客户端
-
最后的修改时间(Last-Modified)
- Last-Modified:资源的最后修改时间
- if-Modified-Since:通过比较两个时间来判断资源在两次请求期间是否有过修改,如果没有修改,则命中协商缓存,浏览器从缓存中获取资源;如果有过修改,则服务器返回资源,同时返回新的Last-Modified时间
- 具体请求过程
- 客户端第一次访问服务端资源时,服务端会返回请求的对应资源,并在响应头加上Last-Modified字段,表示这个资源再服务端上最后一次修改时间
- 当客户端再次请求该资源时,会在请求头加上if-Modified-Since,这个值为上一次服务端返回的Last-Modiffied时间
- 当服务端收到请求时,会将if-Modified-Since的时间和服务端资源的最后修改时间进行对比,若一致,则命中缓存,返回304,反之则返回资源,并更新Last-Modified
-
文件摘要(ETag):唯一标识
- 有些情况下使用最后修改时间来判断是否改动是不够的
- 存在周期性重写某些资源,但资源的实际内容并无变化
- 被修改的信息并不重要,如注释等
- Last-Modified无法精确到毫秒,但有些资源的更新频率小于一秒
- 为解决这些问题,http允许用户对资源打上标签(ETag)来区分两个相同路径获取的资源内容是否一致。通常会采用MD5等密码散列函数对资源编码得到标签(强验证器);或者通过版本号等方式,如W/”v1.0”(W/表示弱验证器)
- 具体请求过程
- 客户端第一次访问服务端资源时,服务端会返回请求的对应资源,并在响应头加上ETag,这个值是根据资源生成的唯一标识(字符串),只要服务端认为资源有变化,则ETag就必须有变化,客户端将资源连同ETag一并缓存
- 当客户端再次请求资源时,会在请求头加上if-None-Match,值为服务端返回的ETag值
- 服务端收到请求后,会根据请求的资源重新生成ETag,并跟if-None-Match进行比较,若一致则命中缓存,反之返回新资源和新的ETag
Last-Modified,If-Modified-Since和ETag,If-None-Match一般同时启用,这是为了处理Last-Modified不可靠的情况。
最终通知方案
根据对浏览器缓存相关知识的了解,我们知道使用Vue开发SPA应用,HTML 与相对应的前端资源会利用浏览器的缓存机制,缓存在本地。从而做到再次进入网站时,可以利用缓存机制快速进入网站,提高网站性能、优化用户体验。但这也造成了我们项目版本迭代后,用户因缓存机制,会继续使用旧迭代版本,需要用户手动刷新去服务器请求获取新的 HTML,来使用新迭代版本。
我根据实际项目开发中存在大小版本开发、临时bug修改上线两个场景,制定了以下技术方案:
-
在nginx中配置不同资源文件的缓存方式,既要保证html资源在发版后即时刷新,也要最大程度利用浏览器缓存机制,提高系统性能
# 前端项目为SPA应用,只有index.html设置为不缓存 location / { proxy_pass http://localhost:xxxx; add_header Cache-Control "no-store"; } # 强缓存 (图片与字体资源设置为强缓存,时间为10min) location ~* \.(jpg|jpeg|png|gif|svg|eot|ttf|otf)$ { proxy_pass http://localhost:xxxx; add_header Cache-Control max-age=600; } # 协商缓存 (js和css资源设置为协商缓存) location ~* \.(css|js)$ { proxy_pass http://localhost:xxxx; add_header Cache-Control "no-cache"; } -
前端代码层面的方案:
-
在前端项目打包时生成一份version.json文件用于保存项目的版本信息和打包的时间戳信息(此处使用到了vite插件的简单开发)
-
versionUpdatePlugin.js
//版本更新vite插件 import * as fs from "fs"; import path from "path"; const writeVersion = (versionFile, content) => { fs.writeFile(versionFile, content, function (err) { if (err) { throw err } }); } export default (options) => { let config; return { name: "version-update", configResolved(resolvedConfig) { config = Object.assign(resolvedConfig, options); }, buildStart() { // 生成版本信息文件路径 const file = config.publicDir + path.sep + 'version.json' // 这里使用全局变量__APP_INFO__作为版本信息进行存储 const content = JSON.stringify(options.version) if (fs.existsSync(config.publicDir)) { writeVersion(file, content) } else { fs.mkdir(config.publicDir, (err) => { if (err) throw err writeVersion(file, content) }) } }, } } -
在vite.config.js中使用该插件
//vite.config.js import versionUpdatePlugin from "./src/utils/versionUpdatePlugin"; //版本更新插件 import { name, version } from "./package.json"; const __APP_INFO__ = { pkg: { name, version }, buildTimestamp: Date.now(), } export default defineConfig({ plugins:[ versionUpdatePlugin({ version: __APP_INFO__, }) ] })
-
-
在全局前置路由守卫中获取该文件内的数据,并跟localstorage中保存的版本信息进行对比
- 如果版本信息不同:弹窗提示用户检测到新版本,点击确认后刷新页面
- 如果只有时间戳信息不同:直接调用location.reload帮用户刷新
router.beforeEach(async (to, from, next) => { let versionStore = useVersionStore(); let versionInfo = await versionCheck(); if (!versionStore.version && !versionStore.buildTimestamp) { //初始化系统的版本信息 bus.emit("showUpdate");//发布订阅者模式,通知版本更新弹窗 versionStore.version = versionInfo.pkg?.version; versionStore.buildTimestamp = versionInfo.buildTimestamp; } else { //已存在版本信息,比较版本信息 if (versionStore.version !== versionInfo.pkg?.version) { //如果版本信息不相同 bus.emit("showUpdate");//发布订阅者模式,通知版本更新弹窗 versionStore.version = versionInfo.pkg?.version; versionStore.buildTimestamp = versionInfo.buildTimestamp; } if (versionStore.version === versionInfo.pkg?.version && versionStore.buildTimestamp !== versionInfo.buildTimestamp) { //如果只是时间戳信息不相同 versionStore.buildTimestamp = versionInfo.buildTimestamp; setTimeout(() => { window.location.reload(); }, 500) } } });
-
参考资料:
Vite打包优化(前端工程化)
Vite打包资源分析
安装rollup-plugin-visualizer插件,可以在打包后生成打包后资源的可视化分析。
npm install rollup-plugin-visualizer -D
在vite.config.js中引入
import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
plugins:[
visualizer({
open: false, //在默认用户代理中打开生成的文件
gzipSize: true, // 收集 gzip 大小并将其显示
brotliSize: true, // 收集 brotli 大小并将其显示
filename: "stats.html", // 分析图生成的文件名
}),
]
})
在控制台运行npm run build指令,即可看到项目打包后资源的可视化内容
优化构建配置
调整 Vite 的构建配置,如使用build.rollupOptions来定制 Rollup 打包器的配置(在vite.config.js中)
-
优化打包后的资源路径
export default defineConfig({ build:{ rollupOptions:{ output:{ // 用于从入口点创建的块的打包输出格式[name]表示文件名,[hash]表示该文件内容hash值 entryFileNames: "js/[name].[hash].js", // 用于命名代码拆分时创建的共享块的输出命名 chunkFileNames: "js/[name].[hash].js", // 用于输出静态资源的命名,[ext]表示文件扩展名 assetFileNames: (assetInfo) => { const info = assetInfo.name.split("."); let extType = info[info.length - 1]; if ( /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/i.test(assetInfo.name) ) { extType = "media"; } else if (/\.(png|jpe?g|gif|svg)(\?.*)?$/.test(assetInfo.name)) { extType = "img"; } else if (/\.(woff2?|eot|ttf|otf)(\?.*)?$/i.test(assetInfo.name)) { extType = "fonts"; } return `${extType}/[name].[hash].[ext]`; }, } } } }) -
优化打包文件合并与拆分规则
export default defineConfig({ build:{ rollupOptions:{ output:{ // 最小化拆分包,将node_modules里面的文件单独打包(使得浏览器加载时可以利用缓存提高加载速度) manualChunks(id) { if (id.includes("node_modules")) { // 通过拆分包的方式将所有来自node_modules的模块打包到单独的chunk中 return id .toString() .split("node_modules/")[1] .split("/")[0] .toString() + '.vender'; } }, } } } })
优化后效果:
使用资源压缩
-
使用前端gzip压缩体积较大的文件(体积可以减小为原来的1/3左右)
安装
vite-plugin-compression插件npm install vite-plugin-compression -D在vite.config.js中引入
import compression from 'vite-plugin-compression'; export default defineConfig({ plugins:[ compression({ algorithm: "gzip", // 指定压缩算法为gzip,[ 'gzip' , 'brotliCompress' ,'deflate' , 'deflateRaw'] ext: ".gz", // 指定压缩后的文件扩展名为.gz threshold: 1024 * 10, // 仅对文件大小大于threshold的文件进行压缩,默认为10KB deleteOriginFile: false, // 是否删除原始文件,默认为false filter: /\.(js|css|json|html|ico|svg)(\?.*)?$/i, // 匹配要压缩的文件的正则表达式,默认为匹配.js、.css、.json、.html、.ico和.svg文件 compressionOptions: { level: 9 }, // 指定gzip压缩级别,默认为9(最高级别) verbose: true, //是否在控制台输出压缩结果 disable: false, //是否禁用插件 }), ] })在nginx中配置优先使用gz压缩文件(暂时还没实现,本地不太好测试)
参考资料:静态gzip
目标效果:在响应表头中查看有没有
content-encoding: gzip属性;有content-encoding: gzip的情况再看ETag有没有W\,有表示服务器启用了动态压缩,没有则表示返回了.gz,说明gzip_static生效。 -
去除代码中多余的空格、注释、console信息等
在
vite.config.js的build属性中配置以下信息export default defineConfig({ build:{ chunkSizeWarningLimit: 2000, // 消除打包大小超过500kb警告 minify: "terser", // Vite 2.6.x 以上需要配置 minify: "terser", terserOptions 才能生效 terserOptions: { compress: { keep_infinity: true, // 防止 Infinity 被压缩成 1/0,这可能会导致 Chrome 上的性能问题 drop_console: true, // 生产环境去除 console drop_debugger: true, // 生产环境去除 debugger }, format: { comments: true, // false删除注释 }, }, } })
TreeShaking
确保移除未使用的代码。Vite 默认支持 Rollup 的摇树优化。因此不需要特殊配置。
(但webpack项目需要了解对应插件的使用,大家可以自行搜索了解)
代码分割,按需导入
使用动态导入(Dynamic Imports)来实现代码分割,减少初始加载包的大小。
-
路由懒加载
component: () => import('../pages/login.vue') -
组件懒加载
使用动态组件时,应当使用异步加载组件(defineAsyncComponent),优化初次渲染
<template> <div class="profile-clear-index-page"> <Teleport v-if="mountedStatus" to="#titleTeleport"> <nav-menu v-model="curNav" :nav-list="navList"></nav-menu> </Teleport> <component :is="curNav"></component> </div> </template> <script setup> import { ref, reactive, onMounted, defineAsyncComponent } from "vue"; import NavMenu from "@/components/menu/NavMenu.vue"; defineOptions({ components: { //使用异步加载组件优化页面组件的首次加载 MyTask: defineAsyncComponent(() => import("./pages/MyTask.vue")), WaitLaunch: defineAsyncComponent(() => import("./pages/WaitLaunch.vue")), AllData: defineAsyncComponent(() => import("./pages/AllData.vue")), CleanLog: defineAsyncComponent(() => import("./pages/CleanLog.vue")), }, }); const curNav = ref("MyTask"); const navList = ref([ { label: "我的任务", value: "MyTask" }, { label: "待上线", value: "WaitLaunch" }, { label: "全部数据", value: "AllData" }, { label: "清理日志", value: "CleanLog" }, ]); const mountedStatus = ref(false); onMounted(() => { mountedStatus.value = true; }); </script> -
优化main.js中全局引入的内容,减少首次加载所需的资源
一个小例子(地图组件的注册)=>改为在相应使用到地图的页面组件中引入
其他优化方向...
开发环境下
-
使用预构建(Pre-bundling)
Vite 允许你预构建依赖,这样在开发环境中可以快速从磁盘读取,而不是每次服务器启动都重新构建。
-
使用自动导入插件
unplugin-auto-import/vite unplugin-vue-components/vite
-
使用vite.config.js中sever属性中的proxy配置后端代理
别人接手项目时不需要交接nginx配置文件,直接配在项目里更高效
生产环境下
-
使用插件对图片资源进行压缩
-
异步字体加载:
对于字体文件,使用异步方式加载,避免阻塞页面渲染。
......
-
打包速度优化
总结
- 实现使用纯前端方案解决项目新版本发布时的资源刷新问题
- 根据实际开发场景实现了有感刷新和无感刷新两种效果
- 在前端工程化方面进行了一些实质性探索,并从工程化角度对项目代码进行了一些优化