iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
AI Engineering

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

Day 6 — 課文頁:`window.LESSONS`、課內導覽與領域驅動擴章

  • 分享至 

  • xImage
  •  

今天要解的問題

課文頁是整個網站的核心:左邊是同章的單元清單(完成打勾),右邊是課文本體 + 隨堂測驗,底下是「上一課 / 完成本課,前往下一課」。

但在寫 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"] = `…`;
})();

三個設計點:

  1. key 是 lessonKey(chId, lessonId)(Day 3),也就是 "ch01-1"。扁平字串,不做嵌套。
  2. 每門課一個檔。單檔太大會編輯不動(我試過一個 5000 行的檔案,編輯器都變慢);按課程切分也讓 Day 19 的動態載入變成可能。
  3. IIFE 包起來,只往 window.LESSONS 掛東西,不污染全域。window.LESSONS = window.LESSONS || {} 讓載入順序無所謂。

LessonView:sidebar

const 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();
  },

「下一課」有三種情況,這是最容易寫錯的地方:

  1. 本章還有下一課 → 原地換課。
  2. 本章結束、同門課還有下一章 → 跳到下一章(真導航)。
  3. 整門課結束 → 慶祝 + 回課程頁。

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 }) 別忘記。原地換內容而不重置捲動位置,會讓使用者以為頁面沒反應。

課程不能永遠都是 12 課:讓資料契約支援真正擴章

做到這裡時,我發現一個比字串跳脫更大的內容問題:除了初級統計與研究課,進階課幾乎都是 2 模組 × 2 章 × 3 課=12 課。AWS、機器學習、Python、結構方程式竟然一樣長,這不是整齊,是資料骨架反過來限制了知識範圍。

所以我訂下新規則:課數由領域決定,不由版型決定。 目前實際分布已經變成:

  • 初級統計:14 章/49 課。
  • 迴歸、類別資料、時間序列、多變量:各 8 章/24 課。
  • AWS、GCP:各 10 章/30 課。
  • MILR:5 章/15 課。
  • 其他尚待深化的課目前仍是 4 章/12 課,但那只是「尚未擴完」,不再是模板上限。

全站因此成長到 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
  • sidebar 列出本章所有單元,當前課高亮。
  • 點 sidebar 換課:內容變、URL 變、按上一頁回到課程頁(不是上一課)。
  • 「完成本課」→ sidebar 出現 ✓、頂欄百分比上升、自動前進下一課。
  • 一路按到本章最後一課 → 應跳到下一章。
  • ?ch=ch01&l=999 → 自動回本章第一課。

小結與明天預告

今天的關鍵決策:

  1. 課文寫成 JS 字串window.LESSONS),純粹為了 file:// 可用。
  2. data-* 把當前課 key 掛在 DOM 上,避免 View 內的模組狀態。
  3. 同章換課用 replaceState + 重繪整頁,不做 diff。
  4. 導覽三種情況,範圍嚴格限制在同一門課內。

明天處理數學式。這篇會是全系列最有價值的一篇——因為 MathJax 帶來了兩個**「靜靜地壞掉」**的 bug:不報錯、不當掉、結構驗證器完全抓不到,只有真的用眼睛看畫面才會發現。

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


上一篇
Day 5 — 課程頁、URL 契約與進度模型
下一篇
Day 7 — MathJax 整合與兩個「靜靜壞掉」的 bug
系列文
知識圖譜 : 技能樹式學習歷程9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言