浏览器缓存与前端工程化知识整理

755 阅读13分钟

浏览器缓存与前端工程化知识整理

浏览器缓存相关知识整理

浏览器请求资源的过程

  1. 浏览器在加载资源时,先根据这个资源的一些http header判断它是否命中强缓存,强缓存如果命中,浏览器直接从自己的缓存中读取资源,不会发请求到服务器。比如某个css文件,如果浏览器在加载它所在的网页时,这个css文件的缓存配置命中了强缓存,浏览器就直接从缓存中加载这个css,连请求都不会发送到网页所在服务器;
  2. 当强缓存没有命中的时候,浏览器一定会发送一个请求到服务器,通过服务器端依据资源的另外一些http header验证这个资源是否命中协商缓存,如果协商缓存命中,服务器会将这个请求返回(304),但是不会返回这个资源的数据,而是告诉客户端可以直接从缓存中加载这个资源,于是浏览器就又会从自己的缓存中去加载这个资源;
  3. 强缓存与协商缓存的共同点是:如果命中,都是从客户端缓存中加载资源,而不是从服务器加载资源数据;区别是:强缓存不发请求到服务器,协商缓存会发请求到服务器
  4. 当协商缓存也没有命中的时候,浏览器直接从服务器加载资源数据
image-20240523153048072.png

强缓存

客户端第一次访问服务端资源时,服务端会返回请求的对应资源,告诉客户端将这个资源保存在本地,并且告诉客户端如果在未来的某个时间点之前还访问这个资源,则直接从本地获取就好,不需要再向服务端请求。

服务端在返回对应资源时,同时也返回了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-SinceETag,If-None-Match一般同时启用,这是为了处理Last-Modified不可靠的情况。

最终通知方案

根据对浏览器缓存相关知识的了解,我们知道使用Vue开发SPA应用,HTML 与相对应的前端资源会利用浏览器的缓存机制,缓存在本地。从而做到再次进入网站时,可以利用缓存机制快速进入网站,提高网站性能、优化用户体验。但这也造成了我们项目版本迭代后,用户因缓存机制,会继续使用旧迭代版本,需要用户手动刷新去服务器请求获取新的 HTML,来使用新迭代版本。

我根据实际项目开发中存在大小版本开发、临时bug修改上线两个场景,制定了以下技术方案:

  1. 在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";
    }
    
  2. 前端代码层面的方案:

    1. 前端项目打包时生成一份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__,
            })
          ]
        })
        
    2. 在全局前置路由守卫中获取该文件内的数据,并跟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)
          }
        }
      
      });
      

参考资料:

  1. vite插件开发
  2. WEB项目部署发版-通知用户在线更新方案

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指令,即可看到项目打包后资源的可视化内容

image-20240523154321782.png

优化构建配置

调整 Vite 的构建配置,如使用build.rollupOptions来定制 Rollup 打包器的配置(在vite.config.js中)

  1. 优化打包后的资源路径

    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]`;
            },
          }
    		}
    	}
    })
    
  2. 优化打包文件合并与拆分规则

    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';
              }
            },
          }
    		}
    	}
    })
    

优化后效果:

image-20240523161631086.png

使用资源压缩

  1. 使用前端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生效。

  2. 去除代码中多余的空格、注释、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)来实现代码分割,减少初始加载包的大小。

  1. 路由懒加载

    component: () => import('../pages/login.vue')
    
  2. 组件懒加载

    使用动态组件时,应当使用异步加载组件(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>
    
  3. 优化main.js中全局引入的内容,减少首次加载所需的资源

    一个小例子(地图组件的注册)=>改为在相应使用到地图的页面组件中引入

其他优化方向...

开发环境下
  1. 使用预构建Pre-bundling

    Vite 允许你预构建依赖,这样在开发环境中可以快速从磁盘读取,而不是每次服务器启动都重新构建。

    vite optimizeDeps

  2. 使用自动导入插件

    unplugin-auto-import/vite unplugin-vue-components/vite

    参考: github.com/sxzz/elemen…

  3. 使用vite.config.js中sever属性中的proxy配置后端代理

    别人接手项目时不需要交接nginx配置文件,直接配在项目里更高效

生产环境下
  1. 使用插件对图片资源进行压缩

  2. 异步字体加载

    对于字体文件,使用异步方式加载,避免阻塞页面渲染。

    ......

  3. 打包速度优化

总结

  1. 实现使用纯前端方案解决项目新版本发布时的资源刷新问题
  2. 根据实际开发场景实现了有感刷新和无感刷新两种效果
  3. 在前端工程化方面进行了一些实质性探索,并从工程化角度对项目代码进行了一些优化