前幾天我們完成了 IndexedDB 的本地資料儲存,在沒有後端 API 與伺服器資料庫的情況下,依然能順暢地在瀏覽器端運作與記錄資料。
但是,雖然 Local-First(本地優先)架構帶來順暢使用與保有隱私,一旦使用者手動清理瀏覽器快取、開啟無痕模式,或是更換電腦與手機,原本記錄的任務資料就會消失。
因此為了避免這樣的問題,我決定增加資料的匯入匯出功能,讓使用者可以視需求備份,如果之後轉換設備,或是清除瀏覽器快取後,可以自行重新匯入資料。
JSON(JavaScript Object Notation) 是 Web 開發中最標準的資料交換格式。將資料匯出與匯入的運作流程可拆解為以下步驟:
讀取資料:從應用程式狀態(或 IndexedDB)中取出當前所有的任務與紀錄資料。
包裝結構:除了任務陣列外,額外附帶應用程式版本號(version)與匯出時間戳記(exportedAt)。
物件轉字串:使用 JSON.stringify() 將 JavaScript 物件轉換為標準 JSON 字串。
建立 Blob 與下載連結:將 JSON 字串封裝成 Blob 物件,並透過 URL.createObjectURL() 產生臨時下載網址,以動態 <a> 標籤觸發瀏覽器下載檔案。
記憶體釋放:下載觸發完畢後,即時呼叫 URL.revokeObjectURL() 釋放佔用的記憶體空間。
觸發檔案選擇:透過隱藏的 <input type="file" accept=".json"> 讓使用者選擇備份檔。
非同步讀取檔案:使用 JavaScript 原生的 FileReader API 讀取 JSON 檔案內容。
防呆與格式驗證(Data Validation):將 JSON 字串解析為物件後,檢查資料結構是否符合規範(例如是否包含關鍵欄位 id 與 title),避免使用者誤匯入無效或損壞的檔案。
覆蓋二次確認:跳出警告彈窗(confirm),提醒使用者還原動作將會覆蓋當前資料。
狀態同步與持久化:驗證無誤後更新全域狀態,同時寫回 IndexedDB 並重新渲染畫面。
以下為匯入與匯出邏輯的核心程式碼結構:
function exportDataToJSON() {
if (!tasks || tasks.length === 0) return;
// 1. 建立包含版本號與時間戳記的封包結構
const backupData = {
version: "1.0.0",
exportedAt: new Date().toISOString(),
data: tasks
};
// 2. 轉為 JSON 字串並建立 Blob
const jsonString = JSON.stringify(backupData, null, 2);
const blob = new Blob([jsonString], { type: "application/json" });
const downloadUrl = URL.createObjectURL(blob);
// 3. 建立動態連結觸發下載
const downloadLink = document.createElement("a");
downloadLink.href = downloadUrl;
downloadLink.download = `TaskTimer_Backup_${new Date().toISOString().split("T")[0]}.json`;
downloadLink.click();
// 4. 釋放記憶體
URL.revokeObjectURL(downloadUrl);
}
語法拆解:
後設資料封裝 (version & exportedAt):將 tasks 陣列包裹在物件中,並補上 version 與 exportedAt。方便系統判斷備份檔的版號。
美化縮排 (JSON.stringify(..., null, 2)):加上 null, 2 參數能輸出排版漂亮的 JSON 格式,方便使用者下載後打開檢視或除錯。
Blob 與 URL.createObjectURL:Blob 將 JSON 字串封裝為瀏覽器可辨識的二進位物件,再透過 createObjectURL 產生一組臨時的下載 URL(blob:http://...)。
虛擬 <a> 標籤下載法:透過 DOM 動態建立 <a download="..."> 並執行 .click(),不需跳轉頁面即可自動觸發瀏覽器下載檔案,檔名並以當日日期 (YYYY-MM-DD) 命名。
記憶體管理 (URL.revokeObjectURL):觸發下載後立即呼叫 revokeObjectURL() ,釋放剛才產生的 Blob URL,避免長時間佔用瀏覽器記憶體。
匯入功能是使用者復原資料的核心,必須考慮相容性、格式驗證與使用者二次確認:
function importDataFromJSON(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const parsedData = JSON.parse(e.target.result);
const targetTasks = Array.isArray(parsedData) ? parsedData : parsedData.data;
// 簡單的資料結構校驗 (Validation)
const isValid = targetTasks?.every(t => t.id && t.title);
if (!isValid) throw new Error("資料格式不符");
if (confirm("還原備份將會覆蓋現有資料,確定要繼續嗎?")) {
tasks = targetTasks;
await syncAndRender(); // 重新渲染並同步至 IndexedDB
}
} catch (error) {
alert("檔案讀取失敗,請確認是否為正確的 JSON 備份檔!");
}
};
reader.readAsText(file);
}
語法拆解:
FileReader 非同步讀取:使用 HTML5 的 FileReader API,透過 readAsText(file) 非同步讀取使用者選擇的檔案,並在 onload 事件中回傳檔案內容。
向下相容性(Backward Compatibility):
Array.isArray(parsedData) ? parsedData : parsedData.data
寫法同時相容「純陣列的舊版 JSON」與「帶有版本號的 backupData.data 新封包」,確保舊使用者的備份檔依然能順利還原。
嚴謹的資料結構校驗 (Validation):使用 Array.prototype.every() 檢查陣列中的每一筆物件是否都具備必填欄位(id 與 title),避免匯入格式不對的亂碼或不完整資料導致系統崩潰。
破壞性操作防護 (confirm):覆蓋資料屬於不可逆的破壞性操作,在覆寫記憶體與 IndexedDB 前彈出對話框進行二次確認,確保使用者操作安全。
錯誤捕捉 (try...catch):若使用者傳入非 JSON 格式的檔案(例如 .txt 或 .png),JSON.parse() 會給予友善的錯誤提示。