函式——可重用的程式碼塊

程式設計中另一個基本概念是函式,它允許你將完成單一任務的程式碼儲存在一個定義好的塊中,然後透過一個簡短的命令在需要時呼叫該程式碼,而不是重複多次鍵入相同的程式碼。在本文中,我們將探討函式背後的基本概念,例如基本語法、如何呼叫和定義它們、作用域和引數。

預備知識 瞭解 HTMLCSS 基礎,熟悉前面課程中介紹的 JavaScript 基礎。
學習成果
  • 函式旨在建立可重用的程式碼塊,以便在需要時進行呼叫。
  • 函式在 JavaScript 中隨處可見,其中一些是內置於瀏覽器中的,另一些是使用者定義的。
  • 函式和方法之間的區別。
  • 呼叫函式。
  • 匿名函式和箭頭函式。
  • 定義函式引數,向函式呼叫傳遞實參。
  • 全域性作用域和函式/塊級作用域。
  • 理解什麼是回撥函式。

在哪裡可以找到函式?

在 JavaScript 中,函式無處不在。事實上,到目前為止,我們一直在整個課程中使用函式;我們只是沒有過多地談論它們。然而,現在是時候我們開始明確地談論函式,並真正探索它們的語法了。

幾乎任何時候你使用一個帶有括號 — () — 的 JavaScript 結構,並且你沒有使用像 for 迴圈while 或 do...while 迴圈if...else 語句這樣的常見內建語言結構時,你都在使用一個函式。

內建瀏覽器函式

在本課程中,我們大量使用了內置於瀏覽器中的函式。

例如,每次我們操作文字字串時

js
const myText = "I am a string";
const newString = myText.replace("string", "sausage");
console.log(newString);
// the replace() string function takes a source string,
// and a target string and replaces the source string,
// with the target string, and returns the newly formed string

或者每次我們運算元組時

js
const myArray = ["I", "love", "chocolate", "frogs"];
const madeAString = myArray.join(" ");
console.log(madeAString);
// the join() function takes an array, joins
// all the array items together into a single
// string, and returns this new string

或者每次我們生成隨機數時

js
const myNumber = Math.random();
// the random() function generates a random number between
// 0 and up to but not including 1, and returns that number

我們都在使用一個函式

注意:如果需要,可以隨時將這些行輸入到瀏覽器的 JavaScript 控制檯中,以重新熟悉它們的功能。

JavaScript 語言有許多內建函式,可以讓你在不必自己編寫所有程式碼的情況下完成有用的事情。事實上,當你呼叫(一個花哨的詞,意思是執行或執行)一個內建瀏覽器函式時,你正在呼叫的某些程式碼是無法用 JavaScript 編寫的——其中許多函式正在呼叫後臺瀏覽器程式碼的部分,這些程式碼主要用 C++ 等低階系統語言編寫,而不是 JavaScript 等 Web 語言。

請記住,某些內建瀏覽器函式不屬於核心 JavaScript 語言——有些是作為瀏覽器 API 的一部分定義的,這些 API 在預設語言的基礎上提供更多功能(有關更多描述,請參閱我們課程的這個早期部分)。我們將在後面的模組中更詳細地探討瀏覽器 API 的使用。

函式與方法

作為物件一部分的函式稱為方法;你將在模組後面學習物件。目前,我們只是想澄清關於方法與函式可能存在的任何混淆——你很可能會在 Web 上檢視相關資源時遇到這兩個術語。

到目前為止我們使用的內建程式碼有兩種形式:函式方法。你可以在我們的 JavaScript 參考中檢視內建函式的完整列表,以及內建物件及其對應的方法。

到目前為止,你也在課程中看到了許多自定義函式——在你的程式碼中定義,而不是在瀏覽器中定義的函式。每當你看到一個自定義名稱後面直接跟著括號時,你都在使用一個自定義函式。在我們的random-canvas-circles.html示例中(另請參閱我們迴圈文章的完整原始碼),我們包含了自定義的draw()函式,它看起來像這樣:

js
function draw() {
  ctx.clearRect(0, 0, WIDTH, HEIGHT);
  for (let i = 0; i < 100; i++) {
    ctx.beginPath();
    ctx.fillStyle = "rgb(255 0 0 / 50%)";
    ctx.arc(random(WIDTH), random(HEIGHT), random(50), 0, 2 * Math.PI);
    ctx.fill();
  }
}

這個函式在<canvas>元素中繪製 100 個隨機圓。每次我們想這樣做時,我們只需用這個來呼叫函式:

js
draw();

而不是每次想重複時都重新編寫所有程式碼。函式可以包含你喜歡的任何程式碼——你甚至可以在函式內部呼叫其他函式。例如,上面的函式呼叫了三次random()函式,該函式由以下程式碼定義:

js
function random(number) {
  return Math.floor(Math.random() * number);
}

我們需要這個函式,因為瀏覽器的內建 Math.random() 函式只生成 0 到 1 之間的隨機小數。我們需要一個 0 到指定數字之間的隨機整數。

呼叫函式

你現在可能已經很清楚這一點了,但以防萬一,要真正使用一個函式,在定義它之後,你必須執行或呼叫它。這是透過在程式碼中某個地方包含函式名稱,然後是括號來完成的。

js
function myFunction() {
  alert("hello");
}

myFunction();
// calls the function once

注意:這種建立函式的形式也稱為函式宣告。它總是會被提升,因此你可以在函式定義上方呼叫函式,並且它會正常工作。

函式引數

有些函式在呼叫時需要指定引數——這些是需要在函式括號內包含的值,函式需要它們才能正常工作。

注意:引數有時被稱為實參、屬性或甚至特性。

例如,瀏覽器內建的 Math.random() 函式不需要任何引數。當被呼叫時,它總是返回 0 到 1 之間的隨機數:

js
const myNumber = Math.random();

然而,瀏覽器內建的字串 replace() 函式需要兩個引數——在主字串中查詢的子字串,以及用於替換該字串的子字串:

js
const myText = "I am a string";
const newString = myText.replace("string", "sausage");

注意:當你需要指定多個引數時,它們用逗號分隔。

可選引數

有時引數是可選的——你不需要指定它們。如果你不指定,函式通常會採用某種預設行為。例如,陣列 join() 函式的引數是可選的:

js
const myArray = ["I", "love", "chocolate", "frogs"];
const madeAString = myArray.join(" ");
console.log(madeAString);
// returns 'I love chocolate frogs'

const madeAnotherString = myArray.join();
console.log(madeAnotherString);
// returns 'I,love,chocolate,frogs'

如果沒有包含引數來指定連線/分隔字元,則預設使用逗號。

預設引數

如果你正在編寫一個函式並希望支援可選引數,你可以透過在引數名稱後新增=,然後是預設值來指定預設值:

js
function hello(name = "Chris") {
  console.log(`Hello ${name}!`);
}

hello("Ari"); // Hello Ari!
hello(); // Hello Chris!

匿名函式和箭頭函式

到目前為止,我們只建立了這樣一個函式:

js
function myFunction() {
  alert("hello");
}

但是你也可以建立一個沒有名稱的函式:

js
(function () {
  alert("hello");
});

這被稱為匿名函式,因為它沒有名稱。當函式期望接收另一個函式作為引數時,你經常會看到匿名函式。在這種情況下,函式引數通常作為匿名函式傳遞。

注意:這種建立函式的形式也稱為函式表示式。與函式宣告不同,函式表示式不會被提升。

匿名函式示例

例如,假設你想在使用者在文字框中輸入時執行一些程式碼。為此,你可以呼叫文字框的 addEventListener() 函式。這個函式期望你傳遞給它(至少)兩個引數:

  • 要監聽的事件名稱,在本例中是 keydown
  • 事件發生時要執行的函式。

當用戶按下某個鍵時,瀏覽器將呼叫你提供的函式,並向其傳遞一個包含此事件資訊的引數,其中包括使用者按下的特定鍵:

js
function logKey(event) {
  console.log(`You pressed "${event.key}".`);
}

textBox.addEventListener("keydown", logKey);

你可以將匿名函式傳遞給 addEventListener(),而不是定義一個單獨的 logKey() 函式:

js
textBox.addEventListener("keydown", function (event) {
  console.log(`You pressed "${event.key}".`);
});

箭頭函式

如果你像這樣傳遞一個匿名函式,還有另一種你可以使用的形式,稱為箭頭函式。你將function(event)寫成(event) =>

js
textBox.addEventListener("keydown", (event) => {
  console.log(`You pressed "${event.key}".`);
});

如果函式只接受一個引數,你可以省略引數周圍的括號:

js
textBox.addEventListener("keydown", event => {
  console.log(`You pressed "${event.key}".`);
});

最後,如果你的函式只包含一個 return 語句,你也可以省略大括號和 return 關鍵字,並隱式返回表示式。在下面的示例中,我們使用 Arraymap() 方法將原始陣列中的每個值翻倍:

js
const originals = [1, 2, 3];

const doubled = originals.map(item => item * 2);

console.log(doubled); // [2, 4, 6]

map() 方法依次獲取陣列中的每個項,並將其傳遞給給定的函式。然後它獲取該函式返回的值並將其新增到新陣列中。

因此,在上面的例子中,item => item * 2 等同於以下箭頭函式:

js
function doubleItem(item) {
  return item * 2;
}

你可以使用相同的簡潔語法重寫 addEventListener 示例。

js
textBox.addEventListener("keydown", (event) =>
  console.log(`You pressed "${event.key}".`),
);

在這種情況下,console.log() 的值(即 undefined)會從回撥函式中隱式返回。

我們建議你使用箭頭函式,因為它們可以使你的程式碼更短、更具可讀性。要了解更多資訊,請參閱 JavaScript 指南中關於箭頭函式的部分,以及我們的 箭頭函式參考頁面

注意:箭頭函式和普通函式之間存在一些細微差別。它們超出了本入門教程的範圍,並且在我們此處討論的情況下不太可能產生差異。要了解更多資訊,請參閱箭頭函式參考文件

箭頭函式線上示例

這是我們上面討論的“keydown”示例的完整工作示例:

HTML

html
<input id="textBox" type="text" />
<div id="output"></div>

JavaScript

js
const textBox = document.querySelector("#textBox");
const output = document.querySelector("#output");

textBox.addEventListener("keydown", (event) => {
  output.textContent = `You pressed "${event.key}".`;
});

結果 - 嘗試在文字框中輸入並檢視輸出

函式作用域和衝突

讓我們談談作用域——這是一個處理函式時非常重要的概念。當你建立一個函式時,函式內部定義的變數和其他內容都處於它們自己獨立的作用域中,這意味著它們被鎖定在自己獨立的隔間中,外部程式碼無法訪問。

所有函式之外的頂層稱為全域性作用域。在全域性作用域中定義的值可以在程式碼的任何地方訪問。

JavaScript 這樣設定有各種原因——但主要是出於安全和組織考慮。有時你不希望變數在程式碼的任何地方都可以訪問。你從其他地方呼叫的外部指令碼可能會開始干擾你的程式碼並導致問題,因為它們恰好使用了與程式碼其他部分相同的變數名,從而導致衝突。這可能是惡意行為,也可能只是意外。

例如,假設你有一個引用兩個外部 JavaScript 檔案的 HTML 檔案,並且它們都定義了一個使用相同名稱的變數和函式:

html
<!-- Excerpt from the HTML -->
<script src="first.js"></script>
<script src="second.js"></script>
<script>
  greeting();
</script>
js
// first.js
const name = "Chris";
function greeting() {
  alert(`Hello ${name}: welcome to our company.`);
}
js
// second.js
const name = "Zaptec";
function greeting() {
  alert(`Our company is called ${name}.`);
}

你可以在 GitHub 上檢視這個即時執行的示例(另請參閱原始碼)。在閱讀下面的解釋之前,請將其載入到單獨的瀏覽器選項卡中。

  • 當示例在瀏覽器中渲染時,你將首先看到一個警報框顯示 Hello Chris: welcome to our company.,這意味著第一個指令碼檔案中定義的 greeting() 函式已被內部指令碼中的 greeting() 呼叫所呼叫。

  • 然而,第二個指令碼根本沒有載入和執行,並且控制檯打印出錯誤:Uncaught SyntaxError: Identifier 'name' has already been declared。這是因為 name 常量已經在 first.js 中宣告,並且你不能在同一作用域中兩次宣告相同的常量。由於第二個指令碼沒有載入,因此無法呼叫 second.js 中的 greeting() 函式。

  • 如果我們從 second.js 中刪除 const name = "Zaptec"; 這一行並重新載入頁面,兩個指令碼都會執行。此時警報框會顯示 Our company is called Chris. 函式可以被重新宣告,並且原始碼順序中最後的宣告將被使用。之前的宣告實際上被覆蓋了。

將程式碼的各個部分鎖定在函式中可以避免此類問題,這被認為是最佳實踐。

這有點像動物園。獅子、斑馬、老虎和企鵝都關在自己的圍欄裡,只能接觸到裡面的東西——就像函式作用域一樣。如果它們能夠進入其他圍欄,就會出現問題。最好的情況是,不同的動物會在不熟悉的環境中感到非常不舒服——獅子或老虎會在企鵝冰冷的水域中感到非常糟糕。最壞的情況是,獅子和老虎可能會試圖吃掉企鵝!

Four different animals enclosed in their respective habitat in a Zoo

動物園管理員就像全域性作用域——他們擁有進入每個圍欄、補充食物、照顧生病動物等的鑰匙。

玩轉作用域

讓我們來看一個真實的例子來演示作用域。

  1. 首先,在本地複製我們的 function-scope.html 示例。它包含兩個名為 a()b() 的函式,以及三個變數——xyz——其中兩個在函式內部定義,一個在全域性作用域中。它還包含第三個名為 output() 的函式,該函式接受一個引數並在頁面上的段落中輸出它。

  2. 在瀏覽器和文字編輯器中開啟示例。

  3. 開啟瀏覽器開發者工具中的 JavaScript 控制檯。在 JavaScript 控制檯中,輸入以下命令:

    js
    output(x);
    

    你應該會在瀏覽器視口中看到變數 x 的值。

  4. 現在嘗試在控制檯中輸入以下內容:

    js
    output(y);
    output(z);
    

    這兩個都應該在控制檯中丟擲類似於“ReferenceError: y is not defined”的錯誤。這是為什麼呢?因為函式作用域:yz 被鎖定在 a()b() 函式內部,所以當從全域性作用域呼叫 output() 時,它無法訪問它們。

  5. 但是,當它從另一個函式內部呼叫時會怎樣?嘗試編輯 a()b(),使它們看起來像這樣:

    js
    function a() {
      const y = 2;
      output(y);
    }
    
    function b() {
      const z = 3;
      output(z);
    }
    

    儲存程式碼並在瀏覽器中重新載入,然後嘗試從 JavaScript 控制檯呼叫 a()b() 函式:

    js
    a();
    b();
    

    你應該在瀏覽器視口中看到 yz 的值。這工作正常,因為 output() 函式是在其他函式內部呼叫的,與它正在列印的變數在同一個作用域中定義。output() 本身可以在任何地方使用,因為它是在全域性作用域中定義的。

  6. 現在嘗試像這樣更新你的程式碼:

    js
    function a() {
      const y = 2;
      output(x);
    }
    
    function b() {
      const z = 3;
      output(x);
    }
    
  7. 再次儲存並重新載入,然後在 JavaScript 控制檯中再次嘗試此操作:

    js
    a();
    b();
    

    a()b() 呼叫都應該將 x 的值列印到瀏覽器視口。這些工作正常,因為儘管 output() 呼叫與 x 定義的作用域不同,但 x 是一個全域性變數,因此在所有程式碼中都可用。

  8. 最後,嘗試像這樣更新你的程式碼:

    js
    function a() {
      const y = 2;
      output(z);
    }
    
    function b() {
      const z = 3;
      output(y);
    }
    
  9. 再次儲存並重新載入,然後在 JavaScript 控制檯中再次嘗試此操作:

    js
    a();
    b();
    

    這次,a()b() 呼叫將在控制檯中丟擲煩人的 ReferenceError: 變數名未定義 錯誤——這是因為 output() 呼叫和它們試圖列印的變數不在同一個函式作用域中——這些變數對這些函式呼叫實際上是不可見的。

注意:相同的作用域規則不適用於迴圈(例如,for() { })和條件塊(例如,if () { })——它們看起來非常相似,但它們不是一回事!請注意不要將這些混淆。

注意:ReferenceError: "x" is not defined 錯誤是你將遇到的最常見的錯誤之一。如果你收到此錯誤並且你確定你已定義了該變數,請檢查它位於哪個作用域中。

總結

本文探討了函式背後的基本概念,為下一篇文章鋪平了道路,我們將在其中進行實踐,並引導你完成構建自己的自定義函式的步驟。

另見