diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index cafc6fd..cc9c99c 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -8,7 +8,7 @@ import { useProgress } from '@/context/ProgressContext'; import Home from '@/screens/Home'; import ChapterMap from '@/screens/ChapterMap'; import Quiz from '@/screens/Quiz'; -import { chapters, getLevel, MIXED_SIZE, sampleQuestions, shuffle, type Question } from '@easylearn/core'; +import { chapters, getLevel, LEVEL_SIZE, MIXED_SIZE, sampleFixedQuestions, type Question } from '@easylearn/core'; type ViewState = | { name: 'home' } @@ -36,12 +36,12 @@ export default function HomeScreen() { const startLevel = (levelId: string) => { const level = getLevel(levelId); if (!level) return; - setView({ name: 'quiz', levelId, questions: sampleQuestions(level.questions) }); + setView({ name: 'quiz', levelId, questions: sampleFixedQuestions(level.questions, LEVEL_SIZE) }); }; const startMixedPractice = () => { const pool = chapters.flatMap((ch) => ch.levels.flatMap((l) => l.questions)); - const picked = shuffle(pool).slice(0, MIXED_SIZE).sort((a, b) => a.difficulty - b.difficulty); + const picked = sampleFixedQuestions(pool, MIXED_SIZE); setView({ name: 'mixed', questions: picked }); }; diff --git a/apps/mobile/app/(tabs)/notes.tsx b/apps/mobile/app/(tabs)/notes.tsx index d66eda6..c35a571 100644 --- a/apps/mobile/app/(tabs)/notes.tsx +++ b/apps/mobile/app/(tabs)/notes.tsx @@ -13,8 +13,8 @@ import { getWrongEntries, getWrongQuestions, REVIEW_SIZE, + sampleFixedQuestions, sampleQuestions, - shuffle, type Question, } from '@easylearn/core'; @@ -41,7 +41,7 @@ export default function NotesScreen() { } const startReview = () => { - const picked = shuffle(getWrongQuestions(progress.wrongIds)).slice(0, REVIEW_SIZE); + const picked = sampleFixedQuestions(getWrongQuestions(progress.wrongIds), REVIEW_SIZE); if (picked.length === 0) return; setView({ name: 'review', questions: picked }); }; diff --git a/apps/mobile/screens/ChapterMap.tsx b/apps/mobile/screens/ChapterMap.tsx index 2e473f7..23bdcda 100644 --- a/apps/mobile/screens/ChapterMap.tsx +++ b/apps/mobile/screens/ChapterMap.tsx @@ -3,12 +3,11 @@ import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { Text } from '@/components/Themed'; import Icon from '@/components/Icon'; import { colors, fonts } from '@/constants/theme'; -import { chapters, type IconName, type Progress } from '@easylearn/core'; +import { chapters, LEVEL_SIZE, type Progress } from '@easylearn/core'; -type StatusIcon = 'lock' | 'check-circle' | 'play'; +type StatusIcon = 'check-circle' | 'play'; const STATUS_COLOR: Record = { - lock: colors.locked, 'check-circle': colors.cyan, play: colors.primary, }; @@ -37,15 +36,12 @@ export default function ChapterMap({ chapterId, progress, onStartLevel, onBack } {chapter.levels.map((level, i) => { const record = progress.completedLevels[level.id]; - const prevDone = i === 0 || progress.completedLevels[chapter.levels[i - 1].id]; - const locked = !prevDone; - const statusIcon: StatusIcon = locked ? 'lock' : record ? 'check-circle' : 'play'; + const statusIcon: StatusIcon = record ? 'check-circle' : 'play'; return ( onStartLevel(level.id)} - style={[styles.levelRow, locked && styles.levelLocked]} + style={styles.levelRow} > @@ -56,9 +52,7 @@ export default function ChapterMap({ chapterId, progress, onStartLevel, onBack } {record ? `最佳 ${record.best}/${record.total}` - : locked - ? '完成上一關解鎖' - : `共 ${level.questions.length} 題`} + : `共 ${Math.min(LEVEL_SIZE, level.questions.length)} 題`} ); @@ -104,9 +98,6 @@ const styles = StyleSheet.create({ paddingVertical: 16, paddingHorizontal: 18, }, - levelLocked: { - opacity: 0.55, - }, levelIcon: { width: 28, height: 28, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8c5a46f..1c61df1 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -6,10 +6,11 @@ import { getWrongEntries, getSavedQuestions, chapters, - shuffle, sampleQuestions, + sampleFixedQuestions, REVIEW_SIZE, MIXED_SIZE, + LEVEL_SIZE, } from '@easylearn/core' import Navbar from '@/components/Navbar' import Home from '@/screens/Home' @@ -51,21 +52,21 @@ const App = () => { const [view, setView] = useState({ name: 'home' }) const startLevel = (levelId: string) => { - // 整個題池隨機排序作答(同難度內順序每次不同) + // 固定抽 LEVEL_SIZE 題作答,題池不足就整包抽完(同難度內順序每次不同) const level = getLevel(levelId) if (!level) return - setView({ name: 'quiz', levelId, questions: sampleQuestions(level.questions) }) + setView({ name: 'quiz', levelId, questions: sampleFixedQuestions(level.questions, LEVEL_SIZE) }) } const startReview = () => { - const picked = shuffle(getWrongQuestions(progress.wrongIds)).slice(0, REVIEW_SIZE) + const picked = sampleFixedQuestions(getWrongQuestions(progress.wrongIds), REVIEW_SIZE) if (picked.length === 0) return setView({ name: 'review', questions: picked }) } const startMixedPractice = () => { const pool = chapters.flatMap((ch) => ch.levels.flatMap((l) => l.questions)) - const picked = shuffle(pool).slice(0, MIXED_SIZE).sort((a, b) => a.difficulty - b.difficulty) + const picked = sampleFixedQuestions(pool, MIXED_SIZE) setView({ name: 'mixed', questions: picked }) } diff --git a/apps/web/src/screens/ChapterMap.tsx b/apps/web/src/screens/ChapterMap.tsx index 467aeef..1baef15 100644 --- a/apps/web/src/screens/ChapterMap.tsx +++ b/apps/web/src/screens/ChapterMap.tsx @@ -1,5 +1,5 @@ import Icon from '@/components/Icons' -import { chapters, type IconName, type Progress } from '@easylearn/core' +import { chapters, LEVEL_SIZE, type IconName, type Progress } from '@easylearn/core' interface ChapterMapProps { chapterId: string | null @@ -25,14 +25,11 @@ const ChapterMap = ({ chapterId, progress, onStartLevel, onBack }: ChapterMapPro {chapter.levels.map((level, i) => { const record = progress.completedLevels[level.id] - const prevDone = i === 0 || progress.completedLevels[chapter.levels[i - 1].id] - const locked = !prevDone - const statusIcon: IconName = locked ? 'lock' : record ? 'check-circle' : 'play' + const statusIcon: IconName = record ? 'check-circle' : 'play' return ( ) diff --git a/packages/core/src/data/chapters.ts b/packages/core/src/data/chapters.ts index b10fce9..f5b1510 100644 --- a/packages/core/src/data/chapters.ts +++ b/packages/core/src/data/chapters.ts @@ -47,6 +47,36 @@ import fp16 from './questions/fp-16-sharing-resources.json' import fp17 from './questions/fp-17-coordinating-timelines.json' import fp18 from './questions/fp-18-reactive-onion.json' import fp19 from './questions/fp-19-road-ahead.json' +import ri1 from './questions/ri-1-router.json' +import ri2 from './questions/ri-2-i18n.json' +import ri3 from './questions/ri-3-testing.json' +import ri4 from './questions/ri-4-redux.json' +import ri5 from './questions/ri-5-native.json' +import ri6 from './questions/ri-6-ecosystem.json' +import ri7 from './questions/ri-7-misc.json' +import ri8 from './questions/ri-8-modern.json' +import ri9 from './questions/ri-9-core-basics.json' +import ri10 from './questions/ri-10-core-props-events.json' +import ri11 from './questions/ri-11-core-vdom.json' +import ri12 from './questions/ri-12-core-hoc-composition.json' +import ri13 from './questions/ri-13-core-ecosystem-position.json' +import ri14 from './questions/ri-14-core-rendering-patterns.json' +import ri15 from './questions/ri-15-core-jsx-details.json' +import ri16 from './questions/ri-16-core-styling-tools.json' +import ri17 from './questions/ri-17-core-project-conventions.json' +import ri18 from './questions/ri-18-misc-early.json' +import ri19 from './questions/ri-19-misc-hooks-foundations.json' +import ri20 from './questions/ri-20-misc-rendering-internals.json' +import ri21 from './questions/ri-21-misc-reducer-context-deep.json' +import ri22 from './questions/ri-22-misc-useeffect-deep.json' +import ri23 from './questions/ri-23-misc-layouteffect-ref-imperative.json' +import ri24 from './questions/ri-24-misc-memo-callback-custom-hooks.json' +import ri25 from './questions/ri-25-misc-advanced-hooks.json' +import ri26 from './questions/ri-26-misc-code-splitting-perf.json' +import ri27 from './questions/ri-27-misc-error-boundaries.json' +import ri28 from './questions/ri-28-misc-component-conventions.json' +import ri29 from './questions/ri-29-misc-forms-composition.json' +import ri30 from './questions/ri-30-misc-state-alternatives-internals.json' import type { Chapter, Level, Question, WrongEntry, WrongEntryMeta } from '../types' // 題目 JSON 的 type 欄位在匯入時只會被推斷成 string,用 as 收斂回字面量聯合型別 @@ -82,6 +112,16 @@ export const chapters: Chapter[] = [ fp11, fp12, fp13, fp14, fp15, fp16, fp17, fp18, fp19, ].map(asLevel), }, + { + id: 'react-interview', + title: 'React 面試題', + icon: 'lightbulb', + levels: [ + ri9, ri10, ri11, ri12, ri13, ri14, ri15, ri16, ri17, + ri1, ri2, ri3, ri4, ri5, ri6, ri7, ri8, + ri18, ri19, ri20, ri21, ri22, ri23, ri24, ri25, ri26, ri27, ri28, ri29, ri30, + ].map(asLevel), + }, ] // 抽象屏障:所有跟「題目 × 所屬章節」相關的查詢都透過這份攤平清單計算, diff --git a/packages/core/src/data/questions/fp-1-welcome.json b/packages/core/src/data/questions/fp-1-welcome.json index 468bcfb..9d973e2 100644 --- a/packages/core/src/data/questions/fp-1-welcome.json +++ b/packages/core/src/data/questions/fp-1-welcome.json @@ -58,6 +58,63 @@ "answer": "a", "explanation": "「相同輸入永遠得到相同輸出」是純函式最基本的判準——不管什麼時候呼叫、呼叫幾次,只要參數相同,結果就該相同。B、C、D 描述的都是依賴外部狀態或時機的 Action 行為,不是純函式。", "verify": { "manual": "概念題:以具體呼叫情境取代抽象特性列舉,無可執行程式碼。" } + }, + { + "id": "fp-1-q4", + "type": "concept", + "difficulty": 1, + "topic": "純函式讓測試不需要 mock 任何東西", + "docs": "", + "story": "", + "prompt": "幫 calcTax(price) 寫測試,跟幫另一個會發送推播通知的函式 notifyMember(userId) 寫測試比起來,最大的差異是什麼?", + "code": "function calcTax(price) { return price * 1.05; }\nfunction notifyMember(userId) {\n sendPushNotification(userId, \"...\");\n saveNotifyLog(userId);\n}", + "options": [ + { "id": "a", "text": "兩者測試起來難度差不多,只要換一套合適的 mock 工具,notifyMember 也能像 calcTax 一樣直接斷言結果,副作用不會增加多少額外工作" }, + { "id": "b", "text": "notifyMember 反而比較好測試,因為它牽涉的動作比較多,測試案例寫起來自然比較完整、比較看得出程式邏輯" }, + { "id": "c", "text": "測試 calcTax 只需要呼叫它、斷言回傳值即可;測試 notifyMember 得先 mock 掉推播服務、假造資料庫紀錄,才能在不真的推播、不真的寫資料庫的前提下驗證邏輯——這正是純函式帶來的測試便利性" }, + { "id": "d", "text": "只有 calcTax 能被單元測試,notifyMember 依賴外部服務,技術上完全沒辦法寫出任何測試" } + ], + "answer": "c", + "explanation": "純函式的測試只需要「給輸入、斷言輸出」;牽涉副作用的函式(如 notifyMember),測試前得先隔離、模擬它依賴的外部世界,這份額外的準備工作量正是副作用帶來的代價,如選項 c 所述。", + "verify": { "manual": "概念題:以測試撰寫難度的具體對比取代抽象敘述,無可執行程式碼。" } + }, + { + "id": "fp-1-q5", + "type": "concept", + "difficulty": 2, + "topic": "相同輸入不同輸出,代表問題通常不在這個函式本身", + "docs": "", + "story": "", + "prompt": "使用者回報「同樣按下結帳按鈕,有時候算出來的金額不一樣」。如果 calcTotal(cart) 嚴格遵守「相同輸入永遠同樣輸出」這個純函式規則,這個 bug 有沒有可能出在 calcTotal 內部?", + "code": "", + "options": [ + { "id": "a", "text": "很有可能,純函式一樣會受到執行當下的系統時間、網路延遲、使用者時區等外部環境影響,就算輸入的 cart 完全相同也可能算出不同的金額" }, + { "id": "b", "text": "不太可能:如果 calcTotal 真的是純函式,同樣的 cart 輸入必然算出同樣結果,「金額不一樣」代表兩次傳入的 cart 內容本身就不同;問題應該往 cart 是怎麼組出來的方向查,而不是懷疑 calcTotal 本身" }, + { "id": "c", "text": "無法判斷,因為金額算得一不一致跟這個函式到底是不是純函式完全沒有關係,純不純只影響程式碼好不好維護、好不好測試" }, + { "id": "d", "text": "一定是 calcTotal 內部偷用了亂數產生器或讀取了目前系統時間,這是唯一可能造成金額浮動的原因,不用再往其他地方查" } + ], + "answer": "b", + "explanation": "純函式的「相同輸入同樣輸出」保證,反過來也是一個很好用的除錯線索(如選項 b):確認一個函式真的是純函式之後,看到「同樣操作、不同結果」就能直接排除它,把注意力放回輸入資料本身是怎麼變出來的。", + "verify": { "manual": "概念題:以除錯情境取代抽象特性說明,無可執行程式碼。" } + }, + { + "id": "fp-1-q6", + "type": "concept", + "difficulty": 1, + "topic": "FP 降低心智負擔:只看輸入輸出,不用追蹤全部呼叫路徑", + "docs": "", + "story": "", + "prompt": "除錯一個純函式 calcDiscount(price, rate),跟除錯一個到處讀寫全域變數的函式,何者需要在腦中同時記住的東西比較少?", + "code": "", + "options": [ + { "id": "a", "text": "讀寫全域變數的函式比較好除錯,因為隨時能看到目前的全域狀態,不像純函式那樣得另外準備輸入值才能觀察行為" }, + { "id": "b", "text": "兩者需要在腦中記住的東西一樣多,跟這個函式純不純完全無關,差別只在於個人除錯習慣好不好" }, + { "id": "c", "text": "純函式反而更難除錯,因為看不到任何中間狀態,得靠額外的 log 才能知道內部發生了什麼事" }, + { "id": "d", "text": "純函式:結果只取決於傳進來的參數,除錯時只需要盯著這幾個輸入值和函式邏輯,不必在腦中追蹤「全域變數在其他地方分別被誰改過、現在值到底是多少」——這正是 FP 想降低的心智負擔" } + ], + "answer": "d", + "explanation": "全域狀態的問題在於「任何地方都可能是兇手」,除錯時得在腦中同時追蹤一大堆分散的修改點;純函式把所有相關資訊都收斂到參數列表裡,除錯時只需要盯著眼前這一小段,如選項 d 所述,這是 FP 對「人類工作記憶有限」這個現實的務實回應。", + "verify": { "manual": "概念題:以除錯所需心智負擔的具體對比取代抽象敘述,無可執行程式碼。" } } ] } diff --git a/packages/core/src/data/questions/fp-19-road-ahead.json b/packages/core/src/data/questions/fp-19-road-ahead.json index b0619a9..4768024 100644 --- a/packages/core/src/data/questions/fp-19-road-ahead.json +++ b/packages/core/src/data/questions/fp-19-road-ahead.json @@ -58,6 +58,63 @@ "answer": "a", "explanation": "這三個情境分別對應三個具體的重構技巧:從 Action 萃取 Calculation、用顯性參數合併重複函式、用並行原語處理不確定的執行順序——這是把學到的技巧收斂回真實專案的練習;CSS 打包、資料庫選型、變數改名這類工程雜務,跟這裡談的副作用/時序控制技巧無關。", "verify": { "manual": "概念題:情境分析,無可執行程式碼。" } + }, + { + "id": "fp-19-q4", + "type": "concept", + "difficulty": 2, + "topic": "從「修改既有程式碼」到「設計全新系統」的轉變", + "docs": "", + "story": "", + "prompt": "書的前半段(萃取 Calculation、分層設計)主要是在「改善既有的、已經寫壞的程式碼」;後半段談的 reactive architecture、onion architecture 這類主題,跟前半段是什麼關係?", + "code": "", + "options": [ + { "id": "a", "text": "後半段的內容跟前半段完全無關,其實是出版社為了增加頁數,硬把兩本探討不同主題的書拼湊成一冊,讀者其實可以只挑其中一半閱讀,不影響理解" }, + { "id": "b", "text": "從「重構現有程式碼」轉向「設計全新系統架構」:前半段處理「已經寫壞的程式碼怎麼改善」,後半段的 reactive/onion architecture 處理「從零設計系統時 Actions、Calculations、Data 怎麼安排」——同一套概念用在不同情境" }, + { "id": "c", "text": "後半段只是把前半段談過的內容換一套名詞重新講一遍,本質上是同樣的東西包裝成看起來比較新的樣子,沒有任何真正新增的概念" }, + { "id": "d", "text": "前半段講的是可以直接落地套用的具體技巧,後半段講的架構思維說穿了只是空談理論,實務上完全沒辦法應用在真實的商業專案裡" } + ], + "answer": "b", + "explanation": "書的編排本身就有一條「先學會怎麼補救、再學會怎麼預防」的脈絡:前半段的重構技巧針對已經存在的混亂,後半段的架構思維(如選項 b)則是把同一套 Actions/Calculations/Data 的分類原則,提前用在系統設計階段,讓混亂從一開始就少發生。", + "verify": { "manual": "概念題:以書籍前後段落內容關係取代單純章節條列,無可執行程式碼。" } + }, + { + "id": "fp-19-q5", + "type": "concept", + "difficulty": 2, + "topic": "過度抽象的風險:不是每段程式碼都值得硬套技巧", + "docs": "", + "story": "", + "prompt": "一個只會被呼叫一次、邏輯簡單到一眼就能看懂的小函式,有人堅持照書裡的技巧硬是把它拆成三層(萃取 Calculation、再用高階函式合併、再包一層防禦性拷貝)。這樣做恰當嗎?", + "code": "", + "options": [ + { "id": "a", "text": "恰當,書裡教的每一種技巧本來就該無條件套用在所有程式碼上,不管有沒有實際痛點,套用得越多,程式碼品質自然越高越好" }, + { "id": "b", "text": "恰當,因為拆成越多層、程式碼的行數自然增加,行數越多就代表被重構得越徹底,品質也就跟著越高" }, + { "id": "c", "text": "不恰當,但唯一的原因是這樣拆三層會拖慢執行速度、讓效能變差,跟程式碼好不好讀完全無關" }, + { "id": "d", "text": "不恰當:這些技巧只該用在真的因為副作用混雜、時序不確定而難以理解或測試的程式碼上;對簡單清楚、沒有痛點的程式碼硬套三層,只會增加不必要的間接層次,違背技巧原本要解決的問題" } + ], + "answer": "d", + "explanation": "所有重構技巧都該對應一個真實存在的痛點,套用技巧本身不是目的。對沒有痛點的程式碼強行套用,只會增加讀者需要在腦中追蹤的間接層次,如選項 d 所述,這跟這些技巧「讓程式碼更容易推理」的初衷背道而馳。", + "verify": { "manual": "概念題:以過度抽象的具體情境取代抽象原則說明,無可執行程式碼。" } + }, + { + "id": "fp-19-q6", + "type": "concept", + "difficulty": 3, + "topic": "全書技巧的共同主軸:把影響範圍限縮到人腦能推理的大小", + "docs": "", + "story": "", + "prompt": "回顧全書談過的技巧——區分 Actions/Calculations/Data、萃取計算、分層設計、defensive copying、時間線圖、reactive architecture——這些技巧最終共同想達成的效果,最貼切的說法是什麼?", + "code": "", + "options": [ + { "id": "a", "text": "這些技巧的共同目標只是讓程式碼的檔案數量變多、看起來比較有規模,跟好不好理解沒有直接關係" }, + { "id": "b", "text": "這些技巧的目的只是為了符合函數式程式設計語言本身的語法規範,跟程式碼好不好理解完全無關" }, + { "id": "c", "text": "把程式碼的影響範圍、什麼時候會被誰改動,限縮到人腦可以一次掌握、可以推理清楚的範圍內——不管是限縮副作用擴散、巢狀資料共享風險、還是時間線的不確定性,核心都是讓開發者不用追蹤整個系統,就能對一小段程式碼有信心" }, + { "id": "d", "text": "這些技巧彼此完全獨立,沒有任何共同的核心精神,純粹是作者把不同章節的零散技巧硬湊在一起而已" } + ], + "answer": "c", + "explanation": "全書每一項技巧表面上處理的問題不同(副作用、抽象層級、資料共享、執行順序),但共同的深層目標一致,如選項 c 所述:把「需要同時放在腦中才能理解這段程式碼」的範圍縮小,讓程式碼在局部就能被完整推理,不用理解整個系統才能安心修改一小塊。", + "verify": { "manual": "概念題:全書技巧收斂的總結性推理,無可執行程式碼。" } } ] } diff --git a/packages/core/src/data/questions/fp-2-overview.json b/packages/core/src/data/questions/fp-2-overview.json index 6f471df..f653da8 100644 --- a/packages/core/src/data/questions/fp-2-overview.json +++ b/packages/core/src/data/questions/fp-2-overview.json @@ -58,6 +58,63 @@ "answer": "a", "explanation": "「順序不定造成的錯誤」正是時間線圖存在的意義——型別檢查、縮排格式都抓不到「兩條時間線交錯讀寫同一份資料」這種問題,只有把執行順序畫出來才能看清楚。", "verify": { "manual": "概念題:以具體雙擊 bug 情境取代對工具定義的直接背誦,無可執行程式碼。" } + }, + { + "id": "fp-2-q4", + "type": "concept", + "difficulty": 1, + "topic": "程式碼重複但邏輯幾乎一樣:適合用高階函式消除重複", + "docs": "", + "story": "", + "prompt": "專案裡有 sumPrices(items)、maxPrice(items)、countExpensive(items) 三個函式,程式碼幾乎一模一樣,只有迴圈裡「怎麼處理每個元素」不同。這種重複,書裡建議用什麼技巧解決?", + "code": "", + "options": [ + { "id": "a", "text": "把三個函式的程式碼各自複製貼上、獨立維護,不做任何抽象整併,避免共用邏輯出錯時互相牽連" }, + { "id": "b", "text": "改用時間線圖分析,因為這三個函式的重複問題出在執行順序不一致,不是走訪邏輯本身重複" }, + { "id": "c", "text": "改用 defensive copying,因為這三個函式重複的根本原因是資料在傳遞過程中被意外修改" }, + { "id": "d", "text": "把「怎麼處理每個元素」變成一個參數(傳入一個函式),用高階函式(如 reduce、map、filter)抽出共同的走訪邏輯,各自只保留真正不同的那一小段" } + ], + "answer": "d", + "explanation": "三個函式的共同點是「走訪陣列」,差異點是「拿每個元素做什麼」;高階函式(如選項 d)把差異點抽成參數(一個函式),共同點留在通用的走訪邏輯裡,這是消除這類重複最直接的手法。", + "verify": { "manual": "概念題:以具體重複函式情境取代對章節工具清單的直接背誦,無可執行程式碼。" } + }, + { + "id": "fp-2-q5", + "type": "concept", + "difficulty": 2, + "topic": "多個非同步請求需要彼此協調:適合用時間線圖與並行原語", + "docs": "", + "story": "", + "prompt": "頁面需要同時打三個 API(使用者資料、訂單紀錄、優惠券),只有三個都回來之後才能顯示畫面,中途某一個先回來也不能提早顯示不完整的畫面。這種「協調多條時間線」的需求,屬於書裡哪一類技巧涵蓋的問題?", + "code": "", + "options": [ + { "id": "a", "text": "屬於萃取 Calculation 要解決的問題,因為三支 API 呼叫本質上都是把外部資料轉換成畫面需要的格式" }, + { "id": "b", "text": "屬於時間線圖與並行原語(如「等多個非同步操作一起完成」這類原語)要解決的問題——這是「多條時間線需要協調」的情境,跟單純的「一步接一步」計算完全不同類型" }, + { "id": "c", "text": "屬於分層設計要解決的問題,因為這裡同時牽涉到三個不同的 API,屬於抽象層級混雜的情況" }, + { "id": "d", "text": "這不屬於書裡任何技巧涵蓋的範圍,多個非同步請求的協調只能土法煉鋼手動處理,沒有系統化解法" } + ], + "answer": "b", + "explanation": "「多條時間線該怎麼互相等待、協調」是時間線圖與並行原語這組技巧(如選項 b)的核心關懷,跟萃取 Calculation(處理副作用混雜)、分層設計(處理抽象層級混雜)解決的是完全不同類型的問題。", + "verify": { "manual": "概念題:以具體多重 API 協調情境取代對章節分類的直接背誦,無可執行程式碼。" } + }, + { + "id": "fp-2-q6", + "type": "concept", + "difficulty": 2, + "topic": "巢狀資料結構操作雜亂:適合用巢狀資料的更新手法", + "docs": "", + "story": "", + "prompt": "一段程式碼要「把每個訂單裡、每件商品的價格都調漲 10%」,訂單資料是巢狀結構(訂單陣列 → 每個訂單裡有商品陣列)。直接寫巢狀 for 迴圈手動修改,程式碼變得很難讀。書裡哪個主題會提供處理這類巢狀資料更新的手法?", + "code": "", + "options": [ + { "id": "a", "text": "跟 React 狀態管理有關的主題,這個資料轉換問題單純是前端框架處理畫面更新的細節,跟資料結構本身無關" }, + { "id": "b", "text": "只有時間線圖能處理巢狀資料的更新問題,因為時間線圖本來就是設計來處理所有資料操作的通用工具" }, + { "id": "c", "text": "巢狀資料(nested data)操作的主題:提供像 update 這類「不整個攤平重寫,安全更新巢狀結構某一層資料」的函式模式,取代手寫巢狀迴圈直接修改" }, + { "id": "d", "text": "書裡沒有討論這類巢狀資料的操作技巧,遇到這種情境只能自己手寫巢狀迴圈,沒有既有手法可以參考" } + ], + "answer": "c", + "explanation": "巢狀資料更新是一個獨立於「副作用」「執行順序」之外的問題類別——重點在「怎麼安全地只改動巢狀結構裡的某一小塊、又不破壞其餘部分的不可變性」,如選項 c 所述,書裡有專門一個主題處理這類手法。", + "verify": { "manual": "概念題:以具體巢狀資料調整情境取代對章節分類的直接背誦,無可執行程式碼。" } } ] } diff --git a/packages/core/src/data/questions/fp-3-actions-calculations-data.json b/packages/core/src/data/questions/fp-3-actions-calculations-data.json index d3565f2..1ee63e3 100644 --- a/packages/core/src/data/questions/fp-3-actions-calculations-data.json +++ b/packages/core/src/data/questions/fp-3-actions-calculations-data.json @@ -96,6 +96,25 @@ "answer": "a", "explanation": "這個順序反映的是「推理與測試難度」:Data 不用執行就能檢視、Calculation 只需要給輸入斷言輸出、Action 則要準備假的外部環境還要驗證副作用真的發生了。能停在光譜左邊解決問題,就不要往右升級。", "verify": { "manual": "概念題:以三種寫法的測試難度對比取代抽象偏好說明,無可執行程式碼。" } + }, + { + "id": "fp-3-q6", + "type": "concept", + "difficulty": 1, + "topic": "判斷 Action 的關鍵問句:呼叫時機/次數會不會影響結果", + "docs": "", + "story": "", + "prompt": "面對一段陌生的程式碼,想快速判斷它是 Action 還是 Calculation,該問自己哪個關鍵問題?", + "code": "function getCurrentUser() { return currentLoggedInUser; }\nfunction double(x) { return x * 2; }", + "options": [ + { "id": "a", "text": "問「這個函式的程式碼行數有沒有超過 10 行」,超過 10 行就歸類成 Action,沒超過就歸類成 Calculation,跟行數多寡直接掛鉤" }, + { "id": "b", "text": "問「呼叫一次跟呼叫兩次、現在呼叫跟等一下呼叫,結果會不會不一樣?」——getCurrentUser() 可能因登入狀態改變而回傳不同結果,是 Action;double(x) 不管何時呼叫幾次,同樣的 x 永遠得到同樣結果,是 Calculation" }, + { "id": "c", "text": "問「這個函式有沒有回傳值」,有回傳值一律歸類成 Calculation,沒有回傳值一律歸類成 Action,跟函式內部實際做了什麼無關" }, + { "id": "d", "text": "問「這個函式的名稱是不是動詞開頭」,只要命名是動詞開頭,就直接判定它是 Action,不需要再看函式內部邏輯" } + ], + "answer": "b", + "explanation": "「呼叫時機或次數會不會影響結果」(如選項 b)是辨識 Action 最直接的判斷句:只要答案是「會」,就代表這個函式依賴或影響了某個會隨時間變化的外部狀態,是 Action;答案是「不會」,才有資格被歸類成 Calculation。程式碼行數、有沒有回傳值、命名風格都不是可靠的判斷依據。", + "verify": { "manual": "概念題:辨識口訣搭配具體程式碼範例,無可執行程式碼。" } } ] } diff --git a/packages/core/src/data/questions/fp-5-improve-actions.json b/packages/core/src/data/questions/fp-5-improve-actions.json index d411f2b..75e9d8a 100644 --- a/packages/core/src/data/questions/fp-5-improve-actions.json +++ b/packages/core/src/data/questions/fp-5-improve-actions.json @@ -96,6 +96,25 @@ "answer": "a", "explanation": "這正是分層設計的核心精神:底層的 cart 操作應該穩定、少變動;商業規則建立在底層之上,可以隨業務需求快速調整,不會牽動底下的基礎操作。", "verify": { "manual": "概念題:情境分析,無可執行程式碼。" } + }, + { + "id": "fp-5-q6", + "type": "concept", + "difficulty": 2, + "topic": "判斷一個 Action 能不能被完全轉成 Calculation:問它牽涉的 I/O 是必要的還是順便的", + "docs": "", + "story": "", + "prompt": "add_item_to_cart 這個函式做了兩件事:①計算新的購物車內容 ②把新內容存進資料庫。哪一部分有機會被轉成純 Calculation,哪一部分永遠沒辦法?", + "code": "function add_item_to_cart(name, price) {\n shopping_cart = add_item(shopping_cart, name, price); // ① 計算\n saveToDb(shopping_cart); // ② 寫入資料庫\n}", + "options": [ + { "id": "a", "text": "兩者都能完全轉成 Calculation,只要重構手法夠高明,連寫入資料庫這個動作本身也能變成單純的運算,不需要真的碰任何外部系統,也不會留下任何副作用" }, + { "id": "b", "text": "①沒辦法轉成 Calculation,②反而可以,因為資料庫查詢跟寫入操作本質上都只是一種能夠預先算好結果、不會影響外部世界的運算過程而已" }, + { "id": "c", "text": "兩者都永遠是 Action,因為這兩個步驟緊密綁在同一個函式呼叫序列裡,彼此互相依賴,沒有任何一部分可以被單獨拆解出來重構成純函式" }, + { "id": "d", "text": "①(算新購物車內容)能轉成 Calculation:只是根據輸入算出新資料,不需要碰資料庫;②(寫入資料庫)永遠沒辦法變成 Calculation,因為「真的存進去」本身就是對外部世界產生效果,是 Action 的定義性特徵——重構目標是把 ① 從 ② 分離,而非讓 ② 消失" } + ], + "answer": "d", + "explanation": "重構 Action 的目標從來不是消滅所有副作用,而是把「可以獨立算出來的部分」跟「真正無法迴避的外部效果」分開,如選項 d 所述。這裡計算新購物車內容可以完全獨立驗證,但寫入資料庫這個動作本身,不管包裝得多漂亮,終究是一個 Action。", + "verify": { "manual": "概念題:以具體函式拆解取代抽象改善原則,無可執行程式碼。" } } ] } diff --git a/packages/core/src/data/questions/fp-7-defensive-copying.json b/packages/core/src/data/questions/fp-7-defensive-copying.json index ba15879..f172de5 100644 --- a/packages/core/src/data/questions/fp-7-defensive-copying.json +++ b/packages/core/src/data/questions/fp-7-defensive-copying.json @@ -96,6 +96,25 @@ "answer": "a", "explanation": "深拷貝的代價會隨資料規模線性甚至更快成長,所以是「不得已才用」的工具:只在真正需要隔離不受信任程式碼的邊界上使用,其餘地方交給便宜很多的 copy-on-write。", "verify": { "manual": "概念題:對照書中拷貝成本的討論,無可執行程式碼。" } + }, + { + "id": "fp-7-q6", + "type": "concept", + "difficulty": 2, + "topic": "defensive copying 保護的是誰:你自己的那一份,不是全世界", + "docs": "", + "story": "", + "prompt": "用 defensive copying 包裝了一個不受信任的第三方函式庫呼叫之後,這個第三方函式庫本身還是可以任意亂改「它自己拿到的那份拷貝」內部的資料。這樣還算是 defensive copying 成功發揮作用嗎?", + "code": "", + "options": [ + { "id": "a", "text": "不算成功,defensive copying 完全失敗了,因為不受信任的程式碼依然能任意修改它拿到的那份資料,代表深拷貝這個保護機制根本沒有發揮任何實際作用" }, + { "id": "b", "text": "無法判斷,這件事跟 defensive copying 的定義完全無關,成不成功要看第三方函式庫本身的程式碼品質好不好、寫得嚴不嚴謹" }, + { "id": "c", "text": "算成功:defensive copying 保護的不是「阻止不受信任的程式碼做任何事」,而是「確保這種行為不會擴散到呼叫端自己手上的原始資料」;第三方函式庫怎麼蹂躪自己那份拷貝是它的事,只要呼叫端資料沒被污染,目的就達成了" }, + { "id": "d", "text": "不算成功,只有第三方函式庫連它自己拿到的那份拷貝都完全不能修改、不能有任何動作,才能算是徹底防禦成功" } + ], + "answer": "c", + "explanation": "defensive copying 劃的是一條「隔離線」,不是「禁止線」:它保證的是隔離線這一側(呼叫端)的資料乾淨,不去管、也管不著隔離線另一側(不受信任的程式碼)內部想怎麼胡搞它自己那份拷貝,如選項 c 所述。理解這個邊界,才不會誤以為深拷貝之後就能約束第三方程式碼的行為。", + "verify": { "manual": "概念題:釐清防禦邊界的具體情境,無可執行程式碼。" } } ] } diff --git a/packages/core/src/data/questions/ri-1-router.json b/packages/core/src/data/questions/ri-1-router.json new file mode 100644 index 0000000..fafc2ca --- /dev/null +++ b/packages/core/src/data/questions/ri-1-router.json @@ -0,0 +1,271 @@ +{ + "id": "ri-1", + "title": "React Router:前端路由基礎", + "questions": [ + { + "id": "ri-1-q1", + "type": "concept", + "difficulty": 1, + "topic": "四種 Router 元件怎麼挑", + "docs": "", + "story": "", + "prompt": "團隊把一個 React SPA 部署到一台陽春的靜態檔案伺服器,沒有設定「找不到路徑就一律回傳 index.html」這種改寫規則。使用者在 /about 頁面按重新整理時,伺服器會直接回傳 404(因為它真的去找一個叫 about 的檔案,當然找不到)。在不能改伺服器設定的前提下,App 最外層該用哪一個 Router 元件包起來?", + "code": "", + "options": [ + { "id": "a", "text": "HashRouter —— 網址其實是 /#/about 這種雜湊片段,# 後面的部分瀏覽器根本不會送給伺服器,伺服器永遠只看到對根目錄的請求,重新整理不會 404;代價是網址多了一個 # 符號" }, + { "id": "b", "text": "BrowserRouter —— 用 HTML5 history API,網址乾淨沒有 #,但重新整理 /about 時瀏覽器會真的對伺服器發出 /about 請求,沒有改寫規則就會 404,跟「不能改伺服器設定」這個前提直接衝突" }, + { "id": "c", "text": "MemoryRouter —— 路由狀態只存在記憶體裡,不會反映在網址列,使用者看不到網址、也存不了書籤,不適合一般對外的網頁" }, + { "id": "d", "text": "StaticRouter —— 是給伺服器端渲染(SSR)用的一次性渲染結果,瀏覽器端後續的互動式導航不會用它" } + ], + "answer": "a", + "explanation": "四種 Router 差在「路由狀態存在哪裡、伺服器看不看得到」:BrowserRouter 用真實網址、需要伺服器配合改寫規則;HashRouter 把路由資訊藏進 # 後面,伺服器完全不會收到、天生免疫「重新整理 404」這個問題;MemoryRouter 只活在記憶體,適合測試或 React Native 這種沒有網址列的環境;StaticRouter 只服務 SSR 那一次性的渲染。這題的限制條件(不能改伺服器、但要能重新整理)剛好點名 HashRouter 的存在意義。", + "verify": { + "checks": [], + "manual": "情境屬部署設定與伺服器行為,需要真實伺服器與瀏覽器環境才能觀察;且依賴 react-router-dom(本專案未安裝),無法用 node 直接執行驗證。" + } + }, + { + "id": "ri-1-q2", + "type": "concept", + "difficulty": 1, + "topic": "useNavigate 導頁後不留歷史記錄", + "docs": "", + "story": "", + "prompt": "按下「登出」按鈕後,要把使用者導到 /login,並且不希望使用者按瀏覽器的上一頁又跳回登出前的頁面。下面這段寫法,按上一頁之後會發生什麼事?", + "code": "function LogoutButton() {\n const navigate = useNavigate();\n\n function handleLogout() {\n logout();\n navigate(\"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/login\", { replace: true });\n }\n\n return ;\n}", + "options": [ + { "id": "a", "text": "不會跳回登出前的頁面:{ replace: true } 讓這次導頁『取代』目前這筆歷史記錄,而不是新增一筆,登出前那一頁的紀錄已經被蓋掉了" }, + { "id": "b", "text": "會跳回登出前的頁面,因為 navigate 預設一律會新增一筆歷史記錄,replace 選項不影響瀏覽器上一頁的行為" }, + { "id": "c", "text": "瀏覽器的上一頁按鈕會整個失效、不能按" }, + { "id": "d", "text": "會跳到瀏覽器設定的首頁,跟這個 App 的路由完全無關" } + ], + "answer": "a", + "explanation": "useNavigate() 回傳的函式是 React Router v6 之後取代 v5 withRouter/this.props.history.push 的寫法。第二個參數 { replace: true } 對應 history 的 replace():把目前這一筆歷史記錄換成新位置,而不是 push() 那樣疊加一筆新的。登出、送出表單後的導頁常用 replace,避免使用者按上一頁又跑回一個已經沒意義的舊狀態。", + "verify": { + "checks": [], + "manual": "牽涉瀏覽器 history 堆疊與使用者實際點擊上一頁的操作;且 useNavigate 依賴 react-router-dom(本專案未安裝),無法用 node 直接執行驗證。" + } + }, + { + "id": "ri-1-q3", + "type": "concept", + "difficulty": 2, + "topic": "useParams 讀出來的動態路由參數", + "docs": "", + "story": "", + "prompt": "路由設定成 } />,目前網址是 /users/42。UserProfile 元件裡執行 console.log(useParams()),會印出什麼?", + "code": "// 路由設定\n} />\n\n// 目前網址:/users/42\nfunction UserProfile() {\n const params = useParams();\n console.log(params);\n}", + "options": [ + { "id": "a", "text": "{ userId: \"42\" } —— useParams() 依路由裡每個 : 開頭的區段名稱當 key,抓出網址對應位置的實際內容當 value;即使網址上看起來是數字,抓到的一律是字串 \"42\",不是數字 42" }, + { "id": "b", "text": "{ userId: 42 } —— React Router 會自動把看起來像數字的參數轉型" }, + { "id": "c", "text": "\"42\" —— 直接回傳字串本身,不會包成物件" }, + { "id": "d", "text": "undefined —— useParams 這個 hook 只能在 class component 裡使用" } + ], + "answer": "a", + "explanation": "useParams() 回傳的是一個「路由參數名稱 → 網址對應片段字串」的物件,key 就是路徑裡 :userId 拿掉冒號後的名字,value 永遠是字串——網址本來就是文字,React Router 不會替你猜測要不要轉型。如果程式邏輯需要數字,得自己 Number(params.userId) 轉換。這也是 hook 的一種,只能在函式元件(或自訂 hook)裡呼叫,不能進 class component。", + "verify": { + "checks": [], + "manual": "useParams 需要 react-router-dom 的路由 context 才能運作,本專案未安裝該套件,無法用 node 直接執行驗證,需人工審核。" + } + }, + { + "id": "ri-1-q4", + "type": "fill-in", + "difficulty": 2, + "topic": "查詢字串背後其實是原生 URLSearchParams", + "docs": "https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/get", + "story": "", + "prompt": "React Router 的 useSearchParams 底層就是瀏覽器原生的 URLSearchParams。不依賴任何路由套件,只用原生 API 讀出查詢字串裡的 keyword,該呼叫哪個方法?把空格填起來", + "code": "const search = \"?keyword=react&page=2\";\nconst params = new URLSearchParams(search);\nconsole.log(params.____(\"keyword\"));", + "options": [ + { "id": "a", "text": "get" }, + { "id": "b", "text": "getItem" }, + { "id": "c", "text": "read" }, + { "id": "d", "text": "value" } + ], + "answer": "a", + "explanation": "URLSearchParams 是瀏覽器(跟 Node.js)都內建的原生 API,get(name) 回傳指定查詢參數的字串值,沒有對應的參數會回傳 null。getItem 是 localStorage/sessionStorage 的方法名稱,容易跟這裡搞混;read、value 都不是這個介面存在的方法。React Router 的 useSearchParams() hook,回傳的第一個值本質上就是一個 URLSearchParams 實例,用法完全共通。", + "verify": { + "checks": [ + { "code": "const search = \"?keyword=react&page=2\";\nconst params = new URLSearchParams(search);\nconsole.log(params.get(\"keyword\"));", "expected": "react" } + ] + } + }, + { + "id": "ri-1-q5", + "type": "concept", + "difficulty": 3, + "topic": "v5 到 v6:catch-all 404 路由的寫法變了", + "docs": "", + "story": "", + "prompt": "v5 熟悉的寫法是「在 Switch 裡省略 path 屬性的 Route,會永遠比對成功」,拿來當 404 頁面的 fallback。改用 v6 之後,如果只是照抄這個「省略 path」的習慣,還能達到一樣的 catch-all 效果嗎?", + "code": "// v5 寫法(Switch 底下,省略 path 的 Route 永遠比對成功)\n\n \n \n\n\n// v6 寫法(直接照抄「省略 path」這個習慣)\n\n } />\n } />\n", + "options": [ + { "id": "a", "text": "不一樣:v6 拿掉了「省略 path 就永遠比對成功」這條規則,一個沒有 path 也沒有 index 的 Route,在 v6 裡不會被當成 catch-all;要重新達到 404 效果,得明確寫成 } />" }, + { "id": "b", "text": "一樣,v6 完全相容 v5 的路由比對規則,只是把元件名稱從 Switch 改成 Routes" }, + { "id": "c", "text": "一樣,只是 v6 要求元件要改用 element 屬性傳,比對規則本身沒變" }, + { "id": "d", "text": "不一樣,因為 v6 整個移除了「用路由做 404 頁面」這個概念" } + ], + "answer": "a", + "explanation": "v5 到 v6 是一次不相容的改版:v6 引進了更精確的路徑比對演算法,也連帶取消了「沒寫 path 就無條件命中」這個隱性規則(沒有 path/index 的 Route 現在多半用在巢狀佈局裡配合 Outlet,不再是萬用 fallback)。要在 v6 做出跟 v5 一樣的『其餘路徑都導向 NotFound』效果,需要顯式寫 path=\"*\",用萬用字元語法取代舊版「省略等於命中」的隱性行為。", + "verify": { + "checks": [], + "manual": "v5 的 Switch/Route 比對規則與 v6 的差異需要對照兩個套件版本的實際執行行為,且兩者皆依賴 react-router(本專案未安裝任一版本),無法用 node 直接執行驗證。" + } + }, + { + "id": "ri-1-q6", + "type": "concept", + "difficulty": 3, + "topic": "登入後自動導向:Navigate 取代舊版 Redirect", + "docs": "", + "story": "", + "prompt": "使用者已經登入時,Login 頁面應該直接跳轉到 /dashboard,不要顯示登入表單。在 React Router v6 的函式元件裡,已登入的分支該 return 哪一段?", + "code": "function Login() {\n const isLoggedIn = useIsLoggedIn();\n\n if (isLoggedIn) {\n // 該 return 哪一段?\n }\n\n return ;\n}", + "options": [ + { "id": "a", "text": "改用 v6 的 元件並加上 replace,取代掉 v5 的 ", "code": "return ;" }, + { "id": "b", "text": "沿用 v5 的 元件", "code": "return ;" }, + { "id": "c", "text": "直接呼叫 class component 風格的 history.push", "code": "history.push(\"/dashboard\");\nreturn null;" }, + { "id": "d", "text": "整頁導頁,捨棄 React 應用程式現有的狀態", "code": "window.location.href = \"/dashboard\";\nreturn null;" } + ], + "answer": "a", + "explanation": "v6 把 v5 的 元件整個移除,改用 :直接 return 它,React Router 就會在渲染時觸發導頁,加上 replace 可以避免使用者按上一頁又跳回已經登入過的 Login 頁。选项 c 是 class component 時代透過 this.props.history 導頁的寫法,函式元件拿不到那個 props;選項 d 能導頁,但等於整頁重新整理、丟掉 React 應用程式目前的記憶體狀態,不是 React Router 想解決的用法。", + "verify": { + "checks": [], + "manual": "Navigate/Redirect 都依賴 react-router-dom 的路由 context 與瀏覽器環境,本專案未安裝該套件,無法用 node 直接執行驗證。" + } + }, + { + "id": "ri-1-q7", + "type": "concept", + "difficulty": 1, + "topic": "React Router 解決的問題:讓網址跟畫面內容同步", + "docs": "", + "story": "", + "prompt": "一個 React 應用程式從頭到尾只有一個畫面,沒有任何『網址列變了、畫面內容也該跟著換』的需求。有人建議還是先引入 React Router「以防萬一」,這個建議合理嗎?", + "code": "", + "options": [ + { "id": "a", "text": "不一定合理:React Router 存在的核心價值,是讓『網址(URL)』跟『畫面上顯示的內容』保持同步——多個畫面、頁面能被加入書籤與分享連結、瀏覽器上一頁/下一頁要能正確切換畫面,才是它真正解決的問題;如果應用程式只有一個畫面、沒有這些需求,引入一整套路由函式庫只是增加不必要的依賴與複雜度,等真的需要多畫面切換時再導入也不遲" }, + { "id": "b", "text": "合理,任何 React 專案都應該無條件引入 React Router,不管有沒有多個畫面" }, + { "id": "c", "text": "不合理,React Router 只能用在有後端伺服器的專案,純前端 SPA 不能使用" }, + { "id": "d", "text": "合理,因為 React Router 是 React 官方套件,跟 react/react-dom 一樣是必要依賴" } + ], + "answer": "a", + "explanation": "任何函式庫的引入都該對應一個真實需求,React Router 也不例外:它要解決的是『網址跟畫面內容同步』這個問題。只有一個畫面的應用程式(例如一個小工具、一個單頁表單)沒有這個需求,硬加進來只是多一層抽象跟一個外部依賴,之後真的長出第二個畫面時再導入完全來得及,不需要為了『以防萬一』預先背負複雜度。", + "verify": { + "checks": [], + "manual": "屬架構決策的情境推理題,取決於專案實際需求,無可執行程式碼。" + } + }, + { + "id": "ri-1-q8", + "type": "concept", + "difficulty": 2, + "topic": "history 函式庫 vs React Router:底層記錄 vs 畫面對應", + "docs": "", + "story": "", + "prompt": "React Router 內部其實是包了一層 history 這個函式庫。如果只是想在 React Native(沒有瀏覽器 window.history)裡管理『畫面堆疊』,直接學 history 這個底層函式庫、自己刻導航邏輯,還是直接用 React Router(或其對應版本),何者更合適?", + "code": "", + "options": [ + { "id": "a", "text": "直接用 React Router 更合適:history 函式庫本身只負責管理『一串位置紀錄』這個底層資料結構,並依環境提供 browser/hash/memory 三種實作(memory 版本正是給沒有瀏覽器 window.history 的環境用的,例如 React Native、Node.js 測試);React Router 在這之上,額外處理了『目前的位置該對應渲染哪個畫面元件』這一整層邏輯,這才是應用程式開發真正需要的,直接手刻在 history 之上重新做一遍等於重造輪子" }, + { "id": "b", "text": "直接用 history 更合適,React Router 沒辦法在沒有瀏覽器的環境使用" }, + { "id": "c", "text": "兩者功能完全一樣,選哪個都沒差" }, + { "id": "d", "text": "history 函式庫已經被棄用,任何情境都該直接使用 React Router 的內部 API" } + ], + "answer": "a", + "explanation": "history 只回答「使用者去過哪些位置、現在在哪」這個底層問題;「這個位置該渲染哪個畫面元件」這一層對應邏輯(也就是應用程式開發者真正每天在寫的東西)是 React Router 加上去的價值。這也是為什麼 React Router 能透過 memory history 支援 React Native——沒有瀏覽器不影響「位置紀錄」跟「畫面對應」這兩件事本身的運作方式。", + "verify": { + "checks": [], + "manual": "history/react-router 均未安裝於本專案,底層行為需要真實套件執行才能觀察,無法用 node 直接驗證。" + } + }, + { + "id": "ri-1-q9", + "type": "concept", + "difficulty": 1, + "topic": "push():像陣列一樣不斷疊加歷史紀錄", + "docs": "", + "story": "", + "prompt": "使用者依序造訪了 A → B(用 push)→ C(用 push)。這時候瀏覽器的歷史紀錄堆疊長什麼樣子,按幾次上一頁能回到 A?", + "code": "navigate(\"/b\"); // push,堆疊:[A, B]\nnavigate(\"/c\"); // push,堆疊:[A, B, C]", + "options": [ + { "id": "a", "text": "堆疊是 [A, B, C],目前在 C;按一次上一頁回到 B,再按一次回到 A——push 每次都是『在陣列尾端新增一筆』,不會覆蓋掉任何既有紀錄,所以造訪過的每個頁面都能透過上一頁逐步回溯" }, + { "id": "b", "text": "堆疊是 [C],因為 push 每次都會清空之前的紀錄,按一次上一頁就會離開這個網站" }, + { "id": "c", "text": "堆疊是 [A, C],B 被中間跳過,按一次上一頁直接回到 A" }, + { "id": "d", "text": "push 不會影響瀏覽器歷史紀錄,只有 replace 才會" } + ], + "answer": "a", + "explanation": "把 history 想像成一個只能往尾端新增、不能隨意刪改的陣列,是理解 push/replace 最直接的心智模型:push() 永遠是「在陣列尾端加一筆」,之前每一筆都完整保留;replace() 才是「把陣列最後一筆換成新的」,不會增加陣列長度。這裡全程都是 push,所以堆疊裡三個頁面都在,可以逐步用上一頁回溯到最早的 A。", + "verify": { + "checks": [], + "manual": "瀏覽器 history 堆疊的實際行為需要真實瀏覽器環境操作觀察,且依賴 react-router-dom(本專案未安裝),無法用 node 直接驗證。" + } + }, + { + "id": "ri-1-q10", + "type": "concept", + "difficulty": 2, + "topic": "v6 的 Routes 內建互斥比對,不再需要額外包 Switch", + "docs": "", + "story": "", + "prompt": "v5 若把多個 未經任何單一元素包裝、直接當成 (如 )的子元素排開,因為 規定只能接受一個子元素,官方會印出「Router may have only one child element」這類警告。這跟另一個獨立的問題——網址同時符合多個 Route 的 path 時,若沒有用 包住,這些符合的 Route 會全部一起渲染出來——是兩碼子事:只要用任一個元素(不限 )包起來就能讓「only one child」警告消失,但只有 才會讓比對變成互斥、只渲染第一個符合的。改用 v6 的 之後,還需要自己額外處理這兩個問題嗎?", + "code": "", + "options": [ + { "id": "a", "text": "不需要:v6 的 本身就只接受 作為子元素、天生保證互斥比對,同時符合多個 path 時也只會渲染最匹配的那一個;『子元素數量』與『只渲染第一個符合的』這兩件事都內建在 Routes 裡,不再需要像 v5 那樣額外包一層 才能解決" }, + { "id": "b", "text": "仍然需要,v6 只是把 Switch 改名成 Routes,行為完全一樣,一樣要手動確保只有一個包裝層" }, + { "id": "c", "text": "不需要,因為 v6 已經完全移除『只渲染一個 Route』這個概念,允許同時渲染所有符合的 Route" }, + { "id": "d", "text": "需要,但要改用 包住所有 Route 才能達到互斥效果" } + ], + "answer": "a", + "explanation": "v5 的「Router may have only one child element」警告,根源只是 規定子元素只能有一個——把多個 未經包裝直接排開就會觸發,用任何單一元素包起來(哪怕只是一個
)都能讓警告消失,這跟是不是用 沒有必然關係。但單純用
包起來並不會讓比對變成互斥:網址同時符合多個 的 path 時,這些 Route 依然會全部一起渲染,要避免這個問題必須明確用 包住,因為「只挑第一個符合的渲染」是 Switch 自己的比對邏輯,不是子元素數量規則附帶的效果。v6 把這兩件事直接內建進 Routes:Routes 本身就只接受 Route 作為子元素、也保證只渲染第一個符合的,因此拿掉了「忘記包 Switch」這整類錯誤,官方文件也不再提供獨立的 Switch 元件。", + "verify": { + "checks": [], + "manual": "react-router-dom 未安裝於本專案,Routes 的比對邏輯需要真實套件執行才能觀察,無法用 node 直接驗證。" + } + }, + { + "id": "ri-1-q11", + "type": "concept", + "difficulty": 2, + "topic": "用 navigate 的 state 選項傳遞資料,不出現在網址列", + "docs": "", + "story": "", + "prompt": "送出表單後想跳到 /result 頁面,同時把表單送出後拿到的 response.data 一併帶過去,不想放進網址的 query string 裡(避免資料暴露在網址列、或資料太大不適合塞進 URL)。v6 的 useNavigate 該怎麼寫?", + "code": "const navigate = useNavigate();\n\nfunction handleSubmit(response) {\n navigate(\"/result\", {\n state: { detail: response.data },\n });\n}\n\n// 在 /result 頁面元件裡\nfunction ResultPage() {\n const location = useLocation();\n console.log(location.state); // ?\n}", + "options": [ + { "id": "a", "text": "{ detail: response.data }:navigate() 的第二個參數可以帶一個 state 物件,這份資料會存在瀏覽器的 history entry 裡,不會出現在網址列上(不像 query string);目的頁面透過 useLocation().state 就能讀回這份資料,適合傳遞『不想被使用者看到網址、或不方便序列化進字串』的內容" }, + { "id": "b", "text": "undefined:state 只能在 v5 的 class component 裡透過 this.props.history 存取,v6 已經移除這個功能" }, + { "id": "c", "text": "response.data 會被自動轉成 query string 附加在網址後面" }, + { "id": "d", "text": "程式會拋出例外,navigate() 的第二個參數只能是字串" } + ], + "answer": "a", + "explanation": "這是 v5 「history.push({ pathname, state })」這種寫法在 v6 的對應版本:navigate 的第二個參數物件裡的 state 欄位,會原封不動存進這筆瀏覽器歷史紀錄,不出現在網址上,目的頁面用 useLocation().state 讀回。要注意這份資料存在『這筆 history entry』裡、不在網址裡,所以不會出現在分享出去的連結上,直接輸入網址造訪、開新分頁,或任何『重新產生一筆新 history entry』的情境,該筆新 entry 都不會帶有這份 state,讀到的會是 undefined;同一筆 entry 單純整頁重新整理時,state 是否還在則要看瀏覽器實作(多數現代瀏覽器會保留),不是「重新整理就一定會清掉」這種絕對規則。不適合放「必須可被分享連結、書籤或直接貼網址存取」的關鍵資料。", + "verify": { + "checks": [], + "manual": "react-router-dom 未安裝於本專案,navigate 的 state 傳遞需要真實路由 context 才能觀察,無法用 node 直接驗證。" + } + }, + { + "id": "ri-1-q12", + "type": "concept", + "difficulty": 3, + "topic": "元件樹之外觸發導頁:自訂 history 實例", + "docs": "", + "story": "", + "prompt": "一個放在 utils/api.js 的攔截器(不是 React 元件,是一般 JS 函式),偵測到 API 回傳 401 未授權時,想導頁到 /login。但 useNavigate() 是 Hook,只能在函式元件或自訂 Hook 內呼叫,這種『元件樹之外』的情境該怎麼觸發導頁?", + "code": "", + "options": [ + { "id": "a", "text": "自己建立並匯出一個共用的 history 實例(例如用 history 套件的 createBrowserHistory()),讓最上層改用能接受自訂 history 的 Router,app 裡任何地方(包括 utils/api.js 這種非元件檔案)都直接 import 這個共用 history、呼叫 history.push('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/login') 觸發導頁,不受 Hook『只能在元件內呼叫』的限制" }, + { "id": "b", "text": "在 utils/api.js 裡直接呼叫 useNavigate(),Hook 沒有這個限制,任何檔案都能用" }, + { "id": "c", "text": "沒有辦法,React Router 不支援在元件樹之外觸發任何導頁行為" }, + { "id": "d", "text": "用 window.location.reload() 就能達到跟 useNavigate 完全一樣的效果" } + ], + "answer": "a", + "explanation": "Hook 的『只能在元件內呼叫』限制,本質上是因為它依賴 React 內部的呼叫順序追蹤,跟一般 JS 函式的執行環境不相容。解法是把「誰負責記錄目前位置」這件事,從『只能透過 Hook 存取的 React 內部狀態』,換成『一個一般 JS 變數就能 import 的共用實例』——由這個共用 history 實例驅動 Router,App 裡任何檔案(不管是不是元件)都能直接操作它,等於把導頁能力從 React 樹狀結構裡解放出來。", + "verify": { + "checks": [], + "manual": "history/react-router 均未安裝於本專案,自訂 history 實例的路由行為需要真實套件執行才能觀察,無法用 node 直接驗證。" + } + } + ] +} diff --git a/packages/core/src/data/questions/ri-10-core-props-events.json b/packages/core/src/data/questions/ri-10-core-props-events.json new file mode 100644 index 0000000..f64a309 --- /dev/null +++ b/packages/core/src/data/questions/ri-10-core-props-events.json @@ -0,0 +1,170 @@ +{ + "id": "ri-10", + "title": "Core React:事件處理與受控元件", + "questions": [ + { + "id": "ri-10-q1", + "type": "predict-output", + "difficulty": 1, + "topic": "React 事件處理器接收的是 SyntheticEvent,不是原生事件", + "docs": "https://react.dev/learn/responding-to-events", + "story": "", + "prompt": "在原生 HTML 裡,事件處理器的 this 通常指向觸發事件的 DOM 節點;React 的事件命名也用駝峰式(onClick 而不是 onclick)。下面這段程式碼,e 這個參數實際上是什麼?", + "code": "function Button() {\n function handleClick(e) {\n console.log(e.type);\n }\n return ;\n}", + "options": [ + { "id": "a", "text": "e 是 React 包裝過的 SyntheticEvent(合成事件)物件,不是瀏覽器原生的 Event;它把不同瀏覽器的事件差異抹平,提供一致的跨瀏覽器介面,屬性與方法(如 e.type、e.preventDefault())用法上跟原生事件非常相似,但底層是 React 自己管理的物件" }, + { "id": "b", "text": "e 就是瀏覽器原生的 Event 物件,React 對事件完全沒有做任何包裝" }, + { "id": "c", "text": "e 永遠是 undefined,React 的事件處理器不會傳入任何參數" }, + { "id": "d", "text": "e 是一個字串,內容就是事件名稱本身" } + ], + "answer": "a", + "explanation": "React 的事件系統會把原生瀏覽器事件包裝成 SyntheticEvent,抹平跨瀏覽器的行為差異、統一介面,這也是為什麼 React 的事件命名(onClick)跟原生 HTML 屬性(onclick)看起來相似又不完全相同——底層機制其實不一樣,只是 API 設計得盡量貼近開發者對原生事件的直覺。", + "verify": { "checks": [], "manual": "SyntheticEvent 需要真實瀏覽器事件觸發才能觀察,無法用 node 直接驗證,屬事實性說明題。" } + }, + { + "id": "ri-10-q2", + "type": "concept", + "difficulty": 1, + "topic": "inline 條件表達式:用 && 或三元運算子取代 if/else 區塊", + "docs": "", + "story": "", + "prompt": "想在 JSX 裡「有未讀訊息才顯示紅點」,不想為了這一個小判斷另外抽出一個 if/else 區塊。下列哪種寫法最符合 JSX 慣例?", + "code": "function Badge({ unreadCount }) {\n return (\n
\n 收件匣\n {unreadCount > 0 && }\n
\n );\n}", + "options": [ + { "id": "a", "text": "用 && 運算子:JSX 的大括號裡可以放任意運算式,unreadCount > 0 && 這種寫法利用了 JS 的短路求值——條件為 false 時,&& 直接回傳 false(React 不會渲染 false/null/undefined,等於什麼都不顯示);條件為 true 時才會回傳並渲染右邊的 JSX,是 JSX 裡表達『有條件才顯示』最常見的慣例寫法" }, + { "id": "b", "text": "JSX 內部完全不能放任何條件邏輯,必須把整個元件拆成兩個各自完整的 return 語句" }, + { "id": "c", "text": "用 && 運算子時,如果 unreadCount 剛好是 0,畫面上會顯示字面上的數字 0" }, + { "id": "d", "text": "inline 條件判斷只能用在 class component,函式元件不支援" } + ], + "answer": "a", + "explanation": "JSX 大括號本質上就是嵌入一個 JavaScript 表達式,&&、三元運算子、立即執行函式都能用。要注意選項 c 提到的陷阱是真實存在的(如果條件寫成 unreadCount &&,當 unreadCount 是數字 0,&& 會回傳 0 而不是 false,React 真的會把字面上的 0 渲染出來)——這裡的寫法是 unreadCount > 0,比較結果一定是布林值,才能安全避開這個陷阱。", + "verify": { + "checks": [ + { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Badge({ unreadCount }) {\n return (\n
\n 收件匣\n {unreadCount > 0 && }\n
\n );\n}\nconsole.log(renderToStaticMarkup());\nconsole.log(renderToStaticMarkup());", "expected": "
收件匣
\n
收件匣
" } + ] + } + }, + { + "id": "ri-10-q3", + "type": "predict-output", + "difficulty": 2, + "topic": "陣列渲染缺少 key 時,React 印出的 console 警告", + "docs": "https://react.dev/learn/rendering-lists", + "story": "", + "prompt": "渲染一份清單時忘記加上 key。這段程式碼執行起來會怎麼樣?", + "code": "function List({ items }) {\n return (\n
    \n {items.map((item) =>
  • {item}
  • )}\n
\n );\n}", + "options": [ + { "id": "a", "text": "畫面依然會正常渲染出清單內容,不會拋出例外;但 React 會在 console 印出「Warning: Each child in a list should have a unique 'key' prop」這類警告——key 是用來幫助 React 在清單增刪、重新排序時,正確追蹤『哪個元素對應哪筆資料』,缺少 key 不會讓程式壞掉,但清單有動態增刪時容易出現渲染錯位、state 對應錯誤等難以察覺的 bug" }, + { "id": "b", "text": "程式會直接拋出例外,無法完成渲染" }, + { "id": "c", "text": "React 會自動幫每個項目補上一個隨機的 key,開發者不需要擔心任何問題" }, + { "id": "d", "text": "少了 key 只會影響 CSS 樣式,不影響任何 JavaScript 層面的行為" } + ], + "answer": "a", + "explanation": "key 不是一個必要到「沒有就會壞掉」的屬性,這也是它常被忽略的原因;但清單一旦牽涉到增刪、重新排序,React 靠 key 判斷「這個元素是不是同一個」,缺少它時 React 只能依賴陣列索引做猜測比對,容易出現 state 錯位(詳見既有 react-9 章節談的 key 與 state 對應關係)。這類問題往往要等清單真的動態變化才會現形,靜態清單看起來完全正常。", + "verify": { "checks": [], "manual": "console 警告訊息需要真實瀏覽器開發模式才會顯示,無法用 node -e 直接捕捉這類 React 內部警告輸出。" } + }, + { + "id": "ri-10-q4", + "type": "predict-output", + "difficulty": 2, + "topic": "受控元件:畫面上的值完全由 state 決定,不是使用者打字直接反映", + "docs": "https://react.dev/reference/react-dom/components/input", + "story": "", + "prompt": "這個輸入框的 value 被綁定到 state,onChange 卻故意把輸入轉成大寫再存回去。使用者實際打小寫 abc,畫面上的輸入框最終顯示什麼?", + "code": "function UppercaseInput() {\n const [value, setValue] = useState(\"\");\n return (\n setValue(e.target.value.toUpperCase())}\n />\n );\n}", + "options": [ + { "id": "a", "text": "顯示 ABC:這是「受控元件」的定義性特徵——輸入框畫面上顯示的內容,完全由 value 這個 prop(背後綁的是 state)決定,不是瀏覽器原生輸入行為直接反映在畫面上;每次使用者打字觸發 onChange,React 攔截原始輸入、轉成大寫存進 state,下一次渲染時 input 的 value 就是這個轉換後的大寫值,使用者會親眼看到自己打的字『被自動轉成大寫』" }, + { "id": "b", "text": "顯示 abc,因為瀏覽器原生的輸入行為不受 React state 控制" }, + { "id": "c", "text": "輸入框會完全無法輸入任何文字,因為 value 被綁定成 state 之後就變成唯讀" }, + { "id": "d", "text": "程式會拋出例外,value 屬性不能跟 onChange 一起使用" } + ], + "answer": "a", + "explanation": "「受控元件」意味著表單元素的顯示內容完全交給 React state 決定:使用者的輸入不是直接反映到畫面上,而是先觸發 onChange 事件、由開發者決定 state 該變成什麼、React 重新渲染後 input 的 value 才跟著更新。這個機制讓「即時轉大寫」「限制只能輸入數字」這類即時攔截與轉換輸入的功能變得直接可行。", + "verify": { "checks": [], "manual": "受控元件的輸入互動需要真實瀏覽器與使用者鍵盤事件才能觀察,無法用 node 直接驗證。" } + }, + { + "id": "ri-10-q5", + "type": "concept", + "difficulty": 2, + "topic": "非受控元件:用 ref 讀取,不必每個按鍵都觸發重繪", + "docs": "", + "story": "", + "prompt": "一個只有送出時才需要讀取一次值、過程中完全不需要即時驗證或轉換輸入內容的搜尋框,用「非受控元件」(defaultValue + ref)取代「受控元件」(value + onChange + state),有什麼實際好處?", + "code": "function SearchBox() {\n const inputRef = useRef(null);\n function handleSubmit() {\n console.log(inputRef.current.value); // 送出當下才讀一次\n }\n return (\n
\n \n \n
\n );\n}", + "options": [ + { "id": "a", "text": "使用者每次按鍵都不會觸發這個元件的 setState、也就不會觸發重繪;因為這個場景完全不需要即時知道、驗證或轉換輸入內容,讓瀏覽器原生自己管理輸入框的值、只在真正需要的那一刻(送出)用 ref 讀一次,省去『每個按鍵都要走一次 React 狀態更新與重繪』的開銷" }, + { "id": "b", "text": "非受控元件的效能永遠比受控元件差,因為多用了一個 ref" }, + { "id": "c", "text": "非受控元件無法搭配 defaultValue 設定初始值,只能是空字串" }, + { "id": "d", "text": "使用非受控元件之後,這個輸入框就無法再被使用者手動輸入文字" } + ], + "answer": "a", + "explanation": "受控元件的即時性(每個按鍵都能攔截、驗證、轉換)是有代價的:每次按鍵都要走一次 state 更新與重繪。當這種即時性沒有實際需求時(只在提交那一刻讀一次值),非受控元件讓瀏覽器自己管理輸入框的內部狀態,React 端完全不用參與每一次按鍵,是更輕量的選擇——這也是 React 官方文件裡明確建議的取捨判斷。", + "verify": { "checks": [], "manual": "受控 vs 非受控元件的重繪次數差異需要真實瀏覽器互動才能觀察,無法用 node 直接驗證。" } + }, + { + "id": "ri-10-q6", + "type": "predict-output", + "difficulty": 2, + "topic": "cloneElement:複製一個既有 element,同時覆寫部分 props", + "docs": "https://react.dev/reference/react/cloneElement", + "story": "", + "prompt": "createElement 是從零建立一個新 element;cloneElement 則是拿一個既有的 element 複製、同時覆寫部分 props。下面這段程式碼,最終渲染出的 className 會是什麼?", + "code": "const original =

Hello

;\nconst cloned = React.cloneElement(original, { className: \"new\" });\n\nconsole.log(renderToStaticMarkup(cloned));", + "options": [ + { "id": "a", "text": "

Hello

:cloneElement(element, newProps) 會用 newProps 裡的欄位覆蓋原本 element 的同名 props,沒有在 newProps 裡提到的 props(例如這裡的 children「Hello」)會照原樣保留;這常用在 Higher-Order Component 裡『不動子元件原本的內容,只想額外覆寫或補上某幾個 props』的情境" }, + { "id": "b", "text": "

Hello

,cloneElement 不會真的覆寫任何既有的 props" }, + { "id": "c", "text": "程式會拋出例外,cloneElement 不能修改已經建立好的 element 的 props" }, + { "id": "d", "text": "

,cloneElement 會清空原本 element 的所有子內容" } + ], + "answer": "a", + "explanation": "createElement 是從零開始描述一個全新的 element;cloneElement 則是「複製+局部覆寫」,其餘沒被覆寫的 props(包括 children)維持原樣。這在某些需要「攔截並增強子元件、卻不想重新宣告全部 props」的 HOC 或工具函式場景中很實用,但也因為隱含了對子元件 props 結構的假設,用多了容易讓程式碼難以追蹤資料從哪裡來,官方文件也提醒盡量減少對它的依賴。", + "verify": { + "checks": [ + { "jsx": "import React from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nconst original =

Hello

;\nconst cloned = React.cloneElement(original, { className: \"new\" });\nconsole.log(renderToStaticMarkup(cloned));", "expected": "

Hello

" } + ] + } + }, + { + "id": "ri-10-q7", + "type": "concept", + "difficulty": 2, + "topic": "Lifting State Up:兩個手足元件要共享同一份 state,往上移到共同父層", + "docs": "", + "story": "", + "prompt": "TemperatureInput(攝氏)與另一個 TemperatureInput(華氏)需要「其中一個改了,另一個要跟著同步換算顯示」,但兩者是平行的手足元件,沒有直接的父子關係可以傳 props。該怎麼設計?", + "code": "// 兩個手足元件各自管理自己的 state,彼此看不到對方 → 無法同步\n// 解法:把溫度這份 state 往上移到共同的父層\nfunction Calculator() {\n const [celsius, setCelsius] = useState(0);\n return (\n <>\n \n setCelsius((f - 32) * 5/9)} />\n \n );\n}", + "options": [ + { "id": "a", "text": "把這份需要共享的 state 往上移到兩者共同的父層(這裡是 Calculator),子層不再各自持有自己的一份 state,而是透過 props 接收目前的值、透過 props 傳入的回呼函式通知父層『我變了』;父層是唯一的真實來源(single source of truth),兩個手足元件的畫面都從同一個地方換算出來,自然保持同步" }, + { "id": "b", "text": "讓兩個手足元件直接互相 import 對方、直接呼叫對方元件內部的 setState" }, + { "id": "c", "text": "把其中一個元件的 state 直接複製一份貼到另一個元件裡,兩份各自獨立更新" }, + { "id": "d", "text": "這種情境沒有辦法解決,React 不支援手足元件之間的資料同步" } + ], + "answer": "a", + "explanation": "「Lifting State Up」是 React 官方文件裡處理『多個元件需要反映同一份、正在變化的資料』的標準模式:與其在多個地方各自維護一份容易不同步的 state,不如把這份 state 移到它們共同的最近父層,子層透過 props 讀取現況、透過父層傳入的回呼函式請求變更。這也是 Context、Redux 這類「集中管理共享狀態」方案背後的同一個核心原則,只是應用的範圍更大。", + "verify": { "checks": [], "manual": "屬狀態設計模式的推理題,牽涉多元件協調的完整互動情境,無法用單一 node 執行片段驗證。" } + }, + { + "id": "ri-10-q8", + "type": "concept", + "difficulty": 1, + "topic": "state 跟 props 都變動時,畫面該以哪個為準", + "docs": "", + "story": "", + "prompt": "一個顯示使用者名稱的元件,同時吃 props.initialName(父層傳入的初始值)跟自己的 state(使用者可以在畫面上編輯暱稱、暫存在 state 裡,尚未送出儲存)。使用者編輯過暱稱後,父層重新渲染、又傳入一次一模一樣的 initialName,畫面該顯示使用者編輯過的內容,還是父層傳入的原始值?", + "code": "function NicknameEditor({ initialName }) {\n const [name, setName] = useState(initialName); // 只在第一次渲染生效\n return setName(e.target.value)} />;\n}", + "options": [ + { "id": "a", "text": "應該顯示使用者編輯過的內容:useState(initialName) 只有在元件第一次渲染時會拿 initialName 當初始值,之後即使父層重新傳入同樣(或不同)的 initialName,也不會覆蓋掉使用者後續在畫面上編輯的 state;state 一旦建立,就是這個元件自己的、獨立於 props 之後變化的記憶,這正是「使用者的編輯內容不該被父層的重新渲染打斷」這個常見需求背後的機制" }, + { "id": "b", "text": "應該顯示父層傳入的原始值,因為 props 的優先權永遠高於 state" }, + { "id": "c", "text": "兩者會自動合併成一個新字串顯示" }, + { "id": "d", "text": "程式會拋出例外,同一個元件不能同時使用 props 與 state" } + ], + "answer": "a", + "explanation": "useState(initialValue) 的 initialValue 參數只在元件的第一次渲染生效,這跟 useRef 的初始值行為一致(見既有 react-11 章節)。這個特性剛好符合這裡的需求:使用者一旦開始編輯,這份「使用者輸入」的 state 就該自己獨立存在,不因為父層重新渲染、重新傳入同樣的初始值而被打斷或覆蓋——這也是分辨『初始值』與『目前值』兩種不同概念的具體案例。", + "verify": { + "checks": [ + { "jsx": "import { useState } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nfunction NicknameEditor({ initialName }) {\n const [name] = useState(initialName);\n return ;\n}\nconsole.log(renderToStaticMarkup());", "expected": "" } + ] + } + } + ] +} diff --git a/packages/core/src/data/questions/ri-11-core-vdom.json b/packages/core/src/data/questions/ri-11-core-vdom.json new file mode 100644 index 0000000..16f69c5 --- /dev/null +++ b/packages/core/src/data/questions/ri-11-core-vdom.json @@ -0,0 +1,162 @@ +{ + "id": "ri-11", + "title": "Core React:Virtual DOM 與渲染機制", + "questions": [ + { + "id": "ri-11-q1", + "type": "concept", + "difficulty": 1, + "topic": "Virtual DOM:先在記憶體裡算好差異,再一次套用到真實 DOM", + "docs": "", + "story": "", + "prompt": "一份清單裡只有第 3 項的文字變了,其餘 99 項完全沒變。React 為什麼不是直接把整個清單的真實 DOM 全部砍掉重建,而是只更新第 3 項?", + "code": "", + "options": [ + { "id": "a", "text": "React 在記憶體裡維護一份輕量的 Virtual DOM(用普通 JS 物件描述畫面),每次 state 改變時,會先在記憶體裡算出新舊兩份 Virtual DOM 的差異(diff),再把差異部分對應到真實 DOM 上做最小幅度的更新;直接操作真實 DOM(重排、重繪)比操作記憶體中的普通物件昂貴得多,只更新真正變化的部分能大幅減少不必要的瀏覽器工作量" }, + { "id": "b", "text": "React 每次都會把整個畫面的真實 DOM 完全砍掉重建,Virtual DOM 只是行銷術語,沒有實質作用" }, + { "id": "c", "text": "Virtual DOM 是瀏覽器原生提供的 API,React 只是呼叫瀏覽器內建的差異比對功能" }, + { "id": "d", "text": "Virtual DOM 只在 React Native 環境使用,網頁版 React 不會用到這個機制" } + ], + "answer": "a", + "explanation": "「操作記憶體中的物件」遠比「操作真實 DOM」便宜,這是 Virtual DOM 存在的根本理由。React 靠比較新舊兩棵 Virtual DOM 樹(reconciliation),精準算出「哪些節點真的需要變」,再只把這些必要的變更套用到真實 DOM,避免了『整個畫面重新產生』這種昂貴又沒必要的操作。", + "verify": { "checks": [], "manual": "屬渲染機制的概念說明題,無可執行程式碼佐證內部差異比對演算法。" } + }, + { + "id": "ri-11-q2", + "type": "concept", + "difficulty": 2, + "topic": "Virtual DOM 的運作三步驟:渲染、比較、提交", + "docs": "", + "story": "", + "prompt": "state 改變後,React 從『使用者看到畫面變化』回推,大致經過哪幾個階段?", + "code": "", + "options": [ + { "id": "a", "text": "①渲染階段:呼叫元件函式,算出一棵新的 Virtual DOM 樹;②比較階段(diffing):把這棵新樹跟上一次渲染留下的舊樹逐層比對,找出真正變化的部分;③提交階段(commit):只把比對出來的差異套用到真實 DOM 上,並執行對應的副作用(如 useEffect)。這三個階段合稱 reconciliation(協調)" }, + { "id": "b", "text": "React 只有一個階段:state 一改就立刻同步、逐一直接操作真實 DOM,中間沒有任何比較步驟" }, + { "id": "c", "text": "React 會把整個瀏覽器分頁重新整理一次,才能反映 state 的變化" }, + { "id": "d", "text": "這三個階段的順序是隨機的,每次執行都可能不一樣" } + ], + "answer": "a", + "explanation": "把「渲染」(算出新樹)、「比較」(跟舊樹 diff)、「提交」(真正動手改 DOM)拆成三個階段,是理解 React 內部運作、也是理解 useEffect(提交後才執行)跟 useLayoutEffect(提交後、瀏覽器繪製前)為什麼會有時序差異的基礎。三個階段的順序是固定的,不是隨機的。", + "verify": { "checks": [], "manual": "屬 React 內部渲染流程的說明題,需要原始碼層級理解,無法用單一可執行片段驗證。" } + }, + { + "id": "ri-11-q3", + "type": "concept", + "difficulty": 2, + "topic": "Shadow DOM 解決樣式封裝問題,Virtual DOM 解決渲染效能問題——兩者目標不同", + "docs": "", + "story": "", + "prompt": "有人把 Shadow DOM(瀏覽器原生的 Web Components 標準之一)跟 Virtual DOM(React 的渲染機制)搞混,覺得兩者是同一種技術的不同稱呼。這樣理解正確嗎?", + "code": "", + "options": [ + { "id": "a", "text": "不正確:Shadow DOM 是瀏覽器原生標準,目的是讓自訂元素擁有『封裝的』DOM 子樹與樣式(外部 CSS 選擇器打不進去,內部樣式也不會外洩),解決的是樣式與 DOM 結構的封裝隔離問題;Virtual DOM 是 React(與其他框架)自己在應用層實作的機制,目的是用記憶體中的物件比對減少真實 DOM 操作,解決的是渲染效能問題——兩者的目標、實作層級完全不同,只是名字都有『DOM』與『某種抽象層』的意味" }, + { "id": "b", "text": "正確,Shadow DOM 與 Virtual DOM 是同一項瀏覽器標準技術的兩種不同稱呼" }, + { "id": "c", "text": "不正確,因為 Shadow DOM 是 React 專屬技術,其他框架不能使用" }, + { "id": "d", "text": "不正確,因為 Virtual DOM 是瀏覽器原生提供的標準 API" } + ], + "answer": "a", + "explanation": "這是一組經典的名詞混淆:Shadow DOM 是瀏覽器規範,解決樣式/DOM 封裝隔離(例如