課文頁是整個網站的核心:左邊是同章的單元清單(完成打勾),右邊是課文本體 + 隨堂測驗,底下是「上一課 / 完成本課,前往下一課」。
但在寫 View 之前,得先回答一個看起來很基礎、實際上決定了很多後續設計的問題:課文 HTML 要存在哪裡?
| 方式 | 優 | 致命問題 |
|---|---|---|
fetch("lessons/ch01-1.json") |
按需載入、檔案好管理 | file:// 下 fetch 被 CORS 擋死 |
fetch Markdown + 前端轉譯 |
內容好寫、可版控 | 同上,還要背一個 Markdown 函式庫 |
寫成 JS 字串,用 script 標籤載入 |
file:// 完全可用、零依賴 |
檔案較大、要處理字串跳脫 |
file:// 直開是 Day 1 的硬需求,所以答案只能是第三個。script src="..." 不受 CORS 限制(它不是 XHR),這是唯一能在本機雙擊開檔還讀得到外部內容的方法。
契約長這樣:
/* js/lessons/ch01-04.js */
(function () {
const L = (window.LESSONS = window.LESSONS || {});
L["ch01-1"] = `
<p>統計不是一堆公式,而是一套「在資訊不完整時做決定」的方法…</p>
<div class="callout idea"><span class="co-label">💡 核心觀念</span>
統計的核心問題是:我只看到一部分(樣本),能不能推論全部(母體)?</div>
<h2>敘述統計與統計推論</h2>
…
`;
L["ch01-2"] = `…`;
})();
三個設計點:
lessonKey(chId, lessonId)(Day 3),也就是 "ch01-1"。扁平字串,不做嵌套。window.LESSONS 掛東西,不污染全域。window.LESSONS = window.LESSONS || {} 讓載入順序無所謂。LessonView:sidebarconst LessonView = {
renderSidebar(ch, activeId, onSelect) {
const list = document.getElementById("lesson-list");
list.innerHTML = ch.lessons.map(l => `
<button class="lesson-item ${l.id === activeId ? "active" : ""} ${Progress.isDone(ch.id, l.id) ? "completed" : ""}"
data-lesson="${l.id}">
<span class="check">✓</span>
<span class="li-title">${l.title}</span>
<span class="li-min">${l.min} 分</span>
</button>
`).join("");
list.querySelectorAll(".lesson-item").forEach(btn => {
btn.addEventListener("click", () => onSelect(btn.dataset.lesson));
});
},
onSelect 是 controller 傳進來的 callback——View 不知道點下去會發生什麼事,它只負責通知。這是 MVC 那條線的具體長相:View 不讀 URL、不寫 localStorage、不決定導覽。
(這裡用 <button> 而不是 <a>,因為同章換課是原地更新、不是真導航。跨頁的地方我都用 <a>——見 Day 4。)
LessonView:課文本體 renderLesson(course, ch, lesson, idx) {
const crumb = document.getElementById("crumb");
crumb.innerHTML =
`<a href="index.html">地圖</a> › <a href="course.html?c=${course.id}">${course.title}</a> › ` +
`${ch.num} ${ch.title} · 第 ${idx + 1} 課,共 ${ch.lessons.length} 課`;
document.getElementById("lesson-title").textContent = lesson.title;
const body = document.getElementById("lesson-body");
body.dataset.lessonkey = lessonKey(ch.id, lesson.id);
body.innerHTML = window.LESSONS[lessonKey(ch.id, lesson.id)] || "<p>本課內容準備中。</p>";
this.bindQuizzes(); // Day 8
if (window.Interactive) Interactive.activate(body); // Day 9
if (window.MathJax && MathJax.typesetPromise)
MathJax.typesetPromise([body]); // Day 7
},
四件事的順序有意義:填 HTML → 綁測驗 → 啟用互動元件 → 排版數學式。全部都在同一個地方發生,所以「換課之後某個功能失效」這種 bug 只需要檢查這一個函式。
body.dataset.lessonkey 把當前課的 key 寫進 DOM。看起來多餘(controller 明明知道),但測驗綁定需要它來組成計分用的 key,而測驗綁定發生在 View 內部。用 data-* 把狀態掛在 DOM 上,比在 View 裡放一個模組級變數乾淨——後者會在快速換課時產生時序問題。
|| "<p>本課內容準備中。</p>" 是最後防線。理論上 Day 10 的驗證器會保證每一課都有課文,但**「理論上不會發生」的事在 UI 上也要有 fallback**,不能白畫面。
ChapterController:課內導覽的邏輯這是整個專案邏輯最密的一支:
const ChapterController = {
init() {
const params = new URLSearchParams(location.search);
const chId = params.get("ch") || "ch01";
const ch = findChapter(chId);
const course = ch && courseOfChapter(chId);
if (!ch || !ch.lessons.length || !course) { location.href = "index.html"; return; }
const lessonId = params.get("l") || ch.lessons[0].id;
const idx = ch.lessons.findIndex(l => l.id === lessonId);
if (idx === -1) { this.goto(chId, ch.lessons[0].id); return; } // l 無效 → 回本章第一課
const lesson = ch.lessons[idx];
document.title = `${ch.num} ${ch.title} — 學徑 LearnPath`;
LessonView.renderSidebar(ch, lessonId, id => this.goto(chId, id));
LessonView.renderLesson(course, ch, lesson, idx);
/* 同一門課的章節序列(跨模組攤平),用來算跨章導覽 */
const chapters = courseChapters(course);
const chIdx = chapters.findIndex(c => c.id === chId);
LessonView.renderNavButtons({
hasPrev: idx > 0 || chIdx > 0,
isDone: Progress.isDone(chId, lessonId),
onPrev: () => {
if (idx > 0) this.goto(chId, ch.lessons[idx - 1].id);
else if (chIdx > 0) {
const prev = chapters[chIdx - 1];
location.href = `chapter.html?ch=${prev.id}&l=${prev.lessons[prev.lessons.length - 1].id}`;
}
},
onComplete: () => {
Progress.setDone(chId, lessonId, true);
if (window.Gamify) Gamify.onLessonComplete(lessonKey(chId, lessonId));
if (idx < ch.lessons.length - 1) this.goto(chId, ch.lessons[idx + 1].id);
else if (chIdx < chapters.length - 1) location.href = `chapter.html?ch=${chapters[chIdx + 1].id}`;
else {
alert(`🎉 恭喜!你完成了「${course.title}」整門課程!`);
location.href = `course.html?c=${course.id}`;
}
},
});
NavView.render();
},
「下一課」有三種情況,這是最容易寫錯的地方:
courseChapters(course) 把模組攤平成章節陣列,是第 2 種情況的關鍵。而導覽範圍嚴格限制在同一門課內——第 14 章的下一課不會跳到別門課的第 1 章。這聽起來理所當然,但如果你用「全站章節陣列」來算 chIdx + 1,就會發生跨課亂跳。
history.replaceState goto(chId, lessonId) {
const url = new URL(location.href);
url.searchParams.set("ch", chId);
url.searchParams.set("l", lessonId);
history.replaceState(null, "", url);
this.init(); // 重新渲染整頁(含 sidebar 打勾狀態)
window.scrollTo({ top: 0 });
},
三行做完三件事:URL 同步、重新渲染、回到頂部。
用 replaceState 不用 pushState 是刻意的。同章換課如果每次都推一個歷史紀錄,讀完 5 課要按 5 次上一頁才能回課程頁——瀏覽器的返回鍵應該回到「來的地方」,而不是重播我的閱讀過程。跨章才用真導航(location.href),那時 URL 改變本來就該進歷史。
this.init() 直接重跑整個 controller,而不是精準更新變動的部分。看起來粗暴,但整頁渲染成本是毫秒級,換來的是不可能出現局部狀態不同步。這是沒有框架時最省心的策略:重繪整頁,別做 diff。
window.scrollTo({ top: 0 }) 別忘記。原地換內容而不重置捲動位置,會讓使用者以為頁面沒反應。
做到這裡時,我發現一個比字串跳脫更大的內容問題:除了初級統計與研究課,進階課幾乎都是 2 模組 × 2 章 × 3 課=12 課。AWS、機器學習、Python、結構方程式竟然一樣長,這不是整齊,是資料骨架反過來限制了知識範圍。
所以我訂下新規則:課數由領域決定,不由版型決定。 目前實際分布已經變成:
全站因此成長到 18 門、111 章、340 課、約 117 小時。但擴章會同時改兩份資料:curriculum.js 的章課規格與 js/lessons/<course>.js 的正文。只改一邊,Day 10 的雙向 mapping 一定失敗;人工複製又很容易在 quiz、LaTeX 或 id 上犯錯。
我把這件事做成 scripts/add-chapters.js:
<staging>/<courseId>/spec.json
<staging>/<courseId>/<chapterId>-<lessonId>.html
# 先驗證,不改正式資料
node scripts/add-chapters.js /tmp/stage/aws --dry
# 全部通過後,才同步附加課綱與 window.LESSONS
node scripts/add-chapters.js /tmp/stage/aws
node scripts/verify.js
管線會先檢查:章課 id 不撞號、每課正文長度足夠、恰好一個 quiz、data-answer 找得到對應 data-opt、沒有 Markdown fence/完整 <script>、MathJax 分隔符沒有錯誤雙反斜線、公式裡沒有裸 </>。全部通過才一次更新兩個正式檔案;失敗就完全不動。
這裡的重點不是「自動產生內容」——課文仍然逐課實寫。它自動化的是規格與搬運:
規格可以機械驗證,知識內容不能用模板灌水。
課文寫在 template literal 裡,內文不能有未跳脫的反引號和 ${。
我寫到一課要示範 shell 指令時,課文裡出現了 `date`,整個檔案的語法立刻爆掉——而且錯誤訊息會指向檔案的最後一行(字串提早結束,後面全被當程式碼),完全看不出真正的位置。
當下的止血法是跳脫成 \`。但這個雷會反覆出現(尤其課文含程式碼範例),所以後來我改用另一種產生方式:課文先寫成獨立的純 HTML 檔,再用一支腳本把它包成 JS:
node -e '
const fs=require("fs");
const html=fs.readFileSync("/tmp/ml01-1.html","utf8");
fs.appendFileSync("js/lessons/ml.js", `L[${JSON.stringify("ml01-1")}] = ${JSON.stringify(html)};\n`);
'
JSON.stringify 幫你處理所有跳脫,產出的是雙引號字串,反引號與 ${ 都不再有意義。代價是專案裡從此有兩種字串慣例並存,這會在明天引爆一個更難看的 bug。
python3 -m http.server 8901
# http://localhost:8901/chapter.html?ch=ch01
?ch=ch01&l=999 → 自動回本章第一課。今天的關鍵決策:
window.LESSONS),純粹為了 file:// 可用。data-* 把當前課 key 掛在 DOM 上,避免 View 內的模組狀態。replaceState + 重繪整頁,不做 diff。明天處理數學式。這篇會是全系列最有價值的一篇——因為 MathJax 帶來了兩個**「靜靜地壞掉」**的 bug:不報錯、不當掉、結構驗證器完全抓不到,只有真的用眼睛看畫面才會發現。
程式碼:
github.com/<user>/<repo>/tree/day06