【JavaScript 筆記】函數(上) - part 5

歡迎你點入本篇文章,本系列網頁程式設計,主要紀錄我個人自學的軌跡,另外也作為日後個人複習用。若你喜歡本篇文章,歡迎在文章底下點一顆愛心,或是追蹤我的個人公開頁~


基本函數語法

宣告一個函數使用關鍵字 function 宣告,如下:

1
2
3
4
function functionName(parameter1, parameter2) {
// 函數主體(要執行的程式碼)
return returnValue;
}

範例:

1
2
3
4
5
6
function greet(name) {
console.log(`哈囉,${name}!`);
}

greet("LukeTseng"); // 哈囉,LukeTseng!
greet("Amy"); // 哈囉,Amy!

函數宣告有完整的 Hoisting,可以在宣告之前就呼叫,JS 引擎會自動把整個函數上升(Hoisting)到最頂端:

1
2
3
4
5
sayHi(); // Hi!

function sayHi() {
console.log("Hi!");
}

image

呼叫帶有參數的函數

一般參數

呼叫時傳入的值叫引數(Argument),函數定義裡接收的變數叫參數(Parameter) :

1
2
3
4
5
6
7
8
9
function add(a, b) {    // a, b 是參數(Parameter)
console.log(a + b);
}

add(3, 5); // 3, 5 是引數(Argument)
add(10, 20);

// 如果傳入的引數數量不夠,缺少的參數值為 undefined
add(10); // NaN(10 + undefined = NaN)

預設參數(Default Parameters)

ES6 新增,當呼叫時沒有傳入對應引數(或傳入 undefined),就自動使用預設值:

1
2
3
4
5
6
7
8
function greet(name = "訪客", greeting = "哈囉") {
console.log(`${greeting}${name}!`);
}

greet(); // 哈囉,訪客!
greet("LukeTseng"); // 哈囉,LukeTseng!
greet("LukeTseng", "嗨"); // 嗨,LukeTseng!
greet(undefined, "嗨"); // 嗨,訪客!

有回傳值的函數

return 語句讓函數回傳一個值,呼叫後可接收這個結果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function calculateBMI(height, weight) {
let bmi = weight / ((height / 100) ** 2);
return bmi; // 回傳計算結果
}

let result = calculateBMI(175, 70);
console.log(result.toFixed(2)); // 22.86

// return 也可以提早結束函數
function checkAge(age) {
if (age < 0) {
return "年齡不合法"; // 提早結束,後面的程式碼不執行
}
return `你 ${age} 歲`;
}

console.log(checkAge(-1)); // 年齡不合法
console.log(checkAge(21)); // 你 21 歲

須注意函數沒有 returnreturn 後面沒有值時,預設回傳 undefined

函數的作用域(Scope)

所謂作用域亦即變數在哪個範圍內有效,函數會建立自己的區域作用域(Local Scope),內部宣告的變數在外部無法存取:

1
2
3
4
5
6
7
8
9
10
11
let globalVar = "我是全域變數";  // 全域作用域(Global Scope)

function myFunc() {
let localVar = "我是區域變數"; // 函式作用域(Local Scope)
console.log(globalVar); // 函數內可以存取全域變數
console.log(localVar); // 我是區域變數
}

myFunc();
console.log(globalVar); // 我是全域變數
console.log(localVar); // ReferenceError:localVar is not defined

作用域鏈(Scope Chain)

JS 找變數時,會從最內層往外層一層一層找,直到找到全域為止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let x = "全域";

function outer() {
let x = "外層函數";

function inner() {
let x = "內層函數";
console.log(x); // "內層函數"(先找自己)
}

inner();
console.log(x); // "外層函數"(inner 裡的 x 不影響這裡)
}

outer();
console.log(x); // "全域"

函數種類

1. Anonymous Function(匿名函數)

亦即沒有名字的函數,通常用在不需要重複呼叫的一次性場合:

1
2
3
4
5
6
7
8
9
// 直接傳入作為引數,沒有名字
setTimeout(function() {
console.log("2秒後執行");
}, 2000);

// 常見於陣列方法
[1, 2, 3].forEach(function(num) {
console.log(num * 2); // 2, 4, 6
});

2. Function Expression(函數運算式)

把函數存入變數,這個變數就代表這個函數:

1
2
3
4
5
6
const square = function(n) {
return n * n;
};

console.log(square(5)); // 25
console.log(typeof square); // "function"

與函數宣告的差別:Function Expression 不會 Hoisting,必須先定義才能呼叫。

1
2
console.log(square(3));  // ReferenceError:Cannot access before initialization
const square = function(n) { return n * n; };

image

3. Arrow Function(箭頭函數)

ES6 新增,語法最簡潔,是現代 JS 最常用的函數寫法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 完整語法
const add = (a, b) => {
return a + b;
};

// 簡化 1:只有一個參數,可省略括號
const double = n => {
return n * 2;
};

// 簡化 2:函數主體只有一行 return,可省略 {} 和 return
const triple = n => n * 3;

// 簡化 3:無參數,括號不能省略
const sayHi = () => console.log("Hi!");

console.log(add(3, 4)); // 7
console.log(double(5)); // 10
console.log(triple(4)); // 12
sayHi(); // Hi!

// 回傳物件時,要用括號包住(避免 {} 被誤認為區塊)
const getUser = (name) => ({ name: name, role: "student" });
console.log(getUser("LukeTseng")); // { name: "LukeTseng", role: "student" }

4. IIFE(立即呼叫函數運算式)

IIFE 全名為 Immediately Invoked Function Expression。

意思就是定義完馬上執行,執行完即消失,不會污染全域作用域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 語法:用 () 包住函數,再加一對 () 立刻呼叫
(function() {
let secret = "這個變數只存在這裡";
console.log("IIFE 執行了!");
console.log(secret);
})();

// secret 在外面完全存取不到
console.log(typeof secret); // "undefined"

// 也可傳入引數
(function(name) {
console.log(`哈囉,${name}!`);
})("LukeTseng");
// 哈囉,LukeTseng!

// 箭頭函數版 IIFE
(() => {
console.log("箭頭 IIFE!");
})();

image

IIFE 常用於初始化設定、模組封裝等需要立刻執行但不想暴露內部變數的場合。

5. Callback Function(回呼函數)

把函數當作引數傳給另一個函數,讓那個函數在適當時機呼叫它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function doTask(task, callback) {
console.log(`正在執行:${task}`);
callback(); // 在適當時機呼叫傳入的函數 onComplete
}

function onComplete() {
console.log("任務完成!");
}

doTask("洗碗", onComplete); // 這邊傳入了 onComplete 函數進去
// 正在執行:洗碗
// 任務完成!

// 常見的內建 Callback 場景
[3, 1, 4, 1, 5].sort((a, b) => a - b); // 排序
[1, 2, 3].map(n => n * 10); // [10, 20, 30]
[1, 2, 3, 4].filter(n => n % 2 === 0); // [2, 4]

// 非同步 Callback(定時器)
console.log("開始");
setTimeout(() => {
console.log("2秒後執行"); // 這行最後才印出
}, 2000);
console.log("結束");
// 開始 -> 結束 -> (2秒後)2秒後執行

6. Rest Parameter Function(其餘參數函數)

... 把不定數量的引數全部收集成一個陣列,讓函數接受任意多個參數:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 語法:最後一個參數前加 ...
function sum(...numbers) {
let total = 0;
for (let n of numbers) {
total += n;
}
return total;
}

console.log(sum(1, 2)); // 3
console.log(sum(1, 2, 3, 4)); // 10
console.log(sum(10, 20, 30, 40)); // 100

// Rest 和一般參數混用:rest 必須放最後
function introduce(greeting, ...names) {
names.forEach(name => console.log(`${greeting}${name}!`));
}

introduce("哈囉", "LukeTseng", "Amy", "Bob");
// 哈囉,LukeTseng!
// 哈囉,Amy!
// 哈囉,Bob!

函式種類比較

種類名稱有無Hoisting主要用途
函數宣告Y完整提升一般功能函數
Function Expression可有可無N存入變數、有條件建立
Anonymous FunctionNN一次性使用,傳入 Callback
Arrow Function可有可無N現代主流,簡潔語法
IIFE可有可無N(立即執行)初始化、避免污染全域
Callback Function可有可無視宣告方式事件處理、非同步操作
Rest Parameter通常有視宣告方式不定數量引數的函數

函數綁定(Function Binding)

Function Binding(函數綁定)是決定函數執行時,this 關鍵字指向哪個物件的機制。

為什麼需要 Function Binding?

在 JS 中,函數是獨立存在的物件,同一個函數可以被不同的物件呼叫,此時就會有一個問題:函數內的 this 到底是誰?

1
2
3
4
5
6
7
8
9
10
11
12
13
const luke = { name: "Luke" };
const amy = { name: "Amy" };

function greet() {
console.log(`Hello, I'm ${this.name}`);
}

// 同一個函數,被不同物件呼叫,this 就會不同
luke.greet = greet;
amy.greet = greet;

luke.greet(); // Hello, I'm Luke(this → luke)
amy.greet(); // Hello, I'm Amy(this → amy)

Function Binding 要解決的問題

當函數被 “轉手傳遞” 後,this 的指向很容易意外脫落,變成 windowundefined

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const student = {
name: "Luke",
greet() {
console.log(`我是 ${this.name}`);
}
};

student.greet(); // 我是 Luke

// 把方法取出來,this 就消失了
const fn = student.greet;
fn(); // 我是 undefined(this 變成 window)

// 傳進 setTimeout,同樣出問題
setTimeout(student.greet, 1000); // 我是 undefined

Function Binding 就是為了解決 this 脫落的問題,明確控制函數執行時 this 要指向誰。

this Binding(this 綁定)

this 是一個在函數執行當下才決定值的關鍵字,它不是在定義時決定,而是在呼叫時決定,根據呼叫方式不同,this 有四種綁定規則:

規則一:預設綁定(Default Binding)

函數被直接呼叫(不附屬於任何物件),this 指向全域物件(瀏覽器中是 window),嚴格模式下為 undefined

1
2
3
4
5
function showThis() {
console.log(this);
}

showThis(); // window(瀏覽器)或 undefined(嚴格模式)

規則二:隱含式綁定(Implicit Binding)

函數被當作物件的方法呼叫,this 指向呼叫它的那個物件:

1
2
3
4
5
6
7
8
9
10
11
12
const student = {
name: "Luke",
greet() {
console.log(`我是 ${this.name}`); // this → student
}
};

student.greet(); // 我是 Luke

// 把方法取出來後,this 就消失了
const fn = student.greet;
fn(); // 我是 undefined(this 變成 window,window.name 不存在)

規則三:顯式綁定(Explicit Binding)

callapplybind 強制指定 this 的目標,接下來會詳細說明。

規則四:new 關鍵字綁定

new 呼叫函數時,this 指向新建立的物件:

1
2
3
4
5
6
7
8
9
function Person(name) {
this.name = name; // this -> 新建的物件
this.greet = function() {
console.log(`我是 ${this.name}`);
};
}

const luke = new Person("Luke");
luke.greet(); // 我是 Luke

四種規則的優先順序

當多個規則同時存在時,優先順序由高到低為:

new 綁定 > 顯式綁定(call/apply/bind)> 隱含式綁定 > 預設綁定

Permanent Binding(永久綁定)

bind() 建立的新函數,其 this 被永久固定,無論之後怎麼呼叫、再次 call/apply 都無法改變:

1
2
3
4
5
6
7
8
9
10
11
12
const user = { name: "Luke" };
const admin = { name: "Admin" };

function sayName() {
console.log(this.name);
}

const boundFn = sayName.bind(user); // 永久綁定到 user

boundFn(); // "Luke"
boundFn.call(admin); // "Luke"(call 無法覆蓋 bind 的綁定)
boundFn.apply(admin); // "Luke"(apply 也無法覆蓋)

Partial Application(部分應用)

bind() 除了綁定 this,還可以預先填入部分參數,產生一個新的、已預填某些引數的函數:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function multiply(a, b) {
return a * b;
}

// 預先固定第一個參數 a = 2,建立一個乘以2的函數
const double = multiply.bind(null, 2); // null 表示不關心 this
const triple = multiply.bind(null, 3); // 預先固定 a = 3

console.log(double(5)); // 10(等同 multiply(2, 5))
console.log(double(10)); // 20
console.log(triple(5)); // 15

// 應用:建立不同稅率的計算函數
function calculateTax(rate, price) {
return price * rate;
}

const vat5 = calculateTax.bind(null, 0.05); // 5% 稅率
const vat10 = calculateTax.bind(null, 0.10); // 10% 稅率

console.log(vat5(1000)); // 50
console.log(vat10(1000)); // 100

Methods of Function Binding

1. bind()

建立一個新的函數,this 永久綁定,不立即執行 :

語法:

1
const newFn = 原函數.bind(thisArg, arg1, arg2, ...)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const person = {
name: "Luke",
school: "國立XX大學"
};

function introduce(age, job) {
console.log(`我是 ${this.name}${age} 歲,在 ${this.school} 上學,職業:${job}`);
}

// 建立新函數,this 綁定 person,不立即執行
const lukeSelfIntro = introduce.bind(person, 21);

// 之後再呼叫
lukeSelfIntro("學生");

// 應用:解決 setTimeout 內的 this 問題
const timer = {
name: "計時器",
start() {
setTimeout(function() {
console.log(`${this.name} 啟動`); // this 會是 window
}, 1000);

setTimeout(function() {
console.log(`${this.name} 啟動`); // this 正確綁定
}.bind(this), 1000); // 用 bind 修正
}
};

2. call()

立即執行函數,同時指定 this 與參數,參數逐一傳入:

語法:

1
函數.call(thisArg, arg1, arg2, ...)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const player1 = { name: "Luke",  score: 95 };
const player2 = { name: "Amy", score: 87 };

function showInfo(level, server) {
console.log(`玩家:${this.name},分數:${this.score},等級:${level},伺服器:${server}`);
}

// 借用函數,分別套用到不同物件上
showInfo.call(player1, "鑽石", "台灣");

showInfo.call(player2, "黃金", "馬來西亞");

// 借用 Array 方法給類陣列物件(如 arguments)
function collectArgs() {
const arr = Array.prototype.slice.call(arguments); // arguments 轉成真正的陣列
console.log(arr);
}
collectArgs(1, 2, 3); // [1, 2, 3]

3. apply()

call() 幾乎相同,差別在於參數要包在陣列裡傳入:

語法:

1
函數.apply(thisArg, [arg1, arg2, ...])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const player1 = { name: "Luke", score: 95 };

function showInfo(level, server) {
console.log(`玩家:${this.name},等級:${level},伺服器:${server}`);
}

// apply 的參數用陣列包起來
showInfo.apply(player1, ["鑽石", "台灣"]);

// apply 的最大優勢:搭配展開陣列使用
const scores = [87, 45, 99, 72, 63];

// Math.max 不接受陣列,但可以用 apply 展開
console.log(Math.max.apply(null, scores)); // 99
// 現代寫法
console.log(Math.max(...scores)); // 99(兩者等價)

bind / call / apply 三者比較

特性bind()call()apply()
是否立即執行回傳新函數立即執行立即執行
傳入參數方式逐一傳入逐一傳入陣列傳入
this 是否永久固定永久固定僅此次僅此次
應用事件監聽、計時器修正函數借用展開陣列參數

Arrow Functions and this Binding

箭頭函數沒有自己的 this,它的 this 是在定義當下從外層作用域繼承進來的,稱為詞法綁定(Lexical Binding,亦稱靜態綁定)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const student = {
name: "Luke",

// 一般函數:this 由呼叫時決定
greetNormal: function() {
console.log(`一般函數:${this.name}`);
},

// 箭頭函數:this 從定義處的外層繼承(這裡外層是全域)
greetArrow: () => {
console.log(`箭頭函數:${this.name}`); // this -> window,不是 student
}
};

student.greetNormal(); // 一般函數:Luke
student.greetArrow(); // 箭頭函數:undefined

解決巢狀的 this 問題

這是箭頭函數設計出來最主要要解決的問題:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const team = {
name: "資工系",
members: ["Luke", "Amy", "Bob"],

// 用一般函數,內層的 this 會跑掉
listMembersNormal: function() {
this.members.forEach(function(member) {
// 這裡的 this 是 window
console.log(`${this.name} 的成員:${member}`);
});
},

// 用箭頭函數,this 繼承外層的 team
listMembersArrow: function() {
this.members.forEach((member) => {
// 箭頭函數繼承外層 this,指向 team
console.log(`${this.name} 的成員:${member}`);
});
}
};

team.listMembersNormal();
// undefined 的成員:Luke(錯誤)

team.listMembersArrow();
// 資工系 的成員:Luke
// 資工系 的成員:Amy
// 資工系 的成員:Bob

call/apply/bind 對箭頭函數無效

由於箭頭函數的 this 在定義時就固定了,強制綁定完全無效:

1
2
3
4
5
6
7
8
const obj = { num: 100 };
window.num = 2026;

const arrowFn = (a) => this.num + a; // this 永遠是定義時的外層(window)

console.log(arrowFn.call(obj, 1)); // 2027
// window.num + 1 = 2026 + 1 = 2027
// call 完全無法改變箭頭函數的 this

原本預期是輸出 100 + 1,也就是我們傳入的 obj 物件,但 this 用在箭頭函數只會是定義時外層的 window。

何時用箭頭函數、何時用一般函數

情境建議原因
物件的方法(method)一般函數需要 this 指向物件本身
forEachmapfilter 的 Callback箭頭函數繼承外層 this,避免錯誤
setTimeout / setInterval 內部箭頭函數同上,否則需要 .bind(this)
需要 arguments 物件的函數一般函數箭頭函數沒有 arguments
建構子(搭配 new一般函數箭頭函數不能用 new 呼叫

總整理

參考資料

Functions in JavaScript - GeeksforGeeks

JavaScript Function Binding - GeeksforGeeks

JavaScript 函数 | 菜鸟教程

披薩筆記 - JS 底層觀念 - this 物件 下(this 綁定控制)

使用 bind、call、apply 改變 this 指向的對象 - 一顆藍莓

重新認識 JavaScript: Day 20 What’s “THIS” in JavaScript (鐵人精華版) - iT 邦幫忙::一起幫忙解決難題,拯救 IT 人的一天