閱讀書架可以包含標題、按鈕及簡短的狀態訊息。讀者將項目置頂時,三件事必須一致:程式中的布林狀態、卡片的外觀,以及按鈕的無障礙狀態。只改其中一項,頁面便會向不同的觀察者表達不同結果。
DOM 提供代表目前文件的物件。事件讓程式知道某個動作已發生。普通 JavaScript 物件則保存狀態,供下一次文件更新使用。以下會以一個只在瀏覽器內運作的小例子,把三者連接起來。
DOM 是由物件構成的樹
定義
文件物件模型
**文件物件模型(Document Object Model,DOM)**以節點樹代表文件。元素節點代表標題、按鈕等元素,文字節點保存文字。節點可以有子節點,而其位置決定了父節點與兄弟節點。
瀏覽器解析 HTML 時會建立 DOM。JavaScript 隨後可以改動同一棵樹:新增節點、修改文字,或移動既有元素,都會改變目前文件。因此,原始碼文字與目前 DOM 是兩種不同的表示。
在瀏覽器中,window 代表瀏覽視窗的全域環境,document 則提供其文件的存取介面。DOM 是供 JavaScript 使用的 API,而不是 JavaScript 語言本身。在瀏覽器以外執行的 JavaScript 程式,不會單憑使用相同語法便取得某個網頁的 DOM。
<section id="shelf">
<h2>Reading shelf</h2>
<p>One item is available.</p>
</section>
此 section 包含一個 h2 元素與一個 p 元素。換行及縮排也可能形成空白文字節點。這解釋了 children 與 childNodes 的差別:前者只包含子元素,後者也包含文字等其他類型的節點。
const shelf = document.querySelector("#shelf");
const heading = shelf.firstElementChild;
const paragraph = heading.nextElementSibling;
console.log(heading.tagName); // H2
console.log(paragraph.parentNode === shelf); // true
console.log(shelf.children.length); // 2
以上運算假設前面的文件已完成解析,且仍保留所示結構。firstElementChild、lastElementChild、nextElementSibling 與 previousElementSibling 在元素之間導航。到達邊界時便沒有下一個元素,例如最後一個元素的 nextElementSibling 是 null。
選取器識別節點;集合記錄匹配結果
document.getElementById("shelf") 按 ID 搜尋;document.querySelector("#shelf") 使用 CSS 選取器語法,傳回第一個匹配元素。查詢沒有匹配元素時會傳回 null。同一份文件中的 ID 應保持唯一;class 則可以描述多個元素。
querySelectorAll 傳回靜態 NodeList。該次查詢決定了集合的成員,但其中的節點仍是可以改動的普通 DOM 節點。相對地,getElementsByTagName 與 getElementsByClassName 傳回動態 HTMLCollection,其成員會隨文件改動而更新。
例題
預測靜態與動態集合
假設 #reading-list 起初包含兩個 li 元素。
const list = document.querySelector("#reading-list");
const snapshot = list.querySelectorAll("li");
const liveItems = list.getElementsByTagName("li");
const item = document.createElement("li");
item.textContent = "Browser rendering";
list.appendChild(item);
console.log(snapshot.length); // 2
console.log(liveItems.length); // 3
snapshot[0].textContent = "Revised first title";
由於沒有重新查詢,新元素不會加入 snapshot;liveItems 會追蹤樹的變化,所以包含新元素。最後一行仍會修改文件內的第一個元素:靜態集合固定的是成員,而不是每個成員物件的狀態。
常見錯誤
移除元素會在迭代過程中改變動態集合
若向前迴圈移除了 liveItems[0],原本的第二項便成為新的第一項。此時增加索引,可能跳過該項。要移除動態集合的所有項目,一個直接方法是在長度仍為正數時持續移除第一個元素;另一方法是先取得靜態 querySelectorAll 結果,再逐一處理所記錄的節點。
思考檢查
靜態 NodeList 固定的是甚麼?
在例子之後,程式修改第一項的文字,並加入第四項。原來的 snapshot 會顯示舊文字嗎?它的長度會變成四嗎?
解答 · 集合成員與節點狀態是兩回事
第一個項目仍參照同一個第一節點,所以讀取其 textContent 會取得修改後的文字。snapshot 的長度仍是二;它沒有取得任何後來加入的節點。動態集合則已包含四個項目。
選擇符合改動目的的屬性
| 操作 | 適合的介面 | 含義 |
|---|---|---|
| 讀取或替換普通節點文字 | textContent | 把提供的字串當作文字。 |
| 讀取文字輸入欄目前的值 | value | 取得控制項的目前值。 |
| 新增或移除一個 CSS class | classList.add、remove、toggle | 保留其他 class,改動指定的類別標記。 |
| 設定一項行內樣式 | style.backgroundColor | 修改該項行內 CSS 屬性。 |
| 讀取或設定 HTML 屬性 | getAttribute、setAttribute | 處理屬性的字串值。 |
innerHTML 會把所指定的字串解析成標記;textContent 則插入文字。例如,將 textContent 設為 "<strong>Selected</strong>",會顯示尖括號與標籤名稱。當內容是標題或訊息,而不是新的 HTML 結構時,這正是所需的行為。
innerText 與實際呈現的文字有關;樣式隱藏部分內容時,其結果可能與 textContent 不同。直接更新文字時,textContent 能清楚表達操作目的。
元素的 style 屬性代表行內宣告,並不列出樣式表中所有可能影響該元素的規則。許多互動狀態可以透過切換 class,再由 CSS 定義外觀,使維護更清晰。兩種方式可以共存,但同一個可見狀態應有清楚的管理來源。
明確建立及編輯節點
新建立的元素起初未連接至文件。當某個父節點接收它時,它才成為顯示文件的一部分。
const list = document.querySelector("#reading-list");
const item = document.createElement("li");
const label = document.createTextNode("Understanding events");
item.appendChild(label);
list.appendChild(item);
appendChild 把節點放到父節點的子節點末端。加入既有節點會移動該節點,而不會製造副本。insertBefore(newNode, existingChild) 將節點放在指定子節點之前;replaceChild(newNode, oldChild) 替換一個子節點;removeChild(child) 移除該父節點的一個子節點。元素也可以對自己呼叫 remove()。
父子關係在此十分重要。傳給 replaceChild 的 oldChild,或傳給 removeChild 的 child,必須確實屬於該父節點。DOM 操作描述的是結構改動,所以執行前應先識別所需的父節點。
思考檢查
加入節點會複製它嗎?
頁面上有兩個清單。程式保存第一個清單中某項目的參照,然後呼叫 secondList.appendChild(item)。之後共有多少個副本?哪個清單擁有該項目?
解答 · 同一節點在樹中只有一個位置
仍然只有一個項目節點。它從第一個清單移到第二個清單的末端,變數仍參照同一節點。要建立第二個獨立元素,必須另外建構或明確執行複製操作。
事件把動作連接至處理函式
事件監聽器是在事件目標上,為某種事件類型註冊的函式。當事件系統向該目標派發適當的 click 事件時,click 監聽器便會執行。註冊函式並不會立即執行其主體。
const button = document.querySelector("#pin");
button.addEventListener("click", handlePin);
function handlePin(event) {
console.log(event.currentTarget.id);
}
應傳入函式 handlePin,而不是呼叫 handlePin() 後得到的結果。在註冊時呼叫函式,會在錯誤時間執行它,並以其傳回值取代原本想註冊的監聽器。
事件物件描述已派發的事件。event.currentTarget 是目前執行中的監聽器所註冊的目標;event.target 則識別事件起源。若按鈕內含一個 span,而讀者點擊該 span,target 可以是 span,但按鈕監聽器的 current target 仍是按鈕。
對普通 click 等會冒泡的事件,事件沿樹傳播時,祖先上的監聽器也可能執行。因此,書架上的監聽器可以觀察起源於書架內某個按鈕的點擊。這不表示每種事件都會冒泡;應按具體事件的行為作判斷。
其他事件描述不同的動作。input 跟隨輸入控制項的編輯;change 表示已確認的變更。鍵盤處理函式會收到 event.key 等資料。按住按鍵時,keydown 處理函式可以重複執行,所以一次按鍵與一次滑鼠點擊未必產生相同的事件序列。
一個完整的置頂互動
例題
追蹤卡片兩次切換置頂的過程
以下獨立文件在記憶體內保存一個布林狀態,並由它推導可見呈現及無障礙狀態。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reading shelf</title>
<style>
body { max-width: 36rem; margin: 2rem auto; padding: 0 1rem; }
.pinned { border-inline-start: 0.35rem solid currentColor; padding-inline-start: 1rem; }
button { font: inherit; padding: 0.5rem; }
</style>
</head>
<body>
<main>
<h1>Reading shelf</h1>
<article id="book">
<h2>Understanding browser events</h2>
<button id="pin" type="button" aria-pressed="false">
<span aria-hidden="true">★</span> Toggle pin
</button>
<p id="status" aria-live="polite">Not pinned</p>
</article>
</main>
<script>
const book = { title: "Understanding browser events", pinned: false };
const card = document.querySelector("#book");
const button = document.querySelector("#pin");
const status = document.querySelector("#status");
function render() {
card.classList.toggle("pinned", book.pinned);
button.setAttribute("aria-pressed", String(book.pinned));
status.textContent = book.pinned ? "Pinned" : "Not pinned";
}
button.addEventListener("click", () => {
book.pinned = !book.pinned;
render();
});
render();
</script>
</body>
</html>
元素位於 script 之前,因此三次查詢都能找到所需節點。book.pinned 是模型狀態。render 把該狀態套用至 class、aria-pressed 屬性及狀態文字。把布林值作為 classList.toggle 的第二個參數,便能令 class 與該值一致:true 加入 class,false 移除 class。
| 階段 | book.pinned | 卡片含有 pinned | aria-pressed | 狀態文字 |
|---|---|---|---|---|
| 初次呈現 | false | 否 | "false" | Not pinned |
| 第一次點擊 | true | 是 | "true" | Pinned |
| 第二次點擊 | false | 否 | "false" | Not pinned |
處理函式改動一次狀態,再透過同一個函式更新所有表示。若只更新邊框,狀態文字與按鈕狀態便會不一致。重新載入文件時,會建立另一個 pinned: false 的物件;此例不會在文件載入之間保存狀態。
物件除了屬性,還有身分
物件是一組屬性的集合。屬性值也可以是物件、陣列、函式或基本值。點記法使用程式中直接寫出的屬性名稱,方括號記法則可以使用計算得到的鍵。
例題
兩個名稱參照同一個可變物件
先建立一個 pinned 屬性為 false 的書籍物件,再依序執行以下敘述,分清每一步是在建構新物件,還是在指派另一個參照。
const book = { title: "DOM trees", pinned: false };
const key = "title";
console.log(book.title); // DOM trees
console.log(book[key]); // DOM trees
const alias = book;
alias.pinned = true;
console.log(book.pinned); // true
兩次標題讀取都輸出 DOM trees:key 保存的名稱,與點記法選取的是同一個屬性。把 book 指派給 alias,會得到同一物件的另一個參照,而不是把屬性複製至新的獨立物件。因此,設定 alias.pinned 會改變其後 book.pinned 讀取的屬性,最後輸出 true。const 綁定阻止重新指派該綁定,卻不會凍結物件的屬性。
例題
建立獨立的簡單記錄
const original = { title: "DOM trees", pinned: true };
const copy = { title: original.title, pinned: original.pinned };
copy.pinned = false;
console.log(original.pinned); // true
console.log(copy.pinned); // false
console.log(original === copy); // false
物件字面值建立了一筆新記錄。在此例中,兩個屬性都是基本值,因此兩筆記錄可以獨立改動。若屬性保存另一個物件,單純指派該屬性值仍會共用巢狀物件。
物件也可以透過以 new 呼叫的函式來建構:
function ReadingItem(title) {
this.title = title;
this.pinned = false;
}
const first = new ReadingItem("DOM trees");
const second = new ReadingItem("Browser events");
console.log(first === second); // false
這裏每次 new 呼叫都會建立獨立物件,並以該物件作為 this 執行函式,形成可重複使用的初始化方式。字面值與建構函式是不同的建構技術;兩者都會產生可讀取及更新屬性的物件。
方法呼叫決定其接收者
普通函式的 this 值取決於呼叫方式。像 shelf.getCount() 這樣的方法呼叫,其接收者是 shelf。把函式複製至變數,不會永久保留該接收者。
"use strict";
const shelf = {
count: 2,
getCount() {
return this.count;
}
};
console.log(shelf.getCount()); // 2
const detached = shelf.getCount;
// detached() has this === undefined in this strict-mode call.
// Reading this.count there would throw a TypeError.
箭頭函式不會建立自己的 this,而是保留外圍環境的值。因此,監聽器需要取得其註冊元素時,使用 event.currentTarget 比假設所有函式形式都提供相同 this 行為更清楚。置頂例子不需要接收者或 this:其處理函式直接使用外圍的狀態與元素參照。
JSON 是資料的文字表示
JSON 可以表示物件、陣列、字串、數值、布林值及 null。物件鍵與字串值使用雙引號;註解及尾隨逗號不屬於其語法。JSON 文字不包含可執行的方法或 DOM 節點。
例題
解碼記錄、改動記錄,再編碼新狀態
先有一段 JSON 字串,描述一本已置頂的書及兩個標籤。以下逐步從文字建立記錄,再把改動後的記錄轉回文字。
const text = '{"title":"DOM trees","pinned":true,"tags":["web","reading"]}';
const record = JSON.parse(text);
console.log(record.tags[0]); // web
record.pinned = false;
const encoded = JSON.stringify(record);
console.log(typeof encoded); // string
解析會建立 record,其中 pinned 起初為 true,tags 是有兩個項目的陣列。索引零選出 web,形成第一個輸出。下一次指派把記錄的旗標改為 false;將該記錄編碼,會產生含有 "pinned":false 的新 JSON 字串,所以第二個輸出是 string。原來的變數 text 仍保存原本的字串;改動解析後的物件並沒有重寫它。
JSON.parse 讀取文字並建立值;JSON.stringify 則由支援的值產生文字。兩者都不會自動傳送或儲存資料。之後的單元會在通訊中使用這些表示,但資料表示與傳輸是不同操作。
將同一段 JSON 文字解析兩次,會建立兩筆獨立記錄。JSON 也不保留共用物件參照的身分:它描述資料值,而不是由參照及方法組成的圖。因此,JSON 往返轉換並非複製任意 JavaScript 物件的通用方法。
DOM 與程式庫介面是不同層次
jQuery 等程式庫提供自身的元素選取、事件註冊及內容更新介面,但這些操作仍作用於瀏覽器的 DOM。應分清三個層次:JavaScript 是語言,DOM 是瀏覽器 API,程式庫則在部分操作之上提供另一層介面。不論程式採用哪種介面,理解底層的節點及狀態改動仍然有用。
練習
思考檢查
練習 1:追蹤狀態更新
在置頂文件中,處理函式仍改變 book.pinned,但不再呼叫 render()。一次點擊後,布林值是甚麼?卡片、狀態文字與按鈕屬性分別顯示甚麼?解釋程式狀態與文件狀態為何不同。
解答 · 練習 1 解答
布林值變成 true。初次 render 已移除 class、顯示「Not pinned」,並把 aria-pressed 設為 "false"。沒有新的 render,這些文件值便保持不變。更新普通物件不會自動更新 DOM;程式必須把新值套用至各種呈現。
思考檢查
練習 2:識別事件涉及的元素
ID 為 pin 的 button 包含一個星形 span。按鈕與其外圍 article 各註冊了一個 click 監聽器。讀者點擊星形時,在普通冒泡且沒有改變傳播的前提下,事件的 target 與各監聽器的 current target 是甚麼?
解答 · 練習 2 解答
起源 target 是星形 span。按鈕監聽器執行時,currentTarget 是按鈕;article 監聽器執行時,currentTarget 是 article。事件起源保持相同,但目前執行的監聽器有不同的註冊目標。
思考檢查
練習 3:分清參照與 JSON 文字
設 a = { pinned: false }、b = a,以及 c = JSON.parse(JSON.stringify(a))。之後程式設定 b.pinned = true,a.pinned 與 c.pinned 分別是甚麼?建立 c 會把任何內容寫入磁碟嗎?
解答 · 練習 3 解答
a.pinned 變成 true,因為 a 與 b 參照同一物件。c.pinned 仍是 false,因為解析先前 JSON 文字時,建立了保存當時值的另一筆記錄。轉換只是在記憶體內建立字串,再建立物件,沒有執行磁碟寫入。
思考檢查
練習 4:選擇更新操作
標題要顯示字面文字 <em>DOM</em>。textContent 與 innerHTML 哪個屬性能得到此結果?若卡片已有 book compact 兩個 class,如何在保留它們的同時加入 pinned?
解答 · 練習 4 解答
把標題的 textContent 設為該字串;它會把尖括號與標籤名稱視作文字。innerHTML 則會解析出一個強調元素。使用 card.classList.add("pinned") 或 card.classList.toggle("pinned", true),便可保留 book 與 compact,同時加入指定 class。
這裏使用的語言工具,會在 JavaScript、函式與集合中進一步說明。