前言

浏览器缓存设计一直是web性能优化中非常重要的一个环节,也是SPA应用盛行的今天不得不考虑的问题.作为一名优秀的前端工程师,为了让我们的应用更流畅,用户体验更好,我们有必要做好浏览器缓存策略.

每个Web应用体验都必须快速,对于渐进式 Web 应用更是如此。快速是指在屏幕上获取有意义内容所需的时间,要在不到 5 秒的时间内提供交互式体验。并且,它必须真的很快。很难形容可靠的高性能有多重要。可以这样想: 本机应用的首次加载令人沮丧。已安装的渐进式 Web 应用必须能让用户获得可靠的性能。

本文会介绍一些笔者曾经做过的Web性能优化方案以及浏览器缓存的基本流程,并会着重介绍如何利用浏览器缓存API封装适合自己团队的前端缓存库来极大地提高应用性能,并为公司省钱.

你将收获

  • 熟悉浏览器缓存的基本过程

  • Web性能优化基本方案以及缓存策略为公司带来的价值

  • 基于localStorage的缓存方案设计以及库的封装(vuex/redux数据持久化解决方案)

  • 基于indexedDB的缓存方案设计以及库的封装

  • 结合http请求库(axios/umi-request)进行更细粒度的缓存代理层设计

正文

1.浏览器缓存的基本过程

首先要想设计一个优秀的缓存策略,一定要了解浏览器缓存的流程,接下来是笔者总结的一个基本的流程图:

上图展示了一个基本的从浏览器请求到展示资源的过程,我们的缓存策略一部分可以从以上流程出发来做优化.我们都知道页面的缓存状态是由header决定的,下面具体介绍几个概念:

1. ETag

由服务端根据资源内容生成一段 hash 字符串,标识资源的状态,用户第一次请求时服务器会将ETag随着资源一起返回给浏览器, 再次请求时浏览器会将这串字符串传回服务器,验证资源是否已经修改,如果没有修改直接使用缓存.具体流程可以是如下情景:

基于内容的hash往往会比Last-modified更准确.

2. Last-modified

服务器端资源最后的修改时间,必须和 cache-control 共同使用,是检查服务器端资源是否更新的一种方式。当浏览器再次进行请求时,会向服务器传送 If-Modified-Since 报头,询问 Last-Modified 时间点之后资源是否被修改过。如果没有修改,则返回 304,使用缓存;如果修改过,则再次去服务器请求资源,返回200,重新请求资源。

3. Expires

缓存过期时间,用来指定资源到期的时间,是服务器端的具体的时间点。也就是说,Expires=max-age + 请求时间,需要和 Last-modified 结合使用. Expires 是 Web 服务器响应消息头字段,在响应 http 请求时告诉浏览器在过期时间前浏览器可以直接从浏览器缓存取数据,而无需再次请求。

4. Cache-Control的max-age

单位为秒,指定设置缓存最大的有效时间。当浏览器向服务器发送请求后,在 max-age 这段时间里浏览器就不会再向服务器发送请求了。以上就是浏览器缓存几个基本的概念,更多知识可以在wiki中学习,这里就不一一介绍了.接下来我们具体看看如何优化web应用以及缓存策略给公司带来的价值.

2.Web性能优化基本方案以及缓存策略为公司带来的价值

Web性能优化又是老生常谈的问题了,几年前就一直在探讨这个问题,笔者大致盘点一下性能优化的几个常用的方向:

1.资源的合并与压缩.

比如我们常用的gulp或者webpack这些打包工具, 可以帮我们压缩js,css,html代码,并且将不同页面模块的js,css打包合并到一个文件中,好处就是减少了http请求,降低了资源的体积,使得响应更快.但是仍然存在一个缺陷,就是合并代码会导致一次请求的资源体积会比之前分包的要大,所以会一定程度的影响页面渲染时间,所以这里需要做一个权衡,或者部分采用按需加载的方式.

2.图片压缩

一个网站往往更占资源的是媒体文件,比如图片,视频,音频等,对于图片在发布到线上时最好是需求提前压缩一下, 为了减少图片请求几年前常用的做法是雪碧图,也就是几张图片合成一张大图,通过背景定位来显示不同的图片,不过目前貌似用的不多了,现在更多的采用字体图标,svg,或者webp,所以我们需要根据不同的场景使用不同的策略,当然目前主流的云平台支持对象存储,对媒体资源有不错的优化,有条件的可以采用这种方案,比如七牛云,阿里的对象存储oss.

3. 合理规划html代码结构

这个优化主要是为了提高页面渲染时间,我们都知道css和js的加载一般都是阻塞的, css不会阻塞js和外部脚本的加载,但是会阻塞js的执行, 如果我们把css放到body最底部,那么我们在网络不好的情况下可能会看到先展示html文本然后才渲染页面样式的窘境,如果我们把js脚本放到head内,那么将会阻塞后面内容的渲染,并且造成一些应dom还未生成的导致的错误, 虽然我们可以采用async、defer让script变成异步的,但是如果不同js文件有依赖关系,那么很可能导致意外的错误,所以我们的最佳实践往往是如下这种结构的:

<html>
<head>
  <title>趣谈前端</title>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0">
  <link rel="icon" href="/ico.png" type="image/x-icon">
  <link rel="stylesheet" href="/umi.348436c0.css">
<head>
<body>
  <div>...</div>
  // html内容
  
  <script src="/umi.520.js"></script>
</body>
</html>
复制代码

4.资源的懒加载和预加载

资源的懒加载可以极大的降低页面首屏时间, 我们不仅仅可以对图片采用懒加载, 即只给用户展示可视区域内的图片(虽然图片的懒加载意义更加重大),我们还可以对内容进行懒加载,本质上是一种特殊的分页技巧, jquery时代的lazyload是一个很好的例子,当然现在自己实现一个懒加载方案也非常简单,我们只需要使用getBoundingClientRect这个API配合具体业务使用即可,内容型平台用的比较多,比如我们手机滑到某一区域才加载更多内容,笔者之前做的某头条的广告埋点上报机制就是一个很好的例子.大致思路如下:

预加载就是提前加载图片,当用户需要查看时可直接从本地缓存中渲染.这种机制和懒加载往往相反,预加载为了带来更加流畅的用户体验,比如漫画网站,我们如果不使用预加载,那么用户频繁切换图片时体验是相当差的,所以我们需要提前将图片加载好,牺牲的代价就是用户可能会等待一定的时间来开启"漫画之旅".

5.静态资源使用cdn

cdn的好处就是可以突破浏览器同域名下一次最大请求并发数量,从而不用"排队"来提高加载速度.我们都是到同一域名下浏览器最多并发请求6条(不同浏览器之间有差异),超过6条的则会等待前面的请求完成才会继续发起,如果使用cdn,一方面它采用离用户最近的资源来响应,另一方面cdn往往和应用处于不同的域下,所以可以不用等待其他域下的并发数限制,从而加速网站响应.

6.浏览器缓存

这一块就是本文上一节中探讨的内容,这里不做过多介绍了,我们还可以采用localStorage, indexedDB来进一步优化缓存,我们下面会详细介绍这一块的内容.

7.代码层面的优化

代码层面往往就是工程师自己对代码掌控的能力,一个优秀的工程师往往会写出代码量更少,性能更好的代码, 比如采用函数式编程来优化代码结构,使用算法来提高js代码执行效率(比如排序,搜索算法),如果想了解更多这方面的知识,可以参考笔者之前写的两篇文章:

所以说在写代码时,请无时无都都提醒自己, 今天的代码跑性能测试了吗?

8.使用web worker技术并行执行js代码,减少阻塞

Web Worker的作用就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。

Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是Worker比较耗费资源,一旦使用完毕,就应该关闭。

知道了这些web性能优化知识,我们还要充分理解为什么要做这些优化.有过内容平台开发经验的朋友可能会知道,内容平台比较耗资源的就是媒体资源,比如图片,视频等,我们为了有更好的用户体验往往会将这些资源放到第三方服务平台存储,这样会有更好的请求性能还不用担心服务器压力,但是唯一缺点就是烧钱.每一个请求都是钱,虽然不多, 但是也抗不了百万千万的ip请求量,所以这些做的好的内容平台每年至少在这块花个几百万很正常,尤其是按请求付费.所以优化好了网站, 一方面可以带来更多的用户,更好的用户体验,也可以帮公司省流量, 进而帮老板省钱!(跪求求一个年终奖o(╥﹏╥)o).

接下里的内容,就教大家如何省钱.

3.基于localStorage的缓存方案设计以及库的封装(vuex/redux数据持久化解决方案)

localStorage属性允许你访问一个Document 源(origin)的对象 Storage;存储的数据将保存在浏览器会话中。localStorage 类似 sessionStorage,但其区别在于:存储在 localStorage 的数据可以长期保留;而当页面会话结束——也就是说,当页面被关闭时,存储在 sessionStorage 的数据会被清除 。

关于localStorage的文章也写了很多,使用方法也很简单, 这里就不做过多介绍了,但是有没有考虑自己封装一个localStorage呢? 大多数人可能会觉得很多余,因为localStorage提供的api已经够简单了,没必要封装,但是你有没有考虑过,localStorage是持久化缓存,不支持过期时间,所以有些业务场景下原生localStorage是满足不了的,所以这种情况下饿哦们需要自己实现具有过期时间的localStorage库, 关于如何实现该功能,笔者之前也写过一篇文章,有详细的介绍,并且可以让localStorage使用起来更强大,感兴趣的可以学习研究一下:

  • 基于 localStorage 实现一个具有过期时间的 DAO 库

笔者已经将库发布到npm上了,可以通过如下方式安装使用:

import dao from @alex_xu/dao
复制代码

或者在html标签中直接使用umd文件,github地址: 基于localStorage封装的可以设置过期时间的库

我们常用的vue里的状态管理库vuex,因为状态都是存在内存中的,那么如果要做web离线应用,或者web游戏,我们往往需要考虑持久化缓存, 那么我们也可以借助localStorage来实现状态的持久化功能,但是请记住,localStorage的存储空间在5-10M,如果有更大的需求,可以采用接下来介绍的indexedDB来实现.

4.基于indexedDB的缓存方案设计以及库的封装

IndexedDB主要用于客户端存储大量结构化数据(包括, 文件/ blobs)。该API使用索引来实现对该数据的高性能搜索。虽然 Web Storage 对于存储较少量的数据很有用,但对于存储更大量的结构化数据来说,这种方法不太有用。IndexedDB是一个事务型数据库系统,类似于基于SQL的RDBMS。然而,不像RDBMS使用固定列表,IndexedDB是一个基于JavaScript的面向对象的数据库。它允许我们存储和检索用键索引的对象;可以存储结构化克隆算法支持的任何对象。我们只需要指定数据库模式,打开与数据库的连接,然后检索和更新一系列事务。

我们刚刚接触indexedDB时往往觉得它很难懂, 我们首先需要使用open方法打开数据库,因为indexedDB大部分方法都是异步的,所以我们很难管理, 包括创建事务,创建表(一组数据的对象存储区), 添加对象存储等,这里笔者不会介绍如何使用indexedDB的具体使用方法,而是叫大家如何简化操作indexedDB的使用流程,封装成一个简单好用的缓存库.以下的封装都是基于promise,这样使用起来更优雅.以下是封装的思路:

我们工作中处理的indexedDB无非如上几个操作,所以我们需要将其从indexedDB底层API中抽离出来这几个api.具体实现如下:

declare global {
  interface Window { xdb: any; }
}

const xdb = (() => {
  let instance:any = null
  let dbName = ''
  let DB = function(args:any) {
    const cfg = {
      name: args.name || 'test',
      version: args.version || 1,
      onSuccess(e:Event) {
        args.onSuccess && args.onSuccess(e)
      },
      onUpdate(e:Event) {
        args.onUpdate && args.onUpdate(e)
      },
      onError(e:Event) {
        args.onError && args.onError(e)
      }
    }
    this.dbName = args.name
    this.request = null
    this.db = null
    // 打开/创建数据库
    this.init = function() {
      if (!window.indexedDB) {
        console.log('你的浏览器不支持该版本')
        return
      }

      let _this = this
      
      this.request = window.indexedDB.open(this.dbName, cfg.version)
      this.request.onerror = function (event:Event) {
        cfg.onError(event)
      }
      
      
      this.request.onsuccess = function (event:Event) {
        _this.db = _this.request.result
        cfg.onSuccess(event)
      }
      
      this.request.onupgradeneeded = function (event:any) {
        _this.db = event.target.result
        cfg.onUpdate(event)
      }
    }

    this.init()

    // 添加表
    this.createTable = function(name:string, opts:any = {}) {
      let objectStore:any
      if (!this.db.objectStoreNames.contains(name)) {
        opts = {
          keyPath: opts.keyPath,
          indexs: Array.isArray(opts.indexs) ? opts.indexs : []
        }

        // indexs = [{
        //   indexName: 'name',
        //   key: 'name',
        //   unique: true
        // }]

        objectStore = this.db.createObjectStore(name, { keyPath: opts.keyPath })

        if(opts.length) {
          opts.indexs.forEach((item:any) => {
            objectStore.createIndex(item.indexName, item.key, { unique: item.unique })
          })
        }
        return objectStore
      }
    }

    // 访问表中数据
    this.get = function(tableName:string, keyPathVal:any) {
      let _this = this
      return new Promise((resolve, reject) => {
        let transaction = this.db.transaction([tableName])
        let objectStore = transaction.objectStore(tableName)
        let request = objectStore.get(keyPathVal)
  
        request.onerror = function(event:Event) {
          reject({status: 500, msg: '事务失败', err: event})
        }
  
        request.onsuccess = function(event:Event) {
          if (request.result) {
            // 判断缓存是否过期
            if(request.result.ex < Date.now()) {
              resolve({status: 200, data: null})
              _this.del(tableName, keyPathVal)
            }else {
              resolve({status: 200, data: request.result})
            }
          } else {
            resolve({status: 200, data: null})
          }
        }
      })
    }

    // 遍历访问表中所有数据
    this.getAll = function(tableName:string) {
      return new Promise((reslove, reject) => {
        let objectStore = this.db.transaction(tableName).objectStore(tableName)
        let result:any = []
        objectStore.openCursor().onsuccess = function (event:any) {
          let cursor = event.target.result
  
          if (cursor) {
            result.push(cursor.value)
            cursor.continue()
          } else {
            reslove({status: 200, data: result})
          }
        }

        objectStore.openCursor().onerror = function (event:Event) {
          reject({status: 500, msg: '事务失败', err: event})
        }
      })
    }

    // 从表中添加一条数据
    this.add = function(tableName:string, row:any, ex:number) {
      return new Promise((reslove, reject) => {
        let request = this.db.transaction([tableName], 'readwrite')
          .objectStore(tableName)
          .add(Object.assign(row, ex ? { ex: Date.now() + ex } : {}))

        request.onsuccess = function (event:Event) {
          reslove({status: 200, msg: '数据写入成功'})
        }

        request.onerror = function (event:Event) {
          reject({status: 500, msg: '数据写入失败', err: event})
        }
      })
      
    }

    // 更新表中的数据
    this.update = function(tableName:string, row:any) {
      return new Promise((reslove, reject) => {
        let request = this.db.transaction([tableName], 'readwrite')
          .objectStore(tableName)
          .put(row)

        request.onsuccess = function (event:Event) {
          reslove({status: 200, msg: '数据更新成功'})
        }

        request.onerror = function (event:Event) {
          reject({status: 500, msg: '数据更新失败', err: event})
        }
      })
    }

    // 删除某条数据
    this.del = function(tableName:string, keyPathVal:any) {
      return new Promise((resolve, reject) => {
        let request = this.db.transaction([tableName], 'readwrite')
          .objectStore(tableName)
          .delete(keyPathVal)

        request.onsuccess = function (event:Event) {
          resolve({status: 200, msg: '数据删除成功'})
        }

        request.onerror = function (event:Event) {
          reject({status: 500, msg: '数据删除失败', err: event})
        }
      })
    }

    // 清空表数据
    this.clear = function(tableName:string) {
      return new Promise((resolve, reject) => {
        let request = this.db.transaction([tableName], 'readwrite')
          .objectStore(tableName)
          .clear()

        request.onsuccess = function (event:Event) {
          resolve({status: 200, msg: '数据表已清空'})
        }

        request.onerror = function (event:Event) {
          reject({status: 500, msg: '数据表清空失败', err: event})
        }
      })
    }
  }

  return {
    loadDB(args:any) {
      if(instance === undefined || dbName !== args.name) {
        instance = new (DB as any)(args)
      }
      return instance
    }
  }

})()

window.xdb = xdb

export default xdb
复制代码

这样就实现了一个基于promise的且支持过期时间的indexedDB库,实现过期时间也非常简单,就是在创建表的行时在底层添加一个过期时间字段,用户需要设置改行过期时间时, 只需要添加过期时间即可,当我们再次获取表格数据时只需要检测改行是否过期,如果过期就清除重新设置即可.

5.结合http请求库(axios/umi-request)进行更细粒度的缓存代理层设计

为了更大程度的发挥indexedDB存储空间的优势,并且进一步优化缓存策略,我们来可以做缓存拦截.我们都知道,一个应用的有些请求不需要频繁获取,比如省市级联数据, 区位地图数据,或者一些不需要经常更新的数据, 如果我们可以做到只请求一次, 下次请求直接使用内存数据,并设置一个过期时间, 到过期时间之后会重新请求数据, 那么是不是对请求又可以做一次优化?我们第一印象可能会写出这样的代码:

if(!store.get('xx')){
   http.get('xxx').then(res => {
    res && store.set('xx', res, 12 * 60 * 60 * 1000)
  })
}
复制代码

这样虽然可以实现功能,但是每一个业务都要写类似的代码, 往往很难受, 所以作为一个有追求的程序员,我们可以在请求上下功夫.我们都有过axios或者fetch库的使用经验,我们也接触过请求/响应拦截器的使用, 那么我们能不能考虑对请求本身也做一层拦截呢?我想实现的效果是我们在业务里还是正常的像之前一样使用请求,比如:

req.get('/getName?type=xxx').then(res)
复制代码

然而内部已经帮我们做好请求缓存了,我们的req实际上不是axios或者fetch的实例,而是一层代理.

通过这种方式我们对原来的请求方式可以不做任何改变, 完全采用代理机制在请求拦截器中和响应拦截器中布局我们的代理即可,关键点就是存到数据库中的内容要和服务器响应的内容结构一致.

以上方式我们可以对所有的get请求做缓存,如果我们只想对部分请求做缓存,其实利用以上机制实现也很简单,我们只需要设置缓存白名单, 在请求拦截器中判断如果在白名单内才走缓存逻辑即可.

这样,我们再次进行某项数据的搜索时,可以不走任何http请求,直接从indexedDB中获取,这样可以为公司节省大量的流量.

关于indexedDB的库的封装,我也发布到npm和github上了,大家可以直接使用或者进行二次开发.

  • github地址: xdb-采用promise封装的indexedDB存储库

最后

如果想学习更多H5游戏, webpack,node,gulp,css3,javascript,nodeJS,canvas数据可视化等前端知识和实战,欢迎在公号《趣谈前端》加入我们的技术群一起学习讨论,共同探索前端的边界.