讀完一課,怎麼知道自己真的懂了?
我要的不是完整測驗系統,是 algomaster 那種輕量 check-in:一課結尾一題選擇題,點下去立刻知道對錯,附一段解釋為什麼其他選項錯。
再加上一個更長期的問題:兩百多課的課文,怎麼保持品質一致? 今天一起處理。
課文裡的測驗長這樣:
<div class="quiz" data-answer="1"> <!-- 0-based! -->
<div class="quiz-label">📝 隨堂測驗</div>
<p class="quiz-q">下列哪一項是「不拒絕 H₀」的正確解讀?</p>
<button class="quiz-opt">已證明 H₀ 為真</button>
<button class="quiz-opt">現有證據不足以推翻 H₀</button>
<button class="quiz-opt">H₀ 與 H₁ 同時成立</button>
<div class="quiz-exp">✔ 詳解:假設檢定不會「證明」H₀…</div>
</div>
沒有 JSON、沒有題庫檔——題目就寫在課文裡,因為它是課文的一部分。少一個資料層就少一個同步問題。
綁定邏輯:
bindQuizzes() {
document.querySelectorAll(".quiz").forEach(quiz => {
const correct = quiz.dataset.answer;
quiz.querySelectorAll(".quiz-opt").forEach((opt, i) => {
opt.addEventListener("click", () => {
if (quiz.classList.contains("answered")) return; // 只能答一次
quiz.classList.add("answered");
const key = (document.getElementById("lesson-body").dataset.lessonkey || "") + ":quiz";
quiz.querySelectorAll(".quiz-opt").forEach((o, j) => {
o.disabled = true;
if (String(j) === correct) o.classList.add("correct");
else if (j === i) o.classList.add("wrong");
});
if (String(i) === correct && window.Gamify) Gamify.onQuizCorrect(key);
});
});
});
}
幾個細節:
.wrong)與「正解」(.correct),這才有學習價值。.answered 鎖定:答過就不能改,避免亂點到對為止。String(j) === correct:dataset 讀出來是字串,忘記轉型就永遠不相等。這種 bug 的症狀是「所有選項都算錯」。key 帶上課文 key:"ch09-1:quiz",Day 9 的計分需要它來確保同一題只給一次 XP。CSS 用 class 切換,不寫行內樣式:
.quiz-exp { display: none; }
.quiz.answered .quiz-exp { display: block; }
.quiz-opt.correct { border-color: var(--green); background: var(--green-soft); }
.quiz-opt.wrong { border-color: var(--red); background: var(--red-soft); }
詳解的顯示完全交給 CSS——JS 只負責加一個 class。狀態在 DOM class 上,樣式在 CSS 裡,這是沒有框架時最好維護的模式。
這是設計決策,不是偷懶:
assert(quizCount === 1, `${key}: expected exactly one quiz, got ${quizCount}`);
第 3 點最重要。「每課恰一題」不是靠自律,是靠 Day 10 的驗證器在 CI 裡強制執行。多寫一題會失敗、忘記寫也會失敗。
(章末的多題總測驗會在第 14 天做,那是另一個層次的東西:計分、亂序、錯題回顧。)
兩百多課要一致,靠的不是意志力,是規格。這是我實際遵守的骨架:
開場 1–2 句(這課解決什麼問題,白話)
💡 callout.idea(核心觀念,一課最多 1–2 個)
<h2> 小節 ×2–4:概念 → .formula 公式 → .example 例題
⚠️ callout.warn(常見誤區,強烈建議每課至少一個)
✅ callout.tip(實用技巧/總整理,可選)
📝 .quiz 隨堂測驗(固定收尾,恰一題)
長度基準:中文 400–800 字 + 1–3 個公式塊 + 1–2 個例題。
<!-- Callout 三種語意 -->
<div class="callout idea"><span class="co-label">💡 核心觀念</span> 內文…</div>
<div class="callout warn"><span class="co-label">⚠️ 常見誤區</span> 內文…</div>
<div class="callout tip"><span class="co-label">✅ 學習提示</span> 內文…</div>
<!-- 公式塊 -->
<div class="formula">\[ \bar{x} \pm z_{\alpha/2}\frac{\sigma}{\sqrt{n}} \]
<div class="f-note">σ 已知時的信賴區間</div></div>
<!-- 例題盒 -->
<div class="example"><div class="ex-head">📌 例題:平均通話時間</div><div class="ex-body">
<p>題目 → 解法 → 「👉」收尾點出 takeaway。</p></div></div>
<!-- 名詞表(中英對照) -->
<dl class="terms"><dt>型一誤差 (Type I error)</dt><dd>H₀ 為真卻拒絕它。</dd></dl>
只有六種區塊,全部是 class 沒有客製標籤。這讓課文是乾淨的語意 HTML——第 12 天要建搜尋索引、第 15 天要自動抽名詞表時,都可以直接用選擇器掃出來。規格化的內容會在之後變成可程式化的資產。
callout.idea),再給公式。公式不是解釋,是精確化。⚠️。學生真正會卡住的地方是混淆(互斥 vs 獨立、p 值誤讀、「接受 H₀」),點名它比多講一遍定義有效。這段是今天最該寫的部分。
課綱有兩百多課要寫。我當時的想法是:「結構都一樣,寫一支產生器不就好了?」於是有了 advanced.js——輸入課程名與關鍵字,套進共用的講解段落,一次生出全部課文。
一天就「完成」了兩百多課。看起來很豐富,實際上:
data-answer = lessonIndex % 3。題目是套版的、正確答案是餘數。它「答得出對錯」,但那個對錯毫無意義。最後我把整個產生器刪掉,兩百多課全部重寫。
規格可以樣板化,內容不行。
差別在於:
⚠️ 誤區、都要恰一題測驗、公式塊都用 .formula)→ 品質下限提高。順帶一個具體的判準:如果你的產生器需要「填入關鍵字」,那你產的是內容;如果它只是「檢查骨架是否齊全」,那你做的是規格。前者刪掉,後者保留——後者就是第 10 天的驗證器。
測驗選項的順序不能隨便改。
data-answer 是位置索引,改順序就要同步改索引。這點很明顯。但有個不明顯的:
我後來發現全站測驗的正解位置分布得不太均勻,想寫個腳本重排選項讓它均勻一點。實際統計 207 課的分布是 0:65 / 1:70 / 2:72——已經很接近均勻了,原本的印象是錯的。
而且真正的殺手在這裡:207 課裡有 141 課的詳解直接指名選項位置,例如「選項二誤把 entropy 當成…」。重排選項會讓這 141 段詳解全部前後矛盾。
結論:不重排。要重排就得連詳解一起改寫,成本遠大於收益。
這件事的教訓是:動手改資料前先量。 我如果直接寫腳本重排,會製造一百多處錯誤,而且結構驗證器一個都抓不到(答案索引還是有效的,只是文字對不上)。
先寫一段檢查,這會直接併進 Day 10 的驗證器:
function countClass(html, className) {
const attrs = [...html.matchAll(/class=["']([^"']+)["']/g)];
return attrs.filter(m => m[1].split(/\s+/).includes(className)).length;
}
for (const [key, html] of Object.entries(L)) {
const quizCount = countClass(html, "quiz");
if (quizCount !== 1) console.log(`${key}: 測驗數 ${quizCount}(應為 1)`);
const m = html.match(/class=["']quiz["'][^>]*data-answer=["'](\d+)["']/);
if (!m) { console.log(`${key}: 缺 data-answer`); continue; }
const optCount = countClass(html, "quiz-opt");
if (Number(m[1]) >= optCount)
console.log(`${key}: data-answer ${m[1]} 超出 ${optCount} 個選項`);
}
注意 countClass 不能用 html.includes('class="quiz"')——因為 class="quiz-opt" 也含 quiz 字樣。要拆開 class 屬性逐一比對,這種細節不小心就會讓驗證器誤判整站。
瀏覽器實測:點一個錯的選項,確認三件事同時發生——你選的變紅、正解變綠、詳解出現。只測「答對」的路徑是不夠的。
今天的重點:
明天做遊戲化:XP、等級、連續學習天數、徽章。順便處理一個真的資安問題——localStorage 是使用者可以隨意編輯的,而我把它的內容塞進 innerHTML。