# LESSON-16｜JavaScript 基礎：DOM 與動態渲染

> 對象：完全沒有前端概念的入門者
>
> 時間：3 小時
>
> **本堂專屬焦點：**用 DOM API 將服務資料安全地渲染到頁面

## 1. 課程目標

完成依資料數量產生服務清單的 renderServices 函式，理解 DOM 節點建立與安全文字寫入，不把資料當成 HTML 執行。

## 2. 任務情境

服務資料已經整理好，現在要讓頁面依資料產生卡片，新增資料時不用手動複製 HTML。

## 3. 先備知識與檔案結構

本堂先備：知道 HTML 會提供頁面結構，JavaScript 可以在瀏覽器執行；會在編輯器儲存檔案、用瀏覽器開啟 HTML、按右鍵開啟 DevTools 的 Elements 與 Console。上一堂的 services 陣列、object、property、querySelector 會在本堂接到真正的畫面。

### 檔案地圖

- `examples/minimal.html`：只讀最小實驗：先觀察完整的資料→DOM 渲染、空清單與重複渲染。
- `examples/faq-variant.html`：情境變體：把 services 流程改成 FAQ，確認方法可移植。
- `starter/files/index.html`：Starter HTML：依序建立 #service-list 與 #status。
- `starter/files/main.js`：Starter JavaScript：依序準備資料、渲染函式與呼叫流程。
- `checkpoint/files/main.js`：Checkpoint JavaScript：故意用 innerHTML，供學生重現安全性缺口。
- `solution/files/main.js`：Solution JavaScript：以 textContent 完成安全渲染與可觀察 status。

## 授課補強｜180 分鐘教學節奏

**本堂專屬焦點：**用 DOM API 將服務資料安全地渲染到頁面

固定的是八段時間，變化的是本堂檔案、教師操作、學生產出與驗收證據。

| 時間 | 教學活動 | 教師交付 | 學生產出 |
| --- | --- | --- | --- |
| 0–10 分鐘 | 情境導入｜服務資料已經整理好，現在要讓頁面依資料產生卡片，新增資料時不用手動複製 HTML。 | 展示 examples/minimal.html、examples/faq-variant.html、starter/files/index.html、starter/files/main.js、checkpoint/files/main.js、solution/files/main.js、#service-list、#status 目前的狀態，先請學生預測「完成依資料數量產生服務清單的 renderServices 函式，理解 DOM 節點建立與安全文字寫入，不把資料當成 HTML 執行。」完成後會出現什麼畫面或資料結果。 | 說出本堂問題與完成目標。；完成後：知道要從 examples/minimal.html、examples/faq-variant.html、starter/files/index.html、starter/files/main.js、checkpoint/files/main.js、solution/files/main.js、#service-list、#status 找到第一個可觀察結果。；驗收：以Starter、Checkpoint、Solution 入口可開啟、Elements 清單筆數與資料一致、特殊字串以文字顯示、空陣列有狀態、Console 無錯誤中的第一項作為導入前後的對照證據。 |
| 10–35 分鐘 | 概念建立｜DOM、selector、querySelector、createElement、textContent、innerHTML、append、replaceChildren、forEach、null 與 defer | 用 examples/minimal.html、examples/faq-variant.html、starter/files/index.html、starter/files/main.js、checkpoint/files/main.js、solution/files/main.js、#service-list、#status 中的最小片段逐詞解釋 DOM、selector、querySelector、createElement、textContent、innerHTML、append、replaceChildren、forEach、null 與 defer，每介紹一個詞就回到瀏覽器或 DevTools 指出可觀察結果。 | 完成名詞對照表，並先回答一題預測題。；完成後：能用自己的話解釋 DOM、selector、querySelector、createElement、textContent、innerHTML、append、replaceChildren、forEach、null 與 defer。；驗收：口頭回答一題預測題，並指出Starter、Checkpoint、Solution 入口可開啟、Elements 清單筆數與資料一致、特殊字串以文字顯示、空陣列有狀態、Console 無錯誤中對應的畫面、Console、Network、DOM 或資料證據。 |
| 35–55 分鐘 | 教師示範｜從 services 陣列建立 li 清單項目，將名稱與價格用 textContent 放入 #service-list，並用 #status 顯示實際筆數。 | 從最小可執行範例開始，逐次修改 examples/minimal.html、examples/faq-variant.html、starter/files/index.html、starter/files/main.js、checkpoint/files/main.js、solution/files/main.js、#service-list、#status；每次只改一個檢查點，先預測再重新整理或操作。 | 跟著完成一次修改，記下修改前後差異。；完成後：看到 Starter、Checkpoint、Solution 入口可開啟、Elements 清單筆數與資料一致、特殊字串以文字顯示、空陣列有狀態、Console 無錯誤 中至少一項可觀察結果。；驗收：保留一次修改前後畫面，並記下Starter、Checkpoint、Solution 入口可開啟、Elements 清單筆數與資料一致、特殊字串以文字顯示、空陣列有狀態、Console 無錯誤中至少兩項觀察結果。 |
| 55–95 分鐘 | 學生跟做｜完成依資料數量產生服務清單的 renderServices 函式，理解 DOM 節點建立與安全文字寫入，不把資料當成 HTML 執行。 | 將示範拆成 10–15 分鐘一個小檢查點，巡迴確認學生真的修改到 examples/minimal.html、examples/faq-variant.html、starter/files/index.html、starter/files/main.js、checkpoint/files/main.js、solution/files/main.js、#service-list、#status，卡住時先要求說出症狀與證據。 | 在自己的專案完成：完成依資料數量產生服務清單的 renderServices 函式，理解 DOM 節點建立與安全文字寫入，不把資料當成 HTML 執行。；完成後：核心成果能在自己的專案重現，而不是只在講師畫面成功。；驗收：提交核心成果、修改檔案清單，以及Starter、Checkpoint、Solution 入口可開啟、Elements 清單筆數與資料一致、特殊字串以文字顯示、空陣列有狀態、Console 無錯誤中可重現的至少兩項證據。 |
| 95–110 分鐘 | 觀察與除錯｜故意拼錯 #service-list 讓 querySelector 回傳 null，或先用 innerHTML 渲染含有 <strong> 標記的服務名稱，再從 Elements 與 Console 找出差異。 | 故意重現「故意拼錯 #service-list 讓 querySelector 回傳 null，或先用 innerHTML 渲染含有 <strong> 標記的服務名稱，再從 Elements 與 Console 找出差異。」，示範從症狀、證據、假設、單一修正到重新驗證的除錯紀錄。 | 重現一個錯誤，再只修改一處並重新驗證。；完成後：能寫出症狀、原因、修正與修正後結果。；驗收：留下錯誤狀態與修正後狀態的對照，證明Starter、Checkpoint、Solution 入口可開啟、Elements 清單筆數與資料一致、特殊字串以文字顯示、空陣列有狀態、Console 無錯誤中的錯誤已消失。 |
| 110–155 分鐘 | 情境變體與獨立挑戰｜把同一套資料→DOM 流程改成 FAQ 清單，新增空陣列狀態並保留資料與畫面分離。 | 先圈出 examples/minimal.html、examples/faq-variant.html、starter/files/index.html、starter/files/main.js、checkpoint/files/main.js、solution/files/main.js、#service-list、#status 中不變的技術結構，再示範如何完成「把同一套資料→DOM 流程改成 FAQ 清單，新增空陣列狀態並保留資料與畫面分離。」，最後請學生獨立完成同一變體。 | 完成變體：把同一套資料→DOM 流程改成 FAQ 清單，新增空陣列狀態並保留資料與畫面分離。；完成後：能說明哪些結構保留、哪些內容替換，且沒有引入超出本堂範圍的技術。；驗收：將變體結果與原始結果並列，附上檔案或操作位置與一次重新驗證紀錄。 |
| 155–175 分鐘 | 驗收、展示與討論｜Starter、Checkpoint、Solution 入口可開啟、Elements 清單筆數與資料一致、特殊字串以文字顯示、空陣列有狀態、Console 無錯誤 | 依 Starter、Checkpoint、Solution 入口可開啟、Elements 清單筆數與資料一致、特殊字串以文字顯示、空陣列有狀態、Console 無錯誤 逐項抽查學生的畫面、Console、Network、DOM 或資料與檔案，請學生先展示證據，再討論不同解法。 | 展示：Starter、Checkpoint、Solution 入口可開啟、Elements 清單筆數與資料一致、特殊字串以文字顯示、空陣列有狀態、Console 無錯誤；完成後：能回答「如果驗收失敗，下一個要查什麼？」。；驗收：留下可重現的驗收包：結果截圖或網址、檔案位置、檢查步驟與實際觀察。 |
| 175–180 分鐘 | 重點回顧與下堂銜接｜事件：下一堂會讓卡片、選單與 FAQ 對使用者操作做出反應。 | 用「概念、操作、證據」三問回看 examples/minimal.html、examples/faq-variant.html、starter/files/index.html、starter/files/main.js、checkpoint/files/main.js、solution/files/main.js、#service-list、#status 的成果，再說明它如何接到「事件：下一堂會讓卡片、選單與 FAQ 對使用者操作做出反應。」。 | 寫下三句回顧：我理解了什麼、我做出了什麼、我用什麼證據確認；再預測下一堂會修改哪個檔案。；完成後：能用一句話說明本堂成果如何銜接：事件：下一堂會讓卡片、選單與 FAQ 對使用者操作做出反應。；驗收：講師抽問一位學生重述事件：下一堂會讓卡片、選單與 FAQ 對使用者操作做出反應。，並收回一項本堂驗收證據。 |

## 4. 觀念拆解

本堂第一次出現的詞，先從「白話 → 正式定義 → 何時使用 → 最小例子 → 可觀察證據 → 混淆概念」建立完整理解。

### DOM（Document Object Model）

- **白話：**瀏覽器把 HTML 解析成 JavaScript 可以找到與修改的文件樹。
- **正式定義：**代表目前文件節點、階層與內容狀態的程式介面。
- **使用時機：**要讓 JavaScript 找到清單、按鈕或狀態文字時。
- **最小範例：**`document.querySelector("#service-list")` 找到頁面上的清單元素。
- **觀察證據：**Elements 看到的節點階層、節點數量與畫面內容會隨程式更新。
- **容易混淆：**和原始 HTML 文字混淆；DOM 是瀏覽器解析後目前正在使用的文件狀態。

### 元素（element）

- **白話：**頁面中的一個完整內容單位，例如 h1、ul 或 li。
- **正式定義：**瀏覽器建立的文件節點，通常由 HTML 標籤與其內容形成。
- **使用時機：**要說明 JavaScript 找到或建立的是哪一個頁面單位時。
- **最小範例：**`document.createElement("li")` 建立一個新的 li 元素。
- **觀察證據：**Elements 可以看到元素的標籤、屬性、子節點與文字。
- **容易混淆：**和標籤混淆；標籤是 HTML 的寫法，元素是瀏覽器建立的節點單位。

### selector（selector）

- **白話：**告訴 CSS 或 JavaScript 要找哪一個元素的寫法。
- **正式定義：**用來匹配文件元素的模式；本堂先使用 id selector，例如 `#service-list`。
- **使用時機：**要讓 `querySelector` 精準找到 HTML 目標時。
- **最小範例：**`#service-list` 會尋找 id 為 service-list 的元素。
- **觀察證據：**selector 和 HTML 的 id 完全一致時，querySelector 不會得到 null。
- **容易混淆：**和 property 混淆；selector 決定找誰，property 是 CSS 要改變的設定名稱。

### querySelector（querySelector）

- **白話：**依照一個 selector 找到頁面中的第一個元素。
- **正式定義：**Document 與 Element 提供、以 CSS selector 字串查找第一個符合節點的方法。
- **使用時機：**要把 JavaScript 連到 `#service-list` 或 `#status` 時。
- **最小範例：**`const list = document.querySelector("#service-list");`。
- **觀察證據：**Console 沒有因 null 而報錯，且 list 的文字或子節點會真的變化。
- **容易混淆：**和 querySelectorAll 混淆；本堂一次只找一個目標，後續再學多個結果。

### null（null）

- **白話：**代表「目前沒有找到物件」的特殊值。
- **正式定義：**JavaScript 表示刻意沒有物件值的原始值；querySelector 找不到目標時會回傳 null。
- **使用時機：**畫面沒有更新，或要判斷 selector 是否找到元素時。
- **最小範例：**`const list = document.querySelector("#missing");` 的結果是 `null`。
- **觀察證據：**Console 顯示 null，接著若直接呼叫 `list.append()` 會指出哪一行發生錯誤。
- **容易混淆：**和 undefined 混淆；null 常表示明確的空物件，undefined 常表示尚未提供值。

### createElement（Document.createElement()）

- **白話：**在 JavaScript 裡建立一個還沒放進頁面的新元素。
- **正式定義：**Document 介面用來建立指定 HTML 標籤元素節點的方法。
- **使用時機：**資料有幾筆不固定，不能手動複製固定數量 HTML 時。
- **最小範例：**`const card = document.createElement("li");`。
- **觀察證據：**在 Elements 看到新的 li 節點，而且節點數量和資料筆數一致。
- **容易混淆：**和 append 混淆；createElement 建立節點，append 才把節點接到文件裡。

### textContent（Node.textContent）

- **白話：**讀取或替換元素裡的純文字。
- **正式定義：**Node 提供、代表節點文字內容並可更新文字的屬性；它不會把字串解析成 HTML。
- **使用時機：**資料來自使用者或外部來源，只需要顯示文字而不需要插入標籤時。
- **最小範例：**`card.textContent = item.name + "｜" + item.price + " 元";`。
- **觀察證據：**`<strong>` 這類字串會原樣出現在畫面文字中，Elements 不會多出 strong 子元素。
- **容易混淆：**和 innerHTML 混淆；textContent 寫文字，innerHTML 會解析 HTML 標記。

### innerHTML（Element.innerHTML）

- **白話：**把字串當成 HTML 讀取或寫入元素內容。
- **正式定義：**Element 提供、以 HTML markup 序列化或解析子樹內容的屬性。
- **使用時機：**只有在內容來源和 markup 都是可信且確實需要 HTML 結構時，才由熟悉者使用。
- **最小範例：**`card.innerHTML = item.name;` 會把名稱中的 `<strong>` 當成標籤解析。
- **觀察證據：**比較 Elements 的子元素與 `card.textContent`；特殊字串若變成 strong 節點就表示被解析了。
- **容易混淆：**和 textContent 混淆；初學者渲染資料時優先使用 textContent。

### append（Element.append()）

- **白話：**把一個或多個節點或文字接到元素的最後面。
- **正式定義：**Element 介面用來將 Node 或字串附加為子內容的方法。
- **使用時機：**已經建立 li 後，要把它放進 `#service-list` 時。
- **最小範例：**`list.append(card);` 把 card 放到清單最後。
- **觀察證據：**Elements 的子節點順序和資料陣列順序一致。
- **容易混淆：**和 createElement 混淆；append 不負責建立節點，只負責接上去。

### replaceChildren（Element.replaceChildren()）

- **白話：**清空元素目前的子內容，準備重新放入一批結果。
- **正式定義：**Element 介面以指定節點取代所有現有子節點的方法；沒有參數時會清空。
- **使用時機：**同一個渲染函式會被重複呼叫，且不希望舊清單和新清單重複時。
- **最小範例：**`list.replaceChildren();` 先清空，再用 forEach 逐筆 append。
- **觀察證據：**重新渲染後 li 數量仍等於資料筆數，不會每按一次就多一批重複內容。
- **容易混淆：**和 removeChild 混淆；本堂一次清理整個容器，不逐筆追蹤舊節點。

### forEach（Array.prototype.forEach()）

- **白話：**依序對陣列中的每一筆資料執行一次函式。
- **正式定義：**Array 的迭代方法，依陣列順序對每個元素呼叫 callback，不建立新的陣列結果。
- **使用時機：**要把 services 的每一筆資料轉成一個 li 節點時。
- **最小範例：**`services.forEach((item) => { /* 建立一個 li */ });`。
- **觀察證據：**資料有三筆就執行三次，Elements 會有三個 li，且順序相同。
- **容易混淆：**和 map 混淆；forEach 重點是逐筆執行副作用，map 重點是產生新陣列。

### defer（script defer）

- **白話：**讓外部 JavaScript 延後到 HTML 解析完成後執行。
- **正式定義：**script 元素的布林屬性；外部腳本可在背景下載，並於文件解析完成後依序執行。
- **使用時機：**腳本需要找到 body 裡的元素，又希望保留外部 main.js 檔案時。
- **最小範例：**`<script src="main.js" defer></script>`。
- **觀察證據：**重新整理時不會因 HTML 尚未建立就找不到目標；Network 能看到 main.js 載入，Console 沒有時序錯誤。
- **容易混淆：**和 async 混淆；本堂先用 defer 保持文件順序與可預測的執行時機。

### 資料陣列／物件（array and object）

- **白話：**陣列保存多筆資料，物件用欄位描述其中一筆資料。
- **正式定義：**array 是有順序的集合；object 是以 property key/value 描述一個資料單位。
- **使用時機：**畫面數量會隨資料增減，不想為每一筆手寫 HTML 時。
- **最小範例：**`const services = [{ name: "到府整理", price: 1200 }];`。
- **觀察證據：**Console 可讀到 services.length、item.name 與 item.price，畫面項目數與陣列長度一致。
- **容易混淆：**和畫面上的 li 混淆；資料是來源，DOM 元素是渲染後的結果。

### role=status（ARIA status role）

- **白話：**告訴輔助技術這段文字是會更新的狀態訊息。
- **正式定義：**ARIA role，用來標示具有即時性但通常不需打斷使用者的狀態區域。
- **使用時機：**渲染完成、空清單或操作結果需要被讀到時。
- **最小範例：**`<p id="status" role="status">尚未渲染</p>`。
- **觀察證據：**Elements 可看到 role，#status 文字會隨渲染結果更新。
- **容易混淆：**和單純的 class 混淆；class 主要是樣式或定位，role 是語意與輔助技術資訊。

## 5. Live Lab

### 最小可執行實驗

- **入口：**`examples/minimal.html`
- **操作：**開啟 examples/minimal.html，先觀察三筆服務，再按「顯示空清單」與「恢復服務」，最後用 Elements 查看 li 與文字節點。
- **預期結果：**三筆資料會產生三個 li；空陣列會顯示空狀態；恢復後仍只有三筆，特殊 <strong> 標記只會顯示成文字。
- **重設方式：**重新整理 examples/minimal.html，回到三筆 services 的初始資料。
- **第一個檢查位置：**先確認目前 URL 是 examples/minimal.html，再選取 #service-list；不要先猜 JavaScript 哪一行。

### Step 1：先建立 DOM 的目標位置

- **教學目的：**先讓 HTML 提供可被程式找到的 ul 與 status，避免把資料和畫面位置混在一起。
- **講師白話解釋：**先請學生說出預測，再說明這一步只處理一個責任。
- **精確檔案與區塊：**starter/files/index.html 的 #service-list 與 #status
- **修改前：**Starter 只有服務區標題，還沒有 ul#service-list 或 p#status。
- **本次只修改：**加入 ul#service-list、role=status 與 aria-live=polite，並讓 id 和後續 selector 完全一致。
- **程式碼或操作：**

  ```text
  <ul id="service-list" aria-label="服務方案清單"></ul>
  <p id="status" role="status" aria-live="polite">尚未渲染</p>
  ```
- **預期結果：**Elements 看到一個空的 ul 與一個 status 段落；Console 尚未有渲染錯誤。
- **學生可能的錯誤答案：**只建立 div，或把 HTML 的 id 寫成 serviceList，之後才發現 querySelector 找不到。
- **講師追問：**如果 JavaScript 要找 #service-list，HTML 必須提供哪一個完全相同的字串？
- **Elements／Console／檔案行號 驗收：**Elements 的 DOM 階層、id 唯一性與 role=status 屬性。
- **卡住時的單一檢查：**只搜尋 index.html 的 service-list、status 是否各出現一次。

### Step 2：先把資料和 DOM 查找接起來

- **教學目的：**理解 services 是資料來源，list/status 是畫面出口，三者是不同責任。
- **講師白話解釋：**先請學生說出預測，再說明這一步只處理一個責任。
- **精確檔案與區塊：**starter/files/main.js 的檔案開頭
- **修改前：**Starter 的 main.js 沒有可渲染資料，也沒有找到 HTML 目標的變數。
- **本次只修改：**宣告三筆 services，並用 querySelector 找到 #service-list 與 #status。
- **程式碼或操作：**

  ```text
  const services = [
    { name: "到府整理 <strong>優惠</strong>", price: 1200 },
    { name: "定期維護", price: 800 },
    { name: "到府檢查", price: 600 },
  ];
  
  const list = document.querySelector("#service-list");
  const status = document.querySelector("#status");
  ```
- **預期結果：**程式有資料來源與兩個 DOM 參考；尚未呼叫渲染時，畫面仍是空清單。
- **學生可能的錯誤答案：**宣告 services 後直接寫 services.innerHTML，或把一整個 HTML 片段塞進資料陣列。
- **講師追問：**services、list、status 哪一個是資料？哪兩個是 DOM 節點的參考？
- **Elements／Console／檔案行號 驗收：**檔案中的變數、Console 的 services.length，以及 Elements 的目標節點。
- **卡住時的單一檢查：**只看三個 const 的名稱和 querySelector selector 是否逐字對得上 HTML。

### Step 3：把一筆資料轉成一個 li

- **教學目的：**用 createElement、textContent、append 和 forEach 形成最小渲染循環。
- **講師白話解釋：**先請學生說出預測，再說明這一步只處理一個責任。
- **精確檔案與區塊：**starter/files/main.js 的 renderServices
- **修改前：**資料和目標已存在，但還沒有函式把每筆資料轉成 DOM。
- **本次只修改：**先清空 list，再逐筆建立 li，以 textContent 寫入內容，最後 append 到清單。
- **程式碼或操作：**

  ```text
  function renderServices(items) {
    list.replaceChildren();
    items.forEach((item) => {
      const card = document.createElement("li");
      card.textContent = item.name + "｜" + item.price + " 元";
      list.append(card);
    });
  }
  ```
- **預期結果：**函式定義完成；呼叫後三筆資料會變成三個 li，且特殊字串仍是純文字。
- **學生可能的錯誤答案：**把所有資料寫成一個 li，或用 innerHTML 讓名稱中的標記被解析。
- **講師追問：**為什麼要先 replaceChildren？如果不先清空，第二次 render 會發生什麼？
- **Elements／Console／檔案行號 驗收：**Elements 的 li 數量、文字節點、順序與函式內四個 DOM API 的責任。
- **卡住時的單一檢查：**只檢查 list.replaceChildren、createElement、textContent、append 是否在同一個 forEach 流程中。

### Step 4：把函式接到實際頁面結果

- **教學目的：**區分「宣告函式」和「呼叫函式」，並讓 status 與 Console 提供驗收出口。
- **講師白話解釋：**先請學生說出預測，再說明這一步只處理一個責任。
- **精確檔案與區塊：**starter/files/main.js 的 renderServices(services)
- **修改前：**函式已定義，但如果沒有呼叫，畫面仍然是空清單。
- **本次只修改：**在函式宣告後呼叫 renderServices(services)，更新 status，並印出實際筆數。
- **程式碼或操作：**

  ```text
  renderServices(services);
  status.textContent = "已渲染 " + services.length + " 筆服務";
  console.log("LESSON-16：已渲染 " + services.length + " 筆服務");
  ```
- **預期結果：**重新整理後看到三個 li，#status 顯示已渲染 3 筆，Console 沒有 ReferenceError 或 TypeError。
- **學生可能的錯誤答案：**只寫 function renderServices，或在 function 宣告前呼叫尚未準備好的變數。
- **講師追問：**哪一行才真正讓瀏覽器開始把 services 變成畫面？
- **Elements／Console／檔案行號 驗收：**畫面 li 數量、#status 文字、Console 訊息與無紅色錯誤。
- **卡住時的單一檢查：**搜尋 renderServices(services) 是否出現在函式宣告之後，且 services 已經宣告。

### Step 5：重現 innerHTML 和 textContent 的差異

- **教學目的：**用一個可觀察的特殊字串理解安全文字寫入，不把「看起來正常」當成安全證據。
- **講師白話解釋：**先請學生說出預測，再說明這一步只處理一個責任。
- **精確檔案與區塊：**checkpoint/files/main.js 的 card 寫入行
- **修改前：**Checkpoint 使用 card.innerHTML，第一筆名稱中的 <strong> 標記會被瀏覽器解析成真正的子元素。
- **本次只修改：**先用 Elements 比較 childElementCount，再把 card.innerHTML 改成 card.textContent。
- **程式碼或操作：**

  ```text
  card.textContent = item.name + "｜" + item.price + " 元";
  ```
- **預期結果：**第一筆的 <strong> 標記會以文字顯示，li 不會多出 strong 子元素。
- **學生可能的錯誤答案：**為了讓文字變粗而保留 innerHTML，或只看畫面沒有檢查 DOM 子節點。
- **講師追問：**哪一個證據能分辨「文字長得像標籤」和「瀏覽器真的建立了標籤元素」？
- **Elements／Console／檔案行號 驗收：**Elements 的 childElementCount、innerHTML 與 textContent 前後比較。
- **卡住時的單一檢查：**只看 renderServices 內 card 的那一行，不要先修改資料或 HTML。

### Step 6：把同一套流程移植到 FAQ

- **教學目的：**確認學生學到的是資料→DOM 的方法，而不是只會複製 services 這個名稱。
- **講師白話解釋：**先請學生說出預測，再說明這一步只處理一個責任。
- **精確檔案與區塊：**examples/faq-variant.html 的 faq 陣列與 #faq-list
- **修改前：**FAQ 變體只有資料與空清單，沒有手寫每一筆問題的 HTML。
- **本次只修改：**用 createElement 建立 li、h2、p，分別用 textContent 放入 question 與 answer，再 append 到 #faq-list。
- **程式碼或操作：**

  ```text
  faqs.forEach((faq) => {
    const item = document.createElement("li");
    const question = document.createElement("h2");
    const answer = document.createElement("p");
    question.textContent = faq.question;
    answer.textContent = faq.answer;
    item.append(question, answer);
    list.append(item);
  });
  ```
- **預期結果：**FAQ 兩筆資料變成兩個 li，每個 li 有 h2 與 p，資料內容沒有被當 HTML 解析。
- **學生可能的錯誤答案：**直接複製兩份 HTML，或把整份 FAQ 陣列直接指定給 innerHTML。
- **講師追問：**如果明天 FAQ 從 2 筆變成 20 筆，哪個檔案責任應該改變最少？
- **Elements／Console／檔案行號 驗收：**FAQ 入口畫面、Elements 的 li/h2/p 階層、status 文字與 Console 無錯誤。
- **卡住時的單一檢查：**先確認目前 URL 是 examples/faq-variant.html，再對照 faq-list 是否和 querySelector 相同。

### Live Lab 與階段任務對照

- Step 1 對應 **starter-1**：index.html｜#service-list；學生停點：Elements 有 ul#service-list 與 p#status，且清單一開始是空的。；證據：在 Elements 搜尋 #service-list、#status，確認 id 只出現一次。
- Step 2 對應 **starter-2**：main.js｜services 與 querySelector；學生停點：Console 可觀察 services.length；程式知道要更新哪個清單與狀態。；證據：檔案中有 services、list、status 三個清楚責任的變數，且 selector 與 HTML id 一致。
- Step 3 對應 **starter-3**：main.js｜renderServices；學生停點：畫面項目數量和 services 筆數一致，特殊字串會以文字顯示而不是變成 HTML。；證據：Elements 看到 li 節點；第一筆的 <strong> 標記是文字，不是巢狀元素。
- Step 4 對應 **starter-4**：main.js｜renderServices(services)；學生停點：重新整理後看到三筆服務，status 顯示已渲染 3 筆，Console 沒有錯誤。；證據：畫面 li 數量、#status 文字與 Console 筆數一致。
- Step 5 對應 **checkpoint-1**：main.js｜card.textContent／card.innerHTML；學生停點：第一筆服務的 <strong> 標記會以文字顯示，Elements 不會多出 strong 子元素。；證據：修改前後比較第一個 li 的 innerHTML、childElementCount 與畫面文字。
- Step 6 對應 **faq-variant**：examples/faq-variant.html 的 faq 陣列與 #faq-list｜用 createElement 建立 li、h2、p，分別用 textContent 放入 question 與 answer，再 append 到 #faq-list。；學生停點：FAQ 兩筆資料變成兩個 li，每個 li 有 h2 與 p，資料內容沒有被當 HTML 解析。；證據：FAQ 入口畫面、Elements 的 li/h2/p 階層、status 文字與 Console 無錯誤。

## 6. 觀察與除錯

教師先示範故意錯誤，再要求學生依序寫下「症狀 → 證據 → 假設 → 單一修正 → 重新驗證」。本堂常見錯誤是：故意拼錯 #service-list 讓 querySelector 回傳 null，或先用 innerHTML 渲染含有 <strong> 標記的服務名稱，再從 Elements 與 Console 找出差異。

## 7. 學生任務

### 教師完整示範

示範 從 services 陣列建立 li 清單項目，將名稱與價格用 textContent 放入 #service-list，並用 #status 顯示實際筆數。；每次只修改一個檔案或區塊，先讓學生預測，再立即回到畫面、Elements、Console 與檔案行號。

### 學生跟做

把教師示範拆成三到四個 10–15 分鐘微任務：先完成入口，再完成核心修改，接著重現錯誤，最後留下驗收證據。學生必須在自己的資料夾重現，不以講師畫面成功為完成。

### 情境變體

把同一套資料→DOM 流程改成 FAQ 清單，新增空陣列狀態並保留資料與畫面分離。

### 獨立挑戰

不看 Solution，新增一個 testimonials 陣列，使用同一套 createElement、textContent、append 與 replaceChildren 產生 ul；額外測試空陣列並留下 Elements 與 Console 證據。

### 驗收證據

Starter、Checkpoint、Solution 入口可開啟、Elements 清單筆數與資料一致、特殊字串以文字顯示、空陣列有狀態、Console 無錯誤。學生需提交修改檔案清單、操作步驟、預期結果、實際觀察與一項可重現證據。

### 離場前回顧

請學生回答：我理解了什麼？我做出了什麼？我用什麼證據確認？下一堂會接著處理什麼？

下堂銜接：事件：下一堂會讓卡片、選單與 FAQ 對使用者操作做出反應。

### 官方來源補充

- [MDN：Document.querySelector()](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector)
- [MDN：Document.createElement()](https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement)
- [MDN：Node.textContent](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent)
- [MDN：Element.append()](https://developer.mozilla.org/en-US/docs/Web/API/Element/append)
- [MDN：Element.replaceChildren()](https://developer.mozilla.org/en-US/docs/Web/API/Element/replaceChildren)
- [MDN：script defer](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script#defer)
