# 前端如何緩存大筆資料:IndexedDB 介紹/應用

最近疫情下,開始出現許多網路應用,而最先也是最為知名的就是口罩地圖的應用,利用 google map 與政府提供的 open api 做結合, 讓使用者能夠從地圖上得知各家供應店家的口罩庫存等資訊 ; 然而也因此在大量使用 google map api 的情況下也產生了不少費用的支出。

所以當自身有類似應用時該如何減少支出? 以自身為例,地圖應用上最常用到 google map 的Geolocation API的服務, 將地址轉為座標,然後定位在地圖上。

但如果流量大的話相對應的支出也一定會暴漲! 在沒有資料庫的情況下,單純前端,我們能做的就是盡量透過緩存資料來減少 api request 的次數。

# 該使用哪種方式進行緩存

緩存資料上會建議用以下:

那為什麼不用 localStorage,sessionStorage 呢?

可以參考 web.dev 的文章 refer to storage-for-the-web

# IndexedDB 介紹

簡單介紹一下自己使用後感受到的優點:

  • key-value 的儲存形式,透過索引功能來高效率搜尋資料
  • 同源政策 same-origin policy:只能取用同網域下的資料
  • Async API : 提供非同步 api,單線程的應用下取用資料時就不會有 block the main thread 的情況造成使用者體驗不佳
  • transaction : 能夠確保大量寫入資料時的完整性,如果有單筆資料寫入失敗會全數 rollback

# 相容性

瀏覽器相容性表格可參閱:When Can I Use IndexedDB

# 儲存限制

單一資料庫項目的容量/大小並沒有任何限制,但是各個 IndexedDB資料庫的容量就有限制,且根據各瀏覽器其限制會不同。

  • Chrome allows the browser to use up to 60% of total disk space. You can use the StorageManager API to determine the maximum quota available. Other Chromium-based browsers may allow the browser to use more storage.
  • Internet Explorer 10 and later can store up to 250MB and will prompt the user when more than 10MB has been used.
  • Firefox allows an origin to use up to 2GB. You can use the StorageManager API to determine how much space is still available.
  • Safari (both desktop and mobile) appears to allow up to 1GB. When the limit is reached, Safari will prompt the user, increasing the limit in 200MB increments. I was unable to find any official documentation on this.

refer to storage-for-the-web

# 資料鍵 (Key)

  • data type: string, date, float和 array
  • 必須是能排序的值(無法處理多國語言字串排序)
  • 物件存檔有三種方式產生資料鍵: 資料鍵產生器 (key generator)、資料鍵路徑 (key path) 以及指定值。

資料鍵產生器 (key generator): 用產生器自動產生資料鍵

資料鍵路徑 (key path):空字串或是javascript identifier(包含用 "." 分隔符號的名稱)且路徑不能有空白 (實測過中文會被轉成空字串)

# 實作

稍微看完以上介紹後,接下來實作基本緩存資料的方式。

這邊會用將 indexedDB api 用 promise 包裝後的套件:idb做演示

初始化

mkdir folder
cd folder
npm init -y

Install idb

npm i idb

folder structure:

.
└─ src
   └─ `index.js`
├── index.html
├── package.json
...

這邊會先用open api 來當作範例使用的資料來源

const getData = () => {
  return fetch(
    "https://raw.githubusercontent.com/kiang/pharmacies/master/json/points.json"
  )
    .then((res) => res.json())
    .then((json) => json)
    .catch((err) => console.error(err));
};

DB 初始化

async function initDB() {
  const dbPromise = await openDB("GeoData", 1, {
    upgrade(db) {
      // Create a store of objects
      db.createObjectStore("geo", {
        // The 'id' property of the object will be the key.
        keyPath: "id",
        // If it isn't explicitly set, create a value by auto incrementing.
        autoIncrement: true
      });
      // Create an index on the 'county' property of the objects.
      // store.createIndex("county", "county");
    }
  });

  const idbGeo = {
    async get(key) {
      return (await dbPromise).get("geo", key);
    },
    async set(key, val) {
      return (await dbPromise).put("geo", val, key);
    },
    async delete(key) {
      return (await dbPromise).delete("geo", key);
    },
    async clear() {
      return (await dbPromise).clear("geo");
    },
    async keys() {
      return (await dbPromise).getAllKeys("geo");
    }
  };

  return { dbPromise, idbGeo };
}

接下來主程式的部分,實作簡單的緩存範例

(async function () {
  // ...

  const { dbPromise, idbGeo } = await initDB();
  let keys = await idbGeo.keys(); // 取出key值來確認 db 是否有cache資料了
  if (!keys.length) { // 無資料的情況下才進行以下動作
    const jsonData = await getData(); // api 取資料回來
    await storeData(jsonData.features, dbPromise); // 存進indexedDB
    keys = await idbGeo.keys();
  }

  // ...
})();

實作大筆資料的儲存storeData

// 大量資料的存取時使用transaction確保完整性
async function storeData(data, db) {
  const tx = db.transaction("geo", "readwrite"); // 參數的部分單純取資料可用`readonly`

  const asyncList = (data) =>
    data.map((item) => {
      return tx.store.add(item);
    });

  await Promise.all([...asyncList(data), tx.done]); // 最後一步 call done method 來結束這次的transaction
}

# chrome devtool 查看工具使用

實作完以上程式後,可以透過chrome devtool > application > Storage > IndexedDB 查看究竟資料存進IndexedDB了沒以及儲存後的樣貌

devtool

# 錯誤處理(QuotaExceededError)

使用者瀏覽器的內存不足時會丟出 QuotaExceededError (DOMException) 的錯誤, 務必記得handle error避免使用者體驗不好,並依照各自邏輯進行錯誤處理。

例如範例: 當transaction時出現錯誤會呼叫callback .onabort

// 以上範例加上error handler
const transaction = db.transaction("geo",, 'readwrite');
transaction.onabort = function(event) {
  const error = event.target.error; // DOMException
  if (error.name == 'QuotaExceededError') {
    // Fallback code goes here
  }
};

# 瀏覽器資料庫清空

  • 使用者要求清空資料庫。許多瀏覽器讓使用者可以清空網站的 cookies、書籤、密碼和 IndexedDB 資料庫。
  • 私密瀏覽。Firefox 和 Chrome 等瀏覽器提供私密瀏覽模式,當瀏覽結束後,資料庫會被清空。
  • 超出硬碟空間或資料庫空間上限。
  • 資料毀損。
  • 當未來有不相容於現在的修改。

補充:說明空間已滿時

Web storage is categorized into two buckets, "Best Effort" and "Persistent"

indexedDB 屬於"Best Effort"(非常久性) 當瀏覽器空間不足時會開始清除非持久性資料 也就是eviction policy

每家的eviction policy 也不同:

  • Chromium-based browsers: 當瀏覽器空間不足時,會開始從最少使用的data清除直到空間不再超出限制。
  • Internet Explorer 10+: 沒有清除機制,但無法再寫入新資料。
  • Firefox: 當硬碟空間不足時,會開始從最少使用的data清除直到空間不再超出限制。
  • Safari: 以前沒有清除機制, 但現行有實施7日機制(當使用者七日沒有使用safari時,將會清空資料)。

如果是重要資訊:

You can request persistent storage for your site to protect critical user or application data.

# 總結

不再像是過去只會使用localStorage來暫存一些緩存資訊,這次學到IndexedDB來應對未來越來越龐大的緩存需求, 在使用上,需要多多注意的是針對瀏覽器空間限制與多寡的處理上,可以透過StorageManager API 來知道目前瀏覽器的內存資訊,並加以處理;

以及瀏覽器的資料清除機制,確保這些資料不是必要性的緩存,並在每次確認緩存資料來確保是否要重新更新。 最後提到,如果是重要的使用者資訊或是緩存資料,可以透過persistent storage 除非是使用者自行清除,不然是能夠避免瀏覽器的自動清除。

# Reference