函式

函式是 JavaScript 中最基本的組成部分之一。JavaScript 中的函式類似於一個過程——一組執行任務或計算值的語句,但一個過程要作為函式,它應該接受一些輸入並返回一個輸出,並且輸入和輸出之間存在明顯的關聯。要使用函式,你必須在希望呼叫它的作用域中定義它。

另請參閱關於 JavaScript 函式的詳盡參考章節以瞭解詳細資訊。

定義函式

函式宣告

一個函式定義(也稱為函式宣告函式語句)由 function 關鍵字組成,後跟

  • 函式名。
  • 用括號括起來並用逗號分隔的函式引數列表。
  • 定義函式的 JavaScript 語句,用花括號 { /* … */ } 括起來。

例如,以下程式碼定義了一個名為 square 的函式

js
function square(number) {
  return number * number;
}

函式 square 接受一個名為 number 的引數。該函式包含一個語句,該語句表示返回函式的引數(即 number)乘以它自身。 return 語句指定函式返回的值,即 number * number

引數本質上是按值傳遞給函式的——因此,如果函式體內的程式碼為傳遞給函式的引數賦了一個全新的值,則此更改不會全域性反映,也不會在呼叫該函式的程式碼中反映

當你將一個物件作為引數傳遞時,如果函式更改了該物件的屬性,那麼這種更改在函式外部是可見的,如下例所示

js
function myFunc(theObject) {
  theObject.make = "Toyota";
}

const myCar = {
  make: "Honda",
  model: "Accord",
  year: 1998,
};

console.log(myCar.make); // "Honda"
myFunc(myCar);
console.log(myCar.make); // "Toyota"

當你將一個數組作為引數傳遞時,如果函式更改了陣列的任何值,那麼這種更改在函式外部是可見的,如下例所示

js
function myFunc(theArr) {
  theArr[0] = 30;
}

const arr = [45];

console.log(arr[0]); // 45
myFunc(arr);
console.log(arr[0]); // 30

函式宣告和表示式可以巢狀,這形成了作用域鏈。例如

js
function addSquares(a, b) {
  function square(x) {
    return x * x;
  }
  return square(a) + square(b);
}

有關更多資訊,請參閱函式作用域和閉包

函式表示式

儘管上述函式宣告在語法上是一個語句,但函式也可以透過函式表示式建立。

這樣的函式可以是匿名函式;它不必有名字。例如,函式 square 可以定義為

js
const square = function (number) {
  return number * number;
};

console.log(square(4)); // 16

然而,函式表示式可以提供一個名稱。提供一個名稱允許函式引用自身,也使得在偵錯程式的堆疊跟蹤中更容易識別該函式

js
const factorial = function fac(n) {
  return n < 2 ? 1 : n * fac(n - 1);
};

console.log(factorial(3)); // 6

當將函式作為引數傳遞給另一個函式時,函式表示式很方便。以下示例定義了一個 map 函式,它應該將一個函式作為第一個引數,一個數組作為第二個引數。然後,它用一個由函式表示式定義的函式呼叫

js
function map(f, a) {
  const result = new Array(a.length);
  for (let i = 0; i < a.length; i++) {
    result[i] = f(a[i]);
  }
  return result;
}

const numbers = [0, 1, 2, 5, 10];
const cubedNumbers = map(function (x) {
  return x * x * x;
}, numbers);
console.log(cubedNumbers); // [0, 1, 8, 125, 1000]

在 JavaScript 中,可以根據條件定義函式。例如,以下函式定義僅在 num 等於 0 時定義 myFunc

js
let myFunc;
if (num === 0) {
  myFunc = function (theObject) {
    theObject.make = "Toyota";
  };
}

除了這裡描述的定義函式之外,你還可以使用 Function 建構函式在執行時從字串建立函式,這與 eval() 非常相似。

方法是作為物件屬性的函式。在使用物件中閱讀更多關於物件和方法的資訊。

呼叫函式

定義一個函式不會執行它。定義它只是命名函式並指定函式被呼叫時要執行的操作。

呼叫函式實際上使用指定的引數執行指定的操作。例如,如果定義了函式 square,你可以按如下方式呼叫它

js
square(5);

上述語句以引數 5 呼叫函式。函式執行其語句並返回 25

函式在被呼叫時必須在作用域內,但函式宣告可以被提升(在程式碼中出現在呼叫下方)。函式宣告的作用域是宣告它的函式(或者如果是頂層宣告,則是整個程式)。

函式的引數不限於字串和數字。你可以將整個物件傳遞給函式。showProps() 函式(定義在使用物件中)是一個接受物件作為引數的例子。

函式可以呼叫自身。例如,這是一個遞迴計算階乘的函式

js
function factorial(n) {
  if (n === 0 || n === 1) {
    return 1;
  }
  return n * factorial(n - 1);
}

然後你可以按如下方式計算 15 的階乘

js
console.log(factorial(1)); // 1
console.log(factorial(2)); // 2
console.log(factorial(3)); // 6
console.log(factorial(4)); // 24
console.log(factorial(5)); // 120

還有其他呼叫函式的方法。通常在需要動態呼叫函式、函式引數數量可變或函式呼叫的上下文需要設定為在執行時確定的特定物件的情況下。

事實證明,函式本身就是物件——反過來,這些物件也具有方法。(請參閱Function 物件。)call()apply() 方法可以用於實現此目標。

函式提升

考慮下面的例子

js
console.log(square(5)); // 25

function square(n) {
  return n * n;
}

這段程式碼執行沒有任何錯誤,儘管在宣告 square() 函式之前就呼叫了它。這是因為 JavaScript 直譯器將整個函式宣告提升到當前作用域的頂部,所以上面的程式碼等同於

js
// All function declarations are effectively at the top of the scope
function square(n) {
  return n * n;
}

console.log(square(5)); // 25

函式提升只適用於函式宣告——不適用於函式表示式。以下程式碼將不起作用

js
console.log(square(5)); // ReferenceError: Cannot access 'square' before initialization
const square = function (n) {
  return n * n;
};

遞迴

函式可以引用並呼叫自身。它可以透過函式表示式或宣告的名稱來引用,也可以透過任何引用函式物件的在作用域內的變數來引用。例如,考慮以下函式定義

js
const foo = function bar() {
  // statements go here
};

在函式體內部,你可以將函式本身稱為 barfoo,並使用 bar()foo() 呼叫它。

呼叫自身的函式稱為遞迴函式。在某些方面,遞迴類似於迴圈。兩者都執行相同的程式碼多次,並且兩者都需要一個條件(以避免無限迴圈,或者在這種情況下是無限遞迴)。

例如,考慮以下迴圈

js
let x = 0;
// "x < 10" is the loop condition
while (x < 10) {
  // do stuff
  x++;
}

它可以轉換為遞迴函式宣告,然後呼叫該函式

js
function loop(x) {
  // "x >= 10" is the exit condition (equivalent to "!(x < 10)")
  if (x >= 10) {
    return;
  }
  // do stuff
  loop(x + 1); // the recursive call
}
loop(0);

然而,有些演算法不能是簡單的迭代迴圈。例如,透過遞迴獲取樹結構(例如 DOM)的所有節點更容易

js
function walkTree(node) {
  if (node === null) {
    return;
  }
  // do something with node
  for (const child of node.childNodes) {
    walkTree(child);
  }
}

與函式 loop 相比,這裡的每個遞迴呼叫本身都會進行許多遞迴呼叫。

可以將任何遞迴演算法轉換為非遞迴演算法,但邏輯通常要複雜得多,並且這樣做需要使用堆疊。

事實上,遞迴本身就使用了一個棧:函式棧。堆疊式的行為可以在以下示例中看到

js
function foo(i) {
  if (i < 0) {
    return;
  }
  console.log(`begin: ${i}`);
  foo(i - 1);
  console.log(`end: ${i}`);
}
foo(3);

// Logs:
// begin: 3
// begin: 2
// begin: 1
// begin: 0
// end: 0
// end: 1
// end: 2
// end: 3

立即呼叫的函式表示式 (IIFE)

一個立即呼叫的函式表示式 (IIFE) 是一種程式碼模式,它直接呼叫定義為表示式的函式。它看起來像這樣

js
(function () {
  // Do something
})();

const value = (function () {
  // Do something
  return someValue;
})();

函式不是儲存在變數中,而是立即呼叫。這幾乎等同於直接編寫函式體,但有一些獨特的優點

  • 它建立了一個額外的變數作用域,這有助於將變數限制在它們有用的地方。
  • 它現在是一個表示式而不是一系列語句。這允許你在初始化變數時編寫複雜的計算邏輯。

更多資訊,請參閱 IIFE 術語表條目。

函式作用域和閉包

函式為變數形成作用域——這意味著在函式內部定義的變數無法從函式外部的任何地方訪問。函式作用域繼承自所有上層作用域。例如,在全域性作用域中定義的函式可以訪問在全域性作用域中定義的所有變數。在另一個函式內部定義的函式也可以訪問在其父函式中定義的所有變數,以及父函式有權訪問的任何其他變數。另一方面,父函式(以及任何其他父作用域)無權訪問內部函式中定義的變數和函式。這為內部函式中的變數提供了一種封裝。

js
// The following variables are defined in the global scope
const num1 = 20;
const num2 = 3;
const name = "Chamakh";

// This function is defined in the global scope
function multiply() {
  return num1 * num2;
}

console.log(multiply()); // 60

// A nested function example
function getScore() {
  const num1 = 2;
  const num2 = 3;

  function add() {
    return `${name} scored ${num1 + num2}`;
  }

  return add();
}

console.log(getScore()); // "Chamakh scored 5"

閉包

我們也將函式體稱為閉包。閉包是任何引用某些變數的原始碼片段(最常見的是函式),即使宣告這些變數的作用域已退出,閉包也會“記住”這些變數。

閉包通常用巢狀函式來演示,以表明它們在父作用域的生命週期之外仍能記住變數;但實際上,巢狀函式不是必需的。從技術上講,JavaScript 中的所有函式都形成閉包——有些只是沒有捕獲任何東西,閉包甚至不必是函式。有用閉包的關鍵要素如下

  • 一個父作用域,它定義了一些變數或函式。它應該有一個明確的生命週期,這意味著它應該在某個時刻完成執行。任何不是全域性作用域的作用域都滿足此要求;這包括塊、函式、模組等。
  • 在父作用域內定義的內部作用域,它引用了在父作用域中定義的某些變數或函式。
  • 內部作用域設法在父作用域的生命週期之外存活。例如,它被儲存到在父作用域外部定義的變數中,或者它從父作用域返回(如果父作用域是一個函式)。
  • 然後,當你在父作用域之外呼叫該函式時,即使父作用域已完成執行,你仍然可以訪問在父作用域中定義的變數或函式。

以下是閉包的典型示例

js
// The outer function defines a variable called "name"
const pet = function (name) {
  const getName = function () {
    // The inner function has access to the "name" variable of the outer function
    return name;
  };
  return getName; // Return the inner function, thereby exposing it to outer scopes
};
const myPet = pet("Vivie");

console.log(myPet()); // "Vivie"

它可能比上面的程式碼複雜得多。一個包含用於操縱外部函式內部變數的方法的物件可以被返回。

js
const createPet = function (name) {
  let sex;

  const pet = {
    // setName(newName) is equivalent to setName: function (newName)
    // in this context
    setName(newName) {
      name = newName;
    },

    getName() {
      return name;
    },

    getSex() {
      return sex;
    },

    setSex(newSex) {
      if (
        typeof newSex === "string" &&
        (newSex.toLowerCase() === "male" || newSex.toLowerCase() === "female")
      ) {
        sex = newSex;
      }
    },
  };

  return pet;
};

const pet = createPet("Vivie");
console.log(pet.getName()); // Vivie

pet.setName("Oliver");
pet.setSex("male");
console.log(pet.getSex()); // male
console.log(pet.getName()); // Oliver

在上面的程式碼中,外部函式的 name 變數對內部函式是可訪問的,並且除了透過內部函式之外沒有其他方法可以訪問內部變數。內部函式的內部變數充當外部引數和變數的安全儲存。它們為內部函式提供“持久”和“封裝”的資料來處理。函式甚至不必分配給變數,也不必具有名稱。

js
const getCode = (function () {
  const apiCode = "0]Eal(eh&2"; // A code we do not want outsiders to be able to modify…

  return function () {
    return apiCode;
  };
})();

console.log(getCode()); // "0]Eal(eh&2"

在上面的程式碼中,我們使用了IIFE模式。在這個 IIFE 作用域內,存在兩個值:一個變數 apiCode 和一個未命名函式,該函式被返回並賦值給變數 getCodeapiCode 在返回的未命名函式的範圍內,但不在程式任何其他部分的範圍內,因此除了透過 getCode 函式之外,無法讀取 apiCode 的值。

多層巢狀函式

函式可以多層巢狀。例如

  • 一個函式(A)包含一個函式(B),而 B 本身又包含一個函式(C)。
  • 函式 BC 在這裡都形成了閉包。因此,B 可以訪問 A,而 C 可以訪問 B
  • 此外,由於 C 可以訪問 B,而 B 又可以訪問 A,因此 C 也可以訪問 A

因此,閉包可以包含多個作用域;它們遞迴地包含其包含函式的作用域。這被稱為作用域鏈。考慮以下示例

js
function A(x) {
  function B(y) {
    function C(z) {
      console.log(x + y + z);
    }
    C(3);
  }
  B(2);
}
A(1); // Logs 6 (which is 1 + 2 + 3)

在這個例子中,C 訪問了 ByAx。這之所以可以做到,是因為

  1. B 形成了一個包含 A 的閉包(即 B 可以訪問 A 的引數和變數)。
  2. C 形成了一個包含 B 的閉包。
  3. 因為 C 的閉包包含了 B,而 B 的閉包包含了 A,所以 C 的閉包也包含了 A。這意味著 C 可以訪問 B A 的引數和變數。換句話說,C 連結BA 的作用域,按該順序

然而,反之則不成立。A 無法訪問 C,因為 A 無法訪問 B 的任何引數或變數,而 CB 的一個變數。因此,C 仍然只對 B 私有。

命名衝突

當閉包作用域中的兩個引數或變數具有相同的名稱時,會發生命名衝突。更深層巢狀的作用域具有優先權。因此,最內層的作用域具有最高優先權,而最外層的作用域具有最低優先權。這就是作用域鏈。鏈中的第一個是最內層的作用域,最後一個是最外層的作用域。考慮以下情況

js
function outside() {
  const x = 5;
  function inside(x) {
    return x * 2;
  }
  return inside;
}

console.log(outside()(10)); // 20 (instead of 10)

命名衝突發生在語句 return x * 2 處,發生在 inside 的引數 xoutside 的變數 x 之間。這裡的作用域鏈是 inside => outside => 全域性物件。因此,insidex 優先於 outsidex,返回的是 20 (insidex) 而不是 10 (outsidex)。

使用 arguments 物件

函式的引數以類陣列物件的形式維護。在函式內部,你可以按如下方式訪問傳遞給它的引數

js
arguments[i];

其中 i 是引數的序號,從 0 開始。因此,傳遞給函式的第一個引數將是 arguments[0]。引數的總數由 arguments.length 指示。

使用 arguments 物件,你可以用比正式宣告接受的引數更多的引數來呼叫函式。如果你事先不知道將有多少引數傳遞給函式,這通常很有用。你可以使用 arguments.length 來確定實際傳遞給函式的引數數量,然後使用 arguments 物件訪問每個引數。

例如,考慮一個連線多個字串的函式。該函式唯一的正式引數是一個字串,它指定了用於分隔要連線項的字元。該函式定義如下

js
function myConcat(separator) {
  let result = ""; // initialize list
  // iterate through arguments
  for (let i = 1; i < arguments.length; i++) {
    result += arguments[i] + separator;
  }
  return result;
}

你可以向此函式傳遞任意數量的引數,它會將每個引數連線成一個字串“列表”

js
console.log(myConcat(", ", "red", "orange", "blue"));
// "red, orange, blue, "

console.log(myConcat("; ", "elephant", "giraffe", "lion", "cheetah"));
// "elephant; giraffe; lion; cheetah; "

console.log(myConcat(". ", "sage", "basil", "oregano", "pepper", "parsley"));
// "sage. basil. oregano. pepper. parsley. "

注意: arguments 變數是“類陣列”的,但不是一個數組。它是類陣列的,因為它具有帶編號的索引和 length 屬性。但是,它不具備所有陣列操作方法。

有關更多資訊,請參閱 JavaScript 參考中的Function 物件。

函式引數

有兩種特殊型別的引數語法:預設引數剩餘引數

預設引數

在 JavaScript 中,函式的引數預設值為 undefined。但是,在某些情況下,設定不同的預設值可能很有用。這正是預設引數所做的事情。

過去,設定預設值的通用策略是在函式體中測試引數值,如果它們是 undefined,則分配一個值。

在以下示例中,如果未為 b 提供值,則在計算 a*b 時其值將為 undefined,並且對 multiply 的呼叫通常會返回 NaN。但是,此示例中的第二行阻止了這種情況

js
function multiply(a, b) {
  b = typeof b !== "undefined" ? b : 1;
  return a * b;
}

console.log(multiply(5)); // 5

使用預設引數,函式體中的手動檢查不再是必需的。你可以在函式頭中將 1 設定為 b 的預設值

js
function multiply(a, b = 1) {
  return a * b;
}

console.log(multiply(5)); // 5

有關更多詳細資訊,請參閱參考中的預設引數

剩餘引數

剩餘引數語法允許我們將不定數量的引數表示為陣列。

在以下示例中,函式 multiply 使用剩餘引數來收集從第二個引數到末尾的引數。然後,該函式將這些引數乘以第一個引數。

js
function multiply(multiplier, ...theArgs) {
  return theArgs.map((x) => multiplier * x);
}

const arr = multiply(2, 1, 2, 3);
console.log(arr); // [2, 4, 6]

箭頭函式

箭頭函式表示式(也稱為胖箭頭,以區別於未來 JavaScript 中假想的 -> 語法)與函式表示式相比具有更短的語法,並且沒有自己的 thisargumentssupernew.target。箭頭函式始終是匿名的。

有兩個因素影響了箭頭函式的引入:更短的函式this非繫結

更短的函式

在某些函式式模式中,更短的函式很受歡迎。比較一下

js
const a = ["Hydrogen", "Helium", "Lithium", "Beryllium"];

const a2 = a.map(function (s) {
  return s.length;
});

console.log(a2); // [8, 6, 7, 9]

const a3 = a.map((s) => s.length);

console.log(a3); // [8, 6, 7, 9]

沒有單獨的 this

在箭頭函數出現之前,每個新定義的函式都有自己的 this 值(對於建構函式來說是一個新物件,在嚴格模式函式呼叫中為 undefined,如果函式作為“物件方法”呼叫,則為基本物件,等等)。這在面向物件程式設計風格中被證明並不理想。

js
function Person() {
  // The Person() constructor defines `this` as itself.
  this.age = 0;

  setInterval(function growUp() {
    // In nonstrict mode, the growUp() function defines `this`
    // as the global object, which is different from the `this`
    // defined by the Person() constructor.
    this.age++;
  }, 1000);
}

const p = new Person();

在 ECMAScript 3/5 中,這個問題透過將 this 中的值分配給一個可以被閉包的變數來解決。

js
function Person() {
  // Some choose `that` instead of `self`.
  // Choose one and be consistent.
  const self = this;
  self.age = 0;

  setInterval(function growUp() {
    // The callback refers to the `self` variable of which
    // the value is the expected object.
    self.age++;
  }, 1000);
}

或者,可以建立一個繫結函式,以便將正確的 this 值傳遞給 growUp() 函式。

箭頭函式沒有自己的 this;它使用封閉執行上下文的 this 值。因此,在下面的程式碼中,傳遞給 setInterval 的函式內部的 this 與封閉函式中的 this 具有相同的值

js
function Person() {
  this.age = 0;

  setInterval(() => {
    this.age++; // `this` properly refers to the person object
  }, 1000);
}

const p = new Person();