iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
AI Engineering

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

Day 19 — 效能與無障礙:把 Lighthouse 推到綠

  • 分享至 

  • xImage
  •  

今天要解的問題

跑一次 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 更糟。這是很常見的錯。


無障礙:從 78 到 100

1. Landmark 與標題層級

<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]}`);
}

2. 按鈕的 accessible name

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>

3. 不能只用顏色

Day 14 已經順手修過測驗的部分(加 / 前綴)。系統性檢查後還有三處:

位置 問題 修法
地圖節點的類別 只用顏色分類 圖例已有文字;節點 aria-label 加上類別名
章節卡的「完成」 只用綠色邊框 章號改成 (Day 5 已做)
儀表板熱力圖 只用深淺 每格 <title> 有具體數字(Day 17 已做)

4. 焦點可見

/* 不要 outline: none! */
:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
  border-radius: 4px;
}

:focus-visible 而不是 :focus——前者只在鍵盤操作時顯示,滑鼠點擊不會出現外框。這解決了「設計師覺得 outline 醜所以拿掉」與「鍵盤使用者需要看到焦點」的長年衝突。

5. 減少動態

@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;

6. 動態內容的通知

換課是原地更新(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 區域如果設成相同的文字,多數螢幕閱讀器不會再讀一次。

7. 數學式的無障礙

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

手動檢查清單:

  • Network 面板chapter.html?ch=ch09 只載入 ch05-08.js/ch09-14.js(stat 課),不該有 aws.jsgcp.js
  • Tab 鍵從頁首走到頁尾,每一步都看得到焦點框。
  • 第一次按 Tab 出現「跳到主要內容」,按 Enter 直接到課文。
  • 開啟 VoiceOver/NVDA:換課時會唸出新課名。
  • 系統開啟「減少動態」→ 沒有平滑捲動、沒有 toast 滑入動畫、Day 18 的動畫直接跳結果。
  • 縮放到 200%(瀏覽器縮放)版面不爆。

小結與明天預告

今天的重點:

  1. 動態插入 script 做按需載入——同一招在 Day 12(搜尋索引)與今天(課文)解決了兩個不同問題,而且維持 file:// 可用。1.52 MB → 0.22 MB。
  2. 先畫骨架、後補內容:課綱靜態載入讓 sidebar 立刻可用,感知效能遠好於整頁等齊。
  3. 無障礙的高投報項目:skip link、:focus-visiblearia-currentaria-live 通知、不只用顏色
  4. crossorigin 對字型 preload 是必填,漏了會下載兩次。
  5. Lighthouse 100 分是及格線:至少要用鍵盤與螢幕閱讀器實走一次。

明天是第二階段的最後一天,也是技術上最硬的一篇:把亂碼的 PDF 教材變成可讀文字。我會拆解兩種不同成因的 PDF 亂碼(PowerPoint 的空 CMap 與 iOS 重新輸出的 cmap 消失),以及免 root 裝 OCR 時發現的一個讓速度差 80 倍的參數。


上一篇
Day 18 — 教學動畫:canvas 上跑真的計算
系列文
知識圖譜 : 技能樹式學習歷程19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言