BACK
Featured image of post 前端緩存大筆資料:IndexedDB 介紹/應用

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

Indexed Database API(簡稱IndexedDB,以前稱WebSimpleDB)是W3C推薦的一項網頁瀏覽器標準,是為提供一個具有索引的JSON物件集合的事務性本地資料庫操作介面。W3C於2015年1月8日發布了IndexedDB介面的最終建議。IndexedDB是一個嵌入在瀏覽器中的事務資料庫。該資料庫的管理圍繞JSON物件集合的概念,這類似NoSQL資料庫MongoDB與CouchDB。其中每個物件使用插入時生成的鍵標識。而索引系統最佳化對儲存物件的存取。

參考網站1
參考網站2


IndexedDB 介紹

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

相容性


儲存限制

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

  • Chrome:允許瀏覽器使用多達總磁盤空間的60%。 您可以使用StorageManager API來確定可用的最大配額。 其他基於Chromium的瀏覽器可能允許該瀏覽器使用更多存儲空間。
  • Internet Explorer 10 和更高版本:最多可以存儲250MB,並且在使用了10MB以上時將提示用戶。
  • Firefox:允許一個來源最多使用2GB。 您可以使用StorageManager API來確定仍有多少可用空間。
  • Safari (both desktop and mobile) 似乎最多可容納1GB,達到限制後,Safari會提示用戶,以200MB為增量增加限制。

refer to storage-for-the-web


資料鍵(Key)

  • data type: string, date, float和 array
  • 必須是能排序的值(無法處理多國語言字串排序)
  • 物件存檔有三種方式產生資料鍵: 資料鍵產生器 (key generator)資料鍵路徑 (key path) 以及指定值
  • 資料鍵產生器 (key generator):用產生器自動產生資料鍵。
  • 資料鍵路徑 (key path):空字串或是javascript identifier(包含用 “.” 分隔符號的名稱)且路徑不能有空白 (實測過中文會被轉成空字串)。

基本操作步驟

操作IndexedDB的基本步驟建議如下:
  1. 開啟資料庫和交易(transaction)
  2. 建立物件存檔(object store)
  3. 發出資料庫操作請求,例如新增或取得資料
  4. 聆聽對應DOM事件等待操作完成
  5. 從result物件上取得結果進行其他工作

使用方式

1. 試驗瀏覽器的前綴標示

  • 如果需要試驗瀏覽器的前綴標示,可以如下:
1
2
3
4
5
6
7
// In the following line, you should include the prefixes of implementations you want to test.
window.indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;
// DON'T use "var indexedDB = ..." if you're not in a function.
// Moreover, you may need references to some window.IDB* objects:
window.IDBTransaction = window.IDBTransaction || window.webkitIDBTransaction || window.msIDBTransaction;
window.IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange || window.msIDBKeyRange;
// (Mozilla has never prefixed these objects, so we don't need window.mozIDB*)
請注意瀏覽器前綴標示的實作可能不完整、有些問題或仍然遵守舊版標準,因此不建議在正式版程式碼中使用。與其宣稱支援又有問題,倒不如直接不支援。
1
2
3
if (!window.indexedDB) {
    window.alert("Your browser doesn't support a stable version of IndexedDB. Such and such feature will not be available.");
}

2. 開啟資料庫

1
2
3
4
5
6
7
8
9
// Let us open database
let request = window.indexedDB.open("DB名稱", 3);

request.onerror = function(event) {
  // Do something with request.errorCode!
};
request.onsuccess = function(event) {
  // Do something with request.result!
};
  • 開啟請求並不會立刻開啟資料庫或交易,呼叫open()方法會回傳一個IDBOpenDBRequest物件,這個物件擁有兩個事件(successerror)。大部分IndexedDB的非同步功能都會回傳一個IDBDatabase類物件,然後我們可以註冊成功和失敗事件處理器。
  • .open()方法第二個參數是資料庫版本,資料庫版本決定了資料庫結構,也就是資料庫物件存檔的結構。如果請求版本不存在(比如因為這是一個新資料庫或是資料庫版本已升級),onupgradeneeded事件會被觸發,然後我們可以在onupgradeneeded事件處理器中再建立新的版本,下面升級資料庫版本有更詳細的說明。

3. 使用資料鍵產生器

  • 當建立物件存檔時設定autoIncrement旗標為ture將啟動資料鍵產生器,預設上該旗標為false
  • 有了資料鍵產生器,當新增資料到物件存檔中,資料鍵產生器會自動幫我們產生資料鍵。資料鍵產生器產生的資料鍵由整數1開始,而基本上新產生的資料鍵是由前一個資料鍵加1產生。資料鍵的產生不會因為資料刪除或清空所有資料而重新開始起算,所以資料鍵值是一直累加上去的,除非資料庫操作中斷,整個交易作業被取消。
我們可以建立一個有資料鍵產生器的物件存檔如下:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Open the indexedDB.
var request = indexedDB.open(dbName, 3);

request.onupgradeneeded = function (event) {
    var db = event.target.result;

    // Create another object store called "names" with the autoIncrement flag set as true.
    var objStore = db.createObjectStore("names", { autoIncrement : true });

    // Because the "names" object store has the key generator, the key for the name value is generated automatically.
    // The added records would be like:
    // key : 1 => value : "Bill"
    // key : 2 => value : "Donna"
    for (var i in customerData) {
        objStore.add(customerData[i].name);
    }
}

關於資料鍵產生器細節,請參考“W3C Key Generators”

4. 新增和刪除資料

  • 在操作資料庫之前必須要先進行交易,交易來自資料庫物件,在交易中要指定涵蓋物件存檔範圍,然後也要決定是要變更資料庫或純粹讀取資料。
  • 交易共有三種種類,分別是讀取(read-only),讀寫(read/write), 以及版本變更(versionchange),如果只需要讀資料最好只使用讀取(read-only)交易,因為讀取(read-only)交易可以多重同步進行。
創建資料庫後,如果要寫入資料請這麼做:
1
2
3
4
var transaction = db.transaction(["customers"], "readwrite");
// Note: Older experimental implementations use the deprecated constant IDBTransaction.READ_WRITE instead of "readwrite".
// In case you want to support such an implementation, you can just write:
// var transaction = db.transaction(["customers"], IDBTransaction.READ_WRITE);
  • 呼叫 transaction() 方法會回傳一個交易物件。transaction()第一個接受參數代表交易涵蓋的物件存檔,雖然傳入空陣列會讓交易涵蓋所有物件存檔,但請不要這麼做,因為根據正式標準傳入空陣列應該要導致InvalidAccessError錯誤;第二個參數代表交易種類,不傳入的話預設為讀取交易,本例要寫入資料庫所以傳入讀寫(“readwrite”)。
  • 交易的生命週期和事件循環關係密切。當我們發起交易又回到事件循環中後,如果忽略,那麼交易將轉成結束,唯一保持交易存活的方法是在交易上發出請求;當請求完成後我們會收到DOM事件,假設請求結果成功,我們可以在事件的回呼函數(callback中)繼續進行交易,反之,如果我們沒有繼續進行交易,那麼交易將結束,也就是說只要尚有未完成請求的話,交易就會繼續存活,如果收到TRANSACTION_INACTIVE_ERR錯誤那便意謂著交易早已結束,我們錯過了繼續進行交易的機會。
  • 交易能收到三種事件: 錯誤(error)中斷(abort)以及完成(complete),其中錯誤事件會向上傳遞,所以任何一個交易下轄的請求產生錯誤事件,該交易都會收到。如果交易收到錯誤事件時,瀏覽器預設行為會中斷交易,除非我們有在錯誤事件上呼叫preventDefault()阻擋瀏覽器預設行動,否則整筆交易都將取消、復原,這樣的設計告訴我們必須要思考如何處裡錯誤,或者說如果對每一個錯誤進行處裡過於麻煩,那麼至少加入一個概括性的錯誤處理器也是可以。只要不處裡錯誤或呼叫abort(),交易將取消、復原,然後中斷事件接著觸發,反之,當所有請求都完成後,我們會收到一個完成事件,所以說如果我們同時發起多項請求時,可以只追蹤單一交易而非個別請求,這樣會大大減輕我們的負擔。
有了交易之後便能夠從中取得物件存檔,有了物件存檔便能夠新增資料(請注意唯有在建立交易時指定的物件存檔能夠取得)。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Do something when all the data is added to the database.
transaction.oncomplete = function(event) {
    alert("All done!");
};

transaction.onerror = function(event) {
    // Don't forget to handle errors!
};

var objectStore = transaction.objectStore("customers");
for (var i in customerData) {
    var request = objectStore.add(customerData[i]);
    request.onsuccess = function(event) {
        // event.target.result == customerData[i].ssn;
    };
}
  • 呼叫 add() 方法可以加入一筆新資料,呼叫後會回傳一個IDBRequest物件,即為上方範例中的request,如果新增成功,request的成功事件會被觸發,而成功事件物件中有一個result屬性,這個result值剛好就等於新資料的資料鍵,所以說上方範例中的event.target.result剛好就等於顧客的ssn值(因為我們用ssn屬性作為資料鍵路徑)。請注意add方法只在當物件存檔中沒有相同資料鍵資料存在時有用,如果想要更動或是直接覆蓋現存資料請呼叫put方法。

5. 移除資料

移除資料十分容易:
1
2
3
4
var request = db.transaction(["customers"], "readwrite").objectStore("customers").delete("444-44-4444");
request.onsuccess = function(event) {
    // It's gone!
};

6. 讀取資料

要取資料庫內的資料有數種途徑,第一個最簡單的途徑就是提供資料鍵,呼叫 get() 方法取得資料:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var transaction = db.transaction(["customers"]);
var objectStore = transaction.objectStore("customers");
var request = objectStore.get("444-44-4444");
request.onerror = function(event) {
    // Handle errors!
};
request.onsuccess = function(event) {
    // Do something with the request.result!
    alert("Name for SSN 444-44-4444 is " + request.result.name);
};
假設我們把錯誤處理放在資料庫層級,我們可以再縮短上面的程式碼如下:
1
2
3
db.transaction("customers").objectStore("customers").get("444-44-4444").onsuccess = function(event) {
    alert("Name for SSN 444-44-4444 is " + event.target.result.name);
};
  • 呼叫 transcation 方法而不指定模式會開啟讀取(readonly)模式,接著取得我們的目標物件存檔,輸入目標資料的資料鍵,呼叫get方法取得請求物件,然後在請求物件上註冊成功事件處理器,當作業成功後,成功事件會觸發,成功事件的物件中含有請求物件(event.target如上述範例),請求物件中含有請求結果(event.target.result如上述範例)。

7. 使用指標(Cursor)

使用get方法需要知道資料鍵,如果想要一一存取物件存檔中的資料,我們可以利用指標:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var objectStore = db.transaction("customers").objectStore("customers");

objectStore.openCursor().onsuccess = function(event) {
    var cursor = event.target.result;
    if (cursor) {
        alert("Name for SSN " + cursor.key + " is " + cursor.value.name);
        cursor.continue();
    } else {
        alert("No more entries!");
    }
};
  • openCursor 方法第一個參數用來接受資料鍵範圍物件來限制存取資料範圍,第二個參數用來指定存取進行方向,像上面的範例程式便是以資料鍵由小到大之方向存取資料;呼叫openCursor方法後一樣會回傳一個請求物件,成功時成功事件會觸發,不過這裡有些地方要特別注意,當成功事件處裡函數被喚起時,指標物件(cursor)會存放在result屬性內(亦即上述event.target.result),cursor物件下有兩個屬性,key屬性是資料鍵,value屬性是資料值,如果要取得下一份資料就呼叫cursor的continue()方法,然後cursor物件就會指向下一份資料,當沒有資料時,cursor會是undefined,當請求一開始便找沒有資料,result屬性也會是undefined。
以下用cursor存取一遍資料後放在陣列中的作法相當常見:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var customers = [];

objectStore.openCursor().onsuccess = function(event) {
    var cursor = event.target.result;
    if (cursor) {
        customers.push(cursor.value);
        cursor.continue();
    } else {
        alert("Got all customers: " + customers);
    }
};

Warning: 以下範例不是IndexedDB標準!

Mozilla瀏覽器自己做了一個 getAll() 方法來方便一次取得所有cursor下的資料值,這個方法相當方便,不過請小心未來它有可能會消失。以下程式碼的效果和上面的一樣:
1
2
3
objectStore.getAll().onsuccess = function(event) {
    alert("Got all customers: " + event.target.result);
};
  • 一一檢查cursor的value屬性較不利性能表現,因為物件是被動一一建立,然而呼叫 getAll(),Gecko一定要一次建立所有物件,所以如果想要一次取得所有物件的資料值陣列使用 getAll() 比較好,如果想要一一檢查每筆資料則請利用cursor的方法。

8. 使用索引

  • 利用一定唯一的ssn碼作為資料鍵相當合乎邏輯(隱私權的問題先擱置一放,不在本文探討範圍)。不過當我們想要查詢使用者的名字的時候,如果沒有索引就需要一一檢查每一筆資料,十分沒有效率,所以我們可以建立name的索引。
1
2
3
4
var index = objectStore.index("name");
index.get("Donna").onsuccess = function(event) {
    alert("Donna's SSN is " + event.target.result.ssn);
};
  • 因為 name 不是唯一值,所以可能會有多筆資料符合"Donna"名字,此時呼叫 get() 會取得資料鍵最小值的資料。

9. 設定指標查詢範圍和方向

  • 如果想要限定指標查詢範圍,那麼在乎叫 openCursor()openKeyCursor() 時第一個參數要傳入 IDBKeyRange 物件以限制範圍。IDBKeyRange物件能夠只聚焦在單一資料鍵上或者一段上下限區間;上下限區間可以是封閉(含界限)或開放(不含界限),請看以下範例:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Only match "Donna"
var singleKeyRange = IDBKeyRange.only("Donna");

// Match anything past "Bill", including "Bill"
var lowerBoundKeyRange = IDBKeyRange.lowerBound("Bill");

// Match anything past "Bill", but don't include "Bill"
var lowerBoundOpenKeyRange = IDBKeyRange.lowerBound("Bill", true);

// Match anything up to, but not including, "Donna"
var upperBoundOpenKeyRange = IDBKeyRange.upperBound("Donna", true);

// Match anything between "Bill" and "Donna", but not including "Donna"
var boundKeyRange = IDBKeyRange.bound("Bill", "Donna", false, true);

index.openCursor(boundKeyRange).onsuccess = function(event) {
    var cursor = event.target.result;
    if (cursor) {
        // Do something with the matches.
        cursor.continue();
    }
};
有時候我們會想要由大到小查看資料而非預設由小到大方向,傳入第二個"prev"字串便能做到:
1
2
3
4
5
6
7
objectStore.openCursor(null, "prev").onsuccess = function(event) {
    var cursor = event.target.result;
    if (cursor) {
        // Do something with the entries.
        cursor.continue();
    }
};
由於"name"索引不具唯一性,所以一個名字下可能會出現多筆資料,此時如果想要避開這多筆資料,請傳入"nextunique"或"prevunique"做為第二個方向參數,當傳入之後,如一個名字下遇到多筆資料,則只有資料鍵最小的資料會被回傳。
1
2
3
4
5
6
7
index.openKeyCursor(null, "nextunique").onsuccess = function(event) {
    var cursor = event.target.result;
    if (cursor) {
        // Do something with the entries.
        cursor.continue();
    }
};

關於可傳入的方向參數,請參考IDBCursor常數。


安全性

  • IndexedDB遵守同源政策,所以它綁定創建它的來源網站,其他來源網站無法存取。
  • 就像對載入 <frame><iframe> 網頁的第三方cookie所設下的安全性和隱私權考量限制,IndexedDB無法在載入 <frame><iframe> 網頁上運作,詳情請見 bug 595307

瀏覽器關閉風險

當瀏覽器關閉,例如使用者按下關閉鈕,任何未完成IndexedDB交易都將默默中止,這些交易不會完成,錯誤事件也不會觸發。既然瀏覽器可能隨時被關閉,我們無法完全指望任何特定交易一定會完成,或是依賴錯誤事件做出相應處理,針對這種狀況,我們需要注意:
  1. 每一筆交易結束後都應該要保持資料庫完整性,例如說,有一串使用者編輯項目清單正要存入資料庫,我們如果先在一個交易中清除舊清單,然後在另一個交易中存入新清單,那就會面臨到清除完就清單後,新清單存入交易還來不及回存,瀏覽器就關閉的風險,而這個時候資料庫裡面的清單資料將消失。所以比較好的做法應該是在同一筆交易中完成清除舊清單和存入新清單。
  2. 永遠不要在unload事件中執行資料庫交易,因為如果unload事件是觸發在瀏覽器關閉下,任何資料庫交易都不會發生,或許,瀏覽器(或分頁)打開時讀取資料庫,更新資料庫當使用者編輯資料,當瀏覽器(或分頁)關閉時儲存資料這樣的做法比較直覺,不過資料庫的操作是非同步進行地,所以瀏覽器關閉的執行會在資料庫操作前發生,進而中斷後續非同步的資料庫交易,所以在unload事件中執行資料庫交易是行不通地。
  • 事實上不論瀏覽器是否正常關閉,都沒有任何方法保證IndexedDB交易能夠順利完成,請見 bug 870645

範例練習:將聊天室推播訊息寫進 IndexedDB

  • 建立db.js,並將操作 IndexedDB 整合至檔案中。
  • vue檔引入db.js,並一次僅撈最新50筆資料。
db.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 回傳是否支援 IndexedDB
export function isSupportDB() {
    const indexedDB = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB || window.msIndexedDB
    return !!indexedDB
}

// 打開or建立DB
export function openDB() {
    return new Promise((resolve, reject) => {
        const request = indexedDB.open("ChatDB") // 打開or建立聊天室DB
        request.onerror = e => {
            console.log("idb create fail")
            reject(e)
        }
        request.onsuccess = () => {
            console.log("idb create success")
            DBObject = request.result
            resolve(DBObject) // 成功後返回DB物件
        }
        request.onupgradeneeded = e => {
            // 若版本已升級則重新建立DB物件,並返回DB物件
            DBObject = e.target.result
            DBObject.createObjectStore("chatData", { keyPath: "index", autoIncrement: true })
            resolve(DBObject)
        }
    })
}

// 取歷史聊天紀錄最新50筆
export function getHistory(num) {
    return new Promise(resolve => {
        const objectStore = DBObject.transaction(["chatData"], "readonly").objectStore("chatData")
        let getKey = objectStore.getAllKeys()
        getKey.onsuccess = () => {
            let result = []
            for (let i = getKey.result.length - num * 50 - 1; i > getKey.result.length - num * 50 - 50 - 1; i--) {
                let getItem = objectStore.get(getKey.result[i])
                getItem.onsuccess = () => {
                    result.unshift(getItem.result)
                }
            }
            resolve(result)
        }

    // 一次撈全部資料
    // const request = DBObject.transaction(["chatData"], "readonly").objectStore("chatData").getAll()

    // request.onsuccess = () => {
    //    resolve(request.result)
    // }
    })
}

// 收到推播將訊息寫入idb
export function addToDB(data) {
    const request = DBObject.transaction(["chatData"], "readwrite")
    request.objectStore("chatData").add(data)

    //数据写入成功的回调
    request.onsuccess = () => {}

    request.onerror = event => {
        console.log(event)
    }
}

// 清除idb聊天紀錄
export function clearDB() {
    const request = DBObject.transaction(["chatData"], "readwrite")
    request.objectStore("chatData").clear()
}
chatroom.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
...
<script>
import * as idb from "@/utils/db"

export default {
    data: {
        return {
            msgData: [],
            historyRange: 0
        }
    }
    mounted() {
        setTimeout(() => {
            this.getHistory(this.historyRange)
        }, 500)
    }
    methods: {
        getHistory(num) {
            if (idb.isSupportDB()) {
                // 若支援則開啟DB,並待回傳後撈資料
                idb.openDB().then(() => {
                    idb.getHistory(num).then(result => {
                        setTimeout(() => {
                            if (!this.msgData.length) {
                                // 未有資料時直接定義
                                this.msgData = result
                            } else {
                                result.forEach(item => {
                                    // 將撈到的資料由前塞入msgData
                                    this.msgData.unshift(item)
                                })
                            }
                            this.historyRange++
                        }, 0)
                    })
                })
            }
        },
        add(data) {
            idb.addToDB(data)
        },
        clearDB() {
            idb.clearDB()
        }
    }
}
</script>
comments powered by Disqus