iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
AI Engineering

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

Day 5 — 課程頁、URL 契約與進度模型

  • 分享至 

  • xImage
  •  

今天要解的問題

點了地圖節點之後要有東西。課程頁要顯示一門課的模組結構、章節卡、以及我在這門課的完成度。

但真正的重點是今天要定兩個契約——寫錯的話,未來每一次改動都會付代價:

  1. URL 契約:頁面靠什麼參數運作、參數無效時怎麼辦。
  2. 儲存契約:localStorage 的 key 與資料格式。

契約的定義是:寫下來之後就不能隨便改的東西。 值得花一整天想清楚。

URL 契約

index.html                              → 學習地圖
course.html?c=<courseId>                → 課程頁
chapter.html?ch=<chapterId>&l=<lessonId> → 課文頁

三條規則:

  1. 參數是 id,不是索引。?c=stat 不用 ?c=0。課程順序一調整,所有分享出去的索引式連結就全錯了,而且錯得很安靜(會導到別門課)。
  2. 無效參數 redirect 首頁,不顯示錯誤頁。 使用者手動亂改 URL 是他家的事,我不需要為此做錯誤頁。
  3. 不放能被反查的資訊。 課文頁只有 ch 沒有 c,課程由 courseOfChapter() 反查(Day 3)。URL 裡有兩個真相來源時,你就得處理它們互相矛盾的情況。

實作:

/* js/controllers/course.js */
const CourseController = {
  init() {
    const params = new URLSearchParams(location.search);
    const course = findCourse(params.get("c") || "stat");
    if (!course) { location.href = "index.html"; return; }

    document.title = `${course.title} — 學徑 LearnPath`;
    CourseView.render(document.getElementById("course-root"), course);
    NavView.render();
  },
};

params.get("c") || "stat" ——沒帶參數時給一個合理預設(起點課),而不是 redirect。這樣直接開 course.html 也不會壞。

document.title 由 controller 設定,不是寫在 HTML 裡。頁面標題是頁面狀態的一部分,狀態的擁有者是 controller。

進度模型:Progress

/* js/models/progress.js */
const Progress = {
  KEY: "statmaster-progress-v1",

  load() {
    try { return JSON.parse(localStorage.getItem(this.KEY)) || {}; }
    catch { return {}; }
  },
  save(p) { localStorage.setItem(this.KEY, JSON.stringify(p)); },

  isDone(chId, lessonId) { return !!this.load()[lessonKey(chId, lessonId)]; },

  setDone(chId, lessonId, done) {
    const p = this.load();
    if (done) p[lessonKey(chId, lessonId)] = true;
    else delete p[lessonKey(chId, lessonId)];
    this.save(p);
  },

  doneCount() { return Object.keys(this.load()).length; },
  chapterDone(ch) { return ch.lessons.filter(l => this.isDone(ch.id, l.id)).length; },
  courseDone(course) {
    return courseChapters(course).reduce((s, ch) => s + this.chapterDone(ch), 0);
  },
  percent() {
    const total = totalLessonCount();
    return total ? Math.round(this.doneCount() / total * 100) : 0;
  },
};

四個設計決定

1. key 帶版本號:statmaster-progress-v1

-v1 不是裝飾。有一天格式一定會需要改(例如要記完成時間),到時候可以:新程式讀 -v2,讀不到就去讀 -v1 做一次遷移再寫回 -v2。沒有版本號的話,你只有兩個選擇——硬改(清空所有人的進度)或永遠不改。

2. 只存完成的課

{ "ch01-1": true, "ch01-2": true, "ch03-4": true }

不存 false。沒有 key 就是沒完成,資料量與課程總數無關、只與使用者進度有關。而且新增課程不需要遷移——這在一個會持續長內容的網站上非常重要。

3. load() 用 try/catch 包住

JSON.parse 會 throw。使用者裝的擴充套件、手動改壞的值、隱私模式的怪行為,都可能讓 localStorage 裡是垃圾。進度讀取失敗絕不能讓整頁白掉,回傳空物件、當作沒進度就好。

4. 每次查詢都重讀 localStorage

isDone() 裡有一次 this.load(),也就是說渲染 20 個章節卡會讀 localStorage 幾十次。這聽起來很糟,但:一次讀取是微秒級、資料只有幾 KB,而換來的是永不過期的快取——沒有「記憶體狀態與 localStorage 不同步」這種 bug 的可能。這是刻意的取捨:用一點效能買掉一整類 bug。真的量到問題再加快取。

CourseView:三層渲染

const CourseView = {
  render(rootEl, course) {
    const total = courseLessonCount(course);
    const done = Progress.courseDone(course);
    const pct = total ? Math.round(done / total * 100) : 0;
    const color = CAT_META[course.cat].color;

    const head = `
      <header class="course-head">
        <div class="course-icon"
             style="background:${color}18;color:${color};border-color:${color}55">${course.icon}</div>
        <div class="course-head-main">
          <div class="course-cat" style="color:${color}">${CAT_META[course.cat].label}</div>
          <h1>${course.title}</h1>
          <p class="course-desc">${course.desc}</p>
          <div class="course-meta">
            <div class="pbar course-pbar"><i style="width:${pct}%"></i></div>
            <span>${done}/${total} 課 · ${pct}%</span>
          </div>
        </div>
      </header>`;

    rootEl.innerHTML = head + course.modules.map(m => this.moduleHTML(m)).join("");
  },

  moduleHTML(m) {
    return `
    <section class="module">
      <div class="module-head">
        <span class="m-num">${m.module}</span><h3>${m.title}</h3>
        <span class="m-sub">${m.sub || ""}</span>
      </div>
      <div class="chapter-grid">${m.chapters.map(ch => this.chapterCard(ch)).join("")}</div>
    </section>`;
  },

  chapterCard(ch) {
    const total = ch.lessons.length;
    const done = Progress.chapterDone(ch);
    const pct = total ? Math.round(done / total * 100) : 0;
    const complete = done === total && total > 0;
    return `
    <a class="chapter-card ${complete ? "done" : ""}" href="chapter.html?ch=${ch.id}">
      <div class="cc-top">
        <span class="cc-num">${complete ? "✓" : ch.num.replace("CH ", "")}</span>
        <h4>${ch.title}</h4>
      </div>
      <p class="cc-desc">${ch.desc}</p>
      <div class="cc-foot">
        <div class="pbar"><i style="width:${pct}%"></i></div>
        <span class="cc-count">${done}/${total} 課</span>
      </div>
    </a>`;
  },
};

render → moduleHTML → chapterCard 一層一個方法,每個方法只回傳字串。沒有 template engine、沒有 virtual DOM,就是三個純函式。

小細節:章節全部完成時,章號變成 。這是零成本的成就回饋——同一個位置、同一個樣式,只換一個字元。Day 9 會系統性地做這件事。

NavView:三頁共用的頂欄

const NavView = {
  render() {
    const pct = Progress.percent();
    const bar = document.getElementById("nav-progress-bar");
    const text = document.getElementById("nav-progress-text");
    if (bar) bar.style.width = pct + "%";
    if (text) text.textContent = pct + "% 完成";
  },
};

十行的 View。重點是那兩個 if (el)三頁共用的元件必須容忍元素不存在。哪天某頁不放進度條,這支不該報錯。防禦式寫法在多頁共用元件上是必需的,不是潔癖。

踩到的雷

script 載入順序:Progress 用到了 lessonKey()totalLessonCount(),那是 curriculum.js 的東西。

<!-- script src="js/models/curriculum.js" · 必須在前 -->
<!-- script src="js/models/progress.js" -->
<!-- script src="js/views/nav-view.js" -->
<!-- script src="js/views/course-view.js" -->
<!-- script src="js/controllers/course.js" · 最後 -->

順序錯了會得到 lessonKey is not defined。這是不用 ES modules 的代價:沒有 import,就得靠載入順序。我把順序寫進專案文件並在 code review 時檢查——Day 10 的驗證器也會間接抓到這件事(它模擬同樣的載入順序,順序錯就 throw)。

為什麼還是不用 modules?因為 type="module"file:// 會受 CORS 限制,直接讓「雙擊 index.html」壞掉。這是 Day 1 定下的硬需求,我選擇為它付這個代價。

驗證

python3 -m http.server 8901
# http://localhost:8901/course.html?c=stat

檢查清單:

  • 章節卡有標題、描述、0/N 課 與進度條。
  • ?c=不存在的id → 自動回首頁。
  • course.html(不帶參數)→ 顯示預設課程。
  • console 手動寫進度,重整看數字是否跟上:
localStorage.setItem("statmaster-progress-v1", JSON.stringify({"ch01-1":true,"ch01-2":true}));
location.reload();   // 第一張章節卡應變成 2/3,頂欄百分比也要動

最後一步很重要:它同時驗了 Model、兩個 View 與儲存契約。

小結與明天預告

今天定下的兩個契約,之後 25 天都不會再改:

  • URL:參數用 id、無效就回首頁、不放可反查的資訊。
  • 儲存:key 帶版本號、只存完成項、讀取一定要 try/catch。

明天做課文頁——整個網站的核心。會處理一個有趣的問題:課文 HTML 要放在哪裡? 我試過 JSON、Markdown,最後選了一個看起來很怪的答案(寫成 JS 字串),而理由完全是 file:// 的 CORS 限制。


上一篇
Day 4 — 首頁的 SVG 學習地圖:手工座標 vs 自動佈局
下一篇
Day 6 — 課文頁:`window.LESSONS`、課內導覽與領域驅動擴章
系列文
知識圖譜 : 技能樹式學習歷程9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言