點了地圖節點之後要有東西。課程頁要顯示一門課的模組結構、章節卡、以及我在這門課的完成度。
但真正的重點是今天要定兩個契約——寫錯的話,未來每一次改動都會付代價:
契約的定義是:寫下來之後就不能隨便改的東西。 值得花一整天想清楚。
index.html → 學習地圖
course.html?c=<courseId> → 課程頁
chapter.html?ch=<chapterId>&l=<lessonId> → 課文頁
三條規則:
?c=stat 不用 ?c=0。課程順序一調整,所有分享出去的索引式連結就全錯了,而且錯得很安靜(會導到別門課)。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(不帶參數)→ 顯示預設課程。localStorage.setItem("statmaster-progress-v1", JSON.stringify({"ch01-1":true,"ch01-2":true}));
location.reload(); // 第一張章節卡應變成 2/3,頂欄百分比也要動
最後一步很重要:它同時驗了 Model、兩個 View 與儲存契約。
今天定下的兩個契約,之後 25 天都不會再改:
明天做課文頁——整個網站的核心。會處理一個有趣的問題:課文 HTML 要放在哪裡? 我試過 JSON、Markdown,最後選了一個看起來很怪的答案(寫成 JS 字串),而理由完全是 file:// 的 CORS 限制。