跑一次 Lighthouse,chapter.html 的成績單很難看:
| 項目 | 分數 | 主因 |
|---|---|---|
| Performance | 61 | 一次載入 20 個課文檔(1.5 MB),其中 19 個當下用不到 |
| Accessibility | 78 | 缺 landmark、按鈕沒有 accessible name、只用顏色表達狀態 |
| Best Practices | 92 | 尚可 |
最大的問題是從 Day 6 就存在的:chapter.html 的 script 清單把所有課程的課文都載進來,只為了讀其中一課。
現況:
<!-- script src="js/lessons/ch01-04.js" -->
<!-- script src="js/lessons/ch05-08.js" -->
<!-- script src="js/lessons/ch09-14.js" -->
<!-- script src="js/lessons/reg.js" -->
<!-- … 再 16 個 … -->
為什麼當初這樣寫?因為 file:// 不能 fetch,而 script 標籤是唯一能載入外部內容的方式——所以就全部靜態載入了。這是「先求能用」的合理起點,但現在該還債了。
解法:Day 12 用過的技巧——動態插入 script。它同樣不受 CORS 限制,file:// 下一樣能用。
/* js/models/lesson-loader.js */
const LessonLoader = {
/* 課程 id → 課文檔名。初級統計切三個檔,其餘一門一檔 */
FILES: {
stat: ["ch01-04", "ch05-08", "ch09-14"],
reg: ["reg"], prob: ["prob"], comp: ["comp"], cda: ["cda"], ts: ["ts"],
mult: ["mult"], sem: ["sem"], ml: ["ml"], sdm: ["sdm"], milr: ["milr"],
py: ["py"], soft: ["soft"], sql50: ["sql50"], mssql: ["mssql"],
adb: ["adb"], aws: ["aws"], gcp: ["gcp"],
},
_loaded: {}, // name → Promise
loadFile(name) {
if (this._loaded[name]) return this._loaded[name];
this._loaded[name] = new Promise((res, rej) => {
const s = document.createElement("script");
s.src = `js/lessons/${name}.js?v=${window.ASSET_VER || ""}`;
s.onload = () => res(name);
s.onerror = () => rej(new Error(`載入課文檔失敗:${name}`));
document.head.appendChild(s);
});
return this._loaded[name];
},
/* 載入某門課需要的全部課文檔 */
loadCourse(courseId) {
const files = this.FILES[courseId];
if (!files) return Promise.reject(new Error(`未知課程:${courseId}`));
return Promise.all(files.map(f => this.loadFile(f)));
},
};
Controller 改成 async:
/* js/controllers/chapter.js */
const ChapterController = {
async init() {
const params = new URLSearchParams(location.search);
const chId = params.get("ch") || "ch01";
const ch = findChapter(chId); // 課綱是輕量的,仍然靜態載入
const course = ch && courseOfChapter(chId);
if (!ch || !course) { location.href = "index.html"; return; }
/* 先畫骨架(sidebar 只需要課綱),課文載入中顯示 skeleton */
LessonView.renderSidebar(ch, lessonId, id => this.goto(chId, id));
LessonView.showSkeleton();
try {
await LessonLoader.loadCourse(course.id);
} catch (e) {
LessonView.showError("課文載入失敗,請檢查網路後重新整理。");
return;
}
LessonView.renderLesson(course, ch, lesson, idx);
/* … 其餘不變 … */
},
關鍵設計:課綱仍然靜態載入。 curriculum.js 只有 40 KB,而 sidebar、麵包屑、導覽邏輯全部只需要課綱。所以骨架可以立刻畫出來,只有課文本體需要等——這讓感知效能遠好於「整頁等到齊」。
Skeleton 而不是轉圈圈:
showSkeleton() {
const body = document.getElementById("lesson-body");
body.innerHTML = `<div class="skel" aria-busy="true" aria-live="polite" aria-label="課文載入中">
${"<div class='skel-line'></div>".repeat(6)}
<div class="skel-box"></div>
${"<div class='skel-line'></div>".repeat(4)}
</div>`;
}
aria-busy + aria-live="polite" 讓螢幕閱讀器知道「內容正在載入」,而不是讀出一堆空 div。
使用者在讀第 1 課時,第 2 課的課文檔已經在同一個檔案裡(同門課),所以不需要預取。真正值得預取的是同門課的下一章(同檔)與地圖上的鄰居課程(不同檔):
/* 課文渲染完成、瀏覽器空閒時,預取可能要去的地方 */
if ("requestIdleCallback" in window) {
requestIdleCallback(() => {
const nextCourses = (COURSE_MAP.edges.filter(([a]) => a === course.id).map(([, b]) => b));
nextCourses.slice(0, 2).forEach(id => LessonLoader.loadCourse(id).catch(() => {}));
}, { timeout: 3000 });
}
requestIdleCallback 保證這件事不會跟當前渲染搶資源。.catch(() => {}) 因為預取失敗完全不重要,不該打擾使用者。
| 之前 | 之後 | |
|---|---|---|
chapter.html 首次載入 JS |
1.52 MB | 0.14 MB(課綱)+ 0.08 MB(該門課課文) |
| LCP(模擬 4G) | 4.1 s | 1.6 s |
| Performance 分數 | 61 | 94 |
還有兩個較小的改善:
<!-- MathJax 用 defer 已經有了,再加上 preload 讓它更早開始下載 -->
<link rel="preload" href="vendor/mathjax/tex-chtml.js" as="script">
<!-- 字型 preload(首屏就要用) -->
<link rel="preload" href="vendor/fonts/NotoSansTC-Regular.woff2" as="font" type="font/woff2" crossorigin>
crossorigin 對字型 preload 是必填——少了它,瀏覽器會下載兩次(一次 preload、一次真正使用時),比不 preload 更糟。這是很常見的錯。
<body>
<a class="skip-link" href="#lesson-main">跳到主要內容</a>
<nav class="nav" aria-label="主導覽">…</nav>
<div class="chapter-layout">
<aside class="sidebar" aria-label="本章課程清單">…</aside>
<main class="content-card" id="lesson-main">
<nav class="crumb" aria-label="麵包屑">…</nav>
<h1 class="lesson-title" id="lesson-title"></h1>
<div class="lesson-body" id="lesson-body"></div>
</main>
</div>
<footer class="footer">…</footer>
</body>
.skip-link {
position: absolute; left: -9999px;
}
.skip-link:focus {
left: 8px; top: 8px; z-index: 100;
padding: 8px 14px; background: var(--surface); border: 2px solid var(--accent);
border-radius: var(--radius);
}
skip link 是鍵盤使用者最有感的一個改善:課文頁的 sidebar 有十幾個按鈕,沒有 skip link 就得 Tab 十幾次才能到課文。
標題層級檢查:課文裡我用 <h2> 做小節(Day 8 的規格),頁面的 <h1> 是課名。不能跳級(h1 → h3)。寫一支檢查:
/* 加進 verify.js:課文不得出現 h1(頁面已有)與跳級 */
for (const [key, html] of Object.entries(LESSONS)) {
assert(!/<h1[\s>]/.test(html), `${key}: 課文不應包含 <h1>`);
const levels = [...html.matchAll(/<h([2-6])[\s>]/g)].map(m => +m[1]);
for (let i = 1; i < levels.length; i++)
assert(levels[i] - levels[i - 1] <= 1, `${key}: 標題跳級 h${levels[i-1]} → h${levels[i]}`);
}
Lighthouse 抓到的問題:主題切換按鈕只有一個 emoji,螢幕閱讀器讀出「地球儀」之類的東西。
Day 11 已經處理了(aria-label),但還有幾處漏的:
/* sidebar 的課程按鈕:加上完成狀態 */
`<button class="lesson-item ${done ? "completed" : ""}" data-lesson="${l.id}"
aria-current="${l.id === activeId ? "true" : "false"}"
aria-label="第 ${i + 1} 課 ${l.title},${l.min} 分鐘${done ? ",已完成" : ""}">`
aria-current="true" 是「當前項目」的標準表達,比 aria-selected 更適合導覽清單。
那個 ✓ 打勾符號要對螢幕閱讀器隱藏(因為 aria-label 已經說了「已完成」,否則會讀兩次):
<span class="check" aria-hidden="true">✓</span>
Day 14 已經順手修過測驗的部分(加 ✓/✗ 前綴)。系統性檢查後還有三處:
| 位置 | 問題 | 修法 |
|---|---|---|
| 地圖節點的類別 | 只用顏色分類 | 圖例已有文字;節點 aria-label 加上類別名 |
| 章節卡的「完成」 | 只用綠色邊框 | 章號改成 ✓(Day 5 已做) |
| 儀表板熱力圖 | 只用深淺 | 每格 <title> 有具體數字(Day 17 已做) |
/* 不要 outline: none! */
:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
:focus-visible 而不是 :focus——前者只在鍵盤操作時顯示,滑鼠點擊不會出現外框。這解決了「設計師覺得 outline 醜所以拿掉」與「鍵盤使用者需要看到焦點」的長年衝突。
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
注意 html { scroll-behavior: smooth } 也要被覆蓋——平滑捲動對前庭系統敏感的使用者會引發不適。
JS 動畫也要檢查(Day 18 的 canvas 動畫已經做了):
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
換課是原地更新(Day 6 的 replaceState),螢幕閱讀器不會知道內容變了:
/* 換課後主動通知 */
announce(msg) {
let region = document.getElementById("a11y-live");
if (!region) {
region = document.createElement("div");
region.id = "a11y-live";
region.setAttribute("aria-live", "polite");
region.setAttribute("aria-atomic", "true");
region.className = "sr-only";
document.body.appendChild(region);
}
region.textContent = ""; // 先清空,確保相同訊息也會被再讀一次
setTimeout(() => { region.textContent = msg; }, 50);
}
/* renderLesson 之後 */
this.announce(`已切換到第 ${idx + 1} 課:${lesson.title}`);
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
textContent = "" 再延遲設值那招是必要的 hack:aria-live 區域如果設成相同的文字,多數螢幕閱讀器不會再讀一次。
MathJax 3 的 CHTML 輸出預設有 aria-label(從 TeX 產生的語音文字),但品質參差。至少要確保容器不會被當成一堆亂碼讀出來:
window.MathJax = {
tex: { inlineMath: [["\\(", "\\)"]], displayMath: [["\\[", "\\]"]] },
options: {
skipHtmlTags: ["script", "noscript", "style", "textarea"],
enableAssistiveMml: true, // 產生隱藏的 MathML 供螢幕閱讀器使用
},
chtml: { fontURL: "vendor/mathjax/output/chtml/fonts/woff-v2" },
};
enableAssistiveMml 會多產生一份隱藏的 MathML——體積代價(每個公式多幾百 bytes)換來真正可讀的數學式。對一個統計教學網站來說,這個交換非常值得。
動態載入打破了 smoke test。 Day 10 的測試在頁面載入後立刻檢查 mjx-container,但現在課文是非同步載入的,測試會在 skeleton 階段就斷言失敗。
修法不是加 sleep(Day 10 說過那是 flaky 之源),而是等真實條件:
def wait_lesson(timeout=20):
end = time.time() + timeout
while time.time() < end:
r = d.execute_script("""
const b = document.getElementById('lesson-body');
return { ready: !!b && !b.querySelector('.skel'),
mjx: b ? b.querySelectorAll('mjx-container').length : 0 };
""")
if r["ready"]: return r
time.sleep(0.3)
return r
aria-hidden 用錯位置會讓內容消失。 我一度在 .lesson-body 的載入中狀態加 aria-hidden="true",結果課文載入後忘記移除,整篇課文對螢幕閱讀器完全不存在——而視覺上完全正常。這是無障礙 bug 的典型特徵:只有實際用輔助技術測試才會發現。
實際測試方式:macOS 用 VoiceOver(Cmd+F5)、Windows 用 NVDA(免費)。至少要用鍵盤走完一次完整流程(首頁 → 課程 → 課文 → 答題 → 完成本課),不用滑鼠。這 10 分鐘會抓到 Lighthouse 抓不到的問題——Lighthouse 只能檢查靜態屬性,不能判斷「操作流程是否可行」。
Lighthouse 的 Accessibility 100 分不等於無障礙。 它是自動檢查,涵蓋範圍大概是實際問題的三成。100 分是及格線,不是終點。
node scripts/verify.js # 含新增的標題層級檢查
python3 -m http.server 8901
python3 scripts/smoke-test.py # 已更新等待策略
# Lighthouse(CLI)
npx lighthouse http://localhost:8901/chapter.html?ch=ch09 \
--only-categories=performance,accessibility,best-practices \
--preset=desktop --output=json --output-path=/tmp/lh.json
node -e 'const r=require("/tmp/lh.json");
for (const [k,v] of Object.entries(r.categories)) console.log(k, Math.round(v.score*100));'
目標成績:
performance 94
accessibility 100
best-practices 100
手動檢查清單:
chapter.html?ch=ch09 只載入 ch05-08.js/ch09-14.js(stat 課),不該有 aws.js、gcp.js 等。今天的重點:
script 做按需載入——同一招在 Day 12(搜尋索引)與今天(課文)解決了兩個不同問題,而且維持 file:// 可用。1.52 MB → 0.22 MB。:focus-visible、aria-current、aria-live 通知、不只用顏色。crossorigin 對字型 preload 是必填,漏了會下載兩次。明天是第二階段的最後一天,也是技術上最硬的一篇:把亂碼的 PDF 教材變成可讀文字。我會拆解兩種不同成因的 PDF 亂碼(PowerPoint 的空 CMap 與 iOS 重新輸出的 cmap 消失),以及免 root 裝 OCR 時發現的一個讓速度差 80 倍的參數。