瀏覽器把這一頁收成物件之後,JavaScript 才碰得到畫面。這一篇從 window 與 document 開始,接著選取、改寫、建立節點,再把點擊、輸入與送出接上函式。最後用一份資料重畫清單。網路請求與瀏覽器儲存留到下一篇。
認識瀏覽器環境 JavaScript 在瀏覽器裡不是獨自存在,它一開始就站在分頁提供的物件上。這一章先分清瀏覽器物件模型與文件物件模型,再用一棵小樹看出文件怎麼層層包住。接著處理 script 什麼時候執行,避免程式在元素出現之前就去找它。
BOM 與 DOM 瀏覽器物件模型(Browser Object Model)描述分頁本身,文件物件模型(Document Object Model)描述這一份 HTML。文件物件掛在分頁物件上,所以改網址與改標題是兩層不同的物件,卻從同一個起點走下去。
graph LR
windowNode["window"] --> locationNode["location"]
windowNode --> historyNode["history"]
windowNode --> navigatorNode["navigator"]
windowNode --> documentNode["document"]
window 與 document window 代表這個分頁,是瀏覽器裡的全域物件。document 是它的一個屬性,指向目前這份文件。這一節只確認兩者的關係,還不改畫面上的文字。
觀念
瀏覽器分頁的全域物件是 window,文件物件 document 就掛在它身上,兩種讀法拿到的是同一個物件。用 const 宣告的名字留在程式裡,不會自動變成這個全域物件的屬性。
04-js/pages/85-js-window.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > window 與 document</title > </head > <body > <p > 結果在 DevTools 的 Console。</p > <script > console .log (window .window === window ); console .log (document === window .document ); const course = 'DOM' ; console .log (window .course ); </script > </body > </html >
解析
第一行是 true,因為全域物件的 window 屬性還是指回自己。
第二行是 true,直接寫 document 就是在讀 window 上的那份文件。
course 用 const 宣告,第三行印出 undefined,它沒有被掛到 window 上。
舊式的 var 才會變成全域物件的屬性。這一課不使用那種宣告。
換你試試: 在最後再印出 'setTimeout' in window。確認計時器函式一開始就在全域物件上。
參考答案
setTimeout 是瀏覽器放在 window 上的函式,所以 in 的結果是 true。course 仍然不是 window 的屬性,第三行還是 undefined。
04-js/pages/85-js-window.html // ... const course = 'DOM'; console.log(window.course); + console.log('setTimeout' in window); </script> </body> // ...
DOM 樹 document 裡面的標籤會組成一棵樹。html 是樹根元素,head 與 body 是它的兩個子元素,你寫在 body 裡的標題與段落再往下長。程式之後都是沿著這棵樹找到節點。
觀念
文件節點的最外層元素可以用 documentElement 拿到,它就是 html。head 與 body 則是 document 上現成的兩個屬性,對應樹的左右兩枝。這一節只印出標籤名,往下數子節點留到選取那一節。
graph TD
documentNode["document"] --> htmlNode["html"]
htmlNode --> headNode["head"]
htmlNode --> bodyNode["body"]
bodyNode --> mainNode["main"]
mainNode --> h1Node["h1 標題"]
mainNode --> pNode["p 段落"]
04-js/pages/86-js-dom-tree.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > DOM 樹</title > </head > <body > <p > 結果在 DevTools 的 Console。</p > <main > <h1 > 標題</h1 > <p > 段落</p > </main > <script > console .log (document .documentElement .tagName ); console .log (document .head .tagName ); console .log (document .body .tagName ); </script > </body > </html >
解析
第一行是 HTML,也就是畫面上那棵樹的根元素。
第二行是 HEAD,第三行是 BODY,對應 html 底下的兩枝。
標籤名由 tagName 讀出時是大寫。
body 裡的 main、標題與段落已經在樹上,這一支程式還沒有走下去讀它們。
換你試試: 再印出 document.title。它應是這份 HTML 的 title,也就是「DOM 樹」。
參考答案
title 是文件的標題文字,讀的是 head 裡的 title 元素。前三行的標籤名不變。
04-js/pages/86-js-dom-tree.html // ... console.log(document.head.tagName); console.log(document.body.tagName); + console.log(document.title); </script> </body> // ...
script 載入時機 瀏覽器由上往下讀 HTML。script 若在元素還沒被讀到時就執行,文件裡的 body 仍是 null,後面的程式就會失敗。這一節看四種做法:把 script 放在 body 尾端、加上 defer、等 DOMContentLoaded,以及模組腳本。
放在 body 尾端 上一篇的課堂 demo 把 script 放在 body 最後。放在最後時,前面的元素已經進到樹上,程式讀得到 body。外部檔是實務做法;為了閱讀方便,課堂 demo 把程式寫在同一份 HTML 裡。
觀念
瀏覽器讀到沒有 defer 的 script 會先停下來執行,再繼續往下讀 HTML。把 script 放在 body 尾端,執行當下 body 已經存在,tagName 讀得到。
04-js/pages/87-js-script-end.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > script 放在 body 尾端</title > </head > <body > <p id ="message" > 頁面文字</p > <script > console .log (document .body .tagName ); </script > </body > </html >
解析
script 寫在段落後面,瀏覽器先把段落放進樹,再執行這段 script。
document.body 已經是那個 body 元素,所以印出 BODY。
段落的 id 這時也已經在文件裡,只是這一支程式還沒有去選它。
換你試試: 把 script 移到 head,並拿掉任何 defer。程式改印 document.body 本身,確認結果是 null,頁面不要因此丟出錯誤。
參考答案
script 在 head 裡立刻執行時,瀏覽器還沒讀到 body,所以 document.body 是 null。這就是程式要嘛放在尾端、要嘛延後執行的原因。
04-js/pages/87-js-script-end.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > script 放在 body 尾端</title > <script > console .log (document .body ); </script > </head > <body > <p id ="message" > 頁面文字</p > </body > </html >
defer 外部檔可以留在 head,只要 script 帶有 src 並加上 defer。瀏覽器先下載,等整份文件解析完再依序執行,畫面就不會被一支很大的程式檔卡住。
內嵌的 script 沒有 src,寫上 defer 也不會延後。課堂 demo 為了閱讀方便把程式寫在同一份 HTML,所以這一頁把程式放在 body 尾端,執行時機和外部檔的 defer 同一類。
觀念
defer 只對有 src 的 script 生效,告訴瀏覽器先把 HTML 讀完再執行。多支 defer 會依照它們在文件裡的順序執行。這一頁沒有另開檔,改把同一段程式放在 body 尾端,執行時 body 已經存在。
04-js/pages/88-js-defer.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > defer</title > </head > <body > <p > 結果在 DevTools 的 Console。</p > <script > console .log (document .body .tagName ); </script > </body > </html >
解析
script 在段落後面,所以不會在 head 裡提前執行。
執行時 body 已經在樹上,印出 BODY。這和外部檔加上 defer 的時機同一類。
段落仍留在畫面上,程式只寫進 Console。
換你試試: 把 script 移到 head,不要加 defer,程式改印 document.body。重新整理後,Console 應出現 null。看完再移回 body 尾端。
參考答案
沒有 defer 的內嵌 script 在 head 裡會立刻執行,當時 body 還沒出現,所以印出 null。移回 body 尾端之後,才會再印出 BODY。外部檔若要留在 head,要加上 defer 才會延後。
04-js/pages/88-js-defer.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > defer</title > <script > console .log (document .body ); </script > </head > <body > <p > 結果在 DevTools 的 Console。</p > </body > </html >
DOMContentLoaded 有時外部檔必須放在 head,又不能加 defer。這時改聽文件解析完成的事件,等樹建好再讀 body。事件的綁法下一章會展開,這裡先用它把執行時機延後。
觀念
DOMContentLoaded 在瀏覽器解析完 HTML 時發出,當時圖片可以還沒下載完。這一頁的 script 放在 head,才能在登記當下看到 body 仍是 null。其他課堂 demo 把程式放在 body 尾端。外部檔若也放在 head 又沒有 defer,同樣要等這個事件。
04-js/pages/89-js-ready.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > DOMContentLoaded</title > <script > document .addEventListener ('DOMContentLoaded' , () => { console .log (document .body .tagName ); }); </script > </head > <body > <p > 結果在 DevTools 的 Console。</p > </body > </html >
解析
script 在 head,而且沒有 defer,所以一讀到標籤就執行到 addEventListener。
監聽只是先登記,函式要等文件解析完才跑,因此印出的是 BODY。
若在監聽外面直接讀 tagName,body 仍是 null,那一行會失敗。
換你試試: 在 addEventListener 之前先印出 document.body。第一行應是 null,事件裡那一行仍是 BODY。
參考答案
登記監聽的那一刻,解析還沒走到 body,所以第一行是 null。等 DOMContentLoaded 發生,第二行才印出 BODY。
04-js/pages/89-js-ready.html // ... <title>DOMContentLoaded</title> <script> + console.log(document.body); + document.addEventListener('DOMContentLoaded', () => { console.log(document.body.tagName); // ...
模組腳本 上一篇選讀把程式拆成模組時,script 要加上 type=”module”。這種腳本會延後執行,就算標籤寫在 head,也會等文件解析完。
觀念
模組腳本預設就有 defer 的時機,而且一份檔案一個作用域。標籤放在 head 時,這支程式仍會在 body 出現之後才執行,所以讀得到 tagName。
需要 import 與 export 時必須拆成多個檔。這一頁借用同一種外部 script,其他課堂 demo 則把程式寫在同一份 HTML。
04-js/pages/90-js-module.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 模組腳本</title > <script type ="module" src ="90-js-module.js" > </script > </head > <body > <p > 結果在 DevTools 的 Console。</p > </body > </html >
04-js/pages/90-js-module.js console .log (document .body .tagName );
解析
type 設成 module 之後,這支檔不會在讀到標籤時立刻執行。
執行時文件已經解析完,所以印出 BODY。
這一支還沒有使用匯出與匯入,只借用模組腳本的載入時機。
換你試試: 拿掉 type=”module”,程式改印 document.body。確認 Console 出現 null,再把模組型別加回去。
參考答案
拿掉模組型別後,head 裡的 script 會立刻執行,document.body 仍是 null。加回 type=”module” 才會延後到印出 BODY。
04-js/pages/90-js-module.html // ... <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>模組腳本</title> - <script type="module" src="90-js-module.js"></script> + <script src="90-js-module.js"></script> </head> <body> // ...
04-js/pages/90-js-module.js console .log (document .body );
BOM 瀏覽器物件模型是掛在 window 上的物件與函式。這一章先處理網址與導覽,再看視窗、瀏覽器資訊、列印與計時。會換頁、捲動或列印的動作都包成函式,打開頁面後在 Console 自己呼叫,結果看 DevTools 的 Console。
認識 window window 是這個分頁的全域物件。直接寫屬性名稱,就等於從它再點一次。網址、視窗與瀏覽器資訊都從這裡往下找。
全域物件 直接寫 innerWidth,讀到的就是 window.innerWidth,兩邊是同一個值。用 const 或 let 宣告的名字只留在自己的程式裡。底下這些物件也掛在 window 上,後面按用途拆開來用。
graph LR
windowNode["window"] --> locationNode["location"]
windowNode --> historyNode["history"]
windowNode --> navigatorNode["navigator"]
windowNode --> screenNode["screen"]
windowNode --> documentNode["document"]
04-js/pages/91-js-global.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 全域物件</title > </head > <body > <p > 結果在 DevTools 的 Console。</p > <script > console .log (window .innerWidth === innerWidth); const label = 'bom' ; let count = 1 ; console .log (window .label ); console .log (window .count ); </script > </body > </html >
解析
第一行是 true。直接寫名字,與從 window 點出來,是同一個值。
後兩行是 undefined。const 與 let 不會變成全域屬性。
const 與 let const 與 let 不會變成 window 的屬性。舊式的 var 才會掛上去,這一課不用。
換你試試: 在最後印出 typeof window.alert。內建函式會得到 'function'。
參考答案
alert 一開始就掛在 window 上,所以 typeof 是 'function'。這一行只詢問型別,不會把對話框叫出來。label 與 count 仍然不是 window 的屬性。
04-js/pages/91-js-global.html // ... console.log(window.label); console.log(window.count); + console.log(typeof window.alert); </script> </body> // ...
true undefined undefined function
常用的 BOM 物件 下面常用的五個物件都掛在 window 上,直接寫名字就讀得到。各用一句話記它負責什麼,後面的小節再拆開使用。
location 讀寫網址,也可以導向或重新載入。
history 在瀏覽紀錄裡返回、前進,或改寫目前這一筆。
navigator 讀語言與連線,也可以寫入剪貼簿。
screen 讀整塊螢幕的寬高,不隨視窗拉窄而變。
document 指向這一份 HTML,改內容留到下一章。
列印、捲動與計時不是獨立物件,而是 window 上的函式,後面各有一節。
網址與導覽 location 管網址列現在的內容,history 管這個分頁走過的紀錄。先讀出網址的各段,再決定要導向、重新載入,或在紀錄之間往返。不重新載入也能改網址的寫法放在選讀。
讀取網址(location) 一個網址由協定、主機、路徑、查詢字串與 hash 組成。location 把每一段拆成屬性,讀出來就能判斷目前在哪一頁、帶了什麼參數。
href 回傳完整網址。
origin 回傳協定、主機與連接埠。
protocol 回傳協定,會帶著最後的冒號。
host 回傳主機與連接埠。
pathname 回傳路徑。
search 回傳問號開頭的查詢字串,沒有時是空字串。
hash 回傳井字號開頭的片段,沒有時是空字串。
04-js/pages/92-js-location.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 讀取網址</title > </head > <body > <p > 結果在 DevTools 的 Console。</p > <script > console .log (location.href ); console .log (location.origin ); console .log (location.protocol ); console .log (location.host ); console .log (location.pathname ); console .log (location.search ); console .log (location.hash ); </script > </body > </html >
在網址後面加上 ?page=2#top 再重新整理,Console 會多出查詢字串與片段:
http://127.0.0.1:5500/01-html-css-js/04-js/pages/92-js-location.html?page=2#top http://127.0.0.1:5500 http: 127.0.0.1:5500 /01-html-css-js/04-js/pages/92-js-location.html ?page=2 #top
解析
第一行是完整網址,後面幾行是把它拆開的結果,用 Live Server 開啟與直接開檔時,協定和主機會不同。
search 包含開頭的問號,hash 包含開頭的井字號,沒有這兩段時都是空字串。
這支程式只有讀取,沒有改任何屬性,所以頁面不會跳走。
查詢字串若要取出單一參數,可以用 new URLSearchParams(location.search).get('page'),會得到字串 '2'。
換你試試: 指定 location.hash = 'next' 後再印一次 location.hash,確認網址列只多了 #next,頁面沒有整頁重新載入。
參考答案
指定 hash 時不必自己加井字號,瀏覽器會補上,所以印出 #next。改 hash 只是移到同一份文件的片段,不會向伺服器重新要一整頁。
04-js/pages/92-js-location.html // ... <p>結果在 DevTools 的 Console。</p> <script> - console.log(location.href); - console.log(location.origin); - console.log(location.protocol); - console.log(location.host); - console.log(location.pathname); - console.log(location.search); console.log(location.hash); + location.hash = 'next'; + console.log(location.hash); </script> </body> // ...
導向與重新載入(location) 要用程式換頁時,可以指定 location.href,或呼叫 assign、replace 這兩個方法。href 與 assign 效果相同,都會在瀏覽紀錄新增一筆,使用者按「上一頁」可以回到原頁;replace 則用新網址取代目前這一筆紀錄,原頁就回不去了。reload 不換網址,只是把目前這一頁重新載入。
指定 href 會導向並新增一筆紀錄。
assign 同樣導向並新增一筆紀錄,上一頁可以回來。
replace 導向並取代目前紀錄,上一頁回不到原頁。
reload 重新載入目前這一頁,不換網址。
導向會讓頁面立刻離開,如果直接寫在檔案裡,一開頁就會跳走。所以這個範例把每種寫法包成函式,打開頁面後在 Console 自己呼叫。
04-js/pages/93-js-redirect.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 導向與重新載入</title > </head > <body > <p > 在 DevTools 的 Console 呼叫 goAssign()、goReplace() 或 reloadPage()。</p > <script > const target = '92-js-location.html' ; const goAssign = ( ) => { location.assign (target); }; const goReplace = ( ) => { location.replace (target); }; const reloadPage = ( ) => { location.reload (); }; console .log ('目前頁面:' , location.pathname ); </script > </body > </html >
解析
在 Console 輸入 goAssign() 會跳到 92 號頁面,按上一頁可以回到這一頁。
重新打開這一頁再輸入 goReplace(),同樣跳到 92 號頁面,但按上一頁會跳過這一頁,回到更早的頁面。
reloadPage() 不換網址,Console 會被清空並重新印出目前頁面。
三種寫法都包在函式裡,只有呼叫時才會導向,頁面打開時不會自己跳走。
登入成功後要讓使用者能回上一頁時用 assign;導向錯誤頁或登入檢查失敗時用 replace,避免使用者按上一頁又回到不該停留的頁面。
換你試試: 新增一個 goHref 函式,用指定 location.href 的方式導向同一個目標,並確認它跟 goAssign 一樣可以按上一頁回來。
參考答案
指定 href 與呼叫 assign 都會新增一筆瀏覽紀錄,所以兩者按上一頁的結果相同。
04-js/pages/93-js-redirect.html // ... location.replace(target); }; + const goHref = () => { + location.href = target; + }; + const reloadPage = () => { location.reload(); // ...
返回與前進(history) back 回到上一筆紀錄,forward 前往下一筆。相對步數交給 go,筆數看 length,四個都在 history 上。這一節用網址片段造出幾筆紀錄,再從 Console 呼叫函式來往返。
back 回到上一筆,不回傳有用的值。
forward 前往下一筆,不回傳有用的值。
go 依整數步數移動,負數往回、正數往前,0 會重新載入。
length 回傳紀錄筆數。步數超出範圍時,頁面留在原地,筆數也不增加。
graph LR
olderNode["更早的紀錄<br/>back"] --> nowNode["目前這一筆"]
nowNode --> newerNode["下一筆<br/>forward"]
04-js/pages/94-js-history.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 返回與前進</title > </head > <body > <p > 在 DevTools 的 Console 呼叫 showPlace()、goBack()、goForward() 或 goSteps(步數)。</p > <script > if (location.hash === '' ) { location.hash = 'one' ; location.hash = 'two' ; location.hash = 'three' ; } const showPlace = ( ) => { console .log (location.hash ); console .log (history.length ); }; const goBack = ( ) => { history.back (); }; const goForward = ( ) => { history.forward (); }; const goSteps = (step ) => { history.go (step); }; showPlace (); </script > </body > </html >
空白分頁直接打開時,Console 是:
從練習選單點進來時,length 會把更早的頁面算進去,數字比 4 大。三段片段仍各加一筆。
呼叫 goBack(),等網址變成 #two,再呼叫 showPlace():
解析
片段還是空的時候才連寫 one、two、three,瀏覽器會補上井字號,停在 #three。
空白分頁的第一筆是打開這一頁,再加上三段片段,所以 length 是 4。
goBack 與 goSteps 不會在同一輪立刻改掉網址。等網址變了再呼叫 showPlace。
往返只在既有紀錄裡移動,length 維持原數字。go(0) 才會重新載入。
重新載入後網址仍帶著片段,if 就不會再累加。go(20) 這種超出範圍的步數會停在原頁。
換你試試: 在 if 裡再加一段,把 hash 設成 'four'。先把網址列的井字號與後面的字刪掉再重新整理。
然後呼叫 goSteps(-1),等網址變了再 showPlace,片段是 #three。
參考答案
多寫一段 four 之後,空白分頁的 length 變成 5,停在 #four。goSteps(-1) 回到 #three,筆數仍是 5。網址已經帶有片段時,if 不會重跑,所以要先清掉井字號再重新整理。
04-js/pages/94-js-history.html // ... location.hash = 'two'; location.hash = 'three'; + location.hash = 'four'; } const showPlace = () => { // ...
載入時:
呼叫 goSteps(-1),等網址變了再呼叫 showPlace():
選讀:pushState 與 replaceState pushState 多加一筆紀錄,並帶上一小包資料,文件不會重新載入。replaceState 改的是目前這一筆,筆數不增加。
pushState 多加一筆紀錄並寫入 state,文件不重新載入。
replaceState 改寫目前這一筆,筆數不增加。
第一個引數會變成 history.state,還沒寫過時是 null。
第二個引數是標題,現在的瀏覽器不用,傳空字串。
第三個引數是新的網址片段。
04-js/pages/95-js-state.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 選讀:pushState 與 replaceState</title > </head > <body > <p > 結果在 DevTools 的 Console。</p > <script > history.pushState ({ page : 1 }, '' , '#one' ); console .log (history.state .page ); console .log (location.hash ); history.replaceState ({ page : 1 , note : 'replaced' }, '' , '#one' ); console .log (history.state .note ); console .log (history.length ); </script > </body > </html >
空白分頁直接打開時:
從選單進來時,最後一行會把更早的頁面算進去。replaceState 不會讓這個數字再加 1。
解析
pushState 讓網址片段變成 #one,state 的 page 是 1,文件沒有整頁重載。
replaceState 改掉目前這一筆的 state,所以接著印出 replaced,筆數停在原地。
按上一頁時瀏覽器會發出 popstate。這一節先不監聽。
換你試試: 再 pushState 一筆 page 為 2 的資料,片段用 #two,然後印出 history.state.page 與 history.length。最後的 page 是 2,筆數比剛才多 1。
參考答案
第二次 pushState 變成目前這一筆,所以 page 是 2。空白分頁上,replace 之後的筆數是 2,再 push 一次變成 3。
04-js/pages/95-js-state.html // ... console.log(history.state.note); console.log(history.length); + + history.pushState({ page: 2 }, '', '#two'); + console.log(history.state.page); + console.log(history.length); </script> </body> // ...
視窗與畫面 這一節看視窗、螢幕與捲動。尺寸先印在一起對照,捲動再另外改位置。系統深色與寬度斷點留在選讀。
視窗與螢幕尺寸 視窗內部的尺寸會跟著拉動邊緣改變,整塊螢幕的像素則維持不變。內部寬高與像素比在 window 上,螢幕寬高在 screen 上。兩組數字放在同一份輸出,才看得出差別。
innerWidth 與 innerHeight 是視窗內部可排版的寬高,不含四周工具列。
outerWidth 是外框寬度。側邊沒有捲軸時,它可能與內部寬相同。
screen.width 是整塊螢幕的寬,不隨視窗拉窄而變。availWidth 再扣掉系統工作列。
devicePixelRatio 是一個 CSS 像素對應幾個實體像素。高密度螢幕常見 2,一般螢幕常是 1。
04-js/pages/96-js-size.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 視窗與螢幕尺寸</title > </head > <body > <p > 結果在 DevTools 的 Console。</p > <script > console .log (innerWidth); console .log (innerHeight); console .log (outerWidth); console .log (screen.width ); console .log (screen.availWidth ); console .log (devicePixelRatio); </script > </body > </html >
下面是某一種視窗與螢幕的例子,實際數字以你的裝置為準:
解析
前兩行是視窗內部的寬與高。拉動視窗邊緣後重新整理,這兩個數字會變。
第三行是外框寬度。頁面還沒有高到出現捲軸時,它常與第一行相同。
第四行是整塊螢幕的寬。把瀏覽器拉窄再重新整理,它仍維持螢幕大小。
第五行是扣掉系統工作列之後的可用寬度,不會大於螢幕寬。
最後一行是 devicePixelRatio。它掛在 window 上,直接寫名字即可。
換你試試: 再印出 outerHeight 與 screen.height。含工具列時外框高度通常更大,螢幕高度則不隨視窗拉窄而變。
參考答案
outerHeight 把工具列算進視窗高度,所以通常大於 innerHeight。screen.height 是整塊螢幕的高,不隨視窗拉窄而變。前六行仍是內部寬高、外框寬、螢幕寬、可用寬與像素比。
04-js/pages/96-js-size.html // ... console.log(screen.availWidth); console.log(devicePixelRatio); + console.log(outerHeight); + console.log(screen.height); </script> </body> // ...
1185 800 1200 1440 1440 2 900 900
捲動位置 捲動距離放在 scrollX 與 scrollY,單位是像素,停在最左上角時兩個都是 0。要改位置就用 scrollTo 或 scrollBy。這兩個方法掛在 window 上,包成函式後在 Console 呼叫,頁面才會動。
scrollX 與 scrollY 回傳目前捲動的像素,停在左上角時都是 0。
scrollTo 移到指定的 top 與 left,不回傳有用的值。
scrollBy 在目前位置上再加一段距離,也不回傳有用的值。
選項 behavior 設成 smooth 就是平滑,設成 auto 就是立刻跳。
graph LR
topNode["scrollY 為 0"] --> downNode["scrollBy 往下加"]
downNode --> backNode["scrollTo 回到 0"]
04-js/pages/97-js-scroll.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 捲動位置</title > </head > <body > <p > 在 DevTools 的 Console 呼叫 showScroll()、scrollDown() 或 scrollTop()。</p > <div style ="height: 1800px" > </div > <p > 頁面底部</p > <script > const showScroll = ( ) => { console .log (scrollX); console .log (scrollY); }; const scrollDown = ( ) => { scrollBy ({ top : 400 , left : 0 , behavior : 'smooth' }); }; const scrollTop = ( ) => { scrollTo ({ top : 0 , left : 0 , behavior : 'smooth' }); }; showScroll (); </script > </body > </html >
頁面載入時:
呼叫 scrollDown(),等捲動停下來再呼叫 showScroll():
解析
載入時頁面在最上方,所以 scrollX 與 scrollY 都是 0。
scrollDown 用 scrollBy 往下加 400,behavior 是平滑。
平滑捲動還沒停時就印位置,scrollY 仍可能是 0。等它停了再呼叫 showScroll。
scrollTop 用 scrollTo 回到左上角,同樣是平滑移動。
換你試試: 把下移距離從 400 改成 800。等捲動停下來再看 scrollY,約是 800。
參考答案
scrollBy 的 top 改成 800 之後,停下來的垂直距離約是 800。scrollTop 仍回到 0。
04-js/pages/97-js-scroll.html // ... }; const scrollDown = () => { - scrollBy({ top: 400, left: 0, behavior: 'smooth' }); + scrollBy({ top: 800, left: 0, behavior: 'smooth' }); }; const scrollTop = () => { // ...
選讀:媒體查詢與深色模式 matchMedia 讀的是瀏覽器目前是否符合某段媒體查詢。(prefers-color-scheme: dark) 用來問系統是不是深色。
直接寫名字就等於 window.matchMedia,寬度斷點也用同一種問法。
matchMedia 回傳一份查詢結果,不是布林。
matches 回傳目前是否符合,符合是 true,不符合是 false。
04-js/pages/98-js-media.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 選讀:媒體查詢與深色模式</title > </head > <body > <p > 結果在 DevTools 的 Console。</p > <script > const darkQuery = window .matchMedia ('(prefers-color-scheme: dark)' ); console .log (darkQuery.matches ); const wideQuery = matchMedia ('(min-width: 800px)' ); console .log (wideQuery.matches ); </script > </body > </html >
系統是淺色、視窗寬過 800 像素時:
解析
window.matchMedia 與直接寫名字是同一個函式。
回傳的是查詢結果,不是布林。matches 才是目前是否符合。
系統是淺色時第一行是 false。視窗寬過 800 像素時,第二行是 true。
改了系統深色或把視窗拉窄之後,要重新整理才會換成新的結果。
這一節不監聽偏好的後續變化。
換你試試: 再讀 (max-width: 600px)。視窗比 600 像素窄時,這一列是 true。
參考答案
第三段問的是視窗是否夠窄。寬過 800 像素時,最小寬度那列是 true,最大寬度 600 那列是 false。把視窗拉窄到 600 以下再重新整理,後兩列會對調。
04-js/pages/98-js-media.html // ... const wideQuery = matchMedia('(min-width: 800px)'); console.log(wideQuery.matches); + + const narrowQuery = matchMedia('(max-width: 600px)'); + console.log(narrowQuery.matches); </script> </body> // ...
選讀:瀏覽器資訊 navigator 提供語言、連線與識別字串,剪貼簿也從這個物件寫入。整節都是選讀,做完網址與視窗再看即可。
語言與連線(navigator) navigator.language 是瀏覽器偏好的語言,navigator.onLine 表示瀏覽器是否認為目前有網路。這一節只讀這些值,不改瀏覽器的設定。
language 回傳偏好語言的字串,繁體中文環境常見 zh-TW。
languages 回傳偏好語言清單,第一個多半與 language 相同。
onLine 回傳瀏覽器是否認為有網路,型別是布林。
userAgent 回傳一長串識別文字,內容會隨版本改變。
04-js/pages/99-js-navigator.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 語言與連線</title > </head > <body > <p > 結果在 DevTools 的 Console。</p > <script > console .log (navigator.language ); console .log (navigator.onLine ); console .log (navigator.userAgent ); </script > </body > </html >
下面是一台 Mac 上的 Chrome、繁體中文、目前有網路的例子。第三行的版本號會依你的瀏覽器不同:
zh-TW true Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
解析
第一行是偏好語言。繁體中文的環境常見 zh-TW。
第二行在瀏覽器認為有網路時是 true。它代表瀏覽器眼中的連線狀態。
第三行是 userAgent,中文叫使用者代理字串。瀏覽器每次向伺服器要網頁時,都會附上這串文字自我介紹,JS 也讀得到同一串。
這串文字由幾段組成,從左到右大致是:
Mozilla/5.0 是早年留下的開頭。現在幾乎所有瀏覽器都這樣寫,已經沒有實際意義。
括號裡的 Macintosh; Intel Mac OS X 10_15_7 是作業系統。Windows 會寫成 Windows NT 10.0,手機會出現 iPhone 或 Android。
AppleWebKit/537.36 (KHTML, like Gecko) 是排版引擎。寫「像 Gecko」是為了讓只認 Firefox 的舊網站也願意給正常版面。
Chrome/141.0.0.0 才是真正的瀏覽器與版本。
結尾的 Safari/537.36 也是為了相容舊網站而加上的,並不代表你在用 Safari。
所以同一串裡同時出現 Mozilla、Gecko、Chrome、Safari,是瀏覽器互相模仿留下的歷史包袱。近年瀏覽器還會刻意把部分資訊固定住,例如較新的 macOS 也一律寫成 10_15_7,版本號後面幾位也常被歸零,避免被拿來追蹤使用者。
識別字串不可靠 userAgent 適合拿來看看,不適合拿來寫判斷,因為內容會變、還會互相模仿,猜錯的機率很高。想知道某個功能能不能用,與其猜「這是哪一種瀏覽器」,不如直接檢查那個功能存不存在,例如寫 if (navigator.clipboard):有就使用,沒有就改用別的做法。這種寫法叫做功能偵測。
換你試試: 再印出 navigator.languages[0]。它多半與第一行相同。
參考答案
languages 是偏好語言的清單,第一個多半就是 language。最後一行仍是那串識別文字。
04-js/pages/99-js-navigator.html // ... console.log(navigator.onLine); console.log(navigator.userAgent); + console.log(navigator.languages[0]); </script> </body> // ...
zh-TW true Mozilla/5.0 ... zh-TW
寫入剪貼簿 剪貼簿是 navigator.clipboard,寫入用 writeText。呼叫包成函式,在 Console 執行。
它回傳 Promise,成功時用 .then 接下一行,Promise 的細節在下一篇。
04-js/pages/100-js-clipboard.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 寫入剪貼簿</title > </head > <body > <p > 在 DevTools 的 Console 呼叫 copyText()。</p > <script > const copyText = ( ) => { navigator.clipboard .writeText ('BOM' ).then (() => { console .log ('已複製' ); }); }; console .log ('呼叫 copyText()' ); </script > </body > </html >
載入時先印出提示。在 localhost 呼叫 copyText() 且成功時,會再多一行:
解析
頁面打開時只印出提示,不會自己寫進剪貼簿。
寫入的文字是 BOM,成功後 .then 才印已複製。
第二行要比第一行晚出現,因為要等 Promise 完成。
安全連線與 Promise writeText 要在 https 或 localhost 下使用,Live Server 的 127.0.0.1 算 localhost。Promise 的寫法見 JavaScript:非同步、Fetch 與瀏覽器儲存 。從 Console 呼叫時若出現錯誤,多半是網址不在安全連線,或瀏覽器還要求一次點擊。
換你試試: 把寫入的文字改成 'history',成功時改印 已寫入。
參考答案
writeText 的引數改成 history,then 裡的文字改成已寫入。呼叫方式仍是 copyText()。
04-js/pages/100-js-clipboard.html // ... <script> const copyText = () => { - navigator.clipboard.writeText('BOM').then(() => { - console.log('已複製'); + navigator.clipboard.writeText('history').then(() => { + console.log('已寫入'); }); }; // ...
列印與對話框 print 會打開系統的列印對話框。確認、警示與輸入是另外三個會把程式停住的函式,詳細做法在前一篇。這一節先講列印,再簡單列出另外三個。
列印 print 是 window 的方法,會打開列印對話框,預設印出目前這一頁。對話框關掉之前,函式裡的下一行不會跑。若直接寫在檔案最外層,一開頁就會跳出,所以包成函式再從 Console 呼叫。
04-js/pages/101-js-print.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 列印</title > </head > <body > <p > 在 DevTools 的 Console 呼叫 printPage()。</p > <script > const printPage = ( ) => { print (); }; console .log ('呼叫 printPage() 會打開列印對話框' ); </script > </body > </html >
解析
頁面打開時只印出提示,不會自己跳出列印。
在 Console 呼叫 printPage 才會打開對話框。
按取消就回到這一頁,網址與文件都留在原地。
換你試試: 在 print 前後各印「列印之前」和「列印之後」。關掉對話框才看得到第二句。
參考答案
print 會停住這個分頁。先印出「列印之前」,對話框關掉之後才印「列印之後」。載入時的那行提示仍然先出現。
04-js/pages/101-js-print.html // ... <script> const printPage = () => { + console.log('列印之前'); print(); + console.log('列印之後'); }; console.log('呼叫 printPage() 會打開列印對話框'); // ...
呼叫 printPage() 並關掉對話框之後:
呼叫 printPage() 會打開列印對話框 列印之前 列印之後
另開視窗 window.open 會開出新分頁或新視窗。呼叫當下若沒有使用者的點擊,瀏覽器通常會把這個視窗擋下來。
alert、confirm 與 prompt 這三個函式在 JavaScript 基礎:值、函式、陣列與模組 已經看過完整示範。這裡只記用途、回傳值,以及它們會把程式停住。三者都掛在 window 上。
alert 只顯示訊息,按掉之後回傳 undefined。
confirm 讓人選確定或取消,回傳 true 或 false。
prompt 回傳輸入的字串,按取消得到 null。
對話框關掉之前,函式裡的下一行不會跑,頁面的點擊也一起停。所以可以先用 confirm 問過,再決定要不要呼叫 print。
04-js/pages/102-js-confirm.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > alert、confirm 與 prompt</title > </head > <body > <p > 在 DevTools 的 Console 呼叫 printIfConfirmed()。</p > <script > const printIfConfirmed = ( ) => { const ok = confirm ('要列印這一頁嗎' ); console .log (ok); if (ok) { print (); } }; console .log ('呼叫 printIfConfirmed()' ); </script > </body > </html >
載入時只印出提示。呼叫 printIfConfirmed() 之後要先關掉確認框,才看得到 true 或 false:
呼叫 printIfConfirmed() true
解析
頁面打開時只印出提示,不會自己跳出確認或列印。
confirm 會停住這個分頁。印出 ok 的那一行要等對話框關掉才出現。
按確定時 ok 是 true,接著才呼叫 print。按取消時是 false,不會列印。
按取消就回到這一頁,網址與文件都留在原地。
換你試試: 按取消時改印「已取消」,按確定仍開啟列印。不要再印出 ok。
參考答案
按取消才走進 else,印出已取消,並且不會列印。按確定仍呼叫 print,Console 不會多出已取消。載入時的提示還是先出現。
04-js/pages/102-js-confirm.html // ... const printIfConfirmed = () => { const ok = confirm('要列印這一頁嗎'); - console.log(ok); if (ok) { print(); + } else { + console.log('已取消'); } }; // ...
按取消並關掉對話框之後:
計時器 計時器把函式排進稍後的工作,程式不會停在那一行乾等。setTimeout 做一次,setInterval 每隔一段做一次。兩者都回傳識別碼,要取消就交給對應的 clear 函式。這一節先把結果印在 Console。選讀再看 requestAnimationFrame,它對齊下一次畫面更新。
setTimeout 排程一次,回傳識別碼,交給 clearTimeout 取消。
setInterval 依間隔重複,回傳的識別碼交給 clearInterval。
時間只保證不會更早 延遲的毫秒是下限。分頁切到背景時,瀏覽器可能把計時器拖得更晚。
setTimeout 與 clearTimeout setTimeout 登記一次工作。還沒到點就可以用回傳的識別碼取消;到點之後再取消也不會拋錯,只是工作已經做完。
setTimeout 登記一次函式,至少等待第二個引數的毫秒,回傳識別碼。
clearTimeout 用那個識別碼取消。識別碼對不上時,什麼都不會發生。
graph LR
setNode["setTimeout"] --> idNode["識別碼"]
idNode --> clearNode["clearTimeout 取消"]
idNode --> runNode["到點執行"]
觀念
setTimeout 的第一個引數是到點要呼叫的函式,第二個是至少等待的毫秒。回傳值交給 clearTimeout 就會取消這一次。識別碼要留在變數裡,臨時寫出來的計時器沒有名字可取消。
04-js/pages/103-js-timeout.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > setTimeout 與 clearTimeout</title > </head > <body > <p > 結果在 DevTools 的 Console。</p > <script > const pending = setTimeout (() => { console .log ('這句不該出現' ); }, 500 ); clearTimeout (pending); setTimeout (() => { console .log ('一秒後' ); }, 1000 ); </script > </body > </html >
解析
pending 那次在 500 毫秒前就被取消,Console 不會出現「這句不該出現」。
第二個計時器沒有取消,大約一秒後印出「一秒後」。
兩次呼叫各自有識別碼,clearTimeout 只會取消你交出去的那一個。
頁面上的說明文字保持原樣,結果只在 Console。
換你試試: 把第二個延遲改成 2000,印出的文字改成「兩秒後」。被取消的那句仍然不可以出現。
參考答案
clearTimeout 仍拿 pending 的識別碼,所以 500 毫秒那次不會執行。留下的計時器等兩秒,印出「兩秒後」。
04-js/pages/103-js-timeout.html // ... clearTimeout(pending); setTimeout(() => { - console.log('一秒後'); - }, 1000); + console.log('兩秒後'); + }, 2000); </script> </body> // ...
setInterval 與 clearInterval setInterval 會依間隔重複呼叫,直到你清除它。回傳的識別碼要交給 clearInterval,不能拿去給 clearTimeout 用在語意上雖然多半仍能停,課堂仍配對使用,避免之後讀程式的人看錯。
setInterval 依間隔重複呼叫函式,回傳識別碼。
clearInterval 用那個識別碼停下。課堂把 set 與 clear 配成同一對。
觀念
間隔函式裡要自己決定何時停。把 setInterval 的回傳值留在變數,數到目標就 clearInterval。沒有清除的間隔會一直佔著這個分頁。
04-js/pages/104-js-interval.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > setInterval 與 clearInterval</title > </head > <body > <p > 結果在 DevTools 的 Console。</p > <script > let count = 0 ; const id = setInterval (() => { count += 1 ; console .log (count); if (count === 3 ) { clearInterval (id); console .log ('已停止' ); } }, 400 ); </script > </body > </html >
解析
count 用 let,因為每次間隔都要加 1。
大約每 400 毫秒印出 1、2、3。
數到 3 時用同一個 id 清除,後面不再繼續加。
「已停止」印出之後,這個間隔就結束了。
換你試試: 改成數到 5 才 clearInterval,停止訊息仍印在最後一個數字後面。
參考答案
條件改成 5 之後,間隔會多跑兩次才清除。識別碼仍是同一個 id。
04-js/pages/104-js-interval.html // ... count += 1; console.log(count); - if (count === 3) { + if (count === 5) { clearInterval(id); console.log('已停止'); // ...
選讀:requestAnimationFrame requestAnimationFrame 把函式排到下一次畫面更新,並回傳識別碼。cancelAnimationFrame 用這個識別碼取消。它只排一次,這一節不在回呼裡再排下一次。
requestAnimationFrame 把函式排到下一次畫面更新,回傳識別碼。
cancelAnimationFrame 用那個識別碼取消這一次。
04-js/pages/105-js-frame.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 選讀:requestAnimationFrame</title > </head > <body > <p > 結果在 DevTools 的 Console。</p > <script > const id = requestAnimationFrame (() => { console .log ('下一幀' ); }); console .log ('已預約' , id); </script > </body > </html >
第一行的數字是識別碼,每次可能不同。重點是它比下一幀更早出現。
解析
同步的那行先印出識別碼,回呼裡的文字等下一次畫面更新才出現。
識別碼交給 cancelAnimationFrame 就能取消這一次。
這一節只預約一幀。
換你試試: 用 cancelAnimationFrame 取消剛才的識別碼,讓下一幀不會出現。
參考答案
取消之後,回呼不會再印下一幀。Console 只留下已取消與識別碼。
04-js/pages/105-js-frame.html // ... console.log('下一幀'); }); - console.log('已預約', id); + cancelAnimationFrame(id); + console.log('已取消', id); </script> </body> // ...
DOM 文件物件模型是那棵已經建好的樹。這一章先把節點選出來,再改內容、屬性與 class,最後建立、搬移與移除節點。事件下一章才綁。程式仍在載入時跑一次,畫面會立刻變成結果。
選取元素 選取是後面所有改寫的起點。一個選擇器對上一個元素時用 querySelector,對上一整組時用 querySelectorAll。找不到時兩者的回傳值不一樣,先分清楚再呼叫方法。
querySelector 回傳第一個符合的元素,找不到時是 null。
querySelectorAll 回傳查詢當下的節點清單,沒有符合時長度是 0。
querySelector querySelector 從呼叫它的那個節點往下,在它的子孫層裡依文件順序找出第一個符合選擇器的元素。寫成 document.querySelector 時,起點就是整份文件。這一節先從 document 找起,並練習選不到時怎麼先停下來。
觀念
選擇器寫法和 CSS 相同,id 用井字號,class 用句點。找到就回傳那個元素;沒有符合的元素時回傳 null,這時不能再點出方法。
所以不一定要從 document 開始。querySelector 也掛在每個元素上,先選到某個容器,再對它呼叫,搜尋範圍就只剩那個容器的子孫,同名 class 只會對上那一塊裡面的元素,容器本身不算在內。
04-js/pages/106-js-query.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > querySelector</title > </head > <body > <h1 id ="title" > 原標題</h1 > <p class ="note" > 原補充</p > <script > const title = document .querySelector ('#title' ); title.textContent = '已選到標題' ; const missing = document .querySelector ('#missing' ); console .log (missing); if (missing) { missing.textContent = '有找到' ; } </script > </body > </html >
解析
第一個選擇器對上 h1,所以標題變成「已選到標題」。
#missing 在這份文件裡不存在,Console 印出 null。
if 先擋住 null,後面那行就不會去改一個不存在的元素。
換你試試: 再選到 class 為 note 的段落,把文字改成「這是補充」。標題與 null 的判斷留著。
參考答案
class 選擇器會對上那個段落。缺的 id 仍然是 null,所以 if 裡面不會執行。
04-js/pages/106-js-query.html // ... missing.textContent = '有找到'; } + + const note = document.querySelector('.note'); + note.textContent = '這是補充'; </script> </body> // ...
querySelectorAll 一次要改同一組元素時,改問 querySelectorAll。回傳的清單可以走訪,就算一個都沒有,也不會是 null。
觀念
回傳值是 NodeList,長度用 length 讀。它記得的是查詢當下的那些元素,之後再插入的新元素不會自動跑進這份清單。清單本身沒有 map,要逐項處理就用 forEach。從某個元素呼叫時,範圍只有它的後代。
04-js/pages/107-js-query-all.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > querySelectorAll</title > </head > <body > <ul id ="list" > <li class ="item" > 蘋果</li > <li class ="item" > 香蕉</li > <li class ="item" > 橘子</li > </ul > <script > const items = document .querySelectorAll ('.item' ); items.forEach ((item, index ) => { item.textContent = `${index + 1 } . ${item.textContent} ` ; }); console .log (items.length ); </script > </body > </html >
解析
選擇器對上三個 li,length 是 3。
forEach 的 index 從 0 開始,畫面上的序號改從 1 顯示。
原本文字被讀出來再組回去,所以蘋果仍在第一列。
清單是空的時 length 為 0,forEach 只是不跑,不會丟出錯誤。
換你試試: 序號保留,每項文字後面再接上「(清單)」。
參考答案
模板字串可以把序號、原本文字與後綴接成一次指派。三列都會帶上同一個後綴。
04-js/pages/107-js-query-all.html // ... const items = document.querySelectorAll('.item'); items.forEach((item, index) => { - item.textContent = `${index + 1}. ${item.textContent}`; + item.textContent = `${index + 1}. ${item.textContent}(清單)`; }); console.log(items.length); // ...
在容器裡找 querySelector 與 querySelectorAll 可以改從某一塊元素呼叫,搜尋就縮在那一塊裡面。同名 class 散落在不同區塊時,就不會互相抓到。這一節用新聞區與商品區比對數量。
觀念
這兩個方法文件上有,元素上也有。搜尋起點是呼叫它的那個節點,範圍是後代。容器自己要改由父層或 document 來選。
04-js/pages/108-js-query-scope.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 在容器裡找</title > </head > <body > <section id ="news" > <p class ="item" > 新聞一</p > <p class ="item" > 新聞二</p > </section > <section id ="shop" > <p class ="item" > 商品一</p > </section > <script > const shop = document .querySelector ('#shop' ); const news = document .querySelector ('#news' ); console .log (document .querySelectorAll ('.item' ).length ); console .log (shop.querySelectorAll ('.item' ).length ); console .log (shop.querySelector ('#shop' )); console .log (news.querySelector ('.item' ).textContent ); </script > </body > </html >
Console:
解析
整頁有三段 class 為 item 的段落,所以第一個長度是 3。
從商品區往下找,只剩商品一,長度是 1。
商品區自己的 id 不在它的後代裡,第三行是 null。
新聞區的第一段是新聞一,商品區的同名 class 沒有被改到。
換你試試: 只把新聞區第一個 .item 的文字改成「頭條」。商品區不要動。
參考答案
範圍縮在新聞區,所以只有頭條被改寫。兩區的數量仍是 3 與 1。
04-js/pages/108-js-query-scope.html // ... console.log(shop.querySelectorAll('.item').length); console.log(shop.querySelector('#shop')); + news.querySelector('.item').textContent = '頭條'; console.log(news.querySelector('.item').textContent); </script> // ...
節點關係 選到一個元素之後,可以沿樹往上、往下或往旁走,也可以問它自己符不符合某個選擇器。這一節以 section#card 為中心,四個方向分開看。文件裡外層是 article#story,中間是 main#app。
往上
parentElement 是直接父元素,沒有父元素時回傳 null。
closest 從自己往上找最近的符合者,含自己,找不到回傳 null。
往下
children 是直接子元素的即時集合,只算元素。
firstElementChild 與 lastElementChild 是第一個與最後一個子元素,沒有時回傳 null。
querySelector 找第一個後代,querySelectorAll 找全部後代,範圍不含自己。
同層
previousElementSibling 是前一個元素兄弟,沒有時回傳 null。
nextElementSibling 是後一個元素兄弟,沒有時回傳 null。
判斷
matches 回答自己是否符合選擇器,回傳 true 或 false,不去找別的元素。
graph TD
story["article#story<br/>closest 祖先"] --> app["main#app<br/>parentElement"]
app --> intro["p#intro<br/>previousElementSibling"]
app --> card["section#card<br/>被選到的元素"]
app --> outro["p#outro<br/>nextElementSibling"]
card --> heading["h2#heading<br/>firstElementChild"]
card --> text["p#text"]
card --> action["button#action<br/>lastElementChild"]
元素與文字節點 parentNode 與 childNodes 會把換行空白算成文字節點。
nextSibling 同樣會算到旁邊的空白。找元素時用帶 Element 的版本。
往上 父元素是包住它的那一層。還要再往上時,用選擇器找最近的祖先,自己如果已經符合也算。這一節從卡片出發,把兩層的 id 寫回狀態列。
觀念
parentElement 只看直接包住卡片的 main。closest 會一路往上,直到碰到符合的 article,找不到就停在 null。
04-js/pages/109-js-up.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 往上找</title > </head > <body > <article id ="story" > <main id ="app" > <p id ="intro" > 前言</p > <section id ="card" > <h2 id ="heading" > 標題</h2 > <p id ="text" > 內文</p > <button type ="button" id ="action" > 按鈕</button > </section > <p id ="outro" > 結語</p > </main > </article > <p id ="status" > </p > <script > const card = document .querySelector ('#card' ); const status = document .querySelector ('#status' ); status.textContent = `${card.parentElement.id} / ${card.closest('article' ).id} ` ; console .log (card.closest ('aside' )); </script > </body > </html >
Console:
解析
卡片的直接父元素是 main,所以狀態列前半是 app。
closest 找到外層 article,後半是 story。
文件裡沒有 aside,Console 是 null。
換你試試: 把 Console 那行改成 closest(‘#text’)。#text 在卡片裡面,不是祖先。
參考答案
closest 含自己、再往祖先找。卡片自己不是 #text,祖先也不是,所以仍是 null。狀態列維持 app / story。
04-js/pages/109-js-up.html // ... const status = document.querySelector('#status'); status.textContent = `${card.parentElement.id} / ${card.closest('article').id}`; - console.log(card.closest('aside')); + console.log(card.closest('#text')); </script> </body> // ...
往下 往下可以只看下一層,也可以用選擇器把所有後代找出來。這一節先數卡片的直接子元素,再只在卡片裡找段落。
觀念
children 只算元素,第一個與最後一個用 firstElementChild、lastElementChild。從卡片呼叫 querySelectorAll 時,範圍停在卡片的後代。
04-js/pages/110-js-down.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 往下找</title > </head > <body > <article id ="story" > <main id ="app" > <p id ="intro" > 前言</p > <section id ="card" > <h2 id ="heading" > 標題</h2 > <p id ="text" > 內文</p > <button type ="button" id ="action" > 按鈕</button > </section > <p id ="outro" > 結語</p > </main > </article > <p id ="status" > </p > <script > const card = document .querySelector ('#card' ); console .log (card.children .length ); console .log (card.firstElementChild .id ); console .log (card.lastElementChild .id ); console .log (card.querySelectorAll ('p' ).length ); </script > </body > </html >
Console:
解析
卡片下一層有標題、段落、按鈕,長度是 3。
第一個子元素是 heading,最後一個是 action。
卡片裡的段落只有內文那一段,前言在卡片外面,所以長度是 1。
換你試試: 再印出 childNodes.length。它會把標籤之間的換行也算進去。
參考答案
三個元素之間與前後各有換行,所以 childNodes 的長度是 7。前四行維持元素的結果。
04-js/pages/110-js-down.html // ... console.log(card.lastElementChild.id); console.log(card.querySelectorAll('p').length); + console.log(card.childNodes.length); </script> </body> // ...
同層 同一層的鄰居不包含父元素,也不包含自己的子元素。這一節讀卡片左右兩邊的元素 id。
觀念
previousElementSibling 與 nextElementSibling 只認元素。標籤之間的換行不會變成兄弟。
04-js/pages/111-js-side.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 同層元素</title > </head > <body > <article id ="story" > <main id ="app" > <p id ="intro" > 前言</p > <section id ="card" > <h2 id ="heading" > 標題</h2 > <p id ="text" > 內文</p > <button type ="button" id ="action" > 按鈕</button > </section > <p id ="outro" > 結語</p > </main > </article > <p id ="status" > </p > <script > const card = document .querySelector ('#card' ); console .log (card.previousElementSibling .id ); console .log (card.nextElementSibling .id ); </script > </body > </html >
Console:
解析
卡片前面的元素是前言,id 是 intro。
後面的元素是結語,id 是 outro。
標題在卡片裡面,不會被算成同層鄰居。
換你試試: 再印出 #intro 的 previousElementSibling。前言已經是 main 裡的第一個元素。
參考答案
前言沒有更前面的元素兄弟,所以第三行是 null。前兩行仍是 intro 與 outro。
04-js/pages/111-js-side.html // ... console.log(card.previousElementSibling.id); console.log(card.nextElementSibling.id); + console.log(document.querySelector('#intro').previousElementSibling); </script> </body> // ...
判斷 有時不必再找一個元素,只要知道手上這顆符不符合選擇器。matches 回答的是布林值。
觀念
matches 檢查元素自己。它不往上,也不往下,不符合時回傳 false,不會回傳 null。
04-js/pages/112-js-match.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > matches</title > </head > <body > <article id ="story" > <main id ="app" > <p id ="intro" > 前言</p > <section id ="card" > <h2 id ="heading" > 標題</h2 > <p id ="text" > 內文</p > <button type ="button" id ="action" > 按鈕</button > </section > <p id ="outro" > 結語</p > </main > </article > <p id ="status" > </p > <script > const card = document .querySelector ('#card' ); console .log (card.matches ('section' )); console .log (card.matches ('p' )); </script > </body > </html >
Console:
解析
卡片是 section,第一行是 true。
它不是段落,第二行是 false。
這個方法不回傳元素,所以沒有找到或沒找到的 null。
換你試試: 再印出 #text 是否符合 p。那一顆是段落。
參考答案
內文那顆段落符合 p,第三行是 true。卡片的兩個結果留著。
04-js/pages/112-js-match.html // ... console.log(card.matches('section')); console.log(card.matches('p')); + console.log(document.querySelector('#text').matches('p')); </script> </body> // ...
選讀:getElementById 與 getElements 系列 舊程式、文章與第三方範例仍常出現這組寫法,讀得到才能改。新的程式以 querySelector 與 querySelectorAll 為主。這一節只把對照建立起來,後面的練習仍用選擇器。
getElementById getElementById 用 id 找一顆元素。參數是 id 文字本身。這一節拿它和選擇器對到的結果比對,並看找不到時的回傳值。
觀念
參數不要加井字號。找到就回傳那顆元素,找不到回傳 null,結果與 querySelector 配上同一個 id 選擇器相同。
04-js/pages/113-js-by-id.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > getElementById</title > </head > <body > <h1 id ="title" > 原標題</h1 > <ul id ="list" > <li class ="item" > 蘋果</li > <li class ="item" > 香蕉</li > </ul > <script > const list = document .getElementById ('list' ); console .log (list === document .querySelector ('#list' )); console .log (document .getElementById ('missing' )); </script > </body > </html >
Console:
解析
兩種寫法對上同一顆 ul,第一行是 true。
getElementById 的參數是 list,井字號只出現在選擇器那一邊。
missing 不在文件裡,第二行是 null。
換你試試: 用 getElementById 選到 title,把文字改成「已用 id 選到」。
參考答案
標題被改寫。兩行 Console 仍是 true 與 null,因為清單與 missing 沒有變。
04-js/pages/113-js-by-id.html // ... console.log(list console.log(document.getElementById('missing')); + document.getElementById('title').textContent = '已用 id 選到'; </script> </body> // ...
即時集合 getElementsByClassName 與 getElementsByTagName 一次拿回一組元素。getElementsByName 依 name 找,表單欄位較常見,回傳的也是即時集合,這一節不另做練習。下面用 class 對照選擇器,看新增一列之後誰的長度會變。
觀念
回傳值是 HTMLCollection,文件一改它就跟著變,而且沒有 forEach。逐項走訪用 for…of,或先用 Array.from 轉成陣列。querySelectorAll 回傳的 NodeList 停在查詢當下,而且可以 forEach。
04-js/pages/114-js-live.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 即時集合</title > </head > <body > <ul id ="list" > <li class ="item" > 蘋果</li > <li class ="item" > 香蕉</li > </ul > <script > const list = document .getElementById ('list' ); const live = document .getElementsByClassName ('item' ); const snap = document .querySelectorAll ('.item' ); console .log (live.length ); console .log (snap.length ); const extra = document .createElement ('li' ); extra.className = 'item' ; extra.textContent = '橘子' ; list.append (extra); console .log (live.length ); console .log (snap.length ); for (const item of live) { console .log (item.textContent ); } </script > </body > </html >
Console:
解析
新增之前兩邊都是 2。
多了一顆 li 之後,即時集合的長度變成 3,節點清單仍是 2。
for…of 走訪的是變長之後的集合,所以印出蘋果、香蕉、橘子。
節點清單沒有自動收進橘子。它有 forEach,即時集合沒有。
換你試試: 再印出 getElementsByTagName(‘li’) 的長度,並用 Array.from 把查詢當下的清單轉成陣列後 forEach,印出每一項文字。
參考答案
標籤名那份也是即時集合,三顆 li 都算進去,長度是 3。forEach 走的是新增前的清單,只剩蘋果與香蕉。
04-js/pages/114-js-live.html // ... console.log(item.textContent); } + + console.log(document.getElementsByTagName('li').length); + Array.from(snap).forEach((item) => { + console.log(item.textContent); + }); </script> </body> // ...
操作元素 選到元素之後才能改它。先改文字與標記,再分清 attribute 與 property:日常直接改欄位,屬性方法只留給少數情況,最後讀畫面上算完的樣式、尺寸與位置。事件下一章才綁,需要人去觸發的函式先在 Console 呼叫。
內容 讀者看到的文字,和瀏覽器要解析的標記,是兩件事。隱藏起來的字也未必會跟著出現。下面先分清純文字的兩種讀法,再寫元素裡面的標記,最後看連元素自己一起換掉。
textContent 與 innerText 兩種都讀寫元素裡的文字。差別在於會不會看 CSS,以及會不會為了答案去排版。這一節用一段被藏起來的字把兩邊分開。
textContent 讀寫子樹的純文字,隱藏的字也包含在內。
innerText 讀寫畫面上看得到的文字,會先排版再回答。
觀念
textContent 把子樹裡的文字接起來,display 設成 none 的內容也算。innerText 依畫面上看得到的結果來讀,隱藏的字不會出現,而且讀的時候會觸發排版。
04-js/pages/115-js-content.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > textContent 與 innerText</title > </head > <body > <p id ="box" > 可見<span style ="display: none" > 隱藏</span > </p > <script > const box = document .querySelector ('#box' ); console .log (box.textContent ); console .log (box.innerText ); </script > </body > </html >
Console:
解析
第一行把可見與隱藏接在一起,是可見隱藏。
第二行只剩畫面上的可見。
innerText 會先排版才回答,文字很多時比另一種讀法慢。
換你試試: 把 textContent 設成「可見」,再印一次 innerText。
參考答案
整段被換成可見之後,沒有隱藏的子元素,innerText 也是可見。
04-js/pages/115-js-content.html // ... console.log(box.textContent); console.log(box.innerText); + box.textContent = '可見'; + console.log(box.innerText); </script> </body> // ...
innerHTML 讀寫的是元素裡面的標記。寫進去的字串會被解析成子節點,原來的子元素整段被換掉。使用者打的字不要從這裡放進頁面。
innerHTML 讀出裡面目前的標記字串。
指派新字串,或用 += 接上一段,都會清掉原子元素再整段重建。
觀念
指派新字串時,瀏覽器先清掉全部子元素,再依字串建立新的。原本子元素上的監聽不會留在新內容裡,輸入框裡打過的字也不會出現在畫面上。+= 會先讀出整段標記再整段寫回,連剛建立的子元素也會被換成新的一顆。
使用者打的字串若放進來,角括號會被當成標籤,這是跨站腳本攻擊(XSS)。純文字用 textContent。
04-js/pages/116-js-inner.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > innerHTML</title > </head > <body > <div id ="card" > <input id ="name" value ="小明" /> <button id ="save" type ="button" > 儲存</button > </div > <p id ="plain" > </p > <script > const card = document .querySelector ('#card' ); const name = document .querySelector ('#name' ); const save = document .querySelector ('#save' ); name.value = '改過' ; save.addEventListener ('click' , () => { console .log ('按過' ); }); console .log (name.value ); card.innerHTML = '<b id="mark">粗體</b>' ; const mark = document .querySelector ('#mark' ); card.innerHTML += '<i>斜體</i>' ; console .log (card.innerHTML ); console .log (mark.isConnected ); console .log (mark === document .querySelector ('#mark' )); console .log (name.isConnected ); console .log (save.isConnected ); </script > </body > </html >
Console:
改過 <b id="mark">粗體</b><i>斜體</i> false false false false
解析
第一行是改過的輸入值。按鈕上此時已有點擊監聽。
標記那行是 += 之後的結果,畫面上是粗體與斜體。
mark 在接上斜體之前就選到了。重建後它的 isConnected 是 false,和重新選到的 b 也不是同一顆。
name 與 save 的 isConnected 都是 false。輸入值與監聽留在舊節點上,畫面上沒有原來的輸入框與按鈕。
不要插入來路不明的字串 字串會被解析成標籤,使用者打的內容放進來就有跨站腳本攻擊(XSS)的風險。純文字放進 textContent。只有程式自己寫死的一小段標記,才偶爾交給 innerHTML。
換你試試: 把 '<b>純文字</b>' 放進 #plain 的 textContent,並印出 textContent。畫面上要看得到角括號。
參考答案
textContent 不解析標記,b 會照字串留在畫面上。上面的卡片仍是粗體與斜體。
04-js/pages/116-js-inner.html // ... console.log(name.isConnected); console.log(save.isConnected); + + const plain = document.querySelector('#plain'); + plain.textContent = '<b>純文字</b>'; + console.log(plain.textContent); </script> </body> // ...
改過 <b id="mark">粗體</b><i>斜體</i> false false false false <b>純文字</b>
outerHTML 讀出來的字串包含元素自己的開始與結束標籤。寫入時,瀏覽器用解析出來的節點換掉這顆元素。原來的變數不會自動改指到換上的那顆。
outerHTML 讀取時連同元素自己的標籤。
指派新字串會把元素本身換掉,變數仍指向舊元素。
觀念
讀取時,字串從自己的開始標籤排到結束標籤。指派之後舊元素離開頁面,再改舊變數,畫面仍是新節點上的文字。
04-js/pages/117-js-outer.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > outerHTML</title > </head > <body > <p id ="note" > 舊公告</p > <script > const note = document .querySelector ('#note' ); console .log (note.outerHTML ); note.outerHTML = '<p id="note">新公告</p>' ; const next = document .querySelector ('#note' ); console .log (note.isConnected ); console .log (note === next); note.textContent = '改舊的' ; console .log (next.textContent ); </script > </body > </html >
Console:
<p id="note">舊公告</p> false false 新公告
解析
第一行包含 p 自己的標籤,文字是舊公告。
舊變數的 isConnected 是 false,它和重新選到的元素比較也是 false。
舊變數改成改舊的之後,重新選到的文字仍是新公告。要改畫面,得改重新選到的那顆。
寫入 outerHTML 的注意事項
寫入後變數仍指向舊元素。舊元素已經離開頁面,isConnected 是 false。再改它,畫面不會變,要重新 querySelector 才拿得到新元素。
舊元素上的事件監聽跟著舊節點離開畫面。新元素沒有那些監聽。
剛 createElement、還沒插入頁面時,元素沒有父節點,指派後沒有變化。document.documentElement 的父節點是文件,不能整顆換掉,指派會丟出 NoModificationAllowedError。
和 innerHTML 一樣會把字串解析成標籤,使用者輸入有跨站腳本攻擊(XSS)的風險。
要換掉整顆元素時,用 replaceWith,或建立新元素再插上,比指派字串清楚。
換你試試: 把指派的字串改成 <section id="note">新公告</section>,再印出兩顆元素的 tagName。
參考答案
頁面上換成 section,舊變數仍是原來的 p。第一行還是換掉之前讀到的標記。
04-js/pages/117-js-outer.html // ... const note = document.querySelector('#note'); console.log(note.outerHTML); - note.outerHTML = '<p id="note">新公告</p>'; + note.outerHTML = '<section id="note">新公告</section>'; const next = document.querySelector('#note'); console.log(note.isConnected); // ... note.textContent = '改舊的'; console.log(next.textContent); + console.log(next.tagName); + console.log(note.tagName); </script> </body> // ...
<p id="note">舊公告</p> false false 新公告 SECTION P
attribute 與 property 文字和標記改的是元素裡面。標籤上寫的 attribute 是當初的字串,DOM 物件上的 property 是目前狀態。多數會對應並一起更新,下面三處不一樣。
讀 href 這個 property 會得到完整網址,標籤上仍是相對路徑。
輸入框的 value,property 是現在框裡的文字,attribute 停在一開始的值。
標籤上的 class 對應 className,兩邊字串會一起改。
觀念
日常改 property 就好。屬性方法讀的是標籤上的原始字串,下一節再談何時才需要。下面用輸入框和連結把三處差異印出來。
04-js/pages/118-js-prop.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > attribute 與 property</title > </head > <body > <input id ="name" type ="text" value ="原始" /> <a id ="link" class ="nav" href ="/tour" > 行程</a > <script > const name = document .querySelector ('#name' ); const link = document .querySelector ('#link' ); name.value = '目前' ; console .log (name.getAttribute ('value' )); console .log (name.value ); console .log (link.getAttribute ('href' )); console .log (link.href ); console .log (link.getAttribute ('class' )); console .log (link.className ); </script > </body > </html >
Console:
原始 目前 /tour http://127.0.0.1:5500/tour nav nav
解析
第一行仍是標籤上的原始,第二行才是框裡的目前。
第三行是標籤上的 /tour,第四行被補成完整網址。主機以你開這頁的位址為準,下面用 Live Server 當例子。
後兩行都是 nav。class 和 className 是同一段字,欄位不叫 class。
換你試試: 把 className 設成 ‘nav current’,再印 getAttribute(‘class’) 與 className。
參考答案
改 className 之後,標籤上的 class 跟著變成兩個名字。前面六行不變。
04-js/pages/118-js-prop.html // ... console.log(link.getAttribute('class')); console.log(link.className); + link.className = 'nav current'; + console.log(link.getAttribute('class')); + console.log(link.className); </script> </body> // ...
原始 目前 /tour http://127.0.0.1:5500/tour nav nav nav current nav current
屬性方法 已經有對應的 property,上一節直接改就好。沒有對應欄位、自訂屬性,或想讀 HTML 原本寫的值,才用屬性方法。讀寫的都是標籤上的字串。
讀原始字串,或問屬性在不在。
getAttribute 讀標籤上的字,沒有該屬性時回傳 null。
hasAttribute 只問在不在,回傳 true 或 false。
寫上一段字,或把屬性拿掉。
setAttribute 把字串寫回標籤,沒有該屬性就先加上。
removeAttribute 從標籤拿掉屬性,之後再讀是 null。
toggleAttribute 會在有無之間切換,第二個引數可以強制加上或拿掉。日常少用,這裡不展開。
觀念
aria-label 沒有同名 property,所以用方法改。自訂屬性也沒有對應欄位。想讀相對路徑時,方法拿得到標籤上寫的那段,href 則會補成完整網址。
04-js/pages/119-js-attr.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 屬性方法</title > </head > <body > <button type ="button" id ="go" aria-label ="收藏行程" > 收藏</button > <a id ="link" href ="/tour" > 行程</a > <script > const go = document .querySelector ('#go' ); const link = document .querySelector ('#link' ); console .log (go.getAttribute ('aria-label' )); go.setAttribute ('aria-label' , '已收藏' ); console .log (go.getAttribute ('aria-label' )); console .log (go.hasAttribute ('aria-label' )); go.removeAttribute ('aria-label' ); console .log (go.getAttribute ('aria-label' )); go.setAttribute ('data-note' , '熱門' ); console .log (go.getAttribute ('data-note' )); console .log (link.getAttribute ('href' )); console .log (link.href ); </script > </body > </html >
Console:
收藏行程 已收藏 true null 熱門 /tour http://127.0.0.1:5500/tour
解析
第一行是標籤上的收藏行程,第二行被改成已收藏。
第三行 true 表示屬性還在。拿掉之後,下一行是 null。
data-note 是自訂屬性,沒有對應欄位,寫進去再讀是熱門。
後兩行是同一條連結。方法讀到 /tour,href 是完整網址。主機以你開這頁的位址為準。
換你試試: 再印一次 hasAttribute(‘aria-label’)。移除之後應該是 false。
參考答案
aria-label 已經拿掉,所以最後一行是 false。data-note 還在。前面七行不變。
04-js/pages/119-js-attr.html // ... console.log(link.getAttribute('href')); console.log(link.href); + console.log(go.hasAttribute('aria-label')); </script> </body> // ...
收藏行程 已收藏 true null 熱門 /tour http://127.0.0.1:5500/tour false
property 存取(推薦) 上一節的方法留在少數情況。有對應欄位時,直接讀寫 property,這是日常做法。下面五段都掛在元素物件上,回傳的東西不一樣。
一般欄位直接改 id、src、alt、href、value。
布林欄位用 true 或 false 開關 hidden、disabled 與 checked。
dataset 讀寫 data- 開頭的自訂資料,得到的都是字串。
classList 管現在有哪些 class,一次加減一個名字。
style 只管行內樣式,樣式表裡的規則碰不到。
一般 property 字串欄位用點指定新值就好。src 與 href 讀出來是完整網址。輸入框的 value 前面看過,改的是框裡現在的字,標籤上不一定跟著變。
id 讀寫識別名稱,寫入後標籤上變成同一個字。
alt 讀寫替代文字,圖片載不到時顯示這段。
src 讀寫圖片位址,讀欄位得到完整網址。
href 讀寫連結位址,讀欄位同樣是完整網址。
value 讀寫輸入框現在的文字。
觀念
下面一次改圖片、連結和輸入框。id 與 alt 寫進去就印出來。src 改成相對路徑後,getAttribute 讀到的仍是 cover.png。href 設成完整網址,印出來就是這段。value 改的是框裡現在的字。
04-js/pages/120-js-field.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 一般 property</title > </head > <body > <img id ="photo" alt ="舊說明" src ="photo.png" /> <a id ="link" href ="/tour" > 行程</a > <input id ="name" type ="text" value ="原始" /> <script > const photo = document .querySelector ('#photo' ); const link = document .querySelector ('#link' ); const name = document .querySelector ('#name' ); photo.id = 'cover' ; photo.alt = '夏季特賣' ; photo.src = 'cover.png' ; console .log (photo.id ); console .log (photo.alt ); console .log (photo.getAttribute ('src' )); link.href = 'https://example.com/tour' ; console .log (link.href ); name.value = '目前' ; console .log (name.value ); </script > </body > </html >
Console:
cover 夏季特賣 cover.png https://example.com/tour 目前
解析
id 改成 cover,alt 改成夏季特賣。兩張圖故意不放,載不到時會看到替代文字。
第三行是標籤上的 cover.png。直接讀 src 會得到完整網址。
href 設成完整網址,印出來就是這段。
value 從原始改成目前,這一行是框裡的文字。
換你試試: 再印 getAttribute(‘value’) 與 value,對照標籤上的原始值。
參考答案
框裡已是目前,標籤上的 value 仍是原始。前面五行不變,最後兩行是原始與目前。
04-js/pages/120-js-field.html // ... name.value = '目前'; console.log(name.value); + console.log(name.getAttribute('value')); + console.log(name.value); </script> </body> // ...
cover 夏季特賣 cover.png https://example.com/tour 目前 原始 目前
布林 property 上一節那些欄位是字串。hidden、disabled、checked 是布林,要設 true 或 false,不要把 false 這個字寫進標籤。標籤上這類叫布林屬性,物件上則是布林 property。
hidden 為 true 時,元素不佔版面。
disabled 為 true 時,不能按、也不能改。
checked 為 true 時,方塊是勾選的,不要拿 value 判斷。
觀念
按鈕一開始帶著 disabled。設成 false 之後,屬性從標籤消失,按鈕就可以按。checked 用來表示有沒有勾,不要拿 value 判斷。hidden 設成 true 時,按鈕離開版面。
04-js/pages/121-js-bool.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 布林 property</title > </head > <body > <p > <button type ="button" id ="go" disabled > 送出</button > </p > <p > <input id ="ok" type ="checkbox" /> </p > <script > const go = document .querySelector ('#go' ); const ok = document .querySelector ('#ok' ); go.disabled = false ; console .log (go.disabled ); go.hidden = true ; console .log (go.hidden ); ok.checked = true ; console .log (ok.checked ); </script > </body > </html >
Console:
解析
disabled 設成 false,按鈕可以按。
hidden 設成 true,按鈕從版面消失。
checked 設成 true,方塊變成勾選。
布林不要寫成字串 setAttribute 的第二個引數一律是字串。把 disabled 加回標籤時,即使寫成 false 這個字,屬性只要在,disabled 就是 true,按鈕又不能按。要打開按鈕,把 disabled 設成 false。
換你試試: 把 hidden 設回 false,再印一次。按鈕會回到版面,而且可以按。
參考答案
hidden 回到 false 之後,按鈕重新出現。前面三行不變,disabled 仍是 false,checked 仍是 true。
04-js/pages/121-js-bool.html // ... ok.checked = true; console.log(ok.checked); + go.hidden = false; + console.log(go.hidden); </script> </body> // ...
dataset 前面用 setAttribute 寫得進自訂屬性。編號這類 data- 開頭的資料改走 dataset,不用自己拼前綴。讀到的值都是字串。
名字對上屬性,寫入後標籤會跟著多一段。
dataset.userId 對上 data-user-id,沒有該欄位時是 undefined。
getAttribute 要寫完整屬性名,沒有該屬性時回傳 null。
指定 level 會加上 data-level,讀回來仍是字串,不是數字。
觀念
下面先讀 userId,再寫入 level。兩種讀法都是字串,所以 7 不是數字。
04-js/pages/122-js-dataset.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > dataset</title > </head > <body > <p id ="item" data-user-id ="7" > 商品</p > <script > const item = document .querySelector ('#item' ); console .log (item.dataset .userId ); console .log (item.getAttribute ('data-user-id' )); item.dataset .level = '2' ; console .log (item.dataset .level ); console .log (item.getAttribute ('data-level' )); </script > </body > </html >
Console:
解析
前兩行都是 7。dataset 用小駝峰,getAttribute 用完整屬性名,值都是字串。
寫入 level 之後,標籤上多了 data-level,後兩行都是 2。
換你試試: 再設 dataset.unit 為 ‘支’,印出 getAttribute(‘data-unit’)。
參考答案
unit 沒有連字號,對上的屬性名是 data-unit。最後一行是支。前面四行不變。
04-js/pages/122-js-dataset.html // ... console.log(item.dataset.level); console.log(item.getAttribute('data-level')); + item.dataset.unit = '支'; + console.log(item.getAttribute('data-unit')); </script> </body> // ...
classList 前面用 className 會把 class 整段換掉。classList 是 property 回傳的物件,加減的是個別名字。顏色留在樣式表,下面讓盒子先變大,再換成另一個狀態。
加上、拿掉,或依目前狀態切換。
add 與 remove 可以一次傳多個 class。
toggle 省略第二個引數時,在有無之間切換。
第二個引數是布林時,改成強制加上或拿掉。
contains 回傳現在有沒有這個 class。
replace 拿掉舊名並放入新名,舊名不存在時不會放新的。
觀念
一次加上 on 與 big。toggle 的第二個引數是 false,所以原本沒有的 hit 不會被加上。replace 再拿掉 big、放上 hit。
04-js/pages/123-js-class.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > classList</title > <style > #box { padding : 0.75rem 1rem ; border : 2px solid #0f766e ; display : inline-block; } #box .on { background : #99f6e4 ; } #box .hit { background : #bbf7d0 ; } #box .big { font-size : 1.4rem ; } </style > </head > <body > <p id ="box" > 盒子</p > <script > const box = document .querySelector ('#box' ); box.classList .add ('on' , 'big' ); box.classList .toggle ('hit' , false ); console .log (box.classList .contains ('on' )); box.classList .replace ('big' , 'hit' ); console .log (box.classList .contains ('big' )); </script > </body > </html >
Console:
解析
add 一次放上 on 與 big,盒子出現青色背景與較大的字。
toggle 的第二個引數是 false,hit 被強制拿掉,不會因為原本沒有就反被加上。
replace 拿掉 big、放上 hit。樣式表裡 hit 寫在 on 後面,背景改成綠色。
第一行是 true,第二行是 false。
狀態用 class,數字才用 style 外觀的開關留在 class。寬度、座標這類每次不同的數字,才寫進行內樣式。
換你試試: 用 toggle 把 on 強制拿掉,再印一次 contains(‘on’)。
參考答案
強制拿掉之後,盒子只剩 hit。第三行是 false。
04-js/pages/123-js-class.html // ... box.classList.replace('big', 'hit'); console.log(box.classList.contains('big')); + box.classList.toggle('on', false); + console.log(box.classList.contains('on')); </script> </body> // ...
style class 管的是狀態,顏色留在樣式表。算出來的數字才寫進 style。它是 property 回傳的物件,只碰行內樣式,屬性名用小駝峰。
行內樣式用小駝峰讀寫,例如 backgroundColor。
把值設成空字串,會清掉那一條行內樣式。
setProperty 用來寫入 --gap 這類 CSS 變數。
觀念
背景色寫進 backgroundColor。變數名字以兩條減號開頭,style 物件沒有這種欄位,改用 setProperty。讀回來的顏色常常是 rgb 寫法。
04-js/pages/124-js-style.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > style</title > <style > #box { color : #0f766e ; padding : 0.75rem 1rem ; display : inline-block; } </style > </head > <body > <p id ="box" > 盒子</p > <script > const box = document .querySelector ('#box' ); box.style .backgroundColor = '#ccfbf1' ; box.style .setProperty ('--gap' , '8px' ); box.style .marginTop = 'var(--gap)' ; console .log (box.style .backgroundColor ); console .log (box.style .getPropertyValue ('--gap' )); </script > </body > </html >
Console:
解析
背景色寫進行內樣式,盒子多了一塊青色。
讀回來的顏色是 rgb(204, 251, 241),不是當初的十六進位。
變數 –gap 是 8px,上邊界用這個變數,所以盒子往下移一點。
換你試試: 把 backgroundColor 設成空字串,再印一次。變數留著。
參考答案
空字串清掉行內背景,樣式表沒有另設背景,青色就消失。第三行是空的。
04-js/pages/124-js-style.html // ... console.log(box.style.backgroundColor); console.log(box.style.getPropertyValue('--gap')); + box.style.backgroundColor = ''; + console.log(box.style.backgroundColor); </script> </body> // ...
選讀:讀取畫面結果 前面改的是元素上的 property。這一節讀的是瀏覽器算完、畫在畫面上的結果,不會寫回你設過的欄位。樣式問最終值,位置問排版之後的矩形。
getComputedStyle 讀最終樣式,唯讀。
尺寸與位置讀排版之後的寬高與座標,也可以把元素捲進視窗。
getComputedStyle 上一節的 style 讀不到樣式表裡的顏色。getComputedStyle 是 window 的方法,直接寫名字就能呼叫。它讀算完的最終樣式,每個值都是字串,而且唯讀。
style.color 只看行內樣式,樣式表裡的顏色讀不到。
getComputedStyle 回傳算完的結果,不能拿來改值。
觀念
這一頁的文字顏色寫在樣式表。第一行是空字串,第二行是 rgb 形式。狀態仍優先改 class,這裡只示範怎麼讀。
04-js/pages/125-js-computed.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > getComputedStyle</title > <style > #box { color : #0f766e ; } </style > </head > <body > <p id ="box" > 盒子</p > <script > const box = document .querySelector ('#box' ); console .log (box.style .color ); console .log (getComputedStyle (box).color ); </script > </body > </html >
Console:
解析
第一行是空字串,因為顏色不在行內樣式。
第二行是樣式表上的 #0f766e,以 rgb(15, 118, 110) 出現。
狀態仍優先改 class。這裡只示範怎麼讀已經生效的值。
換你試試: 把 style.color 設成 ‘rgb(15, 118, 110)’,再印一次 style.color。
參考答案
寫進行內之後,style.color 才讀得到。第三行與計算後的顏色相同。
04-js/pages/125-js-computed.html // ... console.log(box.style.color); console.log(getComputedStyle(box).color); + box.style.color = 'rgb(15, 118, 110)'; + console.log(box.style.color); </script> </body> // ...
rgb(15, 118, 110) rgb(15, 118, 110)
尺寸與位置 顏色問的是最終樣式。元素多大、離視窗多遠,要等排版之後才知道,而且不是你設過的 property。下面把讀尺寸和捲到某一段包成函式,打開頁面後在 Console 自己呼叫。
getBoundingClientRect 回傳相對視窗的矩形,裡面有 top、left、width、height。
offsetWidth 與 offsetHeight 回傳含框線的整數寬高。
scrollIntoView 把元素捲進視窗,不回傳有用的值。
觀念
矩形的 top 會跟著捲動變。offsetWidth 是含框線的整數寬。scrollIntoView 把 behavior 設成 smooth 就是平滑移動。
04-js/pages/126-js-rect.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 尺寸與位置</title > <style > #box { display : inline-block; padding : 8px ; border : 2px solid #0f766e ; } </style > </head > <body > <div style ="height: 240px" > </div > <p id ="box" > 目標</p > <div style ="height: 900px" > </div > <p id ="end" > 頁面底部</p > <script > const showBox = ( ) => { const box = document .querySelector ('#box' ); const rect = box.getBoundingClientRect (); console .log (Math .round (rect.top )); console .log (box.offsetWidth ); }; const goEnd = ( ) => { document .querySelector ('#end' ).scrollIntoView ({ behavior : 'smooth' }); }; console .log ('呼叫 showBox() 或 goEnd()' ); </script > </body > </html >
Console:
呼叫 showBox() 或 goEnd() 260 68
解析
載入時只印出提示,頁面不會自己捲動。
showBox 的第一個數字是目標離視窗頂端的距離,實際數字以你的畫面為準。
第二個數字是含框線的寬。offsetHeight 則是含框線的高。
goEnd 會平滑捲到頁面底部那一段。
換你試試: showBox 再多印 offsetHeight。
參考答案
第三個數字是含框線的高度。提示與前兩個數字仍會先出現。下面的高度只是一種視窗的例子。
04-js/pages/126-js-rect.html // ... console.log(Math.round(rect.top)); console.log(box.offsetWidth); + console.log(box.offsetHeight); }; const goEnd = () => { // ...
呼叫 showBox() 或 goEnd() 260 68 40
節點操作 頁面載入時還沒有的列,要用程式建立後再插進樹。已經在樹上的節點若再插入別處,瀏覽器是把它搬走,不是複製一份。複製、移除與清空留在下一節。
建立與插入 新的節點先被做出來,再決定放在目標的哪一邊。四個方法對上四個位置,舊的寫法只要看得懂即可。下面這張圖以 p#target 為中心。
建立
createElement 做出一顆還沒掛上文件的元素。
createTextNode 做出純文字節點,沒有自己的標籤。
cloneNode 複製節點,留到下一節再練。
以目標為基準
before 放在目標同層的前面,可一次放多個節點或字串。
prepend 放在目標內部的最前面。
append 放在目標內部的最後面。
after 放在目標同層的後面。
位置字串
beforebegin 就是 before 那個位置。
afterbegin 就是 prepend 那個位置。
beforeend 就是 append 那個位置。
afterend 就是 after 那個位置。
搬移
把已在頁面上的節點再插一次就是搬走,文件裡仍只有一顆。
graph TD
beforeNode["before / beforebegin<br/>目標前面"] --> targetNode["p#target"]
targetNode --> startNode["prepend / afterbegin<br/>內部最前"]
targetNode --> endNode["append / beforeend<br/>內部最後"]
afterNode["after / afterend<br/>目標後面"]
targetNode --> afterNode
舊的插入寫法 appendChild 與 insertBefore 一次只收一顆節點,而且會回傳那顆節點。現在的 before、prepend、append、after 可以混用節點與字串,字串會變成純文字。課堂用新的四個方法。
同一個目標可以同時示範四個插入點。字串若直接放進這四個方法,會變成純文字。這一節改用元素與一個文字節點,讓位置在畫面上分開。
觀念
before 與 after 是目標的兄弟。prepend 與 append 在目標裡面。createTextNode 做出的文字沒有標籤,接在終點後面。
insertAdjacentElement 與 insertAdjacentHTML 用前面那四個位置字串,效果與四個方法相同。
insertAdjacentText 插入的是純文字,不會被解析成標籤。
04-js/pages/127-js-create.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 建立與插入</title > </head > <body > <div id ="stage" > <p id ="target" > 目標</p > </div > <script > const stage = document .querySelector ('#stage' ); const target = document .querySelector ('#target' ); const before = document .createElement ('p' ); before.textContent = '前面' ; target.before (before); const start = document .createElement ('span' ); start.textContent = '起點' ; target.prepend (start); const end = document .createElement ('span' ); end.textContent = '終點' ; target.append (end); const after = document .createElement ('p' ); after.textContent = '後面' ; target.after (after); target.append (document .createTextNode ('尾端文字' )); const labels = []; for (const node of stage.children ) labels.push (node.textContent ); console .log (labels.join (' | ' )); </script > </body > </html >
Console:
解析
舞台上的三列依序是前面、目標內部的文字、後面。
目標內部從左到右是起點、目標、終點、尾端文字,所以中間沒有空格。
before 放在目標同層前面,位置字串是 beforebegin。
prepend 放在目標內部最前,位置字串是 afterbegin。
append 放在目標內部最後,位置字串是 beforeend。
after 放在目標同層後面,位置字串是 afterend。
換你試試: 把「前面」再 append 到 #stage。觀察它從第一列變成最後一列,而且沒有變兩顆。
參考答案
已在頁面上的節點再插入就是搬移。第二行只剩一顆「前面」,而且在最後。
04-js/pages/127-js-create.html // ... for (const node of stage.children) labels.push(node.textContent); console.log(labels.join(' | ')); + + stage.append(before); + const moved = []; + for (const node of stage.children) moved.push(node.textContent); + console.log(moved.join(' | ')); </script> </body> // ...
前面 | 起點目標終點尾端文字 | 後面 起點目標終點尾端文字 | 後面 | 前面
移動、複製與移除 同一個節點在文件裡只會有一個位置。把已經存在的節點再 append 到別處,它會從舊位置消失並出現在新位置。要留下原件,就先複製。
cloneNode 複製節點。引數 true 時連子節點一起複製,回傳的新節點還沒掛上文件。
把已在頁面上的節點再插入別處是搬移,文件裡仍只有一顆。
remove 把節點從樹上拿掉。
replaceChildren 不帶引數時清掉全部子節點。
觀念
cloneNode(true) 連子節點一起複製,回傳的是新節點,還沒有掛上文件。remove 把節點從樹上拿掉。replaceChildren 不帶引數時,會清掉全部子節點。
04-js/pages/128-js-node-move.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 移動、複製與移除</title > </head > <body > <ul id ="list" > <li id ="first" > 第一</li > <li id ="second" > 第二</li > </ul > <script > const list = document .querySelector ('#list' ); const first = document .querySelector ('#first' ); const copy = first.cloneNode (true ); list.append (copy); const second = document .querySelector ('#second' ); list.prepend (second); </script > </body > </html >
解析
複製出來的「第一」被 append 到結尾,原來的第一列還在。
#second 本來就在清單裡,prepend 是把它搬到最前,不會變成兩顆。
true 代表深複製。傳 false 時只複製元素自己,裡面的文字不會跟著走。
換你試試: 改成先 replaceChildren 清空清單,再 append 一列「只剩這項」。
參考答案
replaceChildren 拿掉原本的兩列與任何複製。畫面上只留下新建立的那一列。
04-js/pages/128-js-node-move.html // ... <script> const list = document.querySelector('#list'); - const first = document.querySelector('#first'); - const copy = first.cloneNode(true); - list.append(copy); + list.replaceChildren(); - const second = document.querySelector('#second'); - list.prepend(second); + const item = document.createElement('li'); + item.textContent = '只剩這項'; + list.append(item); </script> </body> // ...
選讀:template 元素 同一種列要做很多次時,可以把骨架放在 template 裡。template 的內容不會直接出現在畫面上,程式複製之後再填文字、再插入。
觀念
template.content 是一份文件片段。cloneNode(true) 複製出片段,查詢與填值都在片段上做完,最後 append 到真正的清單。片段插進去時,外層不會多包一層。
04-js/pages/129-js-template.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 選讀:template 元素</title > </head > <body > <template id ="row" > <li > <span class ="name" > </span > </li > </template > <ul id ="list" > </ul > <script > const list = document .querySelector ('#list' ); const row = document .querySelector ('#row' ); const fragment = row.content .cloneNode (true ); fragment.querySelector ('.name' ).textContent = '筆' ; list.append (fragment); </script > </body > </html >
解析
template 自己不佔畫面上的一列,清單一開始是空的。
複製後在片段裡找到 span,文字改成「筆」。
append 片段時,瀏覽器放入的是裡面的 li。
換你試試: 用同樣的步驟再加一列,文字是「紙」。畫面上要有兩列。
參考答案
每次 cloneNode 都得到新的片段,改其中一列不會改到另一列,也不會改到 template 原本的骨架。
04-js/pages/129-js-template.html // ... const list = document.querySelector('#list'); const row = document.querySelector('#row'); - const fragment = row.content.cloneNode(true); - fragment.querySelector('.name').textContent = '筆'; - list.append(fragment); + const pen = row.content.cloneNode(true); + pen.querySelector('.name').textContent = '筆'; + list.append(pen); + + const paper = row.content.cloneNode(true); + paper.querySelector('.name').textContent = '紙'; + list.append(paper); </script> </body> // ...
DOM 事件 到這裡為止,程式都在載入時改一次畫面。事件讓函式改在使用者動作時才跑。這一章先綁定與解除,再讀事件物件,然後處理輸入、鍵盤、委派與表單送出。捕獲與冒泡的完整階段放在選讀。
綁定與解除 監聽是「這個元素發生這件事時,呼叫這個函式」。函式要留成同一個參考,之後才拿得掉。同一個事件可以疊好幾個函式,也可以聲明只跑一次。
addEventListener 登記一份監聽,同一事件可以疊很多份。
removeEventListener 用同一個函式參考拿掉其中一份,匿名函式對不上就拿不掉。
addEventListener 點擊是第一個要接上的事件。按鈕被按下時改寫狀態文字,並另外登記一個只會執行一次的監聽。
addEventListener 用事件名與函式登記一份監聽,同一事件可以疊很多份。
選項 once 讓這份跑完第一次就被移除。
觀念
addEventListener 的第一個引數是事件名,第二個是函式。選項 { once: true } 讓這個函式在第一次之後就被拿掉。
04-js/pages/130-js-listen.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > addEventListener</title > </head > <body > <button type ="button" id ="go" > 點我</button > <p id ="status" > 尚未點</p > <script > const status = document .querySelector ('#status' ); const go = document .querySelector ('#go' ); go.addEventListener ('click' , () => { status.textContent = '點過了' ; }); go.addEventListener ('click' , () => { console .log ('只會看到一次' ); }, { once : true }); </script > </body > </html >
解析
兩份監聽都綁在同一個按鈕的 click 上,第一下會先改文字,再印出那句。
第二份帶有 once,所以從第二下開始 Console 不再出現那句。
第一份沒有 once,之後每次點擊仍會把文字設成「點過了」。
換你試試: 把只會跑一次的那份改成設定文字「首次點過了」,不要再印到 Console。觀察第一下與第二下的文字。
參考答案
同一個點擊會依登記順序執行。第一下先寫成「點過了」,再被改成「首次點過了」。第二下只剩第一份監聽。
04-js/pages/130-js-listen.html // ... }); go.addEventListener('click', () => { - console.log('只會看到一次'); + status.textContent = '首次點過了'; }, { once: true }); </script> // ...
removeEventListener 解除監聽時,瀏覽器用函式參考來辨認是哪一份。匿名函式各寫一次就是不同的函式,對不上,也就拿不掉。
觀念
把函式先存進變數,addEventListener 與 removeEventListener 都傳這同一個變數。事件名也要相同,這裡都是 click。
04-js/pages/131-js-unlisten.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > removeEventListener</title > </head > <body > <p id ="status" > 0</p > <button type ="button" id ="go" > 加一</button > <button type ="button" id ="stop" > 停止</button > <script > const status = document .querySelector ('#status' ); const go = document .querySelector ('#go' ); const stop = document .querySelector ('#stop' ); const onClick = ( ) => { const count = Number (status.textContent ) + 1 ; status.textContent = String (count); }; go.addEventListener ('click' , onClick); stop.addEventListener ('click' , () => { go.removeEventListener ('click' , onClick); }); </script > </body > </html >
解析
按下「加一」時,onClick 把段落裡的數字加 1。
按下「停止」後,同一個 onClick 被移除,再按「加一」不會增加。
停止鈕自己的監聽是另一個函式,不受這次移除影響。
換你試試: 在 onClick 裡,數字變成 3 時就移除自己。停止鈕仍然可以提前移除。
參考答案
函式執行中可以拿自己的參考去解除。第三次點擊把數字設成 3 之後,這份監聽已經不在了。
04-js/pages/131-js-unlisten.html // ... const count = Number(status.textContent) + 1; status.textContent = String(count); + if (count === 3) { + go.removeEventListener('click', onClick); + } }; go.addEventListener('click', onClick); // ...
選讀:onclick 等舊式寫法 HTML 屬性與元素上的 onclick 都還在規格裡,舊程式與舊教學很常見,所以要看得懂。新的程式一律用 addEventListener。這一節用三顆按鈕把三種寫法並排。
HTML 屬性把一段字串寫在標籤上,只能呼叫全域函式。
指派給 onclick 只能留一個函式,後寫的會蓋掉前面的。
addEventListener 可以疊很多份,也能帶上只跑一次或捕獲階段的選項。
觀念
標籤上的 onclick 是一段字串,只能呼叫全域函式,函式宣告才會變成全域,const 則不會。把函式指派給元素的 onclick 時,一顆元素只能留一個,後寫的蓋掉前面的,解除時設回 null。它不能設定 once 或 capture。嚴格的內容安全政策會擋掉寫在標籤上的事件。
04-js/pages/132-js-onclick.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 選讀:onclick</title > </head > <body > <button type ="button" id ="old" onclick ="fromHtml()" > HTML 屬性</button > <button type ="button" id ="prop" > 屬性指派</button > <button type ="button" id ="modern" > addEventListener</button > <p id ="status" > 尚未點</p > <script > const status = document .querySelector ('#status' ); function fromHtml ( ) { status.textContent = '來自 HTML 屬性' ; } const prop = document .querySelector ('#prop' ); prop.onclick = () => { status.textContent = '第一個函式' ; }; prop.onclick = () => { status.textContent = '只剩第二個' ; }; const modern = document .querySelector ('#modern' ); modern.addEventListener ('click' , () => { console .log ('第一份' ); }); modern.addEventListener ('click' , () => { console .log ('第二份' ); }); </script > </body > </html >
Console:
解析
點第一顆時,狀態變成來自 HTML 屬性。那份程式是字串,而且呼叫全域函式。
第二顆被指派兩次,畫面上只剩只剩第二個。第一個函式已經被蓋掉。
第三顆的兩份監聽都會執行。點下去時 Console 先出現第一份,再出現第二份。
舊式寫法沒有被規格移除。新程式仍用 addEventListener,才能疊加、移除,並帶上選項。
換你試試: 在兩次指派之後,把第二顆的 onclick 設成 null。再點它應沒有反應。
參考答案
設成 null 會清掉這顆按鈕的舊式監聽。第三顆的兩份 addEventListener 仍會印出。
04-js/pages/132-js-onclick.html // ... status.textContent = '只剩第二個'; }; + prop.onclick = null; const modern = document.querySelector('#modern'); modern.addEventListener('click', () => { // ...
event 物件 監聽的函式會收到一個事件物件,裡面有這次動作的資訊。最常用的是實際點到誰、監聽掛在誰身上,以及要不要取消瀏覽器原本會做的事。
共通
type 是這次事件的名字,例如 click。
target 是實際發生事件的那個元素。
currentTarget 是這份監聽登記的元素,和 target 可以不同。
timeStamp 回傳從頁面載入到這次事件的毫秒數。
滑鼠
clientX 與 clientY 是相對視窗左上角的座標。
pageX 與 pageY 是相對整份文件左上角,有捲動時會比視窗座標大。
offsetX 與 offsetY 是相對事件目標內緣的座標。
button 回傳哪一個按鍵,0 是左鍵。
鍵盤
key 是這一刻產生的字,會受到 Shift 與輸入法影響。
code 是鍵盤上的位置,例如 KeyS,不跟著輸入法改變。
repeat 在按住不放、重複觸發時回傳 true。
ctrlKey 與 metaKey 表示 Ctrl 或 ⌘ 是否按住,回傳布林。
shiftKey 與 altKey 表示另外兩個修飾鍵是否按住。
方法
preventDefault 取消瀏覽器這次的預設動作,傳播還會繼續。
stopPropagation 從這一層打斷後續傳播。
graph LR
pageNode["文件左上角<br/>pageX pageY"] --> viewNode["視窗左上角<br/>clientX clientY"]
viewNode --> padNode["元素內緣<br/>offsetX offsetY"]
target 與 currentTarget 監聽可以掛在父元素上,點擊卻發生在子元素。事件物件用兩個屬性把這兩層分開。
觀念
target 是實際被點到的元素。currentTarget 是這份監聽登記的那個元素。點清單裡的文字時,兩者通常不是同一個節點。
04-js/pages/133-js-target.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > target 與 currentTarget</title > </head > <body > <ul id ="menu" > <li > 蘋果</li > <li > 香蕉</li > </ul > <p id ="status" > </p > <script > const menu = document .querySelector ('#menu' ); const status = document .querySelector ('#status' ); menu.addEventListener ('click' , (event ) => { status.textContent = `${event.target.tagName} / ${event.currentTarget.id} ` ; }); </script > </body > </html >
解析
監聽掛在 ul 上,所以 currentTarget 的 id 是 menu。
點到文字時,target 是那顆 li,標籤名是 LI。
點在兩顆 li 上,第二段都是 menu,第一段都是 LI。
換你試試: 改成把 event.target 的文字寫進狀態段落。點蘋果時段落應是「蘋果」。
參考答案
target 就是被點到的 li,textContent 是那一列的文字。currentTarget 這次沒有寫出來。
04-js/pages/133-js-target.html // ... const status = document.querySelector('#status'); menu.addEventListener('click', (event) => { - status.textContent = `${event.target.tagName} / ${event.currentTarget.id}`; + status.textContent = event.target.textContent; }); </script> // ...
滑鼠座標 同一種移動會帶出三組座標。這一節先在方塊上即時顯示視窗座標與元素內座標,再讓點擊把小圓點放到游標的位置。
觀念
clientX 與 clientY 從視窗左上角算起,捲動頁面時這組數字會變。offsetX 與 offsetY 從事件目標的內緣算起,圓點要用這組才會落在方塊裡。pageX 與 pageY 從整份文件的左上角算起,有捲動時比視窗座標大。
04-js/pages/134-js-pointer.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 滑鼠座標</title > <style > #pad { position : relative; height : 160px ; background : #ccfbf1 ; } #dot { position : absolute; width : 12px ; height : 12px ; border-radius : 50% ; background : #0f766e ; left : 0 ; top : 0 ; } </style > </head > <body > <p id ="pos" > 在方塊上移動滑鼠</p > <div id ="pad" > <span id ="dot" > </span > </div > <script > const pos = document .querySelector ('#pos' ); const pad = document .querySelector ('#pad' ); const dot = document .querySelector ('#dot' ); pad.addEventListener ('mousemove' , (event ) => { const text = `${event.clientX} , ${event.clientY} / ${event.offsetX} , ${event.offsetY} ` ; pos.textContent = text; console .log (text); }); pad.addEventListener ('click' , (event ) => { dot.style .left = `${event.offsetX} px` ; dot.style .top = `${event.offsetY} px` ; }); </script > </body > </html >
Console:
解析
斜線前是視窗座標,斜線後是方塊內座標。數字跟著游標變,下面只是某一點的例子。
點在方塊裡時,圓點的 left 與 top 用的是 offset,所以會落在游標下方。
若改用 client 來放圓點,捲動或方塊不在原點時,位置會偏掉。
換你試試: mousemove 的文字再補上 pageX 與 pageY,放在斜線之後的下一組。
參考答案
第三組是文件座標。方塊還沒被捲走時,它會和視窗座標很接近。
04-js/pages/134-js-pointer.html // ... const dot = document.querySelector('#dot'); pad.addEventListener('mousemove', (event) => { - const text = `${event.clientX}, ${event.clientY} / ${event.offsetX}, ${event.offsetY}`; + const text = `${event.clientX}, ${event.clientY} / ${event.offsetX}, ${event.offsetY} / ${event.pageX}, ${event.pageY}`; pos.textContent = text; console.log(text); // ...
180, 90 / 24, 16 / 180, 90
鍵盤資訊 keydown 帶來的不只是字,還有這顆鍵在鍵盤上的位置、是否為重複觸發,以及有沒有按住修飾鍵。這一節把這些資訊寫上畫面,並用一組快捷鍵擋掉瀏覽器的存檔。
觀念
key 是這一刻產生的字,Shift 或輸入法會讓它改變。code 是物理位置,KeyS 不會因為輸入法換成中文就改掉。repeat 在按住不放時變成 true。ctrlKey、metaKey、shiftKey、altKey 是四個布林。
04-js/pages/135-js-keyinfo.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 鍵盤資訊</title > </head > <body > <p id ="info" > 按任意鍵</p > <p id ="saved" > </p > <script > const info = document .querySelector ('#info' ); const saved = document .querySelector ('#saved' ); document .addEventListener ('keydown' , (event ) => { const mods = [ event.ctrlKey ? 'Ctrl' : '' , event.metaKey ? 'Meta' : '' , event.shiftKey ? 'Shift' : '' , event.altKey ? 'Alt' : '' , ].filter (Boolean ); const text = `${event.key} / ${event.code} / ${event.repeat} / ${mods.join('+' )} ` ; info.textContent = text; console .log (text); if ((event.ctrlKey || event.metaKey ) && event.key === 's' ) { event.preventDefault (); saved.textContent = '已儲存' ; } }); </script > </body > </html >
Console:
解析
按 a 時,key 是 a、code 是 KeyA、repeat 是 false。下面是這種按法的例子。
按住 Shift 再按同一顆鍵,key 可能變成 A,code 仍是 KeyA。
切換輸入法之後,key 可能變成中文,code 仍指向原來的位置。
Ctrl 或 ⌘ 配上 s 時,preventDefault 擋掉瀏覽器存檔,畫面出現已儲存。
換你試試: 改成 key 是 k 時顯示「已搜尋」,同樣擋掉預設動作。S 那組不要留著。
參考答案
組合鍵改看 k。畫面寫已搜尋,瀏覽器不會因為這個鍵去執行它原本的動作。
04-js/pages/135-js-keyinfo.html // ... info.textContent = text; console.log(text); - if ((event.ctrlKey || event.metaKey) && event.key === 's') { + if ((event.ctrlKey || event.metaKey) && event.key === 'k') { event.preventDefault(); - saved.textContent = '已儲存'; + saved.textContent = '已搜尋'; } }); // ...
preventDefault 有些元素自帶動作。連結會前往 href,表單會送出並重新載入。取消這件事不會拆掉監聽,只是這一次不去做預設動作。
觀念
在監聽裡呼叫 preventDefault,瀏覽器就不會跟著預設動作走。事件仍然會繼續冒泡,傳播要留到後面再停。
04-js/pages/136-js-prevent.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > preventDefault</title > </head > <body > <p > <a id ="jump" href ="https://example.com" > 前往範例</a > </p > <p id ="status" > 還在這頁</p > <script > const status = document .querySelector ('#status' ); document .querySelector ('#jump' ).addEventListener ('click' , (event ) => { event.preventDefault (); status.textContent = '已攔下預設跳轉' ; }); </script > </body > </html >
解析
連結的 href 仍是範例網站,預設動作是離開這頁。
preventDefault 讓這次點擊留在原頁,段落改成攔下的說明。
沒有呼叫它的話,瀏覽器會開啟那個網址。
參考答案
兩條連結各自有監聽,都會取消預設跳轉。狀態文字共用同一個段落。
04-js/pages/136-js-prevent.html // ... <body> <p><a id="jump" href="https://example.com">前往範例</a></p> + <p><a id="more" href="https://example.org">另一條</a></p> <p id="status">還在這頁</p> <script> // ... status.textContent = '已攔下預設跳轉'; }); + + document.querySelector('#more').addEventListener('click', (event) => { + event.preventDefault(); + status.textContent = '已攔下預設跳轉'; + }); </script> </body> // ...
常用事件 click 之外,表單控制項最常遇到輸入與選定,文字框還會遇到鍵盤。這一節先分清字每次變化,以及值確定下來,是兩個事件。
input 在內容每次改變時發生,文字框打一個字就會觸發。
change 在文字框離開焦點後,或選單換了選項時發生。
keydown 在鍵按下時發生,按住會重複。
keyup 在鍵放開時發生,不會因為按住而重複。
文字框正在打字時,和選單選定某一項時,通知程式的時機不同。預覽要跟著每一個字,選定的結果則等值確定。
input 在內容每次改變時發生,預覽可以跟著每一個字。
change 等值確定才發生,文字框要離開焦點,選單則是選項真的換了。
觀念
input 在內容每次改變時發生。change 在文字框離開焦點後,或選單選了不同選項時發生。兩者都用 value 讀目前的值。
04-js/pages/137-js-input.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > input 與 change</title > </head > <body > <input id ="name" type ="text" /> <p id ="preview" > </p > <select id ="color" > <option value ="紅" > 紅</option > <option value ="藍" > 藍</option > </select > <p id ="picked" > </p > <script > const name = document .querySelector ('#name' ); const preview = document .querySelector ('#preview' ); const color = document .querySelector ('#color' ); const picked = document .querySelector ('#picked' ); name.addEventListener ('input' , () => { preview.textContent = name.value ; }); color.addEventListener ('change' , () => { picked.textContent = color.value ; }); </script > </body > </html >
解析
每打一個字,input 就更新預覽,不必等離開文字框。
選單要等選項真的換了,change 才把值寫進第二段。
value 是控制項目前的字串,選單的 value 來自 option。
換你試試: 選單發生 change 時,除了寫進選定段落,也把「輸入值 / 顏色」寫進預覽。
參考答案
change 的當下可以同時讀文字框與選單。預覽會改成兩段資訊,之後再打字仍由 input 覆蓋成只有名字。
04-js/pages/137-js-input.html // ... color.addEventListener('change', () => { picked.textContent = color.value; + preview.textContent = `${name.value} / ${color.value}`; }); </script> // ...
keydown 鍵盤事件看的是這一次按下的鍵,而不是文字框裡的整段內容。Enter 常用來表示確定,Escape 常用來表示取消。
keydown 在鍵按下時發生,按住會重複,鍵名看 key。
keyup 在鍵放開時發生。方向鍵、打字與蓄力都從這兩個事件長出來。
觀念
keydown 在鍵按下時發生。event.key 是鍵的名字,例如 Enter。若這個文字框以後放進表單,Enter 可能會觸發送出,所以這裡先取消預設動作。
04-js/pages/138-js-keydown.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > keydown</title > </head > <body > <input id ="name" type ="text" /> <p id ="status" > </p > <script > const name = document .querySelector ('#name' ); const status = document .querySelector ('#status' ); name.addEventListener ('keydown' , (event ) => { if (event.key === 'Enter' ) { event.preventDefault (); status.textContent = name.value ; } }); </script > </body > </html >
解析
只有 key 是 Enter 時才讀 value,其他鍵不會改段落。
preventDefault 避免 Enter 去觸發包住它的表單。這一頁還沒有表單,呼叫它仍然安全。
段落顯示的是按下 Enter 當下的整段文字。
換你試試: key 是 Escape 時,把文字框的 value 設成空字串。Enter 的行為保留。
參考答案
同一個 keydown 可以依 key 分兩條路。Escape 只清輸入框,不改狀態段落。
04-js/pages/138-js-keydown.html // ... status.textContent = name.value; } + if (event.key === 'Escape') { + name.value = ''; + } }); </script> // ...
方向鍵移動方塊 方向鍵可以改一個方塊的位置。第一步先讓它移動,先不處理邊界,方塊可以跑出欄位。完成寫法在 139-js-arrow.html.final。
觀念
監聽放在 document 上,方塊不需要先被點選。四個方向各改 x 或 y。方向鍵的預設動作會捲動頁面,所以先 preventDefault。座標同時寫進狀態段落與 Console。
04-js/pages/139-js-arrow.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 方向鍵移動</title > <style > #field { position : relative; width : 240px ; height : 160px ; background : #ccfbf1 ; } #box { position : absolute; width : 32px ; height : 32px ; background : #0f766e ; left : 0 ; top : 0 ; } </style > </head > <body > <div id ="field" > <div id ="box" > </div > </div > <p id ="status" > 0, 0</p > <script > const box = document .querySelector ('#box' ); const status = document .querySelector ('#status' ); let x = 0 ; let y = 0 ; document .addEventListener ('keydown' , (event ) => { if (event.key === 'ArrowRight' ) x += 16 ; if (event.key === 'ArrowLeft' ) x -= 16 ; if (event.key === 'ArrowDown' ) y += 16 ; if (event.key === 'ArrowUp' ) y -= 16 ; if (!event.key .startsWith ('Arrow' )) return ; event.preventDefault (); box.style .left = `${x} px` ; box.style .top = `${y} px` ; status.textContent = `${x} , ${y} ` ; console .log (status.textContent ); }); </script > </body > </html >
Console:
解析
按一次右鍵,x 增加 16,狀態與 Console 都是 16, 0。
左鍵與上鍵會把數字減下去,這一步允許出現負數。
不是方向鍵時直接結束,其他鍵不會改位置。
換你試試: 碰到欄位邊緣就停住。往右的最大值是欄位寬減掉方塊寬,這一頁是 208。
參考答案
加上邊界之後,數字不會小於 0,也不會大於欄位減掉方塊的尺寸。完成檔是 139-js-arrow.html.final。
04-js/pages/139-js-arrow.html.final // ... <script> const box = document.querySelector('#box'); + const field = document.querySelector('#field'); const status = document.querySelector('#status'); let x = 0; // ... if (!event.key.startsWith('Arrow')) return; event.preventDefault(); + const maxX = field.clientWidth - box.offsetWidth; + const maxY = field.clientHeight - box.offsetHeight; + x = Math.min(maxX, Math.max(0, x)); + y = Math.min(maxY, Math.max(0, y)); box.style.left = `${x}px`; box.style.top = `${y}px`; // ...
打字小遊戲 畫面上隨機出現一個英文字母。按對就加分並換下一個,這一步按錯先不提示。完成寫法在 140-js-type.html.final。字母每次重新整理都不一樣。
觀念
只處理長度為 1 的 key,方向鍵與 Enter 就不會被當成答案。比對前先轉成大寫,所以小寫按鍵也算對。分數寫進段落,並印出新的分數。
04-js/pages/140-js-type.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 打字小遊戲</title > </head > <body > <p id ="letter" > A</p > <p id ="score" > 0</p > <p id ="hint" > </p > <script > const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZ' ; const letter = document .querySelector ('#letter' ); const score = document .querySelector ('#score' ); let points = 0 ; const nextLetter = ( ) => { letter.textContent = letters[Math .floor (Math .random () * letters.length )]; }; nextLetter (); document .addEventListener ('keydown' , (event ) => { if (event.key .length !== 1 ) return ; if (event.key .toUpperCase () === letter.textContent ) { points += 1 ; score.textContent = String (points); console .log (points); nextLetter (); } }); </script > </body > </html >
Console:
解析
載入時先抽一個字母,分數是 0。
按對一次,分數變成 1,Console 印出 1,字母立刻換成下一個。
按錯時這一步沒有提示,分數也不會減少。
換你試試: 按錯時把 #hint 設成「再試一次」。按對就清空提示,再換下一個字母。
參考答案
按錯只改提示,不加分。按對時提示被清掉,分數才加 1。完成檔是 140-js-type.html.final。
04-js/pages/140-js-type.html.final // ... const letter = document.querySelector('#letter'); const score = document.querySelector('#score'); + const hint = document.querySelector('#hint'); let points = 0; const nextLetter = () => { letter.textContent = letters[Math.floor(Math.random() * letters.length)]; + hint.textContent = ''; }; nextLetter(); // ... console.log(points); nextLetter(); + } else { + hint.textContent = '再試一次'; } }); // ...
空白鍵蓄力 按住空白鍵開始蓄力,放開時再用兩次時間戳的差當作力道。這一步只做到按下時顯示蓄力中。完成寫法在 141-js-charge.html.final。
觀念
keydown 遇到空白鍵且不是重複觸發時,記下 timeStamp,並擋掉捲動頁面的預設動作。按住不放時 repeat 是 true,起點不會被後面的重複事件改掉。
04-js/pages/141-js-charge.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 空白鍵蓄力</title > </head > <body > <p id ="power" > 力道 0</p > <script > let start = 0 ; document .addEventListener ('keydown' , (event ) => { if (event.key !== ' ' || event.repeat ) return ; event.preventDefault (); start = event.timeStamp ; document .querySelector ('#power' ).textContent = '蓄力中' ; console .log ('蓄力中' ); }); </script > </body > </html >
Console:
解析
按下空白鍵,畫面與 Console 都是蓄力中。
按住不放不會重新起算,因為重複的 keydown 被略過。
這一步還沒有聽 keyup,放開時力道還不會出現。
換你試試: 放開空白鍵時,用這次 timeStamp 減去起點,四捨五入後顯示「力道 數字」。實際數字等於你按住的毫秒。
參考答案
keyup 只在有起點時計算。放開後畫面變成力道加上毫秒,下面的 180 只是按住約 0.18 秒的例子。完成檔是 141-js-charge.html.final。
04-js/pages/141-js-charge.html.final // ... <script> let start = 0; + const power = document.querySelector('#power'); document.addEventListener('keydown', (event) => { if (event.key !== ' ' || event.repeat) return; event.preventDefault(); start = event.timeStamp; - document.querySelector('#power').textContent = '蓄力中'; + power.textContent = '蓄力中'; console.log('蓄力中'); }); + + document.addEventListener('keyup', (event) => { + if (event.key !== ' ' || !start) return; + const force = Math.max(0, Math.round(event.timeStamp - start)); + start = 0; + power.textContent = `力道 ${force}`; + console.log(power.textContent); + }); </script> </body> // ...
事件委派 點到內層元素時,click 會一路往外層傳。這種往外送叫做事件冒泡,所以父層的監聽也收得到。event.target 是真正被點到的元素。完整的三個階段留在後面選讀,這裡先不談捕獲。
清單會動態新增、刪除、重繪。監聽只放在不會被換掉的父層,新出現的按鈕也收得到,不用每次重綁。後面的待辦清單就用這招。
closest 從被點到的節點往上找按鈕,含自己,找不到回傳 null。
按鈕的 dataset 存放要做的動作,父層讀到才決定怎麼改。
觀念
父層收到 click 之後,用 closest 從被點到的節點往上找按鈕。找不到,或按鈕在父層外面,就直接結束。找得到就讀那顆按鈕的 dataset。
04-js/pages/142-js-delegate.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 事件委派</title > </head > <body > <ul id ="list" > <li > <button type ="button" data-id ="1" > 一</button > </li > <li > <button type ="button" data-id ="2" > 二</button > </li > </ul > <p id ="status" > </p > <script > const list = document .querySelector ('#list' ); const status = document .querySelector ('#status' ); list.addEventListener ('click' , (event ) => { const button = event.target .closest ('button' ); if (!button || !list.contains (button)) return ; status.textContent = button.dataset .id ; console .log (event.target .tagName ); }); </script > </body > </html >
解析
點第一顆時,Console 印出 BUTTON。那就是 event.target,監聽掛在清單上仍收得到,是因為 click 往外冒泡。
closest 從被點到的節點往上找,點在按鈕文字上也找得到那顆按鈕。
contains 確認這顆按鈕是清單的後代。dataset.id 就是標籤上的 data-id,點第一顆時段落是 1。
之後用程式或 HTML 加進來的按鈕,只要還在這份清單裡,同一份監聽就收得到。
換你試試: 在清單裡加第三顆按鈕,data-id 設成 3,文字是「三」。不要新增監聽。點它時段落應是 3。
參考答案
新按鈕還是這份清單的後代,冒泡仍會進到同一份監聽。讀到的 id 是 3。
04-js/pages/142-js-delegate.html // ... <li><button type="button" data-id="1">一</button></li> <li><button type="button" data-id="2">二</button></li> + <li><button type="button" data-id="3">三</button></li> </ul> <p id="status"></p> // ...
選讀:捕獲與冒泡 前面只用到冒泡。這裡看完整的三個階段:由外往內的捕獲、抵達目標、再由內往外的冒泡。
graph LR
docDown["document"] -- 捕獲 --> outerCap["#outer 捕獲監聽"]
outerCap -- 捕獲 --> innerTarget["#inner 目標"]
innerTarget -- 冒泡 --> outerBubble["#outer 冒泡監聽"]
outerBubble -- 冒泡 --> docUp["document"]
預設的監聽發生在冒泡階段,也就是事件已經到了目標、正往外走的時候。若要在往內走時就先看到,要把捕獲打開。
捕獲由外往內。第三個引數 true,或選項 capture,會在這階段呼叫。
冒泡由內往外。沒寫選項時,監聽走這一段。
stopPropagation 從呼叫的那一層打斷後面的路,已經走過的捕獲不會取消。
觀念
選項 capture 代表這份監聽走捕獲階段,第三個引數寫 true 也是同一件事。stopPropagation 從呼叫的那一層打斷後續的路,已經走過的捕獲不會被取消。
04-js/pages/143-js-bubble.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 選讀:捕獲與冒泡</title > <style > #outer { padding : 1rem ; background : #ccfbf1 ; } button { margin-right : 0.4rem ; } </style > </head > <body > <div id ="outer" > <button type ="button" id ="inner" > 內層</button > <button type ="button" id ="stop" > 停止冒泡</button > </div > <p id ="status" > </p > <script > const outer = document .querySelector ('#outer' ); const inner = document .querySelector ('#inner' ); const stop = document .querySelector ('#stop' ); const status = document .querySelector ('#status' ); outer.addEventListener ('click' , () => { status.textContent = '外捕獲' ; console .log ('外捕獲' ); }, true ); inner.addEventListener ('click' , () => { status.textContent = `${status.textContent} 內層` ; console .log ('內層' ); }); outer.addEventListener ('click' , () => { status.textContent = `${status.textContent} 外冒泡` ; console .log ('外冒泡' ); }); stop.addEventListener ('click' , (event ) => { event.stopPropagation (); status.textContent = `${status.textContent} 停止` ; console .log ('停止' ); }); </script > </body > </html >
解析
點「內層」時,Console 依序是外捕獲、內層、外冒泡。捕獲監聽先把文字設成第一段。
點「停止冒泡」時,捕獲仍會先發生,接著 stopPropagation 讓外層的冒泡監聽不跑。
外層有兩份 click,一份在捕獲、一份在冒泡,它們是分開登記的。
換你試試: 在內層的監聽裡也呼叫 stopPropagation。點「內層」時,畫面上應停在「外捕獲 內層」。
參考答案
內層已經是目標。在目標呼叫 stopPropagation 會取消後面的冒泡,外層那份冒泡監聽就不會再改文字。捕獲階段在這之前已經跑過。
04-js/pages/143-js-bubble.html // ... console.log('外捕獲'); }, true); - inner.addEventListener('click', () => { + inner.addEventListener('click', (event) => { + event.stopPropagation(); status.textContent = `${status.textContent} 內層`; console.log('內層'); // ...
表單處理 表單控制項的目前內容在 value,核取方塊的是否勾選在 checked。整張表單要一次讀取時,改用 FormData。送出前先取消重新載入,再依規則檢查。
value 與 checked 文字框的 value 是目前打進去的字串。核取方塊不用 value 表示有沒有勾,那個答案在 checked,是布林值。
value 回傳文字框目前的字串。
checked 回傳核取方塊是否勾選,型別是布林。
觀念
文字變化聽 input,勾選變化聽 change。畫面上的句子由一支小函式負責組,載入時先呼叫一次,才不會等使用者動作才出現。
04-js/pages/144-js-form-value.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > value 與 checked</title > </head > <body > <p > <input id ="title" type ="text" value ="草稿" /> </p > <p > <label > <input id ="done" type ="checkbox" /> 完成</label > </p > <p id ="result" > </p > <script > const title = document .querySelector ('#title' ); const done = document .querySelector ('#done' ); const result = document .querySelector ('#result' ); const render = ( ) => { const mark = done.checked ? '已勾選' : '未勾選' ; result.textContent = `${title.value} / ${mark} ` ; }; title.addEventListener ('input' , render); done.addEventListener ('change' , render); render (); </script > </body > </html >
解析
載入時 value 已是「草稿」,checked 是 false,所以先看到未勾選。
打字觸發 input,勾選觸發 change,兩條路都呼叫同一支 render。
三元運算子只負責把布林改成要顯示的那兩個詞。
換你試試: 在核取方塊的標籤上加上 checked。重新整理後,第一眼應是「草稿 / 已勾選」。
參考答案
checked 屬性讓這顆方塊一開始就是勾選的,render 在載入時讀到 true。之後仍可改勾選狀態。
04-js/pages/144-js-form-value.html // ... <body> <p><input id="title" type="text" value="草稿" /></p> - <p><label><input id="done" type="checkbox" /> 完成</label></p> + <p><label><input id="done" type="checkbox" checked /> 完成</label></p> <p id="result"></p> <script> // ...
送出與檢查 表單的預設動作是依 action 送出並重新載入。這一頁還沒有要送給伺服器,所以送出時先留在原頁,再讀欄位。
preventDefault 取消這次送出的重新載入。
checkValidity 回傳是否通過 required 等規則。
reportValidity 請瀏覽器顯示沒通過的提示。
FormData 依 name 把欄位打包成一包資料。
get 從這包資料取出單一字串,沒有該 name 時是 null。
觀念
novalidate 關掉瀏覽器自動擋下送出,submit 才一定進得了監聽。監聽裡用 checkValidity 看 required 等規則,失敗就 reportValidity,請瀏覽器顯示提示。通過之後用 FormData 依 name 取值。
04-js/pages/145-js-submit.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 送出與檢查</title > </head > <body > <form id ="editor" novalidate > <input name ="title" required /> <button type ="submit" > 送出</button > </form > <p id ="result" > </p > <script > const form = document .querySelector ('#editor' ); const result = document .querySelector ('#result' ); form.addEventListener ('submit' , (event ) => { event.preventDefault (); if (!form.checkValidity ()) { form.reportValidity (); result.textContent = '還沒通過檢查' ; return ; } const data = new FormData (form); result.textContent = data.get ('title' ); }); </script > </body > </html >
解析
preventDefault 取消重新載入,無論檢查過不過都要先呼叫。
空白時 checkValidity 是 false,因為輸入框有 required。
FormData 用 name 找欄位。沒有 name 的控制項不會進這包資料。
通過檢查後,段落就是標題欄的字串。
換你試試: 輸入框加上 minlength=”2”。只打一個字就送出時,段落應是「還沒通過檢查」,並出現瀏覽器的提示。
參考答案
minlength 也是約束驗證的規則。一個字不算通過,checkValidity 仍是 false,FormData 那一行不會執行。
04-js/pages/145-js-submit.html // ... <body> <form id="editor" novalidate> - <input name="title" required /> + <input name="title" required minlength="2" /> <button type="submit">送出</button> </form> // ...
資料驅動畫面 前面每一個監聽都直接改某個元素。清單一長,容易改到畫面卻忘了改資料,或相反。這一章把資料放在一個物件,畫面只由一支 render 依資料重畫。
狀態與重繪 狀態是現在這份清單有哪些項目。render 不負責決定要新增什麼,它只看狀態,先清空再把每一項做成元素。點擊只改狀態,然後再呼叫 render。
狀態物件記下現在要顯示的資料,畫面不另外記一份。
render 只依狀態重畫。新增與刪除先改狀態,再呼叫它。
graph LR
stateNode["狀態"] --> renderNode["render"]
renderNode --> viewNode["畫面"]
viewNode --> eventNode["事件改狀態"]
eventNode --> stateNode
觀念
項目放在 state.items。render 用 replaceChildren 清掉舊列,再用 createElement 與 textContent 依陣列重做。這樣畫面上不會留下上一輪的節點。
04-js/pages/146-js-render.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 狀態與重繪</title > <style > body { font-family : system-ui, sans-serif; margin : 1.5rem ; } button { font : inherit; } </style > </head > <body > <ul id ="list" > </ul > <button type ="button" id ="add" > 加一筆</button > <script > const state = { items : ['筆' , '紙' ] }; const list = document .querySelector ('#list' ); const render = ( ) => { list.replaceChildren (); state.items .forEach ((name ) => { const li = document .createElement ('li' ); li.textContent = name; list.append (li); }); }; document .querySelector ('#add' ).addEventListener ('click' , () => { state.items .push ('新項目' ); render (); }); render (); </script > </body > </html >
解析
載入時就呼叫 render,所以不用等點擊也看得到兩列。
按鈕只對陣列 push,不自己建立 li。新列是下一次 render 畫出來的。
每次都整份重畫,狀態與畫面就不會各記各的。
文字來自陣列裡的字串,用 textContent 放進 li。
換你試試: 按鈕 push 的文字改成「橡皮擦」。連點兩次,清單應出現兩次橡皮擦。
參考答案
兩次點擊是兩次 push,陣列裡會有兩筆相同文字。render 照陣列長度畫,不會把相同文字合併。
04-js/pages/146-js-render.html // ... }; document.querySelector('#add').addEventListener('click', () => { - state.items.push('新項目'); + state.items.push('橡皮擦'); render(); }); // ...
綜合練習:待辦清單 待辦的每一筆是物件,含有 id、標題與是否完成。畫面仍然只由 render 產生。新增走表單送出,完成與刪除走清單上的一份 click,用按鈕的 data-action 分辨。
從狀態畫出清單 第一步先不要接事件。狀態裡放兩筆待辦,render 為每一筆做出標題與兩顆按鈕,完成的那筆加上 class。
觀念
done 為 true 時,li 加上 done,樣式表負責刪除線。按鈕先放上 data-action 與 data-id,下一步才用它們分辨點擊。id 轉成字串後放進 dataset。
04-js/pages/147-js-todo.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 待辦清單</title > <style > body { font-family : system-ui, sans-serif; margin : 1.5rem ; } form { display : flex; gap : 0.5rem ; margin-bottom : 1rem ; } input , button { font : inherit; } li { margin : 0.4rem 0 ; } li .done span { text-decoration : line-through; } li button { margin-left : 0.35rem ; } </style > </head > <body > <form id ="editor" novalidate > <input id ="title" name ="title" required /> <button type ="submit" > 新增</button > </form > <ul id ="list" > </ul > <script > const state = { nextId : 3 , items : [ { id : 1 , title : '讀講義' , done : false }, { id : 2 , title : '改練習檔' , done : true }, ], }; const list = document .querySelector ('#list' ); const render = ( ) => { list.replaceChildren (); state.items .forEach ((item ) => { const li = document .createElement ('li' ); if (item.done ) li.classList .add ('done' ); const span = document .createElement ('span' ); span.textContent = item.title ; const toggle = document .createElement ('button' ); toggle.type = 'button' ; toggle.dataset .action = 'toggle' ; toggle.dataset .id = String (item.id ); toggle.textContent = item.done ? '改回未完成' : '標成完成' ; const remove = document .createElement ('button' ); remove.type = 'button' ; remove.dataset .action = 'remove' ; remove.dataset .id = String (item.id ); remove.textContent = '刪除' ; li.append (span, toggle, remove); list.append (li); }); }; render (); </script > </body > </html >
解析
第一筆 done 是 false,沒有刪除線。第二筆是 true,標題有刪除線。
每一列有兩顆按鈕,文字會依 done 在「標成完成」與「改回未完成」之間切換。
這一支還沒有聽表單,也還沒有聽清單,點按鈕不會改狀態。
換你試試: 把第二筆的 done 改成 false。重新整理後,兩列都不該有刪除線,兩顆切換鈕都顯示「標成完成」。
參考答案
class 只在 done 為 true 時加上。兩筆都是 false 時,render 不會給任何 li 加上 done。
04-js/pages/147-js-todo.html // ... items: [ { id: 1, title: '讀講義', done: false }, - { id: 2, title: '改練習檔', done: true }, + { id: 2, title: '改練習檔', done: false }, ], }; // ...
新增、切換與刪除 第二步補上兩份監聽。表單負責推進陣列,清單負責改某一筆的 done 或把它濾掉。兩邊最後都只呼叫 render。
觀念
submit 先 preventDefault,再用 checkValidity 看 required。通過後用 FormData 讀 title,推進 state.items,並把輸入框清掉。click 用 closest 找按鈕,依 data-action 決定要切換還是刪除。
04-js/pages/147-js-todo.html.final <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 待辦清單</title > <style > body { font-family : system-ui, sans-serif; margin : 1.5rem ; } form { display : flex; gap : 0.5rem ; margin-bottom : 1rem ; } input , button { font : inherit; } li { margin : 0.4rem 0 ; } li .done span { text-decoration : line-through; } li button { margin-left : 0.35rem ; } </style > </head > <body > <form id ="editor" novalidate > <input id ="title" name ="title" required /> <button type ="submit" > 新增</button > </form > <ul id ="list" > </ul > <script > const state = { nextId : 3 , items : [ { id : 1 , title : '讀講義' , done : false }, { id : 2 , title : '改練習檔' , done : true }, ], }; const list = document .querySelector ('#list' ); const form = document .querySelector ('#editor' ); const render = ( ) => { list.replaceChildren (); state.items .forEach ((item ) => { const li = document .createElement ('li' ); if (item.done ) li.classList .add ('done' ); const span = document .createElement ('span' ); span.textContent = item.title ; const toggle = document .createElement ('button' ); toggle.type = 'button' ; toggle.dataset .action = 'toggle' ; toggle.dataset .id = String (item.id ); toggle.textContent = item.done ? '改回未完成' : '標成完成' ; const remove = document .createElement ('button' ); remove.type = 'button' ; remove.dataset .action = 'remove' ; remove.dataset .id = String (item.id ); remove.textContent = '刪除' ; li.append (span, toggle, remove); list.append (li); }); }; form.addEventListener ('submit' , (event ) => { event.preventDefault (); if (!form.checkValidity ()) { form.reportValidity (); return ; } const data = new FormData (form); const title = String (data.get ('title' )); state.items .push ({ id : state.nextId , title, done : false }); state.nextId += 1 ; form.querySelector ('#title' ).value = '' ; render (); }); list.addEventListener ('click' , (event ) => { const button = event.target .closest ('button' ); if (!button || !list.contains (button)) return ; const id = Number (button.dataset .id ); const item = state.items .find ((row ) => row.id === id); if (!item) return ; if (button.dataset .action === 'toggle' ) item.done = !item.done ; if (button.dataset .action === 'remove' ) { state.items = state.items .filter ((row ) => row.id !== id); } render (); }); render (); </script > </body > </html >
解析
空白標題過不了 required,監聽會先停住,陣列不會多一筆。
新增時用 nextId 當新的 id,然後加 1,避免和舊列重複。
切換只改那一筆的 done。刪除用 filter 留下 id 不同的項目。
按鈕是 render 每次重做的,所以監聽必須放在 ul,不能放在按鈕上。
換你試試: 標題去掉前後空白後若是空字串,不要推進陣列,也不要把輸入框清掉。只打空白再送出時,清單筆數不變。
參考答案
required 只把完全沒字視為沒填,幾個空白仍算有值。trim 之後若是空字串就 return,輸入框還留著那些空白。
04-js/pages/147-js-todo.html.final // ... } const data = new FormData(form); - const title = String(data.get('title')); + const title = String(data.get('title')).trim(); + if (!title) return; state.items.push({ id: state.nextId, title, done: false }); state.nextId += 1; // ...
選讀:打地鼠 這一節把計時器、狀態、class 與事件委派放進同一個小遊戲。九個洞用按鈕排出來,顏色代表狀態:黃是閒置,紅是地鼠出現,綠是剛被打中。
開始後倒數 20 秒。大約每 700 毫秒挑一個閒置洞變紅,800 毫秒沒被打到就回黃。只有紅色可以得分,得分後短暫變綠再回黃。時間到就清掉所有計時器,開始鈕可以再按。
畫面與狀態 第一步只建立狀態與 render。九個洞的狀態都放在同一個陣列,畫面的 class 完全由這份陣列決定。開始鈕先留著,還沒有接計時器。
觀念
state 集中分數、剩餘秒數、是否進行中,以及每個洞要清除的計時器識別碼。render 先拿掉 up 與 hit,再依陣列把不是閒置的狀態加回去。進行中時開始鈕設為不能按。
04-js/pages/148-js-mole.html <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 選讀:打地鼠</title > <style > body { font-family : system-ui, sans-serif; margin : 1.5rem ; } button { font : inherit; } .board { display : grid; grid-template-columns : repeat (3 , 4.5rem ); gap : 0.5rem ; margin : 1rem 0 ; } .hole { width : 4.5rem ; height : 4.5rem ; border : 2px solid #0f766e ; background : #fef9c3 ; } .hole .up { background : #ef4444 ; } .hole .hit { background : #22c55e ; } </style > </head > <body > <p > 剩餘 <span id ="time" > 20</span > 秒,分數 <span id ="score" > 0</span > </p > <button type ="button" id ="start" > 開始</button > <div id ="board" class ="board" > <button type ="button" class ="hole" data-index ="0" > </button > <button type ="button" class ="hole" data-index ="1" > </button > <button type ="button" class ="hole" data-index ="2" > </button > <button type ="button" class ="hole" data-index ="3" > </button > <button type ="button" class ="hole" data-index ="4" > </button > <button type ="button" class ="hole" data-index ="5" > </button > <button type ="button" class ="hole" data-index ="6" > </button > <button type ="button" class ="hole" data-index ="7" > </button > <button type ="button" class ="hole" data-index ="8" > </button > </div > <script > const state = { holes : ['idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' ], score : 0 , timeLeft : 20 , playing : false , clockId : 0 , spawnId : 0 , hideIds : {}, }; const render = ( ) => { document .querySelector ('#score' ).textContent = String (state.score ); document .querySelector ('#time' ).textContent = String (state.timeLeft ); document .querySelectorAll ('.hole' ).forEach ((hole ) => { const index = Number (hole.dataset .index ); hole.classList .remove ('up' , 'hit' ); if (state.holes [index] !== 'idle' ) hole.classList .add (state.holes [index]); }); document .querySelector ('#start' ).disabled = state.playing ; }; render (); </script > </body > </html >
解析
九個按鈕都有 data-index,render 用這個數字對上 holes 陣列。
初始都是 idle,所以沒有紅也沒有綠,秒數是 20,分數是 0。
playing 是 false,開始鈕可以按,但還沒有監聽。
分數與秒數寫進 span 的 textContent,不重組整段 HTML。
三種顏色對應三種素材 黃色、紅色、綠色分別是閒置、出現與打中。若改用圖片,仍然用 class 切換。 不要把任何輸入接進圖片位址。這一課用色塊就夠把狀態看清楚。
換你試試: 把 state 的 timeLeft 改成 15。重新整理後,畫面上的秒數應是 15。洞的顏色仍全部是黃。
參考答案
畫面上的 20 只是 HTML 的初值。render 一執行就改成 state 裡的數字,所以改狀態才會留在畫面上。
04-js/pages/148-js-mole.html // ... holes: ['idle', 'idle', 'idle', 'idle', 'idle', 'idle', 'idle', 'idle', 'idle'], score: 0, - timeLeft: 20, + timeLeft: 15, playing: false, clockId: 0, // ...
開局、命中與結束 第二步把工作拆開。start 重置這一局並啟動計時器,spawn 讓地鼠出現,hit 處理打中,tick 每秒減時間,end 結束並清掉計時器。點擊只綁在棋盤上。
graph TD
startNode["start"] --> tickNode["tick 每秒"]
startNode --> spawnNode["spawn 挑閒置洞"]
spawnNode --> upNode["狀態改成 up"]
upNode --> hideNode["時間到改回 idle"]
upNode --> hitNode["hit"]
hitNode --> scoreNode["分數加一"]
tickNode --> endNode["時間到就 end"]
endNode --> clearNode["清除計時器"]
觀念
進行中若再呼叫 start 就直接結束,避免兩組 setInterval 疊在一起。每個紅洞的 setTimeout 識別碼放在 hideIds,打中或結束時都要 clearTimeout。地鼠回黃之前若已經不是 up,就不要覆蓋成閒置。
04-js/pages/148-js-mole.html.final <!DOCTYPE html > <html lang ="zh-Hant" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 選讀:打地鼠</title > <style > body { font-family : system-ui, sans-serif; margin : 1.5rem ; } button { font : inherit; } .board { display : grid; grid-template-columns : repeat (3 , 4.5rem ); gap : 0.5rem ; margin : 1rem 0 ; } .hole { width : 4.5rem ; height : 4.5rem ; border : 2px solid #0f766e ; background : #fef9c3 ; } .hole .up { background : #ef4444 ; } .hole .hit { background : #22c55e ; } </style > </head > <body > <p > 剩餘 <span id ="time" > 20</span > 秒,分數 <span id ="score" > 0</span > </p > <button type ="button" id ="start" > 開始</button > <div id ="board" class ="board" > <button type ="button" class ="hole" data-index ="0" > </button > <button type ="button" class ="hole" data-index ="1" > </button > <button type ="button" class ="hole" data-index ="2" > </button > <button type ="button" class ="hole" data-index ="3" > </button > <button type ="button" class ="hole" data-index ="4" > </button > <button type ="button" class ="hole" data-index ="5" > </button > <button type ="button" class ="hole" data-index ="6" > </button > <button type ="button" class ="hole" data-index ="7" > </button > <button type ="button" class ="hole" data-index ="8" > </button > </div > <script > const STAY_MS = 800 ; const SPAWN_MS = 700 ; const state = { holes : ['idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' ], score : 0 , timeLeft : 20 , playing : false , clockId : 0 , spawnId : 0 , hideIds : {}, }; const render = ( ) => { document .querySelector ('#score' ).textContent = String (state.score ); document .querySelector ('#time' ).textContent = String (state.timeLeft ); document .querySelectorAll ('.hole' ).forEach ((hole ) => { const index = Number (hole.dataset .index ); hole.classList .remove ('up' , 'hit' ); if (state.holes [index] !== 'idle' ) hole.classList .add (state.holes [index]); }); document .querySelector ('#start' ).disabled = state.playing ; }; const clearTimers = ( ) => { clearInterval (state.clockId ); clearInterval (state.spawnId ); Object .keys (state.hideIds ).forEach ((key ) => { clearTimeout (state.hideIds [key]); }); state.hideIds = {}; }; const spawn = ( ) => { const idleIndexes = []; state.holes .forEach ((status, index ) => { if (status === 'idle' ) idleIndexes.push (index); }); if (idleIndexes.length === 0 ) return ; const index = idleIndexes[Math .floor (Math .random () * idleIndexes.length )]; state.holes [index] = 'up' ; state.hideIds [index] = setTimeout (() => { if (state.holes [index] === 'up' ) state.holes [index] = 'idle' ; render (); }, STAY_MS ); render (); }; const hit = (index ) => { if (!state.playing || state.holes [index] !== 'up' ) return ; clearTimeout (state.hideIds [index]); state.holes [index] = 'hit' ; state.score += 1 ; render (); state.hideIds [index] = setTimeout (() => { state.holes [index] = 'idle' ; render (); }, 300 ); }; const end = ( ) => { state.playing = false ; clearTimers (); state.holes = ['idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' ]; render (); }; const tick = ( ) => { state.timeLeft -= 1 ; if (state.timeLeft <= 0 ) { state.timeLeft = 0 ; end (); return ; } render (); }; const start = ( ) => { if (state.playing ) return ; clearTimers (); state.score = 0 ; state.timeLeft = 20 ; state.playing = true ; state.holes = ['idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' , 'idle' ]; state.clockId = setInterval (tick, 1000 ); state.spawnId = setInterval (spawn, SPAWN_MS ); spawn (); }; document .querySelector ('#board' ).addEventListener ('click' , (event ) => { const hole = event.target .closest ('.hole' ); if (!hole || !event.currentTarget .contains (hole)) return ; hit (Number (hole.dataset .index )); }); document .querySelector ('#start' ).addEventListener ('click' , start); render (); </script > </body > </html >
解析
start 先清掉上一局可能留下的計時器,分數歸零,秒數回到 20,九個洞回到 idle。
spawn 只從 idle 的洞裡隨機挑,所以不會把已經是紅或綠的洞再排一次。
hit 先確認正在遊戲而且該洞是 up,才加分並改成 hit。
tick 把秒數減到 0 時呼叫 end。end 會清間隔、清每個洞的 timeout,並讓開始鈕恢復。
棋盤的 click 用 closest 找到洞,再把 data-index 交給 hit。點在洞以外不會計分。
換你試試: 把地鼠停留的 STAY_MS 改成 1200。其他秒數不要改。紅洞應比原來更晚才自己回黃。
參考答案
停留時間只影響出現後自動回黃的那個 timeout。打中時仍會先清掉它,再改走 300 毫秒的變綠流程。
04-js/pages/148-js-mole.html.final // ... </div> <script> - const STAY_MS = 800; + const STAY_MS = 1200; const SPAWN_MS = 700; const state = { // ...
本篇重點回顧 下面八句是這一篇要帶走的習慣。選讀的 template 與打地鼠可以晚一點再做,前面的選取、重繪與事件要先熟。
window 是分頁的全域物件,document 掛在它身上,用 const 宣告的名字不會變成它的屬性。
課堂 demo 把 script 放在 body 尾端。外部檔要延後就加 defer,模組腳本另開檔。寫在 head 且會立刻執行的程式,改等 DOMContentLoaded。
換頁用 location.assign 或 replace,往返用 history.back,尺寸比較 innerWidth 與 screen.width,捲動用 scrollTo。
單筆選取用 querySelector,多筆用 querySelectorAll,前者找不到是 null,後者則是長度為 0 的清單。
給人讀的文字放進 textContent,使用者打的字不要放進 innerHTML。有對應欄位就改 property,外觀用 class,最終樣式與尺寸另讀。
新節點用 createElement 建立後再插入,已經在樹上的節點再插入是搬移,要留下原件就先 cloneNode。
監聽用 addEventListener 登記並用同一個函式解除,被點到的是 target,父層代收時用 closest 找按鈕。
表單送出時先 preventDefault,檢查通過後用 FormData 讀值,資料改了就 render,結束時把計時器識別碼交給對應的 clear。
專有名詞中英對照 上網查這一篇的說法時,用括號裡的英文當關鍵字。順序跟內文第一次出現相同。
認識瀏覽器環境 這一組是分頁、文件樹,以及 script 何時執行。先分清全域物件與文件,再決定程式要不要延後。
瀏覽器物件模型(Browser Object Model)
文件物件模型(Document Object Model)
全域物件(global object)
節點(node)
DOM 樹(DOM tree)
延遲腳本(defer)
文件解析完成(DOMContentLoaded)
模組腳本(module script)
BOM 這一組依網址、視窗、瀏覽器資訊、列印與計時的順序排列。它們都掛在 window 上,也可以直接寫名字。
位置(Location)
歷史紀錄(History)
瀏覽器資訊(Navigator)
剪貼簿(Clipboard)
螢幕(Screen)
指派(assign)
取代(replace)
重新載入(reload)
返回(back)
前進(forward)
相對步數(go)
歷史狀態(history.state)
推入狀態(pushState)
取代狀態(replaceState)
內部寬度(innerWidth)
內部高度(innerHeight)
外部寬度(outerWidth)
可用寬度(availWidth)
裝置像素比(devicePixelRatio)
水平捲動(scrollX)
垂直捲動(scrollY)
捲動到(scrollTo)
相對捲動(scrollBy)
媒體查詢(matchMedia)
偏好語言(language)
連線狀態(onLine)
使用者代理(userAgent)
功能偵測(feature detection)
寫入剪貼簿(writeText)
列印(print)
警示對話框(alert)
確認對話框(confirm)
輸入對話框(prompt)
計時器(timer)
延遲執行(setTimeout)
間隔執行(setInterval)
動畫幀(requestAnimationFrame)
取消動畫幀(cancelAnimationFrame)
DOM 這一組是選取、走訪、改寫與建立節點。事件的名字放在下一組。
選擇器(selector)
後代(descendant)
節點清單(NodeList)
父元素(parent element)
子元素(child element)
兄弟元素(sibling element)
最近祖先(closest)
符合選擇器(matches)
即時集合(HTMLCollection)
文字內容(textContent)
內部文字(innerText)
內部 HTML(innerHTML)
跨站腳本攻擊(XSS)
外部 HTML(outerHTML)
HTML 屬性(attribute)
DOM 屬性(property)
類別名稱(className)
取得屬性(getAttribute)
是否有屬性(hasAttribute)
設定屬性(setAttribute)
移除屬性(removeAttribute)
切換屬性(toggleAttribute)
布林屬性(boolean attribute)
布林 property(boolean property)
自訂資料(dataset)
自訂資料屬性(data-*)
類別清單(classList)
行內樣式(inline style)
計算後樣式(computed style)
邊界矩形(DOMRect)
捲入視窗(scrollIntoView)
建立元素(createElement)
文字節點(text node)
範本元素(template element)
文件片段(DocumentFragment)
DOM 事件 這一組是監聽、委派與表單送出。名字依照點擊、輸入、冒泡、委派、捕獲、送出的順序。
事件監聽(event listener)
行內事件處理器(inline event handler)
內容安全政策(Content Security Policy)
事件物件(event object)
事件目標(event target)
目前目標(currentTarget)
視窗座標(clientX)
頁面座標(pageX)
偏移座標(offsetX)
鍵值(key)
實體鍵碼(code)
重複按鍵(repeat)
修飾鍵(modifier key)
預設行為(default action)
事件冒泡(event bubbling)
事件委派(event delegation)
事件捕獲(event capture)
表單資料(FormData)
約束驗證(constraint validation)
資料驅動畫面 這一組是清單怎麼跟著資料重畫。待辦清單是這個做法的練習。
選讀:打地鼠 這一組是遊戲裡兩個會重複發生的動作。停留時間與分數都寫在狀態裡。