From 98de66eca1f4f634056be7892a4708e5a830df78 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Sat, 11 Jul 2026 09:51:34 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E9=A1=8C=E6=B1=A0=E5=85=A8=E9=9D=A2?= =?UTF-8?q?=E6=93=B4=E5=85=85=EF=BC=9A29=20=E9=97=9C=E7=B5=B1=E4=B8=80?= =?UTF-8?q?=E8=A3=9C=E5=88=B0=2012=20=E9=A1=8C=EF=BC=8C=E7=B8=BD=E8=A8=88?= =?UTF-8?q?=20348=20=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jsb-3~9、jsa-1~9、react-1~11 共 27 關各新增 6 題(jsb-1、jsb-2 已於前次擴充) - 抽題邏輯改為整個題池洗牌後依難度排序全部作答(src/utils/quiz.js),不再固定抽 6 題 - 每題皆用 scripts/validate-questions.mjs 實際執行程式碼驗證輸出,全數通過 - docs/roadmap.md 加入版控,記錄開發規劃與優先順序 Co-Authored-By: Claude Sonnet 5 --- docs/roadmap.md | 77 ++++++++++ src/App.jsx | 6 +- src/data/questions/jsa-1-this-scope.json | 139 +++++++++++++++++ src/data/questions/jsa-2-closures.json | 139 +++++++++++++++++ src/data/questions/jsa-3-async-basics.json | 138 +++++++++++++++++ src/data/questions/jsa-4-async-await.json | 138 +++++++++++++++++ src/data/questions/jsa-5-classes.json | 139 +++++++++++++++++ .../questions/jsa-6-collections-json.json | 138 +++++++++++++++++ src/data/questions/jsa-7-modules.json | 139 +++++++++++++++++ .../questions/jsa-8-iterators-generators.json | 138 +++++++++++++++++ .../questions/jsa-9-promises-advanced.json | 139 +++++++++++++++++ src/data/questions/jsb-1-variables.json | 140 +++++++++++++++++ src/data/questions/jsb-2-functions.json | 140 +++++++++++++++++ src/data/questions/jsb-3-arrays.json | 139 +++++++++++++++++ src/data/questions/jsb-4-objects.json | 139 +++++++++++++++++ src/data/questions/jsb-5-control-flow.json | 139 +++++++++++++++++ src/data/questions/jsb-6-loops.json | 142 ++++++++++++++++++ src/data/questions/jsb-7-numbers-dates.json | 141 +++++++++++++++++ src/data/questions/jsb-8-strings.json | 141 +++++++++++++++++ src/data/questions/jsb-9-regex.json | 139 +++++++++++++++++ src/data/questions/react-1-jsx.json | 139 +++++++++++++++++ .../questions/react-10-reducer-context.json | 138 +++++++++++++++++ src/data/questions/react-11-refs.json | 135 +++++++++++++++++ src/data/questions/react-2-props.json | 141 +++++++++++++++++ src/data/questions/react-3-state-events.json | 132 ++++++++++++++++ src/data/questions/react-4-hooks.json | 135 +++++++++++++++++ .../questions/react-5-lists-conditional.json | 140 +++++++++++++++++ .../questions/react-6-updating-state.json | 137 +++++++++++++++++ .../questions/react-7-pure-components.json | 135 +++++++++++++++++ src/data/questions/react-8-sharing-state.json | 135 +++++++++++++++++ .../questions/react-9-preserving-state.json | 132 ++++++++++++++++ src/screens/ChapterMap.jsx | 3 +- src/utils/quiz.js | 11 +- 33 files changed, 4091 insertions(+), 12 deletions(-) create mode 100644 docs/roadmap.md diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..8dfbebd --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,77 @@ +# EasyLearn 開發 Roadmap + +> 建立日期:2026-07-11。已完成的基礎:29 關 174+ 題、PWA 可安裝、XP/連續天數/錯題重練、進度匯出匯入。 + +## 已完成 + +### 題池擴充(6 題 → 12 題)✅ 2026-07-11 全數完成 +- 29 關全部擴充到 12 題,總計 348 題 +- 抽題邏輯已改為整個題池作答(洗牌後依難度排序) +- `scripts/validate-questions.mjs` 全數通過(0 失敗),每題程式碼皆實際執行驗證輸出 + +## 進行中 + +(無,下一步請參考「建議的優先順序」) + +## 規劃中的大功能 + +### 1. Clerk 登入 +- 目標:使用者登入後進度跨裝置同步 +- **前置認知**:Clerk 只管身分,不存 app 資料。進度目前只在 localStorage, + 需要搭配一個後端存進度(候選:Supabase / Convex / Firebase,或小量資料塞 Clerk user metadata) +- 為什麼重要:iOS Safari 的 ITP 可能在 7 天不使用後清掉 localStorage, + PWA 使用者的進度有無預警消失的風險——雲端同步不只是方便,是資料安全 +- 步驟拆解: + - [ ] 選定進度儲存後端 + - [ ] Clerk 接入(登入/登出 UI、匿名試玩不強制登入) + - [ ] 進度同步策略:本地優先、背景上傳、衝突時取較高值(XP、best 取 max) + - [ ] 首次登入時把 localStorage 進度搬上雲端 + +### 2. 更完善的錯題本+儲存題目本 +- 現況:`wrongIds` 只是 `{ questionId: true }`,答對一次就從錯題本移除,沒有任何歷史 +- 錯題本強化: + - [ ] 記錄錯誤次數、最後答錯日期(`{ count, lastWrong }`) + - [ ] 錯題本瀏覽頁:可以翻看題目與解釋,不用重考才能看 + - [ ] 間隔重複(spaced repetition):答對不要立刻移除,改成 Leitner 盒制 + (答對升級、答錯降回,最高級才畢業),避免「碰巧對一次就消失」 +- 儲存題目本(收藏): + - [ ] 答題時可加書籤(星號),存 `savedIds` + - [ ] 收藏瀏覽頁,可從收藏直接重練 +- [ ] 進度資料結構升級時做 migration(現在 `load()` 只有 shallow merge, + 巢狀新欄位如 `streak` 內加東西會拿不到預設值) + +### 3. 經驗值成長可視化 +- 現況:XP 只存一個總數,**沒有歷史紀錄——無法回溯**,這個先做資料層越早越好 +- [ ] 先開始記每日 XP log:`xpLog: { 'YYYY-MM-DD': number }`(答題就累計,UI 之後再做) +- [ ] 等級制:XP 換算等級與稱號,Home 顯示等級進度條 +- [ ] 每週長條圖/月曆 heatmap(GitHub style)呈現學習量 +- [ ] 各章節掌握度:以最佳成績算每章正確率,雷達圖或進度條 + +## 其他優化建議(依價值排序) + +### 資料與品質 +- [x] **題目 JSON schema 驗證 script**:`scripts/validate-questions.mjs`,實際執行每題程式碼比對輸出, + 建議未來加進 build 前自動跑(目前需手動 `node scripts/validate-questions.mjs`) +- [ ] **quiz utils 單元測試**:shuffle、sampleQuestions、bumpStreak(跨日邏輯) + 都是純函式,用 Vitest 很快就能蓋到 +- [ ] PWA 版本更新提示:部署新版後舊使用者停在快取版,加「有新版本,點擊更新」toast + +### 學習體驗 +- [ ] 結算畫面顯示「本關哪幾題錯」+直接看解釋,不用進錯題本 +- [ ] 每日目標:例如「今天完成 1 關」,配合 streak 給提示 +- [ ] 答題音效/震動回饋(Vibration API),PWA 手感加分 +- [ ] 皮皮(吉祥物)反應多樣化:連對 combo、破紀錄時有不同表情 + +### 技術體質 +- [ ] Bundle 432 KB 偏大,查一下是誰貢獻的(懷疑 code highlight 相關), + 可 lazy load 或換輕量方案 +- [ ] 無障礙:按鈕 aria-label、鍵盤作答 + +## 建議的優先順序 + +1. ~~題池擴充剩餘 27 關~~(已完成,2026-07-11) +2. XP 每日 log 的**資料層**(不做 UI 也要先記,資料無法回溯) +3. 錯題本強化+收藏(純前端就能做,不用等後端) +4. ~~題目 schema 驗證 script~~(已完成:`scripts/validate-questions.mjs`) +5. XP 可視化 UI(等 log 累積幾天資料) +6. Clerk +雲端同步(工程最大,牽涉後端選型,放在功能穩定後) diff --git a/src/App.jsx b/src/App.jsx index d6af8fd..11541ab 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,20 +1,18 @@ import { useState } from 'react' import { useProgress } from './hooks/useProgress' import { getLevel, getWrongQuestions } from './data/chapters' -import { shuffle, sampleQuestions, QUIZ_SIZE } from './utils/quiz' +import { shuffle, sampleQuestions, REVIEW_SIZE } from './utils/quiz' import Home from './screens/Home' import ChapterMap from './screens/ChapterMap' import Quiz from './screens/Quiz' -const REVIEW_SIZE = QUIZ_SIZE - export default function App() { const { progress, answerQuestion, finishLevel, finishReview, exportProgress, importProgress } = useProgress() const [view, setView] = useState({ name: 'home' }) function startLevel(levelId) { - // 從關卡題池隨機抽題,重玩不重複 + // 整個題池隨機排序作答(同難度內順序每次不同) const level = getLevel(levelId) setView({ name: 'quiz', levelId, questions: sampleQuestions(level.questions) }) } diff --git a/src/data/questions/jsa-1-this-scope.json b/src/data/questions/jsa-1-this-scope.json index a45e743..f14418d 100644 --- a/src/data/questions/jsa-1-this-scope.json +++ b/src/data/questions/jsa-1-this-scope.json @@ -142,6 +142,145 @@ { "code": "console.log(b);\nlet b = 2;", "expectError": "Cannot access 'b' before initialization" } ] } + }, + { + "id": "jsa-1-q7", + "type": "predict-output", + "difficulty": 2, + "topic": "bind 把 this 焊死,之後怎麼呼叫都不變", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Function/bind", + "story": "", + "prompt": "方法被 bind 過後,就算用 call 想換一個 this,還會成功嗎?這段印出什麼?", + "code": "const user = {\n name: \"小明\",\n hello() { return \"嗨,\" + this.name; },\n};\nconst fn = user.hello.bind(user);\nconst other = { name: \"小華\" };\nconsole.log(fn());\nconsole.log(fn.call(other));", + "options": [ + { "id": "a", "text": "嗨,小明 然後 嗨,小明" }, + { "id": "b", "text": "嗨,小明 然後 嗨,小華" }, + { "id": "c", "text": "嗨,undefined 然後 嗨,小華" }, + { "id": "d", "text": "會報錯,bind 過的函式不能再用 call" } + ], + "answer": "a", + "explanation": "bind(user) 回傳一個「this 永遠是 user」的新函式,這個綁定是永久性的——即使之後想用 fn.call(other) 硬塞別的 this 進去,也會被無視,因為 this 早就焊死了。這正是解決 jsa-1-q2「方法拆下來 this 就丟了」的標準工具:把方法傳給 React 的 onClick、事件監聽器之前,先 bind(this) 或 bind(這個物件),就不怕呼叫方式把 this 弄丟。", + "verify": { + "checks": [ + { "code": "const user = {\n name: \"小明\",\n hello() { return \"嗨,\" + this.name; },\n};\nconst fn = user.hello.bind(user);\nconst other = { name: \"小華\" };\nconsole.log(fn());\nconsole.log(fn.call(other));", "expected": "嗨,小明\n嗨,小明" } + ] + } + }, + { + "id": "jsa-1-q8", + "type": "predict-output", + "difficulty": 2, + "topic": "call 與 apply:手動指定 this,參數給法不同", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Function/call", + "story": "", + "prompt": "同一個獨立函式,用 call 和 apply 各自指定 this 並傳參數。這段印出什麼?", + "code": "function intro(greeting) { return greeting + \",我是\" + this.name; }\nconst a = { name: \"皮皮\" };\nconsole.log(intro.call(a, \"哈囉\"));\nconsole.log(intro.apply(a, [\"嗨\"]));", + "options": [ + { "id": "a", "text": "哈囉,我是皮皮 然後 嗨,我是皮皮" }, + { "id": "b", "text": "哈囉,我是undefined 然後 嗨,我是undefined" }, + { "id": "c", "text": "會報錯,intro 不是 a 的方法" }, + { "id": "d", "text": "哈囉,我是皮皮 然後 會報錯,apply 參數要用逗號分開" } + ], + "answer": "a", + "explanation": "call 和 apply 都是「呼叫函式的同時,手動指定 this 是誰」,跟 bind 不同的是它們立刻執行、不會回傳新函式。差別只在傳參數的方式:call 逐一列出(call(a, \"哈囉\")),apply 包成陣列(apply(a, [\"嗨\"]))。口訣:call 的 C 對應 Comma(逗號列參數),apply 的 A 對應 Array(陣列傳參數)。call / apply / bind 三兄弟都在解決同一件事:this 不是你想要的那個物件。", + "verify": { + "checks": [ + { "code": "function intro(greeting) { return greeting + \",我是\" + this.name; }\nconst a = { name: \"皮皮\" };\nconsole.log(intro.call(a, \"哈囉\"));\nconsole.log(intro.apply(a, [\"嗨\"]));", "expected": "哈囉,我是皮皮\n嗨,我是皮皮" } + ] + } + }, + { + "id": "jsa-1-q9", + "type": "find-bug", + "difficulty": 3, + "topic": "class 方法一樣會斷線:this 不是物件的護身符", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Classes", + "story": "", + "prompt": "把 class 的方法拆出來單獨呼叫,直接爆炸。為什麼?", + "code": "class Counter {\n count = 0;\n inc() { this.count++; return this.count; }\n}\nconst c = new Counter();\nconst inc = c.inc; // B 行\nconsole.log(c.inc());\nconsole.log(inc()); // 這裡整個炸掉", + "options": [ + { "id": "a", "text": "B 行把方法拆離了實例,跟 jsa-1-q2 同一個坑;class 內部強制是嚴格模式,斷線後 this 直接是 undefined 而不是悄悄變成別的物件" }, + { "id": "b", "text": "class 語法本身有 bug,count 這個欄位不能用" }, + { "id": "c", "text": "inc 方法名稱跟關鍵字衝突" }, + { "id": "d", "text": "class 建立的方法不能被賦值給變數" } + ], + "answer": "a", + "explanation": "class 只是「物件方法 + this 陷阱」的新包裝,底層規則沒變:c.inc() 呼叫時點前面是 c,this 是 c;inc() 單獨呼叫時 this 就跟 c 脫鉤了。差別在於 class 內部永遠是嚴格模式,斷線後 this 是真正的 undefined(不像一般函式在非嚴格模式下 this 會變成全域物件),所以 this.count++ 直接對 undefined 取屬性,丟出 TypeError 而不是默默印出 undefined。修法一樣:inc = c.inc.bind(c),或在 class 裡把方法定義成箭頭函式屬性。", + "verify": { + "checks": [ + { "code": "class Counter {\n count = 0;\n inc() { this.count++; return this.count; }\n}\nconst c = new Counter();\nconsole.log(c.inc());", "expected": "1" }, + { "code": "class Counter {\n count = 0;\n inc() { this.count++; return this.count; }\n}\nconst c = new Counter();\nconst inc = c.inc;\ninc();", "expectError": "Cannot read properties of undefined" } + ] + } + }, + { + "id": "jsa-1-q10", + "type": "predict-output", + "difficulty": 3, + "topic": "setTimeout 傳一般函式,callback 裡的 this 不是你以為的物件", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/API/setTimeout", + "story": "", + "prompt": "計時器到時間後想印物件的名字,卻印出 undefined。這段印出什麼?", + "code": "const timer = {\n label: \"計時器A\",\n start() {\n setTimeout(function () {\n console.log(\"時間到:\" + this.label);\n }, 10);\n },\n};\ntimer.start();", + "options": [ + { "id": "a", "text": "時間到:undefined" }, + { "id": "b", "text": "時間到:計時器A" }, + { "id": "c", "text": "會報錯,this 是 undefined 不能取 .label" }, + { "id": "d", "text": "時間到:null" } + ], + "answer": "a", + "explanation": "setTimeout 呼叫 callback 時,是由計時器系統來呼叫這個函式,前面沒有「誰的方法」這件事,等於是「裸呼叫」——一般函式裸呼叫時(非嚴格模式)this 會變成全域物件,跟 timer 完全無關,this.label 自然是 undefined。想保留 timer,要嘛把 callback 換成箭頭函式(沿用 start 的 this,jsa-1-q5 教過的救星),要嘛先把 this 存進變數(常見寫法 const self = this)。", + "verify": { + "checks": [ + { "code": "const timer = {\n label: \"計時器A\",\n start() {\n setTimeout(function () {\n console.log(\"時間到:\" + this.label);\n }, 10);\n },\n};\ntimer.start();", "expected": "時間到:undefined" } + ] + } + }, + { + "id": "jsa-1-q11", + "type": "fill-in", + "difficulty": 3, + "topic": "方法內部的巢狀函式,this 又不見了", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Functions/Arrow_functions", + "story": "", + "prompt": "把空格填起來,讓 report 正確印出「小舖的報表」", + "code": "const shop = {\n name: \"小舖\",\n report() {\n const helper = ____;\n return helper();\n },\n};\nconsole.log(shop.report());", + "options": [ + { "id": "a", "text": "() => `${this.name}的報表`" }, + { "id": "b", "text": "function () { return `${this.name}的報表`; }" }, + { "id": "c", "text": "() => `${shop.name}的報表`" }, + { "id": "d", "text": "function () { return `${shop.name}的報表`; }" } + ], + "answer": "a", + "explanation": "helper 是在 report 裡面另外定義、直接呼叫的巢狀函式。如果用一般函式(選項 b),它跟外層方法沒有繼承關係,被裸呼叫時 this 又斷線,回傳 undefined 的報表;箭頭函式(選項 a)沒有自己的 this,直接沿用 report 的 this,也就是 shop,穩穩接住。選項 c、d 硬寫死 shop 這個變數名雖然能跑,但換了物件名稱或把方法搬到別的物件上就整個壞掉——用 this 才是真正可重複使用的寫法。", + "verify": { + "checks": [ + { "code": "const shop = {\n name: \"小舖\",\n report() {\n const helper = () => `${this.name}的報表`;\n return helper();\n },\n};\nconsole.log(shop.report());", "expected": "小舖的報表" } + ] + } + }, + { + "id": "jsa-1-q12", + "type": "same-or-not", + "difficulty": 3, + "topic": "全域 this:嚴格模式下裸呼叫,this 是真正的 undefined", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Strict_mode", + "story": "", + "prompt": "同一種「裸呼叫」(前面沒有物件),差別只在有沒有加 \"use strict\"。這兩段印出來一樣嗎?", + "code": "// 版本一:非嚴格模式\nfunction showThis() { console.log(this === undefined); }\nshowThis();\n\n// 版本二:嚴格模式\nfunction showThisStrict() {\n \"use strict\";\n console.log(this === undefined);\n}\nshowThisStrict();", + "options": [ + { "id": "a", "text": "不一樣:版本一印 false,版本二印 true" }, + { "id": "b", "text": "一樣,都印 true" }, + { "id": "c", "text": "一樣,都印 false" }, + { "id": "d", "text": "不一樣:版本一印 true,版本二印 false" } + ], + "answer": "a", + "explanation": "裸呼叫(前面沒有物件)時,this 到底是誰,非嚴格模式和嚴格模式給的答案不一樣:非嚴格模式會很「貼心」地把 this 補成全域物件,所以 this === undefined 是 false(這也是 jsa-1-q10 setTimeout 那題印出 undefined 屬性、而不是直接報錯的原因);嚴格模式(class 內部、ES module 頂層都自動是嚴格模式)不做這個補救,this 就是老老實實的 undefined。這也是為什麼在嚴格模式或 class 裡犯同樣的 this 錯誤,會直接報錯讓你發現,而不是留下一個難查的 undefined 屬性。", + "verify": { + "checks": [ + { "code": "function showThis() { console.log(this === undefined); }\nshowThis();\nfunction showThisStrict() {\n \"use strict\";\n console.log(this === undefined);\n}\nshowThisStrict();", "expected": "false\ntrue" } + ] + } } ] } diff --git a/src/data/questions/jsa-2-closures.json b/src/data/questions/jsa-2-closures.json index f220f49..8351bab 100644 --- a/src/data/questions/jsa-2-closures.json +++ b/src/data/questions/jsa-2-closures.json @@ -140,6 +140,145 @@ { "code": "function delayedLog() {\n let msg = \"第一版\";\n setTimeout(() => console.log(msg), 10);\n msg = \"第二版\";\n}\ndelayedLog();", "expected": "第二版" } ] } + }, + { + "id": "jsa-2-q7", + "type": "predict-output", + "difficulty": 2, + "topic": "IIFE 模組模式:立刻執行,包住私有變數", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Closures", + "story": "", + "prompt": "用「立刻執行的函式」包住 balance,外面完全碰不到它。這段印出什麼?", + "code": "const bankAccount = (function () {\n let balance = 0;\n return {\n deposit(amount) { balance += amount; return balance; },\n getBalance() { return balance; },\n };\n})();\nconsole.log(bankAccount.deposit(100));\nconsole.log(bankAccount.balance);", + "options": [ + { "id": "a", "text": "100 然後 undefined" }, + { "id": "b", "text": "100 然後 100" }, + { "id": "c", "text": "undefined 然後 100" }, + { "id": "d", "text": "會報錯,balance 沒有定義" } + ], + "answer": "a", + "explanation": "(function () { ... })() 是 IIFE(立即呼叫函式運算式):函式定義完馬上自己呼叫自己,執行完只留下回傳的東西。這裡回傳的物件帶著 deposit、getBalance 兩個方法,它們都是 closure,背著同一個 balance;但 balance 本身沒有被回傳出去,外面想用 bankAccount.balance 直接存取,只會拿到 undefined——跟 jsa-2-q5 的私有狀態是同一招,只是這裡連「建立的過程」都藏起來了,這正是模組模式的核心:只曝露你想公開的方法,其餘全部關在門後。", + "verify": { + "checks": [ + { "code": "const bankAccount = (function () {\n let balance = 0;\n return {\n deposit(amount) { balance += amount; return balance; },\n getBalance() { return balance; },\n };\n})();\nconsole.log(bankAccount.deposit(100));\nconsole.log(bankAccount.balance);", "expected": "100\nundefined" } + ] + } + }, + { + "id": "jsa-2-q8", + "type": "fill-in", + "difficulty": 2, + "topic": "迴圈裡建立函式陣列,用 let 才能各自保留自己的索引", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/let", + "story": "", + "prompt": "把空格填起來,讓陣列裡的 3 個函式各自記得自己的索引,印出 0 1 2", + "code": "const fns = [];\nfor (____ i = 0; i < 3; i++) {\n fns.push(() => i);\n}\nconsole.log(fns[0](), fns[1](), fns[2]());", + "options": [ + { "id": "a", "text": "let" }, + { "id": "b", "text": "var" }, + { "id": "c", "text": "const" }, + { "id": "d", "text": "static" } + ], + "answer": "a", + "explanation": "let 讓每一輪迴圈都產生一個全新的 i,push 進去的箭頭函式各自 closure 住那一輪的 i,互不干擾,所以印出 0 1 2。換成 var 只有一個共用的 i,迴圈跑完 i 是 3,三個函式印出的都會是 3 3 3(跟 jsa-1-q4 的 setTimeout 版本是同一個道理,只是這裡不用等非同步,直接呼叫就能看到)。選項 c 的 const 會直接報錯,因為 for 迴圈的 i++ 需要重新賦值,const 宣告的變數不能重新賦值。", + "verify": { + "checks": [ + { "code": "const fns = [];\nfor (let i = 0; i < 3; i++) {\n fns.push(() => i);\n}\nconsole.log(fns[0](), fns[1](), fns[2]());", "expected": "0 1 2" }, + { "code": "const fns = [];\nfor (var i = 0; i < 3; i++) {\n fns.push(() => i);\n}\nconsole.log(fns[0](), fns[1](), fns[2]());", "expected": "3 3 3" } + ] + } + }, + { + "id": "jsa-2-q9", + "type": "predict-output", + "difficulty": 3, + "topic": "debounce 防抖:closure 記住計時器,取消前一次排程", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/API/setTimeout", + "story": "", + "prompt": "debounce 讓短時間內連續呼叫,最後只真正執行一次。這段印出什麼?", + "code": "function debounce(fn, delay) {\n let timer;\n return function (...args) {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), delay);\n };\n}\nlet calls = 0;\nconst log = debounce(() => { calls++; }, 20);\nlog();\nlog();\nlog();\nsetTimeout(() => console.log(calls), 60);", + "options": [ + { "id": "a", "text": "1" }, + { "id": "b", "text": "3" }, + { "id": "c", "text": "0" }, + { "id": "d", "text": "會報錯,timer 還沒宣告就使用" } + ], + "answer": "a", + "explanation": "debounce 回傳的函式背著 timer 這個 closure 變數,每次被呼叫都會先 clearTimeout(timer) 把前一次排定的執行取消掉,再重新排一次。連續呼叫三次 log(),前兩次排的都被下一次呼叫取消,只有最後一次真正撐過 20 毫秒被執行,所以 calls 最後是 1。這就是防抖節流最核心的技巧:用 closure 在多次呼叫之間「記住並取消上一次」,常用在輸入框打字後才發送搜尋請求、視窗 resize 事件等場景,避免瘋狂觸發。", + "verify": { + "checks": [ + { "code": "function debounce(fn, delay) {\n let timer;\n return function (...args) {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), delay);\n };\n}\nlet calls = 0;\nconst log = debounce(() => { calls++; }, 20);\nlog();\nlog();\nlog();\nsetTimeout(() => console.log(calls), 60);", "expected": "1" } + ] + } + }, + { + "id": "jsa-2-q10", + "type": "predict-output", + "difficulty": 3, + "topic": "memoize 記憶化:closure 當快取,重複輸入不用重算", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Closures", + "story": "", + "prompt": "把同一個函式包一層快取,同樣的輸入第二次不用重算。這段印出什麼?", + "code": "function memoize(fn) {\n const cache = {};\n return function (n) {\n if (n in cache) {\n console.log(\"快取命中\");\n return cache[n];\n }\n console.log(\"重新計算\");\n return (cache[n] = fn(n));\n };\n}\nconst square = memoize((n) => n * n);\nconsole.log(square(5));\nconsole.log(square(5));", + "options": [ + { "id": "a", "text": "重新計算 25 快取命中 25" }, + { "id": "b", "text": "重新計算 25 重新計算 25" }, + { "id": "c", "text": "快取命中 25 快取命中 25" }, + { "id": "d", "text": "重新計算 快取命中 25 25" } + ], + "answer": "a", + "explanation": "memoize 回傳的函式背著 cache 這個 closure 物件,第一次呼叫 square(5) 時 cache 裡沒有 5,跑「重新計算」分支,算出 25 順便存進 cache[5];第二次再呼叫 square(5),這次 5 in cache 是 true,直接把存好的答案還回去,不用重算。跟 jsa-2-q5 的私有狀態是同一個機制,只是這裡拿 closure 變數當快取,而不是計數器——昂貴的計算(例如遞迴、大量資料處理)很常用這招省時間。", + "verify": { + "checks": [ + { "code": "function memoize(fn) {\n const cache = {};\n return function (n) {\n if (n in cache) {\n console.log(\"快取命中\");\n return cache[n];\n }\n console.log(\"重新計算\");\n return (cache[n] = fn(n));\n };\n}\nconst square = memoize((n) => n * n);\nconsole.log(square(5));\nconsole.log(square(5));", "expected": "重新計算\n25\n快取命中\n25" } + ] + } + }, + { + "id": "jsa-2-q11", + "type": "predict-output", + "difficulty": 2, + "topic": "巢狀 closure:內層函式一路往外抓變數", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Closures", + "story": "", + "prompt": "三層函式疊在一起,最裡面同時用到最外層和中間層的變數。這段印出什麼?", + "code": "function outer() {\n let x = 10;\n function middle() {\n let y = 20;\n function inner() {\n return x + y;\n }\n return inner();\n }\n return middle();\n}\nconsole.log(outer());", + "options": [ + { "id": "a", "text": "30" }, + { "id": "b", "text": "10" }, + { "id": "c", "text": "20" }, + { "id": "d", "text": "會報錯,inner 拿不到 outer 的 x" } + ], + "answer": "a", + "explanation": "closure 不是只能抓「直接外層」的變數,而是整條作用域鏈往外一路都能抓:inner 找 x 時,自己沒有、middle 也沒有,繼續往外找到 outer 裡的 x = 10;找 y 則在 middle 就找到了。所以 x + y = 30。不用怕巢狀層數多,JavaScript 引擎會自動沿著函式定義時的作用域鏈往外找,這也是為什麼 closure 常被形容成「函式帶著一串出生地的地址簿」。", + "verify": { + "checks": [ + { "code": "function outer() {\n let x = 10;\n function middle() {\n let y = 20;\n function inner() {\n return x + y;\n }\n return inner();\n }\n return middle();\n}\nconsole.log(outer());", "expected": "30" } + ] + } + }, + { + "id": "jsa-2-q12", + "type": "predict-output", + "difficulty": 2, + "topic": "forEach 的 callback 參數自帶獨立綁定,closure 不會抓錯", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach", + "story": "", + "prompt": "幫每個按鈕建立各自的點擊處理器,不用像 for 迴圈那樣擔心 var 的坑。這段印出什麼?", + "code": "const buttons = [\"A\", \"B\", \"C\"];\nconst handlers = [];\nbuttons.forEach(function (label) {\n handlers.push(() => console.log(\"選了:\" + label));\n});\nhandlers[0]();\nhandlers[2]();", + "options": [ + { "id": "a", "text": "選了:A 然後 選了:C" }, + { "id": "b", "text": "選了:C 然後 選了:C" }, + { "id": "c", "text": "選了:undefined 然後 選了:undefined" }, + { "id": "d", "text": "選了:A 然後 選了:B" } + ], + "answer": "a", + "explanation": "forEach 每跑一輪,都會把當下的元素當成「全新的參數」傳給 callback——label 在每一輪都是獨立的變數,跟 jsa-2-q8 的 let 效果一樣,各自被對應的箭頭函式 closure 住,不會互相污染。所以 handlers[0] 記得的是 \"A\",handlers[2] 記得的是 \"C\"。這也是為什麼實務上很多人乾脆改用 forEach、map 取代傳統 for(var...) 迴圈:不只寫起來精簡,還順便閃掉了 var 共用變數的經典 bug。", + "verify": { + "checks": [ + { "code": "const buttons = [\"A\", \"B\", \"C\"];\nconst handlers = [];\nbuttons.forEach(function (label) {\n handlers.push(() => console.log(\"選了:\" + label));\n});\nhandlers[0]();\nhandlers[2]();", "expected": "選了:A\n選了:C" } + ] + } } ] } diff --git a/src/data/questions/jsa-3-async-basics.json b/src/data/questions/jsa-3-async-basics.json index 4c03601..9c05a96 100644 --- a/src/data/questions/jsa-3-async-basics.json +++ b/src/data/questions/jsa-3-async-basics.json @@ -141,6 +141,144 @@ { "code": "setTimeout(() => console.log(\"計時器\"), 0);\nPromise.resolve().then(() => console.log(\"Promise\"));\nconsole.log(\"同步\");", "expected": "同步\nPromise\n計時器" } ] } + }, + { + "id": "jsa-3-q7", + "type": "predict-output", + "difficulty": 1, + "topic": "Promise 建構子本身是同步跑的", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise", + "story": "", + "prompt": "new Promise(...) 裡面那段程式,是等到有人 .then 才跑,還是馬上跑?這段印出什麼?", + "code": "console.log(\"開始\");\nnew Promise((resolve) => {\n console.log(\"executor 執行\");\n resolve();\n}).then(() => console.log(\"then 執行\"));\nconsole.log(\"結束\");", + "options": [ + { "id": "a", "text": "開始 → executor 執行 → 結束 → then 執行" }, + { "id": "b", "text": "開始 → 結束 → executor 執行 → then 執行" }, + { "id": "c", "text": "開始 → executor 執行 → then 執行 → 結束" }, + { "id": "d", "text": "開始 → 結束 → then 執行 → executor 執行" } + ], + "answer": "a", + "explanation": "很多人以為整個 new Promise(...) 都是「非同步的」,其實只有 .then 裡的回呼是排隊的——executor(也就是 (resolve) => {...} 這段)在 new Promise 當下就立刻同步執行。所以「executor 執行」緊跟在「開始」後面印出,跟一般函式呼叫沒兩樣;只有呼叫了 resolve() 之後,掛在 .then 上的回呼才會被排進微任務,等同步程式(包含後面的「結束」)跑完才輪到它。", + "verify": { + "checks": [ + { "code": "console.log(\"開始\");\nnew Promise((resolve) => {\n console.log(\"executor 執行\");\n resolve();\n}).then(() => console.log(\"then 執行\"));\nconsole.log(\"結束\");", "expected": "開始\nexecutor 執行\n結束\nthen 執行" } + ] + } + }, + { + "id": "jsa-3-q8", + "type": "predict-output", + "difficulty": 1, + "topic": "多個 setTimeout 依延遲時間排序", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/API/Window/setTimeout", + "story": "", + "prompt": "三個計時器不是照寫的順序登記,是照延遲時間排隊。這段印出的順序是?", + "code": "setTimeout(() => console.log(\"C\"), 30);\nsetTimeout(() => console.log(\"A\"), 0);\nsetTimeout(() => console.log(\"B\"), 10);\nconsole.log(\"同步\");", + "options": [ + { "id": "a", "text": "同步 → A → B → C" }, + { "id": "b", "text": "同步 → C → A → B" }, + { "id": "c", "text": "同步 → A → C → B" }, + { "id": "d", "text": "C → A → B → 同步" } + ], + "answer": "a", + "explanation": "setTimeout 排的隊伍不是「誰先寫先排」,而是「延遲時間到了才輪到誰」。C 雖然第一個登記,但要等 30 毫秒;A 幾乎立刻(0 毫秒)就到期,所以先跑;接著是 10 毫秒的 B,最後才是 30 毫秒的 C。同步的「同步」永遠最先印,因為它根本沒排隊。", + "verify": { + "checks": [ + { "code": "setTimeout(() => console.log(\"C\"), 30);\nsetTimeout(() => console.log(\"A\"), 0);\nsetTimeout(() => console.log(\"B\"), 10);\nconsole.log(\"同步\");", "expected": "同步\nA\nB\nC" } + ] + } + }, + { + "id": "jsa-3-q9", + "type": "predict-output", + "difficulty": 2, + "topic": "Promise 只能決議一次", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise", + "story": "", + "prompt": "executor 裡連續呼叫了 resolve 兩次又 reject 一次。這段印出什麼?", + "code": "const p = new Promise((resolve, reject) => {\n resolve(\"成功\");\n resolve(\"又呼叫一次\");\n reject(new Error(\"太晚了\"));\n});\np.then((v) => console.log(v)).catch((e) => console.log(\"錯誤\", e.message));", + "options": [ + { "id": "a", "text": "成功" }, + { "id": "b", "text": "又呼叫一次" }, + { "id": "c", "text": "錯誤 太晚了" }, + { "id": "d", "text": "成功 然後 錯誤 太晚了" } + ], + "answer": "a", + "explanation": "一個 Promise 一輩子只能決議一次:狀態從 pending 變成 fulfilled 或 rejected 之後就定型了,之後不管再呼叫幾次 resolve 或 reject 都是空氣,直接被忽略。第一個生效的是 resolve(\"成功\"),後面的 resolve 和 reject 都晚了一步,所以只印出「成功」,catch 完全不會被觸發。", + "verify": { + "checks": [ + { "code": "const p = new Promise((resolve, reject) => {\n resolve(\"成功\");\n resolve(\"又呼叫一次\");\n reject(new Error(\"太晚了\"));\n});\np.then((v) => console.log(v)).catch((e) => console.log(\"錯誤\", e.message));", "expected": "成功" } + ] + } + }, + { + "id": "jsa-3-q10", + "type": "predict-output", + "difficulty": 1, + "topic": "finally 不管成功失敗都會執行", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise/finally", + "story": "", + "prompt": "不管請求成功還是失敗,loading 動畫都要關掉。這段印出什麼?", + "code": "Promise.reject(new Error(\"失敗\"))\n .catch((e) => console.log(\"補救:\", e.message))\n .finally(() => console.log(\"清理完成\"));", + "options": [ + { "id": "a", "text": "補救: 失敗 然後 清理完成" }, + { "id": "b", "text": "只印 清理完成" }, + { "id": "c", "text": "清理完成 然後 補救: 失敗" }, + { "id": "d", "text": "只印 補救: 失敗,finally 不會跑" } + ], + "answer": "a", + "explanation": "catch 先把失敗接住、印出補救訊息,並且把鏈子接回「成功」狀態(因為 catch 的回呼正常執行完畢,沒有再拋錯)。接在後面的 finally 不管前面是成功還是失敗都一定會跑,很適合放「不管結果如何都要做」的收尾動作,例如關掉 loading 動畫。finally 拿不到成功或失敗的值,它只負責「做完收尾」這件事。", + "verify": { + "checks": [ + { "code": "Promise.reject(new Error(\"失敗\"))\n .catch((e) => console.log(\"補救:\", e.message))\n .finally(() => console.log(\"清理完成\"));", "expected": "補救: 失敗\n清理完成" } + ] + } + }, + { + "id": "jsa-3-q11", + "type": "predict-output", + "difficulty": 3, + "topic": "微任務要全部清空才輪到下一個巨任務", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise", + "story": "", + "prompt": "這次微任務排了兩層(then 接 then),計時器也排了兩個。這段印出的順序是?", + "code": "setTimeout(() => console.log(\"計時器1\"), 0);\nPromise.resolve()\n .then(() => console.log(\"微任務1\"))\n .then(() => console.log(\"微任務2\"));\nsetTimeout(() => console.log(\"計時器2\"), 0);\nconsole.log(\"同步\");", + "options": [ + { "id": "a", "text": "同步 → 微任務1 → 微任務2 → 計時器1 → 計時器2" }, + { "id": "b", "text": "同步 → 微任務1 → 計時器1 → 微任務2 → 計時器2" }, + { "id": "c", "text": "同步 → 計時器1 → 計時器2 → 微任務1 → 微任務2" }, + { "id": "d", "text": "同步 → 微任務1 → 計時器1 → 計時器2 → 微任務2" } + ], + "answer": "a", + "explanation": "規則要記兩層:先跑完全部同步程式,接著把「微任務隊列」整個清空(就算清空過程中又排進新的微任務,像這裡第二個 .then,也會在同一輪繼續處理),最後才輪到巨任務(setTimeout)一個一個來。所以就算計時器1 比微任務2 早登記,微任務2 還是搶在計時器1 前面——「微任務優先權」永遠贏過「登記順序」。", + "verify": { + "checks": [ + { "code": "setTimeout(() => console.log(\"計時器1\"), 0);\nPromise.resolve()\n .then(() => console.log(\"微任務1\"))\n .then(() => console.log(\"微任務2\"));\nsetTimeout(() => console.log(\"計時器2\"), 0);\nconsole.log(\"同步\");", "expected": "同步\n微任務1\n微任務2\n計時器1\n計時器2" } + ] + } + }, + { + "id": "jsa-3-q12", + "type": "find-bug", + "difficulty": 2, + "topic": "沒人接住的 rejected Promise 會讓程式當掉", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch", + "story": "", + "prompt": "AI 寫的程式忘了幫這個 reject 加 catch。實際跑起來會怎樣?", + "code": "Promise.reject(new Error(\"斷線了\")); // 沒有接 .catch\nconsole.log(\"同步先印\");", + "options": [ + { "id": "a", "text": "先印出「同步先印」,接著整個程式因為沒人處理的 rejected Promise 而丟出未處理的錯誤、當掉" }, + { "id": "b", "text": "什麼都不會印,Promise.reject 這行就直接讓程式停住" }, + { "id": "c", "text": "印出「同步先印」,錯誤被自動吞掉,程式正常結束不受影響" }, + { "id": "d", "text": "先印出錯誤訊息,才印「同步先印」" } + ], + "answer": "a", + "explanation": "Promise.reject 建立的失敗 Promise 是同步「排進去」的,不會馬上讓程式停下,所以「同步先印」照樣先印出來。但只要這個 rejected Promise 一直沒有 catch 接住,等到微任務輪到它結算時,就會變成一個「未處理的 Promise 拒絕」(unhandled rejection)——現代 Node.js 預設會把它當成沒接住的例外直接讓程式當掉。教訓:每個可能失敗的 Promise 鏈尾端都該有 catch,就像 q5 教的一樣。", + "verify": { + "checks": [ + { "code": "Promise.reject(new Error(\"斷線了\"));\nconsole.log(\"同步先印\");", "expected": "同步先印", "expectError": "斷線了" } + ] + } } ] } diff --git a/src/data/questions/jsa-4-async-await.json b/src/data/questions/jsa-4-async-await.json index 8f2e844..0d3b929 100644 --- a/src/data/questions/jsa-4-async-await.json +++ b/src/data/questions/jsa-4-async-await.json @@ -141,6 +141,144 @@ { "code": "async function load() {\n try {\n return await Promise.reject(new Error(\"伺服器忙線\"));\n } catch (err) {\n return \"改用快取資料\";\n }\n}\nload().then((r) => console.log(r));", "expected": "改用快取資料" } ] } + }, + { + "id": "jsa-4-q7", + "type": "find-bug", + "difficulty": 1, + "topic": "await 只能用在 async 函式裡", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/await", + "story": "", + "prompt": "AI 幫忙加了 await,卻忘了這個函式不是 async。執行會怎樣?", + "code": "function notAsync() {\n const x = await Promise.resolve(1); // 💥\n console.log(x);\n}\nnotAsync();", + "options": [ + { "id": "a", "text": "根本跑不起來:await 只能出現在 async 函式(或模組最上層),這裡會直接報 SyntaxError" }, + { "id": "b", "text": "會印出 Promise { 1 },因為沒等到值" }, + { "id": "c", "text": "會印出 1,只是比較慢" }, + { "id": "d", "text": "會印出 undefined" } + ], + "answer": "a", + "explanation": "await 是「只有 async 函式才有的關鍵字」,拿掉 async 的殼子它就不能用——這不是執行期才發現的錯誤,而是連程式碼都解析不過去的 SyntaxError,錯誤訊息會直接說 await is only valid in async functions。修法很簡單:把 function notAsync() 改成 async function notAsync()。這跟 q3「忘了寫 await」是相反的坑:一個是漏寫,一個是寫了但外殼沒對。", + "verify": { + "checks": [ + { "code": "function notAsync() {\n const x = await Promise.resolve(1);\n console.log(x);\n}\nnotAsync();", "expectError": "await is only valid in async functions" } + ] + } + }, + { + "id": "jsa-4-q8", + "type": "predict-output", + "difficulty": 3, + "topic": "forEach 裡的 async callback 不會等你", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach", + "story": "", + "prompt": "AI 常常把 for...of 寫成 forEach,結果順序整個亂掉。這段印出什麼?", + "code": "const wait = (ms) => new Promise((r) => setTimeout(r, ms));\nasync function run() {\n const items = [\"A\", \"B\", \"C\"];\n items.forEach(async (item) => {\n await wait(10);\n console.log(item);\n });\n console.log(\"跑完 forEach 迴圈\");\n}\nrun();", + "options": [ + { "id": "a", "text": "跑完 forEach 迴圈 → A → B → C" }, + { "id": "b", "text": "A → B → C → 跑完 forEach 迴圈" }, + { "id": "c", "text": "跑完 forEach 迴圈 → C → B → A" }, + { "id": "d", "text": "會報錯,forEach 裡不能放 async 函式" } + ], + "answer": "a", + "explanation": "forEach 完全不理會你傳進去的 callback 是不是 async——它把三個 callback 一口氣「發射」出去就不等了,立刻往下跑完自己的工作(印出「跑完 forEach 迴圈」),跟 q4 學到的「先全部啟動再等」的道理一樣,只是這裡連 await 的機會都沒給你,因為 forEach 根本不回傳 Promise 讓你等。真的要「逐一等待」,得用 for...of 搭配 await(像 q6 那樣),forEach 天生不是為 async 設計的,這是實務上最容易誤踩的坑之一。", + "verify": { + "checks": [ + { "code": "const wait = (ms) => new Promise((r) => setTimeout(r, ms));\nasync function run() {\n const items = [\"A\", \"B\", \"C\"];\n items.forEach(async (item) => {\n await wait(10);\n console.log(item);\n });\n console.log(\"跑完 forEach 迴圈\");\n}\nrun();", "expected": "跑完 forEach 迴圈\nA\nB\nC" } + ] + } + }, + { + "id": "jsa-4-q9", + "type": "predict-output", + "difficulty": 2, + "topic": "async 函式內部 throw 會變成 rejected promise", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/async_function", + "story": "", + "prompt": "async 函式裡直接用 throw 丟錯誤,而不是 return Promise.reject。這段印出什麼?", + "code": "async function risky() {\n throw new Error(\"寵物離家出走\");\n}\nrisky()\n .then(() => console.log(\"不會執行\"))\n .catch((err) => console.log(\"抓到:\", err.message));", + "options": [ + { "id": "a", "text": "抓到: 寵物離家出走" }, + { "id": "b", "text": "程式直接崩潰,因為 throw 沒有被 try/catch 包住" }, + { "id": "c", "text": "不會執行" }, + { "id": "d", "text": "什麼都不印" } + ], + "answer": "a", + "explanation": "在一般同步函式裡 throw 會讓錯誤往外炸;但在 async 函式裡完全不同——整個函式的回傳值本來就會被自動包成 Promise(q2 學過的規則),throw 出來的錯誤也會被這個包裝過程接住,變成一個 rejected 的 Promise,而不是讓程式當場崩潰。所以呼叫端只要掛上 .catch(或用 try/catch 包住 await risky()),就能像接住 Promise.reject 一樣接住它。", + "verify": { + "checks": [ + { "code": "async function risky() {\n throw new Error(\"寵物離家出走\");\n}\nrisky()\n .then(() => console.log(\"不會執行\"))\n .catch((err) => console.log(\"抓到:\", err.message));", "expected": "抓到: 寵物離家出走" } + ] + } + }, + { + "id": "jsa-4-q10", + "type": "predict-output", + "difficulty": 1, + "topic": "await 一個不是 Promise 的值也可以", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/await", + "story": "", + "prompt": "await 後面接的不是 Promise,而是普通的數字和字串。這段印出什麼?", + "code": "async function main() {\n const x = await 42;\n console.log(x);\n const y = await \"普通字串\";\n console.log(y);\n}\nmain();", + "options": [ + { "id": "a", "text": "42 然後 普通字串" }, + { "id": "b", "text": "會報錯,await 後面一定要接 Promise" }, + { "id": "c", "text": "Promise { 42 } 然後 Promise { '普通字串' }" }, + { "id": "d", "text": "undefined 然後 undefined" } + ], + "answer": "a", + "explanation": "await 沒有這麼挑剔:如果後面接的東西不是 Promise,它就直接把那個值當成「已經完成的結果」還給你,不會報錯也不會等待。這代表你不用先檢查一個值是不是 Promise 才敢 await 它——寫成 await maybeAsyncValue() 永遠是安全的,不管那個函式回傳的是不是 Promise,await 都能處理。", + "verify": { + "checks": [ + { "code": "async function main() {\n const x = await 42;\n console.log(x);\n const y = await \"普通字串\";\n console.log(y);\n}\nmain();", "expected": "42\n普通字串" } + ] + } + }, + { + "id": "jsa-4-q11", + "type": "fill-in", + "difficulty": 3, + "topic": "Promise.allSettled 不管成功失敗都給結果", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled", + "story": "", + "prompt": "其中一個任務會失敗,但你不想讓 Promise.all 整組炸掉,想知道每個任務各自的成敗。把空格填起來,印出 fulfilled, rejected", + "code": "const ok = async () => \"成功\";\nconst bad = async () => { throw new Error(\"失敗了\"); };\n\nasync function main() {\n const results = await ____([ok(), bad()]);\n console.log(results.map((r) => r.status).join(\", \"));\n}\nmain();", + "options": [ + { "id": "a", "text": "Promise.allSettled" }, + { "id": "b", "text": "Promise.all" }, + { "id": "c", "text": "Promise.race" }, + { "id": "d", "text": "Promise.any" } + ], + "answer": "a", + "explanation": "Promise.all 是「一個失敗,全部陣亡」——只要有一個 reject,整個 await 就會拋出錯誤,你完全看不到其他任務的結果。Promise.allSettled 不會這樣:不管每個 Promise 成功還是失敗,都會乖乖等到全部「有結果」(settled)才 resolve,回傳的陣列裡每一項都有 status 是 fulfilled 或 rejected,讓你能個別檢查、個別處理,很適合「批次任務,允許部分失敗」的情境。", + "verify": { + "checks": [ + { "code": "const ok = async () => \"成功\";\nconst bad = async () => { throw new Error(\"失敗了\"); };\n\nasync function main() {\n const results = await Promise.allSettled([ok(), bad()]);\n console.log(results.map((r) => r.status).join(\", \"));\n}\nmain();", "expected": "fulfilled, rejected" } + ] + } + }, + { + "id": "jsa-4-q12", + "type": "predict-output", + "difficulty": 2, + "topic": "Promise.race 誰先完成就算誰的", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise/race", + "story": "", + "prompt": "同時發出兩個請求,只想要最快回來的那個結果。這段印出什麼?", + "code": "const wait = (ms, tag) => new Promise((r) => setTimeout(() => r(tag), ms));\nasync function main() {\n const winner = await Promise.race([wait(50, \"慢\"), wait(10, \"快\")]);\n console.log(winner);\n}\nmain();", + "options": [ + { "id": "a", "text": "快" }, + { "id": "b", "text": "慢" }, + { "id": "c", "text": "快 然後 慢" }, + { "id": "d", "text": "undefined,因為兩個同時開始無法判斷誰先" } + ], + "answer": "a", + "explanation": "Promise.race 顧名思義是賽跑:丟進去的 Promise 同時起跑,誰第一個決議(不管成功或失敗)就用誰的結果,其他還沒跑完的就被晾在一邊、不再理會。這裡「快」只花 10 毫秒,比「慢」的 50 毫秒早到終點,所以 winner 拿到的是「快」。常見用途是幫請求加逾時:Promise.race([fetchData(), timeout(3000)]),看誰先到就用誰的結果。", + "verify": { + "checks": [ + { "code": "const wait = (ms, tag) => new Promise((r) => setTimeout(() => r(tag), ms));\nasync function main() {\n const winner = await Promise.race([wait(50, \"慢\"), wait(10, \"快\")]);\n console.log(winner);\n}\nmain();", "expected": "快" } + ] + } } ] } diff --git a/src/data/questions/jsa-5-classes.json b/src/data/questions/jsa-5-classes.json index d434b68..447c426 100644 --- a/src/data/questions/jsa-5-classes.json +++ b/src/data/questions/jsa-5-classes.json @@ -140,6 +140,145 @@ { "code": "class MathKit {\n static double(n) {\n return n * 2;\n }\n}\nconst kit = new MathKit();\nconsole.log(MathKit.double(5));\nconsole.log(typeof kit.double);", "expected": "10\nundefined" } ] } + }, + { + "id": "jsa-5-q7", + "type": "predict-output", + "difficulty": 2, + "topic": "getter/setter 讓存取像屬性一樣", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Classes/get", + "story": "", + "prompt": "c.area 看起來像個普通屬性,其實背後在跑一段計算。這段印出什麼?", + "code": "class Circle {\n constructor(radius) {\n this.radius = radius;\n }\n get area() {\n return Math.PI * this.radius ** 2;\n }\n set radius(r) {\n this._radius = r < 0 ? 0 : r;\n }\n get radius() {\n return this._radius;\n }\n}\nconst c = new Circle(2);\nconsole.log(c.area.toFixed(2));\nc.radius = -5;\nconsole.log(c.radius);", + "options": [ + { "id": "a", "text": "12.57 然後 0" }, + { "id": "b", "text": "12.57 然後 -5" }, + { "id": "c", "text": "會報錯,radius 不能同時有 get 和 set" }, + { "id": "d", "text": "undefined 然後 0" } + ], + "answer": "a", + "explanation": "get 和 set 讓「呼叫方法」偽裝成「存取屬性」:c.area 讀起來像欄位,實際上是呼叫 get area() 現算一次(2 的平方乘上 π ≈ 12.57)。c.radius = -5 這行也不是直接改欄位,而是觸發 set radius(r),裡面做了防呆把負數擋成 0,所以真正存進 this._radius 的是 0。這種「表面是屬性、背後是函式」的寫法很常拿來做資料驗證或格式轉換,且不用改呼叫端的語法。", + "verify": { + "checks": [ + { "code": "class Circle {\n constructor(radius) {\n this.radius = radius;\n }\n get area() {\n return Math.PI * this.radius ** 2;\n }\n set radius(r) {\n this._radius = r < 0 ? 0 : r;\n }\n get radius() {\n return this._radius;\n }\n}\nconst c = new Circle(2);\nconsole.log(c.area.toFixed(2));\nc.radius = -5;\nconsole.log(c.radius);", "expected": "12.57\n0" } + ] + } + }, + { + "id": "jsa-5-q8", + "type": "find-bug", + "difficulty": 3, + "topic": "private 欄位 #field 外部真的碰不到", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Classes/Private_properties", + "story": "", + "prompt": "AI 寫的程式想從外部直接讀 #balance,結果整支程式跑不起來。為什麼?", + "code": "class Wallet {\n #balance = 100;\n show() {\n return this.#balance;\n }\n}\nconst w = new Wallet();\nconsole.log(w.show());\nconsole.log(w.#balance); // 💥", + "options": [ + { "id": "a", "text": "#balance 是 private 欄位,只有 class 內部的程式碼能存取,外部連寫都寫不出來(直接 SyntaxError)" }, + { "id": "b", "text": "#balance 忘了在 constructor 裡初始化" }, + { "id": "c", "text": "private 欄位只能用 get/set 宣告,不能直接寫 #balance = 100" }, + { "id": "d", "text": "w.show() 也會失敗,因為 private 欄位連 class 內部都不能用" } + ], + "answer": "a", + "explanation": "開頭加 # 的欄位是真正的 private:只有這個 class 的方法內部(像 show())能碰它,class 外部就算是同一個檔案也完全存取不到。這不只是「約定俗成不要動」,而是語言層級硬性擋住——在 class 外面寫 w.#balance,連程式都解析不過去,直接是 SyntaxError(Private field '#balance' must be declared in an enclosing class)。這跟第五章其他關卡教的「用 closure 做私有狀態」是同一個目的,但 class 語法把它變成內建功能。", + "verify": { + "checks": [ + { "code": "class Wallet {\n #balance = 100;\n show() {\n return this.#balance;\n }\n}\nconst w = new Wallet();\nconsole.log(w.show());", "expected": "100" }, + { "code": "class Wallet {\n #balance = 100;\n show() {\n return this.#balance;\n }\n}\nconst w = new Wallet();\nconsole.log(w.#balance);", "expectError": "Private field" } + ] + } + }, + { + "id": "jsa-5-q9", + "type": "predict-output", + "difficulty": 1, + "topic": "instanceof 判斷物件的血統", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/instanceof", + "story": "", + "prompt": "想知道一個物件到底是不是某個 class(或它的子類別)做出來的。這段印出什麼?", + "code": "class Animal {}\nclass Dog extends Animal {}\nconst d = new Dog();\nconsole.log(d instanceof Dog);\nconsole.log(d instanceof Animal);\nconsole.log(d instanceof Array);", + "options": [ + { "id": "a", "text": "true 然後 true 然後 false" }, + { "id": "b", "text": "true 然後 false 然後 false" }, + { "id": "c", "text": "true 然後 true 然後 true" }, + { "id": "d", "text": "false 然後 true 然後 false" } + ], + "answer": "a", + "explanation": "instanceof 查的是「這個物件的原型鏈上,找不找得到某個 class 的 prototype」。d 是 new Dog() 做出來的,自然 instanceof Dog 是 true;因為 Dog extends Animal,Dog 的原型鏈會往上接到 Animal,所以 d instanceof Animal 也是 true。但 Dog 跟 Array 完全沒關係,原型鏈上找不到 Array.prototype,所以是 false。判斷「這個東西到底是什麼類型做出來的」,instanceof 是最直接的工具。", + "verify": { + "checks": [ + { "code": "class Animal {}\nclass Dog extends Animal {}\nconst d = new Dog();\nconsole.log(d instanceof Dog);\nconsole.log(d instanceof Animal);\nconsole.log(d instanceof Array);", "expected": "true\ntrue\nfalse" } + ] + } + }, + { + "id": "jsa-5-q10", + "type": "predict-output", + "difficulty": 2, + "topic": "方法定義在 prototype 上,所有實例共用同一份", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Classes", + "story": "", + "prompt": "a 和 b 是兩隻不同的寵物,但它們的 speak 方法是不是各自複製了一份?這段印出什麼?", + "code": "class Pet {\n speak() {\n return \"...\";\n }\n}\nconst a = new Pet();\nconst b = new Pet();\nconsole.log(a.speak === b.speak);\nconsole.log(Object.getPrototypeOf(a) === Pet.prototype);", + "options": [ + { "id": "a", "text": "true 然後 true" }, + { "id": "b", "text": "false 然後 true" }, + { "id": "c", "text": "false 然後 false" }, + { "id": "d", "text": "true 然後 false" } + ], + "answer": "a", + "explanation": "class 裡寫的方法(像 speak)不會在每個 new 出來的實例上各複製一份,而是只存一份在 Pet.prototype 上,所有實例透過原型鏈共用同一個函式——所以 a.speak === b.speak 是 true,Object.getPrototypeOf(a) 也的確指向同一個 Pet.prototype。這跟 q4 那個 class 欄位 count = 0(每個實例各自一份)不一樣:欄位是「掛在每個實例自己身上」,方法是「大家共用同一份,省記憶體」。", + "verify": { + "checks": [ + { "code": "class Pet {\n speak() {\n return \"...\";\n }\n}\nconst a = new Pet();\nconst b = new Pet();\nconsole.log(a.speak === b.speak);\nconsole.log(Object.getPrototypeOf(a) === Pet.prototype);", "expected": "true\ntrue" } + ] + } + }, + { + "id": "jsa-5-q11", + "type": "predict-output", + "difficulty": 3, + "topic": "多層繼承時 super 呼叫鏈一路往上串", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/super", + "story": "", + "prompt": "C 繼承 B,B 又繼承 A,三層都覆寫了 intro 並呼叫 super.intro()。這段印出什麼?", + "code": "class A {\n intro() { return \"A\"; }\n}\nclass B extends A {\n intro() { return super.intro() + \"B\"; }\n}\nclass C extends B {\n intro() { return super.intro() + \"C\"; }\n}\nconsole.log(new C().intro());", + "options": [ + { "id": "a", "text": "ABC" }, + { "id": "b", "text": "CBA" }, + { "id": "c", "text": "C" }, + { "id": "d", "text": "會報錯,super 只能往上一層,不能連續往上兩層" } + ], + "answer": "a", + "explanation": "super 不是只能用一次,繼承鏈有幾層,super.xxx() 就能一路往上接力:C.intro() 先呼叫 super.intro()(也就是 B 的版本),B.intro() 又呼叫自己的 super.intro()(也就是 A 的版本),A 是最上層直接回傳 \"A\"。結果從最底層開始往回疊字串:A → A+\"B\" → AB+\"C\",最後印出 ABC。「覆寫再借用上一層」這招(q3 學過)在多層繼承照樣管用,一層接一層往上找。", + "verify": { + "checks": [ + { "code": "class A {\n intro() { return \"A\"; }\n}\nclass B extends A {\n intro() { return super.intro() + \"B\"; }\n}\nclass C extends B {\n intro() { return super.intro() + \"C\"; }\n}\nconsole.log(new C().intro());", "expected": "ABC" } + ] + } + }, + { + "id": "jsa-5-q12", + "type": "predict-output", + "difficulty": 2, + "topic": "沒寫 constructor 也沒關係,子類別自動幫你 super(...args)", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Classes/constructor", + "story": "", + "prompt": "Cat 繼承 Animal,卻完全沒寫自己的 constructor。這段印出什麼?", + "code": "class Animal {\n constructor(name) {\n this.name = name;\n }\n}\nclass Cat extends Animal {\n meow() {\n return `${this.name} 喵`;\n }\n}\nconsole.log(new Cat(\"小黑\").meow());", + "options": [ + { "id": "a", "text": "小黑 喵" }, + { "id": "b", "text": "undefined 喵" }, + { "id": "c", "text": "會報錯,Cat 沒有 constructor 就不能 new" }, + { "id": "d", "text": "會報錯,子類別一定要自己寫 constructor 並呼叫 super" } + ], + "answer": "a", + "explanation": "q5 教過子類別自己寫 constructor 時,第一行一定要是 super(...);但如果子類別根本沒寫 constructor,JS 會自動幫你補一個預設版本,等同於 constructor(...args) { super(...args) }——把收到的參數原封不動轉交給父類別。所以 new Cat(\"小黑\") 雖然 Cat 沒有自己的 constructor,\"小黑\" 還是順利被父類別 Animal 的 constructor 接住存成 this.name。只有當你自己動手寫了 constructor,才必須自己記得呼叫 super()。", + "verify": { + "checks": [ + { "code": "class Animal {\n constructor(name) {\n this.name = name;\n }\n}\nclass Cat extends Animal {\n meow() {\n return `${this.name} 喵`;\n }\n}\nconsole.log(new Cat(\"小黑\").meow());", "expected": "小黑 喵" } + ] + } } ] } diff --git a/src/data/questions/jsa-6-collections-json.json b/src/data/questions/jsa-6-collections-json.json index a8e161f..d233a21 100644 --- a/src/data/questions/jsa-6-collections-json.json +++ b/src/data/questions/jsa-6-collections-json.json @@ -140,6 +140,144 @@ { "code": "const user = {\n name: \"小美\",\n greet() { return \"hi\"; },\n tags: undefined,\n};\nconst copy = JSON.parse(JSON.stringify(user));\nconsole.log(\"greet\" in copy, \"tags\" in copy, copy.name);", "expected": "false false 小美" } ] } + }, + { + "id": "jsa-6-q7", + "type": "predict-output", + "difficulty": 1, + "topic": "Set 判斷成員:has", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Set/has", + "story": "", + "prompt": "想確認某人在不在名單裡,不用真的找出來、只想知道有沒有。這段印出什麼?", + "code": "const vip = new Set([\"小美\", \"小華\"]);\nconsole.log(vip.has(\"小美\"), vip.has(\"小王\"));", + "options": [ + { "id": "a", "text": "true false" }, + { "id": "b", "text": "false true" }, + { "id": "c", "text": "1 0" }, + { "id": "d", "text": "會報錯,Set 沒有 has 方法" } + ], + "answer": "a", + "explanation": "has() 是 Set 用來問「這個值在不在裡面」的方法,回傳單純的布林值,不是索引也不會因為找不到而報錯。它比陣列的 includes() 快很多——Set 底層設計就是為了讓「查有沒有」變成瞬間的事,資料量大時差異會很明顯。", + "verify": { + "checks": [ + { "code": "const vip = new Set([\"小美\", \"小華\"]);\nconsole.log(vip.has(\"小美\"), vip.has(\"小王\"));", "expected": "true false" } + ] + } + }, + { + "id": "jsa-6-q8", + "type": "fill-in", + "difficulty": 2, + "topic": "Map 用 for...of 走訪 [key, value]", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Map", + "story": "", + "prompt": "把空格填起來,把每個人的分數一行一行印出來", + "code": "const scores = new Map([\n [\"小美\", 90],\n [\"小華\", 80],\n]);\n\nfor (const [name, score] of scores) {\n ____\n}", + "options": [ + { "id": "a", "text": "console.log(name, score);" }, + { "id": "b", "text": "console.log(scores[name]);" }, + { "id": "c", "text": "console.log(score, name.value);" }, + { "id": "d", "text": "console.log(name.key, name.value);" } + ], + "answer": "a", + "explanation": "Map 是可迭代物,for...of 走過去時每一輪拿到的是一個 [key, value] 兩格陣列,用解構 const [name, score] 直接拆開最乾脆。選項 b 又掉回「Map 要用 get 不能用點記法/中括號屬性」的坑;選項 c、d 把 name 誤當成還包著 key/value 的物件,但它已經被解構成單純的字串了。", + "verify": { + "checks": [ + { "code": "const scores = new Map([\n [\"小美\", 90],\n [\"小華\", 80],\n]);\nfor (const [name, score] of scores) {\n console.log(name, score);\n}", "expected": "小美 90\n小華 80" } + ] + } + }, + { + "id": "jsa-6-q9", + "type": "predict-output", + "difficulty": 2, + "topic": "JSON.stringify 的縮排參數", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify", + "story": "", + "prompt": "AI 產生的程式碼裡,JSON.stringify 後面多塞了兩個參數。這段印出什麼?", + "code": "const user = { name: \"小美\", age: 20 };\nconsole.log(JSON.stringify(user, null, 2));", + "options": [ + { "id": "a", "text": "帶縮排、換行的美化版 JSON 字串" }, + { "id": "b", "text": "跟不加參數完全一樣,一行印完" }, + { "id": "c", "text": "會報錯,stringify 只能傳一個參數" }, + { "id": "d", "text": "只印出 2" } + ], + "answer": "a", + "explanation": "JSON.stringify(值, replacer, space) 其實有三個參數:第二個 replacer 是過濾/轉換用的(沒有就傳 null),第三個 space 是縮排量——傳數字 2 就會用 2 個空白幫你把結果排版成一行一個屬性、方便肉眼看的樣子。存檔案、寫進 console 除錯或當範例展示時很常這樣用;真的要塞進 API 或 localStorage 省空間時,通常還是省略這兩個參數印成緊湊的一行。", + "verify": { + "checks": [ + { "code": "const user = { name: \"小美\", age: 20 };\nconsole.log(JSON.stringify(user, null, 2));", "expected": "{\n \"name\": \"小美\",\n \"age\": 20\n}" } + ] + } + }, + { + "id": "jsa-6-q10", + "type": "same-or-not", + "difficulty": 2, + "topic": "structuredClone:內建深拷貝", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/API/structuredClone", + "story": "", + "prompt": "跟前面 JSON 深拷貝那招比,這個內建函式做的事一樣嗎?", + "code": "const original = { user: { name: \"小美\" } };\nconst copy = structuredClone(original);\ncopy.user.name = \"小華\";\nconsole.log(original.user.name, copy.user.name);", + "options": [ + { "id": "a", "text": "一樣:原本不受影響,印出 小美 小華" }, + { "id": "b", "text": "不一樣:structuredClone 其實是淺拷貝,原本也被改了" }, + { "id": "c", "text": "會報錯,structuredClone 不是真的存在" }, + { "id": "d", "text": "一樣,但兩邊都變成 小華" } + ], + "answer": "a", + "explanation": "structuredClone() 是瀏覽器/Node 內建的深拷貝函式,效果跟 JSON.parse(JSON.stringify(x)) 這招很像(每一層都複製成全新物件),但它更正規:能處理 Date、Map、Set、循環參照這些 JSON 會處理不好或直接報錯的東西,唯獨一樣不能複製函式。需要深拷貝時,比起 JSON 兩件套,這是現代寫法應該優先想到的工具。", + "verify": { + "checks": [ + { "code": "const original = { user: { name: \"小美\" } };\nconst copy = structuredClone(original);\ncopy.user.name = \"小華\";\nconsole.log(original.user.name, copy.user.name);", "expected": "小美 小華" } + ] + } + }, + { + "id": "jsa-6-q11", + "type": "fill-in", + "difficulty": 3, + "topic": "Map 轉物件:Object.fromEntries", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Object/fromEntries", + "story": "", + "prompt": "手上有個 Map,但要傳給只吃一般物件的舊函式。把空格填起來,讓 obj 印出 { a: 1, b: 2 }", + "code": "const map = new Map([\n [\"a\", 1],\n [\"b\", 2],\n]);\nconst obj = ____;\nconsole.log(obj);", + "options": [ + { "id": "a", "text": "Object.fromEntries(map)" }, + { "id": "b", "text": "Object.entries(map)" }, + { "id": "c", "text": "map.toObject()" }, + { "id": "d", "text": "JSON.parse(map)" } + ], + "answer": "a", + "explanation": "Object.fromEntries 吃一串 [key, value] 配對(陣列或任何可迭代物都行),組回一個普通物件——正好是 Map 天生的資料形狀,直接餵給它就轉換完成。反過來,物件轉 Map 就用 new Map(Object.entries(obj)),兩個函式互為逆運算,這條路線比手動 for 迴圈組資料乾淨很多。選項 c 的方法不存在,選項 d 的 JSON.parse 是拿字串轉物件,跟 Map 完全對不上。", + "verify": { + "checks": [ + { "code": "const map = new Map([\n [\"a\", 1],\n [\"b\", 2],\n]);\nconst obj = Object.fromEntries(map);\nconsole.log(obj);", "expected": "{ a: 1, b: 2 }" } + ] + } + }, + { + "id": "jsa-6-q12", + "type": "predict-output", + "difficulty": 2, + "topic": "Map.keys() 與 Map.values()", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Map/keys", + "story": "", + "prompt": "只想要鍵,或只想要值,不想要整組 [key, value]。這段印出什麼?", + "code": "const map = new Map([\n [\"a\", 1],\n [\"b\", 2],\n]);\nconsole.log([...map.keys()]);\nconsole.log([...map.values()]);", + "options": [ + { "id": "a", "text": "[ 'a', 'b' ] 然後 [ 1, 2 ]" }, + { "id": "b", "text": "[ 1, 2 ] 然後 [ 'a', 'b' ]" }, + { "id": "c", "text": "[ ['a',1], ['b',2] ] 兩次" }, + { "id": "d", "text": "會報錯,Map 沒有 keys/values 方法" } + ], + "answer": "a", + "explanation": "Map 提供三個各司其職的走訪方法:keys() 只給鍵、values() 只給值、entries()(也就是直接 for...of 這個 Map 時用的)給一組一組的 [key, value]。三個都回傳可迭代物,所以都能用展開語法收成陣列。需要哪一種資料,就直接點對應的方法,不用自己再用 map() 從 entries 裡挑。", + "verify": { + "checks": [ + { "code": "const map = new Map([\n [\"a\", 1],\n [\"b\", 2],\n]);\nconsole.log([...map.keys()]);\nconsole.log([...map.values()]);", "expected": "[ 'a', 'b' ]\n[ 1, 2 ]" } + ] + } } ] } diff --git a/src/data/questions/jsa-7-modules.json b/src/data/questions/jsa-7-modules.json index e997493..e6af8fc 100644 --- a/src/data/questions/jsa-7-modules.json +++ b/src/data/questions/jsa-7-modules.json @@ -139,6 +139,145 @@ { "code": "(async () => {\n const url = \"data:text/javascript,let n = 0; export const inc = () => ++n;\";\n const m1 = await import(url);\n const m2 = await import(url);\n m1.inc();\n console.log(m2.inc());\n})();", "expected": "2" } ] } + }, + { + "id": "jsa-7-q7", + "type": "predict-output", + "difficulty": 2, + "topic": "具名匯入用 as 改名", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/import", + "story": "", + "prompt": "utils.js 匯出的 value 跟 main.js 自己想用的變數名字撞了。這樣寫會怎樣?", + "code": "// utils.js\nexport const value = 10;\n\n// main.js\nimport { value as utilValue } from \"./utils.js\";\nconst value = 99;\nconsole.log(value, utilValue);", + "options": [ + { "id": "a", "text": "99 10" }, + { "id": "b", "text": "10 99" }, + { "id": "c", "text": "會報錯,value 被重複宣告" }, + { "id": "d", "text": "99 99" } + ], + "answer": "a", + "explanation": "import { value as utilValue } 是「領貨的時候順便改名」:匯入進來的東西在這個檔案裡從頭到尾只認得 utilValue 這個名字,跟本地自己宣告的 value 完全是兩個獨立的變數,不會撞名。如果沒加 as、直接 import { value } 又同時 const value = 99,那才會真的因為同一個作用域裡重複宣告而報錯——as 正是拿來解決「進口貨跟自家貨同名」的標準做法。", + "verify": { + "checks": [ + { "code": "(async () => {\n const mod = await import(\"data:text/javascript,export const value = 10;\");\n const { value: utilValue } = mod;\n const value = 99;\n console.log(value, utilValue);\n})();", "expected": "99 10" } + ] + } + }, + { + "id": "jsa-7-q8", + "type": "predict-output", + "difficulty": 2, + "topic": "尾端集中 export { a, b, c }", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/export", + "story": "", + "prompt": "utils.js 想一次把三個東西開放出去,不想在每一行前面各加一次 export。這樣寫合法嗎?印出什麼?", + "code": "// utils.js\nfunction add(a, b) { return a + b; }\nfunction sub(a, b) { return a - b; }\nconst PI = 3.14;\n\nexport { add, sub, PI };\n\n// main.js\nimport { add, sub, PI } from \"./utils.js\";\nconsole.log(add(1, 2), sub(5, 2), PI);", + "options": [ + { "id": "a", "text": "3 3 3.14" }, + { "id": "b", "text": "會報錯,export 一定要寫在宣告的前面" }, + { "id": "c", "text": "undefined undefined undefined" }, + { "id": "d", "text": "3 3 undefined" } + ], + "answer": "a", + "explanation": "export { a, b, c } 這種「尾端集中匯出」跟在每個宣告前面各自加 export 效果完全一樣,只是把「這個檔案對外開放了什麼」集中寫成一張清單放在檔案最後,很多人覺得一眼就能看完整份匯出名單、比較好維護。兩種寫法混著用也沒問題,純粹是風格選擇。", + "verify": { + "checks": [ + { "code": "(async () => {\n const mod = await import(\"data:text/javascript,function add(a, b) { return a + b; } function sub(a, b) { return a - b; } const PI = 3.14; export { add, sub, PI };\");\n console.log(mod.add(1, 2), mod.sub(5, 2), mod.PI);\n})();", "expected": "3 3 3.14" } + ] + } + }, + { + "id": "jsa-7-q9", + "type": "predict-output", + "difficulty": 2, + "topic": "re-export:轉手把別人的匯出讓出去", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/export", + "story": "", + "prompt": "index.js 想讓外面可以直接跟它拿 double,不用知道 double 其實是 math.js 寫的。這樣寫印出什麼?", + "code": "// math.js\nexport const double = (n) => n * 2;\n\n// index.js\nexport { double } from \"./math.js\";\n\n// main.js\nimport { double } from \"./index.js\";\nconsole.log(double(21));", + "options": [ + { "id": "a", "text": "42" }, + { "id": "b", "text": "會報錯,index.js 沒有 import double 就不能 export" }, + { "id": "c", "text": "undefined" }, + { "id": "d", "text": "21" } + ], + "answer": "a", + "explanation": "export { double } from \"./math.js\" 是「re-export」的語法糖:index.js 不用先 import 再 export 兩步,一行就把 math.js 的東西轉手開放出去,而且它自己內部並不會多出一個叫 double 的本地變數(想在 index.js 裡面用它還是得另外 import)。這招常用來幫一堆散落的小檔案做「統一出口」的 index.js,讓外面只需要記得一個進入點。", + "verify": { + "checks": [ + { "code": "(async () => {\n const mathUrl = \"data:text/javascript,export const double = (n) => n * 2;\";\n const indexUrl = \"data:text/javascript,\" + encodeURIComponent(`export { double } from \"${mathUrl}\";`);\n const mod = await import(indexUrl);\n console.log(mod.double(21));\n})();", "expected": "42" } + ] + } + }, + { + "id": "jsa-7-q10", + "type": "fill-in", + "difficulty": 2, + "topic": "動態 import():用到才載入", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/import", + "story": "", + "prompt": "想等使用者真的按下按鈕才載入這個很肥的模組,不要一開頭就載入。把空格填起來", + "code": "// heavy.js\nexport const run = () => \"重模組執行了\";\n\n// main.js\nasync function onClick() {\n const mod = await ____;\n console.log(mod.run());\n}\nonClick();", + "options": [ + { "id": "a", "text": "import(\"./heavy.js\")" }, + { "id": "b", "text": "import \"./heavy.js\"" }, + { "id": "c", "text": "require(\"./heavy.js\")" }, + { "id": "d", "text": "import { run } from \"./heavy.js\"" } + ], + "answer": "a", + "explanation": "動態 import() 是一個函式呼叫(表達式),回傳一個 Promise,resolve 之後拿到整個模組物件——跟開頭寫死的靜態 import 不一樣,它可以放在任何時機才觸發,例如點擊事件裡才去下載某個很少用到的模組,這就是 code splitting/延遲載入的基礎。選項 b 是靜態語句寫法,不是表達式、不能 await;選項 c 是 CommonJS 的東西,跟 ESM 是兩套系統;選項 d 的靜態具名匯入語法必須寫在檔案最上層,不能塞進函式裡按需執行。", + "verify": { + "checks": [ + { "code": "(async () => {\n const mod = await import(\"data:text/javascript,export const run = () => '重模組執行了';\");\n console.log(mod.run());\n})();", "expected": "重模組執行了" } + ] + } + }, + { + "id": "jsa-7-q11", + "type": "predict-output", + "difficulty": 3, + "topic": "循環依賴:互相 import 會怎樣", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide/Modules", + "story": "", + "prompt": "a.js 和 b.js 互相 import 對方(循環依賴,AI 重構拆檔案時偶爾會不小心搞出這種結構)。main.js 印出什麼?", + "code": "// a.js\nimport { bFn } from \"./b.js\";\nexport function aFn() { return \"a\"; }\nexport function callB() { return bFn(); }\n\n// b.js\nimport { aFn } from \"./a.js\";\nexport function bFn() { return \"b+\" + aFn(); }\n\n// main.js\nimport { callB } from \"./a.js\";\nconsole.log(callB());", + "options": [ + { "id": "a", "text": "\"b+a\" —— 因為真正呼叫的時候,兩個檔案都已經完整載入好了" }, + { "id": "b", "text": "會報錯,JS 不准兩個模組互相 import" }, + { "id": "c", "text": "\"undefined\"" }, + { "id": "d", "text": "程式會卡在無窮迴圈裡" } + ], + "answer": "a", + "explanation": "循環依賴(a 引用 b、b 又引用 a)在 ESM 裡不會直接報錯:模組載入器碰到循環時會先讓大家「佔位」,這裡兩邊用的都是 function 宣告——它會被整個掛起來、提前就可以被看見,而且真正呼叫 bFn()/aFn() 是在 main.js 呼叫 callB() 的當下,那時候 a.js、b.js 早就各自完整跑過一輪,所以能順利拿到 \"b+a\"。但如果換成用 const 賦值的箭頭函式、又在模組最上層(不是函式內部)就立刻拿對方的值來用,就會撞到對方還沒初始化的空窗期、變成 undefined 甚至直接報錯——循環依賴能不能安全運作,關鍵在於「真正用到的時候,對方是不是已經跑完了」。", + "verify": { + "checks": [ + { "code": "(async () => {\n const fs = require('node:fs');\n const path = require('node:path');\n const os = require('node:os');\n const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'circ-'));\n fs.writeFileSync(path.join(dir, 'a.mjs'), 'import { bFn } from \"./b.mjs\"; export function aFn() { return \"a\"; } export function callB() { return bFn(); }');\n fs.writeFileSync(path.join(dir, 'b.mjs'), 'import { aFn } from \"./a.mjs\"; export function bFn() { return \"b+\" + aFn(); }');\n const mod = await import('file://' + path.join(dir, 'a.mjs'));\n console.log(mod.callB());\n fs.rmSync(dir, { recursive: true, force: true });\n})();", "expected": "b+a" } + ] + } + }, + { + "id": "jsa-7-q12", + "type": "find-bug", + "difficulty": 2, + "topic": "一個模組只能有一個 export default", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/export", + "story": "", + "prompt": "utils.js 想把兩個函式都設成「預設」,一次全部匯出。哪裡出錯了?", + "code": "// utils.js\nexport default function greet() { return \"hi\"; } // A 行\nexport default function bye() { return \"bye\"; } // B 行 💥", + "options": [ + { "id": "a", "text": "B 行:一個模組最多只能有一個 export default,重複宣告是語法錯誤" }, + { "id": "b", "text": "兩個 default 會自動合併成一個陣列" }, + { "id": "c", "text": "後面那個會安靜蓋掉前面那個,不會報錯" }, + { "id": "d", "text": "沒問題,這是合法的" } + ], + "answer": "a", + "explanation": "export default 是「單一包裹」:一個模組最多只能貼一張預設標籤,寫第二次會直接 SyntaxError,整個檔案跑不起來(不是選項 c 講的「安靜覆蓋」,那種行為只會發生在物件屬性或 Map 的 set 上,模組匯出不是這樣運作的)。真的想一次開放兩樣東西,做法是留一個當 default、其餘的都用具名匯出(export const、export function),或乾脆兩個都用具名匯出。", + "verify": { + "checks": [ + { "code": "(async () => {\n await import(\"data:text/javascript,export default function greet() { return 'hi'; } export default function bye() { return 'bye'; }\");\n})();", "expectError": "Duplicate export of 'default'" }, + { "code": "(async () => {\n const mod = await import(\"data:text/javascript,export default function greet() { return 'hi'; }\");\n console.log(mod.default());\n})();", "expected": "hi" } + ] + } } ] } diff --git a/src/data/questions/jsa-8-iterators-generators.json b/src/data/questions/jsa-8-iterators-generators.json index f1ffc1e..9a4aa40 100644 --- a/src/data/questions/jsa-8-iterators-generators.json +++ b/src/data/questions/jsa-8-iterators-generators.json @@ -141,6 +141,144 @@ { "code": "function* lazy() {\n console.log(\"算 1\");\n yield 1;\n console.log(\"算 2\");\n yield 2;\n}\nconst g = lazy();\nconsole.log(\"開始\");\ng.next();", "expected": "開始\n算 1" } ] } + }, + { + "id": "jsa-8-q7", + "type": "predict-output", + "difficulty": 1, + "topic": "next() 回傳 { value, done } 物件", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Generator/next", + "story": "", + "prompt": "前面幾題我們都只看 .value,這次把 next() 的回傳值整個印出來,長什麼樣子?", + "code": "function* count() {\n yield 1;\n yield 2;\n}\nconst g = count();\nconsole.log(g.next());\nconsole.log(g.next());\nconsole.log(g.next());", + "options": [ + { "id": "a", "text": "{ value: 1, done: false } / { value: 2, done: false } / { value: undefined, done: true }" }, + { "id": "b", "text": "1 / 2 / undefined" }, + { "id": "c", "text": "{ done: false, value: 1 } 但第三次會報錯" }, + { "id": "d", "text": "[1, 2, undefined]" } + ], + "answer": "a", + "explanation": "next() 其實不是只回傳一個值,而是回傳一個 { value, done } 物件:value 是這次交出來的東西,done 告訴你「後面還有沒有下一個」。前兩次都還有 yield 在等,done 是 false;第三次已經沒有 yield 可以執行了,value 變成 undefined、done 才變 true。多次呼叫已經 done 的 generator 不會報錯,只會一直拿到同樣的 { value: undefined, done: true }。", + "verify": { + "checks": [ + { "code": "function* count() {\n yield 1;\n yield 2;\n}\nconst g = count();\nconsole.log(g.next());\nconsole.log(g.next());\nconsole.log(g.next());", "expected": "{ value: 1, done: false }\n{ value: 2, done: false }\n{ value: undefined, done: true }" } + ] + } + }, + { + "id": "jsa-8-q8", + "type": "predict-output", + "difficulty": 3, + "topic": "generator 傳值進去(next 的參數)", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Generator/next", + "story": "", + "prompt": "next() 也可以帶參數,把值「送回去給」正在暫停的 yield。這段印出什麼?", + "code": "function* echo() {\n const x = yield \"準備好了嗎?\";\n console.log(\"收到:\", x);\n}\nconst g = echo();\nconsole.log(g.next().value);\ng.next(\"好了\");", + "options": [ + { "id": "a", "text": "準備好了嗎? 然後 收到: 好了" }, + { "id": "b", "text": "準備好了嗎? 然後 收到: undefined" }, + { "id": "c", "text": "收到: 好了 然後 準備好了嗎?" }, + { "id": "d", "text": "會報錯,next 不能傳參數" } + ], + "answer": "a", + "explanation": "yield 不只是「吐出」值,它本身也是一個表達式,可以「收」下一次 next() 傳進來的參數當作自己的回傳值,賦值給 x。第一次呼叫 g.next() 讓 generator 跑到第一個 yield 就停住並交出 \"準備好了嗎?\",這時候還沒有人回應,所以那次呼叫傳的參數沒有意義;第二次 g.next(\"好了\") 才真正把 \"好了\" 交還給那個暫停中的 yield 表達式。這是 generator 拿來做「雙向溝通」的關鍵機制,比單純的迭代器多了一層互動能力。", + "verify": { + "checks": [ + { "code": "function* echo() {\n const x = yield \"準備好了嗎?\";\n console.log(\"收到:\", x);\n}\nconst g = echo();\nconsole.log(g.next().value);\ng.next(\"好了\");", "expected": "準備好了嗎?\n收到: 好了" } + ] + } + }, + { + "id": "jsa-8-q9", + "type": "fill-in", + "difficulty": 3, + "topic": "Symbol.iterator 自訂可迭代物件", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Iteration_protocols", + "story": "", + "prompt": "不用 generator,手刻一個可以被 for...of/展開語法吃下去的物件。把空格填起來", + "code": "const range = {\n from: 1,\n to: 3,\n [____]() {\n let current = this.from;\n const last = this.to;\n return {\n next() {\n return current <= last\n ? { value: current++, done: false }\n : { value: undefined, done: true };\n },\n };\n },\n};\nconsole.log([...range]);", + "options": [ + { "id": "a", "text": "Symbol.iterator" }, + { "id": "b", "text": "\"iterator\"" }, + { "id": "c", "text": "Symbol.for(\"next\")" }, + { "id": "d", "text": "\"Symbol.iterator\"" } + ], + "answer": "a", + "explanation": "一個物件只要擁有 [Symbol.iterator]() 這個方法、而且這個方法回傳一個帶 next() 的迭代器,它就正式變成「可迭代物」——可以被 for...of、展開語法吃下去。這是 JS 底層真正判斷「這東西能不能被走訪」的依據,陣列、字串、Set、Map、generator 全都是靠實作這個介面才通用的。generator 好用的地方就是它自動幫你把這整套協議寫好了;手刻的話就得像這題一樣自己管理 current 遊標、自己組出 { value, done }。", + "verify": { + "checks": [ + { "code": "const range = {\n from: 1,\n to: 3,\n [Symbol.iterator]() {\n let current = this.from;\n const last = this.to;\n return {\n next() {\n return current <= last\n ? { value: current++, done: false }\n : { value: undefined, done: true };\n },\n };\n },\n};\nconsole.log([...range]);", "expected": "[ 1, 2, 3 ]" } + ] + } + }, + { + "id": "jsa-8-q10", + "type": "predict-output", + "difficulty": 2, + "topic": "無限 generator 搭配 break", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide/Iterators_and_generators", + "story": "", + "prompt": "naturals() 裡面是 while (true),理論上會一直 yield 下去。這段程式印出什麼?", + "code": "function* naturals() {\n let n = 1;\n while (true) {\n yield n++;\n }\n}\nconst result = [];\nfor (const n of naturals()) {\n if (n > 3) break;\n result.push(n);\n}\nconsole.log(result);", + "options": [ + { "id": "a", "text": "[ 1, 2, 3 ]" }, + { "id": "b", "text": "無窮迴圈,程式會卡死" }, + { "id": "c", "text": "[ 1, 2, 3, 4 ]" }, + { "id": "d", "text": "會報錯,generator 不能是無限的" } + ], + "answer": "a", + "explanation": "while (true) 讓這個 generator 理論上可以永遠 yield 下去,但因為 generator 是「懶惰執行」——for...of 每跑一輪才跟它要一個值,要多少算多少——只要迴圈裡有 break,就會在被要求交出第 4 個值「之前」提早喊停,剩下的 while 迴圈永遠不會被執行到。這正是無限 generator 的典型用法:拿來表示一個沒有終點的序列(像自動遞增的 ID),實際要用多少完全由外面的迴圈決定,不會真的卡死。", + "verify": { + "checks": [ + { "code": "function* naturals() {\n let n = 1;\n while (true) {\n yield n++;\n }\n}\nconst result = [];\nfor (const n of naturals()) {\n if (n > 3) break;\n result.push(n);\n}\nconsole.log(result);", "expected": "[ 1, 2, 3 ]" } + ] + } + }, + { + "id": "jsa-8-q11", + "type": "predict-output", + "difficulty": 3, + "topic": "yield* 委派給另一個 generator", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/yield*", + "story": "", + "prompt": "outer 裡面用 yield* 呼叫了 inner()。這段印出什麼?", + "code": "function* inner() {\n yield \"a\";\n yield \"b\";\n}\nfunction* outer() {\n yield 1;\n yield* inner();\n yield 2;\n}\nconsole.log([...outer()]);", + "options": [ + { "id": "a", "text": "[ 1, 'a', 'b', 2 ]" }, + { "id": "b", "text": "[ 1, [Object], 2 ]" }, + { "id": "c", "text": "[ 1, 2 ]" }, + { "id": "d", "text": "會報錯,generator 裡不能呼叫另一個 generator" } + ], + "answer": "a", + "explanation": "yield* 是「委派」:把控制權整個交給另一個可迭代物(這裡是 inner() 產生器),把它吐出來的每一個值原封不動地一個接一個 yield 出去,而不是把 inner() 整個物件當成單一一個值 yield 出去(那才是選項 b 會發生的事,如果漏寫了那顆星號、只寫成普通的 yield inner())。yield* 常用來把幾個小的 generator 拼裝成一個大的 generator,概念上很像函式呼叫函式,只是換成「產生器呼叫產生器」。", + "verify": { + "checks": [ + { "code": "function* inner() {\n yield \"a\";\n yield \"b\";\n}\nfunction* outer() {\n yield 1;\n yield* inner();\n yield 2;\n}\nconsole.log([...outer()]);", "expected": "[ 1, 'a', 'b', 2 ]" } + ] + } + }, + { + "id": "jsa-8-q12", + "type": "same-or-not", + "difficulty": 2, + "topic": "迭代耗盡後再呼叫 next()", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Generator/next", + "story": "", + "prompt": "同一個 generator 物件已經被展開語法吃到底了。這時候再手動呼叫一次 next(),跟拿一個全新的 generator 呼叫第一次,兩者一樣嗎?", + "code": "function* g() {\n yield 1;\n}\nconst it = g();\nconsole.log([...it]); // 版本一:先展開耗盡\nconsole.log(it.next());\n\nconst it2 = g();\nconsole.log(it2.next()); // 版本二:全新的 generator 呼叫第一次", + "options": [ + { "id": "a", "text": "不一樣:耗盡後的 next() 回傳 { value: undefined, done: true },跟全新的 { value: 1, done: false } 完全不同" }, + { "id": "b", "text": "一樣,都回傳 { value: 1, done: false }" }, + { "id": "c", "text": "耗盡後再呼叫 next() 會報錯" }, + { "id": "d", "text": "一樣,兩個都是 undefined" } + ], + "answer": "a", + "explanation": "generator 是「用過即丟」的一次性迭代器:一旦被展開語法(或任何方式)真正走到底,它就會永遠停在 done: true 的狀態,之後不管再呼叫幾次 next() 都只會拿到 { value: undefined, done: true },不會自動重頭來過。想要再跑一輪,必須重新呼叫產生器函式 g() 拿一個全新的迭代器——這也是為什麼同一個 generator 物件不能被兩個 for...of 迴圈各自從頭吃一遍:先跑的那個會把它吃光,第二個只會拿到空的。", + "verify": { + "checks": [ + { "code": "function* g() {\n yield 1;\n}\nconst it = g();\nconsole.log([...it]);\nconsole.log(it.next());\nconst it2 = g();\nconsole.log(it2.next());", "expected": "[ 1 ]\n{ value: undefined, done: true }\n{ value: 1, done: false }" } + ] + } } ] } diff --git a/src/data/questions/jsa-9-promises-advanced.json b/src/data/questions/jsa-9-promises-advanced.json index b50b0b2..64d8a42 100644 --- a/src/data/questions/jsa-9-promises-advanced.json +++ b/src/data/questions/jsa-9-promises-advanced.json @@ -142,6 +142,145 @@ { "code": "Promise.reject(new Error(\"斷線\"))\n .catch(() => \"改用快取\")\n .then((res) => console.log(\"結果:\" + res));", "expected": "結果:改用快取" } ] } + }, + { + "id": "jsa-9-q7", + "type": "predict-output", + "difficulty": 3, + "topic": "Promise.any:第一個成功的就贏", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise/any", + "story": "", + "prompt": "第一個備援 API 很快就失敗,第二個晚一點才成功。用 Promise.any 等它們,中途的失敗會被算數嗎?", + "code": "const bad = new Promise((_, reject) =>\n setTimeout(() => reject(new Error(\"A 掛了\")), 10)\n);\nconst ok = new Promise((r) => setTimeout(() => r(\"B 成功\"), 50));\n\nPromise.any([bad, ok]).then((res) => console.log(res));", + "options": [ + { "id": "a", "text": "B 成功 —— any 只在乎第一個「成功」的,中途的失敗會被安靜跳過" }, + { "id": "b", "text": "拋出錯誤,因為 A 先失敗" }, + { "id": "c", "text": "跟 Promise.race 一樣,誰先有結果就聽誰的,所以印出 A 掛了" }, + { "id": "d", "text": "undefined,因為第一個進去的是失敗的那個" } + ], + "answer": "a", + "explanation": "any 賭的是「誰先成功」:A 在 10ms 失敗只會被記下來,不影響大局,等到 50ms 後 B 成功,any 才定案。跟上一關的 race 不同——race 不管成功失敗,誰先有結果就聽誰的,A 先 reject 的話 race 會直接失敗。any 適合「打好幾個備援來源,只要有一個活的就好」的情境,例如同時打好幾個 CDN 節點。", + "verify": { + "checks": [ + { "code": "const bad = new Promise((_, reject) =>\n setTimeout(() => reject(new Error(\"A 掛了\")), 10)\n);\nconst ok = new Promise((r) => setTimeout(() => r(\"B 成功\"), 50));\nPromise.any([bad, ok]).then((res) => console.log(res));", "expected": "B 成功" } + ] + } + }, + { + "id": "jsa-9-q8", + "type": "predict-output", + "difficulty": 2, + "topic": "then 鏈式回傳新值,改變後面拿到的資料", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise/then", + "story": "", + "prompt": "每一節 then 都把值加工後回傳。最後一個 then 拿到的是原始的 2,還是被前面加工過的值?", + "code": "Promise.resolve(2)\n .then((n) => n * 10)\n .then((n) => n + 1)\n .then((n) => console.log(n));", + "options": [ + { "id": "a", "text": "21 —— 每個 then 拿到的都是上一個 then 的回傳值,一路傳下去" }, + { "id": "b", "text": "2 —— then 只是觀察用,不會真的改變傳遞的值" }, + { "id": "c", "text": "3 —— 每個 then 各自獨立算,跟最初傳進來的 2 加 1" }, + { "id": "d", "text": "報錯,then 不能回傳數字" } + ], + "answer": "a", + "explanation": "then 回傳的其實是一個「新的 Promise」,這個新 Promise 會用你 return 的值去 resolve;下一個 then 接手的是這個新值,不是最初的 2。像水管一節接一節,每節都能加工:2 → *10 → 20 → +1 → 21。這正是「資料處理管線」的寫法,一步步把原始資料轉成你要的形狀。", + "verify": { + "checks": [ + { "code": "Promise.resolve(2)\n .then((n) => n * 10)\n .then((n) => n + 1)\n .then((n) => console.log(n));", "expected": "21" } + ] + } + }, + { + "id": "jsa-9-q9", + "type": "fill-in", + "difficulty": 2, + "topic": "async function 裡 await Promise.all 搭配陣列解構", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise/all", + "story": "", + "prompt": "把空格填起來,讓 user 和 order 各自拿到 Promise.all 展開後的值,印出「小美 訂單A」", + "code": "async function loadPage() {\n const fetchUser = Promise.resolve(\"小美\");\n const fetchOrder = Promise.resolve(\"訂單A\");\n\n ____ = await Promise.all([fetchUser, fetchOrder]);\n console.log(user, order);\n}\nloadPage();", + "options": [ + { "id": "a", "text": "const [user, order]" }, + { "id": "b", "text": "const { user, order }" }, + { "id": "c", "text": "const user, order" }, + { "id": "d", "text": "let user = order" } + ], + "answer": "a", + "explanation": "await Promise.all([...]) 拿到手的是一個「陣列」,所以要用陣列解構 const [user, order] 依序命名。選項 b 的物件解構找的是 user、order 這兩把鍵,但陣列根本沒有這種鍵名,兩個都會是 undefined。await 搭配陣列解構是同時打多支 API、各自命名結果的標準寫法。", + "verify": { + "checks": [ + { "code": "async function loadPage() {\n const fetchUser = Promise.resolve(\"小美\");\n const fetchOrder = Promise.resolve(\"訂單A\");\n const [user, order] = await Promise.all([fetchUser, fetchOrder]);\n console.log(user, order);\n}\nloadPage();", "expected": "小美 訂單A" } + ] + } + }, + { + "id": "jsa-9-q10", + "type": "predict-output", + "difficulty": 2, + "topic": "Promise.all 空陣列,立刻 resolve 成 []", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise/all", + "story": "", + "prompt": "all 裡面什麼都沒放,會一直卡住等待嗎?這兩行印出的順序是?", + "code": "Promise.all([]).then((res) => console.log(res));\nconsole.log(\"同步先跑\");", + "options": [ + { "id": "a", "text": "先印 同步先跑,再印 [ ] —— 空陣列沒東西要等,all 立刻進入完成狀態,但 then 仍要排隊到微任務" }, + { "id": "b", "text": "先印 [ ],再印 同步先跑" }, + { "id": "c", "text": "只印 同步先跑,all 會永遠等下去" }, + { "id": "d", "text": "報錯,Promise.all 不能傳空陣列" } + ], + "answer": "a", + "explanation": "空陣列裡沒有任何成員要等,Promise.all([]) 會立刻進入「已解決」狀態,結果是空陣列 []。不過 then 裡的 callback 仍然是微任務,一定要等目前這輪同步程式碼跑完才輪得到它執行,所以「同步先跑」還是搶先印出來。這也順便複習了微任務排隊的規則:resolve 得再快,also 得排隊。", + "verify": { + "checks": [ + { "code": "Promise.all([]).then((res) => console.log(res));\nconsole.log(\"同步先跑\");", "expected": "同步先跑\n[]" } + ] + } + }, + { + "id": "jsa-9-q11", + "type": "same-or-not", + "difficulty": 2, + "topic": "finally:不論成功失敗都會執行,不改變結果", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise/finally", + "story": "", + "prompt": "AI 想用 finally 關閉 loading 動畫。成功和失敗兩種情況,finally 都會被執行、而且不影響原本的結果嗎?", + "code": "Promise.resolve(\"成功資料\")\n .finally(() => console.log(\"關閉 loading\"))\n .then((res) => console.log(\"結果:\" + res));\n\nPromise.reject(new Error(\"失敗了\"))\n .finally(() => console.log(\"關閉 loading\"))\n .catch((err) => console.log(\"結果:\" + err.message));", + "options": [ + { "id": "a", "text": "一樣:兩種情況 finally 都會印出「關閉 loading」,後面的 then/catch 依然拿到原本的結果" }, + { "id": "b", "text": "不一樣:失敗的那條鏈,finally 不會執行" }, + { "id": "c", "text": "不一樣:finally 執行後,結果會被換成 undefined" }, + { "id": "d", "text": "不一樣:finally 只能接在成功的鏈上,接在 reject 後面會報錯" } + ], + "answer": "a", + "explanation": "finally 不管成功失敗都會跑一次,而且它「不插手」結果——原本該成功的鏈繼續往 then 走、該失敗的鏈繼續往 catch 走,值原封不動地穿過 finally。這正適合關 loading、清 timer 這種「不管結果如何都要做一次」的收尾動作,不用在 then 跟 catch 裡各寫一遍。", + "verify": { + "checks": [ + { "code": "Promise.resolve(\"成功資料\")\n .finally(() => console.log(\"關閉 loading\"))\n .then((res) => console.log(\"結果:\" + res));", "expected": "關閉 loading\n結果:成功資料" }, + { "code": "Promise.reject(new Error(\"失敗了\"))\n .finally(() => console.log(\"關閉 loading\"))\n .catch((err) => console.log(\"結果:\" + err.message));", "expected": "關閉 loading\n結果:失敗了" } + ] + } + }, + { + "id": "jsa-9-q12", + "type": "find-bug", + "difficulty": 3, + "topic": "Promise.resolve 包一個 Promise 不會巢狀,會自動攤平", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise/resolve", + "story": "", + "prompt": "AI 以為 Promise.resolve(inner) 會做出「一個裝著 Promise 的 Promise」,實測卻不是。哪裡想錯了?", + "code": "const inner = Promise.resolve(5);\nconst wrapped = Promise.resolve(inner);\n\nwrapped.then((val) => console.log(val));", + "options": [ + { "id": "a", "text": "Promise.resolve 傳入的東西若本身已經是 Promise,會直接攤平沿用它的結果,不會再包一層;所以 val 拿到的是 5,不是另一個 Promise" }, + { "id": "b", "text": "要 await 兩次才能拿到 5" }, + { "id": "c", "text": "val 會是一個裝著 5 的陣列" }, + { "id": "d", "text": "會報錯,Promise.resolve 不能傳 Promise 進去" } + ], + "answer": "a", + "explanation": "Promise.resolve 有個貼心規則:如果丟進去的東西本身已經是 thenable/Promise,它不會再包一層,而是直接沿用那個 Promise 最終的結果。所以 wrapped 骨子裡就是 inner,.then 拿到的是攤平後的 5,不是巢狀的 Promise。這也是為什麼 JS 世界幾乎看不到「Promise 疊 Promise」的窘境。", + "verify": { + "checks": [ + { "code": "const inner = Promise.resolve(5);\nconst wrapped = Promise.resolve(inner);\nwrapped.then((val) => console.log(val));", "expected": "5" } + ] + } } ] } diff --git a/src/data/questions/jsb-1-variables.json b/src/data/questions/jsb-1-variables.json index e10cf88..e1b90f8 100644 --- a/src/data/questions/jsb-1-variables.json +++ b/src/data/questions/jsb-1-variables.json @@ -141,6 +141,146 @@ { "code": "const name = \"小美\";\nconst msg = `哈囉,${name}!`;\nconsole.log(msg);", "expected": "哈囉,小美!" } ] } + }, + { + "id": "jsb-1-q7", + "type": "predict-output", + "difficulty": 1, + "topic": "const 物件的內容可以改", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/const", + "story": "", + "prompt": "const 宣告的物件,改它的屬性。這段印出什麼?", + "code": "const user = { name: \"小美\" };\nuser.name = \"小華\";\nconsole.log(user.name);", + "options": [ + { "id": "a", "text": "小華" }, + { "id": "b", "text": "小美" }, + { "id": "c", "text": "會報錯,const 的東西不能改" }, + { "id": "d", "text": "undefined" } + ], + "answer": "a", + "explanation": "const 鎖的是「user 這個名字不能改指別的物件」,物件裡面的內容隨便改——跟 const 陣列可以 push 是同一條規則。想連內容都鎖住要用 Object.freeze。「const 就是常數、完全不能動」是最常見的誤解之一,現在破除了。", + "verify": { + "checks": [ + { "code": "const user = { name: \"小美\" };\nuser.name = \"小華\";\nconsole.log(user.name);", "expected": "小華" } + ] + } + }, + { + "id": "jsb-1-q8", + "type": "predict-output", + "difficulty": 2, + "topic": "undefined 與 null 的異同", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/null", + "story": "", + "prompt": "一個沒賦值、一個刻意設成 null。這段印出什麼?", + "code": "let a;\nconst b = null;\nconsole.log(a, b, a == b, a === b);", + "options": [ + { "id": "a", "text": "undefined null true false" }, + { "id": "b", "text": "null null true true" }, + { "id": "c", "text": "undefined null false false" }, + { "id": "d", "text": "undefined undefined true true" } + ], + "answer": "a", + "explanation": "宣告沒賦值是 undefined(系統的「還沒有」),null 是人為放的「刻意為空」。這對兄弟用 == 比是 true(規格特別放行),用 === 比是 false(型別不同)。實務判斷「是不是空」常寫 value == null,一次涵蓋兩者——這是 == 少數的正當用途。", + "verify": { + "checks": [ + { "code": "let a;\nconst b = null;\nconsole.log(a, b, a == b, a === b);", "expected": "undefined null true false" } + ] + } + }, + { + "id": "jsb-1-q9", + "type": "find-bug", + "difficulty": 2, + "topic": "輸入框拿到的都是字串", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Number", + "story": "", + "prompt": "把兩個輸入框的數字相加,5 加 10 卻得到 510。哪裡錯了?", + "code": "const x = \"5\"; // 從輸入框拿到的\nconst y = \"10\"; // 從輸入框拿到的\nconsole.log(x + y); // 510 ?? 預期 15", + "options": [ + { "id": "a", "text": "輸入框的值永遠是字串,+ 變成黏字串;要先 Number(x) + Number(y)" }, + { "id": "b", "text": "x 和 y 的順序寫反了" }, + { "id": "c", "text": "console.log 不支援加法" }, + { "id": "d", "text": "要改用 x ++ y" } + ], + "answer": "a", + "explanation": "表單欄位、URL 參數、localStorage——這些來源給你的永遠是字串,就算看起來像數字。\"5\" + \"10\" 黏成 \"510\"。進門先轉型(Number 或 parseInt),再開始算數學,是處理外部輸入的固定儀式。這個 bug 在真實世界的出現率高到值得考兩次。", + "verify": { + "checks": [ + { "code": "const x = \"5\";\nconst y = \"10\";\nconsole.log(x + y);", "expected": "510" }, + { "code": "const x = \"5\";\nconst y = \"10\";\nconsole.log(Number(x) + Number(y));", "expected": "15" } + ] + } + }, + { + "id": "jsb-1-q10", + "type": "same-or-not", + "difficulty": 2, + "topic": "length > 0 與直接當條件", + "docs": "https://developer.mozilla.org/zh-TW/docs/Glossary/Truthy", + "story": "", + "prompt": "判斷陣列有沒有東西。這兩種寫法行為一樣嗎?", + "code": "const list = [\"a\"];\nconst empty = [];\n\n// 版本一\nif (list.length > 0) console.log(\"有料\");\nif (empty.length > 0) console.log(\"有料2\");\n\n// 版本二\nif (list.length) console.log(\"有料\");\nif (empty.length) console.log(\"有料2\");", + "options": [ + { "id": "a", "text": "一樣:length 是 0 就是 falsy,大於 0 就是 truthy" }, + { "id": "b", "text": "不一樣:版本二空陣列也會印「有料2」" }, + { "id": "c", "text": "不一樣:版本二會報錯" }, + { "id": "d", "text": "不一樣:版本一比較快十倍" } + ], + "answer": "a", + "explanation": "length 是數字:0 是 falsy、1 以上是 truthy,所以兩種寫法等價,第二種是常見的簡寫。但注意前提是「拿 length 來判斷」——直接 if (empty) 判斷陣列本身就錯了,空陣列是 truthy(jsb-5 考過)。判斷有沒有資料,永遠問 length。", + "verify": { + "checks": [ + { "code": "const list = [\"a\"];\nconst empty = [];\nif (list.length > 0) console.log(\"有料\");\nif (empty.length > 0) console.log(\"有料2\");\nif (list.length) console.log(\"有料\");\nif (empty.length) console.log(\"有料2\");", "expected": "有料\n有料" } + ] + } + }, + { + "id": "jsb-1-q11", + "type": "fill-in", + "difficulty": 3, + "topic": "?? 讓 0 保住預設值機制", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing", + "story": "", + "prompt": "折扣可以是 0(合法值),沒設定才用 100。把空格填起來,印出 0", + "code": "const input = 0;\nconst value = input ____ 100;\nconsole.log(value);", + "options": [ + { "id": "a", "text": "??" }, + { "id": "b", "text": "||" }, + { "id": "c", "text": "&&" }, + { "id": "d", "text": "+" } + ], + "answer": "a", + "explanation": "?? 只在左邊是 null 或 undefined 時才用右邊——0 是合法值,保留。|| 會把 0 當 falsy 換成 100,語意就錯了;&& 遇到 0 直接回 0 看似對,但 input 有值時會回 100,整個顛倒。「值可能是 0 或空字串」的預設值,一律 ??。", + "verify": { + "checks": [ + { "code": "const input = 0;\nconst value = input ?? 100;\nconsole.log(value);", "expected": "0" }, + { "code": "const input = 0;\nconst value = input || 100;\nconsole.log(value);", "expected": "100" } + ] + } + }, + { + "id": "jsb-1-q12", + "type": "predict-output", + "difficulty": 3, + "topic": "NaN 不等於任何人,包括自己", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/NaN", + "story": "", + "prompt": "轉型失敗得到 NaN。用 === 和 Number.isNaN 檢查它,各印出什麼?", + "code": "const n = Number(\"abc\");\nconsole.log(n === NaN);\nconsole.log(Number.isNaN(n));", + "options": [ + { "id": "a", "text": "false 和 true" }, + { "id": "b", "text": "true 和 true" }, + { "id": "c", "text": "false 和 false" }, + { "id": "d", "text": "true 和 false" } + ], + "answer": "a", + "explanation": "NaN 是全 JavaScript 最孤僻的值:它不等於任何東西,連自己都不等於——n === NaN 永遠 false,所以「用等號檢查 NaN」是個永遠不會成立的 bug。正解是 Number.isNaN()。看到 if (x === NaN) 這種程式碼(AI 也會寫出來),你現在能一眼判死刑。", + "verify": { + "checks": [ + { "code": "const n = Number(\"abc\");\nconsole.log(n === NaN);\nconsole.log(Number.isNaN(n));", "expected": "false\ntrue" } + ] + } } ] } diff --git a/src/data/questions/jsb-2-functions.json b/src/data/questions/jsb-2-functions.json index d84e682..a63cfeb 100644 --- a/src/data/questions/jsb-2-functions.json +++ b/src/data/questions/jsb-2-functions.json @@ -141,6 +141,146 @@ { "code": "function total(items) {\n let sum = 0;\n for (const item of items) {\n sum += item.price;\n }\n return sum;\n}\nconsole.log(total([{ price: 10 }, { price: 20 }]));", "expected": "30" } ] } + }, + { + "id": "jsb-2-q7", + "type": "predict-output", + "difficulty": 1, + "topic": "少給的參數是 undefined", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide/Functions", + "story": "", + "prompt": "函式要兩個參數,只給了一個。這段印出什麼?", + "code": "function greet(a, b) {\n console.log(a, b);\n}\ngreet(\"嗨\");", + "options": [ + { "id": "a", "text": "嗨 undefined" }, + { "id": "b", "text": "會報錯,參數數量不符" }, + { "id": "c", "text": "嗨 嗨" }, + { "id": "d", "text": "嗨 null" } + ], + "answer": "a", + "explanation": "JavaScript 對參數數量超級寬容:少給的補 undefined、多給的默默忽略,一律不報錯。這是自由也是隱患——參數少傳了不會當場爆炸,而是讓 undefined 流進程式深處才發作。看到函式輸出有 undefined,回頭數一下呼叫時給了幾個參數。", + "verify": { + "checks": [ + { "code": "function greet(a, b) {\n console.log(a, b);\n}\ngreet(\"嗨\");", "expected": "嗨 undefined" } + ] + } + }, + { + "id": "jsb-2-q8", + "type": "predict-output", + "difficulty": 2, + "topic": "函式宣告會提升,函式表達式不會", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide/Functions", + "story": "", + "prompt": "兩個函式都在「定義之前」被呼叫。各自的下場是?", + "code": "sayHi();\nfunction sayHi() {\n console.log(\"嗨\");\n}\n\nsayBye(); // 💥 ?\nconst sayBye = () => console.log(\"掰\");", + "options": [ + { "id": "a", "text": "sayHi 正常印出嗨;sayBye 報錯(宣告前不能使用)" }, + { "id": "b", "text": "兩個都正常執行" }, + { "id": "c", "text": "兩個都報錯" }, + { "id": "d", "text": "sayHi 報錯;sayBye 正常" } + ], + "answer": "a", + "explanation": "function 宣告會被「整個提升」到頂端,所以先呼叫後定義沒問題。但 const sayBye = 箭頭函式是「變數賦值」,走 let/const 的暫時死區規則(jsb-1 的 hoisting 題),提前用直接報錯。讀舊專案會看到兩種寫法混用,知道這個差異就不會誤判「為什麼這個能提前呼叫那個不能」。", + "verify": { + "checks": [ + { "code": "sayHi();\nfunction sayHi() {\n console.log(\"嗨\");\n}", "expected": "嗨" }, + { "code": "sayBye();\nconst sayBye = () => console.log(\"掰\");", "expectError": "Cannot access 'sayBye' before initialization" } + ] + } + }, + { + "id": "jsb-2-q9", + "type": "find-bug", + "difficulty": 2, + "topic": "少了括號=拿到函式本人", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide/Functions", + "story": "", + "prompt": "想拿價格,畫面卻印出一坨函式原始碼。哪裡錯了?", + "code": "function getPrice() {\n return 100;\n}\n\nconst price = getPrice; // B 行\nconsole.log(typeof price); // function ??", + "options": [ + { "id": "a", "text": "B 行少了 ():getPrice 是函式本人,getPrice() 才是執行後的結果 100" }, + { "id": "b", "text": "return 100 要寫成 return \"100\"" }, + { "id": "c", "text": "const 不能存函式的回傳值" }, + { "id": "d", "text": "typeof 寫錯位置" } + ], + "answer": "a", + "explanation": "函式名字不加括號=「這個函式本身」(一個可以傳來傳去的值),加了括號才是「執行它、拿結果」。畫面上出現函式原始碼或 [Function],就是少了括號。反過來,React 那關學過 onClick={handleClick} 刻意不加括號——因為那裡要傳的就是函式本人。一體兩面,看場合。", + "verify": { + "checks": [ + { "code": "function getPrice() {\n return 100;\n}\nconst price = getPrice;\nconsole.log(typeof price);", "expected": "function" }, + { "code": "function getPrice() {\n return 100;\n}\nconst price = getPrice();\nconsole.log(typeof price, price);", "expected": "number 100" } + ] + } + }, + { + "id": "jsb-2-q10", + "type": "same-or-not", + "difficulty": 3, + "topic": "傳數字是複本、傳物件是本尊", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide/Functions", + "story": "", + "prompt": "兩個函式都在裡面改參數。外面的資料一樣安全嗎?", + "code": "// 版本一:改數字參數\nfunction addTen(n) {\n n += 10;\n}\nlet score = 5;\naddTen(score);\nconsole.log(score);\n\n// 版本二:改物件參數\nfunction levelUp(p) {\n p.level += 1;\n}\nconst pet = { level: 1 };\nlevelUp(pet);\nconsole.log(pet.level);", + "options": [ + { "id": "a", "text": "不一樣:score 還是 5(傳進去的是複本),pet.level 變 2(傳進去的是同一個物件)" }, + { "id": "b", "text": "一樣,兩個都被改了" }, + { "id": "c", "text": "一樣,兩個都沒被改" }, + { "id": "d", "text": "版本二會報錯,函式不能改物件" } + ], + "answer": "a", + "explanation": "數字、字串、布林傳進函式時複印一份,裡面怎麼改都不干外面的事;物件和陣列傳的是「同一個東西的門牌」,函式裡一改,外面就變了——jsb-4 的參照觀念在函式參數上重演。「函式呼叫完,我的資料被改了」的悲劇,兇手永遠是物件參數。", + "verify": { + "checks": [ + { "code": "function addTen(n) {\n n += 10;\n}\nlet score = 5;\naddTen(score);\nconsole.log(score);\nfunction levelUp(p) {\n p.level += 1;\n}\nconst pet = { level: 1 };\nlevelUp(pet);\nconsole.log(pet.level);", "expected": "5\n2" } + ] + } + }, + { + "id": "jsb-2-q11", + "type": "fill-in", + "difficulty": 2, + "topic": "其餘參數:收下不定數量的參數", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Functions/rest_parameters", + "story": "", + "prompt": "sum 要能吃任意數量的數字。把空格填起來,印出 6", + "code": "function sum(____) {\n return nums.reduce((s, n) => s + n, 0);\n}\nconsole.log(sum(1, 2, 3));", + "options": [ + { "id": "a", "text": "...nums" }, + { "id": "b", "text": "nums" }, + { "id": "c", "text": "[nums]" }, + { "id": "d", "text": "nums[]" } + ], + "answer": "a", + "explanation": "參數前加三個點是「其餘參數」(rest):把所有傳進來的引數收進一個真正的陣列,直接用 reduce 等陣列方法。選項 b 只接得到第一個引數 1(數字沒有 reduce,報錯);選項 d 是語法錯誤。注意方向:在參數定義處 ... 是「收攏」,在呼叫處 Math.max(...arr) 是「攤開」——同一符號、相反方向。", + "verify": { + "checks": [ + { "code": "function sum(...nums) {\n return nums.reduce((s, n) => s + n, 0);\n}\nconsole.log(sum(1, 2, 3));", "expected": "6" } + ] + } + }, + { + "id": "jsb-2-q12", + "type": "predict-output", + "difficulty": 3, + "topic": "預設參數每次呼叫都是新的", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Functions/Default_parameters", + "story": "", + "prompt": "預設參數是空陣列,函式往裡面 push。連呼叫兩次,各印出多少?", + "code": "function addItem(list = []) {\n list.push(\"x\");\n return list.length;\n}\n\nconsole.log(addItem());\nconsole.log(addItem());", + "options": [ + { "id": "a", "text": "1 和 1 —— 每次呼叫都拿到全新的空陣列" }, + { "id": "b", "text": "1 和 2 —— 預設陣列被兩次呼叫共用" }, + { "id": "c", "text": "會報錯,預設值不能是陣列" }, + { "id": "d", "text": "0 和 0" } + ], + "answer": "a", + "explanation": "JavaScript 的預設參數在「每次呼叫時」重新求值——每次都生一個新的空陣列,互不沾染。特別點名這題是因為 Python 的行為相反(預設可變參數會共用,是 Python 名坑)——從別的語言轉來或讓 AI 跨語言翻譯程式時,這種「長得一樣、行為不同」的地方最值得警惕。", + "verify": { + "checks": [ + { "code": "function addItem(list = []) {\n list.push(\"x\");\n return list.length;\n}\nconsole.log(addItem());\nconsole.log(addItem());", "expected": "1\n1" } + ] + } } ] } diff --git a/src/data/questions/jsb-3-arrays.json b/src/data/questions/jsb-3-arrays.json index f9a1d11..ee781fd 100644 --- a/src/data/questions/jsb-3-arrays.json +++ b/src/data/questions/jsb-3-arrays.json @@ -141,6 +141,145 @@ { "code": "const nums = [25, 100, 9];\nnums.sort((a, b) => a - b);\nconsole.log(nums);", "expected": "[ 9, 25, 100 ]" } ] } + }, + { + "id": "jsb-3-q7", + "type": "predict-output", + "difficulty": 2, + "topic": "reduce 累加陣列", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce", + "story": "", + "prompt": "計算購物車總金額。這段印出什麼?", + "code": "const prices = [120, 45, 30];\nconst total = prices.reduce((sum, p) => sum + p, 0);\nconsole.log(total);", + "options": [ + { "id": "a", "text": "195" }, + { "id": "b", "text": "0" }, + { "id": "c", "text": "[ 120, 45, 30 ]" }, + { "id": "d", "text": "NaN" } + ], + "answer": "a", + "explanation": "reduce 從初始值 0 開始,把每個元素一個一個累加進 sum,跑完回傳最終的總和 195。第二個參數(這裡的 0)是起始值,忘記寫的話 reduce 會拿陣列第一個元素當起始值,遇到空陣列還會直接報錯——養成寫初始值的習慣比較安全。", + "verify": { + "checks": [ + { "code": "const prices = [120, 45, 30];\nconst total = prices.reduce((sum, p) => sum + p, 0);\nconsole.log(total);", "expected": "195" } + ] + } + }, + { + "id": "jsb-3-q8", + "type": "predict-output", + "difficulty": 2, + "topic": "map 與 filter 鏈式呼叫", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Array/map", + "story": "", + "prompt": "先把每個價格加 20 元運費,再只留下超過 100 元的。這段印出什麼?", + "code": "const prices = [80, 150, 200];\nconst result = prices.map((p) => p + 20).filter((p) => p > 100);\nconsole.log(result);", + "options": [ + { "id": "a", "text": "[ 170, 220 ]" }, + { "id": "b", "text": "[ 100, 170, 220 ]" }, + { "id": "c", "text": "[ 80, 150, 200 ]" }, + { "id": "d", "text": "會報錯,陣列方法不能連續呼叫" } + ], + "answer": "a", + "explanation": "map 和 filter 都回傳新陣列,所以可以像水管一樣接下去:先整批做完 map([100, 170, 220]),才把結果交給 filter 再篩一次(>100 留下 170、220,剛好 100 那個不算)。鏈式呼叫是陣列處理最常見的寫法,讀的時候要分階段看,不要想一步到位。", + "verify": { + "checks": [ + { "code": "const prices = [80, 150, 200];\nconst result = prices.map((p) => p + 20).filter((p) => p > 100);\nconsole.log(result);", "expected": "[ 170, 220 ]" } + ] + } + }, + { + "id": "jsb-3-q9", + "type": "predict-output", + "difficulty": 2, + "topic": "some 與 every 的差別", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Array/some", + "story": "", + "prompt": "檢查全班有沒有人及格、是不是全部都及格。這段印出什麼?", + "code": "const scores = [55, 80, 92];\nconsole.log(scores.some((s) => s >= 60));\nconsole.log(scores.every((s) => s >= 60));", + "options": [ + { "id": "a", "text": "true 然後 false" }, + { "id": "b", "text": "false 然後 true" }, + { "id": "c", "text": "true 然後 true" }, + { "id": "d", "text": "false 然後 false" } + ], + "answer": "a", + "explanation": "some 只要「有一個」符合條件就回傳 true——80 和 92 及格了,55 不合格沒關係,所以是 true。every 要求「全部都要」符合,55 扯了後腿,直接變 false。判斷「至少一個」用 some,判斷「全部都」用 every,兩者常用在表單驗證:至少填一項用 some,全部欄位都填了用 every。", + "verify": { + "checks": [ + { "code": "const scores = [55, 80, 92];\nconsole.log(scores.some((s) => s >= 60));\nconsole.log(scores.every((s) => s >= 60));", "expected": "true\nfalse" } + ] + } + }, + { + "id": "jsb-3-q10", + "type": "find-bug", + "difficulty": 2, + "topic": "reverse 會改原陣列", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Array/reverse", + "story": "", + "prompt": "只想倒著顯示一份清單,結果原始清單也被打亂了。哪裡出了問題?", + "code": "const list = [\"a\", \"b\", \"c\"];\nconst shown = list.reverse();\nconsole.log(list);\nconsole.log(shown);", + "options": [ + { "id": "a", "text": "reverse 會直接改動原陣列,不是回傳新陣列;想保留原陣列該用 [...list].reverse()" }, + { "id": "b", "text": "reverse 只能用在字串陣列" }, + { "id": "c", "text": "console.log 印錯變數了" }, + { "id": "d", "text": "沒有 bug,這樣本來就對" } + ], + "answer": "a", + "explanation": "reverse 跟 sort、splice 是同一國的:都會直接動原陣列(原地反轉),回傳值只是同一個陣列的參照而已,所以 list 和 shown 印出來一模一樣,而且 list 本身也真的被改了。想要「新陣列」的倒序版本,得先展開語法複製一份再反轉:[...list].reverse()。這題延續 jsb-3-q4 slice/splice 的教訓:認出哪些陣列方法會動到原資料,是必修課。", + "verify": { + "checks": [ + { "code": "const list = [\"a\", \"b\", \"c\"];\nconst shown = list.reverse();\nconsole.log(list);\nconsole.log(shown);", "expected": "[ 'c', 'b', 'a' ]\n[ 'c', 'b', 'a' ]" }, + { "code": "const list = [\"a\", \"b\", \"c\"];\nconst shown = [...list].reverse();\nconsole.log(list);\nconsole.log(shown);", "expected": "[ 'a', 'b', 'c' ]\n[ 'c', 'b', 'a' ]" } + ] + } + }, + { + "id": "jsb-3-q11", + "type": "same-or-not", + "difficulty": 1, + "topic": "concat 與展開語法合併陣列", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Array/concat", + "story": "", + "prompt": "合併兩個陣列有兩種寫法。這兩段印出的東西一樣嗎?原陣列會被動到嗎?", + "code": "const a = [1, 2];\nconst b = [3, 4];\n\n// 版本一\nconst merged1 = a.concat(b);\n\n// 版本二\nconst merged2 = [...a, ...b];\n\nconsole.log(merged1, merged2, a);", + "options": [ + { "id": "a", "text": "一樣:都是 [1,2,3,4],a 也都沒被改動 [1,2]" }, + { "id": "b", "text": "不一樣:concat 會改動 a" }, + { "id": "c", "text": "不一樣:展開語法會改動 a" }, + { "id": "d", "text": "不一樣:merged1 是 [1,2,3,4],merged2 是 [3,4,1,2]" } + ], + "answer": "a", + "explanation": "concat 和展開語法都是「複製再合併」,不會動到原陣列,結果也一樣是 [1,2,3,4]。兩者是同一件事的兩種寫法,展開語法是 ES6 之後更常見的選擇,也更彈性(可以在陣列裡夾雜其他值,不限於整包陣列)。跟 q10 的 reverse 相反,這兩個方法都很安全,不會偷改原資料。", + "verify": { + "checks": [ + { "code": "const a = [1, 2];\nconst b = [3, 4];\nconst merged1 = a.concat(b);\nconst merged2 = [...a, ...b];\nconsole.log(merged1, merged2, a);", "expected": "[ 1, 2, 3, 4 ] [ 1, 2, 3, 4 ] [ 1, 2 ]" } + ] + } + }, + { + "id": "jsb-3-q12", + "type": "fill-in", + "difficulty": 3, + "topic": "flat 打平巢狀陣列", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Array/flat", + "story": "", + "prompt": "把巢狀陣列打平成一層,印出 [ 1, 2, 3, 4 ]。把空格填起來", + "code": "const nested = [1, [2, 3], [4]];\nconst flat = nested.____();\nconsole.log(flat);", + "options": [ + { "id": "a", "text": "flat" }, + { "id": "b", "text": "flatten" }, + { "id": "c", "text": "flatMap" }, + { "id": "d", "text": "join" } + ], + "answer": "a", + "explanation": "flat() 把巢狀陣列「拆一層」攤平,預設深度是 1;巢狀更深可以寫 flat(2) 或直接 flat(Infinity) 全部打平。flatten 不是真正的方法名(常見的誤記);flatMap 是先 map 再 flat 一層,用途不同;join 是把陣列接成一整條字串,回傳型別完全不一樣。", + "verify": { + "checks": [ + { "code": "const nested = [1, [2, 3], [4]];\nconst flat = nested.flat();\nconsole.log(flat);", "expected": "[ 1, 2, 3, 4 ]" } + ] + } } ] } diff --git a/src/data/questions/jsb-4-objects.json b/src/data/questions/jsb-4-objects.json index b9785b4..582dace 100644 --- a/src/data/questions/jsb-4-objects.json +++ b/src/data/questions/jsb-4-objects.json @@ -141,6 +141,145 @@ { "code": "const pet = { name: \"皮皮\", stage: 2 };\nconst keys = Object.keys(pet);\nconsole.log(keys.length, keys[0]);", "expected": "2 name" } ] } + }, + { + "id": "jsb-4-q7", + "type": "predict-output", + "difficulty": 2, + "topic": "巢狀解構", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment", + "story": "", + "prompt": "使用者資料裡地址是巢狀物件。這段印出什麼?", + "code": "const user = { name: \"小美\", address: { city: \"台北\", zip: \"100\" } };\nconst { name, address: { city } } = user;\nconsole.log(name, city);", + "options": [ + { "id": "a", "text": "小美 台北" }, + { "id": "b", "text": "小美 undefined" }, + { "id": "c", "text": "小美 { city: '台北', zip: '100' }" }, + { "id": "d", "text": "會報錯" } + ], + "answer": "a", + "explanation": "解構可以照著物件的形狀一路往裡拆:{ address: { city } } 直接跳過 address 這層,拿到裡面的 city。注意這裡的 address 只是「路徑」寫法,不會額外產生一個叫 address 的變數——想同時保留 address 整包,要另外寫 const { address } = user。巢狀解構是拆 API 回傳的巢狀資料時很常見的簡寫。", + "verify": { + "checks": [ + { "code": "const user = { name: \"小美\", address: { city: \"台北\", zip: \"100\" } };\nconst { name, address: { city } } = user;\nconsole.log(name, city);", "expected": "小美 台北" } + ] + } + }, + { + "id": "jsb-4-q8", + "type": "find-bug", + "difficulty": 3, + "topic": "展開語法只是淺拷貝", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/Spread_syntax", + "story": "", + "prompt": "用展開語法複製設定,以為安全了,結果巢狀的 theme 還是被改到共用的那份。哪裡出了問題?", + "code": "const base = { theme: { color: \"dark\" }, version: 1 };\nconst copy = { ...base };\ncopy.theme.color = \"light\";\nconsole.log(base.theme.color); // light ?? 預期 dark", + "options": [ + { "id": "a", "text": "{ ...base } 只複製第一層,巢狀的 theme 物件本身還是同一個參照,要巢狀複製得再展開一層" }, + { "id": "b", "text": "copy.theme.color 這樣寫語法有誤" }, + { "id": "c", "text": "展開語法遇到巢狀物件會直接報錯" }, + { "id": "d", "text": "沒有 bug,這是預期行為" } + ], + "answer": "a", + "explanation": "展開語法只複製「第一層」的欄位——version 是數字,複製後兩邊互不影響;但 theme 是個物件,複製的其實是「指向同一個物件的參照」,所以 copy.theme 和 base.theme 骨子裡是同一個東西,改一邊兩邊都變。要真正的深拷貝,巢狀的部分也得展開:{ ...base, theme: { ...base.theme } },或用 structuredClone。這是 q2 物件參照坑的進階版,巢狀資料格外容易踩到,React state 裡藏著巢狀物件時要特別小心。", + "verify": { + "checks": [ + { "code": "const base = { theme: { color: \"dark\" }, version: 1 };\nconst copy = { ...base };\ncopy.theme.color = \"light\";\nconsole.log(base.theme.color);", "expected": "light" }, + { "code": "const base = { theme: { color: \"dark\" }, version: 1 };\nconst copy = { ...base, theme: { ...base.theme } };\ncopy.theme.color = \"light\";\nconsole.log(base.theme.color, copy.theme.color);", "expected": "dark light" } + ] + } + }, + { + "id": "jsb-4-q9", + "type": "predict-output", + "difficulty": 2, + "topic": "in 運算子檢查屬性是否存在", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/in", + "story": "", + "prompt": "用 in 檢查物件有沒有某個欄位,就算值是 undefined 也一樣算有。這段印出什麼?", + "code": "const config = { timeout: undefined, retries: 3 };\nconsole.log(\"timeout\" in config);\nconsole.log(config.timeout !== undefined);\nconsole.log(\"cache\" in config);", + "options": [ + { "id": "a", "text": "true false false" }, + { "id": "b", "text": "false false false" }, + { "id": "c", "text": "true true false" }, + { "id": "d", "text": "true false true" } + ], + "answer": "a", + "explanation": "in 檢查的是「這把鑰匙存不存在」,跟值是什麼無關——timeout 這個欄位確實存在(只是值剛好是 undefined),所以是 true。但如果改用「值是否不等於 undefined」來判斷有沒有這個欄位,會被騙:timeout 明明存在卻回傳 false。cache 這個鍵完全沒出現過,兩種寫法都判 false。這正是 in 和「檢查值」最大的差異,跟 q3 的 ?. 是互補的兩件事:?. 在乎值,in 在乎鑰匙存不存在。", + "verify": { + "checks": [ + { "code": "const config = { timeout: undefined, retries: 3 };\nconsole.log(\"timeout\" in config);\nconsole.log(config.timeout !== undefined);\nconsole.log(\"cache\" in config);", "expected": "true\nfalse\nfalse" } + ] + } + }, + { + "id": "jsb-4-q10", + "type": "fill-in", + "difficulty": 2, + "topic": "動態鍵名 [key]", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/Object_initializer", + "story": "", + "prompt": "想用變數的值當作物件的鍵名,而不是變數名稱本身。把空格填起來,印出 { role: 'admin' }", + "code": "const field = \"role\";\nconst obj = { ____: \"admin\" };\nconsole.log(obj);", + "options": [ + { "id": "a", "text": "[field]" }, + { "id": "b", "text": "field" }, + { "id": "c", "text": "${field}" }, + { "id": "d", "text": "(field)" } + ], + "answer": "a", + "explanation": "直接寫 { field: \"admin\" } 會建立一個字面上就叫 field 的鍵,而不是用變數 field 的值當鍵名。要讓 JS 先「計算」中括號裡的表達式再拿來當鍵,得用 [field] 這種計算屬性名寫法,才會變成 { role: 'admin' }。動態組出物件鍵名(例如依表單欄位名稱存值)幾乎都靠這一招。", + "verify": { + "checks": [ + { "code": "const field = \"role\";\nconst obj = { [field]: \"admin\" };\nconsole.log(obj);", "expected": "{ role: 'admin' }" } + ] + } + }, + { + "id": "jsb-4-q11", + "type": "predict-output", + "difficulty": 2, + "topic": "Object.entries 走訪鍵值", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Object/entries", + "story": "", + "prompt": "想同時印出每個欄位的名稱和值。這段印出什麼?", + "code": "const pet = { name: \"皮皮\", stage: 2 };\nfor (const [key, value] of Object.entries(pet)) {\n console.log(key, value);\n}", + "options": [ + { "id": "a", "text": "name 皮皮 然後 stage 2(兩行)" }, + { "id": "b", "text": "[ 'name', '皮皮' ] 然後 [ 'stage', 2 ](兩行)" }, + { "id": "c", "text": "name stage(一行)" }, + { "id": "d", "text": "會報錯,for...of 不能用在物件上" } + ], + "answer": "a", + "explanation": "Object.entries 把物件拆成一堆 [鍵, 值] 小陣列組成的陣列,例如 [['name','皮皮'],['stage',2]]。搭配 for...of 解構 [key, value],一次就能拿到名稱跟值。想只看名稱用 Object.keys(q6 考過),只看值用 Object.values,兩個都要就是 entries——三兄弟依需求選用。", + "verify": { + "checks": [ + { "code": "const pet = { name: \"皮皮\", stage: 2 };\nfor (const [key, value] of Object.entries(pet)) {\n console.log(key, value);\n}", "expected": "name 皮皮\nstage 2" } + ] + } + }, + { + "id": "jsb-4-q12", + "type": "same-or-not", + "difficulty": 3, + "topic": "hasOwnProperty 只認自己身上的", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Object/hasOwnProperty", + "story": "", + "prompt": "toString 沒有寫在物件字面值裡,但它其實「繼承」自 Object。in 和 hasOwnProperty 對它的判斷一樣嗎?", + "code": "const obj = { name: \"小美\" };\nconsole.log(\"toString\" in obj);\nconsole.log(obj.hasOwnProperty(\"toString\"));", + "options": [ + { "id": "a", "text": "不一樣:in 是 true,hasOwnProperty 是 false" }, + { "id": "b", "text": "一樣:兩個都是 true" }, + { "id": "c", "text": "一樣:兩個都是 false" }, + { "id": "d", "text": "不一樣:in 是 false,hasOwnProperty 是 true" } + ], + "answer": "a", + "explanation": "in 很大方,只要「找得到」這個鍵就算數,不管是物件自己的還是從原型鏈繼承來的(每個物件都天生繼承 toString 這類方法),所以是 true。hasOwnProperty 比較龜毛,只認「物件自己身上真的有」的欄位,繼承來的不算,所以是 false。想確認一個欄位是不是「自己寫」上去的(常用在 for...in 迴圈裡過濾掉繼承的東西),要用 hasOwnProperty,不能只靠 in。", + "verify": { + "checks": [ + { "code": "const obj = { name: \"小美\" };\nconsole.log(\"toString\" in obj);\nconsole.log(obj.hasOwnProperty(\"toString\"));", "expected": "true\nfalse" } + ] + } } ] } diff --git a/src/data/questions/jsb-5-control-flow.json b/src/data/questions/jsb-5-control-flow.json index 91fbdce..05bd426 100644 --- a/src/data/questions/jsb-5-control-flow.json +++ b/src/data/questions/jsb-5-control-flow.json @@ -140,6 +140,145 @@ { "code": "function withdraw(balance, amount) {\n if (amount > balance) {\n throw new Error(\"餘額不足\");\n }\n return balance - amount;\n}\n\ntry {\n console.log(withdraw(100, 50));\n console.log(withdraw(50, 80));\n console.log(\"完成\");\n} catch (err) {\n console.log(\"錯誤:\" + err.message);\n}", "expected": "50\n錯誤:餘額不足" } ] } + }, + { + "id": "jsb-5-q7", + "type": "predict-output", + "difficulty": 2, + "topic": "三元運算子巢狀", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/Conditional_operator", + "story": "", + "prompt": "把 if / else if / else 濃縮成一行三元運算子。分數是 75。這段印出什麼?", + "code": "const score = 75;\nconst grade = score >= 90 ? \"優等\" : score >= 60 ? \"及格\" : \"不及格\";\nconsole.log(grade);", + "options": [ + { "id": "a", "text": "及格" }, + { "id": "b", "text": "優等" }, + { "id": "c", "text": "不及格" }, + { "id": "d", "text": "會報錯,三元運算子不能巢狀" } + ], + "answer": "a", + "explanation": "三元運算子可以一路串接,運作邏輯跟 if / else if / else 一模一樣:由左到右先看 score >= 90 成不成立,不成立就換下一段條件繼續問。這題答案跟 q1 的 if/else if 完全一致,只是換了更精簡(但巢狀多了容易看花眼)的寫法——條件超過兩三層,建議還是拆回 if/else if 比較好讀。", + "verify": { + "checks": [ + { "code": "const score = 75;\nconst grade = score >= 90 ? \"優等\" : score >= 60 ? \"及格\" : \"不及格\";\nconsole.log(grade);", "expected": "及格" } + ] + } + }, + { + "id": "jsb-5-q8", + "type": "predict-output", + "difficulty": 3, + "topic": "finally 一定會執行", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/try...catch", + "story": "", + "prompt": "try 裡面提早 return 了,finally 還會跑嗎?這段印出什麼?", + "code": "function run() {\n try {\n console.log(\"try\");\n return \"結果\";\n } finally {\n console.log(\"finally\");\n }\n}\nconsole.log(run());", + "options": [ + { "id": "a", "text": "try 然後 finally 然後 結果" }, + { "id": "b", "text": "try 然後 結果(finally 被跳過)" }, + { "id": "c", "text": "finally 然後 try 然後 結果" }, + { "id": "d", "text": "try 然後 結果 然後 finally" } + ], + "answer": "a", + "explanation": "finally 是「不管發生什麼事都一定會執行」的收尾區——就算 try 裡已經 return 了,也會先繞去 finally 跑完,才真的把值交出去。所以順序是 try → finally → 結果,而不是 return 之後直接跳過 finally。finally 常拿來做「不管成功失敗都要做」的收尾工作,例如關閉 loading 狀態、釋放資源。", + "verify": { + "checks": [ + { "code": "function run() {\n try {\n console.log(\"try\");\n return \"結果\";\n } finally {\n console.log(\"finally\");\n }\n}\nconsole.log(run());", "expected": "try\nfinally\n結果" } + ] + } + }, + { + "id": "jsb-5-q9", + "type": "predict-output", + "difficulty": 1, + "topic": "continue 跳過本次迴圈", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/continue", + "story": "", + "prompt": "只想印出偶數,遇到奇數就跳過。這段印出什麼?", + "code": "for (let i = 1; i <= 5; i++) {\n if (i % 2 !== 0) continue;\n console.log(i);\n}", + "options": [ + { "id": "a", "text": "2 然後 4" }, + { "id": "b", "text": "1 2 3 4 5" }, + { "id": "c", "text": "1 3 5" }, + { "id": "d", "text": "什麼都不印" } + ], + "answer": "a", + "explanation": "continue 只跳過「這一輪」剩下的程式碼,直接跳去下一次迴圈的判斷式,迴圈本身不會停。跟 break(整個迴圈直接收工不玩了)不一樣——continue 是「這局不算,繼續玩下一局」。這題如果誤用 break,只會印出「什麼都不印」,因為第一輪 i=1 遇到就整個迴圈中止了。", + "verify": { + "checks": [ + { "code": "for (let i = 1; i <= 5; i++) {\n if (i % 2 !== 0) continue;\n console.log(i);\n}", "expected": "2\n4" } + ] + } + }, + { + "id": "jsb-5-q10", + "type": "fill-in", + "difficulty": 2, + "topic": "短路賦值 ??= 補預設值", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/Logical_nullish_assignment", + "story": "", + "prompt": "使用者設定裡 volume 還沒設定(是 undefined),要補上預設值 50,但如果已經是 0(靜音)要保留 0。把空格填起來", + "code": "const settings = { volume: undefined };\nsettings.volume ____ 50;\nconsole.log(settings.volume);", + "options": [ + { "id": "a", "text": "??=" }, + { "id": "b", "text": "||=" }, + { "id": "c", "text": "&&=" }, + { "id": "d", "text": "+=" } + ], + "answer": "a", + "explanation": "??= 是「左邊是 null 或 undefined 才賦值」的簡寫,等於 settings.volume = settings.volume ?? 50。這裡 volume 是 undefined,所以補上 50。如果用 ||=,只要左邊是 falsy(包含 0)就會被蓋掉——靜音的 0 會被誤判成「沒設定」而換成 50,這正是 jsb-1-q11 用 ?? 保護 0 的邏輯,只是這裡多了「順便賦值」這個動作。", + "verify": { + "checks": [ + { "code": "const settings = { volume: undefined };\nsettings.volume ??= 50;\nconsole.log(settings.volume);", "expected": "50" }, + { "code": "const settings = { volume: 0 };\nsettings.volume ??= 50;\nconsole.log(settings.volume);", "expected": "0" } + ] + } + }, + { + "id": "jsb-5-q11", + "type": "same-or-not", + "difficulty": 2, + "topic": "do-while 至少跑一次", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/do...while", + "story": "", + "prompt": "條件一開始就不成立。while 和 do-while 的執行次數一樣嗎?", + "code": "let count = 10;\n\n// 版本一\nwhile (count < 5) {\n console.log(\"while 執行了\");\n count++;\n}\n\n// 版本二\ndo {\n console.log(\"do-while 執行了\");\n count++;\n} while (count < 5);\n\nconsole.log(\"結束\");", + "options": [ + { "id": "a", "text": "不一樣:while 一次都不跑,do-while 一定先跑一次,印出「do-while 執行了」然後「結束」" }, + { "id": "b", "text": "一樣:兩個都不會印任何執行訊息,只印「結束」" }, + { "id": "c", "text": "一樣:兩個都會印「執行了」" }, + { "id": "d", "text": "不一樣:while 會報錯" } + ], + "answer": "a", + "explanation": "while 是「先問再做」,條件一開始就不成立(10 < 5 是 false),一次都不會進去執行。do-while 是「先做再問」,不管條件一開始成不成立,body 至少會跑一次,跑完才檢查要不要再繼續。需要「至少執行一次」的情境(例如至少要顯示一次選單再問要不要重複)就該用 do-while。", + "verify": { + "checks": [ + { "code": "let count = 10;\nwhile (count < 5) {\n console.log(\"while 執行了\");\n count++;\n}\ndo {\n console.log(\"do-while 執行了\");\n count++;\n} while (count < 5);\nconsole.log(\"結束\");", "expected": "do-while 執行了\n結束" } + ] + } + }, + { + "id": "jsb-5-q12", + "type": "predict-output", + "difficulty": 3, + "topic": "&& 串接的短路求值", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/Logical_AND", + "story": "", + "prompt": "&& 串接多個條件,只要前面短路,後面的表達式根本不會被執行。這段印出什麼?", + "code": "function checkA() {\n console.log(\"檢查 A\");\n return false;\n}\nfunction checkB() {\n console.log(\"檢查 B\");\n return true;\n}\nconst result = checkA() && checkB();\nconsole.log(result);", + "options": [ + { "id": "a", "text": "檢查 A 然後 false(checkB 完全沒被呼叫)" }, + { "id": "b", "text": "檢查 A 然後 檢查 B 然後 false" }, + { "id": "c", "text": "檢查 A 然後 檢查 B 然後 true" }, + { "id": "d", "text": "會報錯" } + ], + "answer": "a", + "explanation": "&& 一路往右算,只要前面出現一個 falsy 值就立刻收工,右邊的表達式連呼叫都不會被呼叫——這不只是「回傳值」的問題,是真的不會執行。如果 checkB 裡面藏著發 API 請求或修改資料等副作用,誤以為「反正兩個都會跑」就會漏算。這也是 ?? 與 ||/&& 行為不同(jsb-1-q11)的延伸:短路運算子不只影響結果,也影響「哪些程式碼真的被執行到」。", + "verify": { + "checks": [ + { "code": "function checkA() {\n console.log(\"檢查 A\");\n return false;\n}\nfunction checkB() {\n console.log(\"檢查 B\");\n return true;\n}\nconst result = checkA() && checkB();\nconsole.log(result);", "expected": "檢查 A\nfalse" } + ] + } } ] } diff --git a/src/data/questions/jsb-6-loops.json b/src/data/questions/jsb-6-loops.json index f830d1f..b3955fb 100644 --- a/src/data/questions/jsb-6-loops.json +++ b/src/data/questions/jsb-6-loops.json @@ -141,6 +141,148 @@ { "code": "const stock = { 蘋果: 3, 香蕉: 0 };\nfor (const [name, qty] of Object.entries(stock)) {\n if (qty === 0) continue;\n console.log(`${name} x${qty}`);\n}", "expected": "蘋果 x3" } ] } + }, + { + "id": "jsb-6-q7", + "type": "predict-output", + "difficulty": 1, + "topic": "do...while:先做再檢查", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/do...while", + "story": "", + "prompt": "n 一開始就不小於 5,迴圈還會跑嗎?這段程式印出什麼?", + "code": "let n = 10;\nlet count = 0;\ndo {\n count++;\n} while (n < 5);\nconsole.log(count);", + "options": [ + { "id": "a", "text": "1" }, + { "id": "b", "text": "0" }, + { "id": "c", "text": "無窮迴圈,不會印出東西" }, + { "id": "d", "text": "會報錯" } + ], + "answer": "a", + "explanation": "do...while 跟 while 的差別只有一件事:do 版本「先做再檢查」,第一圈一定會執行,檢查條件是之後每一圈的事。這裡 n < 5 從頭到尾都是 false,但 body 已經跑過一次了,所以 count 是 1。想確保「至少執行一次」(例如至少問使用者一次密碼)就是 do...while 的主場。", + "verify": { + "checks": [ + { "code": "let n = 10;\nlet count = 0;\ndo {\n count++;\n} while (n < 5);\nconsole.log(count);", "expected": "1" } + ] + } + }, + { + "id": "jsb-6-q8", + "type": "find-bug", + "difficulty": 3, + "topic": "for 迴圈裡用 var 造成的閉包陷阱", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/var", + "story": "", + "prompt": "想收集三個函式,各自印出 0、1、2,結果三個都印出 3。哪裡錯了?", + "code": "const fns = [];\nfor (var i = 0; i < 3; i++) { // A 行\n fns.push(() => console.log(i));\n}\nfns[0](); fns[1](); fns[2]();", + "options": [ + { "id": "a", "text": "A 行的 var 該改成 let:var 沒有區塊作用域,三個函式共用同一個 i,迴圈跑完 i 已經是 3" }, + { "id": "b", "text": "fns.push 裡要多傳一個參數" }, + { "id": "c", "text": "fns[0]()、fns[1]()、fns[2]() 呼叫順序錯了" }, + { "id": "d", "text": "console.log(i) 應該寫成 console.log(i())" } + ], + "answer": "a", + "explanation": "var 是整個函式共用一份,不會因為進了迴圈的 { } 就分身——三個箭頭函式的 closure 抓的都是同一個 i,等迴圈跑完,i 已經變成 3,三個函式印出的自然都是 3。改成 let 之後,每一圈的 i 都是全新、獨立的一份,closure 各抓各的,才會印出 0、1、2。這是「closure 抓的是變數不是值」(上一章教過)遇上 var 沒有區塊作用域的組合技,AI 產的迴圈程式碼常常因為用了 var 而中招。", + "verify": { + "checks": [ + { "code": "const fns = [];\nfor (var i = 0; i < 3; i++) {\n fns.push(() => console.log(i));\n}\nfns[0](); fns[1](); fns[2]();", "expected": "3\n3\n3" }, + { "code": "const fns = [];\nfor (let i = 0; i < 3; i++) {\n fns.push(() => console.log(i));\n}\nfns[0](); fns[1](); fns[2]();", "expected": "0\n1\n2" } + ] + } + }, + { + "id": "jsb-6-q9", + "type": "predict-output", + "difficulty": 2, + "topic": "巢狀迴圈", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/for", + "story": "", + "prompt": "迴圈裡面還有一個迴圈。這段程式印出什麼?", + "code": "for (let i = 1; i <= 2; i++) {\n for (let j = 1; j <= 2; j++) {\n console.log(`${i}-${j}`);\n }\n}", + "options": [ + { "id": "a", "text": "1-1、1-2、2-1、2-2" }, + { "id": "b", "text": "1-1、2-2" }, + { "id": "c", "text": "1-1、1-2" }, + { "id": "d", "text": "1-2、2-1" } + ], + "answer": "a", + "explanation": "外層 i 每走一步,內層 j 就要完整跑完一輪:i=1 時 j 從 1 跑到 2(1-1、1-2),i 才變成 2,j 再重新從 1 跑到 2(2-1、2-2)。巢狀迴圈常見於處理表格、座標、九九乘法表——外層決定「第幾行」、內層決定「這一行的每一格」。", + "verify": { + "checks": [ + { "code": "for (let i = 1; i <= 2; i++) {\n for (let j = 1; j <= 2; j++) {\n console.log(`${i}-${j}`);\n }\n}", "expected": "1-1\n1-2\n2-1\n2-2" } + ] + } + }, + { + "id": "jsb-6-q10", + "type": "find-bug", + "difficulty": 2, + "topic": "for 迴圈裡用 let 宣告的變數,出了迴圈就消失", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/for", + "story": "", + "prompt": "迴圈跑完後想看看最後的 i 是多少,結果直接報錯。哪裡出了問題?", + "code": "for (let i = 0; i < 3; i++) {\n // ...\n}\nconsole.log(i); // B 行,想看最後的 i 是多少", + "options": [ + { "id": "a", "text": "B 行:let i 只活在 for 的 { } 裡(區塊作用域),出了迴圈就不存在了;要看最終值得在迴圈外先宣告一個變數自己記" }, + { "id": "b", "text": "for 迴圈少寫了 return i" }, + { "id": "c", "text": "console.log 不能印變數 i" }, + { "id": "d", "text": "i < 3 應該寫成 i <= 3" } + ], + "answer": "a", + "explanation": "let(包括 for 括號裡那個)是區塊作用域,for (let i ...) 的 i 只活在這個迴圈的大括號範圍內,出了迴圈就被回收,外面完全看不到——這跟 jsb-1 教過的「if 裡的 let 出了大括號就消失」是同一條規則。如果真的需要迴圈跑完後的值,得在迴圈外先宣告一個變數(例如 let lastI),迴圈裡面同步更新它。用 var 則不會有這個限制,因為 var 沒有區塊作用域,但這也是 var 常常惹麻煩的原因(上一題剛示範過)。", + "verify": { + "checks": [ + { "code": "for (let i = 0; i < 3; i++) {\n}\nconsole.log(i);", "expectError": "i is not defined" }, + { "code": "for (var i = 0; i < 3; i++) {}\nconsole.log(i);", "expected": "3" } + ] + } + }, + { + "id": "jsb-6-q11", + "type": "fill-in", + "difficulty": 2, + "topic": "Array.from({ length }) 造出固定長度陣列", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Array/from", + "story": "", + "prompt": "沒有現成陣列,只想要「5 個元素、依序是 0,2,4,6,8」。把空格填起來", + "code": "const arr = ____;\nconsole.log(arr.join(\",\"));", + "options": [ + { "id": "a", "text": "Array.from({ length: 5 }, (_, i) => i * 2)" }, + { "id": "b", "text": "Array(5).map((_, i) => i * 2)" }, + { "id": "c", "text": "Array.from(5, (_, i) => i * 2)" }, + { "id": "d", "text": "Array.from({ length: 5 }, i => i * 2)" } + ], + "answer": "a", + "explanation": "Array.from({ length: 5 }, callback) 是「憑空造出固定長度陣列」的標準寫法:第二個參數的 callback 收到 (元素值, 索引),用 (_, i) 拿到索引來運算。選項 b 的 Array(5) 只是建立 5 個「空洞」(empty slot),.map 會直接跳過空洞不執行,結果還是空的;選項 c 傳純數字 5 給 Array.from 的第一個參數,它不是陣列也不是有 length 的物件,會被當空的來源,結果是 [];選項 d 忘了 callback 的第一個參數是元素值(在這裡永遠是 undefined)而不是索引,i * 2 全部變成 NaN。", + "verify": { + "checks": [ + { "code": "const arr = Array.from({ length: 5 }, (_, i) => i * 2);\nconsole.log(arr.join(\",\"));", "expected": "0,2,4,6,8" }, + { "code": "const arr = Array(5).map((_, i) => i * 2);\nconsole.log(arr.join(\",\"));", "expected": ",,,," } + ] + } + }, + { + "id": "jsb-6-q12", + "type": "same-or-not", + "difficulty": 3, + "topic": "迴圈裡用 return 提早結束整個函式 vs break 只跳出迴圈", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/return", + "story": "", + "prompt": "版本一用 return、版本二用 break,都是想找到第一個偶數。這兩段印出來一樣嗎?", + "code": "// 版本一\nfunction findEven(nums) {\n for (const n of nums) {\n if (n % 2 === 0) return n;\n }\n console.log(\"迴圈跑完了\");\n return null;\n}\nconsole.log(findEven([1, 3, 4, 5]));\n\n// 版本二\nfunction findEven2(nums) {\n let found = null;\n for (const n of nums) {\n if (n % 2 === 0) { found = n; break; }\n }\n console.log(\"迴圈跑完了\");\n return found;\n}\nconsole.log(findEven2([1, 3, 4, 5]));", + "options": [ + { "id": "a", "text": "不一樣:版本一只印 4;版本二印 迴圈跑完了 然後 4" }, + { "id": "b", "text": "一樣,都只印 4" }, + { "id": "c", "text": "一樣,都印 迴圈跑完了 然後 4" }, + { "id": "d", "text": "不一樣:版本一印 迴圈跑完了 然後 4;版本二只印 4" } + ], + "answer": "a", + "explanation": "return 是「整個函式立刻結束、交出結果」,找到 4 就直接跳出 findEven,後面那行 console.log(\"迴圈跑完了\") 根本沒機會執行。break 只負責「跳出迴圈」,函式本身還沒結束,迴圈後面的程式碼(包含那行 console.log)照樣會跑。兩種寫法最後回傳的值一樣,但函式裡剩下的程式碼是否執行,天差地遠——這是很多人以為 break 和 return 在迴圈裡「差不多」而踩到的坑。", + "verify": { + "checks": [ + { "code": "function findEven(nums) {\n for (const n of nums) {\n if (n % 2 === 0) return n;\n }\n console.log(\"迴圈跑完了\");\n return null;\n}\nconsole.log(findEven([1, 3, 4, 5]));", "expected": "4" }, + { "code": "function findEven2(nums) {\n let found = null;\n for (const n of nums) {\n if (n % 2 === 0) { found = n; break; }\n }\n console.log(\"迴圈跑完了\");\n return found;\n}\nconsole.log(findEven2([1, 3, 4, 5]));", "expected": "迴圈跑完了\n4" } + ] + } } ] } diff --git a/src/data/questions/jsb-7-numbers-dates.json b/src/data/questions/jsb-7-numbers-dates.json index 499dbf9..efa917d 100644 --- a/src/data/questions/jsb-7-numbers-dates.json +++ b/src/data/questions/jsb-7-numbers-dates.json @@ -141,6 +141,147 @@ { "code": "const d = new Date(2026, 6, 10);\nconsole.log(d.getMonth() + 1);", "expected": "7" } ] } + }, + { + "id": "jsb-7-q7", + "type": "predict-output", + "difficulty": 1, + "topic": "Number.isInteger 判斷是不是整數", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Number/isInteger", + "story": "", + "prompt": "5 和 5.0 是不是整數?這段程式印出什麼?", + "code": "console.log(Number.isInteger(5));\nconsole.log(Number.isInteger(5.0));\nconsole.log(Number.isInteger(5.5));", + "options": [ + { "id": "a", "text": "true true false" }, + { "id": "b", "text": "true false false" }, + { "id": "c", "text": "false true false" }, + { "id": "d", "text": "true true true" } + ], + "answer": "a", + "explanation": "JavaScript 只有一種數字型別(都是浮點數),5 和 5.0 其實是完全同一個值——寫 .0 不會讓它變成「小數類型」。Number.isInteger 檢查的是「小數部分是不是 0」,5 和 5.0 都通過,5.5 才是真的有小數。這跟第一題 0.1 + 0.2 的教訓同源:JS 的數字世界裡沒有整數和浮點數的分別,只有一種 Number。", + "verify": { + "checks": [ + { "code": "console.log(Number.isInteger(5));\nconsole.log(Number.isInteger(5.0));\nconsole.log(Number.isInteger(5.5));", "expected": "true\ntrue\nfalse" } + ] + } + }, + { + "id": "jsb-7-q8", + "type": "fill-in", + "difficulty": 2, + "topic": "Math.max/min 搭配展開運算子找最大最小值", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Math/max", + "story": "", + "prompt": "五科成績存在陣列裡,想找出最高分。把空格填起來,印出 100", + "code": "const scores = [88, 92, 75, 100, 61];\nconsole.log(Math.max(____));", + "options": [ + { "id": "a", "text": "...scores" }, + { "id": "b", "text": "scores" }, + { "id": "c", "text": "scores[0]" }, + { "id": "d", "text": "scores.max()" } + ], + "answer": "a", + "explanation": "Math.max 要吃「一堆數字」當獨立參數,不吃陣列。...scores 把陣列展開成 88, 92, 75, 100, 61 這樣的一串參數,才是它要的格式。直接傳整個陣列(選項 b)會被硬轉成數字,一個陣列轉不出單一數字就是 NaN;只傳第一個元素(選項 c)當然只能拿到 88;陣列身上根本沒有 .max() 這個方法(選項 d),會直接報錯。找最小值同理,換成 Math.min(...scores)。", + "verify": { + "checks": [ + { "code": "const scores = [88, 92, 75, 100, 61];\nconsole.log(Math.max(...scores));", "expected": "100" }, + { "code": "const scores = [88, 92, 75, 100, 61];\nconsole.log(Math.max(scores));", "expected": "NaN" } + ] + } + }, + { + "id": "jsb-7-q9", + "type": "same-or-not", + "difficulty": 2, + "topic": "parseFloat 保留小數,parseInt 只留整數部分", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/parseFloat", + "story": "", + "prompt": "同一個字串 \"2.5\",分別用 parseInt 和 parseFloat 轉換再加 1。這兩段印出來一樣嗎?", + "code": "// 版本一\nconsole.log(parseInt(\"2.5\") + 1);\n\n// 版本二\nconsole.log(parseFloat(\"2.5\") + 1);", + "options": [ + { "id": "a", "text": "不一樣:版本一印 3,版本二印 3.5" }, + { "id": "b", "text": "一樣,都印 3.5" }, + { "id": "c", "text": "一樣,都印 3" }, + { "id": "d", "text": "不一樣:版本一印 3.5,版本二印 3" } + ], + "answer": "a", + "explanation": "parseInt 顧名思義只認「整數」,讀到小數點就停手,\"2.5\" 只拿到 2,加 1 是 3。parseFloat 才認得小數點,會把 2.5 整個讀完,加 1 是 3.5。跟前面 parseInt(\"42px\") 是同一個系列的行為:parseInt 永遠只給你整數部分。要處理金額、身高體重這種有小數的輸入,parseFloat 或 Number 才是對的工具。", + "verify": { + "checks": [ + { "code": "console.log(parseInt(\"2.5\") + 1);", "expected": "3" }, + { "code": "console.log(parseFloat(\"2.5\") + 1);", "expected": "3.5" } + ] + } + }, + { + "id": "jsb-7-q10", + "type": "find-bug", + "difficulty": 3, + "topic": "比較兩個 Date 物件不能用 ===", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Date/getTime", + "story": "", + "prompt": "想確認兩筆訂單是不是同一天建立的,日期明明一樣,卻永遠印出 false。哪裡錯了?", + "code": "const d1 = new Date(2024, 5, 15);\nconst d2 = new Date(2024, 5, 15);\nconsole.log(d1 === d2); // B 行,永遠 false", + "options": [ + { "id": "a", "text": "B 行:Date 是物件,=== 比的是「是不是同一個實體」,就算時間相同也不會相等;要比時間該用 d1.getTime() === d2.getTime()" }, + { "id": "b", "text": "new Date 少傳了時分秒,才會判斷失敗" }, + { "id": "c", "text": "月份寫錯了,5 應該改成 6" }, + { "id": "d", "text": "要先用 JSON.stringify 轉字串再比較" } + ], + "answer": "a", + "explanation": "物件(包含陣列、Date)用 === 比較時,比的是「記憶體裡是不是同一個東西」,不是內容。d1 和 d2 是兩個分別 new 出來的物件,就算裝的時間完全一樣,也是兩個不同的實體,=== 永遠 false。想比較「時間點是否相同」,要先把 Date 轉成可比較的原始值:.getTime()(回傳一個數字,代表距離 1970 年的毫秒數),數字用 === 比才有意義。這跟比較物件內容要用 JSON.stringify 或逐一比對欄位是同一類問題。", + "verify": { + "checks": [ + { "code": "const d1 = new Date(2024, 5, 15);\nconst d2 = new Date(2024, 5, 15);\nconsole.log(d1 === d2);", "expected": "false" }, + { "code": "const d1 = new Date(2024, 5, 15);\nconst d2 = new Date(2024, 5, 15);\nconsole.log(d1.getTime() === d2.getTime());", "expected": "true" } + ] + } + }, + { + "id": "jsb-7-q11", + "type": "predict-output", + "difficulty": 2, + "topic": "getDay 星期幾,星期日是 0", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Date/getDay", + "story": "", + "prompt": "想知道 2026 年 7 月 10 日是星期幾。這段程式印出什麼?", + "code": "const d = new Date(2026, 6, 10);\nconsole.log(d.getDay());", + "options": [ + { "id": "a", "text": "5 —— getDay 回傳 0(星期日)到 6(星期六),5 代表星期五" }, + { "id": "b", "text": "6" }, + { "id": "c", "text": "4" }, + { "id": "d", "text": "7 —— 星期一到星期日對應 1 到 7" } + ], + "answer": "a", + "explanation": "getDay 回傳的數字代表星期幾,範圍是 0~6,而且是從星期日開始算:0 是日、1 是一……6 是六。2026 年 7 月 10 日剛好是星期五,所以是 5。這跟 getMonth 的「月份從 0 起算」是同一種「JS 的 Date 從 0 開始數」的個性——沒有星期 7 這種東西,選項 d 是最容易直覺猜錯的陷阱。", + "verify": { + "checks": [ + { "code": "const d = new Date(2026, 6, 10);\nconsole.log(d.getDay());", "expected": "5" } + ] + } + }, + { + "id": "jsb-7-q12", + "type": "predict-output", + "difficulty": 3, + "topic": "% 取餘數:讓索引繞回陣列開頭", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/Remainder", + "story": "", + "prompt": "索引跑超過陣列長度,用 % 讓它自動繞回開頭。這段程式印出什麼?", + "code": "const days = [\"日\", \"一\", \"二\", \"三\", \"四\", \"五\", \"六\"];\nfor (let i = 5; i <= 8; i++) {\n console.log(days[i % 7]);\n}", + "options": [ + { "id": "a", "text": "五、六、日、一" }, + { "id": "b", "text": "五、六、undefined、undefined" }, + { "id": "c", "text": "五、六、六、六" }, + { "id": "d", "text": "五、六、日、日" } + ], + "answer": "a", + "explanation": "% 是取餘數:i 是 5、6、7、8 時,i % 7 分別是 5、6、0、1——一旦 i 超過陣列長度,餘數就會自動繞回 0 重新數起,剛好對應到 days[0](日)、days[1](一)。這招是「循環索引」的標準寫法,輪播圖、循環播放清單、round-robin 排班都靠它,比自己寫 if (i >= days.length) i = 0 簡潔。", + "verify": { + "checks": [ + { "code": "const days = [\"日\", \"一\", \"二\", \"三\", \"四\", \"五\", \"六\"];\nfor (let i = 5; i <= 8; i++) {\n console.log(days[i % 7]);\n}", "expected": "五\n六\n日\n一" } + ] + } } ] } diff --git a/src/data/questions/jsb-8-strings.json b/src/data/questions/jsb-8-strings.json index b7b823d..5526b47 100644 --- a/src/data/questions/jsb-8-strings.json +++ b/src/data/questions/jsb-8-strings.json @@ -140,6 +140,147 @@ { "code": "const n = 7;\nconsole.log(String(n).padStart(3, \"0\"));\nconsole.log(\"128\".padStart(3, \"0\"));", "expected": "007\n128" } ] } + }, + { + "id": "jsb-8-q7", + "type": "predict-output", + "difficulty": 1, + "topic": "repeat 重複字串", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/String/repeat", + "story": "", + "prompt": "想印出分隔線和重複字元。這段程式印出什麼?", + "code": "console.log(\"哈\".repeat(3));\nconsole.log(\"=\".repeat(10));", + "options": [ + { "id": "a", "text": "哈哈哈 然後 ==========" }, + { "id": "b", "text": "哈3 然後 =10" }, + { "id": "c", "text": "哈哈哈哈哈哈哈哈哈哈 然後 ===" }, + { "id": "d", "text": "會報錯,字串不能重複" } + ], + "answer": "a", + "explanation": "repeat(n) 把字串整個複製貼上 n 次接在一起。\"哈\".repeat(3) 就是「哈」連續出現 3 次;同理 \"=\".repeat(10) 是 10 個等號。做分隔線、進度條、簡單的縮排都很好用,比自己寫迴圈拼接字串省事很多。", + "verify": { + "checks": [ + { "code": "console.log(\"哈\".repeat(3));\nconsole.log(\"=\".repeat(10));", "expected": "哈哈哈\n==========" } + ] + } + }, + { + "id": "jsb-8-q8", + "type": "predict-output", + "difficulty": 2, + "topic": "startsWith / endsWith 檢查開頭結尾", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith", + "story": "", + "prompt": "檢查檔名的開頭和副檔名。這段程式印出什麼?", + "code": "const file = \"report-2026.pdf\";\nconsole.log(file.startsWith(\"report\"));\nconsole.log(file.endsWith(\".pdf\"));\nconsole.log(file.endsWith(\".PDF\"));", + "options": [ + { "id": "a", "text": "true true false" }, + { "id": "b", "text": "true true true" }, + { "id": "c", "text": "false true false" }, + { "id": "d", "text": "true false false" } + ], + "answer": "a", + "explanation": "startsWith / endsWith 就是字面上的意思:檢查字串是不是「這樣開頭」或「這樣結尾」,回傳布林值。跟大部分字串比對一樣,它們區分大小寫:\".pdf\" 對得上,\".PDF\" 因為大小寫不同就對不上,回傳 false。篩選檔案類型、驗證網址開頭(https://)都常靠這兩個方法,比自己切字串再比對輕鬆很多。", + "verify": { + "checks": [ + { "code": "const file = \"report-2026.pdf\";\nconsole.log(file.startsWith(\"report\"));\nconsole.log(file.endsWith(\".pdf\"));\nconsole.log(file.endsWith(\".PDF\"));", "expected": "true\ntrue\nfalse" } + ] + } + }, + { + "id": "jsb-8-q9", + "type": "find-bug", + "difficulty": 2, + "topic": "不分大小寫比對字串,忘記先轉換", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase", + "story": "", + "prompt": "想確認使用者輸入的信箱跟資料庫存的是不是同一個,明明是同一個信箱卻判斷失敗。哪裡錯了?", + "code": "const input = \"Amy@Example.com\";\nconst saved = \"amy@example.com\";\nconsole.log(input === saved); // A 行,想確認是同一個信箱", + "options": [ + { "id": "a", "text": "A 行沒有先統一大小寫:字串比較區分大小寫,要用 input.toLowerCase() === saved.toLowerCase()" }, + { "id": "b", "text": "=== 不能拿來比較字串,要用 .equals()(JS 沒有這個方法)" }, + { "id": "c", "text": "信箱裡的 @ 符號要跳脫處理" }, + { "id": "d", "text": "要先用 trim() 才能比較" } + ], + "answer": "a", + "explanation": "=== 是逐字元精確比對,連大小寫都算數:\"Amy\" 和 \"amy\" 對它來說是完全不同的字元序列。信箱、帳號這種「使用者體感上不分大小寫」的資料,比對前要先用 toUpperCase() 或 toLowerCase() 統一格式,再用 === 比。這跟前面教過的 toUpperCase 不會改變原字串是同一組方法,只是這裡的重點在「拿它的回傳值來比較」,而不是拿去顯示。", + "verify": { + "checks": [ + { "code": "const input = \"Amy@Example.com\";\nconst saved = \"amy@example.com\";\nconsole.log(input === saved);", "expected": "false" }, + { "code": "const input = \"Amy@Example.com\";\nconst saved = \"amy@example.com\";\nconsole.log(input.toLowerCase() === saved.toLowerCase());", "expected": "true" } + ] + } + }, + { + "id": "jsb-8-q10", + "type": "predict-output", + "difficulty": 3, + "topic": "charAt 與中括號索引:查不到的差別", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/String/charAt", + "story": "", + "prompt": "\"cat\" 只有 3 個字,卻去查第 10 個位置。這段程式印出什麼?", + "code": "const s = \"cat\";\nconsole.log(s[10]);\nconsole.log(`[${s.charAt(10)}]`);", + "options": [ + { "id": "a", "text": "undefined 然後 [](空字串)" }, + { "id": "b", "text": "undefined 然後 [undefined]" }, + { "id": "c", "text": "會報錯,索引超出範圍" }, + { "id": "d", "text": "[] 然後 undefined" } + ], + "answer": "a", + "explanation": "兩種取字元的方式,查不到時的回應不一樣:中括號 s[10] 跟陣列取值同一套規則,查無此位置就給 undefined;charAt(10) 是比較古早的方法,查無此位置給的是空字串 \"\"(不是 undefined),所以印出來是空的 [ ]。多數情況兩者結果相同,但邊界之外的行為不同——要嚴謹判斷「有沒有取到值」該用 s[i] !== undefined,用 charAt 判斷容易漏掉這個角落。", + "verify": { + "checks": [ + { "code": "const s = \"cat\";\nconsole.log(s[10]);\nconsole.log(`[${s.charAt(10)}]`);", "expected": "undefined\n[]" } + ] + } + }, + { + "id": "jsb-8-q11", + "type": "fill-in", + "difficulty": 2, + "topic": "展開運算子把字串拆成字元陣列", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/Spread_syntax", + "story": "", + "prompt": "把字串拆成一個個字元組成的陣列。把空格填起來,印出 a-b-c", + "code": "const word = \"abc\";\nconst chars = ____;\nconsole.log(chars.join(\"-\"));", + "options": [ + { "id": "a", "text": "[...word]" }, + { "id": "b", "text": "word.length" }, + { "id": "c", "text": "Array(word)" }, + { "id": "d", "text": "word.split(\",\")" } + ], + "answer": "a", + "explanation": "字串是可以被迭代的(跟陣列一樣有「一格一格拿」的能力),展開運算子 ... 就能把它攤開成一個個字元,裝進新陣列 [...word] 變成 [\"a\",\"b\",\"c\"]。選項 b 拿到的是數字 3,不是陣列,join 會直接報錯;選項 c 的 Array(word) 是把整個字串當成「唯一一個元素」塞進陣列,變成 [\"abc\"](長度 1),join 印出來還是完整的 abc;選項 d 用逗號分割,但 \"abc\" 裡根本沒有逗號,split 找不到分隔點,一樣只回傳 [\"abc\"]。跟 split(\"\") 相比,展開運算子在處理 emoji 等特殊字元時更安全,是現在比較推薦的寫法。", + "verify": { + "checks": [ + { "code": "const word = \"abc\";\nconst chars = [...word];\nconsole.log(chars.join(\"-\"));", "expected": "a-b-c" }, + { "code": "const word = \"abc\";\nconst chars = Array(word);\nconsole.log(chars.join(\"-\"));", "expected": "abc" } + ] + } + }, + { + "id": "jsb-8-q12", + "type": "same-or-not", + "difficulty": 2, + "topic": "includes 判斷是否包含子字串", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/String/includes", + "story": "", + "prompt": "版本一用 includes,版本二用 indexOf 判斷有沒有找到。這兩段印出來一樣嗎?", + "code": "const text = \"hello world\";\n\n// 版本一\nconsole.log(text.includes(\"world\"));\n\n// 版本二\nconsole.log(text.indexOf(\"world\") !== -1);", + "options": [ + { "id": "a", "text": "一樣,都印 true" }, + { "id": "b", "text": "不一樣:版本一印 true,版本二印 6" }, + { "id": "c", "text": "不一樣:版本一報錯" }, + { "id": "d", "text": "一樣,都印 6" } + ], + "answer": "a", + "explanation": "indexOf 回傳「找到的位置」,找不到給 -1;所以「有沒有包含」的判斷式其實就是 indexOf(...) !== -1,只是每次都要多寫一段比較。includes 是後來新增的語法糖,直接把這個判斷包好、回傳布林值,語意更直白——想知道「有沒有」用 includes,想知道「在哪裡」才需要 indexOf。兩者在判斷有沒有找到這件事上結果完全等價。", + "verify": { + "checks": [ + { "code": "const text = \"hello world\";\nconsole.log(text.includes(\"world\"));", "expected": "true" }, + { "code": "const text = \"hello world\";\nconsole.log(text.indexOf(\"world\") !== -1);", "expected": "true" } + ] + } } ] } diff --git a/src/data/questions/jsb-9-regex.json b/src/data/questions/jsb-9-regex.json index 9fbc04f..85cdff6 100644 --- a/src/data/questions/jsb-9-regex.json +++ b/src/data/questions/jsb-9-regex.json @@ -140,6 +140,145 @@ { "code": "const m = \"2026-07-10\".match(/(\\d{4})-(\\d{2})/);\nconsole.log(m[1], m[2]);", "expected": "2026 07" } ] } + }, + { + "id": "jsb-9-q7", + "type": "predict-output", + "difficulty": 2, + "topic": "replace 搭配捕獲組,用 $1 代回抓到的內容", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/String/replace", + "story": "", + "prompt": "把日期格式從斜線改成連字號,順便重新排列。這段印出什麼?", + "code": "const s = \"2026/07/10\";\nconst formatted = s.replace(/(\\d{4})\\/(\\d{2})\\/(\\d{2})/, \"$1-$2-$3\");\nconsole.log(formatted);", + "options": [ + { "id": "a", "text": "2026-07-10" }, + { "id": "b", "text": "$1-$2-$3" }, + { "id": "c", "text": "2026/07/10" }, + { "id": "d", "text": "會報錯,$1 不是合法語法" } + ], + "answer": "a", + "explanation": "替換字串裡的 $1、$2、$3 分別代表第 1、2、3 個捕獲組抓到的內容,跟 match 拿到的 m[1]、m[2] 是同一批東西,只是這次直接拼進替換文字裡。這招常用來重新排版日期、電話、姓名格式,不用自己再拆字串組回去。", + "verify": { + "checks": [ + { "code": "const s = \"2026/07/10\";\nconst formatted = s.replace(/(\\d{4})\\/(\\d{2})\\/(\\d{2})/, \"$1-$2-$3\");\nconsole.log(formatted);", "expected": "2026-07-10" } + ] + } + }, + { + "id": "jsb-9-q8", + "type": "same-or-not", + "difficulty": 1, + "topic": "i 旗標忽略大小寫", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide/Regular_expressions", + "story": "", + "prompt": "同一條規則,一個沒加 i、一個加了 i。比對 \"ABC\" 的結果一樣嗎?", + "code": "console.log(/abc/.test(\"ABC\"));\nconsole.log(/abc/i.test(\"ABC\"));", + "options": [ + { "id": "a", "text": "不一樣:沒加 i 是 false,加了 i 是 true" }, + { "id": "b", "text": "一樣,都是 true" }, + { "id": "c", "text": "一樣,都是 false" }, + { "id": "d", "text": "不一樣:沒加 i 是 true,加了 i 是 false" } + ], + "answer": "a", + "explanation": "正規表達式預設是「大小寫敏感」,abc 跟 ABC 是不同的字。i(ignore case)旗標關掉這個敏感度,讓大小寫視為相同。跟 g(全部找)一樣,i 也是寫在收尾斜線後面,兩個旗標甚至可以一起用:/abc/gi。", + "verify": { + "checks": [ + { "code": "console.log(/abc/.test(\"ABC\"));\nconsole.log(/abc/i.test(\"ABC\"));", "expected": "false\ntrue" } + ] + } + }, + { + "id": "jsb-9-q9", + "type": "predict-output", + "difficulty": 2, + "topic": "\\w \\d \\s 字元類別代表誰", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide/Regular_expressions", + "story": "", + "prompt": "驗證帳號格式只准英數字和底線,不准空白。這段印出什麼?", + "code": "console.log(/^\\w+$/.test(\"user_01\"));\nconsole.log(/^\\w+$/.test(\"user 01\"));", + "options": [ + { "id": "a", "text": "true 和 false" }, + { "id": "b", "text": "true 和 true" }, + { "id": "c", "text": "false 和 false" }, + { "id": "d", "text": "false 和 true" } + ], + "answer": "a", + "explanation": "\\w 是「文字字元」的縮寫,等於 [A-Za-z0-9_](英文字母、數字、底線),但不含空白。\"user_01\" 全是這些合法字元,過關;\"user 01\" 中間有空格,落榜。常見的字元類別還有 \\d(數字,等於 [0-9])和 \\s(空白,含空格、tab、換行)。這三個是寫驗證規則時最常組合使用的積木。", + "verify": { + "checks": [ + { "code": "console.log(/^\\w+$/.test(\"user_01\"));\nconsole.log(/^\\w+$/.test(\"user 01\"));", "expected": "true\nfalse" } + ] + } + }, + { + "id": "jsb-9-q10", + "type": "predict-output", + "difficulty": 2, + "topic": "量詞 + 和 * 的差別:至少一個 vs 零個也行", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide/Regular_expressions", + "story": "", + "prompt": "兩條規則只差一個符號,比對沒有 b 的 \"ac\"。這段印出什麼?", + "code": "console.log(/ab*c/.test(\"ac\"), /ab+c/.test(\"ac\"));", + "options": [ + { "id": "a", "text": "true false" }, + { "id": "b", "text": "false true" }, + { "id": "c", "text": "true true" }, + { "id": "d", "text": "false false" } + ], + "answer": "a", + "explanation": "量詞接在字元後面,規定「這個字元可以出現幾次」:* 是零個以上(可以完全不出現),+ 是一個以上(至少要有一個)。ab*c 允許 b 不出現,所以 \"ac\" 也算符合;ab+c 硬性要求至少一個 b,\"ac\" 沒有 b 就不符合。另外還有 ? 代表零個或一個(可有可無),三者常搞混,記住「* 可以沒有、+ 至少一個、? 最多一個」。", + "verify": { + "checks": [ + { "code": "console.log(/ab*c/.test(\"ac\"), /ab+c/.test(\"ac\"));", "expected": "true false" } + ] + } + }, + { + "id": "jsb-9-q11", + "type": "predict-output", + "difficulty": 3, + "topic": "regex.exec 搭配 g 旗標:每呼叫一次只挖一個", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec", + "story": "", + "prompt": "同一個帶 g 旗標的正規表達式呼叫兩次 exec。這段印出什麼?", + "code": "const re = /\\d+/g;\nconst str = \"a1b22c333\";\nconsole.log(re.exec(str)[0]);\nconsole.log(re.exec(str)[0]);", + "options": [ + { "id": "a", "text": "1 然後 22" }, + { "id": "b", "text": "1 然後 1" }, + { "id": "c", "text": "1 然後 333" }, + { "id": "d", "text": "1 22 333 一次全部印出" } + ], + "answer": "a", + "explanation": "match(/…/g) 一次把全部符合的都撈出來,但 exec 不是——帶 g 旗標的正規表達式物件會記住自己上次找到哪(存在 lastIndex 屬性),每呼叫一次 exec 只往後找下一個。第一次從頭找到 \"1\",lastIndex 移到它後面;第二次接著找到 \"22\"。想一次拿到全部,通常用 while 迴圈重複呼叫 exec,或乾脆改用 match/matchAll。忘記這個「有狀態」的特性,是 exec 最容易踩的坑。", + "verify": { + "checks": [ + { "code": "const re = /\\d+/g;\nconst str = \"a1b22c333\";\nconsole.log(re.exec(str)[0]);\nconsole.log(re.exec(str)[0]);", "expected": "1\n22" } + ] + } + }, + { + "id": "jsb-9-q12", + "type": "find-bug", + "difficulty": 3, + "topic": "replaceAll 遇到正規表達式,一定要加 g 旗標", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/String/replaceAll", + "story": "", + "prompt": "想把每個連字號都換成底線,程式卻直接爆炸。哪裡錯了?", + "code": "const s = \"a-b-c\";\nconsole.log(s.replaceAll(/-/, \"_\")); // 想要 a_b_c,卻報錯", + "options": [ + { "id": "a", "text": "replaceAll 傳正規表達式時強制要求帶 g 旗標,否則直接丟出 TypeError;要寫 /-/g" }, + { "id": "b", "text": "replaceAll 不支援正規表達式,只能傳字串" }, + { "id": "c", "text": "連字號要逃脫成 \\-,否則不合法" }, + { "id": "d", "text": "應該用 replace 兩次才對" } + ], + "answer": "a", + "explanation": "replace(/-/, \"_\") 不加 g 只換第一個,是「靜靜地換錯」;replaceAll 則是「直接不讓你錯」——如果傳的是正規表達式卻沒帶 g 旗標,JS 會主動丟出 TypeError,逼你正視「你是不是忘了要全部換」這件事。修法很簡單:/-/g。這是語言設計上刻意做的安全網,比 replace 忘記 g 那種默默出錯的行為更好抓。", + "verify": { + "checks": [ + { "code": "const s = \"a-b-c\";\nconsole.log(s.replaceAll(/-/, \"_\"));", "expectError": "String.prototype.replaceAll called with a non-global RegExp argument" }, + { "code": "const s = \"a-b-c\";\nconsole.log(s.replaceAll(/-/g, \"_\"));", "expected": "a_b_c" } + ] + } } ] } diff --git a/src/data/questions/react-1-jsx.json b/src/data/questions/react-1-jsx.json index 6cca4ba..04f9c92 100644 --- a/src/data/questions/react-1-jsx.json +++ b/src/data/questions/react-1-jsx.json @@ -141,6 +141,145 @@ { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Item({ children }) {\n return
  • {children}
  • ;\n}\nfunction App() {\n return (\n \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "" } ] } + }, + { + "id": "react-1-q7", + "type": "predict-output", + "difficulty": 2, + "topic": "Fragment(<>...)不會多一層 DOM", + "docs": "https://react.dev/reference/react/Fragment", + "story": "", + "prompt": "想把兩段文字包在一起回傳,又不想多包一層 div 弄亂版面的 CSS。用 <>... 包起來,渲染出的 HTML 長什麼樣?", + "code": "function Bio() {\n return (\n <>\n

    小美

    \n

    前端工程師

    \n \n );\n}", + "options": [ + { "id": "a", "text": "

    小美

    前端工程師

    —— Fragment 本身不會出現在 HTML 裡" }, + { "id": "b", "text": "

    小美

    前端工程師

    " }, + { "id": "c", "text": "<>

    小美

    前端工程師

    " }, + { "id": "d", "text": "會報錯,兩個標籤中間要用逗號分開" } + ], + "answer": "a", + "explanation": "<>... 是 Fragment 的簡寫,它只在 JSX 世界裡幫你把多個元素「捆成一份」,滿足 return 只能有一個根節點的規則;渲染到瀏覽器時它自己完全不會留下任何標籤——不像
    會多一層,可能打亂原本的 CSS 版面(flex、grid 的直接子元素選取器最怕這種多餘的殼)。需要幫每個項目加 key(例如列表)時,才要寫回完整的 。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Bio() {\n return (\n <>\n

    小美

    \n

    前端工程師

    \n \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "

    小美

    前端工程師

    " } + ] + } + }, + { + "id": "react-1-q8", + "type": "find-bug", + "difficulty": 1, + "topic": "沒有 children 的標籤要自我閉合", + "docs": "https://react.dev/learn/writing-markup-with-jsx", + "story": "", + "prompt": "AI 抄了一段 HTML 貼進 JSX,建置直接失敗,錯誤說 unexpected token。哪裡漏了?", + "code": "function Avatar() {\n return ; // 建置失敗\n}", + "options": [ + { "id": "a", "text": " 沒有內容(no children),JSX 要求自己收尾,得寫成 ,不能像 HTML 一樣開了就不管" }, + { "id": "b", "text": "src 的路徑不能用引號包起來" }, + { "id": "c", "text": "img 標籤在 JSX 裡完全不能用" }, + { "id": "d", "text": "圖片路徑要寫成絕對網址才行" } + ], + "answer": "a", + "explanation": "HTML 允許 、
    、 這類「沒有內容」的標籤開了就不用收尾,但 JSX 比較嚴格:標籤中間沒有 children,就一定要自己閉合,寫成 。這條規則對所有標籤都成立,不只 img——自訂元件沒有 children 時(例如 )也是同樣寫法。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Avatar() {\n return ;\n}\nconsole.log(renderToStaticMarkup());", "expected": "" } + ] + } + }, + { + "id": "react-1-q9", + "type": "predict-output", + "difficulty": 2, + "topic": "註解要包在大括號裡:{/* */}", + "docs": "https://react.dev/learn/javascript-in-jsx-with-curly-braces", + "story": "", + "prompt": "AI 想在 JSX 裡加個提醒,直接抄 JS 的 // 註解寫法。畫面會發生什麼事?", + "code": "function Note() {\n return (\n
    \n // 這是待辦事項\n

    買牛奶

    \n
    \n );\n}", + "options": [ + { "id": "a", "text": "畫面上真的印出一行「// 這是待辦事項」的文字 —— JSX 裡標籤中間的文字不會被當成註解" }, + { "id": "b", "text": "// 這是待辦事項 被自動忽略,只顯示「買牛奶」" }, + { "id": "c", "text": "會報錯,JSX 裡完全不能寫註解" }, + { "id": "d", "text": "// 這是待辦事項 會被轉成 HTML 的 " } + ], + "answer": "a", + "explanation": "JSX 標籤之間的文字通通會被當成要顯示的內容,// 開頭在這裡完全不是註解語法,就只是一串照樣被印出來的文字。JSX 裡想寫註解,得先開大括號的窗回到 JS 世界,再用 /* */:{/* 這是待辦事項 */}。單行 // 沒辦法用,因為大括號跟裡面的內容要在同一段表達式裡結束。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Note() {\n return (\n
    \n // 這是待辦事項\n

    買牛奶

    \n
    \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "
    // 這是待辦事項

    買牛奶

    " }, + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Note() {\n return (\n
    \n {/* 這是待辦事項 */}\n

    買牛奶

    \n
    \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "

    買牛奶

    " } + ] + } + }, + { + "id": "react-1-q10", + "type": "find-bug", + "difficulty": 2, + "topic": "JSX 只能回傳單一根節點", + "docs": "https://react.dev/learn/writing-markup-with-jsx", + "story": "", + "prompt": "這個元件想同時回傳兩個並排的段落,建置卻失敗了。錯在哪?", + "code": "function Bio() {\n return (\n

    小美

    \n

    前端工程師

    \n ); // 建置失敗\n}", + "options": [ + { "id": "a", "text": "return 只能有一個根節點;兩個並排的元素要包進一個
    ,或用不會多一層 DOM 的 <>... 包起來" }, + { "id": "b", "text": "h1 和 p 這兩種標籤不能一起出現" }, + { "id": "c", "text": "兩個標籤中間少了逗號分隔" }, + { "id": "d", "text": "小美 要用大括號包起來才合法" } + ], + "answer": "a", + "explanation": "JSX 的 return 骨子裡就是「一個函式呼叫」,函式只能 return 一個值,所以兩個並排的標籤必須先合併成一個節點——包一個
    ,或用上一題的 Fragment <>... 避免多一層 DOM。這是初學者最常撞到的建置錯誤之一,錯誤訊息通常會寫 Adjacent JSX elements must be wrapped。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Bio() {\n return (\n <>\n

    小美

    \n

    前端工程師

    \n \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "

    小美

    前端工程師

    " } + ] + } + }, + { + "id": "react-1-q11", + "type": "same-or-not", + "difficulty": 1, + "topic": "class 要寫成 className", + "docs": "https://react.dev/learn/writing-markup-with-jsx", + "story": "", + "prompt": "AI 抄了一段純 HTML 貼進 JSX,樣式完全沒套用。這兩種寫法效果一樣嗎?", + "code": "// 版本一\n
    內容
    \n\n// 版本二\n
    內容
    ", + "options": [ + { "id": "a", "text": "不一樣:class 是 JS 的保留字,JSX 認得的是 className;用 class 不會報錯,但實際渲染出的屬性不一樣" }, + { "id": "b", "text": "一樣,React 會自動把 class 換成 className" }, + { "id": "c", "text": "不一樣:版本一會直接建置失敗" }, + { "id": "d", "text": "一樣,兩者都不會被渲染出來" } + ], + "answer": "a", + "explanation": "class 在 JavaScript 裡是保留給 class 語法用的關鍵字,JSX 沿用 JS 的規則,所以 DOM 的 class 屬性在 JSX 裡改名叫 className,這樣才不會撞名。忘記改名,React 不會報錯、但也不會把它當成標準的 class 處理——渲染出來的 HTML 屬性名稱就是不一樣,樣式套用邏輯因此「壞掉但不出聲」,是很容易被忽略的一種 bug。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nconsole.log(renderToStaticMarkup(
    內容
    ));\nconsole.log(renderToStaticMarkup(
    內容
    ));", "expected": "
    內容
    \n
    內容
    " } + ] + } + }, + { + "id": "react-1-q12", + "type": "fill-in", + "difficulty": 2, + "topic": "label 的 for 要寫成 htmlFor", + "docs": "https://react.dev/learn/writing-markup-with-jsx", + "story": "", + "prompt": "把空格填起來,讓 label 正確關聯到 id 是 email 的輸入框(HTML 原本寫 for,JSX 裡要換個名字)", + "code": "function EmailField() {\n return (\n
    \n \n \n
    \n );\n}", + "options": [ + { "id": "a", "text": "htmlFor" }, + { "id": "b", "text": "for" }, + { "id": "c", "text": "labelFor" }, + { "id": "d", "text": "target" } + ], + "answer": "a", + "explanation": "for 跟 class 一樣,在 JS 裡是保留字(for 迴圈用的那個 for),JSX 於是把 label 關聯輸入框的屬性改名成 htmlFor。這類「HTML 屬性到 JSX 要改名」的規則不只 class、for,還有像 tabindex→tabIndex 這種駝峰化,但 class 跟 for 是最常被 AI 抄漏的兩個地雷。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction EmailField() {\n return (\n
    \n \n \n
    \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "
    " } + ] + } } ] } diff --git a/src/data/questions/react-10-reducer-context.json b/src/data/questions/react-10-reducer-context.json index 1ce955b..aed705a 100644 --- a/src/data/questions/react-10-reducer-context.json +++ b/src/data/questions/react-10-reducer-context.json @@ -140,6 +140,144 @@ { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nimport { createContext, useContext } from 'react'\nconst ThemeContext = createContext(\"light\");\nfunction Button() {\n const theme = useContext(ThemeContext);\n return ;\n}\nfunction App() {\n return (\n \n \n " } ] } + }, + { + "id": "react-10-q7", + "type": "predict-output", + "difficulty": 2, + "topic": "多個 action type 的 switch", + "docs": "https://react.dev/learn/extracting-state-logic-into-a-reducer", + "story": "", + "prompt": "reducer 認得 increment / decrement / reset 三種 action。依序餵完這串 action,最後 count 是多少?", + "code": "function reducer(state, action) {\n switch (action.type) {\n case \"increment\":\n return { count: state.count + 1 };\n case \"decrement\":\n return { count: state.count - 1 };\n case \"reset\":\n return { count: 0 };\n default:\n return state;\n }\n}\n\nlet s = { count: 0 };\ns = reducer(s, { type: \"increment\" });\ns = reducer(s, { type: \"increment\" });\ns = reducer(s, { type: \"decrement\" });\ns = reducer(s, { type: \"reset\" });\ns = reducer(s, { type: \"increment\" });\nconsole.log(s.count);", + "options": [ + { "id": "a", "text": "1" }, + { "id": "b", "text": "2" }, + { "id": "c", "text": "0" }, + { "id": "d", "text": "5" } + ], + "answer": "a", + "explanation": "跟著 action 一步步算:0 →(increment) 1 →(increment) 2 →(decrement) 1 →(reset) 0 →(increment) 1。真實專案的 reducer 通常不只一種 action,用 switch 把每種「事件種類」對應到各自的狀態轉換邏輯,是 useReducer 最常見的樣子——讀 case 就等於讀懂這個元件所有可能發生的狀態變化。", + "verify": { + "checks": [ + { "code": "function reducer(state, action) {\n switch (action.type) {\n case \"increment\":\n return { count: state.count + 1 };\n case \"decrement\":\n return { count: state.count - 1 };\n case \"reset\":\n return { count: 0 };\n default:\n return state;\n }\n}\nlet s = { count: 0 };\ns = reducer(s, { type: \"increment\" });\ns = reducer(s, { type: \"increment\" });\ns = reducer(s, { type: \"decrement\" });\ns = reducer(s, { type: \"reset\" });\ns = reducer(s, { type: \"increment\" });\nconsole.log(s.count);", "expected": "1" } + ] + } + }, + { + "id": "react-10-q8", + "type": "find-bug", + "difficulty": 2, + "topic": "沒有 default case,未知 action 把 state 弄丟", + "docs": "https://react.dev/learn/extracting-state-logic-into-a-reducer", + "story": "", + "prompt": "打了一個以為安全的 action.type,結果整個 state 直接消失變成 undefined。哪裡錯了?", + "code": "function reducer(state, action) {\n switch (action.type) {\n case \"add\":\n return { count: state.count + action.n };\n } // B 行:沒有 default\n}\n\nlet s = { count: 0 };\ns = reducer(s, { type: \"unknown\" });\nconsole.log(s);", + "options": [ + { "id": "a", "text": "B 行缺了 default: return state:遇到不認得的 action.type,switch 沒有任何 case 命中,函式落到底也沒有 return,靜靜地回傳 undefined,state 就這樣不見了" }, + { "id": "b", "text": "case \"add\" 的邏輯寫錯了" }, + { "id": "c", "text": "action 物件少了 n 欄位" }, + { "id": "d", "text": "console.log 不能印物件" } + ], + "answer": "a", + "explanation": "switch 沒有 default,代表「萬一沒人接得住這個 action」根本沒有安全網——函式執行到底、沒有明確 return,JS 自動補上 undefined。reducer 的鐵律:不認識的 action.type 一律 return state(原封不動传回去),而不是什麼都不回傳。這個 bug 特別陰險,因為打錯字(例如 \"ad\" 打成 \"add\")也會觸發同樣的症狀——整個 state 憑空消失。", + "verify": { + "checks": [ + { "code": "function reducer(state, action) {\n switch (action.type) {\n case \"add\":\n return { count: state.count + action.n };\n }\n}\nlet s = { count: 0 };\ns = reducer(s, { type: \"unknown\" });\nconsole.log(s);", "expected": "undefined" }, + { "code": "function reducer(state, action) {\n switch (action.type) {\n case \"add\":\n return { count: state.count + action.n };\n default:\n return state;\n }\n}\nlet s = { count: 0 };\ns = reducer(s, { type: \"unknown\" });\nconsole.log(s);", "expected": "{ count: 0 }" } + ] + } + }, + { + "id": "react-10-q9", + "type": "same-or-not", + "difficulty": 3, + "topic": "Context 適合放哪種資料,不適合放哪種", + "docs": "https://react.dev/learn/passing-data-deeply-with-context", + "story": "", + "prompt": "把每個按鍵輸入都即時廣播進全域 Context,跟只放在 Editor 自己的 state 裡,對完全不相干的 Sidebar 元件有差嗎?", + "code": "// 版本一:文字內容放進全域 Context\nconst FormContext = createContext();\nfunction App() {\n const [text, setText] = useState(\"\");\n return (\n \n {/* 跟輸入框無關,但也訂閱了同一個 Context */}\n \n \n );\n}\n\n// 版本二:text 只留在 Editor 自己的 state,不經過 Context", + "options": [ + { "id": "a", "text": "有差:版本一每打一個字,Sidebar 也會跟著重繪,因為它訂閱了同一個 Context;版本二把高頻變動的資料留在區域 state,Sidebar 完全不受影響。Context 適合主題、登入使用者這種低頻資料,不適合塞進每個按鍵都在變的表單值" }, + { "id": "b", "text": "沒差,Context 不會導致額外重繪" }, + { "id": "c", "text": "沒差,React 會自動幫沒用到 text 的元件跳過重繪" }, + { "id": "d", "text": "有差,但方向相反:版本二才會拖累 Sidebar" } + ], + "answer": "a", + "explanation": "Context 的訂閱是「整包 value 換了,所有用 useContext 讀它的元件都重繪」,沒有像 props 那樣的細粒度比對。react.dev 明白建議:Context 適合「不常變、但很多地方要用」的資料(主題、語系、登入使用者),高頻變動的資料(打字中的表單、滑鼠座標)留在區域 state 或用其他方案,否則就會出現這題 Sidebar 這種「明明無關卻被拖著重繪」的效能問題。", + "verify": { + "checks": [], + "manual": "重繪次數需要瀏覽器渲染計數才能實測;判準依 react.dev passing-data-deeply-with-context 的「何時使用 context」與效能提醒" + } + }, + { + "id": "react-10-q10", + "type": "fill-in", + "difficulty": 2, + "topic": "action 可以帶多個資料欄位(payload 模式)", + "docs": "https://react.dev/learn/extracting-state-logic-into-a-reducer", + "story": "", + "prompt": "reducer 認得 { type: \"add_todo\", text: 字串 },會把 text 包成一筆新待辦加進陣列。把空格填起來,新增一筆「買牛奶」", + "code": "function reducer(state, action) {\n switch (action.type) {\n case \"add_todo\":\n return { todos: [...state.todos, { text: action.text, done: false }] };\n default:\n return state;\n }\n}\n\nconst next = reducer({ todos: [] }, ____);\nconsole.log(next.todos[0].text);", + "options": [ + { "id": "a", "text": "{ type: \"add_todo\", text: \"買牛奶\" }" }, + { "id": "b", "text": "\"add_todo\"" }, + { "id": "c", "text": "{ type: \"add_todo\" }" }, + { "id": "d", "text": "\"買牛奶\"" } + ], + "answer": "a", + "explanation": "action 不是只能帶一個 type,type 只是「事件名」,其他欄位隨需求自訂——這裡用 text 帶要新增的內容,reducer 裡用 action.text 取出來。選項 c 少了 text 欄位,reducer 會塞進一筆內容是 undefined 的待辦;選項 b、d 連 type 都不完整或格式不對,reducer 的 switch 完全對不上。dispatch 丟的物件要跟 reducer 讀的欄位一一對應,這是 q5 學過的規則的延伸。", + "verify": { + "checks": [ + { "code": "function reducer(state, action) {\n switch (action.type) {\n case \"add_todo\":\n return { todos: [...state.todos, { text: action.text, done: false }] };\n default:\n return state;\n }\n}\nconst next = reducer({ todos: [] }, { type: \"add_todo\", text: \"買牛奶\" });\nconsole.log(next.todos[0].text);", "expected": "買牛奶" } + ] + } + }, + { + "id": "react-10-q11", + "type": "predict-output", + "difficulty": 3, + "topic": "忘記包 Provider,且沒設有意義的預設值", + "docs": "https://react.dev/learn/passing-data-deeply-with-context", + "story": "", + "prompt": "createContext() 沒給任何預設值,Greeting 又忘記包在 Provider 裡就直接解構 user.name。執行會發生什麼事?", + "code": "const UserContext = createContext(); // 沒有給預設值\n\nfunction Greeting() {\n const user = useContext(UserContext);\n return

    哈囉,{user.name}

    ; // 直接解構 user.name\n}\n\nfunction App() {\n return ; // 忘記包 \n}", + "options": [ + { "id": "a", "text": "會噴錯:Cannot read properties of undefined (reading 'name')——沒包 Provider 時 useContext 拿到的是 createContext() 的預設值,這裡沒給、就是 undefined,直接讀 .name 當場炸掉" }, + { "id": "b", "text": "顯示「哈囉,undefined」,畫面正常但字有點怪" }, + { "id": "c", "text": "顯示「哈囉,」,name 被當成空字串" }, + { "id": "d", "text": "React 會主動報錯提醒「忘記包 Provider」" } + ], + "answer": "a", + "explanation": "q4 學過「沒包 Provider 就退回預設值」,這題是它的陰暗面:如果 createContext() 根本沒給預設值(或給了 null),退回去的就是 undefined/null,接下來只要程式碼對它做了 .name 這種解構或屬性存取,就會直接丟出 TypeError。教訓:要嘛在 createContext 給一個「安全」的預設物件,要嘛在使用端做防呆判斷,不要假設 Provider 一定有包好。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nimport { createContext, useContext } from 'react'\nconst UserContext = createContext();\nfunction Greeting() {\n const user = useContext(UserContext);\n return

    哈囉,{user.name}

    ;\n}\nfunction App() {\n return ;\n}\nconsole.log(renderToStaticMarkup());", "expectError": "Cannot read properties of undefined" } + ] + } + }, + { + "id": "react-10-q12", + "type": "predict-output", + "difficulty": 3, + "topic": "reducer 搭配 Context:資料與 dispatch 分開傳", + "docs": "https://react.dev/learn/scaling-up-with-reducer-and-context", + "story": "", + "prompt": "TaskList 只透過 TasksContext 讀取待辦清單,完全不需要碰 dispatch,dispatch 是另一個獨立的 Context。這樣渲染出什麼?", + "code": "const TasksContext = createContext(null);\nconst TasksDispatchContext = createContext(null);\n\nfunction reducer(tasks, action) {\n switch (action.type) {\n case \"added\":\n return [...tasks, { id: action.id, text: action.text }];\n default:\n return tasks;\n }\n}\n\nfunction TaskList() {\n const tasks = useContext(TasksContext);\n return
      {tasks.map((t) =>
    • {t.text}
    • )}
    ;\n}\n\nfunction TaskApp() {\n const [tasks, dispatch] = useReducer(reducer, [{ id: 1, text: \"買菜\" }]);\n return (\n \n \n \n \n \n );\n}", + "options": [ + { "id": "a", "text": "
    • 買菜
    —— TaskList 只讀 TasksContext,dispatch 完全不用經過它,也不用一路當 props 往下傳" }, + { "id": "b", "text": "會報錯,TaskList 沒有拿到 dispatch" }, + { "id": "c", "text": "
      ,因為 dispatch 沒有傳給 TaskList 所以清單是空的" }, + { "id": "d", "text": "
    • 買菜
    • ,缺少外層 ul" } + ], + "answer": "a", + "explanation": "這是 reducer 加 Context 的經典組合技:把「資料」(tasks)和「改變資料的方法」(dispatch)拆成兩個獨立的 Context,各自需要的元件各自訂閱——讀資料的元件(TaskList)不用理會 dispatch,要觸發變更的元件(例如新增按鈕)才去訂閱 TasksDispatchContext。這樣不管元件樹多深,都不用把 dispatch 當成 props 一層層往下傳,這正是 react.dev「scaling up with reducer and context」教的架構。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nimport { createContext, useContext, useReducer } from 'react'\nconst TasksContext = createContext(null);\nconst TasksDispatchContext = createContext(null);\nfunction reducer(tasks, action) {\n switch (action.type) {\n case \"added\":\n return [...tasks, { id: action.id, text: action.text }];\n default:\n return tasks;\n }\n}\nfunction TaskList() {\n const tasks = useContext(TasksContext);\n return
        {tasks.map((t) =>
      • {t.text}
      • )}
      ;\n}\nfunction TaskApp() {\n const [tasks, dispatch] = useReducer(reducer, [{ id: 1, text: \"買菜\" }]);\n return (\n \n \n \n \n \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "
      • 買菜
      " } + ] + } } ] } diff --git a/src/data/questions/react-11-refs.json b/src/data/questions/react-11-refs.json index 360f6d6..2d609d1 100644 --- a/src/data/questions/react-11-refs.json +++ b/src/data/questions/react-11-refs.json @@ -133,6 +133,141 @@ "checks": [], "manual": "屬設計選擇題,無單一執行輸出;判準依 react.dev referencing-values-with-refs 的使用時機" } + }, + { + "id": "react-11-q7", + "type": "predict-output", + "difficulty": 2, + "topic": "useRef 的初始值只在第一次渲染生效", + "docs": "https://react.dev/learn/referencing-values-with-refs", + "story": "", + "prompt": "父層一開始傳 startFrom=0,之後改傳 startFrom=100 讓 Stopwatch 重新渲染。第二次渲染時畫面顯示多少?", + "code": "function Stopwatch({ startFrom }) {\n const initial = useRef(startFrom);\n return

      {initial.current}

      ;\n}\n// 第一次:\n// 第二次:(重新渲染,不是新的元件實例)", + "options": [ + { "id": "a", "text": "一直是 0 —— useRef(startFrom) 只有第一次渲染會拿 startFrom 當 current 的初始值,之後重繪即使帶了新的值,React 也不會理會,current 維持第一次的結果" }, + { "id": "b", "text": "變成 100,因為 useRef 每次渲染都會用新的參數重設 current" }, + { "id": "c", "text": "0 和 100 交替出現" }, + { "id": "d", "text": "會報錯,useRef 不能接收 props 當初始值" } + ], + "answer": "a", + "explanation": "useRef 只在「元件第一次渲染」那一刻讀一次傳進去的初始值,之後不管重繪幾次、傳的參數怎麼變,都不會再理會那個參數——這跟 useState(initialValue) 的行為一模一樣(state 的初始值也只在首次渲染生效),只是 ref 改了不會觸發重繪。如果需要讓 current 跟著某個值更新,得自己在 effect 或事件裡手動賦值,React 不會替你做。", + "verify": { + "checks": [], + "manual": "需要真實的多輪渲染才能觀察 current 是否被重設;機制依 react.dev referencing-values-with-refs(useRef 的引數只在初次渲染使用)" + } + }, + { + "id": "react-11-q8", + "type": "find-bug", + "difficulty": 3, + "topic": "ref 不能在渲染期間讀寫", + "docs": "https://react.dev/learn/referencing-values-with-refs", + "story": "", + "prompt": "這個記錄器偶爾會出現奇怪的重複計數(例如 StrictMode 下數字比預期多)。哪裡犯規了?", + "code": "function Logger({ value }) {\n const historyRef = useRef([]);\n\n historyRef.current.push(value); // B 行:在渲染期間直接改 ref\n\n return

      目前共記錄 {historyRef.current.length} 筆

      ;\n}", + "options": [ + { "id": "a", "text": "B 行違規:官方明講渲染期間不能讀寫 ref.current(極少數 lazy 初始化例外),因為同一次渲染可能被 React 重複呼叫(例如 StrictMode 或未來的並行特性),每呼叫一次就多 push 一筆,數字自然對不上;要挪到 useEffect 或事件處理器裡" }, + { "id": "b", "text": "useRef([]) 不能放陣列" }, + { "id": "c", "text": "p 標籤裡不能放大括號運算" }, + { "id": "d", "text": "value 這個 prop 命名有誤" } + ], + "answer": "a", + "explanation": "渲染函式本身應該是「可重複執行也不出錯」的純函式(這也是 StrictMode 刻意把它多跑一次來抓錯的原因)。ref 的讀寫如果放在渲染路徑上,多跑一次就多副作用一次,數字對不上只是最容易發現的症狀,更隱蔽的情況甚至不會馬上出錯。安全的地方只有兩個:useEffect(渲染完成之後)、事件處理器(使用者觸發的當下)。", + "verify": { + "checks": [], + "manual": "需要 StrictMode 下的重複渲染才能觀察到重複 push;判準依 react.dev referencing-values-with-refs 的「refs 的最佳實踐」(避免在渲染期間讀寫 ref.current)" + } + }, + { + "id": "react-11-q9", + "type": "predict-output", + "difficulty": 3, + "topic": "用 ref 記錄上一次的 state 值", + "docs": "https://react.dev/learn/referencing-values-with-refs", + "story": "", + "prompt": "第一次渲染 value=1。父層把 value 改成 2,觸發第二次渲染。第二次渲染畫面上顯示什麼?", + "code": "function Tracker({ value }) {\n const prevRef = useRef(value);\n\n useEffect(() => {\n prevRef.current = value;\n });\n\n return (\n

      目前:{value},上一次:{prevRef.current}

      \n );\n}", + "options": [ + { "id": "a", "text": "「目前:2,上一次:1」—— useEffect 要等這一輪畫面畫出來之後才會執行、把 ref 更新成最新值,所以「這一次渲染」讀到的 prevRef.current 還是上一輪殘留的舊值" }, + { "id": "b", "text": "「目前:2,上一次:2」,effect 在渲染前就先更新完了" }, + { "id": "c", "text": "「目前:1,上一次:2」,數值顯示相反" }, + { "id": "d", "text": "會報錯,useRef 不能搭配 useEffect 使用" } + ], + "answer": "a", + "explanation": "這是「記住上一輪值」的經典寫法:render 階段讀到的 prevRef.current 永遠是「effect 尚未來得及更新前」的舊值,effect 本身要等瀏覽器畫完畫面才跑(react-4 學過的時序),跑完才把 ref 更新成這一輪的 value,留給下一次渲染去讀。所以每次渲染畫面上看到的「上一次」,其實就是名副其實的上一輪的值,不會提前更新成當下這輪。", + "verify": { + "checks": [], + "manual": "需要多輪渲染與 effect 時序才能觀察;機制依 react.dev referencing-values-with-refs(ref 跨渲染存活+effect 在畫面呈現之後才執行)" + } + }, + { + "id": "react-11-q10", + "type": "fill-in", + "difficulty": 2, + "topic": "callback ref:ref 也可以是一個函式", + "docs": "https://react.dev/learn/manipulating-the-dom-with-refs", + "story": "", + "prompt": "不想額外宣告 useRef,直接用「函式形式」的 ref:DOM 節點掛載時 React 會呼叫它、把節點當參數傳進來,掛載當下就聚焦。把空格填起來", + "code": "function AutoFocusInput() {\n return (\n \n );\n}", + "options": [ + { "id": "a", "text": "(node) => node?.focus()" }, + { "id": "b", "text": "useRef(null)" }, + { "id": "c", "text": "\"input\"" }, + { "id": "d", "text": "() => {}" } + ], + "answer": "a", + "explanation": "ref 屬性除了掛 useRef() 回傳的物件,也能直接放一個函式——這叫 callback ref。React 掛載 DOM 節點時會呼叫它、把節點當參數傳進來;卸載時再呼叫一次、這次傳 null。這裡示範最常見的用途:節點一掛好就立刻聚焦,不必等 useEffect 跑一輪。選項 b 是物件形式的 ref,語法上也合法但不會「主動」做任何事,需要另外在 effect 裡呼叫 .focus();選項 c、d 都接不到真正的 DOM 節點。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction AutoFocusInput() {\n return (\n node?.focus()}\n placeholder=\"自動聚焦\"\n />\n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "" } + ] + } + }, + { + "id": "react-11-q11", + "type": "fill-in", + "difficulty": 2, + "topic": "React 19:ref 直接當成一般 prop 接收", + "docs": "https://react.dev/blog/2024/12/05/react-19", + "story": "", + "prompt": "React 19 起,函式元件不需要 forwardRef 包裹,可以直接把 ref 當成一般 prop 接收。把空格填起來", + "code": "function MyInput({ placeholder, ____ }) {\n return ;\n}\n\n// 使用:", + "options": [ + { "id": "a", "text": "ref" }, + { "id": "b", "text": "forwardRef" }, + { "id": "c", "text": "this.ref" }, + { "id": "d", "text": "props.ref" } + ], + "answer": "a", + "explanation": "React 19 之前,函式元件想接收父層傳來的 ref,一定要用 forwardRef((props, ref) => ...) 包一層,ref 才不會被當成「未知 prop」擋掉。React 19 起這層包裝變成非必要:直接在解構的 props 裡寫出 ref,它就跟其他 prop 一樣被傳進來、可以直接掛到內部的 DOM 節點上。forwardRef 沒有被移除,只是不再是唯一寫法(下一題會比較兩者)。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nimport { useRef } from 'react'\nfunction MyInput({ placeholder, ref }) {\n return ;\n}\nfunction App() {\n const someRef = useRef(null);\n return ;\n}\nconsole.log(renderToStaticMarkup());", "expected": "" } + ] + } + }, + { + "id": "react-11-q12", + "type": "same-or-not", + "difficulty": 2, + "topic": "forwardRef 舊寫法 vs React 19 的 ref-as-prop", + "docs": "https://react.dev/blog/2024/12/05/react-19", + "story": "", + "prompt": "一個用舊寫法的 forwardRef,一個用 React 19 新寫法直接收 ref。外部用起來效果一樣嗎?", + "code": "// 舊寫法\nconst MyInputOld = forwardRef(function MyInputOld(props, ref) {\n return ;\n});\n\n// React 19 新寫法\nfunction MyInputNew({ placeholder, ref }) {\n return ;\n}", + "options": [ + { "id": "a", "text": "效果一樣:兩者外部用起來都能把 ref 轉交給內部的 input 節點;forwardRef 是 React 19 之前的必要包裝,現在保留下來是為了相容舊程式碼,新專案直接把 ref 當一般 prop 接收即可,不用再包一層" }, + { "id": "b", "text": "不一樣,只有新寫法能用" }, + { "id": "c", "text": "不一樣,forwardRef 在 React 19 已經被移除" }, + { "id": "d", "text": "兩者都會報錯,同一個 ref 不能給兩個元件共用" } + ], + "answer": "a", + "explanation": "forwardRef 沒有被移除、也還能正常運作,只是 React 19 之後它不再是「唯一」能接住 ref 的辦法。挑哪種寫法看情境:維護舊程式碼或相容較舊的 React 版本就留著 forwardRef;新寫的元件可以直接把 ref 寫進解構的 props 裡,少一層包裝,程式碼更直覺。要留意的是本題範例只驗證兩者在伺服器端渲染出的 HTML 結構相同——ref.current 真的指到 DOM 節點,需要在瀏覽器環境掛載後才觀察得到。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nimport { forwardRef, useRef } from 'react'\nconst MyInputOld = forwardRef(function MyInputOld(props, ref) {\n return ;\n});\nfunction MyInputNew({ placeholder, ref }) {\n return ;\n}\nfunction App() {\n const r1 = useRef(null);\n const r2 = useRef(null);\n return (\n
      \n \n \n
      \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "
      " } + ] + } } ] } diff --git a/src/data/questions/react-2-props.json b/src/data/questions/react-2-props.json index 74bf790..2b5ef88 100644 --- a/src/data/questions/react-2-props.json +++ b/src/data/questions/react-2-props.json @@ -140,6 +140,147 @@ { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Price({ discount }) {\n return (\n

      折扣:{discount || \"無\"}|{discount ?? \"無\"}

      \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "

      折扣:無|0

      " } ] } + }, + { + "id": "react-2-q7", + "type": "find-bug", + "difficulty": 2, + "topic": "props 是唯讀的,元件不該自己改", + "docs": "https://react.dev/learn/keeping-components-pure", + "story": "", + "prompt": "AI 寫的元件想「調整」收到的 count,直接動手改了 props 物件。執行下去會發生什麼事?", + "code": "function Counter(props) {\n props.count = props.count + 1; // B 行\n return

      目前:{props.count}

      ;\n}\n\n// 使用:", + "options": [ + { "id": "a", "text": "直接噴出 TypeError:Cannot assign to read only property——React 在開發模式下把 props 物件凍結起來,改了就報錯" }, + { "id": "b", "text": "正常運作,畫面顯示「目前:3」" }, + { "id": "c", "text": "React 會自動把改過的值同步回父層" }, + { "id": "d", "text": "沒有任何影響,count 這個名字本身就不能被累加" } + ], + "answer": "a", + "explanation": "props 就像函式的參數,是父層傳下來的一份唯讀快照,元件不該偷偷把它改掉——這不只是慣例,React 在開發模式下真的會把每個元件收到的 props 物件凍結(Object.freeze),一改就直接丟 TypeError,而不是悄悄壞掉又不出聲。這是 React 刻意做的保護,逼你老實面對「props 唯讀」這條規則。想要一個「調整後的值」,該做的是另外宣告一個變數:const display = props.count + 1,完全不去碰 props 本身;畫面要能因為使用者操作而變化,靠的是下一章要學的 state。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Counter(props) {\n props.count = props.count + 1;\n return

      目前:{props.count}

      ;\n}\nconsole.log(renderToStaticMarkup());", "expectError": "Cannot assign to read only property" }, + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Counter({ count }) {\n const display = count + 1;\n return

      目前:{display}

      ;\n}\nconsole.log(renderToStaticMarkup());", "expected": "

      目前:3

      " } + ] + } + }, + { + "id": "react-2-q8", + "type": "predict-output", + "difficulty": 2, + "topic": "傳函式當 props,交給子層在事件發生時呼叫", + "docs": "https://react.dev/learn/responding-to-events", + "story": "", + "prompt": "父層把 handleSave 整個(不加括號)當成 onSave prop 傳給子層。渲染這個 App 的當下(還沒按任何按鈕),「已儲存!」會被印出來嗎?", + "code": "function SaveButton({ onSave }) {\n return ;\n}\n\nfunction App() {\n function handleSave() {\n console.log(\"已儲存!\");\n }\n return ;\n}", + "options": [ + { "id": "a", "text": "不會 —— onSave={handleSave} 傳的是函式本身(還沒被呼叫),要等真的觸發 onClick 才會執行" }, + { "id": "b", "text": "會,元件一渲染就會呼叫 handleSave" }, + { "id": "c", "text": "會,因為 SaveButton 裡的 onClick={onSave} 在渲染時就先執行了一次" }, + { "id": "d", "text": "不確定,要看瀏覽器環境才知道" } + ], + "answer": "a", + "explanation": "把函式整個(不加括號呼叫)當成 prop 傳下去,子層拿到的是一個「還沒被呼叫」的函式引用,等真正需要的時機(這裡是按鈕的 onClick)才由子層代為呼叫。這樣父層可以決定「按下去要做什麼」,子層只負責「什麼時候該做」——這是 React 元件溝通最常見的模式。要是不小心寫成 onSave={handleSave()},那就是在渲染當下立刻呼叫並把回傳值(undefined)當成 prop,「已儲存!」會在畫面都還沒畫出來時就先印出來,是很典型的 AI 誤植錯誤。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction SaveButton({ onSave }) {\n return ;\n}\nfunction App() {\n function handleSave() {\n console.log(\"已儲存!\");\n }\n return ;\n}\nrenderToStaticMarkup();\nconsole.log(\"渲染完成\");", "expected": "渲染完成" }, + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction SaveButton({ onSave }) {\n return ;\n}\nfunction App() {\n function handleSave() {\n console.log(\"已儲存!\");\n }\n return ;\n}\nrenderToStaticMarkup();\nconsole.log(\"渲染完成\");", "expected": "已儲存!\n渲染完成" } + ] + } + }, + { + "id": "react-2-q9", + "type": "predict-output", + "difficulty": 3, + "topic": "展開 props:{...props}", + "docs": "https://react.dev/learn/passing-props-to-a-component", + "story": "", + "prompt": "FancyButton 把自己收到的所有 props 原封不動用 {...props} 轉送給內層的 button。畫面渲染出什麼?", + "code": "function FancyButton(props) {\n return " } + ] + } + }, + { + "id": "react-2-q10", + "type": "predict-output", + "difficulty": 3, + "topic": "children 只有一個插槽;多個區塊就用具名 prop 傳 JSX", + "docs": "https://react.dev/learn/passing-props-to-a-component", + "story": "", + "prompt": "Layout 元件想同時收「側邊欄」和「主要內容」兩塊 JSX,只靠 children 塞得下嗎?", + "code": "function Layout({ sidebar, children }) {\n return (\n
      \n \n
      {children}
      \n
      \n );\n}\n\nfunction App() {\n return (\n 選單}>\n

      主要內容

      \n
      \n );\n}", + "options": [ + { "id": "a", "text": "

      主要內容

      —— sidebar 走具名 prop、children 走標籤中間那份,兩者互不衝突" }, + { "id": "b", "text": "只有 main 裡有內容,aside 是空的,因為一個元件只能收一份 JSX" }, + { "id": "c", "text": "會報錯,prop 不能傳遞 JSX 元素" }, + { "id": "d", "text": "sidebar 跟 children 會互相覆蓋,最後只剩其中一個生效" } + ], + "answer": "a", + "explanation": "children 是 React 內建、專門對應「寫在標籤中間」那份內容的 prop,一個元件永遠只有一份 children。但 JSX 元素本身就是普通的值,可以像任何資料一樣透過「具名 prop」多開幾個插槽,例如這裡的 sidebar——這是 Layout、Modal、Card 這類「有多個內容區塊」元件的標準寫法,children 負責主要內容,其餘區塊各自取名傳進去。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Layout({ sidebar, children }) {\n return (\n
      \n \n
      {children}
      \n
      \n );\n}\nfunction App() {\n return (\n 選單}>\n

      主要內容

      \n
      \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "

      主要內容

      " } + ] + } + }, + { + "id": "react-2-q11", + "type": "predict-output", + "difficulty": 2, + "topic": "陣列、物件都能整包當 props 傳下去", + "docs": "https://react.dev/learn/passing-props-to-a-component", + "story": "", + "prompt": "user 是一個物件、tags 是一個陣列,兩個都直接當 props 傳給子元件。畫面顯示什麼?", + "code": "function Profile({ user, tags }) {\n return

      {user.name}({tags.join(\"、\")})

      ;\n}\n\nfunction App() {\n const user = { name: \"小美\" };\n const tags = [\"前端\", \"React\"];\n return ;\n}", + "options": [ + { "id": "a", "text": "小美(前端、React) —— props 能裝任何 JS 值,物件和陣列當然也行,子層照樣能用 .name、.join 操作它們" }, + { "id": "b", "text": "會報錯,props 只能傳字串和數字" }, + { "id": "c", "text": "[object Object](前端,React)" }, + { "id": "d", "text": "小美([\"前端\",\"React\"])" } + ], + "answer": "a", + "explanation": "props 說到底就是「函式的參數」,是個普通的 JS 物件,塞進去的值想是字串、數字、函式、陣列、物件,甚至另一個 JSX 元素都行(上一題就示範了 JSX)。子元件收到後就用平常操作 JS 值的方式處理它——user.name 取欄位、tags.join 串陣列,完全沒有特殊限制,這也是為什麼 React 元件之間傳整包資料(例如一筆商品、一份表單狀態)這麼自然。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Profile({ user, tags }) {\n return

      {user.name}({tags.join(\"、\")})

      ;\n}\nfunction App() {\n const user = { name: \"小美\" };\n const tags = [\"前端\", \"React\"];\n return ;\n}\nconsole.log(renderToStaticMarkup());", "expected": "

      小美(前端、React)

      " } + ] + } + }, + { + "id": "react-2-q12", + "type": "find-bug", + "difficulty": 2, + "topic": "沒傳的 prop 是 undefined,插進文字會印出「undefined」", + "docs": "https://react.dev/learn/passing-props-to-a-component", + "story": "", + "prompt": "AI 沒幫 Avatar 元件的 title 設定預設值,畫面上出現了詭異的「undefined」四個字。哪裡出了問題?", + "code": "function Avatar({ title }) {\n return {\"頭像:\";\n}\n\nfunction App() {\n return ; // 忘了傳 title\n}", + "options": [ + { "id": "a", "text": "沒傳的 prop 值是 undefined,字串相加會把它轉成文字 \"undefined\" 黏進去;要嘛呼叫端補上 title,要嘛給參數預設值 title = \"\"" }, + { "id": "b", "text": "img 標籤不能使用 alt 屬性" }, + { "id": "c", "text": "App 忘了寫 return" }, + { "id": "d", "text": "title 要寫成 this.title 才抓得到值" } + ], + "answer": "a", + "explanation": "props 沒傳,對應的參數就是貨真價實的 undefined,不會自動變成空字串或任何預設值。字串相加時 JS 會把 undefined 轉型成文字 \"undefined\" 接上去,於是畫面出現這個詭異的英文字——這跟第一章「字串 + undefined」的坑是同一個原理,只是換了個舞台在 React 重演。修法呼應 react-2-q1:用解構預設值 { title = \"\" } 擋掉,或是呼叫端記得補齊 title。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Avatar({ title }) {\n return {\"頭像:\";\n}\nfunction App() {\n return ;\n}\nconsole.log(renderToStaticMarkup());", "expected": "\"頭像:undefined\"" }, + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Avatar({ title = \"\" }) {\n return {\"頭像:\";\n}\nfunction App() {\n return ;\n}\nconsole.log(renderToStaticMarkup());", "expected": "\"頭像:\"" } + ] + } } ] } diff --git a/src/data/questions/react-3-state-events.json b/src/data/questions/react-3-state-events.json index fce3227..8a4f65c 100644 --- a/src/data/questions/react-3-state-events.json +++ b/src/data/questions/react-3-state-events.json @@ -133,6 +133,138 @@ "checks": [], "manual": "需要點擊互動;答案依 react.dev state-as-a-snapshot(setState 不改變當輪快照)" } + }, + { + "id": "react-3-q7", + "type": "fill-in", + "difficulty": 1, + "topic": "event.target.value 拿輸入值", + "docs": "https://react.dev/reference/react-dom/components/input", + "story": "", + "prompt": "想把使用者在輸入框打的字存進 state,跟著畫面同步顯示。把空格填起來", + "code": "function NameInput() {\n const [name, setName] = useState(\"\");\n return (\n setName(____)}\n />\n );\n}", + "options": [ + { "id": "a", "text": "e.target.value" }, + { "id": "b", "text": "e.value" }, + { "id": "c", "text": "name.value" }, + { "id": "d", "text": "e.target" } + ], + "answer": "a", + "explanation": "onChange 收到的是瀏覽器的事件物件 e,真正的輸入框(那個 DOM 節點)放在 e.target,使用者打的字則是 e.target.value——這條路徑很固定,記熟它。選項 b 少了 target 這一層,事件物件本身沒有 value;選項 c 的 name 是 state 變數,不是 DOM 節點;選項 d 拿到整個 input 節點,卻沒有取出文字。value={name} 搭配 onChange 讀 e.target.value 寫回 setName,就是「受控輸入框」的標準寫法:state 是唯一真相,畫面永遠照它顯示。", + "verify": { + "checks": [], + "manual": "需要在輸入框裡實際打字才能觸發 onChange、觀察 e.target.value 抓到的內容;renderToStaticMarkup 只做一次性 SSR 渲染,沒有瀏覽器事件可以觸發;答案依 react.dev input 元件參考文件(受控輸入框:value 顯示、onChange 讀 e.target.value 寫回)" + } + }, + { + "id": "react-3-q8", + "type": "find-bug", + "difficulty": 2, + "topic": "checkbox 要讀 e.target.checked,不是 e.target.value", + "docs": "https://react.dev/reference/react-dom/components/input", + "story": "", + "prompt": "同意條款的打勾框,不管有沒有勾,畫面上的狀態文字都顯示「已同意」。哪裡錯了?", + "code": "function Agree() {\n const [checked, setChecked] = useState(false);\n\n return (\n \n );\n}", + "options": [ + { "id": "a", "text": "B 行讀了 e.target.value:checkbox 的 value 是固定字串(沒設就是預設的 \"on\"),不管勾沒勾都是同一個非空字串,永遠是 truthy;要讀 e.target.checked 才是真正的勾選狀態" }, + { "id": "b", "text": "useState(false) 的初始值不能是布林" }, + { "id": "c", "text": "checked={checked} 要拿掉" }, + { "id": "d", "text": "label 不能包住 input" } + ], + "answer": "a", + "explanation": "文字輸入框看 value,checkbox/radio 看 checked——兩種表單元素的「使用者輸入」放在不同屬性上。e.target.value 對 checkbox 來說是那個 input 的 value 屬性,不管勾沒勾都是同一個非空字串,setChecked 收到的永遠是 truthy,畫面自然卡死在「已同意」。改成 e.target.checked 才拿得到真正的勾選狀態(true / false)。", + "verify": { + "checks": [], + "manual": "需要實際點擊 checkbox 才能觀察勾選狀態切換;renderToStaticMarkup 不會觸發瀏覽器的 change 事件;答案依 react.dev input 元件參考文件(checkbox 讀 e.target.checked)" + } + }, + { + "id": "react-3-q9", + "type": "find-bug", + "difficulty": 2, + "topic": "e.preventDefault 阻止表單送出後重新整理頁面", + "docs": "https://react.dev/learn/responding-to-events", + "story": "", + "prompt": "按下送出後,整個頁面像重新整理一樣閃一下,畫面上剛打的字全部消失。哪裡漏了?", + "code": "function SearchForm() {\n const [keyword, setKeyword] = useState(\"\");\n\n function handleSubmit(e) {\n console.log(\"送出關鍵字:\", keyword); // B 行\n }\n\n return (\n
      \n setKeyword(e.target.value)} />\n \n
      \n );\n}", + "options": [ + { "id": "a", "text": "handleSubmit 沒呼叫 e.preventDefault():瀏覽器對
      送出的預設行為是重新整理頁面,React 的 state 會全部歸零;要在 B 行前面加上 e.preventDefault()" }, + { "id": "b", "text": "onSubmit 要換成 onClick 才對" }, + { "id": "c", "text": "console.log 不能寫在事件處理器裡" }, + { "id": "d", "text": "keyword 的 state 忘記給初始值" } + ], + "answer": "a", + "explanation": " 是 HTML 與生俱來的元素,瀏覽器對它有一套「送出就整頁重新載入」的預設劇本,React 完全不會自動取消。handleSubmit(e) 的 e 就是逃生口:呼叫 e.preventDefault() 才能攔下這齣重新整理,改由 JavaScript 接手處理送出。忘了這一行,SPA 瞬間變回傳統整頁刷新的網頁,state 當然被清空重來。", + "verify": { + "checks": [], + "manual": "preventDefault 攔的是瀏覽器原生的整頁重新載入行為,需要真正的 送出與瀏覽器環境才觀察得到;renderToStaticMarkup 不會觸發也不會處理 DOM 事件;答案依 react.dev responding-to-events 的 Preventing default behavior 段落" + } + }, + { + "id": "react-3-q10", + "type": "find-bug", + "difficulty": 3, + "topic": "e.stopPropagation 阻止事件冒泡到父層", + "docs": "https://react.dev/learn/responding-to-events", + "story": "", + "prompt": "卡片本身點下去會「打開詳情」,卡片裡還有一個獨立的「刪除」按鈕。結果每次按刪除,詳情也跟著打開了。哪裡沒處理好?", + "code": "function Card({ onOpen, onDelete }) {\n return (\n
      \n 卡片內容\n {/* B 行 */}\n
      \n );\n}", + "options": [ + { "id": "a", "text": "B 行的點擊會先觸發按鈕自己的 onDelete,接著一路往外冒泡,外層
      的 onClick(onOpen)也被觸發;要在 onDelete 的處理器裡呼叫 e.stopPropagation() 擋住冒泡" }, + { "id": "b", "text": "按鈕要搬到
      外面才不會觸發 onOpen" }, + { "id": "c", "text": "onClick 不能同時掛在父子兩層元素上" }, + { "id": "d", "text": "onDelete 少寫了 return" } + ], + "answer": "a", + "explanation": "點擊事件預設會像水波一樣,從最裡層一路往外「冒泡」到所有祖先元素,外層 div 的 onClick 自然也會被叫到——這不是 bug,是瀏覽器的預設行為。想讓內層按鈕的點擊「只做自己的事、不驚動外層」,得在它的處理器裡呼叫 e.stopPropagation() 明確攔下冒泡。巢狀可點擊區域(卡片包按鈕、列表項包連結)幾乎都要考慮這件事。", + "verify": { + "checks": [], + "manual": "事件冒泡需要真實的巢狀 DOM 與點擊模擬,renderToStaticMarkup 不會觸發也不會冒泡任何事件;答案依 react.dev responding-to-events 的 Stopping propagation 段落" + } + }, + { + "id": "react-3-q11", + "type": "predict-output", + "difficulty": 3, + "topic": "函式型更新:迴圈裡呼叫 setState 多次也能疊加", + "docs": "https://react.dev/learn/queueing-a-series-of-state-updates", + "story": "", + "prompt": "「連按三下」的效果想用一次點擊做到,於是在處理器裡跑迴圈呼叫三次。count 從 0 開始,按一下之後變多少?", + "code": "function Counter() {\n const [count, setCount] = useState(0);\n\n function addThree() {\n for (let i = 0; i < 3; i++) {\n setCount((c) => c + 1);\n }\n }\n\n return ;\n}", + "options": [ + { "id": "a", "text": "3 —— 更新函式會排進佇列依序執行,迴圈跑三次就接力加了三次" }, + { "id": "b", "text": "1 —— 迴圈裡的 setState 只有最後一次有效" }, + { "id": "c", "text": "0 —— 迴圈裡不能呼叫 setState" }, + { "id": "d", "text": "結果不固定,看瀏覽器效能" } + ], + "answer": "a", + "explanation": "傳更新函式 c => c + 1 給 setCount,React 不是「當場算」,而是把這個函式排進佇列;迴圈跑三次就排了三個函式進去,等這輪事件處理完,React 依序執行:0→1、1→2、2→3,最後交回 3。這正是 q4(直接給值兩行只變 1)想避開的坑:只要是「連續多次、根據前一個結果往下疊加」的更新,不管是手寫幾行還是迴圈跑出來的,一律用函式型更新才安全。", + "verify": { + "checks": [], + "manual": "需要點擊互動才能觀察佇列依序執行的結果;renderToStaticMarkup 只做一次性 SSR,模擬不了連續的 state 更新;答案依 react.dev queueing-a-series-of-state-updates(updater function 依序消化佇列)" + } + }, + { + "id": "react-3-q12", + "type": "same-or-not", + "difficulty": 3, + "topic": "相關欄位合併成一個物件,互不相關就分開存", + "docs": "https://react.dev/learn/choosing-the-state-structure", + "story": "", + "prompt": "拖曳一個點,要同時記錄它的 x、y 座標(兩者永遠一起變、一起讀)。這兩種宣告方式,哪一種是 react.dev 建議的做法?", + "code": "// 版本一:兩個獨立 state\nconst [x, setX] = useState(0);\nconst [y, setY] = useState(0);\n\n// 版本二:合併成一個物件\nconst [position, setPosition] = useState({ x: 0, y: 0 });", + "options": [ + { "id": "a", "text": "都能動,但官方建議:永遠一起變、一起用的資料(像同一個點的 x/y)合併成一個物件比較好,能避免兩個 state 不小心不同步;彼此無關的資料則該分開宣告" }, + { "id": "b", "text": "一定要版本一,React 規定一個元件只能有一個物件型 state" }, + { "id": "c", "text": "一定要版本二,多個 useState 呼叫會拖慢效能" }, + { "id": "d", "text": "兩者完全等價,官方沒有任何建議" } + ], + "answer": "a", + "explanation": "選 state 結構沒有唯一正確答案,但 react.dev 給了判斷原則:「永遠同時改變的資料」合併存放,可以避免忘記同步更新其中一個(例如只更新 x 忘了更新 y,畫面瞬間對不齊);「彼此獨立、互不相關」的資料則該分開宣告,硬要合併只會讓每次更新都要多寫一層展開、徒增出錯機會。x/y 座標正是官方範例裡「該合併」的典型案例;react-6 教的『改物件要展開複製』,用在這種合併後的 state 上就是實戰。", + "verify": { + "checks": [], + "manual": "屬於 state 結構的架構設計判斷,兩種寫法初次渲染的畫面完全相同,差異在後續維護與同步風險,沒有單一可執行輸出可比對;依 react.dev choosing-the-state-structure 的 Group related state 原則" + } } ] } diff --git a/src/data/questions/react-4-hooks.json b/src/data/questions/react-4-hooks.json index 8b791bb..df22a0d 100644 --- a/src/data/questions/react-4-hooks.json +++ b/src/data/questions/react-4-hooks.json @@ -133,6 +133,141 @@ "checks": [], "manual": "無限重繪保護只在瀏覽器端 React 觸發;答案依 react.dev(rendering 期間呼叫 setState 的錯誤行為)" } + }, + { + "id": "react-4-q7", + "type": "find-bug", + "difficulty": 2, + "topic": "useState 初始值傳函式,重算只在首次渲染發生(惰性初始化)", + "docs": "https://react.dev/reference/react/useState", + "story": "", + "prompt": "createInitialTodos() 要跑一段迴圈整理資料,很耗時。這段程式每次重新渲染都會被拖慢一次,為什麼?", + "code": "function TodoList() {\n const [todos, setTodos] = useState(createInitialTodos()); // B 行\n // ...\n}", + "options": [ + { "id": "a", "text": "B 行直接呼叫 createInitialTodos():這是一般的函式呼叫,每次渲染都會執行一次,即使 useState 只有第一次渲染會採用它的結果;要改成 useState(createInitialTodos),傳函式本身,讓 React 只在首次渲染呼叫它" }, + { "id": "b", "text": "createInitialTodos 應該寫成 async function" }, + { "id": "c", "text": "useState 不能吃函式呼叫的結果當初始值" }, + { "id": "d", "text": "todos 要移到 useEffect 裡才會執行" } + ], + "answer": "a", + "explanation": "useState(initialValue) 裡的 initialValue 只有第一次渲染會被拿去用,但它終究是傳給函式呼叫的一個參數——JS 的規則是「呼叫函式前,所有參數都要先算出來」,所以 createInitialTodos() 這行程式碼本身,每次渲染都會乖乖執行一次,算出來的結果卻只有第一次會被留用,之後每次都是做白工再丟掉。改成 useState(createInitialTodos)(不加括號,傳函式本身),React 才會認得「這是初始化用的函式」,只在首次渲染呼叫它,之後的渲染直接跳過,這叫「惰性初始化」(lazy initialization)。", + "verify": { + "checks": [ + { "code": "let calls = 0;\nfunction expensive() { calls++; return calls; }\n// 模擬三次渲染:直接呼叫的版本,每次渲染都執行\nfor (let render = 0; render < 3; render++) {\n expensive();\n}\nconsole.log(calls);", "expected": "3" }, + { "code": "let calls = 0;\nfunction expensive() { calls++; return calls; }\n// 模擬三次渲染:只傳函式本身,只在「首次渲染」才呼叫\nlet initialized = false;\nfor (let render = 0; render < 3; render++) {\n if (!initialized) {\n expensive();\n initialized = true;\n }\n}\nconsole.log(calls);", "expected": "1" } + ] + } + }, + { + "id": "react-4-q8", + "type": "predict-output", + "difficulty": 3, + "topic": "自訂 hook 是共享邏輯,不是共享 state", + "docs": "https://react.dev/learn/reusing-logic-with-custom-hooks", + "story": "", + "prompt": "兩個元件各自呼叫同一個 useToggle()。點擊 SwitchA 讓它變成「開」,SwitchB 會跟著變成「開」嗎?", + "code": "function useToggle(initial = false) {\n const [on, setOn] = useState(initial);\n const toggle = () => setOn((o) => !o);\n return [on, toggle];\n}\n\nfunction SwitchA() {\n const [on, toggle] = useToggle();\n return ;\n}\n\nfunction SwitchB() {\n const [on, toggle] = useToggle();\n return ;\n}", + "options": [ + { "id": "a", "text": "不會:自訂 hook 只是把「一段邏輯」包起來重複使用,每個呼叫它的元件都會拿到自己獨立的 useState,SwitchA 和 SwitchB 是兩份互不相干的 on" }, + { "id": "b", "text": "會,因為它們呼叫的是同一個 useToggle 函式,state 是共用的" }, + { "id": "c", "text": "會,但只有第一次點擊會同步" }, + { "id": "d", "text": "會報錯,同一個自訂 hook 不能被兩個元件使用" } + ], + "answer": "a", + "explanation": "自訂 hook 的本質是「把重複的邏輯抽出來」,不是「把 state 抽出來共用」。useToggle() 裡的 useState 依然是跟著呼叫它的那個元件實例走的(react-8 教過:state 是元件私有的記憶)——SwitchA 呼叫一次就在自己身上長出一份 on,SwitchB 呼叫一次又長出另一份,兩份完全獨立、互不相干。想要兩個元件真的同步同一份 state,答案永遠是 react-8 的解法:狀態上移到共同父層,用 props 往下傳,不是靠共用一個自訂 hook。", + "verify": { + "checks": [], + "manual": "需要各自點擊觀察是否連動;renderToStaticMarkup 只能做一次性渲染,也不會觸發 useState 更新;答案依 react.dev reusing-logic-with-custom-hooks(自訂 hook 共享的是邏輯而非 state 本身)" + } + }, + { + "id": "react-4-q9", + "type": "find-bug", + "difficulty": 3, + "topic": "useEffect 依賴陣列漏寫變數:陳舊閉包(stale closure)", + "docs": "https://react.dev/learn/lifecycle-of-reactive-effects", + "story": "", + "prompt": "計時器想「每秒把 count 加上 step」,後來把 step 從 1 調成 5,count 卻還是每秒只加 1。哪裡出了問題?", + "code": "function Counter({ step }) {\n const [count, setCount] = useState(0);\n\n useEffect(() => {\n const id = setInterval(() => {\n setCount((c) => c + step); // B 行\n }, 1000);\n return () => clearInterval(id);\n }, []); // C 行:注意依賴陣列\n\n return

      {count}

      ;\n}", + "options": [ + { "id": "a", "text": "C 行的依賴陣列漏寫了 step:effect 只在掛載時執行一次,setInterval 裡的 callback 被鎖死在第一次渲染時的 step,之後 step 再怎麼變,callback 讀到的還是舊的那份(stale closure);要把 step 加進依賴陣列 [step]" }, + { "id": "b", "text": "setCount 不能用更新函式 c => c + step" }, + { "id": "c", "text": "setInterval 只能執行一次,要換成 setTimeout" }, + { "id": "d", "text": "count 的初始值要設成 step" } + ], + "answer": "a", + "explanation": "effect 裡的函式會把它建立當下能看到的變數「封起來」隨身帶走,這就是 closure。依賴陣列寫 [],effect 只在第一次渲染執行一次,裡面 setInterval 用到的 step 也就永遠是「第一次渲染時的 step」,就算外面的 step 之後真的變了,這個被封住的 callback 完全不知道,用的還是那份舊的、發霉的閉包,這種 bug 叫「stale closure」(陳舊閉包)。修法:把 effect 實際用到的每個外部變數都誠實列進依賴陣列,讓 effect 在 step 真的變動時重新執行、重新建立一個帶著新 step 的 callback。", + "verify": { + "checks": [], + "manual": "stale closure 需要 setInterval 跨越多秒的時間流逝與 props 變更觀察,renderToStaticMarkup 單次渲染看不到;答案依 react.dev lifecycle-of-reactive-effects 對 effect 依賴與陳舊閉包的說明" + } + }, + { + "id": "react-4-q10", + "type": "find-bug", + "difficulty": 3, + "topic": "依賴陣列放物件字面量,每次渲染都是新參照,effect 一直重跑", + "docs": "https://react.dev/learn/removing-effect-dependencies", + "story": "", + "prompt": "options 的「內容」明明沒變,但 effect 卻在每次渲染後都重新連線一次。哪裡出了問題?", + "code": "function ChatRoom({ roomId }) {\n const options = { roomId, serverUrl: \"https://chat.example.com\" }; // B 行:每次渲染都重新建立\n\n useEffect(() => {\n const connection = createConnection(options);\n connection.connect();\n return () => connection.disconnect();\n }, [options]); // C 行\n\n return

      房間:{roomId}

      ;\n}", + "options": [ + { "id": "a", "text": "B 行每次渲染都用 {} 造一個新物件:即使欄位內容一模一樣,新物件跟舊物件用 === 比一定是 false,C 行的依賴陣列一看「options 變了」就重跑 effect;要嘛把依賴改成 [roomId](options 裡真正用到的原始值),要嘛把 options 的建立搬進 effect 內部" }, + { "id": "b", "text": "createConnection 不能放在 useEffect 裡呼叫" }, + { "id": "c", "text": "C 行的依賴陣列要整個拿掉才會正常" }, + { "id": "d", "text": "options 應該改用 useState 存起來" } + ], + "answer": "a", + "explanation": "依賴陣列比對「變了沒」,用的是參照比對,不是比對物件裡的內容。{ roomId, serverUrl: ... } 這個字面量寫在元件本體裡,每次渲染都會重新蓋一間新物件,就算 roomId 沒變,新物件跟上一輪的舊物件終究是「兩個不同的東西」,effect 認定依賴變了就重跑。修法兩條路都對:依賴陣列改寫成 effect 真正用到的原始值 [roomId](原始值用內容比較,同樣的字串永遠相等),或者乾脆把 options 的建立搬進 useEffect 內部,讓它不再是外部依賴。這是「物件/陣列當依賴」最容易讓人抓狂的效能陷阱。", + "verify": { + "checks": [ + { "code": "const roomId = \"general\";\nconst a = { roomId, serverUrl: \"https://chat.example.com\" };\nconst b = { roomId, serverUrl: \"https://chat.example.com\" };\nconsole.log(a === b, JSON.stringify(a) === JSON.stringify(b));", "expected": "false true" } + ] + } + }, + { + "id": "react-4-q11", + "type": "find-bug", + "difficulty": 2, + "topic": "不相關的邏輯拆成兩個 useEffect,各自宣告各自的依賴", + "docs": "https://react.dev/learn/lifecycle-of-reactive-effects", + "story": "", + "prompt": "同一個 effect 裡塞了「設定文件標題」和「訂閱視窗大小」兩件不相關的事,只依 page 設了依賴。哪裡不妥?", + "code": "useEffect(() => {\n document.title = `第 ${page} 頁`;\n\n function handleResize() {\n setWidth(window.innerWidth);\n }\n window.addEventListener(\"resize\", handleResize);\n return () => window.removeEventListener(\"resize\", handleResize);\n}, [page]); // B 行:依賴只寫了 page", + "options": [ + { "id": "a", "text": "B 行把「設標題」和「訂閱視窗大小」兩件時機完全不同的事塞進同一個 effect:設標題該跟著 page 變動重跑,訂閱/取消訂閱只需要掛載、卸載各跑一次;混在一起會讓兩段邏輯的『該在什麼時機重來一次』糾纏不清,該拆成兩個各自獨立的 useEffect,各自宣告各自真正需要的依賴" }, + { "id": "b", "text": "window.addEventListener 不能寫在 useEffect 裡" }, + { "id": "c", "text": "document.title 一定要寫在 useEffect 外面" }, + { "id": "d", "text": "resize 事件不需要寫 cleanup" } + ], + "answer": "a", + "explanation": "react.dev 的建議很明確:一個 effect 該對應「一個獨立的同步流程」。設定標題跟著 page 變、訂閱 resize 跟著元件的掛載/卸載走——這是兩件時機完全不同的事,擠在同一個 effect 裡,依賴陣列只能挑一邊照顧,另一邊不是漏跑就是多跑(這裡是每次 page 變動都多重新訂閱一次 resize,白白多做事)。拆成兩個 useEffect,一個依賴 [page] 管標題,一個依賴 [] 管訂閱,各自的依賴才寫得乾淨,cleanup 也各自對應各自的副作用,不會混在一起難以除錯。", + "verify": { + "checks": [], + "manual": "需要視窗 resize 與多次 page 變更的瀏覽器互動才能觀察差異;renderToStaticMarkup 不執行 effect;答案依 react.dev lifecycle-of-reactive-effects(每個 Effect 對應一個獨立的同步流程)" + } + }, + { + "id": "react-4-q12", + "type": "predict-output", + "difficulty": 3, + "topic": "依賴變動時,cleanup 會先跑一次,不是只有卸載才跑", + "docs": "https://react.dev/learn/synchronizing-with-effects", + "story": "", + "prompt": "roomId 從 \"general\" 換成 \"random\",effect 依賴寫了 [roomId]。這中間 disconnect 和 connect 誰先誰後?", + "code": "useEffect(() => {\n const connection = createConnection(roomId);\n connection.connect();\n console.log(`已連線:${roomId}`);\n return () => {\n connection.disconnect();\n console.log(`已斷線:${roomId}`);\n };\n}, [roomId]);\n\n// 掛載時 roomId 是 \"general\"\n// 之後父層把 roomId 改成 \"random\"", + "options": [ + { "id": "a", "text": "「已斷線:general」先印,「已連線:random」後印——依賴變動時,React 會先用上一輪的閉包執行 cleanup,再執行這一輪新的 effect" }, + { "id": "b", "text": "「已連線:random」先印,「已斷線:general」後印" }, + { "id": "c", "text": "只印「已連線:random」,舊連線不會被斷開" }, + { "id": "d", "text": "兩個訊息同時印出,順序不固定" } + ], + "answer": "a", + "explanation": "cleanup 函式不是只有元件卸載才會跑——依賴陣列裡的值一旦改變,React 會先用「上一輪的閉包」執行 cleanup(斷開舊房間 general),確保上一輪開的東西收乾淨,才用新的 roomId 重新執行 effect(連上新房間 random)。這個「先清理、再重來」的順序,保證任何時刻都不會有兩個房間同時連線。q5 教的是卸載時的 cleanup,這題告訴你:只要依賴變動觸發 effect 重跑,同一套 cleanup 機制一樣會先發動。", + "verify": { + "checks": [], + "manual": "需要 roomId 變動的多輪渲染與真正的連線/斷線副作用觀察;renderToStaticMarkup 不執行 effect 也沒有 re-render;答案依 react.dev synchronizing-with-effects 對「cleanup 於重新同步前先執行」的說明" + } } ] } diff --git a/src/data/questions/react-5-lists-conditional.json b/src/data/questions/react-5-lists-conditional.json index d429002..c2f30de 100644 --- a/src/data/questions/react-5-lists-conditional.json +++ b/src/data/questions/react-5-lists-conditional.json @@ -140,6 +140,146 @@ { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nconst products = [\n { id: 1, name: \"鍵盤\", stock: 3 },\n { id: 2, name: \"滑鼠\", stock: 0 },\n { id: 3, name: \"螢幕\", stock: 5 },\n];\nfunction Shelf() {\n return (\n
        \n {products\n .filter((p) => p.stock > 0)\n .map((p) =>
      • {p.name}
      • )}\n
      \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "
      • 鍵盤
      • 螢幕
      " } ] } + }, + { + "id": "react-5-q7", + "type": "predict-output", + "difficulty": 1, + "topic": "key 是給 React 認人用的,不會出現在 HTML 裡", + "docs": "https://react.dev/learn/rendering-lists", + "story": "", + "prompt": "list 裡每個
    • 都給了 key。渲染出的 HTML 裡看得到 key 這個屬性嗎?", + "code": "function Fruits({ items }) {\n return (\n
        \n {items.map((it) => (\n
      • {it.name}
      • \n ))}\n
      \n );\n}\n\n// ", + "options": [ + { "id": "a", "text": "
      • 蘋果
      • 香蕉
      —— key 不會出現在 HTML 屬性裡" }, + { "id": "b", "text": "
      • 蘋果
      • 香蕉
      " }, + { "id": "c", "text": "會報錯,key 一定要顯示在畫面上才合法" }, + { "id": "d", "text": "
      • 蘋果
      • 香蕉
      " } + ], + "answer": "a", + "explanation": "key 是寫給 React 自己看的「內部身分證」,讓它在下一次渲染時認出「這是同一項」,跟畫面長什麼樣完全無關——渲染出來的 HTML 裡不會有 key 這個屬性,使用者的瀏覽器根本看不到它。真正影響畫面的 identity 追蹤,是下一次列表增刪、排序時 React 靠 key 決定「哪些節點該保留、哪些該重建」(react-9 會深入這件事在互動時如何影響 state)。這題先建立最基本的認知:key 是給 React 用的,不是給 HTML 或 CSS 用的。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Fruits({ items }) {\n return (\n
        \n {items.map((it) => (\n
      • {it.name}
      • \n ))}\n
      \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "
      • 蘋果
      • 香蕉
      " } + ] + } + }, + { + "id": "react-5-q8", + "type": "same-or-not", + "difficulty": 2, + "topic": "陣列開頭插入資料時,用 index 當 key 的問題初探", + "docs": "https://react.dev/learn/rendering-lists", + "story": "", + "prompt": "目前清單是 [\"買菜\", \"寫程式\"]。用 index 或用內容字串當 key,這次渲染出的 HTML 一樣嗎?", + "code": "// 版本一:index 當 key\n{items.map((it, i) =>
    • {it}
    • )}\n\n// 版本二:內容當 key\n{items.map((it) =>
    • {it}
    • )}", + "options": [ + { "id": "a", "text": "一樣:這次渲染出的 HTML 完全相同,key 從來不會出現在畫面上;但如果之後在陣列「開頭」插入新項目,index 當 key 的版本會讓 React 誤判每一項的身分,開始出現內容錯位(react-9 會深入這個炸彈怎麼引爆)" }, + { "id": "b", "text": "不一樣,index 當 key 的版本畫面會多顯示一串數字" }, + { "id": "c", "text": "不一樣,內容當 key 的版本會報錯,因為字串不能當 key" }, + { "id": "d", "text": "一樣,而且往後不管怎麼增刪都不會有問題" } + ], + "answer": "a", + "explanation": "只看「這一次」的渲染結果,兩種 key 完全沒差——key 本來就不會畫出來。差別只會在「陣列開頭或中間插入、刪除項目」時現形:用 index 當 key,插入一筆之後,原本第 0 項的 index 被新項目佔走,第 1 項「被迫」繼承了 index 0 的身分,React 誤以為它們是同一個東西,如果每項還帶著自己的 state(打勾狀態、輸入框內容),就會發生錯位——這正是 react-9 要深入處理的坑。這題先記住結論:清單會增刪或排序,就不要用 index 當 key,用資料自帶的穩定 id。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nconst items = [\"買菜\", \"寫程式\"];\nfunction ListA() {\n return
        {items.map((it, i) =>
      • {it}
      • )}
      ;\n}\nfunction ListB() {\n return
        {items.map((it) =>
      • {it}
      • )}
      ;\n}\nconsole.log(renderToStaticMarkup());\nconsole.log(renderToStaticMarkup());", "expected": "
      • 買菜
      • 寫程式
      \n
      • 買菜
      • 寫程式
      " } + ] + } + }, + { + "id": "react-5-q9", + "type": "predict-output", + "difficulty": 2, + "topic": "巢狀列表:分類底下還有子清單,map 裡再 map 一次", + "docs": "https://react.dev/learn/rendering-lists", + "story": "", + "prompt": "每個分類底下還有自己的商品清單。渲染出什麼 HTML?", + "code": "const categories = [\n { name: \"水果\", items: [\"蘋果\", \"香蕉\"] },\n { name: \"蔬菜\", items: [\"紅蘿蔔\"] },\n];\n\nfunction Menu() {\n return (\n
        \n {categories.map((cat) => (\n
      • \n {cat.name}\n
          \n {cat.items.map((it) => (\n
        • {it}
        • \n ))}\n
        \n
      • \n ))}\n
      \n );\n}", + "options": [ + { "id": "a", "text": "
      • 水果
        • 蘋果
        • 香蕉
      • 蔬菜
        • 紅蘿蔔
      " }, + { "id": "b", "text": "
      • 水果
      • 蘋果
      • 香蕉
      • 蔬菜
      • 紅蘿蔔
      " }, + { "id": "c", "text": "
      • 水果,蔬菜
      " }, + { "id": "d", "text": "會報錯,
        裡面不能再放
          " } + ], + "answer": "a", + "explanation": "巢狀資料就巢狀 map:外層 map 跑分類,每個分類的
        • 裡面自己再開一個
            ,內層 map 跑該分類的商品。兩層各自有各自的 key(外層用分類名、內層用商品名),互不干擾。
              裡巢狀
                在 HTML 裡完全合法(常見於巢狀選單、分類清單),JSX 只是照著資料的形狀,一層一層把它畫出來。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nconst categories = [\n { name: \"水果\", items: [\"蘋果\", \"香蕉\"] },\n { name: \"蔬菜\", items: [\"紅蘿蔔\"] },\n];\nfunction Menu() {\n return (\n
                  \n {categories.map((cat) => (\n
                • \n {cat.name}\n
                    \n {cat.items.map((it) => (\n
                  • {it}
                  • \n ))}\n
                  \n
                • \n ))}\n
                \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "
                • 水果
                  • 蘋果
                  • 香蕉
                • 蔬菜
                  • 紅蘿蔔
                " } + ] + } + }, + { + "id": "react-5-q10", + "type": "predict-output", + "difficulty": 1, + "topic": "空陣列渲染結果:map 出來是零個元素,不是 bug", + "docs": "https://react.dev/learn/rendering-lists", + "story": "", + "prompt": "items 是空陣列 []。渲染出什麼?", + "code": "function TodoList({ items }) {\n return (\n
                  \n {items.map((it) => (\n
                • {it}
                • \n ))}\n
                \n );\n}\n\n// ", + "options": [ + { "id": "a", "text": "
                  —— map 對空陣列跑零次,中間自然是空的,這不是錯誤" }, + { "id": "b", "text": "會報錯,items 不能是空陣列" }, + { "id": "c", "text": "
                  " }, + { "id": "d", "text": "
                    undefined
                  " } + ], + "answer": "a", + "explanation": "空陣列呼叫 map,callback 一次都不會執行,回傳的還是一個空陣列,React 把它渲染成「什麼都沒有」——
                    標籤本身沒消失,只是裡面空空的,這完全合法,不代表哪裡壞了。如果想在沒資料時顯示「尚無待辦事項」這類提示,得自己加條件:items.length === 0 ?
                  • 尚無待辦事項
                  • : items.map(...)——渲染引擎不會主動幫你補這句話。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction TodoList({ items }) {\n return (\n
                      \n {items.map((it) => (\n
                    • {it}
                    • \n ))}\n
                    \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "
                      " }, + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction TodoList({ items }) {\n return (\n
                        \n {items.length === 0 ?
                      • 尚無待辦事項
                      • : items.map((it) =>
                      • {it}
                      • )}\n
                      \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "
                      • 尚無待辦事項
                      " } + ] + } + }, + { + "id": "react-5-q11", + "type": "predict-output", + "difficulty": 2, + "topic": "條件複雜時用「提前 return」,不要硬塞進三元或 &&", + "docs": "https://react.dev/learn/conditional-rendering", + "story": "", + "prompt": "Panel 元件依序檢查 loading、error、沒資料三種狀況,最後才畫正常內容。status 是 \"error\" 時渲染出什麼?", + "code": "function Panel({ status, data }) {\n if (status === \"loading\") return

                      載入中...

                      ;\n if (status === \"error\") return

                      發生錯誤

                      ;\n if (data.length === 0) return

                      沒有資料

                      ;\n return (\n
                        \n {data.map((d) => (\n
                      • {d}
                      • \n ))}\n
                      \n );\n}\n\n// ", + "options": [ + { "id": "a", "text": "

                      發生錯誤

                      " }, + { "id": "b", "text": "

                      載入中...

                      " }, + { "id": "c", "text": "

                      沒有資料

                      " }, + { "id": "d", "text": "
                        " } + ], + "answer": "a", + "explanation": "四種狀態、四種畫面,硬要塞進一個三元或串好幾個 &&,只會變成難以閱讀的巢狀迷宮。用一連串「提前 return」把每種狀況攔在最前面,函式主體讀起來就是白話文:先檢查 loading,再檢查 error,再檢查沒資料,最後才是正常畫面——像一連串的守門員,過關才輪到下一關。status 是 \"error\",第二道關卡就攔下來直接回傳

                        發生錯誤

                        ,後面的程式碼碰都不會碰到(連 data.length 都不會被檢查)。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Panel({ status, data }) {\n if (status === \"loading\") return

                        載入中...

                        ;\n if (status === \"error\") return

                        發生錯誤

                        ;\n if (data.length === 0) return

                        沒有資料

                        ;\n return (\n
                          \n {data.map((d) => (\n
                        • {d}
                        • \n ))}\n
                        \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "

                        發生錯誤

                        " }, + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Panel({ status, data }) {\n if (status === \"loading\") return

                        載入中...

                        ;\n if (status === \"error\") return

                        發生錯誤

                        ;\n if (data.length === 0) return

                        沒有資料

                        ;\n return (\n
                          \n {data.map((d) => (\n
                        • {d}
                        • \n ))}\n
                        \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "
                        • 蘋果
                        " } + ] + } + }, + { + "id": "react-5-q12", + "type": "fill-in", + "difficulty": 3, + "topic": "用 Fragment 搭配 key 群組列表項,不必多包一層 DOM", + "docs": "https://react.dev/reference/react/Fragment", + "story": "", + "prompt": "每個項目要同時渲染一個
                        和
                        ,但不想在外面多包一層
                        (那樣
                        的結構就壞了)。把空格填起來", + "code": "import { Fragment } from \"react\";\n\nfunction Glossary({ items }) {\n return (\n
                        \n {items.map((item) => (\n ____\n
                        {item.term}
                        \n
                        {item.desc}
                        \n \n ))}\n
                        \n );\n}", + "options": [ + { "id": "a", "text": "" }, + { "id": "b", "text": "
                        " }, + { "id": "c", "text": "<>" }, + { "id": "d", "text": "" } + ], + "answer": "a", + "explanation": "<>... 這個簡寫沒辦法帶 key,列表裡需要 key 的場合就得寫回完整的 ——它跟
                        一樣可以掛 key,卻不會在 HTML 裡留下任何多餘的包裝標籤,
                        、
                        直接乾乾淨淨地當
                        的子元素。選項 b 的 div 雖然也能運作,卻破壞了
                        只能裝
                        /
                        的語意結構;選項 c、d 少了 key,React 對列表裡缺 key 的項目會在主控台丟出警告。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nimport { Fragment } from 'react'\nfunction Glossary({ items }) {\n return (\n
                        \n {items.map((item) => (\n \n
                        {item.term}
                        \n
                        {item.desc}
                        \n
                        \n ))}\n
                        \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "
                        HTML
                        標記語言
                        CSS
                        樣式語言
                        " } + ] + } } ] } diff --git a/src/data/questions/react-6-updating-state.json b/src/data/questions/react-6-updating-state.json index a5ef3bb..949f149 100644 --- a/src/data/questions/react-6-updating-state.json +++ b/src/data/questions/react-6-updating-state.json @@ -138,6 +138,143 @@ { "code": "const state = { profile: { name: \"小美\" }, xp: 10 };\nconst next = {\n ...state,\n profile: { ...state.profile, name: \"小華\" },\n};\nconsole.log(state.profile.name, next.profile.name, state.xp === next.xp);", "expected": "小美 小華 true" } ] } + }, + { + "id": "react-6-q7", + "type": "predict-output", + "difficulty": 2, + "topic": "陣列插入到中間(用展開)", + "docs": "https://react.dev/learn/updating-arrays-in-state", + "story": "", + "prompt": "想在 \"a\" 和 \"c\" 中間塞一個 \"b\",不能用 splice 硬插。這段程式印出什麼?", + "code": "const list = [\"a\", \"c\"];\nconst next = [...list.slice(0, 1), \"b\", ...list.slice(1)];\n\nconsole.log(list, next);", + "options": [ + { "id": "a", "text": "[ 'a', 'c' ] [ 'a', 'b', 'c' ]" }, + { "id": "b", "text": "[ 'a', 'b', 'c' ] [ 'a', 'b', 'c' ]" }, + { "id": "c", "text": "[ 'a', 'c' ] [ 'a', 'c', 'b' ]" }, + { "id": "d", "text": "會報錯,陣列不能用 slice 兩次" } + ], + "answer": "a", + "explanation": "splice 會就地挖開原陣列硬塞一筆,是 state 的大忌。要「插入到中間」又不動本尊,訣竅是用 slice 切成前後兩段,中間夾上新元素,再用展開把三塊接起來組成新陣列——原本的 list 完全沒被動到,next 才是插入後的版本。這跟 q4 的「刪除用 filter」是同一招的變化型:加、刪、插,通通用「回傳新陣列」的方法做。", + "verify": { + "checks": [ + { "code": "const list = [\"a\", \"c\"];\nconst next = [...list.slice(0, 1), \"b\", ...list.slice(1)];\nconsole.log(list, next);", "expected": "[ 'a', 'c' ] [ 'a', 'b', 'c' ]" } + ] + } + }, + { + "id": "react-6-q8", + "type": "fill-in", + "difficulty": 2, + "topic": "更新陣列裡某個物件的屬性(map + 條件展開)", + "docs": "https://react.dev/learn/updating-arrays-in-state", + "story": "", + "prompt": "把 id 是 2 的那筆待辦標記為完成,其餘不動。把空格填起來", + "code": "const todos = [\n { id: 1, text: \"買菜\", done: false },\n { id: 2, text: \"寫程式\", done: false },\n];\n\nfunction toggleDone(id) {\n return todos.map((t) => (____));\n}\n\nconsole.log(toggleDone(2));", + "options": [ + { "id": "a", "text": "t.id === id ? { ...t, done: true } : t" }, + { "id": "b", "text": "t.id === id ? t.done = true : t" }, + { "id": "c", "text": "t.id === id && { done: true }" }, + { "id": "d", "text": "todos[id].done = true" } + ], + "answer": "a", + "explanation": "map 走過每一筆:符合條件的那筆用展開做成一個新物件(保留其他欄位、只換 done),其餘原封不動地放回去——回傳的是一個全新的陣列,但只有真正變動的那筆是新物件。選項 b 的 t.done = true 是就地改本尊(q5 的偷改陷阱);選項 c 沒有處理「不符合」的情況,會讓整包資料形狀跑掉;選項 d 直接拿 id 當索引去挖陣列,恰好對不上還會改錯人。", + "verify": { + "checks": [ + { "code": "const todos = [\n { id: 1, text: \"買菜\", done: false },\n { id: 2, text: \"寫程式\", done: false },\n];\nfunction toggleDone(id) {\n return todos.map((t) => (t.id === id ? { ...t, done: true } : t));\n}\nconsole.log(toggleDone(2));", "expected": "[\n { id: 1, text: '買菜', done: false },\n { id: 2, text: '寫程式', done: true }\n]" } + ] + } + }, + { + "id": "react-6-q9", + "type": "find-bug", + "difficulty": 3, + "topic": "用 Set 當 state 一樣要建新的", + "docs": "https://react.dev/reference/react/useState", + "story": "", + "prompt": "用 Set 存已選標籤,addTag(\"b\") 之後畫面卻沒多顯示一個標籤。哪裡錯了?", + "code": "function TagPicker() {\n const [selected, setSelected] = useState(new Set([\"a\"]));\n\n function addTag(tag) {\n selected.add(tag); // B 行\n setSelected(selected); // C 行\n }\n\n return ;\n}", + "options": [ + { "id": "a", "text": "B 行直接改了原本的 Set,C 行 set 回去還是同一個,React 判斷「沒變」就跳過重繪;要 setSelected(new Set(selected).add(tag))" }, + { "id": "b", "text": "Set 不能拿來當 state,一定要換成陣列" }, + { "id": "c", "text": "addTag 要放進 useEffect 裡才會生效" }, + { "id": "d", "text": "selected.add(tag) 少了 return 關鍵字" } + ], + "answer": "a", + "explanation": "跟物件、陣列一樣的鐵律,Set 和 Map 也適用:selected.add(tag) 是就地突變,回傳值還是同一個 Set 本尊,setSelected(selected) 送回去的跟原本是同一個參照,React 一比對「沒變」就跳過重繪。要讓 React 看見變化,得先複製一份新的 Set 再操作它:new Set(selected).add(tag)(或用展開 new Set([...selected, tag])),把「新的」那個丟給 setSelected。", + "verify": { + "checks": [], + "manual": "需要點擊互動觀察不重繪;機制依 react.dev reference/react/useState(將 state 中的物件、陣列視為唯讀,此原則同樣適用於 Map 和 Set)" + } + }, + { + "id": "react-6-q10", + "type": "same-or-not", + "difficulty": 3, + "topic": "Object.assign 誤用成偷改原物件", + "docs": "https://react.dev/learn/updating-objects-in-state", + "story": "", + "prompt": "都想幫 user 加上 xp: 10。這兩種寫法,對「原本的 user 物件」的影響一樣嗎?", + "code": "// 版本一\nconst user = { name: \"小美\", xp: 0 };\nconst n1 = Object.assign(user, { xp: 10 });\nconsole.log(user.xp, n1 === user);\n\n// 版本二\nconst user2 = { name: \"小美\", xp: 0 };\nconst n2 = Object.assign({}, user2, { xp: 10 });\nconsole.log(user2.xp, n2 === user2);", + "options": [ + { "id": "a", "text": "不一樣:版本一直接改掉了 user 本尊(user.xp 也變 10,n1 跟 user 是同一個);版本二先給一個空物件當底,user2 完好,n2 是新物件" }, + { "id": "b", "text": "一樣,兩者都不會動到原物件" }, + { "id": "c", "text": "一樣,兩者都會動到原物件" }, + { "id": "d", "text": "版本一會報錯,Object.assign 第一個參數不能是變數" } + ], + "answer": "a", + "explanation": "Object.assign 的第一個參數是「目標物件」——會被直接修改,而且回傳的就是同一個參照。把 state 本尊當成第一個參數丟進去,等於就地突變,跟 B 行 user.xp += 10 是同一種錯。要做出「合併出一個新物件」的效果,第一個參數要放一個空物件 {} 當畫布:Object.assign({}, user, { xp: 10 }),或者更常見、更不容易踩錯的寫法是展開:{ ...user, xp: 10 }。", + "verify": { + "checks": [ + { "code": "const user = { name: \"小美\", xp: 0 };\nconst n1 = Object.assign(user, { xp: 10 });\nconsole.log(user.xp, n1 === user);\nconst user2 = { name: \"小美\", xp: 0 };\nconst n2 = Object.assign({}, user2, { xp: 10 });\nconsole.log(user2.xp, n2 === user2);", "expected": "10 true\n0 false" } + ] + } + }, + { + "id": "react-6-q11", + "type": "predict-output", + "difficulty": 1, + "topic": "把陣列指派給另一個變數不會複製它", + "docs": "https://react.dev/learn/updating-arrays-in-state", + "story": "", + "prompt": "想把陣列第二筆改成 99。這段程式印出什麼?", + "code": "const scores = [10, 20, 30];\nconst next = scores;\nnext[1] = 99;\n\nconsole.log(scores, next, scores === next);", + "options": [ + { "id": "a", "text": "[ 10, 99, 30 ] [ 10, 99, 30 ] true" }, + { "id": "b", "text": "[ 10, 20, 30 ] [ 10, 99, 30 ] false" }, + { "id": "c", "text": "[ 10, 99, 30 ] [ 10, 99, 30 ] false" }, + { "id": "d", "text": "會報錯,陣列不能用 index 賦值" } + ], + "answer": "a", + "explanation": "const next = scores 只是把「同一個陣列的參照」多存一份到 next 這個名字裡,並沒有真的複製出新陣列。next[1] = 99 改的就是本尊,所以 scores 跟 next 印出來一模一樣,=== 也是 true。這題故意示範一個常見誤解:以為「宣告一個新變數」等於「做出新資料」——要拿到真正獨立的新陣列,得用 [...scores] 或 map,光是換個變數名字沒有用。", + "verify": { + "checks": [ + { "code": "const scores = [10, 20, 30];\nconst next = scores;\nnext[1] = 99;\nconsole.log(scores, next, scores === next);", "expected": "[ 10, 99, 30 ] [ 10, 99, 30 ] true" } + ] + } + }, + { + "id": "react-6-q12", + "type": "fill-in", + "difficulty": 2, + "topic": "用 Map 當 state 一樣要建新的", + "docs": "https://react.dev/reference/react/useState", + "story": "", + "prompt": "用 Map 存每個商品的庫存,把空格填起來,用「不動原 Map」的方式把 id=2 的庫存改成 5", + "code": "const stock = new Map([[1, 10], [2, 3]]);\n\nfunction updateStock(id, qty) {\n return ____;\n}\n\nconsole.log(updateStock(2, 5).get(2), stock.get(2));", + "options": [ + { "id": "a", "text": "new Map(stock).set(id, qty)" }, + { "id": "b", "text": "stock.set(id, qty)" }, + { "id": "c", "text": "stock" }, + { "id": "d", "text": "Object.assign(stock, { [id]: qty })" } + ], + "answer": "a", + "explanation": "new Map(stock) 複製出一份獨立的新 Map,接下來 .set 只動到這份新的,原本的 stock 完好如初(get(2) 還是 3)。選項 b 直接在本尊上 set,回傳值也是同一個 Map,是跟 q9 一樣的就地突變;選項 d 對 Map 用 Object.assign 語意根本不對,Map 的鍵值不會被這樣寫入。物件、陣列、Set、Map,改之前先複製,是同一條鐵律的四種面貌。", + "verify": { + "checks": [ + { "code": "const stock = new Map([[1, 10], [2, 3]]);\nfunction updateStock(id, qty) {\n return new Map(stock).set(id, qty);\n}\nconsole.log(updateStock(2, 5).get(2), stock.get(2));", "expected": "5 3" } + ] + } } ] } diff --git a/src/data/questions/react-7-pure-components.json b/src/data/questions/react-7-pure-components.json index 1055d45..62d50f4 100644 --- a/src/data/questions/react-7-pure-components.json +++ b/src/data/questions/react-7-pure-components.json @@ -138,6 +138,141 @@ "checks": [], "manual": "StrictMode 雙重渲染是開發模式的客戶端行為,SSR 驗不了;答案依 react.dev keeping-components-pure 的 StrictMode 說明" } + }, + { + "id": "react-7-q7", + "type": "predict-output", + "difficulty": 1, + "topic": "console.log 是安全的除錯手段,不算破壞純粹性", + "docs": "https://react.dev/learn/keeping-components-pure", + "story": "", + "prompt": "為了除錯,在渲染函式裡加了一行 console.log。這樣渲染兩次,會印出什麼?", + "code": "function Score({ points }) {\n console.log(\"渲染,points =\", points);\n return

                        分數:{points}

                        ;\n}\n\n// 連續渲染兩次 ", + "options": [ + { "id": "a", "text": "「渲染,points = 10」「

                        分數:10

                        」各印兩次——console.log 只是輸出訊息,沒有改任何外部變數、props 或 state,元件依然是同樣輸入、同樣結果" }, + { "id": "b", "text": "第二次渲染會因為 console.log 而報錯" }, + { "id": "c", "text": "第二次渲染畫面會變成

                        分數:11

                        ,因為 log 讓 points 累加了" }, + { "id": "d", "text": "只有第一次會印,第二次 console.log 被 React 靜音" } + ], + "answer": "a", + "explanation": "純不純的判準是「有沒有動到函式外面的東西」:console.log 只是把訊息送到主控台,不讀不寫任何外部變數、不碰 props,兩次渲染算出的畫面完全一樣。開發模式的 StrictMode 會讓元件多渲染一次來抓不純的 bug,你會看到這行 log 印兩次——那是體檢在跑,不是程式壞了(呼應 q6)。真正該小心的是像 q1 那樣「靠 log 順帶做了計算或改了外部狀態」,那才是不純。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Score({ points }) {\n console.log(\"渲染,points =\", points);\n return

                        分數:{points}

                        ;\n}\nconsole.log(renderToStaticMarkup());\nconsole.log(renderToStaticMarkup());", "expected": "渲染,points = 10\n

                        分數:10

                        \n渲染,points = 10\n

                        分數:10

                        " } + ] + } + }, + { + "id": "react-7-q8", + "type": "find-bug", + "difficulty": 2, + "topic": "操作 DOM 是副作用,該搬進 Effect", + "docs": "https://react.dev/learn/synchronizing-with-effects", + "story": "", + "prompt": "這個元件在渲染時直接改瀏覽器分頁標題。哪裡不妥?", + "code": "function PageTitle({ title }) {\n document.title = title; // 渲染時直接動 DOM\n return

                        {title}

                        ;\n}", + "options": [ + { "id": "a", "text": "document.title = title 是在動「渲染函式之外的世界」,這種跟外部系統同步的動作屬於副作用,不該混在算畫面的過程裡;要搬進 useEffect,讓 React 畫完面之後才執行" }, + { "id": "b", "text": "document.title 只能在 App 最外層設定一次" }, + { "id": "c", "text": "h1 標籤裡不能同時顯示 title" }, + { "id": "d", "text": "title 應該用 state 存,不能用 props" } + ], + "answer": "a", + "explanation": "渲染函式的工作是「算出這次要顯示的畫面」,回傳 JSX 就該結束了;直接動全域的 document 物件是在跟瀏覽器這個外部系統打交道,React 沒辦法保證這行程式碼跑幾次、什麼時候跑(StrictMode 開發模式會跑兩次),標題就可能被設定成奇怪的中間狀態。正確做法是用 useEffect 把這個副作用包起來,讓它在渲染完成、畫面真的貼到畫面上之後才執行一次——這正是 react-4 教過的 Effect 機制要解決的問題:副作用要跟渲染分開。", + "verify": { + "checks": [], + "manual": "document 在 Node 環境不存在,需要瀏覽器 DOM 才能觀察;原則依 react.dev synchronizing-with-effects(渲染以外的世界互動屬於副作用,該放進 Effect)" + } + }, + { + "id": "react-7-q9", + "type": "same-or-not", + "difficulty": 2, + "topic": "時間戳記寫死在渲染裡 vs 由外面傳入", + "docs": "https://react.dev/learn/keeping-components-pure", + "story": "", + "prompt": "都是顯示一筆時間戳記。對「純不純」來說,這兩種寫法一樣嗎?", + "code": "// 版本一:渲染時當場取得時間\nfunction Log1() {\n return

                        記錄時間:{Date.now()}

                        ;\n}\n\n// 版本二:時間由外面傳入\nfunction Log2({ time }) {\n return

                        記錄時間:{time}

                        ;\n}", + "options": [ + { "id": "a", "text": "不一樣:版本一同樣沒有輸入變化,每次渲染仍可能給出不同的時間值,是不穩定輸出(不純);版本二同樣的 time 永遠印出同樣結果(純)——時間該在事件發生的當下算好,再往下傳" }, + { "id": "b", "text": "一樣,都是純的" }, + { "id": "c", "text": "一樣,都不純,因為都顯示了時間" }, + { "id": "d", "text": "版本二會報錯,props 不能是數字" } + ], + "answer": "a", + "explanation": "跟 q4 的 Math.random() 是同一種毛病:Date.now()、Math.random() 這類「讀取渲染當下的外部狀態」的呼叫,只要放進渲染函式,就會讓同一個元件、同樣的 props,在不同時間點渲染出不同結果——這正是不純的定義。修法也一樣:把不穩定的來源移到渲染之外,在事件處理器或 Effect 裡讀取一次,算好了再用 props 或 state 交給元件,元件本身只負責「顯示」。", + "verify": { + "checks": [], + "manual": "Date.now() 的輸出隨執行時間變動,無法用單次比對驗證;判定依 react.dev keeping-components-pure(渲染期間不可依賴會變動的外部資料)" + } + }, + { + "id": "react-7-q10", + "type": "predict-output", + "difficulty": 1, + "topic": "呼叫其他純函式,組合起來還是純的", + "docs": "https://react.dev/learn/keeping-components-pure", + "story": "", + "prompt": "XpBadge 呼叫了另一個函式 formatXp 來算文字。這樣算不純嗎?渲染出什麼?", + "code": "function formatXp(xp) {\n return `${xp} 點經驗值`;\n}\n\nfunction XpBadge({ xp }) {\n return {formatXp(xp)};\n}\n\n// ", + "options": [ + { "id": "a", "text": "純的——只要被呼叫的函式本身也不碰外部變數、不改 props,組合起來還是純函式;渲染出 10 點經驗值" }, + { "id": "b", "text": "不純,元件裡不能呼叫別的函式" }, + { "id": "c", "text": "不純,因為用了樣板字串" }, + { "id": "d", "text": "會報錯,函式要定義在元件裡面才能呼叫" } + ], + "answer": "a", + "explanation": "純函式可以放心呼叫別的純函式:formatXp 只根據傳進來的 xp 算字串、不碰任何外部東西,XpBadge 呼叫它、把結果塞進 JSX,整條鏈路依然「同樣輸入、同樣輸出」。q3 已經證明「自己建的區域變數」怎麼改都合法,這題再往前一步:把計算拆成獨立的純函式來組合,是完全鼓勵的寫法,也是拆分邏輯、方便測試的常見做法。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction formatXp(xp) {\n return `${xp} 點經驗值`;\n}\nfunction XpBadge({ xp }) {\n return {formatXp(xp)};\n}\nconsole.log(renderToStaticMarkup());", "expected": "10 點經驗值" } + ] + } + }, + { + "id": "react-7-q11", + "type": "fill-in", + "difficulty": 2, + "topic": "渲染中自己建的 Set,怎麼改都合法", + "docs": "https://react.dev/learn/keeping-components-pure", + "story": "", + "prompt": "這個元件想在渲染中用 Set 去除重複標籤(Set 是自己建的,改它沒問題)。把空格填起來", + "code": "function TagList({ tags }) {\n const seen = new Set();\n const unique = tags.filter((t) => {\n if (seen.has(t)) return false;\n ____;\n return true;\n });\n return

                        {unique.join(\"、\")}

                        ;\n}\n\n// ", + "options": [ + { "id": "a", "text": "seen.add(t)" }, + { "id": "b", "text": "seen = seen.add(t)" }, + { "id": "c", "text": "seen.push(t)" }, + { "id": "d", "text": "tags.delete(t)" } + ], + "answer": "a", + "explanation": "seen 是這次渲染自己生出來的區域 Set,渲染中呼叫 seen.add(t) 純粹是操作「自己的」資料,不碰任何外部變數或 props——跟 q3 的 rows.push 是同一件事的 Set 版本,重渲染一次結果照樣相同,畫面顯示「紅、綠」。選項 c 的 push 是陣列方法,Set 沒有這個方法;選項 d 對 tags(props)呼叫 delete 更是直接踩到 q2 的紅線——改到了不該碰的 props。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction TagList({ tags }) {\n const seen = new Set();\n const unique = tags.filter((t) => {\n if (seen.has(t)) return false;\n seen.add(t);\n return true;\n });\n return

                        {unique.join(\"、\")}

                        ;\n}\nconsole.log(renderToStaticMarkup());", "expected": "

                        紅、綠

                        " } + ] + } + }, + { + "id": "react-7-q12", + "type": "same-or-not", + "difficulty": 3, + "topic": "網路請求是副作用,該放進 Effect", + "docs": "https://react.dev/learn/synchronizing-with-effects", + "story": "", + "prompt": "兩個版本都會打 API。對「渲染」這件事的影響一樣嗎?", + "code": "// 版本一:在渲染時發送請求\nfunction Profile({ id }) {\n fetch(`/api/user/${id}`); // 每算一次畫面就打一次\n return

                        使用者 {id}

                        ;\n}\n\n// 版本二:把請求放進 Effect\nfunction Profile2({ id }) {\n useEffect(() => {\n fetch(`/api/user/${id}`);\n }, [id]);\n return

                        使用者 {id}

                        ;\n}", + "options": [ + { "id": "a", "text": "不一樣:版本一渲染本身就帶有副作用,React 每算一次畫面就多打一次 API(StrictMode 下更明顯);版本二渲染只負責回傳 JSX,副作用被 Effect 延後到渲染「之後」才發生,而且能靠依賴陣列 [id] 控制何時該重打" }, + { "id": "b", "text": "一樣,兩者都是渲染完才發送請求" }, + { "id": "c", "text": "一樣,兩者都會造成無限迴圈" }, + { "id": "d", "text": "版本二會報錯,useEffect 裡不能呼叫 fetch" } + ], + "answer": "a", + "explanation": "跟 q8 的 document.title 是同一類問題:發網路請求是跟外部世界(伺服器)打交道,屬於副作用,不該混進「算畫面」的過程。版本一每次渲染都順手發一次請求,元件被重繪多少次就打多少次 API,完全失控;版本二用 useEffect 把它包起來,React 畫完面才執行,還能用依賴陣列 [id] 決定「id 沒變就不用重打」。呼應這兩章的主題:keeping-components-pure 講渲染要純,Effect 就是官方指定「副作用該去的地方」。", + "verify": { + "checks": [], + "manual": "需要瀏覽器/Node fetch 環境與多輪渲染觀察請求次數;機制依 react.dev keeping-components-pure 與 synchronizing-with-effects" + } } ] } diff --git a/src/data/questions/react-8-sharing-state.json b/src/data/questions/react-8-sharing-state.json index ff01933..60774a9 100644 --- a/src/data/questions/react-8-sharing-state.json +++ b/src/data/questions/react-8-sharing-state.json @@ -135,6 +135,141 @@ "checks": [], "manual": "屬架構設計判斷,無單一可執行輸出;依 react.dev sharing-state-between-components 的單一資料源原則" } + }, + { + "id": "react-8-q7", + "type": "fill-in", + "difficulty": 2, + "topic": "lifting state up:state 搬家,子元件變成純接收者", + "docs": "https://react.dev/learn/sharing-state-between-components", + "story": "", + "prompt": "把 Toggle 的開關 state 上移到父層 Panel 管理,子元件不再擁有自己的 state。把空格填起來", + "code": "// 重構前:Toggle 自己管理 state\n// function Toggle() {\n// const [on, setOn] = useState(false);\n// return ;\n// }\n\n// 重構後:state 搬到父層,Toggle 變成純接收者\nfunction Toggle({ on, onToggle }) {\n return ;\n}\n\nfunction Panel() {\n const [on, setOn] = useState(false);\n return <____>;\n}", + "options": [ + { "id": "a", "text": "Toggle on={on} onToggle={() => setOn(!on)}" }, + { "id": "b", "text": "Toggle on={on}" }, + { "id": "c", "text": "Toggle onToggle={setOn}" }, + { "id": "d", "text": "Toggle on={!on} onToggle={setOn(!on)}" } + ], + "answer": "a", + "explanation": "lifting state up 的具體動作是:把子元件裡的 useState 整個拿掉,state 和「修改它的函式」一起留在父層,子元件只透過 props 接「目前的值」和「觸發變更用的函式」。選項 b 少傳了 onToggle,按鈕點了沒反應;選項 c 把 setOn 整包硬塞給 onToggle,點擊事件物件會直接被當成新的 on 值傳進 setOn(一個很容易中招的坑);選項 d 的 setOn(!on) 在渲染當下就執行了,而不是等點擊才執行。父層包一層箭頭函式,是最直接也最安全的寫法。", + "verify": { + "checks": [ + { "jsx": "import { useState } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nfunction Toggle({ on, onToggle }) {\n return ;\n}\nfunction Panel() {\n const [on, setOn] = useState(false);\n return setOn(!on)} />;\n}\nconsole.log(renderToStaticMarkup());", "expected": "" } + ] + } + }, + { + "id": "react-8-q8", + "type": "same-or-not", + "difficulty": 2, + "topic": "controlled vs uncontrolled 元件初探", + "docs": "https://react.dev/reference/react-dom/components/input", + "story": "", + "prompt": "都能讓使用者打字。這兩種 input 寫法,React 對「目前輸入了什麼」的掌握程度一樣嗎?", + "code": "// 版本一:受控,畫面上顯示的值完全由 state 決定\nfunction Field1() {\n const [text, setText] = useState(\"\");\n return setText(e.target.value)} />;\n}\n\n// 版本二:非受控,值由 DOM 自己記,React 不追蹤每個按鍵\nfunction Field2() {\n return ;\n}", + "options": [ + { "id": "a", "text": "不一樣:版本一(受控)每個按鍵都會更新 state,React 隨時知道目前的值、也能控制它(例如強制清空、驗證、同步給別的元件);版本二(非受控)只給了初始值,之後打字由 DOM 自己記,React 不知道使用者現在打了什麼,除非另外用 ref 去讀" }, + { "id": "b", "text": "一樣,React 都全程掌握輸入值" }, + { "id": "c", "text": "一樣,React 都不掌握輸入值" }, + { "id": "d", "text": "版本一會報錯,input 不能同時給 value 跟 onChange" } + ], + "answer": "a", + "explanation": "value + onChange 讓 React 成為輸入框的「唯一真相來源」——這正是本章「單一資料源」精神在表單上的實踐:要跟別的元件共享這個輸入值、要清空、要驗證,隨時能靠 state 做到,這叫受控元件。defaultValue 只給 DOM 一個起始值,打字之後的內容活在 DOM 自己的記憶裡,React 對它視而不見,這叫非受控元件,除非搭配 ref 才能讀到目前的值。多數需要「跟其他 UI 同步」的表單都該選受控。", + "verify": { + "checks": [], + "manual": "需要打字互動觀察 state 是否隨按鍵更新;概念依 react.dev reference/react-dom/components/input 的 controlled vs uncontrolled 說明" + } + }, + { + "id": "react-8-q9", + "type": "predict-output", + "difficulty": 3, + "topic": "把 setter 函式往下傳當 callback", + "docs": "https://react.dev/learn/sharing-state-between-components", + "story": "", + "prompt": "兩個按鈕元件共用同一個 setCount,各自往下傳當 callback。依序點擊「+1」「+1」「歸零」「+1」後,畫面顯示多少?", + "code": "function IncButton({ onChange }) {\n return ;\n}\nfunction ResetButton({ onChange }) {\n return ;\n}\n\nfunction Counter() {\n const [count, setCount] = useState(0);\n return (\n
                        \n \n \n

                        目前:{count}

                        \n
                        \n );\n}", + "options": [ + { "id": "a", "text": "1 —— setCount 本身就是 React 給的更新函式,不管從哪個子元件呼叫,改的都是同一個 count;三次 +1、一次歸零,最後落在 1" }, + { "id": "b", "text": "4 —— 歸零沒有真的生效" }, + { "id": "c", "text": "0 —— 歸零永遠是最後才算" }, + { "id": "d", "text": "會報錯,同一個 setCount 不能傳給兩個元件" } + ], + "answer": "a", + "explanation": "setCount 是 React 保證「身分穩定」的更新函式,不需要包一層額外的箭頭函式也能直接當 props 往下傳、被不同的子元件呼叫——不管從 IncButton 還是 ResetButton 呼叫,動的都是 Counter 這唯一一份 count。依序點擊:+1(0→1)、+1(1→2)、歸零(→0)、+1(0→1),最後畫面停在 1。這是「資料往下、事件往上」的進階版:事件處理器不一定要在父層現包,setter 本身就能直接遞下去給多個消費者共用。", + "verify": { + "checks": [], + "manual": "需要依序多次點擊觀察 state 變化;機制依 react.dev sharing-state-between-components(setter 函式可作為 props 向下傳遞、供多個子元件共用)" + } + }, + { + "id": "react-8-q10", + "type": "fill-in", + "difficulty": 2, + "topic": "state 該放在哪:找兩者最近的共同父層", + "docs": "https://react.dev/learn/sharing-state-between-components", + "story": "", + "prompt": "Left 和 Right 都需要用到 selected,且要能互相同步。selected 的 state 應該放在哪個元件?把空格填起來", + "code": "function Left({ selected, onSelect }) {\n return ;\n}\nfunction Right({ selected, onSelect }) {\n return ;\n}\n\nfunction ____() {\n const [selected, setSelected] = useState(null);\n return (\n
                        \n \n \n
                        \n );\n}\n\nfunction App() {\n return
                        ;\n}", + "options": [ + { "id": "a", "text": "Section(Left 和 Right 最近的共同父層)" }, + { "id": "b", "text": "App(愈高層愈安全)" }, + { "id": "c", "text": "Left(其中一個子元件自己存)" }, + { "id": "d", "text": "Right(另一個子元件自己存)" } + ], + "answer": "a", + "explanation": "react.dev 官方給的三步驟是:①從子元件移除 state、②從共同父層傳固定值下去接好接線、③在共同父層加上 state,把值和修改函式一起往下傳。放的位置原則是「兩者最近的共同父層」:不用往上放到 App 這種更高層(沒有其他人需要用到,放高了只是讓不相干的元件也跟著重繪,呼應 react-7 q8/q12「不必要的重繪」概念);也不能放進 Left 或 Right 任一個裡,那就變回 q2 的老問題——同名不同 state,各存各的。", + "verify": { + "checks": [ + { "jsx": "import { useState } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nfunction Left({ selected, onSelect }) {\n return ;\n}\nfunction Right({ selected, onSelect }) {\n return ;\n}\nfunction Section() {\n const [selected, setSelected] = useState(null);\n return (\n
                        \n \n \n
                        \n );\n}\nconsole.log(renderToStaticMarkup(
                        ));", "expected": "
                        " } + ] + } + }, + { + "id": "react-8-q11", + "type": "predict-output", + "difficulty": 1, + "topic": "兩個 input 綁同一個 state,畫面永遠同步", + "docs": "https://react.dev/learn/sharing-state-between-components", + "story": "", + "prompt": "兩個輸入框都綁同一個 text state。初始渲染畫面上,兩個框顯示的值是什麼?", + "code": "function App() {\n const [text, setText] = useState(\"hi\");\n return (\n
                        \n setText(e.target.value)} />\n setText(e.target.value)} />\n
                        \n );\n}", + "options": [ + { "id": "a", "text": "都是 \"hi\" —— 同一份 state 餵給兩個輸入框,永遠同步;不管在哪一個框打字,改的都是同一個 text,另一個框下次渲染立刻跟著變" }, + { "id": "b", "text": "第一個 \"hi\",第二個空白" }, + { "id": "c", "text": "都是空的,因為兩個 input 搶同一個 state 會清空" }, + { "id": "d", "text": "會報錯,同一個 state 不能綁兩個 input" } + ], + "answer": "a", + "explanation": "跟 q1 的價格範例是同一個原則,只是換成表單:text 只有一份、住在 App,兩個 都從同一個來源讀 value——初始都顯示 \"hi\"。之後不管在哪個框打字,onChange 都呼叫同一個 setText,觸發重繪後兩個框拿到的還是同一個新值,畫面不可能不同步。這就是單一資料源的威力:要讓多個輸入框互相連動,答案永遠是共用同一份 state,而不是各自 useState 再想辦法對齊(q2 的反例)。", + "verify": { + "checks": [ + { "jsx": "import { useState } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nfunction App() {\n const [text, setText] = useState(\"hi\");\n return (\n
                        \n setText(e.target.value)} />\n setText(e.target.value)} />\n
                        \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "
                        " } + ] + } + }, + { + "id": "react-8-q12", + "type": "find-bug", + "difficulty": 3, + "topic": "不是所有 state 都要上提:純粹自己的 UI state 留在原地", + "docs": "https://react.dev/learn/sharing-state-between-components", + "story": "", + "prompt": "menuOpen 只有 Navbar 自己用得到,卻被搬到最上層的 App 管理。這樣做有什麼代價?", + "code": "function App() {\n const [menuOpen, setMenuOpen] = useState(false); // 只有 Navbar 自己用到\n return (\n
                        \n setMenuOpen(!menuOpen)} />\n \n
                        \n );\n}\n\nfunction Navbar({ open, onToggle }) {\n return ;\n}", + "options": [ + { "id": "a", "text": "沒有共享的必要卻硬上提:多繞一手 props,App 也會因為 menuOpen 改變而重新渲染,連帶影響 MainContent 這些其實不相干的兄弟元件;純粹屬於 Navbar 自己的 UI state(例如選單開關),留在 Navbar 內部用自己的 useState 就好" }, + { "id": "b", "text": "這樣寫是對的,state 永遠是愈高層愈好" }, + { "id": "c", "text": "這樣會報錯,App 不能有跟子元件同名的 state" }, + { "id": "d", "text": "MainContent 會拿到錯誤的 menuOpen 值" } + ], + "answer": "a", + "explanation": "本章前面在教「怎麼把 state 往上搬」,但上提是有代價的手術,不是預設動作:只要沒有第二個元件需要用到,就不需要共享,留在原地最單純。硬把只有一個消費者的 state 上提,除了多一層 props 傳遞的麻煩,父層重繪的範圍也跟著變大——不相干的兄弟元件被迫陪著重新渲染(呼應 react-9 會細講的渲染範圍問題)。判斷準則很簡單:這份資料「有超過一個元件需要看到/改到它」嗎?沒有就別搬家,q7 的 lifting state up 是解法,不是每個 state 都要做的手續。", + "verify": { + "checks": [], + "manual": "重繪範圍變大屬於效能/渲染行為,無法用單次執行輸出驗證;原則依 react.dev sharing-state-between-components 的『何時該共享』精神" + } } ] } diff --git a/src/data/questions/react-9-preserving-state.json b/src/data/questions/react-9-preserving-state.json index 2d6bb89..40c110d 100644 --- a/src/data/questions/react-9-preserving-state.json +++ b/src/data/questions/react-9-preserving-state.json @@ -133,6 +133,138 @@ "checks": [], "manual": "需要切換互動觀察 state 銷毀;機制依 react.dev preserving-and-resetting-state(同位置不同型別即重建)" } + }, + { + "id": "react-9-q7", + "type": "predict-output", + "difficulty": 2, + "topic": "同元件、不同位置,state 不共用", + "docs": "https://react.dev/learn/preserving-and-resetting-state", + "story": "", + "prompt": "同一個 ,一開始只出現在 else 分支(已按到 5)。把 showHint 打開後,Counter 多了個在它前面的 。這時 Counter 顯示多少?", + "code": "function App({ showHint }) {\n return (\n
                        \n {showHint ? (\n <>\n \n \n \n ) : (\n \n )}\n
                        \n );\n}\n// Counter 已按到 5,接著 showHint 從 false 切成 true", + "options": [ + { "id": "a", "text": "0 —— Counter 前面多了個 Hint,它在樹上的位置變了,等於換了個新位置,state 歸零重來" }, + { "id": "b", "text": "5 —— 反正還是同一個 Counter 元件,state 不受影響" }, + { "id": "c", "text": "5 —— Hint 是另一個元件,不會干擾 Counter" }, + { "id": "d", "text": "會報錯,同一層不能突然多一個手足元件" } + ], + "answer": "a", + "explanation": "React 認 state 靠「元件在樹上的位置」,不是只看「這是不是同一種元件」。多插一個 Hint 進來,Counter 從「這個位置的第一個、也是唯一的孩子」變成「第二個孩子」,位置變了,state 就跟著歸零。這比 q1 更細一層:同種元件、位置不同,一樣會重置;位置和種類都要對得上,state 才會被留住。", + "verify": { + "checks": [], + "manual": "需要多輪渲染互動觀察;機制依 react.dev preserving-and-resetting-state(state 綁定的是樹上的位置,多插入手足元件會改變位置)" + } + }, + { + "id": "react-9-q8", + "type": "find-bug", + "difficulty": 3, + "topic": "元件定義寫在另一個元件內部", + "docs": "https://react.dev/learn/preserving-and-resetting-state", + "story": "", + "prompt": "聊天室的訊息輸入框,每次按下「送出」,草稿打好的字就無故消失。哪裡錯了?", + "code": "function ChatRoom() {\n const [message, setMessage] = useState(\"\");\n\n function MessageInput() { // B 行:定義寫在 ChatRoom 內部\n const [draft, setDraft] = useState(\"\");\n return setDraft(e.target.value)} />;\n }\n\n return (\n
                        \n \n \n
                        \n );\n}", + "options": [ + { "id": "a", "text": "B 行是元凶:MessageInput 定義在 ChatRoom 函式裡面,ChatRoom 每次重繪都會重新產生一個「全新」的 MessageInput 函式,React 認不出它跟上一輪是同一個元件種類,於是整組拆掉重建,draft 歸零" }, + { "id": "b", "text": "useState 不能用在事件處理器之外" }, + { "id": "c", "text": "input 元素不能放在 div 裡" }, + { "id": "d", "text": "onChange 應該改成 onInput" } + ], + "answer": "a", + "explanation": "元件的「身分」是它的函式本身;MessageInput 寫在 ChatRoom 裡面,代表每次 ChatRoom 重繪,JS 都重新宣告了一次 MessageInput——雖然程式碼長得一樣,但對 React 而言是不同的函式參照,等於「換了種類的元件」,直接觸發 q6 那種整組銷毀重建。這是 react.dev 特別點名的地雷:元件一律要定義在最外層,不要巢狀宣告。", + "verify": { + "checks": [], + "manual": "需要點擊互動觀察 draft 消失;機制依 react.dev preserving-and-resetting-state 的提醒(不要在元件內部定義元件)" + } + }, + { + "id": "react-9-q9", + "type": "predict-output", + "difficulty": 2, + "topic": "用穩定 id 當 key,重新排序後 state 正確跟著資料走", + "docs": "https://react.dev/learn/preserving-and-resetting-state", + "story": "", + "prompt": "三列 ,各自用 useState 存草稿。id 是 \"a\" 的那列打了「hello」。接著把資料陣列重新排序(\"a\" 從第一筆移到最後一筆,id 不變),畫面上 id 是 \"a\" 的那列草稿還在嗎?", + "code": "{rows.map((row) => (\n \n))}\n// rows 原本是 [a, b, c],重新排序成 [b, c, a]", + "options": [ + { "id": "a", "text": "還在,草稿仍是「hello」——key 跟著資料一起移動,React 認得「這還是同一個 a」,state 也跟著搬過去" }, + { "id": "b", "text": "消失了,排序一變 state 就重置" }, + { "id": "c", "text": "草稿會跑到現在排第一的那列(跟 q3 一樣錯位)" }, + { "id": "d", "text": "會報錯,key 不能跟著資料重新排序" } + ], + "answer": "a", + "explanation": "這題是 q3 錯誤示範的正解版:用 index 當 key,排序一變位置對應就亂了;用資料自帶的穩定 id 當 key,不管怎麼搬移、排序,React 都能靠 key 精準認出「這還是原來那個 a」,state(這裡是草稿文字)自然跟著資料一起走,不會弄丟也不會錯位。這正是官方建議「列表一律用穩定 id 當 key」的核心理由。", + "verify": { + "checks": [], + "manual": "需要輸入與重新排序的互動;機制依 react.dev preserving-and-resetting-state(穩定 key 讓 state 跟著對應項目移動)" + } + }, + { + "id": "react-9-q10", + "type": "same-or-not", + "difficulty": 3, + "topic": "重置 state 的兩種官方手法:換位置 vs 換 key", + "docs": "https://react.dev/learn/preserving-and-resetting-state", + "story": "", + "prompt": "目標一樣:切換分頁時 Form 要整個歸零重來。這兩種寫法,效果一樣嗎?", + "code": "// 做法一:兩個分頁分別在三元運算子的不同分支各放一個 Form\n{tab === \"a\" ? : }\n\n// 做法二:同一個位置,但用 key 讓它跟著 tab 變\n", + "options": [ + { "id": "a", "text": "效果一樣:兩種都是官方認可的做法——做法一是利用「不同分支=不同位置」讓每次切換都是全新實例,做法二是直接靠 key 觸發重建,殊途同歸" }, + { "id": "b", "text": "不一樣,只有做法一會重置" }, + { "id": "c", "text": "不一樣,只有做法二會重置" }, + { "id": "d", "text": "都不會重置,兩種都要另外呼叫 useEffect 清空表單" } + ], + "answer": "a", + "explanation": "react.dev 明講重置 state 有兩條路:把元件放到「不同位置」(像做法一,三元運算子兩個分支各自一份,切換時位置本來就不同),或者「同一個位置但換 key」(做法二,也就是 q2、q5 用過的招)。兩者殊途同歸,選哪個看情境:位置天然就會變(例如 if/else 分頁)用做法一很自然;同一個位置想手動觸發重建,用做法二的 key 更直接。", + "verify": { + "checks": [], + "manual": "需要切換分頁互動比較兩種寫法;機制依 react.dev preserving-and-resetting-state 的「Option 1: Rendering a component in different positions」與「Option 2: Resetting state with a key」" + } + }, + { + "id": "react-9-q11", + "type": "find-bug", + "difficulty": 3, + "topic": "多包一層 DOM 標籤也會讓 state 意外重置", + "docs": "https://react.dev/learn/preserving-and-resetting-state", + "story": "", + "prompt": "想幫花俏模式的 Counter 加個外框,結果切換 isFancy 之後,Counter 的計數莫名其妙被重置了。哪裡出了問題?", + "code": "{isFancy ? (\n
                        // B 行:多包了一層 div\n \n
                        \n) : (\n \n)}", + "options": [ + { "id": "a", "text": "B 行讓兩個分支的樹狀結構不一致:一個分支 Counter 是最外層,另一個分支 Counter 包在 div 底下,位置其實不同,state 因此被重置;要讓兩個分支的外層結構保持一致(都包 div,或都不包)" }, + { "id": "b", "text": "className 打錯字,應該用 class" }, + { "id": "c", "text": "三元運算子的兩個分支不能長度不同" }, + { "id": "d", "text": "Counter 需要額外傳一個 key 才能加外框" } + ], + "answer": "a", + "explanation": "跟 q7 是同一條規則的另一種呈現:不是 Counter 本身變了,是它「外面包的東西」變了,導致 Counter 在整棵樹上的路徑不同——一個是「div 底下的第一個孩子」、一個是「這個位置本身」,React 視為兩個不同位置,state 照樣歸零。修法很簡單:讓花俏/不花俏兩種情況共用相同的外層結構,只在裡面切換要不要加 class。", + "verify": { + "checks": [], + "manual": "需要切換互動觀察 state 被重置;機制依 react.dev preserving-and-resetting-state(改變周圍樹狀結構等同改變位置)" + } + }, + { + "id": "react-9-q12", + "type": "fill-in", + "difficulty": 2, + "topic": "用遞增計數器當 key,做一個「重置」按鈕", + "docs": "https://react.dev/learn/preserving-and-resetting-state", + "story": "", + "prompt": "要做一顆「重新開局」按鈕:不換使用者、不換任何 props,純粹按一下就讓 整組歸零重來。把空格填起來", + "code": "function Board() {\n const [resetCount, setResetCount] = useState(0);\n\n return (\n
                        \n \n \n
                        \n );\n}", + "options": [ + { "id": "a", "text": "resetCount" }, + { "id": "b", "text": "\"game\"" }, + { "id": "c", "text": "Math.random()" }, + { "id": "d", "text": "0" } + ], + "answer": "a", + "explanation": "跟 q5 用 userId 當 key 是同一招的變形,差別在這裡的 key 不是來自外部 props,而是元件自己養的一個「純粹用來觸發重建」的計數器:每按一次重置鈕,resetCount+1,key 換了,Game 連同它內部所有 state 整組拆掉重建。選項 b、d 是固定值,按幾次都不會變,等於沒接上重置邏輯;選項 c 的老問題在 q5 就講過——每次渲染都不同,會變成打地鼠式的無限重置。", + "verify": { + "checks": [], + "manual": "需要點擊互動觀察重建;機制依 react.dev preserving-and-resetting-state(key 變更即強制重建)" + } } ] } diff --git a/src/screens/ChapterMap.jsx b/src/screens/ChapterMap.jsx index a876eb4..f6ccc0f 100644 --- a/src/screens/ChapterMap.jsx +++ b/src/screens/ChapterMap.jsx @@ -1,6 +1,5 @@ import { useState } from 'react' import { chapters } from '../data/chapters' -import { QUIZ_SIZE } from '../utils/quiz' import Icon from '../components/Icons' function ChapterList({ progress, onOpenChapter, onBack }) { @@ -84,7 +83,7 @@ function LevelList({ chapter, progress, onStartLevel, onBack }) { ? `最佳 ${record.best}/${record.total}` : locked ? '完成上一關解鎖' - : `一次 ${Math.min(QUIZ_SIZE, level.questions.length)} 題・題池 ${level.questions.length}`} + : `共 ${level.questions.length} 題`} ) diff --git a/src/utils/quiz.js b/src/utils/quiz.js index 4936618..5c42ec2 100644 --- a/src/utils/quiz.js +++ b/src/utils/quiz.js @@ -1,5 +1,5 @@ -// 題池抽題:每次進關從關卡題池隨機抽 QUIZ_SIZE 題,依難度排序作答 -export const QUIZ_SIZE = 6 +// 進關作答整個題池:洗牌後依難度由易到難排(同難度內順序隨機) +export const REVIEW_SIZE = 6 export function shuffle(arr) { const copy = [...arr] @@ -10,8 +10,7 @@ export function shuffle(arr) { return copy } -export function sampleQuestions(pool, n = QUIZ_SIZE) { - const picked = shuffle(pool).slice(0, n) - // 抽完照難度由易到難排,保住關卡內的坡度 - return picked.sort((a, b) => a.difficulty - b.difficulty) +export function sampleQuestions(pool) { + // sort 是穩定排序,洗牌後再排難度,同難度題目每次順序不同 + return shuffle(pool).sort((a, b) => a.difficulty - b.difficulty) } From 3d6507b17f1565fc5a66c3be9acb1973b4f2f68e Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Sat, 11 Jul 2026 10:09:55 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=A1=8C=E5=BA=AB?= =?UTF-8?q?=E5=A4=9A=E8=99=95=E4=BA=8B=E5=AF=A6=E9=8C=AF=E8=AA=A4=EF=BC=9A?= =?UTF-8?q?=E5=A4=B1=E6=95=88=20MDN=20=E9=80=A3=E7=B5=90=E3=80=81setTimeou?= =?UTF-8?q?t=20this=20=E7=B6=81=E5=AE=9A=E3=80=81Promise=20=E7=B3=BB?= =?UTF-8?q?=E5=88=97=E7=B4=B0=E7=AF=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正 setTimeout 相關題目的 404 zh-TW MDN 連結,改用有效的 en-US 連結 - jsa-1-q10:this 綁定改為說明依執行環境而定(Node 綁 Timeout 物件,非全域物件) - jsa-3-q12:未處理 rejection 當掉的行為明確標註為 Node.js 預設行為 - jsa-4-q12:Promise.race 補充說明其他 Promise 仍會執行完,只是結果不被採用 - jsa-8-q10:修正 generator 於 break 前是否已交出第 4 個值的時序描述 - jsa-9:修正 finally 可能被 throw 覆蓋結果、Promise.resolve 對原生 Promise 與 thenable 的處理差異、錯字修正 - jsb-2-q10:改用精確的「按值傳遞、物件值是參照」說法 - react-1-q11:實測後改寫為 class/className 渲染輸出相同、差異在主控台警告;q8/q10 補充說明與引用修正 - docs/roadmap.md:題數摘要更新為 348 題 Co-Authored-By: Claude Sonnet 5 --- docs/roadmap.md | 2 +- src/data/questions/jsa-1-this-scope.json | 4 ++-- src/data/questions/jsa-2-closures.json | 2 +- src/data/questions/jsa-3-async-basics.json | 8 ++++---- src/data/questions/jsa-4-async-await.json | 2 +- src/data/questions/jsa-5-classes.json | 2 +- .../questions/jsa-8-iterators-generators.json | 2 +- src/data/questions/jsa-9-promises-advanced.json | 6 +++--- src/data/questions/jsb-2-functions.json | 6 +++--- src/data/questions/react-1-jsx.json | 16 ++++++++-------- 10 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 8dfbebd..0a19708 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # EasyLearn 開發 Roadmap -> 建立日期:2026-07-11。已完成的基礎:29 關 174+ 題、PWA 可安裝、XP/連續天數/錯題重練、進度匯出匯入。 +> 建立日期:2026-07-11。已完成的基礎:29 關 348 題、PWA 可安裝、XP/連續天數/錯題重練、進度匯出匯入。 ## 已完成 diff --git a/src/data/questions/jsa-1-this-scope.json b/src/data/questions/jsa-1-this-scope.json index f14418d..d711fb1 100644 --- a/src/data/questions/jsa-1-this-scope.json +++ b/src/data/questions/jsa-1-this-scope.json @@ -218,7 +218,7 @@ "type": "predict-output", "difficulty": 3, "topic": "setTimeout 傳一般函式,callback 裡的 this 不是你以為的物件", - "docs": "https://developer.mozilla.org/zh-TW/docs/Web/API/setTimeout", + "docs": "https://developer.mozilla.org/en-US/docs/Web/API/setTimeout", "story": "", "prompt": "計時器到時間後想印物件的名字,卻印出 undefined。這段印出什麼?", "code": "const timer = {\n label: \"計時器A\",\n start() {\n setTimeout(function () {\n console.log(\"時間到:\" + this.label);\n }, 10);\n },\n};\ntimer.start();", @@ -229,7 +229,7 @@ { "id": "d", "text": "時間到:null" } ], "answer": "a", - "explanation": "setTimeout 呼叫 callback 時,是由計時器系統來呼叫這個函式,前面沒有「誰的方法」這件事,等於是「裸呼叫」——一般函式裸呼叫時(非嚴格模式)this 會變成全域物件,跟 timer 完全無關,this.label 自然是 undefined。想保留 timer,要嘛把 callback 換成箭頭函式(沿用 start 的 this,jsa-1-q5 教過的救星),要嘛先把 this 存進變數(常見寫法 const self = this)。", + "explanation": "setTimeout 呼叫 callback 時,是由計時器系統來呼叫這個函式,前面沒有「誰的方法」這件事,this 不再指向 timer,而是由執行環境決定:在 Node.js 裡,計時器系統會把 this 綁成內部的 Timeout 物件;在瀏覽器裡則通常是 window/globalThis。不管哪一種,這個 this 都跟 timer 物件無關,所以 this.label 一律是 undefined。想保留 timer,要嘛把 callback 換成箭頭函式(沿用 start 的 this,jsa-1-q5 教過的救星),要嘛先把 this 存進變數(常見寫法 const self = this)。", "verify": { "checks": [ { "code": "const timer = {\n label: \"計時器A\",\n start() {\n setTimeout(function () {\n console.log(\"時間到:\" + this.label);\n }, 10);\n },\n};\ntimer.start();", "expected": "時間到:undefined" } diff --git a/src/data/questions/jsa-2-closures.json b/src/data/questions/jsa-2-closures.json index 8351bab..62edda2 100644 --- a/src/data/questions/jsa-2-closures.json +++ b/src/data/questions/jsa-2-closures.json @@ -193,7 +193,7 @@ "type": "predict-output", "difficulty": 3, "topic": "debounce 防抖:closure 記住計時器,取消前一次排程", - "docs": "https://developer.mozilla.org/zh-TW/docs/Web/API/setTimeout", + "docs": "https://developer.mozilla.org/en-US/docs/Web/API/setTimeout", "story": "", "prompt": "debounce 讓短時間內連續呼叫,最後只真正執行一次。這段印出什麼?", "code": "function debounce(fn, delay) {\n let timer;\n return function (...args) {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), delay);\n };\n}\nlet calls = 0;\nconst log = debounce(() => { calls++; }, 20);\nlog();\nlog();\nlog();\nsetTimeout(() => console.log(calls), 60);", diff --git a/src/data/questions/jsa-3-async-basics.json b/src/data/questions/jsa-3-async-basics.json index 9c05a96..2af2fc1 100644 --- a/src/data/questions/jsa-3-async-basics.json +++ b/src/data/questions/jsa-3-async-basics.json @@ -170,7 +170,7 @@ "type": "predict-output", "difficulty": 1, "topic": "多個 setTimeout 依延遲時間排序", - "docs": "https://developer.mozilla.org/zh-TW/docs/Web/API/Window/setTimeout", + "docs": "https://developer.mozilla.org/en-US/docs/Web/API/setTimeout", "story": "", "prompt": "三個計時器不是照寫的順序登記,是照延遲時間排隊。這段印出的順序是?", "code": "setTimeout(() => console.log(\"C\"), 30);\nsetTimeout(() => console.log(\"A\"), 0);\nsetTimeout(() => console.log(\"B\"), 10);\nconsole.log(\"同步\");", @@ -264,16 +264,16 @@ "topic": "沒人接住的 rejected Promise 會讓程式當掉", "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch", "story": "", - "prompt": "AI 寫的程式忘了幫這個 reject 加 catch。實際跑起來會怎樣?", + "prompt": "AI 寫的程式忘了幫這個 reject 加 catch。以 Node.js 預設行為為準,實際跑起來會怎樣?", "code": "Promise.reject(new Error(\"斷線了\")); // 沒有接 .catch\nconsole.log(\"同步先印\");", "options": [ - { "id": "a", "text": "先印出「同步先印」,接著整個程式因為沒人處理的 rejected Promise 而丟出未處理的錯誤、當掉" }, + { "id": "a", "text": "先印出「同步先印」,接著在 Node.js 預設行為下,整個程式因為沒人處理的 rejected Promise 而丟出未處理的錯誤、當掉" }, { "id": "b", "text": "什麼都不會印,Promise.reject 這行就直接讓程式停住" }, { "id": "c", "text": "印出「同步先印」,錯誤被自動吞掉,程式正常結束不受影響" }, { "id": "d", "text": "先印出錯誤訊息,才印「同步先印」" } ], "answer": "a", - "explanation": "Promise.reject 建立的失敗 Promise 是同步「排進去」的,不會馬上讓程式停下,所以「同步先印」照樣先印出來。但只要這個 rejected Promise 一直沒有 catch 接住,等到微任務輪到它結算時,就會變成一個「未處理的 Promise 拒絕」(unhandled rejection)——現代 Node.js 預設會把它當成沒接住的例外直接讓程式當掉。教訓:每個可能失敗的 Promise 鏈尾端都該有 catch,就像 q5 教的一樣。", + "explanation": "Promise.reject 建立的失敗 Promise 是同步「排進去」的,不會馬上讓程式停下,所以「同步先印」照樣先印出來。但只要這個 rejected Promise 一直沒有 catch 接住,等到微任務輪到它結算時,就會變成一個「未處理的 Promise 拒絕」(unhandled rejection)。這裡的當掉是 Node.js 的預設行為——瀏覽器環境通常只會觸發一個 unhandledrejection 事件、印出警告,不會直接讓整頁當掉。教訓:每個可能失敗的 Promise 鏈尾端都該有 catch,就像 q5 教的一樣。", "verify": { "checks": [ { "code": "Promise.reject(new Error(\"斷線了\"));\nconsole.log(\"同步先印\");", "expected": "同步先印", "expectError": "斷線了" } diff --git a/src/data/questions/jsa-4-async-await.json b/src/data/questions/jsa-4-async-await.json index 0d3b929..7a8bbb0 100644 --- a/src/data/questions/jsa-4-async-await.json +++ b/src/data/questions/jsa-4-async-await.json @@ -273,7 +273,7 @@ { "id": "d", "text": "undefined,因為兩個同時開始無法判斷誰先" } ], "answer": "a", - "explanation": "Promise.race 顧名思義是賽跑:丟進去的 Promise 同時起跑,誰第一個決議(不管成功或失敗)就用誰的結果,其他還沒跑完的就被晾在一邊、不再理會。這裡「快」只花 10 毫秒,比「慢」的 50 毫秒早到終點,所以 winner 拿到的是「快」。常見用途是幫請求加逾時:Promise.race([fetchData(), timeout(3000)]),看誰先到就用誰的結果。", + "explanation": "Promise.race 顧名思義是賽跑:丟進去的 Promise 同時起跑,誰第一個決議(不管成功或失敗)就用誰的結果。其他還沒決議的 Promise 並不會被取消,它們照樣會繼續跑下去,只是跑完之後的結果不會再被 race 採用。這裡「快」只花 10 毫秒,比「慢」的 50 毫秒早到終點,所以 winner 拿到的是「快」(「慢」還是會在 50 毫秒後把自己跑完,只是沒人理會它的結果)。常見用途是幫請求加逾時:Promise.race([fetchData(), timeout(3000)]),看誰先到就用誰的結果。", "verify": { "checks": [ { "code": "const wait = (ms, tag) => new Promise((r) => setTimeout(() => r(tag), ms));\nasync function main() {\n const winner = await Promise.race([wait(50, \"慢\"), wait(10, \"快\")]);\n console.log(winner);\n}\nmain();", "expected": "快" } diff --git a/src/data/questions/jsa-5-classes.json b/src/data/questions/jsa-5-classes.json index 447c426..e82aea4 100644 --- a/src/data/questions/jsa-5-classes.json +++ b/src/data/questions/jsa-5-classes.json @@ -146,7 +146,7 @@ "type": "predict-output", "difficulty": 2, "topic": "getter/setter 讓存取像屬性一樣", - "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Classes/get", + "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Functions/get", "story": "", "prompt": "c.area 看起來像個普通屬性,其實背後在跑一段計算。這段印出什麼?", "code": "class Circle {\n constructor(radius) {\n this.radius = radius;\n }\n get area() {\n return Math.PI * this.radius ** 2;\n }\n set radius(r) {\n this._radius = r < 0 ? 0 : r;\n }\n get radius() {\n return this._radius;\n }\n}\nconst c = new Circle(2);\nconsole.log(c.area.toFixed(2));\nc.radius = -5;\nconsole.log(c.radius);", diff --git a/src/data/questions/jsa-8-iterators-generators.json b/src/data/questions/jsa-8-iterators-generators.json index 9a4aa40..de3bc1d 100644 --- a/src/data/questions/jsa-8-iterators-generators.json +++ b/src/data/questions/jsa-8-iterators-generators.json @@ -227,7 +227,7 @@ { "id": "d", "text": "會報錯,generator 不能是無限的" } ], "answer": "a", - "explanation": "while (true) 讓這個 generator 理論上可以永遠 yield 下去,但因為 generator 是「懶惰執行」——for...of 每跑一輪才跟它要一個值,要多少算多少——只要迴圈裡有 break,就會在被要求交出第 4 個值「之前」提早喊停,剩下的 while 迴圈永遠不會被執行到。這正是無限 generator 的典型用法:拿來表示一個沒有終點的序列(像自動遞增的 ID),實際要用多少完全由外面的迴圈決定,不會真的卡死。", + "explanation": "while (true) 讓這個 generator 理論上可以永遠 yield 下去,但因為 generator 是「懶惰執行」——for...of 每跑一輪才跟它要一個值,要多少算多少。這裡 for...of 確實有跟 generator 要到第 4 個值(n 會拿到 4),只是 break 的判斷式 n > 3 在 push 之前就先成立,所以 4 沒有被塞進 result,迴圈也就此喊停,剩下的 while 迴圈不會再被執行到。這正是無限 generator 的典型用法:拿來表示一個沒有終點的序列(像自動遞增的 ID),實際要用多少完全由外面的迴圈決定,不會真的卡死。", "verify": { "checks": [ { "code": "function* naturals() {\n let n = 1;\n while (true) {\n yield n++;\n }\n}\nconst result = [];\nfor (const n of naturals()) {\n if (n > 3) break;\n result.push(n);\n}\nconsole.log(result);", "expected": "[ 1, 2, 3 ]" } diff --git a/src/data/questions/jsa-9-promises-advanced.json b/src/data/questions/jsa-9-promises-advanced.json index 64d8a42..e9094c3 100644 --- a/src/data/questions/jsa-9-promises-advanced.json +++ b/src/data/questions/jsa-9-promises-advanced.json @@ -228,7 +228,7 @@ { "id": "d", "text": "報錯,Promise.all 不能傳空陣列" } ], "answer": "a", - "explanation": "空陣列裡沒有任何成員要等,Promise.all([]) 會立刻進入「已解決」狀態,結果是空陣列 []。不過 then 裡的 callback 仍然是微任務,一定要等目前這輪同步程式碼跑完才輪得到它執行,所以「同步先跑」還是搶先印出來。這也順便複習了微任務排隊的規則:resolve 得再快,also 得排隊。", + "explanation": "空陣列裡沒有任何成員要等,Promise.all([]) 會立刻進入「已解決」狀態,結果是空陣列 []。不過 then 裡的 callback 仍然是微任務,一定要等目前這輪同步程式碼跑完才輪得到它執行,所以「同步先跑」還是搶先印出來。這也順便複習了微任務排隊的規則:resolve 得再快,then 也得排隊。", "verify": { "checks": [ { "code": "Promise.all([]).then((res) => console.log(res));\nconsole.log(\"同步先跑\");", "expected": "同步先跑\n[]" } @@ -251,7 +251,7 @@ { "id": "d", "text": "不一樣:finally 只能接在成功的鏈上,接在 reject 後面會報錯" } ], "answer": "a", - "explanation": "finally 不管成功失敗都會跑一次,而且它「不插手」結果——原本該成功的鏈繼續往 then 走、該失敗的鏈繼續往 catch 走,值原封不動地穿過 finally。這正適合關 loading、清 timer 這種「不管結果如何都要做一次」的收尾動作,不用在 then 跟 catch 裡各寫一遍。", + "explanation": "finally 不管成功失敗都會跑一次,正常情況下它「不插手」結果——原本該成功的鏈繼續往 then 走、該失敗的鏈繼續往 catch 走,值原封不動地穿過 finally。這正適合關 loading、清 timer 這種「不管結果如何都要做一次」的收尾動作,不用在 then 跟 catch 裡各寫一遍。但這個「不插手」有個例外:如果 finally 的 callback 自己 throw 錯誤、或回傳一個 rejected 的 Promise,就會用這個新的錯誤蓋掉原本的結果,讓整條鏈變成 rejected——所以 finally 裡的收尾動作最好保持單純,避免自己又拋出例外。", "verify": { "checks": [ { "code": "Promise.resolve(\"成功資料\")\n .finally(() => console.log(\"關閉 loading\"))\n .then((res) => console.log(\"結果:\" + res));", "expected": "關閉 loading\n結果:成功資料" }, @@ -275,7 +275,7 @@ { "id": "d", "text": "會報錯,Promise.resolve 不能傳 Promise 進去" } ], "answer": "a", - "explanation": "Promise.resolve 有個貼心規則:如果丟進去的東西本身已經是 thenable/Promise,它不會再包一層,而是直接沿用那個 Promise 最終的結果。所以 wrapped 骨子裡就是 inner,.then 拿到的是攤平後的 5,不是巢狀的 Promise。這也是為什麼 JS 世界幾乎看不到「Promise 疊 Promise」的窘境。", + "explanation": "Promise.resolve 有個貼心規則:如果丟進去的東西本身已經是原生 Promise,它不會再包一層,而是直接把那個 Promise 原封不動地回傳——wrapped 跟 inner 其實是同一個物件(wrapped === inner)。如果丟進去的是一般的 thenable(帶 then 方法但不是原生 Promise 的物件),Promise.resolve 則會另外建立一個新的 Promise,只是這個新 Promise 的結果會沿用 thenable 最終決議的值。不管哪一種,.then 拿到的都是攤平後的值而不是巢狀的 Promise,這也是為什麼 JS 世界幾乎看不到「Promise 疊 Promise」的窘境。", "verify": { "checks": [ { "code": "const inner = Promise.resolve(5);\nconst wrapped = Promise.resolve(inner);\nwrapped.then((val) => console.log(val));", "expected": "5" } diff --git a/src/data/questions/jsb-2-functions.json b/src/data/questions/jsb-2-functions.json index a63cfeb..8886da2 100644 --- a/src/data/questions/jsb-2-functions.json +++ b/src/data/questions/jsb-2-functions.json @@ -217,19 +217,19 @@ "id": "jsb-2-q10", "type": "same-or-not", "difficulty": 3, - "topic": "傳數字是複本、傳物件是本尊", + "topic": "參數都按值傳遞;物件值是參照,改內容會外洩", "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide/Functions", "story": "", "prompt": "兩個函式都在裡面改參數。外面的資料一樣安全嗎?", "code": "// 版本一:改數字參數\nfunction addTen(n) {\n n += 10;\n}\nlet score = 5;\naddTen(score);\nconsole.log(score);\n\n// 版本二:改物件參數\nfunction levelUp(p) {\n p.level += 1;\n}\nconst pet = { level: 1 };\nlevelUp(pet);\nconsole.log(pet.level);", "options": [ - { "id": "a", "text": "不一樣:score 還是 5(傳進去的是複本),pet.level 變 2(傳進去的是同一個物件)" }, + { "id": "a", "text": "不一樣:score 還是 5(n 拿到的是數字 5 的複本,改 n 不影響 score),pet.level 變 2(p 拿到的是「指向 pet 這個物件」的參照複本,p 跟 pet 指向同一個物件,改 p.level 就是改那個物件本身)" }, { "id": "b", "text": "一樣,兩個都被改了" }, { "id": "c", "text": "一樣,兩個都沒被改" }, { "id": "d", "text": "版本二會報錯,函式不能改物件" } ], "answer": "a", - "explanation": "數字、字串、布林傳進函式時複印一份,裡面怎麼改都不干外面的事;物件和陣列傳的是「同一個東西的門牌」,函式裡一改,外面就變了——jsb-4 的參照觀念在函式參數上重演。「函式呼叫完,我的資料被改了」的悲劇,兇手永遠是物件參數。", + "explanation": "JavaScript 的函式參數永遠是按值傳遞:呼叫函式時,實參的「值」會複製一份給參數。差別在於數字這種原始型別的值就是數字本身,複本改了跟原本的變數無關;物件的值則是一個參照(指向該物件的門牌),複製的是這個門牌,不是物件本身——p 跟 pet 兩個門牌指向同一棟房子,透過 p 去改 level,pet 當然也看得到。但如果在函式裡把 p 重新賦值成另一個新物件(p = { level: 99 }),只是讓 p 這個門牌改指別的地方,pet 仍然指向原本的房子,不會受影響。「函式呼叫完,我的資料被改了」的悲劇,兇手永遠是物件的內容被動了手腳,而不是參數本身被「傳址」。", "verify": { "checks": [ { "code": "function addTen(n) {\n n += 10;\n}\nlet score = 5;\naddTen(score);\nconsole.log(score);\nfunction levelUp(p) {\n p.level += 1;\n}\nconst pet = { level: 1 };\nlevelUp(pet);\nconsole.log(pet.level);", "expected": "5\n2" } diff --git a/src/data/questions/react-1-jsx.json b/src/data/questions/react-1-jsx.json index 04f9c92..98d9610 100644 --- a/src/data/questions/react-1-jsx.json +++ b/src/data/questions/react-1-jsx.json @@ -181,7 +181,7 @@ { "id": "d", "text": "圖片路徑要寫成絕對網址才行" } ], "answer": "a", - "explanation": "HTML 允許 、
                        、 這類「沒有內容」的標籤開了就不用收尾,但 JSX 比較嚴格:標籤中間沒有 children,就一定要自己閉合,寫成 。這條規則對所有標籤都成立,不只 img——自訂元件沒有 children 時(例如 )也是同樣寫法。", + "explanation": "HTML 允許 、
                        、 這類「沒有內容」的標籤開了就不用收尾,但 JSX 比較嚴格:標籤中間沒有 children,就一定要自己閉合,寫成 。這條規則對所有標籤都成立,不只 img——自訂元件沒有 children 時(例如 )也是同樣寫法。驗證輸出裡 前面多出的 ,是 React 19 針對 自動注入的圖片預載提示,跟這題的自我閉合規則無關,看到不用意外。", "verify": { "checks": [ { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Avatar() {\n return ;\n}\nconsole.log(renderToStaticMarkup());", "expected": "" } @@ -228,7 +228,7 @@ { "id": "d", "text": "小美 要用大括號包起來才合法" } ], "answer": "a", - "explanation": "JSX 的 return 骨子裡就是「一個函式呼叫」,函式只能 return 一個值,所以兩個並排的標籤必須先合併成一個節點——包一個
                        ,或用上一題的 Fragment <>... 避免多一層 DOM。這是初學者最常撞到的建置錯誤之一,錯誤訊息通常會寫 Adjacent JSX elements must be wrapped。", + "explanation": "JSX 的 return 骨子裡就是「一個函式呼叫」,函式只能 return 一個值,所以兩個並排的標籤必須先合併成一個節點——包一個
                        ,或用前面 q7 教過的 Fragment <>... 避免多一層 DOM。這是初學者最常撞到的建置錯誤之一,錯誤訊息通常會寫 Adjacent JSX elements must be wrapped。", "verify": { "checks": [ { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Bio() {\n return (\n <>\n

                        小美

                        \n

                        前端工程師

                        \n \n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "

                        小美

                        前端工程師

                        " } @@ -239,19 +239,19 @@ "id": "react-1-q11", "type": "same-or-not", "difficulty": 1, - "topic": "class 要寫成 className", + "topic": "class 在 JSX 裡是未知屬性,正式寫法是 className", "docs": "https://react.dev/learn/writing-markup-with-jsx", "story": "", - "prompt": "AI 抄了一段純 HTML 貼進 JSX,樣式完全沒套用。這兩種寫法效果一樣嗎?", + "prompt": "AI 抄了一段純 HTML 貼進 JSX。這兩種寫法渲染出的 HTML 完全一樣嗎?", "code": "// 版本一\n
                        內容
                        \n\n// 版本二\n
                        內容
                        ", "options": [ - { "id": "a", "text": "不一樣:class 是 JS 的保留字,JSX 認得的是 className;用 class 不會報錯,但實際渲染出的屬性不一樣" }, - { "id": "b", "text": "一樣,React 會自動把 class 換成 className" }, + { "id": "a", "text": "HTML 輸出剛好一樣,都是
                        ——className 是 React 認得的正式屬性,渲染時會轉成 DOM 的 class;class 則是 React 不認得的屬性,被原樣印出,兩邊巧合長一樣。但用 class 在開發模式下會跳出主控台警告 Invalid DOM property `class`. Did you mean `className`?,代表這不是官方支援的寫法" }, + { "id": "b", "text": "一樣,因為 React 會自動把 class 轉換成 className 屬性" }, { "id": "c", "text": "不一樣:版本一會直接建置失敗" }, - { "id": "d", "text": "一樣,兩者都不會被渲染出來" } + { "id": "d", "text": "不一樣:兩者渲染出的屬性名稱不同,一個是 class、一個是 className" } ], "answer": "a", - "explanation": "class 在 JavaScript 裡是保留給 class 語法用的關鍵字,JSX 沿用 JS 的規則,所以 DOM 的 class 屬性在 JSX 裡改名叫 className,這樣才不會撞名。忘記改名,React 不會報錯、但也不會把它當成標準的 class 處理——渲染出來的 HTML 屬性名稱就是不一樣,樣式套用邏輯因此「壞掉但不出聲」,是很容易被忽略的一種 bug。", + "explanation": "class 在 JavaScript 裡是保留給 class 語法用的關鍵字,JSX 沿用 JS 的規則,把 DOM 的 class 屬性改名叫 className,這樣才不會撞名。React 認得 className,渲染時會把它轉成 DOM 上的 class 屬性;class 則不是 React 認得的屬性,但恰好被當成一般(未知)屬性原樣印出,兩者輸出的 HTML 字串因此剛好都是
                        。差別在於開發模式下用 class 會跳出主控台警告,提醒你這不是官方支援的寫法——只是巧合沒有直接壞掉,不代表可以放心混用,正式寫法還是要用 className。", "verify": { "checks": [ { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nconsole.log(renderToStaticMarkup(
                        內容
                        ));\nconsole.log(renderToStaticMarkup(
                        內容
                        ));", "expected": "
                        內容
                        \n
                        內容
                        " } From 40712a3cb2bfd58d958c23aa936f460d6b8cec2b Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Sat, 11 Jul 2026 16:17:50 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E9=8C=AF=E9=A1=8C=E6=9C=AC=E5=BC=B7?= =?UTF-8?q?=E5=8C=96=EF=BC=8B=E6=94=B6=E8=97=8F=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E4=B8=A6=E6=8A=8A=E9=A6=96=E9=A0=81=E6=94=B9=E7=89=88=E6=88=90?= =?UTF-8?q?=E7=B6=B2=E9=A0=81=E7=89=88=204=20=E5=88=86=E9=A0=81=E5=B0=8E?= =?UTF-8?q?=E8=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 錯題本/收藏: - wrongIds 改成 Leitner 盒制(答錯回第 1 盒、答對升盒,畢業才移出錯題本) - 新增收藏題目(savedIds)、唯讀瀏覽頁 QuestionBook(錯題本/收藏共用) - TYPE_META 抽到 data/typeMeta.js,唯讀題卡抽成 QuestionReview 元件 導覽改版: - 頂部改成網頁版橫向 navbar(每日刷題/精選筆記/學習數據/個人資料), 取代原本手機版底部 tab bar 的參考設計 - 首頁重構:連續學習卡、今日統計、隨機綜合練習(跨章節抽題,Quiz 新增 mode="mixed")、 分科清單直接顯示在首頁(ChapterMap 簡化成只管單一章節的關卡清單) - 新增精選筆記頁:錯題本/收藏題庫卡片化 - 新增學習數據頁:近 7 日做題量/正確率雙圖表(用 dataviz skill 驗證過配色)、分科正確率 - 新增個人資料頁:吉祥物+統計格+進度匯出匯入(原本在首頁) - useProgress 新增 dailyStats/chapterStats 追蹤,供學習數據頁使用 Co-Authored-By: Claude Sonnet 5 --- docs/roadmap.md | 81 +++-- src/App.jsx | 154 +++++++-- src/components/Icons.jsx | 38 +++ src/components/Navbar.jsx | 33 ++ src/components/QuestionCard.jsx | 25 +- src/components/QuestionReview.jsx | 64 ++++ src/data/chapters.js | 41 +++ src/data/typeMeta.js | 6 + src/hooks/useProgress.js | 82 ++++- src/index.css | 519 ++++++++++++++++++++++++++++-- src/screens/ChapterMap.jsx | 70 +--- src/screens/Home.jsx | 148 +++++---- src/screens/Notes.jsx | 53 +++ src/screens/Profile.jsx | 69 ++++ src/screens/QuestionBook.jsx | 46 +++ src/screens/Quiz.jsx | 32 +- src/screens/Stats.jsx | 119 +++++++ src/utils/quiz.js | 2 + 18 files changed, 1356 insertions(+), 226 deletions(-) create mode 100644 src/components/Navbar.jsx create mode 100644 src/components/QuestionReview.jsx create mode 100644 src/data/typeMeta.js create mode 100644 src/screens/Notes.jsx create mode 100644 src/screens/Profile.jsx create mode 100644 src/screens/QuestionBook.jsx create mode 100644 src/screens/Stats.jsx diff --git a/docs/roadmap.md b/docs/roadmap.md index 0a19708..fc2ca0e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -13,6 +13,46 @@ (無,下一步請參考「建議的優先順序」) +### XP 每日 log 資料層 ✅ 2026-07-11 +- `useProgress` 新增 `xpLog: { 'YYYY-MM-DD': number }`,`finishLevel`/`finishReview` 都會累加當日 XP +- 純新增頂層欄位,`load()` 現有的 shallow merge 已足夠處理舊資料相容,無需額外 migration +- 尚未做 UI(等級進度條、長條圖/heatmap),資料已開始累積 + +### 更完善的錯題本+儲存題目本 ✅ 2026-07-11 +- 錯題本改成 Leitner 盒制:`wrongIds[id] = { count, lastWrong, box }`,答錯重置回第 1 盒、答對升一盒, + 超過 `GRADUATE_BOX`(=3)才真正畢業移出錯題本,不再是「碰巧對一次就消失」 +- 舊資料相容:`wrongIds` 舊格式是 `{ id: true }`,新增 `migrateWrongIds()` 在 `load()`/`importProgress()` + 時把 boolean 轉成盒制物件——這是第一個需要真正 migration(非 shallow merge 就够)的欄位變更 +- 新增收藏:`savedIds: { id: true }` + `toggleSaved()`,答題卡片右上角星號可收藏 +- 新增兩個瀏覽頁(`src/screens/QuestionBook.jsx` 共用元件,`kind="wrong"|"saved"`): + 不用重考就能翻看題目、正解、解釋;錯題本頁另外顯示答錯次數/熟練度/最近答錯日期,並可直接「開始重練」 + - Home 新增「錯題本 (n)」「收藏 (n)」文字連結 +- 共用元件抽出:`TYPE_META` 移到 `src/data/typeMeta.js`(原本卡死在 QuestionCard.jsx 裡); + 唯讀題目卡抽成 `src/components/QuestionReview.jsx`,答題中的 `QuestionCard` 與瀏覽頁共用同一份題型/星號 UI +- 已用 Playwright(透過系統 Chrome,非 playwright 內建瀏覽器)跑過完整互動流程驗證:收藏/取消收藏即時反映列表、 + 錯題本熟練度正確累加、重練時「畢業的錯題」數字正確(例如 box=1 的錯題重練答對一次只會變成 box=2,不會畢業), + 全程 console 無錯誤 + +### 網頁版導覽改版+學習數據視覺化 ✅ 2026-07-11 +- 首頁改版:頂部改成網頁版橫向 navbar(`src/components/Navbar.jsx`,4 分頁:每日刷題/精選筆記/學習數據/個人資料)。 + 原本先留了「全真模考」disabled 佔位分頁,同一天使用者直接要求移除、換成「個人資料」頁 + (`src/screens/Profile.jsx`:吉祥物+總 XP/連續學習/完成關卡/累計答題統計格, + 原本在 Home 的匯出/匯入進度也搬過來這裡)——所以 navbar 現在 4 個分頁都是可動的,沒有 disabled 項目 +- Home 重構:連續學習天數卡(含本週 7 天 pill,標記已練習的日期)、今日正確率/今日已做兩張統計卡、 + 新增「隨機綜合練習(10 題)」跨章節抽題按鈕(`Quiz` 新增 `mode="mixed"`,沿用 `finishReview` 不動 `completedLevels`)、 + 章節清單從原本獨立的「選擇章節」畫面搬到首頁直接顯示(`ChapterMap.jsx` 簡化成只負責單一章節的關卡清單) +- 新增「精選筆記」頁(`src/screens/Notes.jsx`):把原本首頁的錯題本/收藏文字連結,改成兩張大卡片 + (點卡片進原本的 `QuestionBook` 列表,卡片按鈕直接開始複習/練習);收藏題庫新增「開始練習」 + (沿用 `mode="mixed"` 引擎,抽的是收藏的題目而非跨章節隨機) +- 新增「學習數據」頁(`src/screens/Stats.jsx`):總答題數/平均正確率/累計答對題數統計卡、 + 近 7 日做題量+正確率雙迷你長條圖、分科正確率細分。資料層新增 `useProgress` 的 `dailyStats`/`chapterStats` + (純新增頂層欄位,沿用既有 shallow merge,無需 migration),`chapters.js` 新增 `getChapterIdForQuestion()` + 查表供 `Quiz.jsx` 答題時回報章節 +- 圖表配色用 dataviz skill 的 `validate_palette.js` 驗證過(`#2f9e6b` 正確率/`#7c86ff` 做題量, + dark mode 明度帶+CVD 分離度皆 PASS),且遵守「一軸」原則拆成兩個獨立迷你圖,不做雙 Y 軸疊圖 +- 已用 Playwright(系統 Chrome)跑過完整互動驗證:答題→首頁統計即時更新→精選筆記數字同步→ + 學習數據圖表跟分科正確率正確反映,全程 console 無錯誤 + ## 規劃中的大功能 ### 1. Clerk 登入 @@ -27,25 +67,18 @@ - [ ] 進度同步策略:本地優先、背景上傳、衝突時取較高值(XP、best 取 max) - [ ] 首次登入時把 localStorage 進度搬上雲端 -### 2. 更完善的錯題本+儲存題目本 -- 現況:`wrongIds` 只是 `{ questionId: true }`,答對一次就從錯題本移除,沒有任何歷史 -- 錯題本強化: - - [ ] 記錄錯誤次數、最後答錯日期(`{ count, lastWrong }`) - - [ ] 錯題本瀏覽頁:可以翻看題目與解釋,不用重考才能看 - - [ ] 間隔重複(spaced repetition):答對不要立刻移除,改成 Leitner 盒制 - (答對升級、答錯降回,最高級才畢業),避免「碰巧對一次就消失」 -- 儲存題目本(收藏): - - [ ] 答題時可加書籤(星號),存 `savedIds` - - [ ] 收藏瀏覽頁,可從收藏直接重練 -- [ ] 進度資料結構升級時做 migration(現在 `load()` 只有 shallow merge, - 巢狀新欄位如 `streak` 內加東西會拿不到預設值) - -### 3. 經驗值成長可視化 -- 現況:XP 只存一個總數,**沒有歷史紀錄——無法回溯**,這個先做資料層越早越好 -- [ ] 先開始記每日 XP log:`xpLog: { 'YYYY-MM-DD': number }`(答題就累計,UI 之後再做) -- [ ] 等級制:XP 換算等級與稱號,Home 顯示等級進度條 -- [ ] 每週長條圖/月曆 heatmap(GitHub style)呈現學習量 -- [ ] 各章節掌握度:以最佳成績算每章正確率,雷達圖或進度條 +### 2. 經驗值成長可視化 +- 現況:`xpLog: { 'YYYY-MM-DD': number }` 資料層已完成;近 7 日做題量/正確率雙圖、分科正確率已在 + 「學習數據」頁做出來了(見上方「已完成」) +- [ ] 等級制:XP 換算等級與稱號,Home 顯示等級進度條(目前只有吉祥物 4 階段進化,沒有正式等級稱號) +- [ ] 月曆 heatmap(GitHub style)呈現更長期的學習量,目前只有近 7 日 + +### 3. 全真模考 +- 2026-07-11:navbar 原本預留的 disabled 分頁已移除,換成「個人資料」頁(見上方「已完成」)。 + 這個功能目前**沒有 UI 佔位**,之後要做的話要先決定放在哪個分頁(新增第 5 個 navbar 項目, + 或掛在某個現有分頁底下) +- 需求雛形(參考使用者提供的設計圖):綜合科全真模擬考(跨章節混合出題+計時)+分科模擬考試(每科出題+計時) +- 待確認:計時+強制交卷邏輯、题數與時限、是否要獨立的結算/答題紀錄畫面 ## 其他優化建議(依價值排序) @@ -70,8 +103,10 @@ ## 建議的優先順序 1. ~~題池擴充剩餘 27 關~~(已完成,2026-07-11) -2. XP 每日 log 的**資料層**(不做 UI 也要先記,資料無法回溯) -3. 錯題本強化+收藏(純前端就能做,不用等後端) +2. ~~XP 每日 log 的**資料層**~~(已完成,2026-07-11) +3. ~~錯題本強化+收藏~~(已完成,2026-07-11) 4. ~~題目 schema 驗證 script~~(已完成:`scripts/validate-questions.mjs`) -5. XP 可視化 UI(等 log 累積幾天資料) -6. Clerk +雲端同步(工程最大,牽涉後端選型,放在功能穩定後) +5. ~~網頁版導覽改版+學習數據視覺化~~(已完成,2026-07-11:navbar/首頁重構/精選筆記頁/學習數據頁) +6. 全真模考(navbar 已預留 disabled 分頁,實作優先度看使用者需求) +7. 等級稱號+月曆 heatmap(經驗值可視化的剩餘部分) +8. Clerk +雲端同步(工程最大,牽涉後端選型,放在功能穩定後) diff --git a/src/App.jsx b/src/App.jsx index 11541ab..53ffabd 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,14 +1,34 @@ import { useState } from 'react' import { useProgress } from './hooks/useProgress' -import { getLevel, getWrongQuestions } from './data/chapters' -import { shuffle, sampleQuestions, REVIEW_SIZE } from './utils/quiz' +import { getLevel, getWrongQuestions, getWrongEntries, getSavedQuestions, chapters } from './data/chapters' +import { shuffle, sampleQuestions, REVIEW_SIZE, MIXED_SIZE } from './utils/quiz' +import Navbar from './components/Navbar' import Home from './screens/Home' +import Notes from './screens/Notes' +import Stats from './screens/Stats' +import Profile from './screens/Profile' import ChapterMap from './screens/ChapterMap' import Quiz from './screens/Quiz' +import QuestionBook from './screens/QuestionBook' + +// 決定目前畫面該讓 navbar 的哪個分頁保持高亮 +function navGroupOf(viewName) { + if (viewName === 'stats') return 'stats' + if (viewName === 'profile') return 'profile' + if (['notes', 'review', 'wrongbook', 'savedbook', 'savedpractice'].includes(viewName)) return 'notes' + return 'home' +} export default function App() { - const { progress, answerQuestion, finishLevel, finishReview, exportProgress, importProgress } = - useProgress() + const { + progress, + answerQuestion, + toggleSaved, + finishLevel, + finishReview, + exportProgress, + importProgress, + } = useProgress() const [view, setView] = useState({ name: 'home' }) function startLevel(levelId) { @@ -23,53 +43,145 @@ export default function App() { setView({ name: 'review', questions: picked }) } + function startMixedPractice() { + const pool = chapters.flatMap((ch) => ch.levels.flatMap((l) => l.questions)) + const picked = sampleQuestions(pool).slice(0, MIXED_SIZE) + setView({ name: 'mixed', questions: picked }) + } + + function startSavedPractice() { + const picked = sampleQuestions(getSavedQuestions(progress.savedIds)) + if (picked.length === 0) return + setView({ name: 'savedpractice', questions: picked }) + } + + function handleNavigate(key) { + setView({ name: key }) + } + + let content = null + if (view.name === 'quiz') { const level = getLevel(view.levelId) - return ( + content = ( setView({ name: 'map' })} + onExit={() => setView({ name: 'levellist', chapterId: navGroupOfChapter(level.id) })} /> ) - } - - if (view.name === 'review') { - return ( + } else if (view.name === 'review') { + content = ( setView({ name: 'notes' })} + /> + ) + } else if (view.name === 'mixed') { + content = ( + setView({ name: 'home' })} /> ) - } - - if (view.name === 'map') { - return ( + } else if (view.name === 'savedpractice') { + content = ( + setView({ name: 'notes' })} + /> + ) + } else if (view.name === 'wrongbook') { + content = ( + setView({ name: 'notes' })} + onReview={startReview} + /> + ) + } else if (view.name === 'savedbook') { + content = ( + setView({ name: 'notes' })} + /> + ) + } else if (view.name === 'levellist') { + content = ( setView({ name: 'home' })} /> ) + } else if (view.name === 'notes') { + content = ( + setView({ name: 'wrongbook' })} + onOpenSavedBook={() => setView({ name: 'savedbook' })} + onReview={startReview} + onPracticeSaved={startSavedPractice} + /> + ) + } else if (view.name === 'stats') { + content = + } else if (view.name === 'profile') { + content = + } else { + content = ( + setView({ name: 'levellist', chapterId })} + onMixedPractice={startMixedPractice} + /> + ) } return ( - setView({ name: 'map' })} - onReview={startReview} - exportProgress={exportProgress} - importProgress={importProgress} - /> + <> + +
                        {content}
                        + ) } + +function navGroupOfChapter(levelId) { + for (const ch of chapters) { + if (ch.levels.some((l) => l.id === levelId)) return ch.id + } + return null +} diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx index 6376442..c44f5e6 100644 --- a/src/components/Icons.jsx +++ b/src/components/Icons.jsx @@ -133,6 +133,9 @@ const PATHS = { ), + star: ( + + ), upload: ( <> @@ -140,6 +143,41 @@ const PATHS = { ), + home: ( + <> + + + + ), + clock: ( + <> + + + + ), + 'bar-chart': ( + <> + + + + + + ), + user: ( + <> + + + + ), + shuffle: ( + <> + + + + + + + ), } export default function Icon({ name, size = 18, className = '' }) { diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx new file mode 100644 index 0000000..a14dd12 --- /dev/null +++ b/src/components/Navbar.jsx @@ -0,0 +1,33 @@ +import Icon from './Icons' + +const NAV_ITEMS = [ + { key: 'home', label: '每日刷題', icon: 'home' }, + { key: 'notes', label: '精選筆記', icon: 'book-open' }, + { key: 'stats', label: '學習數據', icon: 'bar-chart' }, + { key: 'profile', label: '個人資料', icon: 'user' }, +] + +export default function Navbar({ active, onNavigate }) { + return ( +
                        +
                        + + + EasyLearn + + +
                        +
                        + ) +} diff --git a/src/components/QuestionCard.jsx b/src/components/QuestionCard.jsx index 5d46443..36961e9 100644 --- a/src/components/QuestionCard.jsx +++ b/src/components/QuestionCard.jsx @@ -1,14 +1,16 @@ import CodeBlock from './CodeBlock' import Icon from './Icons' +import { TYPE_META } from '../data/typeMeta' -const TYPE_META = { - 'predict-output': { icon: 'eye', label: '預測輸出' }, - 'find-bug': { icon: 'bug', label: '抓蟲' }, - 'same-or-not': { icon: 'search', label: '改壞了嗎' }, - 'fill-in': { icon: 'pencil', label: '動手填空' }, -} - -export default function QuestionCard({ question, selected, onSelect, onNext, isLast }) { +export default function QuestionCard({ + question, + selected, + onSelect, + onNext, + isLast, + saved, + onToggleSave, +}) { const answered = selected !== null const correct = answered && selected === question.answer const typeMeta = TYPE_META[question.type] @@ -21,6 +23,13 @@ export default function QuestionCard({ question, selected, onSelect, onNext, isL {typeMeta.label} {question.topic} +

                        {question.prompt}

                        diff --git a/src/components/QuestionReview.jsx b/src/components/QuestionReview.jsx new file mode 100644 index 0000000..3aa0d2f --- /dev/null +++ b/src/components/QuestionReview.jsx @@ -0,0 +1,64 @@ +import CodeBlock from './CodeBlock' +import Icon from './Icons' +import { TYPE_META } from '../data/typeMeta' +import { GRADUATE_BOX } from '../hooks/useProgress' + +// 唯讀題目卡:錯題本/收藏瀏覽頁用,直接看答案與解釋,不用重考 +export default function QuestionReview({ question, saved, onToggleSave, meta }) { + const typeMeta = TYPE_META[question.type] + + return ( +
                        +
                        + + + {typeMeta.label} + + {question.topic} + +
                        + + {meta && ( +
                        + 答錯 {meta.count} 次 + + 熟練度 {meta.box}/{GRADUATE_BOX} + + {meta.lastWrong && 最近答錯:{meta.lastWrong}} +
                        + )} + +

                        {question.prompt}

                        + + +
                        + {question.options.map((opt) => ( +
                        + {opt.text} +
                        + ))} +
                        + +
                        +
                        + + 解釋 +
                        +

                        {question.explanation}

                        + + + 延伸閱讀:官方文件 + +
                        +
                        + ) +} diff --git a/src/data/chapters.js b/src/data/chapters.js index e51c573..9d8572c 100644 --- a/src/data/chapters.js +++ b/src/data/chapters.js @@ -58,6 +58,20 @@ export function getLevel(levelId) { return null } +// 題目 id → 所屬章節 id(分科成效統計用) +const questionChapterMap = new Map() +for (const ch of chapters) { + for (const level of ch.levels) { + for (const q of level.questions) { + questionChapterMap.set(q.id, ch.id) + } + } +} + +export function getChapterIdForQuestion(questionId) { + return questionChapterMap.get(questionId) +} + // 從錯題本 id 集合撈出題目本體(照章節順序) export function getWrongQuestions(wrongIds) { const out = [] @@ -70,3 +84,30 @@ export function getWrongQuestions(wrongIds) { } return out } + +// 錯題本瀏覽用:題目本體+熟練度資訊,最需要複習的(盒子小、越久沒複習)排前面 +export function getWrongEntries(wrongIds) { + const out = [] + for (const ch of chapters) { + for (const level of ch.levels) { + for (const q of level.questions) { + const entry = wrongIds[q.id] + if (entry) out.push({ question: q, ...entry }) + } + } + } + return out.sort((a, b) => a.box - b.box || (a.lastWrong ?? '').localeCompare(b.lastWrong ?? '')) +} + +// 從收藏 id 集合撈出題目本體(照章節順序) +export function getSavedQuestions(savedIds) { + const out = [] + for (const ch of chapters) { + for (const level of ch.levels) { + for (const q of level.questions) { + if (savedIds[q.id]) out.push(q) + } + } + } + return out +} diff --git a/src/data/typeMeta.js b/src/data/typeMeta.js new file mode 100644 index 0000000..97df6d1 --- /dev/null +++ b/src/data/typeMeta.js @@ -0,0 +1,6 @@ +export const TYPE_META = { + 'predict-output': { icon: 'eye', label: '預測輸出' }, + 'find-bug': { icon: 'bug', label: '抓蟲' }, + 'same-or-not': { icon: 'search', label: '改壞了嗎' }, + 'fill-in': { icon: 'pencil', label: '動手填空' }, +} diff --git a/src/hooks/useProgress.js b/src/hooks/useProgress.js index ed7d07b..a1f364c 100644 --- a/src/hooks/useProgress.js +++ b/src/hooks/useProgress.js @@ -2,14 +2,34 @@ import { useEffect, useState } from 'react' const STORAGE_KEY = 'easylearn-progress-v1' +// Leitner 盒制:答對升級、答錯重置回 1,box 超過畢業盒才移出錯題本 +export const GRADUATE_BOX = 3 + const defaultProgress = { xp: 0, // { [levelId]: { best: 答對題數, total: 題數 } } completedLevels: {}, - // 錯題本:{ [questionId]: true },答對即移除 + // 錯題本:{ [questionId]: { count: 答錯次數, lastWrong: 'YYYY-MM-DD', box: 熟練度 1~GRADUATE_BOX } } wrongIds: {}, + // 收藏題目:{ [questionId]: true } + savedIds: {}, // 連續學習天數:{ count, last: 'YYYY-MM-DD' } streak: { count: 0, last: null }, + // 每日 XP 紀錄:{ [YYYY-MM-DD]: 當日累計 XP } + xpLog: {}, + // 每日作答統計:{ [YYYY-MM-DD]: { total, correct } } + dailyStats: {}, + // 分科作答統計:{ [chapterId]: { total, correct } } + chapterStats: {}, +} + +// 舊版 wrongIds 是 { [id]: true },升級成 Leitner 盒物件 +function migrateWrongIds(wrongIds) { + const out = {} + for (const [id, entry] of Object.entries(wrongIds ?? {})) { + out[id] = entry === true ? { count: 1, lastWrong: null, box: 1 } : entry + } + return out } function load() { @@ -17,22 +37,35 @@ function load() { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return defaultProgress const data = JSON.parse(raw) - return { ...defaultProgress, ...data } + return { ...defaultProgress, ...data, wrongIds: migrateWrongIds(data.wrongIds) } } catch { return defaultProgress } } -function todayStr() { +export function todayStr() { return new Date().toLocaleDateString('en-CA') // 本地時區的 YYYY-MM-DD } -function yesterdayStr() { +export function yesterdayStr() { const d = new Date() d.setDate(d.getDate() - 1) return d.toLocaleDateString('en-CA') } +function bumpXpLog(xpLog, amount) { + const today = todayStr() + return { ...xpLog, [today]: (xpLog[today] ?? 0) + amount } +} + +function bumpCounter(map, key, correct) { + const entry = map[key] ?? { total: 0, correct: 0 } + return { + ...map, + [key]: { total: entry.total + 1, correct: entry.correct + (correct ? 1 : 0) }, + } +} + function bumpStreak(streak) { const today = todayStr() if (streak.last === today) return streak @@ -49,13 +82,39 @@ export function useProgress() { localStorage.setItem(STORAGE_KEY, JSON.stringify(progress)) }, [progress]) - // 每答一題就更新錯題本:答錯記入、答對移除 - function answerQuestion(questionId, correct) { + // 每答一題就更新錯題本(Leitner 盒制): + // 答錯 → 記入/重置回第 1 盒;答對 → 升一盒,超過畢業盒才移出錯題本 + // 同時累計每日/分科作答統計,供學習數據頁使用 + function answerQuestion(questionId, correct, chapterId) { setProgress((p) => { const wrongIds = { ...p.wrongIds } - if (correct) delete wrongIds[questionId] - else wrongIds[questionId] = true - return { ...p, wrongIds } + const entry = wrongIds[questionId] + if (correct) { + if (entry) { + const box = entry.box + 1 + if (box > GRADUATE_BOX) delete wrongIds[questionId] + else wrongIds[questionId] = { ...entry, box } + } + } else { + wrongIds[questionId] = { + count: (entry?.count ?? 0) + 1, + lastWrong: todayStr(), + box: 1, + } + } + const dailyStats = bumpCounter(p.dailyStats, todayStr(), correct) + const chapterStats = chapterId ? bumpCounter(p.chapterStats, chapterId, correct) : p.chapterStats + return { ...p, wrongIds, dailyStats, chapterStats } + }) + } + + // 收藏/取消收藏題目 + function toggleSaved(questionId) { + setProgress((p) => { + const savedIds = { ...p.savedIds } + if (savedIds[questionId]) delete savedIds[questionId] + else savedIds[questionId] = true + return { ...p, savedIds } }) } @@ -66,6 +125,7 @@ export function useProgress() { ...p, xp: p.xp + xpEarned, streak: bumpStreak(p.streak), + xpLog: bumpXpLog(p.xpLog, xpEarned), completedLevels: { ...p.completedLevels, [levelId]: { @@ -83,6 +143,7 @@ export function useProgress() { ...p, xp: p.xp + xpEarned, streak: bumpStreak(p.streak), + xpLog: bumpXpLog(p.xpLog, xpEarned), })) } @@ -104,7 +165,7 @@ export function useProgress() { if (typeof data.xp !== 'number' || typeof data.completedLevels !== 'object') { throw new Error('格式不對') } - setProgress({ ...defaultProgress, ...data }) + setProgress({ ...defaultProgress, ...data, wrongIds: migrateWrongIds(data.wrongIds) }) onDone?.(true) } catch { onDone?.(false) @@ -116,6 +177,7 @@ export function useProgress() { return { progress, answerQuestion, + toggleSaved, finishLevel, finishReview, exportProgress, diff --git a/src/index.css b/src/index.css index 2c05ae2..056de5d 100644 --- a/src/index.css +++ b/src/index.css @@ -18,6 +18,15 @@ --badge-bg: #45301f; --radius: 18px; --shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + --streak-from: #1f8f78; + --streak-to: #16b891; + --chart-count: #7c86ff; + --chart-accuracy: #2f9e6b; + --wrong-card-from: #6e2430; + --wrong-card-to: #4c1a24; + --saved-card-from: #7a4a12; + --saved-card-to: #55350f; + --content-width: 760px; } * { @@ -33,10 +42,17 @@ body { } #root { - max-width: 480px; - margin: 0 auto; min-height: 100dvh; - padding: 20px 16px 40px; + display: flex; + flex-direction: column; +} + +#page-body { + width: 100%; + max-width: var(--content-width); + margin: 0 auto; + padding: 24px 20px 40px; + flex: 1; } button { @@ -53,13 +69,82 @@ button { flex-shrink: 0; } -/* ── 首頁 ─────────────────────────── */ -.home-screen { +/* ── 頂部導覽列(網頁版)───────────── */ +.navbar { + position: sticky; + top: 0; + z-index: 10; + background: var(--card); + box-shadow: var(--shadow); +} + +.navbar-inner { + max-width: var(--content-width); + margin: 0 auto; + padding: 0 20px; + display: flex; align-items: center; - text-align: center; - padding-top: 24px; + justify-content: space-between; + gap: 16px; + height: 60px; +} + +.navbar-brand { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 18px; + font-weight: 700; + color: var(--ink); + flex-shrink: 0; +} + +.navbar-brand .icon { + color: var(--primary); +} + +.navbar-tabs { + display: flex; + gap: 4px; + overflow-x: auto; +} + +.navbar-tab { + display: inline-flex; + align-items: center; + gap: 6px; + background: none; + border: none; + color: var(--ink-soft); + font-size: 14px; + font-weight: 600; + padding: 8px 12px; + border-radius: 10px; + white-space: nowrap; + transition: background 0.15s, color 0.15s; } +.navbar-tab:hover { + background: var(--option-bg); + color: var(--ink); +} + +.navbar-tab.is-active { + background: var(--badge-bg); + color: var(--primary); +} + +.page-title { + font-size: 22px; + margin: 0 0 16px; +} + +.section-title { + font-size: 17px; + margin: 24px 0 12px; +} + +/* ── 首頁 ─────────────────────────── */ .app-title { font-size: 34px; margin: 0; @@ -72,6 +157,111 @@ button { font-size: 15px; } +.streak-card { + background: linear-gradient(135deg, var(--streak-from), var(--streak-to)); + border-radius: var(--radius); + padding: 20px; + color: #eafff6; + box-shadow: var(--shadow); +} + +.streak-card-top { + display: flex; + justify-content: space-between; + font-size: 13px; + opacity: 0.9; + margin-bottom: 10px; +} + +.streak-card-count { + display: flex; + align-items: center; + gap: 8px; + font-size: 30px; + font-weight: 700; +} + +.streak-week { + display: flex; + justify-content: space-between; + gap: 6px; + margin-top: 16px; +} + +.streak-day { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + height: 30px; + border-radius: 8px; + font-size: 13px; + background: rgba(255, 255, 255, 0.14); +} + +.streak-day.is-done { + background: rgba(255, 255, 255, 0.32); + font-weight: 700; +} + +.streak-day.is-today { + background: #ffb35c; + color: #2b1608; + font-weight: 700; +} + +.stat-card-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 14px; +} + +.stat-card { + background: var(--card); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 16px; + text-align: left; +} + +.stat-card-head { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; + color: var(--ink-soft); +} + +.stat-card-head .icon { + color: var(--correct); +} + +.stat-card-value { + font-size: 26px; + font-weight: 700; + margin-top: 8px; +} + +.stat-card-hint { + font-size: 12px; + color: var(--ink-soft); + margin-top: 4px; +} + +.mixed-btn { + width: 100%; + max-width: none; + margin-top: 18px; + background: var(--chart-count); + box-shadow: 0 4px 0 #4f58c9; + color: #fff; +} + +.chapter-list { + margin-top: 4px; +} + .mascot { display: flex; flex-direction: column; @@ -149,12 +339,6 @@ button { box-shadow: 0 1px 0 var(--primary-deep); } -.start-btn { - margin-top: 20px; - width: 100%; - max-width: 280px; -} - .secondary-btn { display: inline-flex; align-items: center; @@ -179,19 +363,6 @@ button { color: var(--wrong); } -.streak-badge { - display: inline-flex; - align-items: center; - gap: 6px; - background: var(--badge-bg); - color: var(--primary); - font-size: 14px; - font-weight: 700; - border-radius: 12px; - padding: 6px 14px; - margin-bottom: 16px; -} - .review-banner { display: flex; align-items: center; @@ -208,12 +379,6 @@ button { color: var(--wrong); } -.home-stats { - margin-top: 16px; - color: var(--ink-soft); - font-size: 14px; -} - .progress-io { margin-top: 28px; font-size: 14px; @@ -403,6 +568,37 @@ button { flex-shrink: 0; } +.book-screen .review-btn { + max-width: none; +} + +.book-empty { + color: var(--ink-soft); + font-size: 14px; + text-align: center; + margin-top: 40px; +} + +.book-list { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 12px; +} + +.review-card .option-btn.is-static { + cursor: default; +} + +.review-meta-info { + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 12px; + color: var(--ink-soft); + margin: -4px 0 14px; +} + /* ── 答題 ────────────────────────── */ .quiz-header { display: flex; @@ -479,6 +675,24 @@ button { margin-bottom: 12px; } +.save-btn { + margin-left: auto; + display: flex; + align-items: center; + background: none; + border: none; + color: var(--ink-soft); + padding: 4px; +} + +.save-btn.is-saved { + color: var(--primary); +} + +.icon-filled { + fill: currentColor; +} + .type-badge { display: inline-flex; align-items: center; @@ -659,3 +873,244 @@ button { font-size: 14px; margin-bottom: 24px; } + +/* ── 精選筆記 ─────────────────────── */ +.note-card { + border-radius: var(--radius); + padding: 20px; + margin-bottom: 16px; + box-shadow: var(--shadow); + cursor: pointer; + color: #fff; +} + +.note-card-wrong { + background: linear-gradient(135deg, var(--wrong-card-from), var(--wrong-card-to)); +} + +.note-card-saved { + background: linear-gradient(135deg, var(--saved-card-from), var(--saved-card-to)); +} + +.note-card-head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.note-card-title { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 18px; + font-weight: 700; +} + +.note-card-count { + font-size: 14px; + opacity: 0.9; + margin: 8px 0 16px; +} + +.note-card-btn { + background: rgba(255, 255, 255, 0.9); + color: #2b1608; + border: none; + border-radius: 12px; + padding: 12px 20px; + font-size: 15px; + font-weight: 700; +} + +.note-card-btn:disabled { + background: rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.7); + cursor: default; +} + +/* ── 學習數據 ─────────────────────── */ +.stat-tile-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.stat-tile, +.stat-tile-wide { + background: var(--card); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.stat-tile-wide { + margin-top: 12px; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.stat-tile-label { + font-size: 13px; + color: var(--ink-soft); +} + +.stat-tile-value { + font-size: 24px; + font-weight: 700; + display: flex; + align-items: center; + gap: 6px; +} + +.stat-tile-value .icon { + color: var(--correct); +} + +.mini-chart-pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + background: var(--card); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 18px 16px 8px; +} + +.mini-chart-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--ink-soft); + margin-bottom: 10px; +} + +.legend-dot { + width: 9px; + height: 9px; + border-radius: 50%; +} + +.legend-dot-count { + background: var(--chart-count); +} + +.legend-dot-accuracy { + background: var(--chart-accuracy); +} + +.mini-chart-bars { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 4px; + height: 108px; +} + +.mini-bar-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + height: 100%; +} + +.mini-bar-value { + font-size: 10px; + color: var(--ink-soft); + height: 14px; +} + +.mini-bar { + width: 60%; + min-width: 6px; + border-radius: 4px 4px 0 0; +} + +.mini-bar-count { + background: var(--chart-count); +} + +.mini-bar-accuracy { + background: var(--chart-accuracy); +} + +.mini-bar-label { + font-size: 11px; + color: var(--ink-soft); + margin-top: 6px; +} + +.mini-bar-label.is-today { + color: var(--primary); + font-weight: 700; +} + +.chapter-stat-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.chapter-stat-card { + background: var(--card); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 16px; +} + +.chapter-stat-head { + display: flex; + justify-content: space-between; + font-size: 15px; + margin-bottom: 8px; +} + +.chapter-stat-head strong { + color: var(--correct); +} + +.chapter-stat-sub { + font-size: 12px; + color: var(--ink-soft); + margin-top: 6px; +} + +/* ── 個人資料 ─────────────────────── */ +.profile-screen .mascot { + margin-top: 8px; +} + +.profile-stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-top: 24px; +} + +.profile-stat { + background: var(--card); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 14px 10px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + text-align: center; +} + +.profile-stat-value { + font-size: 18px; + font-weight: 700; +} + +.profile-stat-label { + font-size: 12px; + color: var(--ink-soft); +} diff --git a/src/screens/ChapterMap.jsx b/src/screens/ChapterMap.jsx index f6ccc0f..d3a4995 100644 --- a/src/screens/ChapterMap.jsx +++ b/src/screens/ChapterMap.jsx @@ -1,54 +1,10 @@ -import { useState } from 'react' import { chapters } from '../data/chapters' import Icon from '../components/Icons' -function ChapterList({ progress, onOpenChapter, onBack }) { - return ( -
                        -
                        - -

                        選擇章節

                        -
                        - - {chapters.map((ch) => { - const done = ch.levels.filter((l) => progress.completedLevels[l.id]).length - return ( - - ) - })} -
                        - ) -} - -function LevelList({ chapter, progress, onStartLevel, onBack }) { +// 章節清單顯示在 Home 頁;這裡只負責單一章節的關卡清單 +export default function ChapterMap({ chapterId, progress, onStartLevel, onBack }) { + const chapter = chapters.find((ch) => ch.id === chapterId) + if (!chapter) return null return (
                        @@ -91,21 +47,3 @@ function LevelList({ chapter, progress, onStartLevel, onBack }) {
                        ) } - -export default function ChapterMap({ progress, onStartLevel, onBack }) { - const [chapterId, setChapterId] = useState(null) - const chapter = chapters.find((ch) => ch.id === chapterId) - - if (chapter) { - return ( - setChapterId(null)} - /> - ) - } - - return -} diff --git a/src/screens/Home.jsx b/src/screens/Home.jsx index d681341..7b557f9 100644 --- a/src/screens/Home.jsx +++ b/src/screens/Home.jsx @@ -1,72 +1,108 @@ -import { useRef, useState } from 'react' -import Mascot from '../components/Mascot' import Icon from '../components/Icons' -import { chapters, getWrongQuestions } from '../data/chapters' +import { chapters } from '../data/chapters' +import { todayStr, yesterdayStr } from '../hooks/useProgress' -export default function Home({ progress, onStart, onReview, exportProgress, importProgress }) { - const fileRef = useRef(null) - const [importMsg, setImportMsg] = useState('') +const DAILY_GOAL = 20 +const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日'] - const totalLevels = chapters.reduce((n, ch) => n + ch.levels.length, 0) - const doneLevels = Object.keys(progress.completedLevels).length - const wrongCount = getWrongQuestions(progress.wrongIds).length - const streak = progress.streak?.count ?? 0 +function getWeekDates() { + const now = new Date() + const day = now.getDay() // 0=週日..6=週六 + const mondayOffset = day === 0 ? -6 : 1 - day + const monday = new Date(now) + monday.setDate(now.getDate() + mondayOffset) + return Array.from({ length: 7 }, (_, i) => { + const d = new Date(monday) + d.setDate(monday.getDate() + i) + return d.toLocaleDateString('en-CA') + }) +} - function handleImportFile(e) { - const file = e.target.files[0] - if (!file) return - importProgress(file, (ok) => { - setImportMsg(ok ? '進度匯入成功!' : '檔案格式不對,匯入失敗') - setTimeout(() => setImportMsg(''), 3000) - }) - e.target.value = '' - } +export default function Home({ progress, onOpenChapter, onMixedPractice }) { + const today = todayStr() + const yesterday = yesterdayStr() + const todayStats = progress.dailyStats[today] ?? { total: 0, correct: 0 } + const yesterdayStats = progress.dailyStats[yesterday] + const todayAccuracy = todayStats.total ? Math.round((todayStats.correct / todayStats.total) * 100) : 0 + const yesterdayAccuracy = yesterdayStats?.total + ? Math.round((yesterdayStats.correct / yesterdayStats.total) * 100) + : null + const streak = progress.streak?.count ?? 0 + const weekDates = getWeekDates() return (
                        -

                        EasyLearn

                        -

                        看懂 code・抓得到 bug・改得動程式

                        - - {streak > 0 && ( -
                        - - 連續學習 {streak} 天 +
                        +
                        + 連續學習天數 + 今日任務完成 {todayStats.total} 題
                        - )} +
                        + + {streak} 天 +
                        +
                        + {weekDates.map((date, i) => ( + + {WEEKDAY_LABELS[i]} + + ))} +
                        +
                        - +
                        +
                        +
                        + 今日正確率 + +
                        +
                        {todayAccuracy}%
                        +
                        {yesterdayAccuracy !== null ? `昨日 ${yesterdayAccuracy}%` : '尚無昨日紀錄'}
                        +
                        +
                        +
                        + 今日已做 + +
                        +
                        {todayStats.total} 題
                        +
                        目標:{DAILY_GOAL} 題
                        +
                        +
                        - - {wrongCount > 0 && ( - - )} - -
                        - 已完成 {doneLevels} / {totalLevels} 關 +

                        分科高效刷題

                        +
                        + {chapters.map((ch) => { + const done = ch.levels.filter((l) => progress.completedLevels[l.id]).length + return ( + + ) + })}
                        - -
                        - - ・ - - -
                        - {importMsg &&
                        {importMsg}
                        } - -

                        進度存在這台裝置的瀏覽器裡;換裝置前請先匯出。

                        ) } diff --git a/src/screens/Notes.jsx b/src/screens/Notes.jsx new file mode 100644 index 0000000..d96f34e --- /dev/null +++ b/src/screens/Notes.jsx @@ -0,0 +1,53 @@ +import Icon from '../components/Icons' +import { getWrongQuestions } from '../data/chapters' + +export default function Notes({ progress, onOpenWrongBook, onOpenSavedBook, onReview, onPracticeSaved }) { + const wrongCount = getWrongQuestions(progress.wrongIds).length + const savedCount = Object.keys(progress.savedIds ?? {}).length + + return ( +
                        +

                        精選筆記

                        + +
                        +
                        + + + 錯題本 + +
                        +

                        目前累積 {wrongCount} 題錯題

                        + +
                        + +
                        +
                        + + + 收藏題庫 + +
                        +

                        目前累積 {savedCount} 題收藏

                        + +
                        +
                        + ) +} diff --git a/src/screens/Profile.jsx b/src/screens/Profile.jsx new file mode 100644 index 0000000..e6736e9 --- /dev/null +++ b/src/screens/Profile.jsx @@ -0,0 +1,69 @@ +import { useRef, useState } from 'react' +import Mascot from '../components/Mascot' +import Icon from '../components/Icons' +import { chapters } from '../data/chapters' + +export default function Profile({ progress, exportProgress, importProgress }) { + const fileRef = useRef(null) + const [importMsg, setImportMsg] = useState('') + + const totalLevels = chapters.reduce((n, ch) => n + ch.levels.length, 0) + const doneLevels = Object.keys(progress.completedLevels).length + const streak = progress.streak?.count ?? 0 + const totalAnswered = Object.values(progress.dailyStats ?? {}).reduce((n, d) => n + d.total, 0) + + function handleImportFile(e) { + const file = e.target.files[0] + if (!file) return + importProgress(file, (ok) => { + setImportMsg(ok ? '進度匯入成功!' : '檔案格式不對,匯入失敗') + setTimeout(() => setImportMsg(''), 3000) + }) + e.target.value = '' + } + + return ( +
                        +

                        個人資料

                        + + + +
                        +
                        + {progress.xp} + 總 XP +
                        +
                        + {streak} 天 + 連續學習 +
                        +
                        + + {doneLevels} / {totalLevels} + + 完成關卡 +
                        +
                        + {totalAnswered} + 累計答題 +
                        +
                        + +

                        進度備份

                        +
                        + + ・ + + +
                        + {importMsg &&
                        {importMsg}
                        } +

                        進度存在這台裝置的瀏覽器裡;換裝置前請先匯出。

                        +
                        + ) +} diff --git a/src/screens/QuestionBook.jsx b/src/screens/QuestionBook.jsx new file mode 100644 index 0000000..ffe9dc4 --- /dev/null +++ b/src/screens/QuestionBook.jsx @@ -0,0 +1,46 @@ +import Icon from '../components/Icons' +import QuestionReview from '../components/QuestionReview' + +export default function QuestionBook({ kind, entries, savedIds, onToggleSave, onBack, onReview }) { + const isWrong = kind === 'wrong' + const title = isWrong ? '錯題本' : '收藏題目' + + return ( +
                        +
                        + +

                        {title}

                        +
                        + + {isWrong && entries.length > 0 && ( + + )} + + {entries.length === 0 ? ( +

                        + {isWrong ? '目前沒有錯題,太厲害了!' : '還沒有收藏的題目,答題時點星號收藏吧!'} +

                        + ) : ( +
                        + {entries.map((item) => { + const question = isWrong ? item.question : item + return ( + + ) + })} +
                        + )} +
                        + ) +} diff --git a/src/screens/Quiz.jsx b/src/screens/Quiz.jsx index 43ef05f..7c7899b 100644 --- a/src/screens/Quiz.jsx +++ b/src/screens/Quiz.jsx @@ -2,6 +2,7 @@ import { useState } from 'react' import QuestionCard from '../components/QuestionCard' import Mascot, { getStage } from '../components/Mascot' import Icon from '../components/Icons' +import { getChapterIdForQuestion } from '../data/chapters' const XP_CORRECT = 10 const XP_WRONG = 2 @@ -12,6 +13,7 @@ export default function Quiz({ mode = 'normal', progress, answerQuestion, + toggleSaved, finishLevel, finishReview, onExit, @@ -24,14 +26,16 @@ export default function Quiz({ const [finalXp, setFinalXp] = useState(0) const isReview = mode === 'review' + const isMixed = mode === 'mixed' + const skipsLevelProgress = isReview || isMixed const questions = level.questions const question = questions[index] - const firstClear = !isReview && !progress.completedLevels[level.id] + const firstClear = !skipsLevelProgress && !progress.completedLevels[level.id] function handleSelect(optId) { setSelected(optId) const isCorrect = optId === question.answer - answerQuestion(question.id, isCorrect) + answerQuestion(question.id, isCorrect, getChapterIdForQuestion(question.id)) if (isCorrect) setCorrectCount((c) => c + 1) setXpEarned((x) => x + (isCorrect ? XP_CORRECT : XP_WRONG)) } @@ -43,7 +47,7 @@ export default function Quiz({ } else { const bonus = firstClear ? XP_FIRST_CLEAR_BONUS : 0 const total = xpEarned + bonus - if (isReview) { + if (skipsLevelProgress) { finishReview(total) } else { finishLevel(level.id, correctCount, questions.length, total) @@ -60,7 +64,7 @@ export default function Quiz({

                        {perfect && } - {isReview ? '重練完成!' : perfect ? '全對!太神了' : '關卡完成!'} + {isReview ? '重練完成!' : isMixed ? '練習完成!' : perfect ? '全對!太神了' : '關卡完成!'}

                        @@ -75,18 +79,18 @@ export default function Quiz({
                        {isReview && (
                        - 清掉的錯題 - {correctCount} 題 + 畢業的錯題 + {questions.filter((q) => !progress.wrongIds[q.id]).length} 題
                        )}

                        - {isReview && correctCount < questions.length - ? '沒答對的還留在錯題本,改天再戰!' + {isReview && questions.some((q) => progress.wrongIds[q.id]) + ? '還沒畢業的錯題會留著,繼續練會更熟練!' : '皮皮吃飽了,明天也要來餵牠喔!'}

                        ) @@ -123,7 +127,13 @@ export default function Quiz({ {isReview && (
                        - 錯題重練模式:答對就從錯題本移除 + 錯題重練模式:答對升熟練度,答錯重來,練到最高熟練度才畢業 +
                        + )} + {isMixed && ( +
                        + + 隨機綜合練習:跨章節抽題,不影響關卡進度
                        )}
                        ) diff --git a/src/screens/Stats.jsx b/src/screens/Stats.jsx new file mode 100644 index 0000000..ff09836 --- /dev/null +++ b/src/screens/Stats.jsx @@ -0,0 +1,119 @@ +import Icon from '../components/Icons' +import { chapters } from '../data/chapters' +import { todayStr } from '../hooks/useProgress' + +const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六'] + +function getLast7Dates() { + const today = new Date() + return Array.from({ length: 7 }, (_, i) => { + const d = new Date(today) + d.setDate(today.getDate() - (6 - i)) + return d + }) +} + +export default function Stats({ progress }) { + const dailyStats = progress.dailyStats ?? {} + const chapterStats = progress.chapterStats ?? {} + + const totals = Object.values(dailyStats).reduce( + (acc, d) => ({ total: acc.total + d.total, correct: acc.correct + d.correct }), + { total: 0, correct: 0 }, + ) + const avgAccuracy = totals.total ? Math.round((totals.correct / totals.total) * 100) : 0 + + const days = getLast7Dates().map((d) => { + const key = d.toLocaleDateString('en-CA') + const stat = dailyStats[key] ?? { total: 0, correct: 0 } + const accuracy = stat.total ? Math.round((stat.correct / stat.total) * 100) : 0 + return { key, label: WEEKDAY_LABELS[d.getDay()], total: stat.total, accuracy, isToday: key === todayStr() } + }) + const maxCount = Math.max(1, ...days.map((d) => d.total)) + + return ( +
                        +

                        學習數據分析

                        + +
                        +
                        + 總答題數 + {totals.total} 題 +
                        +
                        + 平均正確率 + {avgAccuracy}% +
                        +
                        +
                        + 累計答對題數 + + + {totals.correct} 題 + +
                        + +

                        近 7 日學習進度

                        +
                        +
                        +
                        + + 做題量 +
                        +
                        + {days.map((d) => ( +
                        + {d.total > 0 ? d.total : ''} + + {d.label} +
                        + ))} +
                        +
                        +
                        +
                        + + 正確率 +
                        +
                        + {days.map((d) => ( +
                        + {d.total > 0 ? `${d.accuracy}%` : ''} + 0 ? Math.max(4, (d.accuracy / 100) * 72) : 2}px` }} + /> + {d.label} +
                        + ))} +
                        +
                        +
                        + +

                        分科成效細分

                        +
                        + {chapters.map((ch) => { + const stat = chapterStats[ch.id] ?? { total: 0, correct: 0 } + const pct = stat.total ? Math.round((stat.correct / stat.total) * 100) : 0 + return ( +
                        +
                        + {ch.title} + {pct}% +
                        +
                        + +
                        +
                        + 答對 {stat.correct} / 總答 {stat.total} +
                        +
                        + ) + })} +
                        +
                        + ) +} diff --git a/src/utils/quiz.js b/src/utils/quiz.js index 5c42ec2..7569655 100644 --- a/src/utils/quiz.js +++ b/src/utils/quiz.js @@ -1,5 +1,7 @@ // 進關作答整個題池:洗牌後依難度由易到難排(同難度內順序隨機) export const REVIEW_SIZE = 6 +// 首頁「隨機綜合練習」跨章節抽題數 +export const MIXED_SIZE = 10 export function shuffle(arr) { const copy = [...arr] From 8ac4811266a8bd3ca63e7d2f7b4c8f7bf0f55e22 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Sat, 11 Jul 2026 16:44:50 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20code=20review=20?= =?UTF-8?q?=E7=99=BC=E7=8F=BE=E7=9A=84=E5=95=8F=E9=A1=8C=EF=BC=9A=E9=9A=A8?= =?UTF-8?q?=E6=A9=9F=E7=B6=9C=E5=90=88=E7=B7=B4=E7=BF=92=E6=8A=BD=E9=A1=8C?= =?UTF-8?q?=E9=82=8F=E8=BC=AF=E3=80=81=E5=B0=8E=E8=A6=BD=E5=88=97=20aria-c?= =?UTF-8?q?urrent=E3=80=81=E7=AD=86=E8=A8=98=E5=8D=A1=E7=89=87=E9=8D=B5?= =?UTF-8?q?=E7=9B=A4=E6=93=8D=E4=BD=9C=E3=80=81=E7=B5=90=E7=AE=97=E9=A0=81?= =?UTF-8?q?=E9=9B=A2=E9=96=8B=E6=8C=89=E9=88=95=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- docs/roadmap.md | 2 +- src/App.jsx | 6 +++++- src/components/Navbar.jsx | 1 + src/screens/Notes.jsx | 27 ++++++++++++++++++++++++--- src/screens/Quiz.jsx | 3 ++- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index fc2ca0e..1b5e3a9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -22,7 +22,7 @@ - 錯題本改成 Leitner 盒制:`wrongIds[id] = { count, lastWrong, box }`,答錯重置回第 1 盒、答對升一盒, 超過 `GRADUATE_BOX`(=3)才真正畢業移出錯題本,不再是「碰巧對一次就消失」 - 舊資料相容:`wrongIds` 舊格式是 `{ id: true }`,新增 `migrateWrongIds()` 在 `load()`/`importProgress()` - 時把 boolean 轉成盒制物件——這是第一個需要真正 migration(非 shallow merge 就够)的欄位變更 + 時把 boolean 轉成盒制物件——這是第一個需要真正 migration(非 shallow merge 就夠)的欄位變更 - 新增收藏:`savedIds: { id: true }` + `toggleSaved()`,答題卡片右上角星號可收藏 - 新增兩個瀏覽頁(`src/screens/QuestionBook.jsx` 共用元件,`kind="wrong"|"saved"`): 不用重考就能翻看題目、正解、解釋;錯題本頁另外顯示答錯次數/熟練度/最近答錯日期,並可直接「開始重練」 diff --git a/src/App.jsx b/src/App.jsx index 53ffabd..d62b186 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -45,7 +45,7 @@ export default function App() { function startMixedPractice() { const pool = chapters.flatMap((ch) => ch.levels.flatMap((l) => l.questions)) - const picked = sampleQuestions(pool).slice(0, MIXED_SIZE) + const picked = shuffle(pool).slice(0, MIXED_SIZE).sort((a, b) => a.difficulty - b.difficulty) setView({ name: 'mixed', questions: picked }) } @@ -73,6 +73,7 @@ export default function App() { finishLevel={finishLevel} finishReview={finishReview} onExit={() => setView({ name: 'levellist', chapterId: navGroupOfChapter(level.id) })} + exitTo="levellist" /> ) } else if (view.name === 'review') { @@ -87,6 +88,7 @@ export default function App() { finishLevel={finishLevel} finishReview={finishReview} onExit={() => setView({ name: 'notes' })} + exitTo="notes" /> ) } else if (view.name === 'mixed') { @@ -101,6 +103,7 @@ export default function App() { finishLevel={finishLevel} finishReview={finishReview} onExit={() => setView({ name: 'home' })} + exitTo="home" /> ) } else if (view.name === 'savedpractice') { @@ -115,6 +118,7 @@ export default function App() { finishLevel={finishLevel} finishReview={finishReview} onExit={() => setView({ name: 'notes' })} + exitTo="notes" /> ) } else if (view.name === 'wrongbook') { diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index a14dd12..9e43457 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -21,6 +21,7 @@ export default function Navbar({ active, onNavigate }) { key={item.key} className={`navbar-tab ${active === item.key ? 'is-active' : ''}`} onClick={() => onNavigate(item.key)} + aria-current={active === item.key ? 'page' : undefined} > {item.label} diff --git a/src/screens/Notes.jsx b/src/screens/Notes.jsx index d96f34e..b178a19 100644 --- a/src/screens/Notes.jsx +++ b/src/screens/Notes.jsx @@ -1,15 +1,30 @@ import Icon from '../components/Icons' import { getWrongQuestions } from '../data/chapters' +function handleActivateKey(handler) { + return (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handler() + } + } +} + export default function Notes({ progress, onOpenWrongBook, onOpenSavedBook, onReview, onPracticeSaved }) { - const wrongCount = getWrongQuestions(progress.wrongIds).length + const wrongCount = getWrongQuestions(progress.wrongIds ?? {}).length const savedCount = Object.keys(progress.savedIds ?? {}).length return (

                        精選筆記

                        -
                        +
                        @@ -29,7 +44,13 @@ export default function Notes({ progress, onOpenWrongBook, onOpenSavedBook, onRe
                        -
                        +
                        diff --git a/src/screens/Quiz.jsx b/src/screens/Quiz.jsx index 7c7899b..ed67942 100644 --- a/src/screens/Quiz.jsx +++ b/src/screens/Quiz.jsx @@ -17,6 +17,7 @@ export default function Quiz({ finishLevel, finishReview, onExit, + exitTo = 'levellist', }) { const [index, setIndex] = useState(0) const [selected, setSelected] = useState(null) @@ -90,7 +91,7 @@ export default function Quiz({ : '皮皮吃飽了,明天也要來餵牠喔!'}

                        )