網站有三頁,每頁都要知道「有哪些課、每門課有哪些章、每章有哪些單元」:
如果這些資訊寫在 HTML 裡,新增一課就要改三個檔案,而且遲早會漏。所以第一個 Model 就是單一事實來源:課綱只存在一個地方,三頁都去查它。
四層嵌套:課程 → 模組 → 章 → 單元。
/* js/models/curriculum.js */
const COURSES = [
{
id: "stat", icon: "Σ", title: "初級統計", cat: "foundation",
tagline: "所有統計方法的共同基礎",
desc: "從「資料是什麼」到迴歸分析:敘述統計、機率、分配、抽樣、估計與假設檢定。",
modules: [
{
module: "模組一", title: "統計基礎與敘述統計", sub: "認識資料,學會描述資料",
chapters: [
{
id: "ch01", num: "CH 1", title: "資料與統計",
desc: "什麼是統計?資料的種類、來源,以及敘述統計與統計推論的差別。",
lessons: [
{ id: "1", title: "為什麼要學統計?", min: 8 },
{ id: "2", title: "資料的種類與衡量尺度", min: 12 },
{ id: "3", title: "敘述統計 vs. 統計推論", min: 10 },
]
},
/* ch02, ch03 … */
]
},
/* 模組二… */
]
},
/* 其他課程… */
];
幾個欄位的用意:
id:URL 參數與課文 key 都用它,全域唯一。cat:對應昨天的 CAT_META,決定顏色與圖例分組。顏色不寫在課程裡,寫類別就好。icon:一到兩個字的縮寫,畫在地圖節點中央(Σ、ML、AWS)。用字元不用圖檔,等於零額外請求。min:這一課預估幾分鐘。這個欄位埋了一個雷,第 10 天會回來處理。
一個對照就很清楚。如果課程頁的章節卡直接寫死在 course.html:
<!-- ❌ -->
<a class="chapter-card" href="chapter.html?ch=ch01">
<h4>資料與統計</h4><span>3 課</span>
</a>
那麼「新增一課」要改的地方是:課程頁的課數、課文頁的 sidebar、首頁的總課數、地圖節點的課數。四處。漏一處就是資料不一致,而且不會報錯,只會顯示錯的數字。
課綱變成資料之後,這些全部變成計算出來的:
const courseChapters = course =>
course.modules.flatMap(m => m.chapters);
const courseLessonCount = course =>
courseChapters(course).reduce((sum, ch) => sum + ch.lessons.length, 0);
const totalLessonCount = () =>
COURSES.reduce((sum, c) => sum + courseLessonCount(c), 0);
新增一課只改一個陣列,四個地方的數字自己跟上。第 10 天我還會寫一支驗證器,強制「課綱裡的每一課都必須有對應課文」,讓漏寫變成 CI 失敗而不是靜默的錯誤。
const findCourse = id => COURSES.find(c => c.id === id);
const findChapter = chId => {
for (const course of COURSES)
for (const m of course.modules) {
const hit = m.chapters.find(ch => ch.id === chId);
if (hit) return hit;
}
return null;
};
/* 由章節反查它屬於哪門課——「上一課/下一課只在同門課內移動」的關鍵 */
const courseOfChapter = chId =>
COURSES.find(c => courseChapters(c).some(ch => ch.id === chId)) || null;
/* 課文的唯一鍵:章 id + 單元 id */
const lessonKey = (chId, lessonId) => `${chId}-${lessonId}`;
courseOfChapter() 值得多說一句。課文頁的 URL 只有 ?ch=ch08&l=2,沒有課程 id。為什麼不放進 URL?因為那就有兩個真相來源了——如果有人手動改成 ?c=ml&ch=ch08,你要相信哪一個?讓章節反查課程,URL 就永遠不會自我矛盾。
代價是 findChapter 是線性掃描。83 章的規模,一次查詢是微秒級,完全不需要建索引——先量再優化。
lessonKey:一個字串省掉一層結構課文內容不存在課綱裡(課綱要保持輕薄,課文有幾百 KB),而是放在另一個全域物件:
window.LESSONS["ch01-1"] = "<p>課文 HTML…</p>";
lessonKey(chId, lessonId) 就是把兩個 id 接成這個扁平的 key。這讓課綱與課文可以分檔存放、分別載入,也讓驗證器能做雙向檢查(課綱有的課文一定要有,課文有的課綱一定要列)。Day 6 會詳談這個契約。
首頁的 SVG 地圖需要節點座標和先修連線,這些同樣進 Model:
const COURSE_MAP = {
viewBox: [1000, 840],
nodes: [
{ c: "stat", x: 500, y: 90, lg: true }, // lg = 放大顯示(起點課)
{ c: "prob", x: 250, y: 240 },
{ c: "reg", x: 500, y: 240 },
/* … */
],
edges: [["stat", "prob"], ["stat", "reg"], ["reg", "mult"]],
zones: [{ label: "研究所核心", x: 500, y: 200 }],
};
nodes 只存 c(課程 id)與座標,課名、顏色、課數都從 COURSES 展開——座標是地圖的資料,課程資訊不是。這也讓驗證器可以斷言「每門課都有節點、每個節點都對應真課程、每條 edge 兩端都存在」。
min 欄位差點變成假資料。
初級統計 49 課的 min 是我一課一課手調的(8–16 分鐘,依課文長度與公式多寡)。但後來擴充的兩百多課,是先寫課綱再寫課文——寫課綱時課文還不存在,我當時填了預設值 10。
結果:207 課全部顯示「10 分」,旁邊初級統計卻是 8、12、14 這種手調過的數字。並排一看,假得非常明顯,而且會侵蝕讀者對整站數據的信任。
教訓:不要在 Model 裡填「暫時的預設值」。要嘛不顯示這個欄位,要嘛就有真值。第 10 天我會寫一支腳本從實際課文長度反推分鐘數,把這筆技術債清掉。
先寫一段最小的健檢,之後會長成 Day 10 的驗證器:
node -e '
const fs=require("fs"), vm=require("vm");
const ctx={window:{}}; vm.createContext(ctx);
vm.runInContext(fs.readFileSync("js/models/curriculum.js","utf8"), ctx);
const C=vm.runInContext("COURSES",ctx);
const chs=C.flatMap(c=>c.modules.flatMap(m=>m.chapters));
const ids=chs.map(c=>c.id);
console.log("courses",C.length,"chapters",chs.length,
"lessons",chs.reduce((s,c)=>s+c.lessons.length,0));
console.log("章 id 唯一:", new Set(ids).size===ids.length);
'
用 node:vm 而不是 require,是因為 curriculum.js 是給瀏覽器用的 script(沒有 module.exports)。vm 讓我在 Node 裡假裝有 window,就能直接把瀏覽器程式當資料讀進來——同一份檔案,兩種執行環境,零重複。這招在 Day 10 會用到極致。
今天的重點:
明天終於有畫面了:用 SVG 把 COURSE_MAP 畫成學習地圖,包含貝茲曲線連線與進度環。我也會說明為什麼手工座標打敗自動佈局。
程式碼:
github.com/<user>/<repo>/tree/day03