跳轉到

TOEIC Master:多益單字學習 App

摘要

背多益單字的 App 很多,但幾乎都要先註冊,而且捷運進隧道就用不了。TOEIC Master 走另一條路:整個 App 沒有後端、沒有帳號,單字、發音檔和測驗紀錄全部存在你自己的裝置裡,飛航模式照樣複習。

代價是所有「後端該做的事」都得在瀏覽器裡重做一遍。這篇主要記錄那部分——包括資料庫為什麼改了四版、以及 Gemini 的發音為什麼不能直接播。

原始碼: https://github.com/YuHsunWang/toeic-master

〔缺:公開網址。目前文件裡只有 repo,沒有可以直接打開的線上版;若有部署請補上,若沒有就把「開啟 App 網址」的說法改掉。〕


主要畫面

TOEIC Master 桌面版

〔缺:這批截圖拍於 2026-06-07,已經和現在的 App 對不上——圖上寫「匯入 8 個範例單字」, 但目前內建的是 252 個;測驗模式與五種主題也還沒加進去。需要重拍後再補其餘畫面。〕


為什麼不做後端

最早的版本是接 Firestore 的——src/db.js 裡那行 vocab: 單字資料(取代 Firestore) 的註解還留著。後來整個換成瀏覽器內建的 IndexedDB(透過 Dexie.js 操作)。

換掉之後拿到三件事:不用登入、離線可用、不用付主機費。失去的是跨裝置同步——你在手機上背的字,電腦上看不到。

我認為對「背單字」這個用途划算:這是一個人、一支手機、零碎時間的活動,很少有人會在兩台裝置之間交替背。但如果你要做的是有社群、有排行榜、或需要老師端看得到學生進度的產品,這個取捨完全不成立,請直接做後端。


資料庫改了四版,每一版都在擋同一件事

Dexie 的 migration 程式碼會留在 repo 裡,所以這段不用回想,直接讀 src/db.js 就看得出當初撞到什麼:

版本 改了什麼
v1 vocab 和 ttsCache 兩張表,vocab 用自動遞增的 id 當主鍵
v2 加 normalizedWord 欄位,升級時掃全表、把重複的資料刪掉
v3 normalizedWord 改成唯一索引(&normalizedWord)
v4 把平面的 en / zh / sent 三個欄位搬進 senses 陣列

前三版其實是同一個問題被修了三次。單字是 AI 生的,同一個字很容易被生成兩次,只是大小寫或前後空白不同——對資料庫來說那就是兩筆不同的資料。

v2 的做法是在應用層比對,寫入前先正規化再查有沒有重複。這在單筆寫入時沒問題,但一次匯入十個字的時候還是會漏。所以 v3 直接把它變成資料庫層的唯一索引,重複的寫入會被 IndexedDB 自己擋下來,丟出 ConstraintError。

現在 addMany() 的寫法是刻意接住那個錯誤的:同一批裡先用 Set 去重,然後逐筆寫入,撞到 ConstraintError 就把那個字記進 skippedWords 繼續跑,最後回報「加了幾個、跳過幾個、跳過哪些」。逐筆寫比整批寫慢,但整批寫只要有一個字重複就會整批失敗,這對使用者體驗是更糟的結果。

v4 是另一件事:一個英文單字常常有好幾個意思,原本一個欄位塞一個解釋的結構撐不住,改成 senses 陣列,上限三個語意。


Gemini 的發音,瀏覽器不能直接播

高音質發音是呼叫 Gemini TTS 拿的,但它回傳的是裸 PCM(24kHz、16-bit、單聲道),瀏覽器的 <audio> 不認這種東西。

解法是在前端手動補上 44 bytes 的 WAV 檔頭,把 PCM 包成瀏覽器認得的 WAV Blob(src/speechService.js 的 pcmToWav)。程式碼不長,但如果不知道問題出在哪,會一直以為是 API 呼叫失敗。

包好的音檔會存進 IndexedDB 的 ttsCache 表,key 是 文字__語音名稱。所以整個發音是三層:

  1. IndexedDB 快取 — 最快,完全不需要網路。
  2. Gemini TTS — 有網路、有 API Key 時用,播完順手寫回快取。
  3. 瀏覽器內建 speechSynthesis — 前兩層都不行時的保底,音質普通但一定有聲音。

第三層是關鍵。少了它,一個沒有 API Key 的使用者按下喇叭會什麼都沒發生,而「沒反應」比「音質普通」糟得多。

離線前先做這件事

連著網路時點「預載發音供離線使用」,把現有單字的發音一次抓完。之後完全離線也聽得到高音質版本。


免 API Key 也要能用

沒有 API Key 的人打開 App 如果看到空白畫面,就不會有第二次了。

所以 src/data/vocab-book.json 裡放了 252 個內建單字,每個都含音標、詞性、多個語意、例句,以及聽力/閱讀的出現頻率。第一次開啟時 seedIfEmpty() 會自動匯入,並在 localStorage 記一個旗標——只做這一次,之後就算使用者把單字全刪光也不會自己長回來。想重新匯入要自己按按鈕。

要自己擴充單字庫的話,scripts/collect-vocab.mjs 是離線批次生成用的:

node scripts/collect-vocab.mjs --rounds 5 --per-round 10
node scripts/collect-vocab.mjs --rounds 3 --category Finance

它每一回合存一次檔,中斷了可以接著跑,也會對既有單字庫和同批結果去重。


測驗

src/quiz.js 出五種題型:中翻英、英翻中、句子填空、看英文解釋選字、聽發音選字。可以指定題數、類型(Reading/Listening/Both)和主題分類。

比對答案時容忍字尾變化——s、es、ed、ing、d 都算對。這是刻意放寬的:測驗要考的是記不記得這個字,不是拼寫時態。


一個還沒解決的問題:API Key 是公開的

這是純前端的 Vite app,所有 VITE_ 開頭的環境變數都會被打包進前端 bundle。也就是說 VITE_GEMINI_API_KEY 在瀏覽器裡是看得到的,任何人打開開發者工具都能拿走。

以個人專案來說可以接受,因為使用者是自己填自己的 key。但只要這個 App 要用你的 key 對外服務,這條路就走不通了,必須改成後端 proxy,或至少在 Google Cloud 端鎖配額和來源網域。

我沒有做後端 proxy,因為那會直接推翻上面「不做後端」的整個前提。這是這個專案結構上的天花板,不是一個待辦事項。


怎麼跑

git clone https://github.com/YuHsunWang/toeic-master.git
cd toeic-master
npm install
cp .env.example .env.local   # 填入 VITE_GEMINI_API_KEY
npm run dev

要測 PWA 離線功能不能用 npm run dev,要 npm run build 之後 npm run preview——Service Worker 只在 production build 註冊。

部署就是靜態網站,npm run build 後把 dist/ 丟上 Vercel、Netlify、Cloudflare Pages 或 GitHub Pages 都行。

PWA 需要 HTTPS

Service Worker 只在 HTTPS 或 localhost 註冊,HTTP 網址裝不了。

安裝到手機主畫面

分享圖示 → 加入主畫面。

右上角三點 → 安裝應用程式。


技術棧

層 技術
前端 React 18、Vite 5、Tailwind CSS 3、lucide-react
本地資料庫 IndexedDB(Dexie 4):vocab 單字表、ttsCache 發音快取表
離線 vite-plugin-pwa(Workbox),app shell 全部快取
AI Gemini:生成單字、高音質 TTS
發音保底 瀏覽器內建 SpeechSynthesis

還沒做完的

  • PWA 圖示沒有補。 vite.config.js 的 manifest 指向 pwa-192x192.png 和 pwa-512x512.png,但 public/ 底下只有 favicon.svg。App 裝得起來,主畫面上會是預設圖示。
  • 沒有跨裝置同步,這是「不做後端」的直接後果,短期不打算改。
  • 五種介面主題(預設 Warm Sand)存在 localStorage,換裝置就沒了,同上。

延伸閱讀(本站)

來源