iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 3

Day 3 — Model 先寫:單一事實來源 `curriculum.js`

  • 分享至 

  • xImage
  •  

今天要解的問題

網站有三頁,每頁都要知道「有哪些課、每門課有哪些章、每章有哪些單元」:

  • 首頁地圖:每個節點要顯示課名、課數、完成率。
  • 課程頁:要列出模組與章節卡。
  • 課文頁:sidebar 要列出同章的所有單元,還要算出「上一課/下一課」。

如果這些資訊寫在 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:一到兩個字的縮寫,畫在地圖節點中央(ΣMLAWS)。用字元不用圖檔,等於零額外請求。
  • min:這一課預估幾分鐘。這個欄位埋了一個雷,第 10 天會回來處理。

為什麼課綱是資料,不是 HTML

一個對照就很清楚。如果課程頁的章節卡直接寫死在 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 失敗而不是靜默的錯誤。

查詢 API:Model 提供的三個函式

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 會用到極致。

小結與明天預告

今天的重點:

  1. 課綱是資料,三頁都去查它;課數之類的東西一律算出來,不手寫。
  2. 查詢 API 放 Model,View 不自己走資料結構。
  3. URL 不放能被反查出來的資訊(課程 id 由章節反查),避免兩個真相來源。
  4. 不填「暫時的預設值」。

明天終於有畫面了:用 SVG 把 COURSE_MAP 畫成學習地圖,包含貝茲曲線連線與進度環。我也會說明為什麼手工座標打敗自動佈局。

程式碼:github.com/<user>/<repo>/tree/day03


上一篇
Day 2 — HTML 骨架與設計 token:CSS 變數就是你的設計系統
系列文
知識圖譜 : 技能樹式學習歷程3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言