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

前言


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

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

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

你將收穫

正文

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 >
  <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>
複製代碼

小夕說 ,贊 13

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 遊戲, webpack,node,gulp,css3,javascript,nodeJS,canvas 數據可視化等前端知識和實戰,歡迎在公號《趣談前端》加入我們的技術羣一起學習討論,共同探索前端的邊界.

❤️ 謝謝支持

歡迎關注公衆號 趣談前端 收貨大廠一手好文章~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/z4OeyqOXawHUpXChwU7P-A