缓存&PWA实践

529 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情

缓存&PWA 实践

一、背景

从上一篇《前端动画实现与原理分析》,我们从 Performance 进行动画的性能分析,并根据 Performance 分析来优化动画。但,前端不仅仅是实现流畅的动画。ToB 项目会经常与数据的保存、渲染打交道。例如开发中,为了提高用户体验,遇到了一些场景,其实就是在利用某些手段,来进行性能优化。

  • Select 下拉:做前端分页展示 → 避免一次性渲染数据造成浏览器的假死状态;
  • indexedDB:存储数据 → 用户下一次进入时,保存上一次编辑的状态 ……

那不免引发思考,我们从缓存与数据存储来思考,该如何优化?

二、 HTTP 缓存

是什么?

Http 缓存其实就是浏览器保存通过 HTTP 获取的所有资源, 是浏览器将网络资源存储在本地的一种行为。

请求的资源在哪里?

  1. 6.8kB + 200 状态码: 没有命中缓存,实际的请求,从服务器上获取资源;
  2. memory cache: 资源缓存在内存中,不会请求服务器,一般已经加载过该资源且缓存在内存中,当关闭页面时,此资源就被内存释放掉了;
  3. disk cache: 资源缓存在磁盘中,不会请求服务器,但是该资源也不会随着关闭页面就释放掉;
  4. 304 状态码:请求服务器,发现资源没有被更改,返回 304 后,资源从本地取出;
  5. service worker: 应用级别的存储手段;

HTTP 缓存分类

1. 强缓存

  1. 浏览器加载资源时,会先根据本地缓存资源的 header 中的信息判断是否命中强缓存。如果命中,则不会像服务器发送请求,而是直接从缓存中读取资源。
  2. 强缓存可以通过设置 HTTP Header 来实现: http1.0 → Expires: 响应头包含日期/时间, 即在此时候之后,响应过期。 http1.1 → Cache-Control:max-age=: 设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。与Expires相反,时间是相对于请求的时间

💡 Cache-control

  • cache-control: max-age=3600 :表示相对时间
  • cache-control:no-cache → 可以存储在本地缓存的,只是在原始服务器进行新鲜度在验证之前,缓存不能将其提供给客户端使用
  • cache-control: no-store → 禁止缓存对响应进行复制,也就是真正的不缓存数据在本地;
  • catch-control:public → 可以被所有用户缓存(多用户共享),包括终端、CDN 等
  • cache-control: private → 私有缓存

2. 协商缓存

  1. 当浏览器对某个资源的请求没有命中强缓存,就会发一个请求到服务器,验证协商缓存是否命中,如果协商缓存命中,请求返回的 http 状态 304,并且会显示 Not Modified 的字符串;
  2. 协商缓存通过【last-Modified,if-Modified-Since】与【ETag, if-None-Match】来管理的。

  • 「Last-Modified、If-Modified-Since」

last-Modified : 表示本地文件最后修改的日期,浏览器会在请求头中带上 if-Modified-since(也是上次返回的 Last-Modified 的值),服务器会将这个值与资源修改的时间进行匹配,如果时间不一致,服务器会返回新的资源,并且将 Last-modified 值更新,并作为响应头返回给浏览器。如果时间一致,表示资源没有更新,服务器会返回 304 状态,浏览器拿到响应状态码后从本地缓存中读取资源。

  • ETag、If-None-Match」

http 1.1 中, 服务器通过 Etag 来设置响应头缓存标示。Etag 是由服务器来生成的。

第一次请求时,服务器会将资源和 ETag 一并返回浏览器,浏览器将两者缓存到本地缓存中。

第二次请求时,浏览器会将 ETag 的值放到 If-None-Match 请求头去访问服务器,服务器接收请求后,会将服务器中的文件标识与浏览器发来的标识进行比对,如果不同, 服务器会返回更新的资源和新的 Etag,如果相同,服务器会返回 304 状态码,浏览器读取缓存。

💡 思考为什么有了 Last-Modified 这一对儿,还需要 Etag 来标识是否过期进行命中协商缓存

  1. 文件的周期性更改,但是文件的内容没有改变,仅仅改变了修改时间,这个时候,并不希望客户端认为该文件被修改了,而重新获取。
  2. 如果文件文件频繁修改,比如 1s 改了 N 次,If-Modified-Since 无法判断修改的,会导致文件已经修改但是获取的资源还是旧的,会存在问题。
  3. 某些服务器不能精确得到文件的最后修改时间,导致资源获取的问题。

⚠️  如果 Etag 与 Last-Modified 同时存在,服务器会先检查 ETag,然后在检查 Last-Modified, 最终确定是返回 304 或 200。

HTTP 缓存实践

测试环境: 利用 Koa,搭建一个 node 服务,用来测试如何命中强缓存还是协商缓存

当 index.js 没有配置任何关于缓存的配置时, 无论怎么刷新 chrome,都没有缓存机制的;

  • 注意⚠️:在开始实验之前要把 network 面板的 Disable cache 勾选去掉,这个选项表示禁用浏览器缓存,浏览器请求会带上 Cache-Control: no-cache 和 Pragma: no-cache 头部信息。

1. 命中强缓存

app.use(async (ctx) => {
    // ctx.body = 'hello Koa'
    const url = ctx.request.url;
    if(url === '/'){
        // 访问跟路径返回 index.html
        ctx.set('Content-type', 'text/html');
        ctx.body = await parseStatic('./index.html')
    }else {
        ctx.set('Content-Type', parseMime(url))
        ctx.set('Expires', new Date(Date.now() + 10000).toUTCString()) // 实验1
        ctx.set('Cache-Control','max-age=20') // 实验2
        ctx.body = await parseStatic(path.relative('/', url))
    }
})

app.listen(3000, () => {
    console.log('starting at port 3000')
})

2. 命中协商缓存

         /**
         * 命中协商缓存
         * 设置 Last-Modified, If-Modified-Since
         */
         ctx.set('cache-control', 'no-cache'); // 关闭强缓存
         const isModifiedSince = ctx.request.header['if-modified-since'];
         const fileStat = await getFileStat(filePath);
         if(isModifiedSince === fileStat.mtime.toGMTString()){
             ctx.status = 304
         }else {
             ctx.set('Last-Modified', fileStat.mtime.toGMTString())
         }  
         ctx.body = await parseStatic(path.relative('/', url))

        /**
         * 命中协商缓存
         * 设置 Etag, If-None-Match
         */
         ctx.set('cache-control', 'no-cache');
         const ifNoneMatch = ctx.request.headers['if-none-match'];
         const fileBuffer = await parseStatic(filePath);
         const hash = crypto.createHash('md5');
         hash.update(fileBuffer);
         const etag = `"${hash.digest('hex')}"`
         if (ifNoneMatch === etag) {
            ctx.status = 304
          } else {
            ctx.set('Etag', etag)
            ctx.body = fileBuffer
          }
    }

三、 浏览器缓存

1.Cookies

  • MDN 定义: 是服务器发送到用户浏览器并报讯在本地的一小块数据,他会在浏览器下次想统一服务器再次发送请求时被携带并发送到服务器上。
  • 应用场景:
    • 会话状态管理【用户登陆状态,购物车,游戏分数或其他需要记录的信息】
    • 个性化设置(如用户自定义设置、主题等)
    • 浏览器行为跟踪(如跟踪分析用户行为等)
  • cookie 的读取与写入:
class Cookie {
	getCookie: (name) => {
		const reg = new RegExp('(^| )'+name+'=([^;]*)')
		let match = document.cookie.match(reg); //  [全量,空格,value,‘;’]
		if(match) {return decodeURI(match[2])}
	}
	setCookie:(key,value,days,domain) => {
		// username=John Smith; expires=Thu, 18 Dec 2043 12:00:00 GMT; path=/
	  let d = new Date();
    d.setTime(d.getTime()+(days*24*60*60*1000));
    let expires = "; expires="+d.toGMTString();
		let domain = domain ? '; domain='+domain : '';
		document.cookie = name + '=' + value + expires + domain + '; path=/'
		
	}
	deleteCookie: (name: string, domain?: string, path?: string)=> {
		// 删除cookie,只需要将时间设置为过期时间,而无需删除cookie的value
        const d = new Date(0);
        domain = domain ? `; domain=${domain}` : '';
        path = path || '/';
        document.cookie =
            name + '=; expires=' + d.toUTCString() + domain + '; path=' + path;
    },
}
  • 存在的问题: 由于通过 Cookie 存储的数据,每次请求都会携带在请求头。对与一些数据是无需交给提交后端的,这个不免会带来额外的开销。

2.WebStorage API

浏览器能以一种比使用 Cookie 更直观的方式存储键值对

localStorage

为每一个给定的源(given origin)维持一个独立的存储区域,浏览器关闭,然后重新打开后数据仍然存在。

sessionStorage

为每一个给定的源(given origin)维持一个独立的存储区域,该存储区域在页面会话期间可用(即只要浏览器处于打开状态,包括页面重新加载和恢复)。

3.indexedDB 与 webSQL

webSQL

基本操作与实际数据库操作基本一致。 最终的数据去向,一般只是做临时存储和大型网站的业务运行存储缓存的作用,页面刷新后该库就不存在了。而其本身与关系数据库的概念比较相似。

indexedDB

随着浏览器的功能不断增强,越来越多的网站开始考虑,将大量数据储存在客户端,这样可以减少从服务器获取数据,直接从本地获取数据。现有的浏览器数据储存方案,都不适合储存大量数据;

IndexedDB 是浏览器提供的本地数据库, 允许储存大量数据,提供查找接口,还能建立索引。这些都是 LocalStorage 所不具备的。就数据库类型而言,IndexedDB 不属于关系型数据库(不支持 SQL 查询语句),更接近 NoSQL 数据库。


四、应用程序缓存

Service Worker

在提及 Service Worker 之前,需要对 web Worker 有一些了解;

webWorker : Web Worker 是浏览器内置的线程所以可以被用来执行非阻塞事件循环的 JavaScript 代码。 js 是单线程,一次只能完成一件事,如果出现一个复杂的任务,线程就会被阻塞,严重影响用户体验, Web Worker 的作用就是允许主线程创建 worker 线程,与主线程同时进行。worker 线程只需负责复杂的计算,然后把结果返回给主线程就可以了。简单的理解就是,worker 线程执行复杂计算并且页面(主线程)ui 很流畅,不会被阻塞。


Service Worker 是浏览器和网络之间的虚拟代理。其解决了如何正确缓存往后网站资源并使其在离线时可用的问题。

Service Worker 运行在一个与页面 js 主线程独立的线程上,并且无权访问 DOM 结构。他的 API 是非阻塞的,并且可以在不同的上下文之间发送和接收消息。

他不仅仅提供离线功能,还可以做包括处理通知、在单独的线程上执行繁重的计算等事务。Service Workers 非常强大,因为他们可以控制网络请求,修改网络请求,返回缓存的自定义响应或者合成响应。

2.PWA

💡 PWA,全称 Progressive web apps,即渐进式 Web 应用。PWA 技术主要作用为构建跨平台的 Web 应用程序,并使其具有与原生应用程序相同的用户体验。
💡 PWA 的核心: 最根本、最基本的,就是 Service Worker 以及在其内部使用的 Cache API。只要通过 Service Worker 与 Cache API,实现了对网站页面的缓存、对页面请求的拦截、对页面缓存的操纵 。

为什么使用 PWA:

传统的 Web 存在的问题:

  1. 缺乏直接入口,需要记住他的域名,或者是保存在收藏夹,寻找起来不够方便;
  2. 依赖于网络。只要客户端处于断网的状态,整个 web 就处于瘫痪状态,客户端无法使用;
  3. 无法像 Native APP 推送消息。

传统 Native APP 存在的问题:

  1. 需要安装与下载。哪怕只是使用 APP 的某个功能,也是需要全盘下载的;
  2. 开发成本高,一般需要兼容安卓与 IOS 系统;
  3. 发布需要审核;
  4. 更新成本高……

PWA 的存在,就是为了解决以上问题所带来的麻烦: 优势:

  1. 桌面入口,打开便捷;
  2. 离线可用,不用过度依赖网络;
  3. 安装方便;
  4. 一次性开发,无需审核,所有平台可用;
  5. 能够进行消息推送
  • Web App Manifest Web App Manifest(Web 应用程序清单)概括地说是一个以 JSON 形式集中书写页面相关信息和配置的文件。
{
  "short_name": "User Mgmt",
  "name": "User Management",
  "icons": [
    {
      "src": "favicon.ico",
      "sizes": "64x64 32x32 24x24 16x16",
      "type": "image/x-icon"
    },
    {
      "src": "logo192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "logo512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": ".", // 调整网站的起始链接
  "display": "standalone", // 设定网站提示模式 : standalone 表示隐藏浏览器的UI
  "theme_color": "#000000", // 设定网站每个页面的主题颜色
  "background_color": "#ffffff" // 設定背景顏色
}
  • ServiceWorker.js 代码
/* eslint-disable no-restricted-globals */

// 确定哪些资源需要缓存
const CACHE_VERSION = 0;
const CACHE_NAME = 'cache_v' + CACHE_VERSION;
const CACHE_URL = [
  '/',
  'index.html',
  'favicon.ico',
  'serviceWorker.js',
  'static/js/bundle.js',
  'manifest.json',
  'users'
]
const preCache = () => {
  return caches
    .open(CACHE_NAME)
    .then((cache) => {
      return cache.addAll(CACHE_URL)
    })
}
const clearCache = () => {
  return caches.keys().then(keys => {
    keys.forEach(key => {
      if (key !== CACHE_NAME) {
        caches.delete(key)
      }
    })
  })
}
// 进行缓存
self.addEventListener('install', (event) => {
  event.waitUntil(
    preCache()
  )
})

// 删除旧的缓存
self.addEventListener('activated', (event) => {
  event.waitUntil(
    clearCache()
  )
})

console.log('hello, service wold');

self.addEventListener('fetch', (event) => {
  console.log('request:', event.request.url)
  event.respondWith(
    fetch(event.request).catch(() => { // 优先网络请求,如果失败,则从缓存中拿资源
      return caches.match(event.request)
    })
  )
})
  • PWA 调试

当离线的时候依然拿到缓存的资源,并且正常展示,可以看出资源被 serviceWorker 缓存。

借助开发者工具: chrome devtools : chrome://inspect/#service-workers ,可以展示当前设备上激活和存储的 service worker

五、总结与思考

参考优秀网站:

  1. 语雀: www.yuque.com/dashboard
  2. PWA 例子: mdn.github.io/pwa-example…