網頁可以包含閱讀時段清單、存放數字的輸入欄位,以及執行計算的按鈕。在把這些元素連接至事件之前,先要理解負責運算的語言。JavaScript 的值有型別,函式建立作用域,而不同陣列方法對回傳甚麼值、修改哪些物件,有不同的規則。
以下例子以小型閱讀紀錄進行計算。每段都可以視為獨立程式:先預測結果,再追蹤產生結果的操作。如果還未熟悉 JavaScript 執行與文件顯示的分別,可以先重溫瀏覽器、伺服器與網頁資源。
1. 綁定為值命名
識別名稱區分大小寫:minutes 與 Minutes 指不同的綁定。一般名稱可以包含字母、數字、底線與錢號,但不能以數字開頭,也不能使用保留關鍵字。
| 宣告 | 可重新賦值給綁定? | 作用域與用途 |
|---|---|---|
let | 可以 | 區塊作用域;用於後續步驟會重新賦值的情況。 |
const | 不可以 | 區塊作用域;宣告時必須初始化。 |
var | 可以 | 函式作用域而非區塊作用域;也容許重複宣告。 |
存取 let 或 const 綁定之前,必須先完成其初始化。在同一詞法作用域內,再次宣告相同名稱的綁定會產生錯誤。內層區塊可以宣告自己的同名綁定,暫時遮蔽外層的名稱。
let completed = 2;
completed += 1;
const target = 5;
{
const target = 8;
console.log(target); // 8
}
console.log(completed, target); // 3 5
const 固定的是綁定,不是所指物件的內容。常數陣列仍然可以加入新元素;把綁定改為指向另一個陣列,才是另一種操作。
JavaScript 採用動態型別:一個 let 綁定可以先存放數字,後來再存放字串。型別屬於值,綁定本身不會強制只容納某一種固定型別。
2. 型別與轉換決定運算式的意義
常見原始值包括字串、數字、布林值、undefined、null,以及 12n 這類大整數。Symbol 也是一種原始型別。陣列與一般物件屬於物件。typeof 回報值的型別,但要記住兩項歷史特例:typeof null 是 "object",陣列也會得到 "object";應使用 Array.isArray(value) 辨認陣列。
數字採用二進制浮點表示。算術運算包括 +、-、*、/、冪運算 ** 與餘數 %。並非每個十進制小數都能精確表示。NaN 是表示數值結果無效的數字型別值,不是字串。大整數屬於另一種型別,一般算術運算不能直接混合大整數與 number 運算元。
例題
追蹤加法與字串串接
console.log(2 + 3 + "4"); // "54"
console.log("2" + 3 + 4); // "234"
console.log(Number("12") + 3); // 15
console.log(String(12) + 3); // "123"
console.log(Number("later")); // NaN
這些加法由左至右組合。第一個運算式先以 2 + 3 得到數字 5,再與字串相加,串接成 "54"。第二個運算式的第一次相加已產生字串,之後便再次串接。明確使用 Number 與 String 轉換,可以清楚表達預期操作。
=== 與 !== 不會先轉換運算元型別。因此 3 === "3" 是 false,而 3 == "3" 是 true,因為寬鬆相等容許轉換。預期接收數字的計算,應先明確轉換,再比較預期型別的值。
條件會把值轉為布林值。Falsy 值包括 false、0、-0、0n、""、null、undefined 與 NaN;其他一般值則為 truthy。特別要注意,"0" 與 "false" 都是非空字串,因此是 truthy。Boolean(value) 可以明確呈現這項轉換。
邏輯 && 與 || 採用短路求值,回傳其中一個運算元的值,未必是布林值。!value 回傳布林轉換後的否定值。位元運算 & 與 | 處理位元,不能在條件中直接當作邏輯運算子使用。
思考檢查
預測數值與型別
"6" + 2、Number("6") + 2、6 === "6" 與 Boolean("0") 的值分別是甚麼?前兩個運算式中,哪個產生數字?
解答 · 分清轉換與計算
結果依次為 "62"、8、false 與 true。只有 Number("6") + 2 產生數字。第一式串接字串;嚴格相等保留數字與字串的分別;非空字串即使文字是 "0",仍然是 truthy。
3. 字串是不可變的文字值
單引號與雙引號都能建立字串字面值。跳脫序列 \n、\" 與 \\ 分別表示換行、引號與反斜線。範本字面值使用反引號,並以 ${expression} 插入運算結果。
const raw = " chapter one, chapter two ";
const titles = raw.trim().split(", ");
const message = `${titles.length} chapters`;
console.log(titles); // ["chapter one", "chapter two"]
console.log(message); // "2 chapters"
console.log(titles.join(" / ")); // "chapter one / chapter two"
trim 回傳移除開頭與結尾空白後的新字串,不會修改 raw。split 是回傳陣列的字串方法,join 則是回傳字串的陣列方法。字串索引從零開始,但 JavaScript 的 length 與索引操作計算的是 UTF-16 編碼單元。例如 "A".length 是 1,而 "📘".length 是 2;這並不是通用的可見字元計數。
正規表達式描述文字模式。在 /^[A-Z]{2}[0-9]{2}$/ 中,^ 與 $ 把匹配限制在完整輸入,[A-Z]{2} 要求兩個大寫拉丁字母,[0-9]{2} 要求兩個數字。其 .test("RD24") 結果為 true,.test("RD240") 則為 false。模式精確描述指定的格式規則,但不能證明該規則以外的事實。
4. 控制流程選擇與重複工作
以 if/else 作分支;只需要在兩個值之間選擇時,可以用條件運算式 condition ? first : second。switch 選取匹配的 case,break 結束該 switch。迴圈重複執行工作:while 在主體之前測試條件,do...while 在之後測試,for 則把初始化、條件與更新放在一起。
const sessions = [12, 0, 18, 7];
let total = 0;
for (const minutes of sessions) {
if (minutes === 0) continue;
total += minutes;
}
const label = total >= 30 ? "target reached" : "keep reading";
console.log(total, label); // 37 "target reached"
for...of 走訪可迭代的值。這裏依次取得 12、0、18 與 7。continue 跳過目前一輪餘下的操作;break 則會結束整個迴圈。for...in 走訪可列舉的屬性鍵,目的不同,並非讀取陣列元素值的預設選擇。
需要重新賦值給陣列元素時,索引迴圈很有用:sessions[i] = sessions[i] + 1 會改變元素。相反,對另一個 let 迴圈綁定執行 minutes = minutes + 1,只會取代該區域綁定的值,不會改變原本取得該值的陣列元素。
5. 函式分開輸入、計算與結果
定義
參數、引數與回傳值
參數是函式定義中的名稱,引數是呼叫時實際傳入的值。return 結束目前呼叫並提供結果。執行至函式結尾而沒有回傳值,結果是 undefined。
function readingTime(pages, minutesPerPage = 2) {
return pages * minutesPerPage;
}
console.log(readingTime(7)); // 14
console.log(readingTime(7, 3)); // 21
function describeSession(label, ...minutes) {
return `${label}: ${minutes.join(", ")}`;
}
console.log(describeSession("week", 8, 11)); // "week: 8, 11"
缺少引數時,其值是 undefined。預設參數在對應引數省略或明確為 undefined 時生效,傳入 null 則不會啟用預設值。剩餘參數把其餘引數收集成陣列,而且必須放在最後。一般函式也有 arguments 物件,箭頭函式則不建立自己的 arguments。
在所屬作用域內,函式宣告可以在宣告位置之前被呼叫。以 const 儲存的函式運算式,必須等到初始化執行後才能使用。以下箭頭運算式與簡短的一般函式都能計算平方:
const square = value => value * value;
const squareWithBody = value => {
return value * value;
};
console.log(square(6), squareWithBody(6)); // 36 36
運算式主體會把該運算式的結果作為回傳值。主體使用大括號時,需要結果便要明確寫出 return。箭頭函式還會擷取外層的 this;一般方法則可以由呼叫方式決定 this。兩者不會因為某段簡短運算式看似相同,就可以任意互換。
6. 函式值、作用域與回呼
makeAdder 指函式值,makeAdder(4) 則呼叫函式。回呼是傳給另一個函式、由後者呼叫的函式。傳入 record,接收方可以稍後呼叫它;傳入 record(),則會立即呼叫並傳入其結果。
例題
追蹤閉包中的兩次回呼
建立一個固定加 4 的函式,先直接使用,再把它當作兩步計算中的轉換函式。追蹤每次內層呼叫收到的引數,分清回傳的函式與它之後產生的數值結果。
function makeAdder(offset) {
return value => offset + value;
}
const addFour = makeAdder(4);
console.log(addFour(9)); // 13
function applyTwice(value, transform) {
return transform(transform(value));
}
console.log(applyTwice(3, addFour)); // 11
內層函式保留對建立它的那次呼叫中 offset 綁定的存取。函式與其外圍詞法環境的存取關係合起來,稱為閉包。參數 value 屬於每次內層呼叫。makeAdder 以外的程式不能直接存取其區域 offset 綁定。
makeAdder(4) 先回傳函式,尚未執行加法。呼叫 addFour(9) 才把 4 加至 9,得到 13。在 applyTwice(3, addFour) 中,內層套用先把 3 轉成 7,外層套用再把 7 轉成 11。兩次回呼使用同一個擷取的偏移量,但接收不同的引數。
回呼不一定是非同步的。applyTwice 在同一次呼叫中立即執行回呼。事件與計時器則以不同時機呼叫回呼。呼叫函式、傳入函式與排程函式是不同操作。
JavaScript 以值傳遞引數。當值是物件參照時,函式可以透過該參照改變共用物件;重新賦值給區域參數,不會重新賦值給呼叫端的綁定。立即呼叫函式運算式,例如 (() => { const note = "ready"; return note; })(),會建立區域作用域並呼叫一次。
思考檢查
追蹤回呼與外圍作用域
applyTwice(5, makeAdder(2)) 回傳甚麼?所產生的內層函式被呼叫多少次?為甚麼仍可以存取它的 offset?
解答 · 追蹤函式的建立與呼叫
makeAdder(2) 建立一個加 2 的函式。applyTwice 先以 5 呼叫它,得到 7;再以 7 呼叫,得到 9。內層函式共呼叫兩次。外層呼叫返回後,閉包仍保留對 makeAdder(2) 中 offset 綁定的存取。
7. 方法與生成器有不同的呼叫行為
物件可以把函式儲存為方法。在 readingLog.describe() 中,呼叫會把 readingLog 提供為一般方法的 this。生成器函式則建立生成器物件,透過 next() 推進執行。
const readingLog = {
title: "Evening reading",
describe() { return this.title; }
};
console.log(readingLog.describe()); // "Evening reading"
function* checkpoints() {
yield 10;
yield 20;
return 30;
}
const steps = checkpoints();
console.log(steps.next()); // { value: 10, done: false }
console.log(steps.next()); // { value: 20, done: false }
console.log(steps.next()); // { value: 30, done: true }
yield 暫停生成器並產生一個值,之後的 next() 令它繼續執行。回傳值會結束生成器,並帶有 done: true,不是另一個由 yield 產生的項目。這種明確暫停與恢復的模型,有別於一直執行至回傳的一般函式。
8. 陣列是有次序、可共用參照的物件
陣列索引從零開始。元素可以屬於不同型別,包括其他陣列與物件。賦值複製的是參照值,不會建立新陣列。
例題
分清別名、複製與修改
const original = [8, 15, 21];
const alias = original;
const copy = [...original];
alias[0] = 10;
copy.push(30);
console.log(original); // [10, 15, 21]
console.log(alias); // [10, 15, 21]
console.log(copy); // [8, 15, 21, 30]
alias 與 original 指向同一陣列,copy 則指向第二個陣列。展開語法、slice() 與 Array.from() 都建立淺複製,內層物件參照仍然共用。對物件陣列作淺複製,並不會逐一複製每個物件。
剩餘參數與展開語法在不同位置使用相同的三點符號。剩餘參數收集引數,而 [...first, ...second] 把可迭代的值展開至新陣列。解構則把元素取出至不同綁定:const [first, ...remaining] = [8, 15, 21] 令 first 得到 8,remaining 得到陣列 [15, 21]。
9. 按修改行為與回傳值選擇操作
| 操作 | 修改原陣列? | 回傳值 |
|---|---|---|
slice(start, end) | 否 | 包含起點、不包含終點的新陣列。 |
splice(start, count, ...items) | 是 | 被移除元素組成的陣列。 |
push(...items)/unshift(...items) | 是 | 在末端/開頭加入元素後的新長度。 |
pop()/shift() | 是 | 被移除的最後/第一個值;空陣列則為 undefined。 |
reverse() | 是 | 次序反轉後的同一個陣列。 |
sort(compare) | 是 | 按比較規則排序後的同一個陣列。 |
const plan = ["read", "pause", "write"];
const preview = plan.slice(0, 2);
const removed = plan.splice(1, 1, "review", "discuss");
console.log(preview); // ["read", "pause"]
console.log(removed); // ["pause"]
console.log(plan); // ["read", "review", "discuss", "write"]
slice 與 splice 容許由末端開始計算的負起點。兩者的第二個引數意義不同:slice 接收結束索引,splice 接收刪除數目。混淆兩者,便會改變操作所回答的問題。
預設 sort() 比較字串表示,因此 [4, 20, 3].sort() 產生 [20, 3, 4]。數字遞增排序應使用 (a, b) => a - b。要保留原陣列,先建立淺複製再排序:[...values].sort((a, b) => a - b)。
10. 搜尋、篩選與轉換
indexOf 與 lastIndexOf 回傳匹配索引,找不到時回傳 -1,比較採用嚴格相等。includes 回答某個值是否存在,也能識別 NaN。使用述詞時,find 回傳第一個匹配元素或 undefined,filter 則回傳全部匹配元素組成的陣列。map 轉換每個走訪的元素並回傳新陣列。forEach 執行回呼工作並回傳 undefined,不會收集回呼的回傳值。
const entries = [" HTML ", "CSS", "JavaScript"];
const clean = entries.map(title => title.trim());
const longTitles = clean.filter(title => title.length >= 4);
const firstLong = clean.find(title => title.length >= 4);
console.log(clean); // ["HTML", "CSS", "JavaScript"]
console.log(longTitles); // ["HTML", "JavaScript"]
console.log(firstLong); // "HTML"
console.log(clean.indexOf("CSS"), clean.includes("DOM")); // 1 false
對這些沒有空洞的陣列,map 保持元素數目與次序;filter 保持保留元素的相對次序,但可以縮短結果。這兩個方法本身不會改變這裏的輸入陣列。回呼如果修改共用物件,仍然可能產生副作用,因此要同時檢查回呼與方法名稱。
常見錯誤
回呼的回傳值需要接收方
values.forEach(value => value + 1) 計算加法後丟棄結果,不會取代陣列元素。需要由這些和組成的新陣列時,使用 values.map(value => value + 1);需要修改原陣列時,則明確賦值給有索引的元素。
類陣列物件有數字索引與 length,卻未必提供陣列方法。Array.from({ 0: "read", 1: "review", length: 2 }) 會建立真正的陣列。瀏覽器的 HTMLCollection 與 NodeList 是相關例子:NodeList 有 forEach,卻沒有陣列的 find 或 filter;HTMLCollection 也不提供這些陣列方法。querySelectorAll 回傳靜態 NodeList,其他 NodeList 則可能是即時更新的。集合的行為取決於建立它的 API,不只是有沒有 length 屬性。
練習
1. 綁定與它所指的物件
解釋為甚麼 const sessions = [10]; sessions.push(15); 合法,之後執行 sessions = [20] 卻不合法。const copy = [...sessions] 會令內層物件彼此獨立嗎?
解答 · 解答 1:固定綁定與淺複製
push 改變已有陣列,不會重新賦值給 sessions 綁定。後一項操作則嘗試重新賦值給常數綁定。展開語法建立另一個外層陣列,但元素保留各自的值,包括內層物件的參照。這些內層物件並沒有被獨立複製。
2. 遺漏的回傳值
預測 const lengths = ["notes", "web"].map(text => { text.length; }); 的結果。只改寫回呼,令結果包含兩個字串長度。
解答 · 解答 2:運算式主體與區塊主體
結果是 [undefined, undefined],因為區塊主體沒有 return 陳述式。使用 text => text.length 或 text => { return text.length; } 都會得到 [5, 3]。map 收集每次回呼的回傳值,而非回呼計算後丟棄的運算式值。
3. 追蹤陣列變化
由 const tasks = ["read", "write", "review"]; 開始,先執行 const saved = tasks.slice(1);,再執行 const taken = tasks.splice(1, 1, "discuss");。寫出最後三個陣列的內容。
解答 · 解答 3:先複製,再編輯
saved 是 ["write", "review"],taken 是 ["write"],tasks 是 ["read", "discuss", "review"]。slice 先建立獨立的外層陣列,之後 splice 才修改 tasks。這些字串元素是值,因此後來的取代不會改變 saved。
4. 定義回呼處理流程
對 [9, 24, 15, 6] 產生新陣列,只保留至少為 10 的值,再把保留值除以 3。保留原次序與原陣列。寫出結果,並解釋為甚麼兩項操作的次序重要。
解答 · 解答 4:先篩選,再轉換
[9, 24, 15, 6].filter(value => value >= 10).map(value => value / 3) 得到 [8, 5]。篩選測試原本的值。先除以 3 再套用相同門檻 10,測試的便是不同數量,結果會是空陣列。這裏兩個方法都回傳新陣列。
5. 恢復生成器
在 checkpoints 例子所示的三次 steps.next() 之後,再呼叫一次會回傳甚麼?30 是由 yield 產生的項目嗎?
解答 · 解答 5:完成狀態會保留
下一次呼叫回傳 { value: undefined, done: true }。生成器已完成,不會重新開始。30 是帶有 done: true 的最終回傳值,而不是由 yield 產生的值。