主題: 學習筆記
JavaScript Callback 與高階函式:把操作當成參數
從自訂陣列轉換函式認識 callback、高階函式與 map,並釐清箭頭函式、陣列修改和淺複製。
要把陣列裡的數字都乘以二,可以寫一個迴圈。如果下一次要每個數字加三,又要重寫一遍迴圈嗎?兩段程式走訪資料的方式相同,差別只有每個元素要做什麼。
學習 JavaScript: The Hard Parts, v3 的這個章節時,我把焦點放在這個差別:如何把「走訪陣列」留下來重用,並在呼叫時決定要執行的操作。這也讓 map()、callback 和高階函式之間的關係更容易理解。
把資料和操作都交給參數
一般參數可以讓同一個函式處理不同資料:
function square(number) {
return number * number;
}
console.log(square(3)); // 9
console.log(square(5)); // 25
number 是函式定義中的參數;呼叫時傳入的 3 和 5 是引數。這樣不必為每個數字各寫一個平方函式,也減少重複的邏輯。
JavaScript 的函式也是值,因此可以像其他值一樣存入變數、傳入函式或從函式回傳。既然資料可以透過參數傳入,處理資料的操作也可以:
function transformItems(items, transform) {
const output = [];
for (const item of items) {
output.push(transform(item));
}
return output;
}
function double(number) {
return number * 2;
}
function addThree(number) {
return number + 3;
}
const numbers = [2, 5, 8];
const doubled = transformItems(numbers, double);
const increased = transformItems(numbers, addThree);
console.log(doubled); // [4, 10, 16]
console.log(increased); // [5, 8, 11]
console.log(numbers); // [2, 5, 8]
transformItems 負責走訪和建立輸出陣列,transform 則接收這次要使用的函式。第一次傳入 double,第二次傳入 addThree,同一套走訪步驟就能處理兩種操作。
這裡的 transformItems 是高階函式(higher-order function):它接收另一個函式作為引數。回傳函式的函式也稱為高階函式。double 和 addThree 在這個例子中是 callback,因為它們被交給 transformItems,再由後者呼叫。這些名稱描述的是函式的使用方式,不需要特殊宣告語法。
傳入函式與呼叫函式
在 transformItems(numbers, double) 裡,double 後面沒有括號,因此傳入的是函式值。transformItems 的參數 transform 接收它;執行到 transform(item) 時,才用目前的元素呼叫該函式。
如果改成 transformItems(numbers, double(2)),JavaScript 會先呼叫 double(2),把回傳的數字 4 傳入 transform。接下來程式試著把數字當成函式呼叫,就會發生 TypeError。
傳入函式不會把原本的 double 名稱搬走或改名。只要原本的作用域允許,程式仍然可以用 double 呼叫同一個函式。
以前一篇的執行脈絡筆記來看,第一次處理元素 2 時,transformItems 呼叫 double(2);double 回傳 4 後,程式回到 transformItems,再把 4 放進 output。這個例子中,每次 callback 都在迴圈繼續前執行完畢。
從自訂函式到 map()
陣列內建的 map() 也會對元素呼叫提供的函式,並將回傳值放進新陣列:
const numbers = [2, 5, 8];
const doubled = numbers.map((number) => number * 2);
console.log(doubled); // [4, 10, 16]
console.log(numbers); // [2, 5, 8]
這裡傳入的是直接寫在呼叫處的箭頭函式。map() 會同步執行這個 callback;以本例而言,numbers 不會被改變。
前面的 transformItems 是幫助理解的簡化範例,並不是 map() 的完整實作。map() 還會把索引與原陣列傳給 callback,並跳過稀疏陣列中的空位。新陣列也不代表 callback 一定沒有副作用:callback 仍可能修改原陣列、元素物件或其他外部狀態。
箭頭函式的簡潔寫法
只有一個簡單識別字作為參數時,可以省略參數括號;函式體如果是一個表達式,也可以省略大括號和 return:
const double = (number) => {
return number * 2;
};
const conciseDouble = number => number * 2;
console.log(double(3), conciseDouble(3)); // 6 6
這兩個函式都會回傳運算結果。要留意,有大括號的函式體不會因為最後一行是表達式就自動回傳:
const missingReturn = number => {
number * 2;
};
console.log(missingReturn(3)); // undefined
沒有參數、多個參數,或參數使用預設值、解構時,需要保留參數括號。選擇寫法時也不只看長度:箭頭函式沒有自己的 this,會使用定義位置外層的 this。例如:
const shop = {
multiplier: 2,
makeTransformer() {
return number => number * this.multiplier;
},
};
const transform = shop.makeTransformer();
console.log(transform(5)); // 10
這次 makeTransformer 以 shop.makeTransformer() 呼叫,方法內的 this 是 shop;它回傳的箭頭函式會使用那個外層 this。一般函式的 this 則需要依它實際的呼叫方式判斷。
宣告要做什麼,也要看得懂怎麼做
高階函式常讓呼叫端更接近想表達的結果。例如,先挑出至少為 5 的數字,再把它們乘以二:
const numbers = [2, 5, 8];
const result = numbers
.filter(number => number >= 5)
.map(number => number * 2);
console.log(result); // [10, 16]
讀這段程式時,可以先看到「篩選」和「轉換」的意圖,不必在呼叫處逐步管理索引與 push。這是較宣告式的寫法;自己寫迴圈、逐步安排處理則比較偏向命令式。
兩種寫法可以一起使用。封裝之後仍然有實際的處理步驟,選擇哪一種應看團隊是否能清楚理解意圖與行為,而不是只比較行數。
修改原陣列,還是建立新陣列?
reverse()、sort() 和 splice() 會修改呼叫它們的陣列。對應的 toReversed()、toSorted() 和 toSpliced() 會建立新陣列,方法本身不直接改變原陣列。
要留意 splice() 和 toSpliced() 的回傳值也不同:
const numbers = [3, 1, 2];
const sorted = numbers.toSorted((a, b) => a - b);
const removed = numbers.splice(1, 1, 9);
console.log(sorted); // [1, 2, 3]
console.log(numbers); // [3, 9, 2]
console.log(removed); // [1]
const source = [3, 1, 2];
const changed = source.toSpliced(1, 1, 9);
console.log(source); // [3, 1, 2]
console.log(changed); // [3, 9, 2]
sorted 在 splice() 之前建立,因此不受後續修改影響。splice() 回傳的是被移除的元素;toSpliced() 回傳的是套用變更後的新陣列。toSorted() 的比較函式在這裡指定數字由小到大排列。
保留原陣列很適合用來產生不同的顯示順序,或避免其他持有同一陣列的程式受到非預期的修改。不過,選擇是否修改資料仍要看需求;這些新方法在較舊的執行環境可能需要相容性處理。
新陣列不等於深複製
當元素是物件時,新舊陣列仍可能指向同一批物件:
const people = [{ name: "Ada" }, { name: "Bo" }];
const reversed = people.toReversed();
reversed[1].name = "Mia";
console.log(people.map(person => person.name)); // ["Mia", "Bo"]
console.log(reversed.map(person => person.name)); // ["Bo", "Mia"]
console.log(people[0] === reversed[1]); // true
兩個陣列是不同的,但 people[0] 和 reversed[1] 指向同一個物件。透過其中一個參照修改 name,另一邊也會看到變化。
如果要另外修改這些人的第一層屬性,可以在建立新陣列時也複製每個物件:
const copiedPeople = people.map(person => ({ ...person }));
copiedPeople[0].name = "Lin";
console.log(people[0].name); // "Mia"
console.log(copiedPeople[0].name); // "Lin"
這個展開寫法同樣是淺複製;如果物件裡還有巢狀物件,內層仍可能共用。要保護哪一層資料,需要依實際資料結構決定。
Callback 不一定是非同步
map() 的 callback 會在 map() 執行期間同步呼叫。setTimeout() 則由執行環境安排 callback 在稍後執行:
console.log("start");
[1, 2].map(number => {
console.log(number);
return number * 2;
});
setTimeout(() => console.log("timer"), 0);
console.log("end");
// start
// 1
// 2
// end
// timer
在這個例子裡,即使等待時間是 0,計時器的 callback 也不會插進目前仍在執行的同步程式。callback 何時執行,取決於接收它的函式或 API;箭頭語法本身不會讓它變成非同步。