iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Build on Google AI

今天學什麼?30 天用 Google AI 打造智慧學習卡系列 第 27 篇

Day 27|重新整理也不怕:使用 LocalStorage 保存 AI 學習卡

  • 分享至 

  • xImage
  •  

Day 27|重新整理也不怕:使用 LocalStorage 保存 AI 學習卡

Day 26 完成多主題管理後,Learning Card App 已經可以同時保存多個 Gemini 產生的主題。

例如:

太陽系
人體器官
海洋探險
森林陸地

使用者可以透過 ThemeSelector 自由切換,而且每個 Theme 都有自己的 LearningCard[]。

但這些資料目前只存在 React State。

只要重新整理網頁:

Generated Themes
↓
React State 重新初始化
↓
AI 產生的學習卡全部消失

所以 Day 27 的目標,就是使用瀏覽器的 LocalStorage 保存 Generated Themes,讓使用者重新整理後仍然可以繼續使用之前產生及編輯過的學習卡。

不過,在加入 LocalStorage 前,我先遇到了一個必須優先處理的 Human Edit 問題。


加入 LocalStorage 前,先修正 Human Edit 錯位

實際操作時,我發現 Human Edit 偶爾會出現奇怪的結果:

編輯第 1 張卡片
↓
按下 Save
↓
第 1 張沒有更新
↓
第 5 張反而出現剛才的修改

如果直接加入 LocalStorage,錯誤更新的資料也會被永久保存。

所以這次先暫停持久化功能,檢查編輯流程的實際程式碼。

原本進入 Edit Mode 時,只會將目前卡片內容複製到 editForm:

currentCard
↓
editForm

但 Save 時,是透過:

selectedThemeId + currentIndex

決定要更新哪一張卡片。

問題在於,currentIndex 只是目前畫面上的位置,不是卡片的固定身分。

更關鍵的是,App 還有一組全域鍵盤事件:

if (e.key === 'ArrowRight') {
  handleNext();
} else if (e.key === 'ArrowLeft') {
  handlePrev();
}

即使使用者正在輸入框中編輯文字,按左右方向鍵移動文字游標時,App 仍然會切換卡片。

如果正在編輯第 1 張卡片,又按下 ArrowLeft:

currentIndex = 0
↓
handlePrev()
↓
currentIndex = cards.length - 1
↓
第 5 張

表單中仍然是第 1 張的草稿,但 Save 已經改成更新第 5 張。

這正好解釋了為什麼問題不是每次發生,而是偶爾才出現。

此外,如果進入 Edit Mode 前正在使用自動朗讀,自動播放計時器也可能繼續呼叫 handleNext(),同樣造成索引變動。


將編輯目標從 Index 改成固定 ID

這次 Gemini 花費 279 秒,修改兩個檔案:

src/components/LearningCardEditor.tsx
src/LearningCardApp.tsx

並完成 Build。

首先將編輯表單抽成獨立的 LearningCardEditor:

LearningCardEditor
├─ 顯示編輯表單
├─ 管理尚未儲存的 formData
├─ onSave(data)
└─ onCancel()

LearningCardEditor 不直接修改 themes,真正的 Theme State 仍由 LearningCardApp 管理。

進入編輯模式時,也不再只記錄目前的 Index,而是固定保存:

{
  themeId: string;
  cardId: string;
}

概念上變成:

開始編輯
↓
記錄 themeId + cardId
↓
使用者修改表單
↓
Save
↓
根據 themeId 找到 Theme
↓
根據 cardId 找到 Card
↓
只更新目標卡片

即使 currentIndex 因為其他操作改變,Save 的目標仍然是最初點擊編輯的那張 Card。

同時也加入兩層鍵盤守衛:

正在編輯
→ 不執行全域卡片快捷鍵

input / textarea / select / contentEditable 取得焦點
→ 不執行全域卡片快捷鍵

進入 Edit Mode 時會停止自動播放,自動播放的 Effect 也不會在編輯期間建立新的計時器。


Human Edit 修正後測試

這次一共完成六項測試:

Test 測試內容 結果
A 編輯第 1 張並在欄位內多次按左右鍵 ✅ PASS
B 第 1 張編輯時按 ArrowLeft ✅ PASS
C 自動朗讀中進入編輯並等待超過 7 秒 ✅ PASS
D 修改內容後按 Cancel ✅ PASS
E 編輯中切換 Theme 再切回 ✅ PASS
F 分別編輯第 1 張與第 5 張 ✅ PASS

確認 Human Edit 不再更新錯誤卡片後,才正式開始 LocalStorage。


為什麼選擇 LocalStorage?

這次要保存的是:

  • Gemini 產生的 Theme
  • Learning Cards
  • Human Edit 修改後的內容
  • Regenerate 使用的 generationParams

這些都屬於非敏感、容量不大的前端資料。

目前 App 也不需要:

  • 帳號登入
  • 跨裝置同步
  • 多人共享
  • 雲端資料庫
  • 後端權限管理

所以先使用 LocalStorage,就足以解決重新整理後資料消失的問題。

LocalStorage 的限制也很明確:

  • 資料只存在目前瀏覽器與網站來源中。
  • 清除瀏覽器資料後會消失。
  • 不會同步到其他裝置。
  • 不適合保存敏感資料或大量資料。

對目前的 Learning Card MVP 來說,這是範圍最小、最直接的方案。


將 LocalStorage 邏輯獨立封裝

第一次 LocalStorage 修改由 Gemini 執行 119 秒,修改:

src/utils/generatedThemesStorage.ts
src/LearningCardApp.tsx

並完成 Build。

這次沒有把所有儲存邏輯直接放進 LearningCardApp.tsx,而是新增:

src/utils/generatedThemesStorage.ts

統一處理:

  • Storage Key
  • 資料讀取
  • JSON 解析
  • Runtime Validation
  • Generated Themes 儲存
  • 讀寫錯誤處理

使用的 Storage Key 是:

learning-card-generated-themes:v1

其中的 v1 是資料格式版本。

如果未來 AppTheme 結構改變,就可以改成新的 Key 或加入資料遷移,不必直接假設舊資料一定相容。


只保存 Generated Themes

App 中同時存在兩種 Theme:

AppTheme[]
├─ Preset Themes
└─ Generated Themes

Preset Themes 已經寫在程式的 CATEGORIES 中,每次啟動 App 都會載入,因此不需要重複寫入 LocalStorage。

儲存時只保留:

theme.source === 'generated'

這次沒有保存:

  • Preset Themes
  • selectedThemeId
  • currentIndex
  • showAnswer
  • App Mode
  • 自動播放狀態
  • 尚未 Save 的編輯草稿

所以重新整理後,App 仍會先顯示預設的海洋主題,但先前產生的太陽系與人體器官會重新出現在 ThemeSelector 中,可以再次選取。


初始化時先讀取,再合併 Theme

為了避免 App 初始化時使用空陣列覆蓋原本的 LocalStorage,這次使用 Lazy State Initialization:

const [themes, setThemes] = useState<AppTheme[]>(() => {
  const presetThemes = CATEGORIES.map((category) => ({
    ...category,
    source: 'preset' as const,
  }));

  const loadedGeneratedThemes = loadGeneratedThemes(
    presetThemes.map((theme) => theme.id)
  );

  return [...presetThemes, ...loadedGeneratedThemes];
});

流程是:

App 初始化
↓
建立 Preset Themes
↓
讀取 LocalStorage
↓
驗證 Generated Themes
↓
排除無效資料與 ID 衝突
↓
合併成 AppTheme[]

不是先建立空的 Generated Themes,再等待 Effect 還原。

這樣可以避免舊資料在初始化過程中被空資料覆蓋。


themes 更新後自動保存

App 只需要監聽 themes:

useEffect(() => {
  saveGeneratedThemes(themes);
}, [themes]);

因此以下操作都不需要各自呼叫 LocalStorage:

Generate 新主題
↓
setThemes()
↓
自動保存

Human Edit Save
↓
setThemes()
↓
自動保存

Regenerate 成功
↓
setThemes()
↓
自動保存

而 Cancel 不會修改 themes,所以尚未儲存的草稿也不會進入 LocalStorage。

Regenerate 失敗時同樣不會用錯誤結果覆蓋原本有效資料。


不能直接相信 LocalStorage 裡的 JSON

LocalStorage 中的內容可能因為下列原因變得無效:

  • 使用者透過 DevTools 手動修改。
  • 舊版程式留下不同格式。
  • JSON 字串損壞。
  • Theme 缺少必要欄位。
  • cards 不是 Array。
  • Card ID 重複。
  • Generated Theme ID 與 Preset Theme 衝突。
  • generationParams.cardCount 不是有效正整數。

所以這次沒有直接寫:

JSON.parse(value) as AppTheme[]

而是逐層檢查資料。

LearningCard 必須包含:

id
emoji
title
content
question
answer

pinyin、parentTip、soundName 若存在,也必須是字串。

generationParams 則檢查:

ageGroup:非空字串
topic:非空字串
cardCount:大於 0 的整數

同時使用 Set 檢查:

  • LocalStorage 中是否有重複 Theme ID。
  • Theme ID 是否與 Preset Theme 衝突。
  • 同一個 Theme 中是否有重複 Card ID。

發現無效資料時,不讓 App Crash,而是略過資料並在 Console 顯示 Warning。


補強 LocalStorage 的錯誤處理

第一次修改完成後,我又檢查了一次實際程式碼,發現還有幾個需要補強的地方:

  • window.localStorage 的屬性存取仍有一部分在 try...catch 外。
  • cardCount 沒有限制為正整數。
  • Optional Card 欄位沒有驗證。
  • 沒有排除重複 Theme ID 與 Card ID。

因此再請 Gemini 做一次最小補修。

這次執行 109 秒,修改:

src/utils/generatedThemesStorage.ts
src/LearningCardApp.tsx

並再次完成 Build。

補修後,window.localStorage 本身的取得也位於 try...catch 中。

如果瀏覽器因為權限、隱私模式、iframe 限制或容量問題拒絕存取,App 會:

捕捉錯誤
↓
Console Warning
↓
保留目前 React State
↓
App 繼續運作

這次也再次證明,AI 回報完成並不代表可以直接結束;實際檢查程式碼,仍然有機會找到需要補強的邊界條件。


實際 LocalStorage 資料

完成測試後,Storage Key 中實際保存了兩個 Generated Themes:

learning-card-generated-themes:v1
│
├─ gen-太陽系
│  ├─ source: generated
│  ├─ 5 張 Learning Cards
│  └─ generationParams
│     ├─ ageGroup: 7~8 歲兒童
│     ├─ topic: 太陽系
│     └─ cardCount: 5
│
└─ gen-人體器官
   ├─ source: generated
   ├─ 3 張 Learning Cards
   └─ generationParams
      ├─ ageGroup: 5~6 歲幼兒
      ├─ topic: 人體器官
      └─ cardCount: 3

簡化後的資料結構如下:

[
  {
    "id": "gen-太陽系",
    "name": "太陽系",
    "emoji": "☀️",
    "source": "generated",
    "cards": [
      {
        "id": "gemini-card-...-1",
        "emoji": "☀️",
        "title": "太陽",
        "content": "...",
        "question": "...",
        "answer": "..."
      }
    ],
    "generationParams": {
      "ageGroup": "7~8 歲兒童",
      "topic": "太陽系",
      "cardCount": 5
    }
  },
  {
    "id": "gen-人體器官",
    "name": "人體器官",
    "emoji": "❤️",
    "source": "generated",
    "cards": [
      {
        "id": "gemini-card-...-1",
        "emoji": "❤️",
        "title": "我的小心臟",
        "content": "...",
        "question": "...",
        "answer": "心臟"
      }
    ],
    "generationParams": {
      "ageGroup": "5~6 歲幼兒",
      "topic": "人體器官",
      "cardCount": 3
    }
  }
]

實際資料符合預期:

  • 只有 Generated Themes。
  • 沒有重複保存 Preset Themes。
  • 太陽系與人體器官有不同 Theme ID。
  • 每張 Card 都有自己的唯一 ID。
  • generationParams 正常保存。
  • UI 樣式欄位也一起保留。
  • 原本的 toddler_stars 使用另一個 Key,沒有混入 Theme 資料。

圖:Day27 LocalStorage save.jpg
DevTools 中可以看到 learning-card-generated-themes:v1 與 toddler_stars 分開保存,Generated Themes Array 中共有太陽系與人體器官兩筆資料。


測試無效 JSON

為了確認 LocalStorage 損壞時不會讓 App 出現白畫面,我將 Storage Value 手動改成:

invalid-json

圖:Day27 test LocalStorage invaild-json.jpg
將 learning-card-generated-themes:v1 的 Value 改成無效 JSON。

重新整理後,JSON.parse() 發生 SyntaxError,但錯誤被 loadGeneratedThemes() 捕捉。

Console 顯示:

[Storage] Failed to load generated themes from LocalStorage:
SyntaxError: "invalid-json" is not valid JSON

結果:

  • App 沒有出現白畫面。
  • Preset Themes 正常顯示。
  • 無效的 Generated Themes 沒有載入。
  • Console 如預期顯示 Warning。

圖:Day27 App console warning.jpg
App 回到預設海洋主題並維持正常操作,Console 顯示 LocalStorage 解析失敗警告。

這表示 LocalStorage 即使被手動改壞,也不會讓整個 Learning Card App 無法使用。


LocalStorage 測試結果

這次一共完成八項測試:

Test 驗證內容 結果
L1 Generate 太陽系後 Refresh ✅ PASS
L2 太陽系、人體器官同時保存 ✅ PASS
L3 Human Edit Save 後 Refresh ✅ PASS
L4 Regenerate 後 Refresh ✅ PASS
L5 Cancel 不保存草稿 ✅ PASS
L6 無效 JSON 不造成 App Crash ✅ PASS
L7 LocalStorage 寫入失敗時 App 仍可使用 ✅ PASS
L8 實際資料只有 Generated Themes ✅ PASS

其中 L3 也再次確認 Human Edit 錯位問題沒有復發:

編輯第 1 張
↓
Save
↓
Refresh
↓
第 1 張保留修改
↓
第 5 張不受影響

L4 則證明 Regenerate 只會更新指定 Theme,並將新結果保存至 LocalStorage,不會覆蓋其他主題。


Day 27 完成後的完整資料流程

現在 Learning Card App 的資料流程已經變成:

Gemini Generate
↓
Structured Output
↓
LearningCard[]
↓
新增或更新 Generated Theme
↓
AppTheme[]
↓
React UI
↓
LocalStorage 自動保存

重新整理時則是:

App 啟動
↓
載入 Preset Themes
↓
讀取 LocalStorage
↓
解析與驗證 Generated Themes
↓
排除無效資料及 ID 衝突
↓
合併 AppTheme[]
↓
ThemeSelector
↓
使用者重新選取先前主題

Human Edit 的流程也更完整:

選擇 Card
↓
固定記錄 themeId + cardId
↓
LearningCardEditor 草稿
↓
Save
↓
更新正確 Theme 與 Card
↓
themes State 更新
↓
LocalStorage 自動保存

今天最大的收穫

一開始我以為 Day 27 只需要加入:

localStorage.setItem()
localStorage.getItem()

但實際完成後才發現,持久化不只是把 State 轉成 JSON。

還需要考慮:

  • 初始化順序。
  • 資料格式驗證。
  • 錯誤資料的 fallback。
  • Storage 權限與容量錯誤。
  • Preset 與 Generated Theme 的合併。
  • Theme ID 與 Card ID 的唯一性。
  • Cancel 與失敗操作不能保存錯誤資料。
  • 寫入前必須先確保 Human Edit 更新的是正確卡片。

這次也再次體會到:

在保存資料之前,要先確保資料更新本身是正確的。

如果 Human Edit 仍然可能把第 1 張內容寫入第 5 張,那麼 LocalStorage 只會讓錯誤保存得更久。

所以 Day 27 最重要的改變,不只是「重新整理後資料還在」,而是把整個資料流程從暫時的 React State,延伸成可以安全保存、還原和驗證的前端狀態管理。


明天預告

目前 Learning Card App 已經完成:

Prompt 條件
↓
Gemini Structured Output
↓
LearningCard[]
↓
AppTheme[]
↓
ThemeSelector
↓
Human Edit
↓
Regenerate
↓
LocalStorage

接下來可以回頭整理整個專題的完整資料流程,確認從使用者輸入條件、Gemini 回應、前端資料轉換,到畫面顯示與保存之間,是如何串接在一起的。

Day 28|從 Prompt 到畫面:整理智慧學習卡完整資料流程


上一篇
Day 26|不只海洋動物:把 Learning Card 擴充成不同學習主題
下一篇
Day 28|從 Prompt 到畫面:整理智慧學習卡完整資料流程
系列文
今天學什麼?30 天用 Google AI 打造智慧學習卡 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言