本篇是 Chrome Extension 的第五篇入門文章。
在本文中將專門介紹 ChrExt 的資料儲存解決方案, 特別是 chrome.storage 的操作方式。雖然這個主題在前面章節中已經提到, 在這裡再從頭說明一遍, 從基礎講起。
目的
基本上 chrome.storage 和 localStorage API 的目的是相同的。但顧名思義, 它僅適用於 Chrome 瀏覽器, 而且只有在 ChrExt 中可以使用本功能。在一般網頁中根本找不到 chrome.storage 物件, 更不用講其下的任何功能了。
建立 ChrExt 之後, 在 chrome.storage 之下有 chrome.storage.local、chrome.storage.sync 和 chrome.storage.managed 三類。最後一種僅適用於企業內部配置的 policies 之搭配, 在本系列文章中不會提到。
而 chrome.storage.local 和 chrome.storage.sync 在語法與用法上相當類似, 唯目的不太一樣。前者僅適用於本機的儲存, 後者則是可以跨機共享資料 (但僅限於同一個 Google 帳戶)。後者可以當作前者使用, 反過來則不行。二者的儲存容量和傳輸限制也有不同, 下面會再提到。
Manifest
要在 ChrExt 中使用 chrome.storage API 你必須在 manifest.json 檔案中聲明 "storage" 的允許權限。寫法如下:
{
"manifest_version": 3,
...
"permissions": [
"storage"
],
...
}
在本文中一律採用 "manifest_version" 為 3 的對應語法。如果你不知道那是什麼意思, 可以回到本系列的第一篇入門文章看起。
如果你沒有寫入這個 permissions 項目, 你的 ChrExt 在執行時會遇到無法識別 chrome.storage 物件的問題。
語法
以下是 chrome.storage.local 的基本語法:
// 程式一
chrome.storage.local.set({key: value}, function() {
console.log('Value is set to ' + value);
});
chrome.storage.local.get(['key'], function(result) {
console.log('Value currently is ' + result.key);
});
以下是 chrome.storage.sync 的基本語法:
// 程式二
chrome.storage.sync.set({key: value}, function() {
console.log('Value is set to ' + value);
});
chrome.storage.sync.get(['key'], function(result) {
console.log('Value currently is ' + result.key);
});
看起來幾乎是一模一樣的, 對吧?
但是兩者當然不會是一樣的, 否則就不需要分為兩種寫法了。最主要的區別, 是如果你採用了 chrome.storage.local, 資料只能在本機存取; 若採用 chrome.storage.sync, 資料則會自動同步到使用同一個 Google 帳號登入的任何一機器上面 (但是 Google 帳號的同步功能必須啟用)。換句話說, 假設你在家裡的兩部機器上都用同一個 Google 帳號登入, 那麼透過同一個 ChrExt 存放的資料都會被同步 (雖然不是即時)。萬一某一部電腦的網路斷了, 資料將被保留在本機上, 等到網路連線恢復後, 會再度同步。
特別值得留意的一點是, 透過 storage.sync 存放的資料是未加密的, 在傳輸過程中可能有被攔截的可能。所以絕對不可以存放個資或者任何有價值的機密資料。
為了稍後說明方便, 我們必須先執行一段簡單的 set 指令。它只做一件事, 就是把一個物件存放到 storage 裡, 其內容是一個僅有兩個項目的字典物件:
// 程式三
chrome.storage.sync.set({ count: 1, log: 'my log' })
如此一來, 待會我們才能示範以 get 指令從 storage 中取出資料。
方法與參數解析
在繼續說明之前, 我們先來研究一下 storage.sync.get 的語法結構。
set 這個 function 的原始語法是這樣的:
(keys?: string | string[] | object, callback?: function) => {...}
再回顧一下我們上面舉過的範例(程式二)寫法:
chrome.storage.sync.get(['key'], function(result) {
console.log('Value currently is ' + result.key);
});
若對照一下原始語法, 我們可以看出這個 storage.sync.get 方法的參數有兩個:即 key? 和 callback?。既然後面都加上問號了, 意思是這兩個參數都是可以省略的 (optional)。如果你什麼參數都不指定 (或是傳入 null), 那麼它會傳回一個 Promise 物件, 裡面包括了你曾經加進去的所有東西:
如上圖, 那個 PromiseResult 所代表的 Object 就是我之前透過 storage.sync.set() 方法塞進去的所有資料。
[ * 註: 我發現 Chrome 更新到版本 108.0.5359.125 之後, callback 參數變成非 optional 了 ]
現在我們再來看一下 keys? 的參數形式:
string | string[] | object
這個寫法的意思是這個參數可以有三種型式:若不是單純的字串, 就是字串陣列, 要不然就是一個物件。
如果是單純一個字串呢?
基本上, 整個 storage 所儲存的資料就是一個 dictionary 結構 (亦即 key : value) 的 JavaScript Object。所以如果你只傳入一個 string 的話, 它就會回傳以它為 key 所對應的值:
當然, 如果你傳入的是字串陳列 那麼它傳回來的是 keys array 中所對應的所有 value 值:
其實, 如果我們只是要取回 storage 中的物件, 我們根本不需要採用前面兩種型式, 亦即第一個參數根本就是不需要的。我們可以在基本語法的範例程式(見程式二)中看到, 物件中所有值都可以在 callback 方法中取出來。所以就算你不傳入第一個參數, 也不會有什麼影響。例如以下程式所示:
chrome.storage.sync.get((e)=>{
console.log(e.count, e.log);
});
至於 keys 參數的第三種型式 (object) 到底有什麼目的呢?
由於我們前面已經先執行過程式三了, 所以 storage 裡是有東西的。但如果我們從來沒有在 storage 裡寫入東西 (或者未曾指定那個 key) 呢? 那麼你如果貿然去執行 chrome.storage.sync.get 的話, 當然會取不到任何東西。
為了避免這種問題, 我們就可以利用這個第三種型式, 指定 storage 物件的預設值:
// 程式四
const initStorage = { count: 1, log: 'My Log' };
chrome.storage.sync.get(initStorage, function (result) { ... }
這麼一來, 我們就不用擔心因為 storage 中沒有對應物件而引發問題了。
不過話說回來, 雖然我們可以藉由上述方法為回傳的結果指定預設值, 但這並不是一個好的做法。如果你並不了解這種寫法的真實意義, 你可能會誤導自己或者別人。程式四的寫法只是為了避免傳回空值的處理方式而已, 實際上你最好別這麼用。
所以, 請特別注意, 如果你從未執行過程式三 (亦即 set() 方法) 就貿然執行程式四 (亦即在 get() 方法裡指定預設值) 的話, 你實際上並未將任何資料寫入 storage – 即使它確實有回傳值。
錯誤處理
在處理 storage 存取時, 最常遇到的就是取錯值的問題。例如, 在我們的範例中 (程式一到三) 存入的物件有 'count' 與 'log' 兩個 key, 但如果你企圖去取一個 'name' 的值, 就會傳回 undefined:
chrome.storage.sync.get('name')
如果我們把傳回 undefined 視為例外狀況, 那麼發生例外的畫面如下:
這個例外是我們攔截到問題之後刻意以手動方式發出來的, 在正常情況下並不會出現這個例外。
由於 chrome.storage 的存取程序是非同步 (asynchronous) 的, 如果你企圖使用同步方式去存取它的值, 有可能取到空值。所以我們可以把讀取程式重新包裝如下:
// 程式五
const initStorage = { count: 1, log: 'My Log' };
var readStorage = async (key, obj=initStorage) => {
return new Promise((resolve, reject) => {
chrome.storage.sync.get(obj, function (result) {
if (result[key] === undefined) {
reject();
} else {
resolve(result[key]);
}
});
});
}
經過這樣包裝之後, 我們就可以使用 readStorage(key) 來取得字典物件裡面的值。但如果你企圖去取字典裡沒有的 key, 就會發生錯誤了:
值得注意的是, 我們是刻意加上 if (result[key] === undefined) 這個判斷式的。若遵照 Google 的教學範例, 寫法是這樣的:
// 程式六
const initStorage = { count: 1, log: 'My Log' };
var readStorage = async (key, obj=initStorage) => {
return new Promise((resolve, reject) => {
chrome.storage.sync.get(obj, function (result) {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
} else {
resolve(result[key]);
}
});
});
}
若照程式六的寫法, 如果 key 值錯誤, 它就不會發出例外, 只會傳回 undefined 而已。只有遇到其它的錯誤狀況 (亦即 chrome.runtime.lastError 必須有值) 時, 才會發出例外。
在這裡, reject() 和 resolve() 是 Promise 內建的 callback 方法, 前者會發出例外, 後者則會傳回結果。在這裡, 它會傳回 result[key] 找到字典內對應的值。你也可以直接傳回 result 物件。
現在我把錯誤鍵值的狀況連同其它的未知錯誤都一併處理, 合併為以下的程式:
// 程式七
const initStorage = { count: 1, log: 'My Log' };
var readStorage = async (key, obj=initStorage) => {
return new Promise((resolve, reject) => {
chrome.storage.sync.get(obj, function (result) {
if (result[key] === undefined)
reject("Invalid Key: " + key);
else if (chrome.runtime.lastError) {
reject("Unexpected error: " + chrome.runtime.lastError);
} else
resolve(result[key]);
});
});
};
非同步及例外處理
在上面我們已經知道如何處理錯誤狀況並發出例外了, 那麼我們如何在程式中進行例外的攔截呢?
以下我示範一個同時取得兩個查詢並能夠同時攔截例外的程式:
// 程式八
function retrieveData(para1='count', para2='log') {
let pCount = readStorage(para1);
let pLog = readStorage(para2);
let count = 0, log = '';
Promise.all([pCount, pLog])
.then( ([count, log]) => {
console.log('Count:', count, ', Log:', log);
})
.catch((e) => {
console.warn(e);
});
}
請注意, 在處理多筆非同步資料時, 我們必須等到資料通通已經成功收到之後, 才能繼續往下執行。在程式八中, 我們發出兩次 readStorage() 方法, 再透過 Promise.all() 等待這兩個回傳值成功傳進來, 然後在 Promise.then() 方法中對資料進行處理, 同時透過 Promise.catch() 方法對例外 (如果有) 進行攔截的動作, 以免中斷其後程式的執行。
因此, 如果我們執行 retrieveData('count, 'log') 或者 retrieveData(), 都可以正常取回其值。當然, 要再強調一下, 你必須記得先賦值。但是如果我們不小心寫錯 key 名稱, 例如 retrieveData('count, 'name') 的話, 這個錯誤就會發出例外並被攔截了:
使用上的限制
前面已經提過, 如果你並不需要在不同機器上同步資料的話, 那麼把以上程式中的 storage.sync 通通改作 storage.local 的話, 也都能正常運作的。差別是前者只能儲存 512 個項目, 後者則沒有這個限制:
此外, storage.sync 有每小時僅能執行 1,800 次的限制 (MAX_WRITE_OPERATIONS_PER_HOUR), 每分鐘為 120 次 (MAX_WRITE_OPERATIONS_PER_MINUTE), 總量大小限制為 102,400 bytes (QUOTA_BYTES), 每項資料的大小限制為 8,192 bytes (QUOTA_BYTES_PER_ITEM); storage.local 則沒有這些限制, 或者比較寬鬆。你也可以透過 getBytesInUse() 查詢你已經使用了多少空間。
如果你有興趣的話, 可以在 Console 中自行查詢各個項目, 如下圖:
若使用最簡單的寫法, 也可以使用程式九以取出資料並繫結到 DOM 上面:
// 程式九
readStorage('count').then(r => {
$('#txtCount').val(r.count);
});
readStorage('log').then(r => {
$('#txtLog').val(r.log);
});
不過, 除非你的應用很複雜, 否則我不建議採用這樣的寫法, 而是建議你採用更直覺的做法, 以方便日後的維護作業。
資料操作策略
chrome.storage.sync 與 local 除了 set() 方法與 get() 方法之外, 另外還提供 remove() 方法和 clear() 方法。其中 clear() 裡面只有一個 callback 參數。這個方法是用來將所有資料清除的, 請務必小心使用。而 remove() 方法可以使用 key 或 [keys] 以移除一個或多個字典項目。
前面已經提過, 存放在 chrome.storage 的內容是一個字典物件, 也就是所謂的 key-value pairs。其形式就是 key: value 或者其陣列。在這裡 key 必須是符合 JavaScript Object 形式的字串, 但 value 就不一定是字串了, 它可以是任何形式的「物件」, 你幾乎可以在裡面塞進任何東西, 所以它其實是可以靈活應用的, 只要你的操作在它容許的範圍 (例如前面提到過的 MAX_WRITE_OPERATIONS_PER_MINUTE 和 QUOTA_BYTES_PER_ITEM 等等) 裡面就行了。
當你在進行資料操作時, 可以選擇採取兩種策略:
第一種, 是把資料一個項目一個項目逐次加入或刪除。由於每執行一次 set() 方法就在字典裡加入一筆, 所以你可以等到需要時再進行寫入。如果你對相同的 key 再執行一次 set() 方法, 等同於覆寫原有的值 (亦即 update)。本文範例 (程式七+程式九) 中採用這種策略。
第二種, 就是自己管理整個字典, 然後使用一次 set() 方法把所有資料以 [key-value pairs] 為參數的形式一次寫入。官網範例即採用這種策略。
不管你打算採用何種策略, 如果你使用 storage.sync 的話, 它的寫入和讀取都不是即時的, 而且有較嚴格的容量和傳輸量的限制, 所以如果你的資料量大的話, 第二種做法有可能會比較吃力。
如果你採用第一種策略, 那麼你可以使用內建的 onChanged 事件處理程序來監看資料的變動情況。假設你允許資料以動態方式新增到 storage 中, 那麼你可以參考官網提供的範例來進行監看:
// 程式十
chrome.storage.onChanged.addListener(function (changes, namespace) {
for (let [key, { oldValue, newValue }] of Object.entries(changes)) {
console.log(
`Storage key "${key}" in namespace "${namespace}" changed.`,
`Old value was "${oldValue}", new value is "${newValue}".`
);
}
});
我們可以做幾個小實驗來看看它的執行結果
運用這個方法, 把程式十稍為修改一下, 你就可以在程式中自行記錄整個字典中的異動。
若再透過我在本系列 #4 介紹過的做法 (見「開發技巧」一節), 我們可以另外開一個頁籤, 重複開一個 popup.html 視窗, 然後利用程式十監看 storage 異動的功能, 在裡面即時記錄資料異動的過程。
其實, 若依 Chrome storage 的設計方式來看, 它最基本的應用方式應該是採用如上述的第一種做法, 也就是依 key:value 方式一筆一筆加入或移除。但是我們在使用之後很快就會遇到一個問題 – 尤其是針對 storage.sync, 它是認 Google 帳號的, 字典只有一本。但如果你有好幾個不同的 ChrExt APP, 你就會面臨 key 取名的問題。
像此類問題可以有兩種解決方法:
- 依不同 APP 對 Key 取名, 例如 A-Name、B-Name、C-Name 等等。你在 A 專案裡所有的 key 都以 A- 開頭。
- 在不同 APP 中建立一本較小的字典, 例如 key 為 A, 它的 value 本身就是另一本字典, 自行維護。
這兩種做法都各有好壞, 看你的專案大小而定。而且你不能忘記儲存筆數和容量大小都有上限這件事。
不管是哪一種做法, 你都可以考慮在每本字典 (或者最頂層) 存放有限的保留字 (Reserved Words)。例如使用 Configure 當作 Key, 裡面存放一些環境設定; 使用 Constants 來存放一些專案內會用到的固定變數等等。
此外, 你應該把 chrome.storage 視為某種動態的 (Dynamic)、揮發性的 (Volatile) 而且不可靠 (Unreliable) 的儲存體。你如果不小心下了一個 clear() 指令, 就可以把所有資料一次抹除; 既沒有任何警告, 也不能恢復。因此, 你應該在你的 ChrExt 裡面做一個備份與回復機制, 造過 JSON.stringify() 和 JSON.parse() 等方法將資料進行拷貝/還原, 以策安全。
此外, 千萬不要忘記 chrome.storage 以下的各種指令大多是非同步性質, 即使是 chrome.storage.local。換句話說, 它在 callback 程式裡做的動作都不是同步的, 也就是說它指令發動下去之後, 「一定不會」馬上生效, 就算你認為它的 size 很小, 應該很快; 但是一定是事與願違。也正因如此, 你應該避免在 callback 程式裡回傳任何東西, 因為你一定會取到 null 值或 undefined, 或者舊的值。
如果你的程式會透過 chrome.storage 取回儲存的值, 尤其是環境變數, 那麼你應該在網頁一啟動時就先把所有可能的值取出來, 然後把整個網頁會做的事放在那個 callback 裡, 就像我們習慣的 jQuery 的 ready() 函式的用法。盡量避免在其它程式執行到一半才想去取那個值, 否則你很有可能會遇到很多難以理解的問題, 例如網頁必須載入很多次之後才會變得正常。
KEEP IT SIMPLE
看到這裡, 讀者應該很清楚 Chrome 的各個 storage 指令都是非同步作業了。在進行非同步作業的操作時, 我們必須很清楚地與同步作業的 coding 習慣做出區隔, 以避免發生莫名其妙的問題。
所以, 即使在撰寫程式時不會出現任何警告, 但是如果在非同步的程式裡以同步作業的想法才揣度程式的走向, 那是一定會出問題的。所以, 我們要記得一件事, 就是絕對不可以在非同步的程式裡回傳任何值給同步的程式! 例如在 callback 程式裡 (不管是它是匿名或非匿名) return 任何值到外面去。如果你這樣做的話, 雖然在偶然的情況下看起來沒有問題, 但實際上卻存在著很多潛在的危險問題!
因此, 也請盡量不要把 chrome.storage 其下的各個指令包裝成獨立的函式, 除非你很清楚你在做什麼。
所以, 除非你的程式已經寫得很複雜, 而且你已經是處理非同步作業的高手, 否則像程式五裡的 readStorage(), 請盡量不要使用。亦即, 不要把非同步物件 (包括 Promise 物件) 和取出的結果當作參數傳來傳去。因為除非你的程式就是在處理非同步作業的, 而且你對非同步處理很熟悉了, 否則最好把你的程式寫得愈簡單愈好。
我的建議是, 盡量把你要做的事情都寫在 callback 函式區段裡。寧可把裡面的程式邏輯包裝起來, 也不要倒過來去包裝這個 callback 函式。如下範例所示:
// 好的做法
function A(a) { ...}
function B(b) { ...}
chrome.storage.sync.get( (e) => {
A(e.a);
B(e.b);
...
));
//壞的做法 - 必定出問題
function getResult() {
...
chrome.storage.sync.get( (e) => {
return([e.a, e.b]);
}
}
其實不光是 ChrExt, 甚至 JavaScript, 任何非同步程式都會有相同的問題。理解問題的源頭, 才能開始發展你自己的 best practice。
參考:
- [入門] Chrome Extension 入門 #1
- [入門] Chrome Extension 入門 #2
- [入門] Chrome Extension 入門 #3
- [入門] Chrome Extension 入門 #4
- [入門] Chrome Extension 入門 #5
- [入門] Chrome Extension 入門 #6
- Manifest V3 migration checklist