上回提到在瀏覽器裡跑的 SQL 引擎 - SQLite WASM 帶出 DB 檔案該存哪裡的議題,我後來用序列化存進 localStroage 做了簡單示範,但 localStorage 5 ~ 10 MB 的容量上限明顯限制了應用場景。

除了 localStorage,瀏覽器還有另外兩種長期儲存資料的好選項:IndexedDB 及 OPFS,這篇將實地操練一番,將這兩項技術正式列為未來可應用的解決方案。

先來張比較表:

項目localStorageIndexedDBOPFS (Origin Private File System)
儲存容量約 5-10MB(依瀏覽器不同)高,可用磁碟空間的 10%-60%,最高可達數 GB無固定初始限制,受磁碟空間和瀏覽器限制
資料類型字串,需 JSON 處理物件支援多種資料類型(物件、二進位、檔案等)主要是二進位檔案,支援檔案和目錄操作
API 複雜度簡單同步 API較複雜,非同步,需事件或 Promise 處理檔案系統風格 API,直覺易懂
操作模式同步操作,易阻塞主執行緒非同步操作,不阻塞主執行緒支援同步與非同步操作,WebWorker 可用同步存取
效能低延遲,快速小量讀寫良好,但寫入延遲稍高,初始化需時間效能最高,較 IndexedDB 快3-4倍,適合高效能需求
安全性易被存取,不適合敏感資料支援交易,資料一致性較佳資料對使用者不可見,提供更好隱私保護
適用情境小型資料,使用者偏好設定,表單暫存大量結構化資料,適合 PWA 離線應用,複雜查詢高速檔案操作,媒體處理,大型檔案分塊上傳

但要注意,以上三者都會因為使用者清除瀏覽器記錄而消失,使用無痕模式時無法讀取及長期保存。考慮前述特性,這些儲存只適合用來存快取、暫存性質,或遺失不會心痛的資料,重要資料還是要存回伺服器端。

不囉嗦直接上程式碼。

MDN 的 IndexedDB 說明文件是最好的參考來源﹐它的操作方式有點特殊,建立 IDBRequest 透過 onupgradeneeded 事件建立資料庫,IDBTransaction 物件再透過 onsuccess 事件取得查詢結果、oncomplete 事件確認動作完,錯誤處置則是靠 onerror,走一個非同步路線。我寫了一個簡單的範例包含新增、刪除、讀取,補充說明寫在註解。線上展示

<!DOCTYPE html>
<html lang="zh-tw">

<head>
    <meta charset="UTF-8">
    <title>IndexedDB 範例</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 6px;
        }

        ul {
            width: 640px;
            min-height: 100px;
            background-color: #f4f4f4;
            padding: 10px;

            li {
                list-style-type: none;
            }
        }
    </style>
</head>

<body>
    <h1>IndexedDB 範例</h1>
    <button onclick="testAdd()">新增<span id="currPlayerId"></span></button>
    <button onclick="testRead()">讀取資料</button>
    <button onclick="testDel()">刪除最後一筆</button>
    <ul id="output"></ul>
    <script>
        const output = document.getElementById('output');
        const colorMapping = {
            'ERR': 'red',
            'QRY': 'blue',
            'ADD': 'green',
            'DEL': 'brown',
            'DDL': 'purple'
        };
        function log(action, message) {
            const li = document.createElement('li');
            li.textContent = `[${action}] ${message}`;
            li.style.color = colorMapping[action] || 'black';
            output.appendChild(li);
        }

        class player {
            constructor(playerId, name, score) {
                this.playerId = playerId;
                this.name = name;
                this.score = score;
            }
            static display(playerObj) {
                return `${playerObj.playerId}|${playerObj.name}|${playerObj.score}`;
            }
        }
        const sampleData = [
            new player('A01', 'Jeffrey', 255),
            new player('A02', 'darkthread', 32767)
        ];
        let sampleIndex = 0;
        function updatePlayerId() {
            document.getElementById('currPlayerId').textContent = `(${sampleData[sampleIndex].playerId})`;
        }
        updatePlayerId();

        let db;

        // 開啟或建立資料庫
        // 如果要異動資料庫結構,可指定不同的版本號
        // 在 onupgradeneeded 事件變更資料庫結構
        const request = indexedDB.open('MyTestDB', 1);

        // 新建或升級資料庫時會觸發 onupgradeneeded 事件
        request.onupgradeneeded = function (event) {
            db = event.target.result;
            if (!db.objectStoreNames.contains('players')) {
                const dbStore = db.createObjectStore('players', { keyPath: 'id', autoIncrement: true });
                // 可建立索引限制重複資料或加速查詢
                dbStore.createIndex('playerId', 'playerId', { unique: true });
                dbStore.createIndex('name', 'name', { unique: false });
                log('DDL', 'Create players table');
            }
        };
        // 開啟資料庫的成功失敗事件
        request.onsuccess = function (event) {
            db = event.target.result;
        };
        request.onerror = function (event) {
            log('ERR', event.target.errorCode);
        };

        function testAdd() {
            const tx = db.transaction('players', 'readwrite');
            const store = tx.objectStore('players');
            const addRequest = store.add(sampleData[sampleIndex]);
            const userDisplay = player.display(sampleData[sampleIndex]);
            addRequest.onerror = function (event) {
                log('ERR', event.target.error);
            };
            sampleIndex = (sampleIndex + 1) % sampleData.length; // 循環使用範例資料
            updatePlayerId();
            tx.oncomplete = function () {
                log(`ADD`, userDisplay);
            };
        };

        function testDel() {
            const tx = db.transaction('players', 'readwrite');
            const store = tx.objectStore('players');
            const request = store.getAll();
            request.onsuccess = function () {
                const results = request.result;
                if (results.length > 0) {
                    const lastItem = results[results.length - 1];
                    const deleteRequest = store.delete(lastItem.id);
                    deleteRequest.onsuccess = function () {
                        log(`DEL`, lastItem.id + '. ' + player.display(lastItem));
                    };
                } else {
                    log('QRY','No items to delete.');
                }
            };
        };

        function testRead() {
            const tx = db.transaction('players', 'readonly');
            const store = tx.objectStore('players');
            const request = store.getAll();
            request.onsuccess = function () {
                const results = request.result;
                results.forEach(item => {
                    log(`QRY`, `${item.id}. ${player.display(item)}`);
                });
            };
        };
    </script>
</body>

</html>

要更新則是利用 store.put()

function testUpdate() {
    const tx = db.transaction('players', 'readwrite');
    const store = tx.objectStore('players');
    
    // 修改現有資料
    const updatedPlayer = new player('A01', 'Jeffrey Updated', 9999);
    updatedPlayer.id = 1; // 需要指定主鍵 id
    
    const updateRequest = store.put(updatedPlayer);
    
    updateRequest.onsuccess = function() {
        log('UPD', `Updated: ${player.display(updatedPlayer)}`);
    };
    
    updateRequest.onerror = function(event) {
        log('ERR', event.target.error.message);
    };
}

F12 開發者工具有 IndexedDB 資料檢視器,挺方便的。

至於 OPFS(Origin Private File System),一樣可以參考 MDN 文件,OPFS 的 API 用起來蠻直覺,基本上就像操作一般的檔案系統,而且直接支援 async/await 要同步或非同步處理都很方便。我實測用 OPFS 寫入 100MB 檔案只需 0.25 秒,效率很不錯,加上可在 Web Worker 使用,非常適用來處理大型資料。線上展示

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>OPFS Example</title>
    <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 6px;
        }

        button {
            margin-right: 2px;
        }

        .messages {
            width: 640px;
            margin-top: 10px;
            min-height: 100px;
            background-color: #f4f4f4;
            padding: 6px;
        }
    </style>
</head>

<body>
    <div id="app">
        <h1>Origin Private File System (OPFS) 範例</h1>
        <button @click="listRootDirectory">列出目錄</button>
        <button @click="writeFile">寫文字檔</button>
        <button @click="readFile">讀文字檔</button>
        <button @click="writeBinaryFile">寫二進位檔</button>
        <button @click="deleteFiles">刪除檔案</button>
        <div class="messages">
            <div v-for="log in logs" :key="log.id" :style="{ color: log.color }">
                {{ log.message }}
            </div>
        </div>
    </div>
    <script>
        const { createApp } = Vue;
        createApp({
            data() {
                return {
                    fileName: 'example.txt',
                    logs: []
                }
            },
            methods: {
                log(message, color = 'black') {
                    this.logs.push({ id: Date.now(), message, color });
                },
                async getRootDir() {
                    if ('storage' in navigator && 'getDirectory' in navigator.storage) {
                        return await navigator.storage.getDirectory();
                    } else {
                        throw new Error('OPFS not supported');
                    }
                },
                async listRootDirectory() {
                    try {
                        const root = await this.getRootDir();
                        const entries = [];
                        this.log('檔案清單:');
                        for await (const entry of root.values()) {
                            const file = await entry.getFile();
                            this.log(` - ${entry.name} / ${file.size.toLocaleString()} bytes`, 'dodgerblue');
                        }
                    } catch (e) {
                        this.log(e.message, 'red');
                    }
                },
                async writeFile() {
                    try {
                        const root = await this.getRootDir();
                        const fileHandle = await root.getFileHandle(this.fileName, { create: true });
                        const writable = await fileHandle.createWritable();
                        await writable.write('Hello, OPFS!');
                        await writable.close();
                        this.log('寫入檔案', 'green');
                    } catch (e) {
                        this.log(e.message, 'red');
                    }
                },
                async readFile() {
                    try {
                        const root = await this.getRootDir();
                        const fileHandle = await root.getFileHandle(this.fileName);
                        const file = await fileHandle.getFile();
                        const text = await file.text();
                        this.log(`檔案內容: ${text}`, 'blue');
                    } catch (e) {
                        this.log(e.message, 'red');
                    }
                },
                async deleteFiles() {
                    try {
                        const root = await this.getRootDir();
                        for await (const entry of root.values()) {
                            await entry.remove();
                            this.log(`刪除檔案: ${this.fileName}`, 'brown');
                        }
                    } catch (e) {
                        this.log(e.message, 'red');
                    }
                },
                async writeBinaryFile() {
                    try {
                        const root = await this.getRootDir();
                        const fileHandle = await root.getFileHandle('example.bin', { create: true });
                        const writable = await fileHandle.createWritable();
                        const chunkSize = 1024 * 1024; // 1MB
                        const totalSize = 100 * 1024 * 1024; // 100MB
                        const buffer = new Uint8Array(chunkSize).fill(0xAB); // 模擬資料
                        const start = performance.now();
                        for (let written = 0; written < totalSize; written += chunkSize) {
                            await writable.write(buffer);
                        }
                        await writable.write(buffer);
                        await writable.close();
                        const end = performance.now();
                        this.log(`寫入 ${totalSize / 1024 / 1024}MiB 二進位檔,耗時 ${(end - start).toFixed(2)} 毫秒`, 'green');
                    } catch (e) {
                        this.log(e.message, 'red');
                    }
                }
            }
        }).mount('#app');
    </script>
</body>

</html>

當然,OPFS 也支援子資料夾結構,列舉項目時,可由 kind == 'file' 或 'directory' 判斷是目錄還是檔案:

async function opfsWriteReadExample() {
    try {
        // 取得 OPFS 根目錄
        const opfsRoot = await navigator.storage.getDirectory();

        // 建立子資料夾 mainFolder
        const mainFolderHandle = await opfsRoot.getDirectoryHandle('mainFolder', { create: true });

        // 在 mainFolderHandle 裡建立檔案 example.txt
        const fileHandle = await mainFolderHandle.getFileHandle('example.txt', { create: true });

        // 寫入檔案內容
        const writable = await fileHandle.createWritable();
        await writable.write('Hello, World!');
        await writable.close();

        // 在 mainFolder 裡建立子資料夾 subFolder
        const subFolderHandle = await mainFolderHandle.getDirectoryHandle('subFolder', { create: true });

        // 列舉 mainFolder 裡的檔案和資料夾
        console.log('mainFolder 內容:');
        for await (const entry of mainFolderHandle.values()) {
            console.log(` - ${entry.name} (${entry.kind})`);
        }

    } catch (error) {
        console.error('OPFS 操作錯誤:', error);
    }
}

// 執行範例
opfsWriteReadExample();

F12 開發者工具僅能觀察 OPFS 佔用的空間,沒有內建檔案總管之類的檢視工具,如有需求可使用第三方外掛或寫幾行程式搞定。

至此,對 IndexedDB 與 OPFS 就算有了基本認識囉~

This post explores long-term data storage options in browsers: IndexedDB and OPFS. IndexedDB offers structured data storage with asynchronous API, while OPFS provides high-performance file system-like operations, ideal for large files. Both are suitable for caching and temporary storage due to their persistence limitations.


Comments

Be the first to post a comment

Post a comment