瀏覽器緩存庫設計總結(localStorage/indexedDB)

前言

瀏覽器緩存設計一直是web性能優化中很是重要的一個環節,也是SPA應用盛行的今天不得不考慮的問題.做爲一名優秀的前端工程師,爲了讓咱們的應用更流暢,用戶體驗更好,咱們有必要作好瀏覽器緩存策略.javascript

每一個Web應用體驗都必須快速,對於漸進式 Web 應用更是如此。快速是指在屏幕上獲取有意義內容所需的時間,要在不到 5 秒的時間內提供交互式體驗。而且,它必須真的很快。很難形容可靠的高性能有多重要。能夠這樣想: 本機應用的首次加載使人沮喪。已安裝的漸進式 Web 應用必須能讓用戶得到可靠的性能。css

本文會介紹一些筆者曾經作過的Web性能優化方案以及瀏覽器緩存的基本流程,並會着重介紹如何利用瀏覽器緩存API封裝適合本身團隊的前端緩存庫來極大地提升應用性能,並爲公司省錢.html

你將收穫

  • 熟悉瀏覽器緩存的基本過程
  • Web性能優化基本方案以及緩存策略爲公司帶來的價值
  • 基於localStorage的緩存方案設計以及庫的封裝(vuex/redux數據持久化解決方案)
  • 基於indexedDB的緩存方案設計以及庫的封裝
  • 結合http請求庫(axios/umi-request)進行更細粒度的緩存代理層設計

正文

1.瀏覽器緩存的基本過程

首先要想設計一個優秀的緩存策略,必定要了解瀏覽器緩存的流程,接下來是筆者總結的一個基本的流程圖: 前端

上圖展現了一個基本的從瀏覽器請求到展現資源的過程,咱們的緩存策略一部分能夠從以上流程出發來作優化.咱們都知道頁面的緩存狀態是由header決定的,下面具體介紹幾個概念:

1. ETag

由服務端根據資源內容生成一段 hash 字符串,標識資源的狀態,用戶第一次請求時服務器會將ETag隨着資源一塊兒返回給瀏覽器, 再次請求時瀏覽器會將這串字符串傳回服務器,驗證資源是否已經修改,若是沒有修改直接使用緩存.具體流程能夠是以下情景: vue

基於內容的hash每每會比Last-modified更準確.

2. Last-modified

服務器端資源最後的修改時間,必須和 cache-control 共同使用,是檢查服務器端資源是否更新的一種方式。當瀏覽器再次進行請求時,會向服務器傳送 If-Modified-Since 報頭,詢問 Last-Modified 時間點以後資源是否被修改過。若是沒有修改,則返回 304,使用緩存;若是修改過,則再次去服務器請求資源,返回200,從新請求資源。java

3. Expires

緩存過時時間,用來指定資源到期的時間,是服務器端的具體的時間點。也就是說,Expires=max-age + 請求時間,須要和 Last-modified 結合使用. Expires 是 Web 服務器響應消息頭字段,在響應 http 請求時告訴瀏覽器在過時時間前瀏覽器能夠直接從瀏覽器緩存取數據,而無需再次請求。node

4. Cache-Control的max-age

單位爲秒,指定設置緩存最大的有效時間。當瀏覽器向服務器發送請求後,在 max-age 這段時間裏瀏覽器就不會再向服務器發送請求了。 以上就是瀏覽器緩存幾個基本的概念,更多知識能夠在wiki中學習,這裏就不一一介紹了.接下來咱們具體看看如何優化web應用以及緩存策略給公司帶來的價值.jquery

2.Web性能優化基本方案以及緩存策略爲公司帶來的價值

Web性能優化又是老生常談的問題了,幾年前就一直在探討這個問題,筆者大體盤點一下性能優化的幾個經常使用的方向:webpack

1.資源的合併與壓縮.

好比咱們經常使用的gulp或者webpack這些打包工具, 能夠幫咱們壓縮js,css,html代碼,而且將不一樣頁面模塊的js,css打包合併到一個文件中,好處就是減小了http請求,下降了資源的體積,使得響應更快.可是仍然存在一個缺陷,就是合併代碼會致使一次請求的資源體積會比以前分包的要大,因此會必定程度的影響頁面渲染時間,因此這裏須要作一個權衡,或者部分採用按需加載的方式.ios

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使用起來更強大,感興趣的能夠學習研究一下:

筆者已經將庫發佈到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上了,你們能夠直接使用或者進行二次開發.

最後

若是想學習更多H5遊戲, webpacknodegulpcss3javascriptnodeJScanvas數據可視化等前端知識和實戰,歡迎在公號《趣談前端》加入咱們的技術羣一塊兒學習討論,共同探索前端的邊界。

更多推薦

相關文章
相關標籤/搜索