From 7a77b51a7751493730fe4908dfbcba3722e33446 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Sat, 18 Jul 2026 20:58:16 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=87=BD=E6=95=B8?= =?UTF-8?q?=E5=BC=8F=E6=80=9D=E8=80=83=E9=A1=8C=E5=BA=AB=E7=A8=8B=E5=BC=8F?= =?UTF-8?q?=E7=A2=BC=E7=89=87=E6=AE=B5=E6=9C=AA=E7=94=A8=20code=20block=20?= =?UTF-8?q?=E9=A1=AF=E7=A4=BA=E7=9A=84=E5=95=8F=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 部分 fp-* 題目把程式碼直接寫進 prompt/選項文字,只能當純文字擠成一行; 改為統一搬進既有的 code 欄位(QuestionOption 新增選填 code 欄位), 交給 CodeBlock 元件做語法上色渲染。 Co-Authored-By: Claude Sonnet 5 --- apps/mobile/components/CodeBlock.tsx | 8 +++++++- apps/mobile/components/QuestionCard.tsx | 1 + apps/mobile/screens/QuestionReview.tsx | 1 + apps/web/src/components/QuestionCard.tsx | 1 + apps/web/src/components/QuestionReview.tsx | 3 ++- apps/web/src/index.css | 5 +++++ docs/question-format.md | 12 ++++++------ packages/core/src/data/questions/fp-1-welcome.json | 4 ++-- .../questions/fp-10-first-class-functions-1.json | 6 +++--- packages/core/src/data/questions/fp-13-chaining.json | 4 ++-- .../core/src/data/questions/fp-14-nested-data.json | 4 ++-- .../src/data/questions/fp-15-timeline-diagrams.json | 10 +++++----- .../questions/fp-3-actions-calculations-data.json | 12 ++++++------ .../data/questions/fp-4-extract-calculations.json | 12 ++++++------ .../src/data/questions/fp-5-improve-actions.json | 6 +++--- .../core/src/data/questions/fp-6-copy-on-write.json | 8 ++++---- .../src/data/questions/fp-7-defensive-copying.json | 2 +- packages/core/src/types.ts | 1 + 18 files changed, 58 insertions(+), 42 deletions(-) diff --git a/apps/mobile/components/CodeBlock.tsx b/apps/mobile/components/CodeBlock.tsx index b53a5db..47cf54c 100644 --- a/apps/mobile/components/CodeBlock.tsx +++ b/apps/mobile/components/CodeBlock.tsx @@ -5,6 +5,9 @@ import { colors, fonts } from '@/constants/theme'; interface CodeBlockProps { code: string; + // 選項按鈕(Pressable)內部不能放 ScrollView,iOS 上 touchable 會搶走捲動手勢, + // 這種情境改用 scroll={false} 讓長行直接換行,不強制水平捲動 + scroll?: boolean; } // 對照 apps/web CodeBlock.tsx 的輕量語法上色:註解、字串、關鍵字、數字(同一份 regex) @@ -39,8 +42,11 @@ const highlight = (code: string): ReactNode[] => { return last < code.length ? [...parts, code.slice(last)] : parts; }; -export default function CodeBlock({ code }: CodeBlockProps) { +export default function CodeBlock({ code, scroll = true }: CodeBlockProps) { if (!code) return null; + if (!scroll) { + return {highlight(code)}; + } return ( {highlight(code)} diff --git a/apps/mobile/components/QuestionCard.tsx b/apps/mobile/components/QuestionCard.tsx index 32413fb..23a8b25 100644 --- a/apps/mobile/components/QuestionCard.tsx +++ b/apps/mobile/components/QuestionCard.tsx @@ -74,6 +74,7 @@ export default function QuestionCard({ answered && !isAnswer && !isPicked && styles.optionDimmed, ]} > + {opt.code && } + {opt.code && } {opt.text} ); diff --git a/apps/web/src/components/QuestionCard.tsx b/apps/web/src/components/QuestionCard.tsx index 760631f..63a3d2a 100644 --- a/apps/web/src/components/QuestionCard.tsx +++ b/apps/web/src/components/QuestionCard.tsx @@ -62,6 +62,7 @@ const QuestionCard = ({ disabled={answered} onClick={() => onSelect(opt.id)} > + {opt.code && } {opt.text} ) diff --git a/apps/web/src/components/QuestionReview.tsx b/apps/web/src/components/QuestionReview.tsx index 3463cfa..7b2d93d 100644 --- a/apps/web/src/components/QuestionReview.tsx +++ b/apps/web/src/components/QuestionReview.tsx @@ -38,7 +38,7 @@ const QuestionReview = ({ question, saved, onToggleSave, meta }: QuestionReviewP )}

{question.prompt}

- + {question.code && }
{question.options.map((opt) => ( @@ -46,6 +46,7 @@ const QuestionReview = ({ question, saved, onToggleSave, meta }: QuestionReviewP key={opt.id} className={`option-btn is-static ${opt.id === question.answer ? 'is-correct' : 'is-dimmed'}`} > + {opt.code && } {opt.text}
))} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ad7ae13..966393b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -939,6 +939,11 @@ button { word-break: break-all; } +.option-btn .code-block { + margin: 0 0 8px; + text-align: left; +} + .option-btn:not(:disabled):hover { border-color: var(--primary); } diff --git a/docs/question-format.md b/docs/question-format.md index 748c582..56a4aa4 100644 --- a/docs/question-format.md +++ b/docs/question-format.md @@ -24,16 +24,16 @@ | 欄位 | 必填 | 說明 | |:--|:--|:--| | `id` | ✅ | 題目唯一代號,格式 `{關卡id}-q{序號}` | -| `type` | ✅ | 題型:`predict-output`(預測輸出)、`find-bug`(抓 bug)、`same-or-not`(改壞了嗎)、`fill-in`(微量手寫填空) | +| `type` | ✅ | 題型:`predict-output`(預測輸出)、`find-bug`(抓 bug)、`same-or-not`(改壞了嗎)、`fill-in`(微量手寫填空)、`concept`(純概念題,無需執行驗證) | | `difficulty` | ✅ | 1–3,同一關內由易到難排列 | | `topic` | ✅ | 知識點名稱(顯示用) | -| `docs` | ✅ | 對應的 MDN / react.dev 文件連結(答題後顯示「延伸閱讀」) | +| `docs` | ✅ | 對應的 MDN / react.dev 文件連結(答題後顯示「延伸閱讀」),`concept` 題若無明確對應文件可留空字串 | | `story` | ✅ | 劇情包裝文字,MVP 一律空字串 `""`(二期職場敘事預留欄位) | -| `prompt` | ✅ | 題目問句 | -| `code` | ✅ | 展示給學員看的程式碼;`fill-in` 題用 `____` 標記空格 | -| `options` | ✅ | 選項陣列 `[{ "id": "a", "text": "..." }]`,2–4 個 | +| `prompt` | ✅ | **只放題目問句**,不放程式碼;即使要比較「版本一/版本二」兩段程式碼,也一律放進 `code` 欄位,`prompt` 只問「這兩者的差異說明了什麼」 | +| `code` | ✅ | 展示給學員看的程式碼;`fill-in` 題用 `____` 標記空格;比較多個版本時用 `// 版本一` / `// 版本二` 註解分隔(同一 `code` 欄位可多行),沒有程式碼可展示的 `concept` 題留空字串 `""` | +| `options` | ✅ | 選項陣列 `[{ "id": "a", "text": "...", "code": "..." }]`,2–4 個;`code` 為選填,只有當這個選項本身是一段完整程式碼(例如「哪一種重構寫法正確」)時才用,`text` 留給簡短說明,不要把整段函式定義塞進 `text` 字串 | | `answer` | ✅ | 正確選項的 id | -| `explanation` | ✅ | 白話解釋——先講「發生了什麼」,再講規則;語氣鼓勵、不說教 | +| `explanation` | ✅ | 白話解釋——先講「發生了什麼」,再講規則;語氣鼓勵、不說教;用文字描述程式碼行為,不要在 `explanation` 裡內嵌完整程式碼(沒有對應的 code 欄位可以承載,會被當純文字擠成一行) | | `verify` | ✅ | 自動驗證設定,見下 | ## verify 欄位(自動驗證) diff --git a/packages/core/src/data/questions/fp-1-welcome.json b/packages/core/src/data/questions/fp-1-welcome.json index 2291354..86dffb1 100644 --- a/packages/core/src/data/questions/fp-1-welcome.json +++ b/packages/core/src/data/questions/fp-1-welcome.json @@ -9,8 +9,8 @@ "topic": "本書對 FP 的定義", "docs": "", "story": "", - "prompt": "同樣是「新用戶註冊後寄送歡迎信」,有兩種寫法:\n版本一:function onSignup(user) { sendWelcomeEmail(user); logAnalytics(user); saveToDb(user); } // 判斷內容、寄信、記錄全部寫在一起\n版本二:function buildWelcomeEmail(user) { return { to: user.email, subject: '歡迎加入' }; } // 只負責算出信件內容,不寄信\n function onSignup(user) { var email = buildWelcomeEmail(user); sendEmail(email); saveToDb(user); }\n根據這兩種寫法的差異,哪一個最符合本書對「函數式程式設計」的核心主張?", - "code": "", + "prompt": "同樣是「新用戶註冊後寄送歡迎信」,有兩種寫法,根據這兩種寫法的差異,哪一個最符合本書對「函數式程式設計」的核心主張?", + "code": "// 版本一\nfunction onSignup(user) {\n sendWelcomeEmail(user);\n logAnalytics(user);\n saveToDb(user);\n} // 判斷內容、寄信、記錄全部寫在一起\n\n// 版本二\nfunction buildWelcomeEmail(user) {\n return { to: user.email, subject: '歡迎加入' };\n} // 只負責算出信件內容,不寄信\nfunction onSignup(user) {\n var email = buildWelcomeEmail(user);\n sendEmail(email);\n saveToDb(user);\n}", "options": [ { "id": "a", "text": "版本二把「決定信件內容」這種可以獨立驗證、不需要真的寄信就能檢查對不對的部分,跟「寄信」這種一定會發生副作用的動作分開;FP 的重點不是把 sendEmail 也消滅掉,而是把可以獨立出來的邏輯跟不可避免的副作用分辨清楚,讓副作用的範圍縮到最小" }, { "id": "b", "text": "版本一比較符合 FP,因為所有邏輯集中在同一個函式裡,程式碼比較短" }, diff --git a/packages/core/src/data/questions/fp-10-first-class-functions-1.json b/packages/core/src/data/questions/fp-10-first-class-functions-1.json index 5404b00..d416ee1 100644 --- a/packages/core/src/data/questions/fp-10-first-class-functions-1.json +++ b/packages/core/src/data/questions/fp-10-first-class-functions-1.json @@ -50,7 +50,7 @@ "prompt": "setPriceByName、setQuantityByName、setShippingByName 這三個高度相似的函式,哪一種重構正確地合併成一個 setFieldByName?", "code": "function setPriceByName(cart, name, price) { /* ... */ }\nfunction setQuantityByName(cart, name, quantity) { /* ... */ }\nfunction setShippingByName(cart, name, shipping) { /* ... */ }", "options": [ - { "id": "a", "text": "function setFieldByName(cart, name, field, value) { var i = cart.findIndex(item => item.name === name); var copy = cart.slice(); copy[i] = { ...copy[i], [field]: value }; return copy; }——呼叫端改成 setFieldByName(cart, \"shirt\", \"price\", 13),一個函式取代三個重複的函式" }, + { "id": "a", "text": "呼叫端改成 setFieldByName(cart, \"shirt\", \"price\", 13),一個函式取代三個重複的函式", "code": "function setFieldByName(cart, name, field, value) {\n var i = cart.findIndex(item => item.name === name);\n var copy = cart.slice();\n copy[i] = { ...copy[i], [field]: value };\n return copy;\n}" }, { "id": "b", "text": "把三個函式合併成 setAllFields(cart, name, price, quantity, shipping),強迫每次呼叫都要傳齊所有欄位" }, { "id": "c", "text": "保留三個函式不變,只是讓它們共用同一個變數名稱" }, { "id": "d", "text": "用一個全域變數紀錄目前要改哪個欄位,三個函式都改成讀這個全域變數" } @@ -66,8 +66,8 @@ "topic": "高階函式的能力", "docs": "", "story": "", - "prompt": "myArray.map(function (x) { return x * 2; }) 這行程式裡,map 完全不知道要怎麼「乘以 2」,這個邏輯是呼叫端用參數傳進去的;但如果自己寫一個 function doubleAll(array) { return array.map(function (x) { return x * 2; }); },doubleAll 就只能做「乘以 2」這一件事。map 和 doubleAll 的差異說明了高階函式的什麼能力?", - "code": "", + "prompt": "map 完全不知道要怎麼「乘以 2」,這個邏輯是呼叫端用參數傳進去的;但如果自己寫一個 doubleAll,它就只能做「乘以 2」這一件事。map 和 doubleAll 的差異說明了高階函式的什麼能力?", + "code": "myArray.map(function (x) { return x * 2; });\n\n// 自己寫一個只能做這一件事的函式\nfunction doubleAll(array) {\n return array.map(function (x) { return x * 2; });\n}", "options": [ { "id": "a", "text": "map 是高階函式(接受函式當參數),它能把「要走訪陣列」這個固定的流程,跟「每個元素要怎麼被轉換」這個可變的邏輯分開;doubleAll 把兩者寫死在一起,只能做一件事——高階函式讓同一段控制流程可以套用在無限多種不同的具體邏輯上,這是只能操作資料的一般函式做不到的" }, { "id": "b", "text": "map 跟 doubleAll 其實完全等價,只是寫法不同,沒有能力上的差異" }, diff --git a/packages/core/src/data/questions/fp-13-chaining.json b/packages/core/src/data/questions/fp-13-chaining.json index 719795c..aa7ee74 100644 --- a/packages/core/src/data/questions/fp-13-chaining.json +++ b/packages/core/src/data/questions/fp-13-chaining.json @@ -9,8 +9,8 @@ "topic": "鏈式呼叫的可讀性", "docs": "", "story": "", - "prompt": "比較兩種寫法,都是「找出下單次數超過 5 次的顧客 email,取前 3 名」:\n版本一:customers.filter(c => c.orders.length > 5).map(c => c.email).slice(0, 3)\n版本二:const frequentBuyers = customers.filter(c => c.orders.length > 5); const emails = frequentBuyers.map(c => c.email); const top3 = emails.slice(0, 3);\n步驟只有 3 步時兩者都好讀;如果串連的步驟變成 6、7 步呢?", - "code": "", + "prompt": "比較兩種寫法,都是「找出下單次數超過 5 次的顧客 email,取前 3 名」。步驟只有 3 步時兩者都好讀;如果串連的步驟變成 6、7 步呢?", + "code": "// 版本一\ncustomers.filter(c => c.orders.length > 5).map(c => c.email).slice(0, 3);\n\n// 版本二\nconst frequentBuyers = customers.filter(c => c.orders.length > 5);\nconst emails = frequentBuyers.map(c => c.email);\nconst top3 = emails.slice(0, 3);", "options": [ { "id": "a", "text": "步驟一多,版本一那種一路串到底的寫法會讓人很難一眼看出「這一步做完之後,資料變成了什麼形狀」;版本二把每個中間結果存進有意義名稱的變數,等於用變數名稱標註了每一步的產出,步驟越多,這種寫法的可讀性優勢越明顯" }, { "id": "b", "text": "不管幾個步驟,版本一永遠比版本二好讀,因為程式碼行數比較少" }, diff --git a/packages/core/src/data/questions/fp-14-nested-data.json b/packages/core/src/data/questions/fp-14-nested-data.json index 27c2f86..3191db3 100644 --- a/packages/core/src/data/questions/fp-14-nested-data.json +++ b/packages/core/src/data/questions/fp-14-nested-data.json @@ -97,8 +97,8 @@ "topic": "遞迴的三個安全守則", "docs": "", "story": "", - "prompt": "這段程式碼會無限遞迴、把呼叫堆疊塞爆:\nfunction badNestedUpdate(object, keys, modify) {\n var key1 = keys[0];\n var restOfKeys = keys.slice(1);\n return update(object, key1, function (value1) {\n return badNestedUpdate(value1, restOfKeys, modify);\n });\n}\n對照正確版本會多一行 if (keys.length === 0) return modify(object);,這段程式碼缺了遞迴的哪個安全守則?", - "code": "", + "prompt": "這段程式碼會無限遞迴、把呼叫堆疊塞爆。對照正確版本會多一行 if (keys.length === 0) return modify(object);,這段程式碼缺了遞迴的哪個安全守則?", + "code": "function badNestedUpdate(object, keys, modify) {\n var key1 = keys[0];\n var restOfKeys = keys.slice(1);\n return update(object, key1, function (value1) {\n return badNestedUpdate(value1, restOfKeys, modify);\n });\n}", "options": [ { "id": "a", "text": "缺了「基本情況(base case)」——沒有檢查 keys 是不是已經空了就直接停止遞迴,所以就算 restOfKeys 每次都用 slice(1) 縮短,程式還是會不斷往下呼叫,永遠碰不到終止條件,最終讓呼叫堆疊爆掉" }, { "id": "b", "text": "缺了「遞迴呼叫」,因為程式碼裡完全沒有呼叫 badNestedUpdate 自己" }, diff --git a/packages/core/src/data/questions/fp-15-timeline-diagrams.json b/packages/core/src/data/questions/fp-15-timeline-diagrams.json index b0c2721..f93b886 100644 --- a/packages/core/src/data/questions/fp-15-timeline-diagrams.json +++ b/packages/core/src/data/questions/fp-15-timeline-diagrams.json @@ -9,8 +9,8 @@ "topic": "時間線圖的兩條基本規則", "docs": "", "story": "", - "prompt": "這段程式:\nconsole.log('A');\nsetTimeout(() => console.log('B'), 0);\nconsole.log('C');\n實際輸出順序是 A、C、B,不是照撰寫順序的 A、B、C。這個結果符合時間線圖的哪一條規則?", - "code": "", + "prompt": "這段程式實際輸出順序是 A、C、B,不是照撰寫順序的 A、B、C。這個結果符合時間線圖的哪一條規則?", + "code": "console.log('A');\nsetTimeout(() => console.log('B'), 0);\nconsole.log('C');", "options": [ { "id": "a", "text": "console.log('A') 和 console.log('C') 在同一條時間線(主程式的同步執行)上,一定照程式碼順序執行;setTimeout 的 callback(印 B)屬於另一條時間線,即使寫在 C 前面,仍然要等主程式這條時間線執行完才輪到它——這正是「同一條時間線內順序固定,不同時間線之間順序不保證跟撰寫順序一致」的具體體現" }, { "id": "b", "text": "這個結果代表 JavaScript 引擎的執行順序是隨機的,跟時間線圖的規則無關" }, @@ -32,8 +32,8 @@ "topic": "什麼會建立新的時間線", "docs": "", "story": "", - "prompt": "下列四段程式碼,哪一段會開啟一條新的時間線?\n① const x = a + b;\n② button.addEventListener('click', handleClick);\n③ for (let i = 0; i < 10; i++) { total += i; }\n④ fetch('/api/data').then(handleResponse);", - "code": "", + "prompt": "下列四段程式碼,哪一段會開啟一條新的時間線?", + "code": "① const x = a + b;\n② button.addEventListener('click', handleClick);\n③ for (let i = 0; i < 10; i++) { total += i; }\n④ fetch('/api/data').then(handleResponse);", "options": [ { "id": "a", "text": "② 和 ④ 會開啟新的時間線——② 註冊的 click handler 要等使用者實際點擊時才會在不確定的時間點執行;④ 的 .then(handleResponse) 要等網路請求回來才會執行,兩者的執行時機都脫離了目前這段程式碼的線性掌控。① 和 ③ 都是會立刻、同步跑完的計算,屬於呼叫它們那條時間線的一部分" }, { "id": "b", "text": "只有 ③ 的 for 迴圈會開啟新的時間線,因為它會重複執行多次" }, @@ -111,7 +111,7 @@ "prompt": "下列 handler 用全域變數共享狀態。哪一種重構正確地減少了時間線之間的共享資源?", "code": "var total = 0;\nfunction add_item_to_cart(item) {\n cart = add_item(cart, item);\n update_total_queue(cart);\n}", "options": [ - { "id": "a", "text": "function add_item_to_cart(cart, item) { var newCart = add_item(cart, item); var total = calc_total(newCart); return { cart: newCart, total }; }——不再讀寫任何全域的 cart 或 total,兩條同時觸發的時間線各自拿到自己的回傳值,不會互相干擾對方的計算過程" }, + { "id": "a", "text": "不再讀寫任何全域的 cart 或 total,兩條同時觸發的時間線各自拿到自己的回傳值,不會互相干擾對方的計算過程", "code": "function add_item_to_cart(cart, item) {\n var newCart = add_item(cart, item);\n var total = calc_total(newCart);\n return { cart: newCart, total };\n}" }, { "id": "b", "text": "把 var total = 0 改成 let total = 0,其餘完全不變,就能解決交錯問題" }, { "id": "c", "text": "把整個函式包進 setTimeout(fn, 0) 裡,藉此避免交錯" }, { "id": "d", "text": "把 cart 改成存進 localStorage,其餘邏輯完全不變" } 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 5490409..d3565f2 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 @@ -9,8 +9,8 @@ "topic": "三者的定義與範例", "docs": "", "story": "", - "prompt": "看以下三行程式:\n① const price = 100;\n② function addTax(price) { return price * 1.05; }\n③ fetch('/api/order', { method: 'POST', body: cart });\n下列哪一組分類正確?", - "code": "", + "prompt": "看以下三行程式,下列哪一組分類正確?", + "code": "① const price = 100;\n② function addTax(price) { return price * 1.05; }\n③ fetch('/api/order', { method: 'POST', body: cart });", "options": [ { "id": "a", "text": "① 是 Data(單純的事實紀錄,本身不執行任何邏輯);② 是 Calculation(輸入到輸出的純運算,沒有依賴或影響任何外部狀態);③ 是 Action(呼叫時機、次數都會影響外部世界,發出真正的網路請求)" }, { "id": "b", "text": "① 是 Action,因為它是一個賦值敘述;② 是 Data,因為它回傳一個數字;③ 是 Calculation,因為它是一個函式呼叫" }, @@ -47,8 +47,8 @@ "topic": "Action 會傳染", "docs": "", "story": "", - "prompt": "下面這個函式呼叫了 sendReceipt()(一個會實際寄信的 Action):\nfunction finalizeOrder(order) {\n var total = calcTotal(order);\n sendReceipt(order, total);\n return total;\n}\n如果你想幫 finalizeOrder 寫單元測試、又不想真的觸發寄信服務,你會遇到什麼困難?這說明了「Action 會傳染」的什麼意思?", - "code": "", + "prompt": "下面這個函式呼叫了 sendReceipt()(一個會實際寄信的 Action)。如果你想幫 finalizeOrder 寫單元測試、又不想真的觸發寄信服務,你會遇到什麼困難?這說明了「Action 會傳染」的什麼意思?", + "code": "function finalizeOrder(order) {\n var total = calcTotal(order);\n sendReceipt(order, total);\n return total;\n}", "options": [ { "id": "a", "text": "只要 finalizeOrder 內部呼叫了 sendReceipt,測試 finalizeOrder 就無法避開這個副作用(除非額外做 mock),因為 finalizeOrder 的整體行為已經跟 sendReceipt 的呼叫時機、次數綁在一起——這就是「Action 會傳染」:呼叫了 Action 的函式,自己也變成難以脫離副作用單獨驗證的 Action" }, { "id": "b", "text": "完全不會遇到任何困難,因為 calcTotal 是純函式,所以整個 finalizeOrder 也自動變成純函式" }, @@ -85,8 +85,8 @@ "topic": "Data > Calculation > Action 的偏好順序", "docs": "", "story": "", - "prompt": "三種寫法都能得到「打完折的金額」:\n① const price = 90;(直接寫死數字)\n② function calcDiscounted(price) { return price * 0.9; }\n③ function applyDiscountAndSave(cart) { cart.total *= 0.9; saveToDb(cart); }\n如果要幫這三種寫法各寫一份測試,哪一種最容易、哪一種最難?這說明了為什麼要偏好 Data > Calculation > Action?", - "code": "", + "prompt": "三種寫法都能得到「打完折的金額」。如果要幫這三種寫法各寫一份測試,哪一種最容易、哪一種最難?這說明了為什麼要偏好 Data > Calculation > Action?", + "code": "① const price = 90; // 直接寫死數字\n② function calcDiscounted(price) { return price * 0.9; }\n③ function applyDiscountAndSave(cart) { cart.total *= 0.9; saveToDb(cart); }", "options": [ { "id": "a", "text": "① 最容易(本身就是靜態值,不用執行任何東西就能檢視對不對);② 次之(純函式,給定輸入直接斷言輸出即可);③ 最難(要準備假的資料庫、還要驗證外部系統真的被呼叫過)——這正是為什麼能用前者解決就不要升級到後者,測試與推理的難度依序遞增" }, { "id": "b", "text": "三者的測試難度完全一樣,跟屬於 Data、Calculation 還是 Action 沒有關係" }, diff --git a/packages/core/src/data/questions/fp-4-extract-calculations.json b/packages/core/src/data/questions/fp-4-extract-calculations.json index 46e94ee..6abdd0c 100644 --- a/packages/core/src/data/questions/fp-4-extract-calculations.json +++ b/packages/core/src/data/questions/fp-4-extract-calculations.json @@ -9,8 +9,8 @@ "topic": "隱性輸入與隱性輸出", "docs": "", "story": "", - "prompt": "看這個函式:\nfunction applyDiscount() {\n cart = cart.map(function (item) {\n item.price *= discountRate;\n return item;\n });\n}\ncart 和 discountRate 都不是參數,而是直接讀取外部的變數;函式執行完也沒有 return,而是直接改掉了外部的 cart。這兩種現象分別叫做什麼?", - "code": "", + "prompt": "看這個函式:cart 和 discountRate 都不是參數,而是直接讀取外部的變數;函式執行完也沒有 return,而是直接改掉了外部的 cart。這兩種現象分別叫做什麼?", + "code": "function applyDiscount() {\n cart = cart.map(function (item) {\n item.price *= discountRate;\n return item;\n });\n}", "options": [ { "id": "a", "text": "讀取外部的 cart、discountRate(沒有透過參數傳入)是隱性輸入;直接修改外部的 cart(沒有透過回傳值傳出)是隱性輸出——兩者都是沒有寫在函式簽名裡、卻實際影響或被影響的部分" }, { "id": "b", "text": "這兩種現象都叫做隱性輸出,跟輸入無關" }, @@ -31,7 +31,7 @@ "prompt": "延續上一題的 applyDiscount(),下列哪一個修改版本正確地把隱性輸入輸出都改成顯性?", "code": "function applyDiscount() {\n cart = cart.map(function (item) {\n item.price *= discountRate;\n return item;\n });\n}", "options": [ - { "id": "a", "text": "function applyDiscount(cart, discountRate) { return cart.map(function (item) { return { ...item, price: item.price * discountRate }; }); }——cart 和 discountRate 都透過參數傳入,結果透過 return 傳出,呼叫端自己決定要不要把回傳值指派回原本的 cart 變數" }, + { "id": "a", "text": "cart 和 discountRate 都透過參數傳入,結果透過 return 傳出,呼叫端自己決定要不要把回傳值指派回原本的 cart 變數", "code": "function applyDiscount(cart, discountRate) {\n return cart.map(function (item) {\n return { ...item, price: item.price * discountRate };\n });\n}" }, { "id": "b", "text": "保留讀寫外部變數的寫法,只是把函式名稱加上 Explicit 字尾" }, { "id": "c", "text": "把 cart 和 discountRate 都改成寫死在函式內部的常數,不再從外部讀取" }, { "id": "d", "text": "用 globalThis.cart 取代 cart,讓讀取的寫法看起來更明確" } @@ -56,7 +56,7 @@ { "id": "d", "text": "把 shopping_cart_total 改存進 sessionStorage,其餘邏輯完全不變" } ], "answer": "b", - "explanation": "正確重構是:function calc_total(cart) { return cart.reduce((sum, item) => sum + item.price, 0); }。呼叫端再寫 shopping_cart_total = calc_total(shopping_cart)。萃取後的 calc_total 不讀也不寫任何全域變數,是純粹的輸入輸出對應。", + "explanation": "正確重構後的 calc_total(cart) 用 reduce 把傳入的 cart 陣列加總後直接 return,呼叫端再自行決定要不要把結果存進 shopping_cart_total。萃取後的 calc_total 不讀也不寫任何全域變數,是純粹的輸入輸出對應。", "verify": { "manual": "概念題:程式實作型改寫成選擇題,正解已於 explanation 給出對照程式碼,無需執行驗證。" } }, { @@ -85,8 +85,8 @@ "topic": "只讀不寫的函式分類", "docs": "", "story": "", - "prompt": "看這個函式:\nfunction isEligibleForFreeShipping(cart) {\n return cart.total > freeShippingThreshold;\n}\n它讀取了外部變數 freeShippingThreshold,但完全沒有修改任何東西。它屬於哪一類?", - "code": "", + "prompt": "看這個函式:它讀取了外部變數 freeShippingThreshold,但完全沒有修改任何東西。它屬於哪一類?", + "code": "function isEligibleForFreeShipping(cart) {\n return cart.total > freeShippingThreshold;\n}", "options": [ { "id": "a", "text": "Calculation——相同的 cart 和相同的 freeShippingThreshold 永遠得到相同結果,沒有副作用;但它帶有「隱性輸入」這個壞味道,之後應該把 freeShippingThreshold 也改成參數傳入,讓它更容易被獨立測試" }, { "id": "b", "text": "Action,因為它讀取了外部狀態,光是讀取就已經算副作用" }, 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 1c177bd..0410360 100644 --- a/packages/core/src/data/questions/fp-5-improve-actions.json +++ b/packages/core/src/data/questions/fp-5-improve-actions.json @@ -9,8 +9,8 @@ "topic": "縮小隱性輸入輸出的價值", "docs": "", "story": "", - "prompt": "有兩個版本的 add_item_to_cart,都還沒被完全轉成 Calculation、仍然是 Action:\n版本一:function add_item_to_cart(name, price) { shopping_cart = add_item(shopping_cart, name, price); calc_cart_total(); render(); saveToDb(); }\n版本二:function add_item_to_cart(cart, name, price) { return add_item(cart, name, price); } // 呼叫端自己決定要不要 render/saveDb\n版本二明顯比版本一容易理解,也比較不容易意外造成錯誤。這說明了什麼?", - "code": "", + "prompt": "有兩個版本的 add_item_to_cart,都還沒被完全轉成 Calculation、仍然是 Action。版本二明顯比版本一容易理解,也比較不容易意外造成錯誤。這說明了什麼?", + "code": "// 版本一\nfunction add_item_to_cart(name, price) {\n shopping_cart = add_item(shopping_cart, name, price);\n calc_cart_total();\n render();\n saveToDb();\n}\n\n// 版本二\nfunction add_item_to_cart(cart, name, price) {\n return add_item(cart, name, price);\n} // 呼叫端自己決定要不要 render/saveDb", "options": [ { "id": "a", "text": "即使函式還沒有完全變成 Calculation,減少它牽涉的隱性輸入輸出,也能讓「這個函式會被什麼狀態影響、又會觸發什麼後果」變得清楚許多——版本二一眼就能看出它只處理 cart 的新增邏輯,不會意外觸發畫面更新或寫資料庫" }, { "id": "b", "text": "沒有意義,兩個版本都還是 Action,效果完全一樣" }, @@ -31,7 +31,7 @@ "prompt": "下列函式直接讀寫全域變數 shopping_cart。哪一個重構正確地把它改成參數傳入、結果傳出?", "code": "function add_item_to_cart(name, price) {\n shopping_cart = add_item(shopping_cart, name, price);\n calc_cart_total();\n}", "options": [ - { "id": "a", "text": "function add_item_to_cart(cart, name, price) { var newCart = add_item(cart, name, price); var total = calc_total(newCart); return { cart: newCart, total }; }——呼叫端自行決定要不要把回傳值指派回 shopping_cart / 畫面上的總額" }, + { "id": "a", "text": "呼叫端自行決定要不要把回傳值指派回 shopping_cart / 畫面上的總額", "code": "function add_item_to_cart(cart, name, price) {\n var newCart = add_item(cart, name, price);\n var total = calc_total(newCart);\n return { cart: newCart, total };\n}" }, { "id": "b", "text": "保留全域變數讀寫,只是把函式名稱加上 New 字尾" }, { "id": "c", "text": "把 shopping_cart 改成存進 window.shopping_cart,其餘邏輯完全不變" }, { "id": "d", "text": "用字串組出變數名稱再用 eval() 動態賦值" } diff --git a/packages/core/src/data/questions/fp-6-copy-on-write.json b/packages/core/src/data/questions/fp-6-copy-on-write.json index 358072b..7a295a4 100644 --- a/packages/core/src/data/questions/fp-6-copy-on-write.json +++ b/packages/core/src/data/questions/fp-6-copy-on-write.json @@ -9,8 +9,8 @@ "topic": "copy-on-write 三步驟", "docs": "", "story": "", - "prompt": "下面這個 add_element_last 忘記做其中一步,導致呼叫端拿到的「新陣列」其實跟傳進來的原陣列是同一份參照:\nfunction add_element_last(array, elem) {\n array.push(elem);\n return array;\n}\n對照 copy-on-write「複製 → 修改複製品 → 回傳複製品」的標準三步驟,這段程式碼漏了哪一步?", - "code": "", + "prompt": "下面這個 add_element_last 忘記做其中一步,導致呼叫端拿到的「新陣列」其實跟傳進來的原陣列是同一份參照。對照 copy-on-write「複製 → 修改複製品 → 回傳複製品」的標準三步驟,這段程式碼漏了哪一步?", + "code": "function add_element_last(array, elem) {\n array.push(elem);\n return array;\n}", "options": [ { "id": "a", "text": "漏了第一步「複製」——沒有先用 array.slice() 複製出一份新陣列,就直接在傳進來的原陣列上呼叫 push,結果修改的其實是呼叫端手上那份原始資料,而不是一份獨立的複製品" }, { "id": "b", "text": "漏了第二步「修改複製品」,因為 push 這個方法不算是一種修改" }, @@ -74,8 +74,8 @@ "topic": "讀取變成 Calculation", "docs": "", "story": "", - "prompt": "如果 cart 被當成不可變資料看待(永遠不會被直接改動,只會被換成新版本),下面這個函式:\nfunction getTotal(cart) {\n return cart.reduce((sum, item) => sum + item.price, 0);\n}\n不管呼叫幾次、什麼時候呼叫,只要傳入的 cart 沒被換成新版本,結果就一定一樣。這說明了什麼?", - "code": "", + "prompt": "如果 cart 被當成不可變資料看待(永遠不會被直接改動,只會被換成新版本),下面這個函式不管呼叫幾次、什麼時候呼叫,只要傳入的 cart 沒被換成新版本,結果就一定一樣。這說明了什麼?", + "code": "function getTotal(cart) {\n return cart.reduce((sum, item) => sum + item.price, 0);\n}", "options": [ { "id": "a", "text": "只要資料被當成不可變,「讀」永遠不會改變任何狀態、也不依賴呼叫時機——getTotal 可以放心在任何地方、任何時候被呼叫,都不用擔心副作用;只有「把 cart 換成新版本」這件事才需要被當成需要控制的動作" }, { "id": "b", "text": "這代表 getTotal 其實是一個 Action,因為它讀取了外部傳入的 cart 物件" }, 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 a68217c..ba15879 100644 --- a/packages/core/src/data/questions/fp-7-defensive-copying.json +++ b/packages/core/src/data/questions/fp-7-defensive-copying.json @@ -50,7 +50,7 @@ "prompt": "legacy 函式 black_friday_promotion(cart) 會直接修改傳入的 cart。哪一種包裝方式正確地用 defensive copying 隔離它?", "code": "function black_friday_promotion(cart) {\n cart.forEach(item => { item.price *= 0.7; });\n return cart; // 直接修改傳入的 cart\n}", "options": [ - { "id": "a", "text": "function safe_black_friday_promotion(cart) { var copyIn = deepCopy(cart); var result = black_friday_promotion(copyIn); return deepCopy(result); }——進入安全區前先深拷貝一份交給不受信任的函式去改,離開時再深拷貝一次結果,確保呼叫端原本的 cart 完全沒被動到" }, + { "id": "a", "text": "進入安全區前先深拷貝一份交給不受信任的函式去改,離開時再深拷貝一次結果,確保呼叫端原本的 cart 完全沒被動到", "code": "function safe_black_friday_promotion(cart) {\n var copyIn = deepCopy(cart);\n var result = black_friday_promotion(copyIn);\n return deepCopy(result);\n}" }, { "id": "b", "text": "直接呼叫 black_friday_promotion(cart),只要回傳值看起來正確就沒問題" }, { "id": "c", "text": "呼叫前用 Object.freeze(cart) 凍結它,指望 legacy 函式自然改不動" }, { "id": "d", "text": "把 cart 轉成 JSON 字串再傳進去,函式看不懂字串自然不會出錯" } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b58543b..9afef2d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -36,6 +36,7 @@ export type QuestionType = 'predict-output' | 'find-bug' | 'same-or-not' | 'fill export interface QuestionOption { id: string text: string + code?: string } export interface Question { From 836137c450c8cc40566a53a57ccc701957230dd2 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Sat, 18 Jul 2026 21:20:14 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=81=B8=E9=A0=85?= =?UTF-8?q?=E6=8C=89=E9=88=95=E9=8D=B5=E7=9B=A4=E5=8F=AF=E5=8F=8A=E6=80=A7?= =?UTF-8?q?=E3=80=81mobile=20CodeBlock=20=E6=8E=92=E7=89=88=E3=80=81fp-14?= =?UTF-8?q?=20=E9=81=9E=E8=BF=B4=E9=A1=8C=E7=9B=AE=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 答題後 disabled 的 + ) })} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 966393b..e5666d3 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -937,6 +937,7 @@ button { transition: border-color 0.15s; font-family: inherit; word-break: break-all; + cursor: pointer; } .option-btn .code-block { @@ -944,7 +945,11 @@ button { text-align: left; } -.option-btn:not(:disabled):hover { +.option-btn[aria-disabled='true'] { + cursor: default; +} + +.option-btn:not([aria-disabled='true']):hover { border-color: var(--primary); } diff --git a/packages/core/src/data/questions/fp-14-nested-data.json b/packages/core/src/data/questions/fp-14-nested-data.json index 3191db3..b87e3b6 100644 --- a/packages/core/src/data/questions/fp-14-nested-data.json +++ b/packages/core/src/data/questions/fp-14-nested-data.json @@ -97,16 +97,16 @@ "topic": "遞迴的三個安全守則", "docs": "", "story": "", - "prompt": "這段程式碼會無限遞迴、把呼叫堆疊塞爆。對照正確版本會多一行 if (keys.length === 0) return modify(object);,這段程式碼缺了遞迴的哪個安全守則?", + "prompt": "這段程式碼在 keys 用完之後不會正常停止,而是會拋出 TypeError(嘗試存取 undefined 的屬性)。對照正確版本會多一行 if (keys.length === 0) return modify(object);,這段程式碼缺了遞迴的哪個安全守則?", "code": "function badNestedUpdate(object, keys, modify) {\n var key1 = keys[0];\n var restOfKeys = keys.slice(1);\n return update(object, key1, function (value1) {\n return badNestedUpdate(value1, restOfKeys, modify);\n });\n}", "options": [ - { "id": "a", "text": "缺了「基本情況(base case)」——沒有檢查 keys 是不是已經空了就直接停止遞迴,所以就算 restOfKeys 每次都用 slice(1) 縮短,程式還是會不斷往下呼叫,永遠碰不到終止條件,最終讓呼叫堆疊爆掉" }, + { "id": "a", "text": "缺了「基本情況(base case)」——沒有檢查 keys 是不是已經空了就直接停止遞迴。keys 用 slice(1) 縮短到空陣列後就不會再變短,之後每次呼叫的 key1 都是 undefined,object 也跟著逐步變成 undefined,最終在 update 內部執行 object[key] 時因為 object 已經是 undefined 而拋出 TypeError" }, { "id": "b", "text": "缺了「遞迴呼叫」,因為程式碼裡完全沒有呼叫 badNestedUpdate 自己" }, { "id": "c", "text": "缺了「往基本情況前進」,因為 keys 的長度從頭到尾都沒有改變" }, - { "id": "d", "text": "這段程式碼完全沒有問題,不會發生無限遞迴" } + { "id": "d", "text": "這段程式碼完全沒有問題,執行起來不會有任何錯誤" } ], "answer": "a", - "explanation": "遞迴要安全,必須同時具備:基本情況(停止條件)、遞迴呼叫、以及每次呼叫都往基本情況靠近。這段程式碼有遞迴呼叫、也有讓 keys 變短,唯獨少了「keys 變空時要停下來」這個基本情況,所以會不斷遞迴到 keys 已經是空陣列、key1 變成 undefined 也還在繼續呼叫。", + "explanation": "遞迴要安全,必須同時具備:基本情況(停止條件)、遞迴呼叫、以及每次呼叫都往基本情況靠近。這段程式碼有遞迴呼叫、也有讓 keys 變短,唯獨少了「keys 變空時要停下來」這個基本情況:keys 變成空陣列後不會再繼續縮短,但呼叫仍然沒有停止,object 逐步變成 undefined,最終在 update 裡存取 object[key] 時因為 object 是 undefined 而拋出 TypeError,並不是傳統認知中「呼叫堆疊塞爆」那種無限遞迴。", "verify": { "manual": "概念題:以具體缺陷程式碼取代抽象守則背誦,無可執行程式碼。" } }, { From 3165a31d75e34f429140bf2f6aa593218cb7a1bc Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Sat, 18 Jul 2026 21:22:32 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E9=A1=8C=E5=BA=AB?= =?UTF-8?q?=E9=A1=8C=E7=9B=AE/=E8=A7=A3=E6=9E=90=E4=B8=AD=E5=B0=8D?= =?UTF-8?q?=E7=AB=A0=E7=AF=80=E8=88=87=E5=85=B6=E4=BB=96=E9=97=9C=E5=8D=A1?= =?UTF-8?q?=E7=9A=84=E8=A8=98=E6=86=B6=E4=BE=9D=E8=B3=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正 fp/jsa/jsb/react 題庫中「答案需要記得某章節內容才能理解」的設計問題: 把「第一章的坑」「react-4 學過的時序」「react-8 教過」這類跨關卡引用, 改成把該觀念直接在題目或解析裡講清楚,不假設使用者記得或已完成其他關卡。 Co-Authored-By: Claude Sonnet 5 --- .../core/src/data/questions/fp-1-welcome.json | 6 ++--- .../fp-10-first-class-functions-1.json | 4 +-- .../questions/fp-15-timeline-diagrams.json | 2 +- .../fp-17-coordinating-timelines.json | 4 +-- .../data/questions/fp-18-reactive-onion.json | 2 +- .../src/data/questions/fp-19-road-ahead.json | 26 +++++++++---------- .../src/data/questions/fp-2-overview.json | 14 +++++----- .../questions/fp-4-extract-calculations.json | 2 +- .../data/questions/fp-5-improve-actions.json | 4 +-- .../src/data/questions/jsa-1-this-scope.json | 2 +- .../src/data/questions/jsa-2-closures.json | 2 +- .../data/questions/jsa-3-async-basics.json | 2 +- .../src/data/questions/jsa-5-classes.json | 6 ++--- .../questions/jsa-6-collections-json.json | 2 +- .../core/src/data/questions/jsb-6-loops.json | 4 +-- .../data/questions/jsb-7-numbers-dates.json | 2 +- .../core/src/data/questions/react-1-jsx.json | 4 +-- .../src/data/questions/react-11-refs.json | 6 ++--- .../src/data/questions/react-2-props.json | 8 +++--- .../src/data/questions/react-4-hooks.json | 2 +- .../questions/react-5-lists-conditional.json | 4 +-- .../questions/react-7-pure-components.json | 4 +-- .../data/questions/react-8-sharing-state.json | 4 +-- 23 files changed, 58 insertions(+), 58 deletions(-) diff --git a/packages/core/src/data/questions/fp-1-welcome.json b/packages/core/src/data/questions/fp-1-welcome.json index 86dffb1..468bcfb 100644 --- a/packages/core/src/data/questions/fp-1-welcome.json +++ b/packages/core/src/data/questions/fp-1-welcome.json @@ -6,10 +6,10 @@ "id": "fp-1-q1", "type": "concept", "difficulty": 1, - "topic": "本書對 FP 的定義", + "topic": "FP 如何看待副作用", "docs": "", "story": "", - "prompt": "同樣是「新用戶註冊後寄送歡迎信」,有兩種寫法,根據這兩種寫法的差異,哪一個最符合本書對「函數式程式設計」的核心主張?", + "prompt": "同樣是「新用戶註冊後寄送歡迎信」,有兩種寫法,根據這兩種寫法的差異,哪一個最符合函數式程式設計看待「副作用」的方式?", "code": "// 版本一\nfunction onSignup(user) {\n sendWelcomeEmail(user);\n logAnalytics(user);\n saveToDb(user);\n} // 判斷內容、寄信、記錄全部寫在一起\n\n// 版本二\nfunction buildWelcomeEmail(user) {\n return { to: user.email, subject: '歡迎加入' };\n} // 只負責算出信件內容,不寄信\nfunction onSignup(user) {\n var email = buildWelcomeEmail(user);\n sendEmail(email);\n saveToDb(user);\n}", "options": [ { "id": "a", "text": "版本二把「決定信件內容」這種可以獨立驗證、不需要真的寄信就能檢查對不對的部分,跟「寄信」這種一定會發生副作用的動作分開;FP 的重點不是把 sendEmail 也消滅掉,而是把可以獨立出來的邏輯跟不可避免的副作用分辨清楚,讓副作用的範圍縮到最小" }, @@ -18,7 +18,7 @@ { "id": "d", "text": "FP 的重點是把版本二裡的 sendEmail 也硬拆成一個不會寄信的「純函式」,副作用完全不該出現在程式的任何地方" } ], "answer": "a", - "explanation": "本書刻意不用「零副作用」當定義,因為真實世界的程式一定要跟外部世界互動(寄信、寫資料庫)。版本二示範的正是 FP 想做的事:把「決定內容」這種能拆出來獨立驗證的邏輯(buildWelcomeEmail),跟真正無法避免的副作用(sendEmail、saveToDb)分開,而不是妄想把副作用完全消滅。", + "explanation": "函數式程式設計刻意不用「零副作用」當標準,因為真實世界的程式一定要跟外部世界互動(寄信、寫資料庫)。版本二示範的正是 FP 想做的事:把「決定內容」這種能拆出來獨立驗證的邏輯(buildWelcomeEmail),跟真正無法避免的副作用(sendEmail、saveToDb)分開,而不是妄想把副作用完全消滅。", "verify": { "manual": "概念題:以具體對照範例取代書中定義的直接背誦,無可執行程式碼。" } }, { diff --git a/packages/core/src/data/questions/fp-10-first-class-functions-1.json b/packages/core/src/data/questions/fp-10-first-class-functions-1.json index d416ee1..a459c06 100644 --- a/packages/core/src/data/questions/fp-10-first-class-functions-1.json +++ b/packages/core/src/data/questions/fp-10-first-class-functions-1.json @@ -18,7 +18,7 @@ { "id": "d", "text": "這個差異只在瀏覽器環境成立,Node.js 環境裡 if 也可以被當成變數傳遞" } ], "answer": "a", - "explanation": "「函式是 first-class」是 Part 2 一切技巧的地基:正因為函式可以被當資料傳來傳去,才能有高階函式、函式工廠這些抽象手法;+、if 這類語法結構做不到這件事。", + "explanation": "「函式是 first-class」是後續一切「把函式當資料操作」技巧的地基:正因為函式可以被當資料傳來傳去,才能有高階函式、函式工廠這些抽象手法;+、if 這類語法結構做不到這件事。", "verify": { "manual": "概念題:以具體語法對比取代抽象定義敘述,無可執行程式碼。" } }, { @@ -56,7 +56,7 @@ { "id": "d", "text": "用一個全域變數紀錄目前要改哪個欄位,三個函式都改成讀這個全域變數" } ], "answer": "a", - "explanation": "把「欄位名」從函式名稱搬進參數列,是本章最核心的重構:三個重複的函式合而為一,未來要加新欄位也不用再新增一個相似函式。", + "explanation": "把「欄位名」從函式名稱搬進參數列,是這類重複函式最核心的重構手法:三個重複的函式合而為一,未來要加新欄位也不用再新增一個相似函式。", "verify": { "manual": "概念題:程式實作型改寫成選擇題,正解程式碼已於選項給出,無需執行驗證。" } }, { diff --git a/packages/core/src/data/questions/fp-15-timeline-diagrams.json b/packages/core/src/data/questions/fp-15-timeline-diagrams.json index f93b886..7df5aa0 100644 --- a/packages/core/src/data/questions/fp-15-timeline-diagrams.json +++ b/packages/core/src/data/questions/fp-15-timeline-diagrams.json @@ -155,7 +155,7 @@ { "id": "d", "text": "因為 JavaScript 是單執行緒,所有非同步 callback 一定會照發送順序依序執行" } ], "answer": "a", - "explanation": "這類 bug 的修法通常是:只採用「最後一次發出的請求」的結果(例如比對請求序號,或用 Ch17 的 JustOnce/取消機制),忽略掉比它更早發出、卻更晚回來的過期回應。", + "explanation": "這類 bug 的修法通常是:只採用「最後一次發出的請求」的結果(例如比對請求序號,或用 JustOnce/取消機制這類專門處理『只要最新結果』的工具),忽略掉比它更早發出、卻更晚回來的過期回應。", "verify": { "manual": "概念題:情境分析,無可執行程式碼。" } } ] diff --git a/packages/core/src/data/questions/fp-17-coordinating-timelines.json b/packages/core/src/data/questions/fp-17-coordinating-timelines.json index 47f871a..d586536 100644 --- a/packages/core/src/data/questions/fp-17-coordinating-timelines.json +++ b/packages/core/src/data/questions/fp-17-coordinating-timelines.json @@ -106,7 +106,7 @@ { "id": "d", "text": "這個改變只跟程式執行效能有關,跟時間模型的顯性隱性無關" } ], "answer": "a", - "explanation": "把「什麼時候發生、發生幾次」從工程師的假設,變成程式碼裡明確、可測試的規則,正是這一整章並行原語共同的精神:Queue 讓順序變顯性、Cut 讓彙整時機變顯性、JustOnce 讓「只發生一次」變顯性。", + "explanation": "把「什麼時候發生、發生幾次」從工程師的假設,變成程式碼裡明確、可測試的規則,正是 Queue、Cut、JustOnce 這幾個並行原語共同的精神:Queue 讓順序變顯性、Cut 讓彙整時機變顯性、JustOnce 讓「只發生一次」變顯性。", "verify": { "manual": "概念題:以具體 submitOrder 重複呼叫情境取代抽象定位比較,無可執行程式碼。" } }, { @@ -163,7 +163,7 @@ { "id": "d", "text": "三種情況都應該用同一個全域旗標變數手動控制,不需要額外的並行原語" } ], "answer": "a", - "explanation": "這題把整章學到的三個並行原語對應回同一個真實情境:JustOnce 對付「重複觸發」,Cut/Promise.all 對付「平行彙整」,而彙整邏輯本身要對「順序不定」保持中立,不能偷偷假設誰先誰後。", + "explanation": "這題把 JustOnce、Cut/Promise.all 這幾個並行原語對應回同一個真實情境:JustOnce 對付「重複觸發」,Cut/Promise.all 對付「平行彙整」,而彙整邏輯本身要對「順序不定」保持中立,不能偷偷假設誰先誰後。", "verify": { "manual": "概念題:情境分析,無可執行程式碼。" } } ] diff --git a/packages/core/src/data/questions/fp-18-reactive-onion.json b/packages/core/src/data/questions/fp-18-reactive-onion.json index 404b191..49e1c67 100644 --- a/packages/core/src/data/questions/fp-18-reactive-onion.json +++ b/packages/core/src/data/questions/fp-18-reactive-onion.json @@ -121,7 +121,7 @@ { "id": "d", "text": "React 的 state 是同步的,ValueCell 是非同步的,兩者機制完全相反" } ], "answer": "a", - "explanation": "這正是反應式架構在前端框架裡最直接的體現:從 setState 到 signals,核心想法都是「訂閱狀態變化、自動觸發依賴它的更新」,跟本章 ValueCell/FormulaCell 的設計脈絡一致。", + "explanation": "這正是反應式架構在前端框架裡最直接的體現:從 setState 到 signals,核心想法都是「訂閱狀態變化、自動觸發依賴它的更新」,跟前面 ValueCell/FormulaCell 那種「訂閱後自動觸發」的設計脈絡一致。", "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 48d3c24..b0619a9 100644 --- a/packages/core/src/data/questions/fp-19-road-ahead.json +++ b/packages/core/src/data/questions/fp-19-road-ahead.json @@ -6,19 +6,19 @@ "id": "fp-19-q1", "type": "concept", "difficulty": 1, - "topic": "全書兩大核心技能", + "topic": "兩階段重構的先後關係", "docs": "", "story": "", - "prompt": "有一段程式碼被兩階段重構:第一階段把混雜在 Action 裡的計算邏輯抽成獨立的 Calculation;第二階段進一步用高階函式消除重複、並且畫時間線圖解決了一個雙擊觸發的 race condition。這兩階段分別對應本書的哪個部分?", + "prompt": "有一段程式碼被兩階段重構:第一階段把混雜在 Action 裡的計算邏輯抽成獨立的 Calculation;第二階段進一步用高階函式消除重複、並且畫時間線圖解決了一個雙擊觸發的 race condition。這兩個階段所用的技巧,彼此是什麼關係?", "code": "", "options": [ - { "id": "a", "text": "第一階段對應 Part 1(區分並萃取 Actions/Calculations/Data);第二階段對應 Part 2(把函式與時間當作一等公民操作,包含高階函式與時間線分析)——這正是全書兩大部分依序建立起來的能力" }, - { "id": "b", "text": "兩階段都對應 Part 1,Part 2 純粹是理論,沒有實際應用" }, - { "id": "c", "text": "兩階段都對應 Part 2,Part 1 只是暖身練習" }, - { "id": "d", "text": "這兩階段的重構跟本書的章節架構沒有任何對應關係" } + { "id": "a", "text": "第一階段(區分並萃取 Actions/Calculations/Data)是後續一切技巧的地基——先把副作用和計算分開,程式碼才會乾淨到能繼續套用第二階段的技巧(把函式與時間本身當成可以操作的對象,包含高階函式與時間線分析);兩者是先後接續、逐步疊加的關係,不是各自獨立、順序隨意的兩套技巧" }, + { "id": "b", "text": "兩個階段互相獨立,先做哪個都沒差,也可以只做其中一個" }, + { "id": "c", "text": "第二階段的技巧不需要用到第一階段的成果,可以直接跳過第一階段" }, + { "id": "d", "text": "這兩個階段的重構跟彼此完全無關,只是剛好發生在同一段程式碼裡" } ], "answer": "a", - "explanation": "回頭看全書架構:Part 1(Ch1-9)打好「分辨與縮小副作用」的地基,Part 2(Ch10-18)在這個地基上,教你把函式和時間本身當成可以操作的一等公民,兩者合起來構成完整的函數式思維工具箱。", + "explanation": "先把「副作用跟邏輯混在一起」這個地基問題解決掉(區分並萃取 Actions/Calculations/Data),程式碼才會變得夠乾淨、夠可預期,後續要疊加高階函式、時間線分析這些技巧才有意義——這正是這兩階段重構要照順序進行的原因,不是兩套互不相干的技巧。", "verify": { "manual": "概念題:以具體兩階段重構取代對章節架構的直接背誦,無可執行程式碼。" } }, { @@ -47,16 +47,16 @@ "topic": "情境分析:專案中可套用的技巧", "docs": "", "story": "", - "prompt": "回顧一個典型的網頁專案(例如購物網站或學習平台),下列哪一組「可以立即套用本書技巧的地方+對應章節」最恰當?", + "prompt": "回顧一個典型的網頁專案(例如購物網站或學習平台),下列哪一組「情境 → 對應解法」的配對最恰當?", "code": "", "options": [ - { "id": "a", "text": "①把畫面更新邏輯直接寫在計算總價的函式裡 → 用 Ch4 萃取 Calculation 拆開;②好幾支函式名稱都長得像 setXxxByField → 用 Ch10 顯性化隱含參數合併成一個函式;③多個非同步請求彙整結果時,程式邏輯假設了固定的到達順序 → 用 Ch17 的 Cut/Promise.all 改寫,讓結果不受回應順序影響" }, - { "id": "b", "text": "把所有 CSS 檔案合併成一個檔案、把圖片都壓縮 → 分別對應 Ch6 與 Ch7" }, - { "id": "c", "text": "把資料庫從 SQL 換成 NoSQL、把伺服器從單機換成多台 → 分別對應 Ch12 與 Ch13" }, - { "id": "d", "text": "把所有變數都改名成中文、把程式碼裡的註解都刪掉 → 分別對應 Ch1 與 Ch2" } + { "id": "a", "text": "①把畫面更新邏輯直接寫在計算總價的函式裡 → 萃取 Calculation,把「算總價」跟「更新畫面」拆成兩個各自獨立的函式;②好幾支函式名稱都長得像 setPriceByName、setQuantityByName → 把欄位名字串化變成參數,合併成一個 setFieldByName 函式;③多個非同步請求彙整結果時,程式邏輯假設了固定的到達順序 → 用 Cut/Promise.all 這類並行原語改寫,讓結果不受回應順序影響" }, + { "id": "b", "text": "把所有 CSS 檔案合併成一個檔案、把圖片都壓縮 → 屬於這裡談的重構技巧" }, + { "id": "c", "text": "把資料庫從 SQL 換成 NoSQL、把伺服器從單機換成多台 → 屬於這裡談的重構技巧" }, + { "id": "d", "text": "把所有變數都改名成中文、把程式碼裡的註解都刪掉 → 屬於這裡談的重構技巧" } ], "answer": "a", - "explanation": "這三個情境分別對應本書三個具體的重構技巧:從 Action 萃取 Calculation(Ch4)、用顯性參數合併重複函式(Ch10)、用並行原語處理不確定的執行順序(Ch17)——這正是把全書技巧收斂回真實專案的練習。", + "explanation": "這三個情境分別對應三個具體的重構技巧:從 Action 萃取 Calculation、用顯性參數合併重複函式、用並行原語處理不確定的執行順序——這是把學到的技巧收斂回真實專案的練習;CSS 打包、資料庫選型、變數改名這類工程雜務,跟這裡談的副作用/時序控制技巧無關。", "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 655eeea..6f471df 100644 --- a/packages/core/src/data/questions/fp-2-overview.json +++ b/packages/core/src/data/questions/fp-2-overview.json @@ -6,19 +6,19 @@ "id": "fp-2-q1", "type": "concept", "difficulty": 1, - "topic": "Part 1 與 Part 2 的核心技能", + "topic": "依 bug 症狀挑選解法:萃取 Calculation 或畫時間線圖", "docs": "", "story": "", - "prompt": "程式裡有兩個 bug:①計算折扣的函式裡藏著一行直接修改全域購物車變數的程式碼,導致單元測試很難寫;②兩個非同步請求的回應順序不定,導致偶爾算錯總價。這兩個 bug 分別比較適合用本書哪個部分的技巧解決?", + "prompt": "程式裡有兩個 bug:①計算折扣的函式裡藏著一行直接修改全域購物車變數的程式碼,導致單元測試很難寫;②兩個非同步請求的回應順序不定,導致偶爾算錯總價。這兩個 bug 分別比較適合用什麼技巧解決?", "code": "", "options": [ - { "id": "a", "text": "① 屬於 Part 1(區分 Actions/Calculations/Data,把計算從全域變數的副作用中萃取出來);② 屬於 Part 2(用時間線圖與並行原語處理非同步順序不定的問題)——這正是全書兩大部分各自對應的核心技能" }, - { "id": "b", "text": "兩種 bug 都應該用 Part 2 的技巧解決,Part 1 只是入門暖身,沒有實際用途" }, - { "id": "c", "text": "兩種 bug 都應該用 Part 1 的技巧解決,Part 2 只是進階選讀,跟修 bug 無關" }, - { "id": "d", "text": "這兩種 bug 都跟本書的技巧無關,應該直接用除錯工具單步除錯找出問題" } + { "id": "a", "text": "① 適合用「區分 Actions/Calculations/Data,把計算從全域變數的副作用中萃取出來」解決;② 適合用「畫時間線圖分析執行順序,搭配並行原語處理非同步順序不定的問題」解決——兩種 bug 的症狀完全不同:前者是邏輯跟副作用糾纏在一起,後者是多條時間線交錯讀寫同一份共享資料" }, + { "id": "b", "text": "兩種 bug 都應該用時間線圖解決,萃取 Calculation 沒有實際用途" }, + { "id": "c", "text": "兩種 bug 都應該用萃取 Calculation 解決,時間線圖只是理論,跟修 bug 無關" }, + { "id": "d", "text": "這兩種 bug 都跟這些技巧無關,應該直接用除錯工具單步除錯找出問題" } ], "answer": "a", - "explanation": "Part 1(Ch1-9)處理的是「副作用混雜在邏輯裡」這類問題,靠萃取 Calculation 解決;Part 2(Ch10-18)處理的是「多條時間線交錯」這類問題,靠高階函式與並行原語解決,兩者對應的 bug 類型不同。", + "explanation": "「副作用混雜在邏輯裡」這類問題(① 直接改全域變數導致難以測試),靠把計算從副作用中萃取出來解決;「多條時間線交錯」這類問題(② 非同步順序不定導致算錯),靠畫時間線圖看清楚交錯狀況、再用並行原語控制順序解決——兩種 bug 的根本成因不同,處理的技巧也完全不同。", "verify": { "manual": "概念題:以兩種具體 bug 情境取代對章節結構的直接背誦,無可執行程式碼。" } }, { diff --git a/packages/core/src/data/questions/fp-4-extract-calculations.json b/packages/core/src/data/questions/fp-4-extract-calculations.json index 6abdd0c..8ee463f 100644 --- a/packages/core/src/data/questions/fp-4-extract-calculations.json +++ b/packages/core/src/data/questions/fp-4-extract-calculations.json @@ -94,7 +94,7 @@ { "id": "d", "text": "視 freeShippingThreshold 的型別而定,數字就是 Calculation、物件就是 Action" } ], "answer": "a", - "explanation": "本書把「副作用(寫)」跟「依賴外部狀態(讀)」分開看:只讀不寫的函式沒有副作用,仍然是 Calculation,只是多了一個沒寫進參數列的隱性輸入,這是後續該修的壞味道,而不是直接判定成 Action。", + "explanation": "「副作用(寫)」跟「依賴外部狀態(讀)」要分開看:只讀不寫的函式沒有副作用,仍然是 Calculation,只是多了一個沒寫進參數列的隱性輸入,這是後續該修的壞味道,而不是直接判定成 Action。", "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 0410360..d411f2b 100644 --- a/packages/core/src/data/questions/fp-5-improve-actions.json +++ b/packages/core/src/data/questions/fp-5-improve-actions.json @@ -75,7 +75,7 @@ { "id": "d", "text": "代表這個函式應該被改寫成 class 的建構子" } ], "answer": "a", - "explanation": "setPriceByName、setQuantityByName、setShippingByName 這類函式高度相似,差別只在「改哪個欄位」——這個差異應該變成參數(field),合併成一個 setFieldByName(cart, name, field, value),這正是 Ch10 要做的重構。", + "explanation": "setPriceByName、setQuantityByName、setShippingByName 這類函式高度相似,差別只在「改哪個欄位」——這個差異應該變成參數(field),合併成一個 setFieldByName(cart, name, field, value),這正是把隱含參數顯性化、消除重複函式的重構方向。", "verify": { "manual": "概念題:對照書中命名壞味道的說明,無可執行程式碼。" } }, { @@ -94,7 +94,7 @@ { "id": "d", "text": "因為 JavaScript 的模組系統強制要求檔案要這樣切分" } ], "answer": "a", - "explanation": "這正是分層設計(Ch8-9)的預告:底層的 cart 操作應該穩定、少變動;商業規則建立在底層之上,可以隨業務需求快速調整,不會牽動底下的基礎操作。", + "explanation": "這正是分層設計的核心精神:底層的 cart 操作應該穩定、少變動;商業規則建立在底層之上,可以隨業務需求快速調整,不會牽動底下的基礎操作。", "verify": { "manual": "概念題:情境分析,無可執行程式碼。" } } ] diff --git a/packages/core/src/data/questions/jsa-1-this-scope.json b/packages/core/src/data/questions/jsa-1-this-scope.json index d711fb1..97e076e 100644 --- a/packages/core/src/data/questions/jsa-1-this-scope.json +++ b/packages/core/src/data/questions/jsa-1-this-scope.json @@ -229,7 +229,7 @@ { "id": "d", "text": "時間到:null" } ], "answer": "a", - "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)。", + "explanation": "setTimeout 呼叫 callback 時,是由計時器系統來呼叫這個函式,前面沒有「誰的方法」這件事,this 不再指向 timer,而是由執行環境決定:在 Node.js 裡,計時器系統會把 this 綁成內部的 Timeout 物件;在瀏覽器裡則通常是 window/globalThis。不管哪一種,這個 this 都跟 timer 物件無關,所以 this.label 一律是 undefined。想保留 timer,要嘛把 callback 換成箭頭函式(箭頭函式沒有自己的 this,會沿用 start 的 this),要嘛先把 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/packages/core/src/data/questions/jsa-2-closures.json b/packages/core/src/data/questions/jsa-2-closures.json index 62edda2..65ee15d 100644 --- a/packages/core/src/data/questions/jsa-2-closures.json +++ b/packages/core/src/data/questions/jsa-2-closures.json @@ -111,7 +111,7 @@ { "id": "d", "text": "score = points;" } ], "answer": "a", - "explanation": "add 和 total 都背著同一個 score(closure),直接 score += points 就能累加。選項 b 的 this.score 是在物件身上另開一個欄位,跟 closure 裡的 score 是兩個世界;選項 d 每次「覆蓋」而不是累加,只會剩 5——第一章那個 = 與 += 的坑又來了。外面完全碰不到 score,這就是最輕量的「私有變數」。", + "explanation": "add 和 total 都背著同一個 score(closure),直接 score += points 就能累加。選項 b 的 this.score 是在物件身上另開一個欄位,跟 closure 裡的 score 是兩個世界;選項 d 每次「覆蓋」而不是累加,只會剩 5——這是「= 會蓋掉舊值、+= 才會累加」的經典陷阱。外面完全碰不到 score,這就是最輕量的「私有變數」。", "verify": { "checks": [ { "code": "function createGame() {\n let score = 0;\n return {\n add(points) { score += points; },\n total() { return score; },\n };\n}\nconst g = createGame();\ng.add(10);\ng.add(5);\nconsole.log(g.total());", "expected": "15" } diff --git a/packages/core/src/data/questions/jsa-3-async-basics.json b/packages/core/src/data/questions/jsa-3-async-basics.json index 2af2fc1..7518de3 100644 --- a/packages/core/src/data/questions/jsa-3-async-basics.json +++ b/packages/core/src/data/questions/jsa-3-async-basics.json @@ -88,7 +88,7 @@ { "id": "d", "text": "一樣,都印 undefined" } ], "answer": "b", - "explanation": "then 鏈是接力賽:上一棒 return 什麼,下一棒就收到什麼。版本二加了大括號卻沒寫 return,等於上一棒空手交棒,下一棒收到 undefined。第一章見過的「大括號吃掉回傳值」陷阱,在 Promise 鏈裡殺傷力更大,因為錯誤會沿著鏈往下傳很遠才被發現。", + "explanation": "then 鏈是接力賽:上一棒 return 什麼,下一棒就收到什麼。版本二加了大括號卻沒寫 return,等於上一棒空手交棒,下一棒收到 undefined。「箭頭函式加了大括號卻忘記寫 return」這個陷阱,在 Promise 鏈裡殺傷力更大,因為錯誤會沿著鏈往下傳很遠才被發現。", "verify": { "checks": [ { "code": "Promise.resolve(10)\n .then((n) => n * 2)\n .then((n) => console.log(n));", "expected": "20" }, diff --git a/packages/core/src/data/questions/jsa-5-classes.json b/packages/core/src/data/questions/jsa-5-classes.json index e82aea4..7626a0b 100644 --- a/packages/core/src/data/questions/jsa-5-classes.json +++ b/packages/core/src/data/questions/jsa-5-classes.json @@ -18,7 +18,7 @@ { "id": "d", "text": "會報錯" } ], "answer": "a", - "explanation": "class 是「物件的製造模具」:new Pet(\"皮皮\") 開一個新物件,constructor 立刻執行,把名字掛到 this 上。之後呼叫 p.speak(),this 就是 p 本人(第二章 this 關卡的規則:誰的方法誰呼叫)。讀懂 class 很重要——AI 和很多函式庫的程式碼都長這樣。", + "explanation": "class 是「物件的製造模具」:new Pet(\"皮皮\") 開一個新物件,constructor 立刻執行,把名字掛到 this 上。之後呼叫 p.speak(),this 就是 p 本人(誰呼叫這個方法,this 就是誰)。讀懂 class 很重要——AI 和很多函式庫的程式碼都長這樣。", "verify": { "checks": [ { "code": "class Pet {\n constructor(name) {\n this.name = name;\n }\n speak() {\n return `${this.name}:啾啾`;\n }\n}\nconst p = new Pet(\"皮皮\");\nconsole.log(p.speak());", "expected": "皮皮:啾啾" } @@ -79,7 +79,7 @@ "topic": "class 與 closure 工廠是同一件事的兩種寫法", "docs": "https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Classes", "story": "", - "prompt": "一個用 class、一個用 closure 工廠(第二章的老朋友)。兩段印出來一樣嗎?", + "prompt": "一個用 class、一個用 closure 工廠。兩段印出來一樣嗎?", "code": "// 版本一\nclass Counter {\n count = 0;\n add() { return ++this.count; }\n}\nconst a = new Counter();\nconst b = new Counter();\na.add(); a.add();\nconsole.log(a.add(), b.add());\n\n// 版本二\nfunction makeCounter() {\n let count = 0;\n return { add: () => ++count };\n}\nconst c = makeCounter();\nconst d = makeCounter();\nc.add(); c.add();\nconsole.log(c.add(), d.add());", "options": [ { "id": "a", "text": "一樣,都印 3 1" }, @@ -180,7 +180,7 @@ { "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 語法把它變成內建功能。", + "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" }, diff --git a/packages/core/src/data/questions/jsa-6-collections-json.json b/packages/core/src/data/questions/jsa-6-collections-json.json index d233a21..6d53af4 100644 --- a/packages/core/src/data/questions/jsa-6-collections-json.json +++ b/packages/core/src/data/questions/jsa-6-collections-json.json @@ -111,7 +111,7 @@ { "id": "d", "text": "版本二會報錯,JSON 不能這樣用" } ], "answer": "a", - "explanation": "展開 {...s1} 只複製「第一層」——裡面的 user 還是同一個物件(淺拷貝),改 c1.user 等於改到本尊。JSON.stringify 把整棵樹印成字串再 parse 回來,每一層都是全新的(深拷貝)。第一章的參照陷阱在巢狀資料裡更兇。另外現代 JS 有更正規的 structuredClone(),不過 JSON 寫法在舊程式裡到處都是,要看得懂。", + "explanation": "展開 {...s1} 只複製「第一層」——裡面的 user 還是同一個物件(淺拷貝),改 c1.user 等於改到本尊。JSON.stringify 把整棵樹印成字串再 parse 回來,每一層都是全新的(深拷貝)。物件互相共用參照的陷阱,在巢狀資料裡更兇。另外現代 JS 有更正規的 structuredClone(),不過 JSON 寫法在舊程式裡到處都是,要看得懂。", "verify": { "checks": [ { "code": "const s1 = { user: { name: \"小美\" } };\nconst c1 = { ...s1 };\nc1.user.name = \"小華\";\nconsole.log(s1.user.name);\nconst s2 = { user: { name: \"小美\" } };\nconst c2 = JSON.parse(JSON.stringify(s2));\nc2.user.name = \"小王\";\nconsole.log(s2.user.name);", "expected": "小華\n小美" } diff --git a/packages/core/src/data/questions/jsb-6-loops.json b/packages/core/src/data/questions/jsb-6-loops.json index b3955fb..f86ea7d 100644 --- a/packages/core/src/data/questions/jsb-6-loops.json +++ b/packages/core/src/data/questions/jsb-6-loops.json @@ -181,7 +181,7 @@ { "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 而中招。", + "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" }, @@ -228,7 +228,7 @@ { "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 常常惹麻煩的原因(上一題剛示範過)。", + "explanation": "let(包括 for 括號裡那個)是區塊作用域,for (let i ...) 的 i 只活在這個迴圈的大括號範圍內,出了迴圈就被回收,外面完全看不到——這跟「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" }, diff --git a/packages/core/src/data/questions/jsb-7-numbers-dates.json b/packages/core/src/data/questions/jsb-7-numbers-dates.json index efa917d..7a5533e 100644 --- a/packages/core/src/data/questions/jsb-7-numbers-dates.json +++ b/packages/core/src/data/questions/jsb-7-numbers-dates.json @@ -64,7 +64,7 @@ { "id": "d", "text": "A 行要用 Math.round 才對" } ], "answer": "a", - "explanation": "toFixed 的本職是「格式化給人看」,所以回傳字串 \"19.99\"——接著 \"19.99\" + 5 就黏成 \"19.995\"(第一章的字串加法陷阱穿了新衣服)。要繼續算數學就先轉回來:Number(rounded) + 5。原則:toFixed 放在「最後要顯示」的那一刻,中間運算全程用數字。", + "explanation": "toFixed 的本職是「格式化給人看」,所以回傳字串 \"19.99\"——接著 \"19.99\" + 5 就黏成 \"19.995\"(又是字串加法把數字黏成文字的老陷阱換了個樣子)。要繼續算數學就先轉回來:Number(rounded) + 5。原則:toFixed 放在「最後要顯示」的那一刻,中間運算全程用數字。", "verify": { "checks": [ { "code": "const price = 19.987;\nconst rounded = price.toFixed(2);\nconst total = rounded + 5;\nconsole.log(total);", "expected": "19.995" }, diff --git a/packages/core/src/data/questions/react-1-jsx.json b/packages/core/src/data/questions/react-1-jsx.json index 98d9610..236a9e6 100644 --- a/packages/core/src/data/questions/react-1-jsx.json +++ b/packages/core/src/data/questions/react-1-jsx.json @@ -18,7 +18,7 @@ { "id": "d", "text": "會報錯,JSX 裡不能做運算" } ], "answer": "a", - "explanation": "JSX 的大括號是「開一扇窗回到 JavaScript」:裡面放的是真的程式,level + 1 會被算成 4。任何合法的 JS 算式都能放——變數、函式呼叫、三元運算子。第二章讀懂的那些 JS,就是要在這扇窗裡用的。", + "explanation": "JSX 的大括號是「開一扇窗回到 JavaScript」:裡面放的是真的程式,level + 1 會被算成 4。任何合法的 JS 算式都能放——變數、函式呼叫、三元運算子,平常寫的那些 JS 語法,就是要在這扇窗裡用的。", "verify": { "checks": [ { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Badge() {\n const level = 3;\n return Lv.{level + 1};\n}\nconsole.log(renderToStaticMarkup());", "expected": "Lv.4" } @@ -112,7 +112,7 @@ { "id": "d", "text": "{\"name\"}" } ], "answer": "a", - "explanation": "JSX 插變數用單純的大括號 {name}。選項 b 的 ${name} 是樣板字串的語法(第一章學過的),只在反引號字串裡有效,在 JSX 裡會被當純文字印出「${name}」;選項 c 直接印出 name 三個字母;選項 d 印出帶引號的字串內容 name。兩套插值語法別搞混:反引號配 ${},JSX 配 {}。", + "explanation": "JSX 插變數用單純的大括號 {name}。選項 b 的 ${name} 是樣板字串(template literal)的語法,只在反引號字串裡有效,在 JSX 裡會被當純文字印出「${name}」;選項 c 直接印出 name 三個字母;選項 d 印出帶引號的字串內容 name。兩套插值語法別搞混:反引號配 ${},JSX 配 {}。", "verify": { "checks": [ { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Welcome({ name }) {\n return

歡迎,{name}!

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

歡迎,小美!

" } diff --git a/packages/core/src/data/questions/react-11-refs.json b/packages/core/src/data/questions/react-11-refs.json index 2d609d1..2fb014f 100644 --- a/packages/core/src/data/questions/react-11-refs.json +++ b/packages/core/src/data/questions/react-11-refs.json @@ -106,7 +106,7 @@ { "id": "d", "text": "先印 DOM 節點,effect 裡印 null" } ], "answer": "a", - "explanation": "時間軸是關鍵:渲染途中畫面根本還沒生出來,current 當然是 null(初始值);React 把 DOM 掛好之後才填入 current,而 useEffect 剛好跑在那之後(react-4 學過的時序),所以 effect 裡拿得到節點。「Cannot read properties of null (reading 'focus')」十之八九就是在渲染途中偷用了 ref。", + "explanation": "時間軸是關鍵:渲染途中畫面根本還沒生出來,current 當然是 null(初始值);React 把 DOM 掛好之後才填入 current,而 useEffect 剛好跑在那之後(React 會先把畫面畫出來,才輪到 effect 執行的時序),所以 effect 裡拿得到節點。「Cannot read properties of null (reading 'focus')」十之八九就是在渲染途中偷用了 ref。", "verify": { "checks": [], "manual": "需要瀏覽器掛載流程;時序依 react.dev manipulating-the-dom-with-refs(commit 後才填入 current)" @@ -128,7 +128,7 @@ { "id": "d", "text": "localStorage —— 存久一點比較保險" } ], "answer": "a", - "explanation": "用刪去法收尾這一章:let 活不過重繪(q3),id 會弄丟、interval 再也清不掉;useState 能存,但改 id 會觸發一次毫無意義的重繪,還可能連鎖觸發 effect;localStorage 是跨「關閉網頁」的持久化,跟這需求無關。「跨重繪存活+不影響畫面」的資料,useRef 是量身訂做的答案——這也是它在真實程式裡最常見的用法。", + "explanation": "用刪去法一一排除:let 活不過重繪(q3),id 會弄丟、interval 再也清不掉;useState 能存,但改 id 會觸發一次毫無意義的重繪,還可能連鎖觸發 effect;localStorage 是跨「關閉網頁」的持久化,跟這需求無關。「跨重繪存活+不影響畫面」的資料,useRef 是量身訂做的答案——這也是它在真實程式裡最常見的用法。", "verify": { "checks": [], "manual": "屬設計選擇題,無單一執行輸出;判準依 react.dev referencing-values-with-refs 的使用時機" @@ -194,7 +194,7 @@ { "id": "d", "text": "會報錯,useRef 不能搭配 useEffect 使用" } ], "answer": "a", - "explanation": "這是「記住上一輪值」的經典寫法:render 階段讀到的 prevRef.current 永遠是「effect 尚未來得及更新前」的舊值,effect 本身要等瀏覽器畫完畫面才跑(react-4 學過的時序),跑完才把 ref 更新成這一輪的 value,留給下一次渲染去讀。所以每次渲染畫面上看到的「上一次」,其實就是名副其實的上一輪的值,不會提前更新成當下這輪。", + "explanation": "這是「記住上一輪值」的經典寫法:render 階段讀到的 prevRef.current 永遠是「effect 尚未來得及更新前」的舊值,effect 本身要等瀏覽器畫完畫面才跑(React 會先把畫面畫出來,才輪到 effect 執行的時序),跑完才把 ref 更新成這一輪的 value,留給下一次渲染去讀。所以每次渲染畫面上看到的「上一次」,其實就是名副其實的上一輪的值,不會提前更新成當下這輪。", "verify": { "checks": [], "manual": "需要多輪渲染與 effect 時序才能觀察;機制依 react.dev referencing-values-with-refs(ref 跨渲染存活+effect 在畫面呈現之後才執行)" diff --git a/packages/core/src/data/questions/react-2-props.json b/packages/core/src/data/questions/react-2-props.json index 2b5ef88..abd48f0 100644 --- a/packages/core/src/data/questions/react-2-props.json +++ b/packages/core/src/data/questions/react-2-props.json @@ -18,7 +18,7 @@ { "id": "d", "text": "哈囉,訪客 和 哈囉,訪客" } ], "answer": "a", - "explanation": "props 就是「呼叫元件時傳進來的參數物件」,{ name = \"訪客\" } 是第一章學過的解構+預設值,原封不動搬到 React 來用。沒傳 name 就補上訪客。你在 JS 章練的每一招,到 React 都是日常。", + "explanation": "props 就是「呼叫元件時傳進來的參數物件」,{ name = \"訪客\" } 就是 JS 的解構+預設值語法,原封不動搬到 React 來用。沒傳 name 就補上訪客。JS 練過的每一招,到 React 都是日常。", "verify": { "checks": [ { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Greet({ name = \"訪客\" }) {\n return

哈囉,{name}

;\n}\nfunction App() {\n return (\n
\n \n \n
\n );\n}\nconsole.log(renderToStaticMarkup());", "expected": "

哈囉,訪客

哈囉,阿明

" } @@ -41,7 +41,7 @@ { "id": "d", "text": "沒有 bug,510 是對的" } ], "answer": "a", - "explanation": "引號傳的 props 永遠是字串——\"5\" + 10 就黏出 \"510\"(第一章的字串加法陷阱在 React 重演)。數字、布林、變數都要走大括號:points={5}。畫面出現黏在一起的怪數字,第一件事就是查 props 是不是被引號傳成了字串。", + "explanation": "引號傳的 props 永遠是字串——\"5\" + 10 就黏出 \"510\"(字串加法把數字黏成文字的陷阱,在 React 重演)。數字、布林、變數都要走大括號:points={5}。畫面出現黏在一起的怪數字,第一件事就是查 props 是不是被引號傳成了字串。", "verify": { "checks": [ { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Score({ points }) {\n return

總分:{points + 10}

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

總分:510

" }, @@ -111,7 +111,7 @@ { "id": "d", "text": "[text]" } ], "answer": "a", - "explanation": "React 把所有 props 打包成一個物件塞給元件的第一個參數,{ text } 就地解構拿出來(第一章物件關卡的參數解構,在 React 是每天寫的標準姿勢)。選項 c 的 props 能收到東西,但函式裡就得寫 props.text——而題目裡直接用了 text,會報 text is not defined。", + "explanation": "React 把所有 props 打包成一個物件塞給元件的第一個參數,{ text } 就地解構拿出來(物件解構語法,在 React 是每天寫的標準姿勢)。選項 c 的 props 能收到東西,但函式裡就得寫 props.text——而題目裡直接用了 text,會報 text is not defined。", "verify": { "checks": [ { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction Title({ text }) {\n return

{text}

;\n}\nconsole.log(renderToStaticMarkup());", "expected": "<h1>嗨</h1>" } @@ -274,7 +274,7 @@ { "id": "d", "text": "title 要寫成 this.title 才抓得到值" } ], "answer": "a", - "explanation": "props 沒傳,對應的參數就是貨真價實的 undefined,不會自動變成空字串或任何預設值。字串相加時 JS 會把 undefined 轉型成文字 \"undefined\" 接上去,於是畫面出現這個詭異的英文字——這跟第一章「字串 + undefined」的坑是同一個原理,只是換了個舞台在 React 重演。修法呼應 react-2-q1:用解構預設值 { title = \"\" } 擋掉,或是呼叫端記得補齊 title。", + "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 <img alt={\"頭像:\" + title} src=\"/a.png\" />;\n}\nfunction App() {\n return <Avatar />;\n}\nconsole.log(renderToStaticMarkup(<App />));", "expected": "<link rel=\"preload\" as=\"image\" href=\"/a.png\"/><img alt=\"頭像:undefined\" src=\"/a.png\"/>" }, diff --git a/packages/core/src/data/questions/react-4-hooks.json b/packages/core/src/data/questions/react-4-hooks.json index df22a0d..27389c1 100644 --- a/packages/core/src/data/questions/react-4-hooks.json +++ b/packages/core/src/data/questions/react-4-hooks.json @@ -174,7 +174,7 @@ { "id": "d", "text": "會報錯,同一個自訂 hook 不能被兩個元件使用" } ], "answer": "a", - "explanation": "自訂 hook 的本質是「把重複的邏輯抽出來」,不是「把 state 抽出來共用」。useToggle() 裡的 useState 依然是跟著呼叫它的那個元件實例走的(react-8 教過:state 是元件私有的記憶)——SwitchA 呼叫一次就在自己身上長出一份 on,SwitchB 呼叫一次又長出另一份,兩份完全獨立、互不相干。想要兩個元件真的同步同一份 state,答案永遠是 react-8 的解法:狀態上移到共同父層,用 props 往下傳,不是靠共用一個自訂 hook。", + "explanation": "自訂 hook 的本質是「把重複的邏輯抽出來」,不是「把 state 抽出來共用」。useToggle() 裡的 useState 依然是跟著呼叫它的那個元件實例走的(state 是元件私有的記憶)——SwitchA 呼叫一次就在自己身上長出一份 on,SwitchB 呼叫一次又長出另一份,兩份完全獨立、互不相干。想要兩個元件真的同步同一份 state,答案永遠是把狀態上移到共同父層、用 props 往下傳,不是靠共用一個自訂 hook。", "verify": { "checks": [], "manual": "需要各自點擊觀察是否連動;renderToStaticMarkup 只能做一次性渲染,也不會觸發 useState 更新;答案依 react.dev reusing-logic-with-custom-hooks(自訂 hook 共享的是邏輯而非 state 本身)" diff --git a/packages/core/src/data/questions/react-5-lists-conditional.json b/packages/core/src/data/questions/react-5-lists-conditional.json index c2f30de..b2532e2 100644 --- a/packages/core/src/data/questions/react-5-lists-conditional.json +++ b/packages/core/src/data/questions/react-5-lists-conditional.json @@ -41,7 +41,7 @@ { "id": "d", "text": "<li>{items}</li>" } ], "answer": "a", - "explanation": "「資料陣列 → 元素陣列」就是 map 的工作,React 會把回傳的陣列攤開渲染;key 讓 React 認得每一項(列表必備)。選項 b 的 forEach 不回傳東西,畫面會是空的 <ul>(第一章的老坑在 React 重演);選項 c 的 for 是陳述式,不能塞進 JSX 大括號;選項 d 把整個陣列塞進一個 <li>,字全部黏在一起。", + "explanation": "「資料陣列 → 元素陣列」就是 map 的工作,React 會把回傳的陣列攤開渲染;key 讓 React 認得每一項(列表必備)。選項 b 的 forEach 不回傳東西,畫面會是空的 <ul>(forEach 不回傳值的老坑,在 React 重演);選項 c 的 for 是陳述式,不能塞進 JSX 大括號;選項 d 把整個陣列塞進一個 <li>,字全部黏在一起。", "verify": { "checks": [ { "jsx": "import { renderToStaticMarkup } from 'react-dom/server'\nfunction TodoList({ items }) {\n return <ul>{items.map((it) => <li key={it}>{it}</li>)}</ul>;\n}\nconsole.log(renderToStaticMarkup(<TodoList items={[\"買菜\", \"寫程式\"]} />));", "expected": "<ul><li>買菜</li><li>寫程式</li></ul>" } @@ -134,7 +134,7 @@ { "id": "d", "text": "會報錯,JSX 裡不能串兩個方法" } ], "answer": "a", - "explanation": "鏈式呼叫從左讀到右:filter 先淘汰沒庫存的滑鼠,map 再把活下來的變成 <li>。大括號裡只要是表達式,串多長都合法。「先 filter 再 map」是列表渲染的高頻組合,第一章陣列關卡的招式在這裡全部派上用場——JS 練得穩,React 就只是換個舞台。", + "explanation": "鏈式呼叫從左讀到右:filter 先淘汰沒庫存的滑鼠,map 再把活下來的變成 <li>。大括號裡只要是表達式,串多長都合法。「先 filter 再 map」是列表渲染的高頻組合,陣列方法的招式在這裡全部派上用場——JS 練得穩,React 就只是換個舞台。", "verify": { "checks": [ { "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 <ul>\n {products\n .filter((p) => p.stock > 0)\n .map((p) => <li key={p.id}>{p.name}</li>)}\n </ul>\n );\n}\nconsole.log(renderToStaticMarkup(<Shelf />));", "expected": "<ul><li>鍵盤</li><li>螢幕</li></ul>" } diff --git a/packages/core/src/data/questions/react-7-pure-components.json b/packages/core/src/data/questions/react-7-pure-components.json index 62d50f4..c80063b 100644 --- a/packages/core/src/data/questions/react-7-pure-components.json +++ b/packages/core/src/data/questions/react-7-pure-components.json @@ -178,7 +178,7 @@ { "id": "d", "text": "title 應該用 state 存,不能用 props" } ], "answer": "a", - "explanation": "渲染函式的工作是「算出這次要顯示的畫面」,回傳 JSX 就該結束了;直接動全域的 document 物件是在跟瀏覽器這個外部系統打交道,React 沒辦法保證這行程式碼跑幾次、什麼時候跑(StrictMode 開發模式會跑兩次),標題就可能被設定成奇怪的中間狀態。正確做法是用 useEffect 把這個副作用包起來,讓它在渲染完成、畫面真的貼到畫面上之後才執行一次——這正是 react-4 教過的 Effect 機制要解決的問題:副作用要跟渲染分開。", + "explanation": "渲染函式的工作是「算出這次要顯示的畫面」,回傳 JSX 就該結束了;直接動全域的 document 物件是在跟瀏覽器這個外部系統打交道,React 沒辦法保證這行程式碼跑幾次、什麼時候跑(StrictMode 開發模式會跑兩次),標題就可能被設定成奇怪的中間狀態。正確做法是用 useEffect 把這個副作用包起來,讓它在渲染完成、畫面真的貼到畫面上之後才執行一次——這正是 Effect 機制要解決的問題:副作用要跟渲染分開。", "verify": { "checks": [], "manual": "document 在 Node 環境不存在,需要瀏覽器 DOM 才能觀察;原則依 react.dev synchronizing-with-effects(渲染以外的世界互動屬於副作用,該放進 Effect)" @@ -268,7 +268,7 @@ { "id": "d", "text": "版本二會報錯,useEffect 裡不能呼叫 fetch" } ], "answer": "a", - "explanation": "跟 q8 的 document.title 是同一類問題:發網路請求是跟外部世界(伺服器)打交道,屬於副作用,不該混進「算畫面」的過程。版本一每次渲染都順手發一次請求,元件被重繪多少次就打多少次 API,完全失控;版本二用 useEffect 把它包起來,React 畫完面才執行,還能用依賴陣列 [id] 決定「id 沒變就不用重打」。呼應這兩章的主題:keeping-components-pure 講渲染要純,Effect 就是官方指定「副作用該去的地方」。", + "explanation": "跟 q8 的 document.title 是同一類問題:發網路請求是跟外部世界(伺服器)打交道,屬於副作用,不該混進「算畫面」的過程。版本一每次渲染都順手發一次請求,元件被重繪多少次就打多少次 API,完全失控;版本二用 useEffect 把它包起來,React 畫完面才執行,還能用依賴陣列 [id] 決定「id 沒變就不用重打」。呼應同一個主題:渲染要純,Effect 就是官方指定「副作用該去的地方」。", "verify": { "checks": [], "manual": "需要瀏覽器/Node fetch 環境與多輪渲染觀察請求次數;機制依 react.dev keeping-components-pure 與 synchronizing-with-effects" diff --git a/packages/core/src/data/questions/react-8-sharing-state.json b/packages/core/src/data/questions/react-8-sharing-state.json index 60774a9..a26b242 100644 --- a/packages/core/src/data/questions/react-8-sharing-state.json +++ b/packages/core/src/data/questions/react-8-sharing-state.json @@ -175,7 +175,7 @@ { "id": "d", "text": "版本一會報錯,input 不能同時給 value 跟 onChange" } ], "answer": "a", - "explanation": "value + onChange 讓 React 成為輸入框的「唯一真相來源」——這正是本章「單一資料源」精神在表單上的實踐:要跟別的元件共享這個輸入值、要清空、要驗證,隨時能靠 state 做到,這叫受控元件。defaultValue 只給 DOM 一個起始值,打字之後的內容活在 DOM 自己的記憶裡,React 對它視而不見,這叫非受控元件,除非搭配 ref 才能讀到目前的值。多數需要「跟其他 UI 同步」的表單都該選受控。", + "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 說明" @@ -265,7 +265,7 @@ { "id": "d", "text": "MainContent 會拿到錯誤的 menuOpen 值" } ], "answer": "a", - "explanation": "本章前面在教「怎麼把 state 往上搬」,但上提是有代價的手術,不是預設動作:只要沒有第二個元件需要用到,就不需要共享,留在原地最單純。硬把只有一個消費者的 state 上提,除了多一層 props 傳遞的麻煩,父層重繪的範圍也跟著變大——不相干的兄弟元件被迫陪著重新渲染(呼應 react-9 會細講的渲染範圍問題)。判斷準則很簡單:這份資料「有超過一個元件需要看到/改到它」嗎?沒有就別搬家,q7 的 lifting state up 是解法,不是每個 state 都要做的手續。", + "explanation": "把 state 往上搬(lifting state up)是有代價的手術,不是預設動作:只要沒有第二個元件需要用到,就不需要共享,留在原地最單純。硬把只有一個消費者的 state 上提,除了多一層 props 傳遞的麻煩,父層重繪的範圍也跟著變大——不相干的兄弟元件被迫陪著重新渲染。判斷準則很簡單:這份資料「有超過一個元件需要看到/改到它」嗎?沒有就別搬家,上提是解法,不是每個 state 都要做的手續。", "verify": { "checks": [], "manual": "重繪範圍變大屬於效能/渲染行為,無法用單次執行輸出驗證;原則依 react.dev sharing-state-between-components 的『何時該共享』精神"