JavaScript 權威指南第七版(GPT 重譯)(三)

绝不原创的飞龙發表於2024-03-22

第六章:物件

物件是 JavaScript 中最基本的資料型別,您在本章之前的章節中已經多次看到它們。因為物件對於 JavaScript 語言非常重要,所以您需要詳細瞭解它們的工作原理,而本章提供了這些細節。它從物件的正式概述開始,然後深入到關於建立物件和查詢、設定、刪除、測試和列舉物件屬性的實用部分。這些以屬性為重點的部分之後是關於如何擴充套件、序列化和定義物件重要方法的部分。最後,本章以關於 ES6 和更高版本語言中新物件字面量語法的長篇部分結束。

6.1 物件簡介

物件是一個複合值:它聚合了多個值(原始值或其他物件),並允許您透過名稱儲存和檢索這些值。物件是一個無序的屬性集合,每個屬性都有一個名稱和一個值。屬性名稱通常是字串(儘管,正如我們將在§6.10.3 中看到的,屬性名稱也可以是符號),因此我們可以說物件將字串對映到值。這種字串到值的對映有各種名稱——您可能已經熟悉了以“雜湊”、“雜湊表”、“字典”或“關聯陣列”命名的基本資料結構。然而,物件不僅僅是一個簡單的字串到值的對映。除了維護自己的一組屬性外,JavaScript 物件還繼承另一個物件的屬性,稱為其“原型”。物件的方法通常是繼承的屬性,這種“原型繼承”是 JavaScript 的一個關鍵特性。

JavaScript 物件是動態的——屬性通常可以新增和刪除——但它們可以用來模擬靜態型別語言的靜態物件和“結構”。它們也可以被用來(透過忽略字串到值對映的值部分)表示字串集合。

任何在 JavaScript 中不是字串、數字、符號、truefalsenullundefined 的值都是物件。即使字串、數字和布林值不是物件,它們也可以像不可變物件一樣行事。

從§3.8 中回想起,物件是可變的,透過引用而不是值來操作。如果變數 x 引用一個物件,並且執行程式碼 let y = x;,那麼變數 y 持有對同一物件的引用,而不是該物件的副本。透過變數 y 對物件進行的任何修改也會透過變數 x 可見。

物件最常見的操作是建立它們並設定、查詢、刪除、測試和列舉它們的屬性。這些基本操作在本章的開頭部分進行了描述。之後的部分涵蓋了更高階的主題。

屬性具有名稱和值。屬性名稱可以是任何字串,包括空字串(或任何符號),但沒有物件可以具有兩個具有相同名稱的屬性。該值可以是任何 JavaScript 值,或者它可以是一個 getter 或 setter 函式(或兩者)。我們將在§6.10.6 中學習有關 getter 和 setter 函式的內容。

有時重要的是能夠區分直接在物件上定義的屬性和從原型物件繼承的屬性。JavaScript 使用術語自有屬性來指代非繼承的屬性。

除了名稱和值之外,每個屬性還有三個屬性屬性

  • writable 屬性指定屬性的值是否可以被設定。

  • enumerable 屬性指定屬性名稱是否由 for/in 迴圈返回。

  • configurable 屬性指定屬性是否可以被刪除以及其屬性是否可以被更改。

JavaScript 的許多內建物件具有隻讀、不可列舉或不可配置的屬性。但是,預設情況下,您建立的物件的所有屬性都是可寫的、可列舉的和可配置的。§14.1 解釋了指定物件的非預設屬性屬性值的技術。

6.2 建立物件

使用物件字面量、new關鍵字和Object.create()函式可以建立物件。下面的小節描述了每種技術。

6.2.1 物件字面量

建立物件的最簡單方法是在 JavaScript 程式碼中包含一個物件字面量。在其最簡單的形式中,物件字面量是一個逗號分隔的冒號分隔的名稱:值對列表,包含在花括號中。屬性名是 JavaScript 識別符號或字串字面量(允許空字串)。屬性值是任何 JavaScript 表示式;表示式的值(可以是原始值或物件值)成為屬性的值。以下是一些示例:

let empty = {};                          // An object with no properties
let point = { x: 0, y: 0 };              // Two numeric properties
let p2 = { x: point.x, y: point.y+1 };   // More complex values
let book = {
    "main title": "JavaScript",          // These property names include spaces,
    "sub-title": "The Definitive Guide", // and hyphens, so use string literals.
    for: "all audiences",                // for is reserved, but no quotes.
    author: {                            // The value of this property is
        firstname: "David",              // itself an object.
        surname: "Flanagan"
    }
};

在物件字面量中最後一個屬性後面加上逗號是合法的,一些程式設計風格鼓勵使用這些尾隨逗號,這樣如果以後在物件字面量的末尾新增新屬性,就不太可能導致語法錯誤。

物件字面量是一個表示式,每次評估時都會建立和初始化一個新的獨立物件。每個屬性的值在每次評估字面量時都會被評估。這意味著如果物件字面量出現在迴圈體內或重複呼叫的函式中,一個物件字面量可以建立許多新物件,並且這些物件的屬性值可能彼此不同。

這裡顯示的物件字面量使用自 JavaScript 最早版本以來就合法的簡單語法。語言的最新版本引入了許多新的物件字面量特性,這些特性在§6.10 中有介紹。

6.2.2 使用 new 建立物件

new運算子建立並初始化一個新物件。new關鍵字必須跟隨一個函式呼叫。以這種方式使用的函式稱為建構函式,用於初始化新建立的物件。JavaScript 包括其內建型別的建構函式。例如:

let o = new Object();  // Create an empty object: same as {}.
let a = new Array();   // Create an empty array: same as [].
let d = new Date();    // Create a Date object representing the current time
let r = new Map();     // Create a Map object for key/value mapping

除了這些內建建構函式,通常會定義自己的建構函式來初始化新建立的物件。這在第九章中有介紹。

6.2.3 原型

在我們討論第三種物件建立技術之前,我們必須停頓一下來解釋原型。幾乎每個 JavaScript 物件都有一個與之關聯的第二個 JavaScript 物件。這第二個物件稱為原型,第一個物件從原型繼承屬性。

所有透過物件字面量建立的物件都有相同的原型物件,在 JavaScript 程式碼中我們可以將這個原型物件稱為Object.prototype。使用new關鍵字和建構函式呼叫建立的物件使用建構函式的prototype屬性的值作為它們的原型。因此,透過new Object()建立的物件繼承自Object.prototype,就像透過{}建立的物件一樣。類似地,透過new Array()建立的物件使用Array.prototype作為它們的原型,透過new Date()建立的物件使用Date.prototype作為它們的原型。初學 JavaScript 時可能會感到困惑。記住:幾乎所有物件都有一個原型,但只有相對較少的物件有一個prototype屬性。具有prototype屬性的這些物件為所有其他物件定義了原型

Object.prototype是少數沒有原型的物件之一:它不繼承任何屬性。其他原型物件是具有原型的普通物件。大多數內建建構函式(以及大多數使用者定義的建構函式)具有從Object.prototype繼承的原型。例如,Date.prototypeObject.prototype繼承屬性,因此透過new Date()建立的 Date 物件從Date.prototypeObject.prototype繼承屬性。這個連結的原型物件系列被稱為原型鏈

如何工作屬性繼承的解釋在§6.3.2 中。第九章更詳細地解釋了原型和建構函式之間的關係:它展示瞭如何透過編寫建構函式並將其prototype屬性設定為由該建構函式建立的“例項”使用的原型物件來定義新的物件“類”。我們將學習如何在§14.3 中查詢(甚至更改)物件的原型。

6.2.4 Object.create()

Object.create()建立一個新物件,使用其第一個引數作為該物件的原型:

let o1 = Object.create({x: 1, y: 2});     // o1 inherits properties x and y.
o1.x + o1.y                               // => 3

您可以傳遞null來建立一個沒有原型的新物件,但如果這樣做,新建立的物件將不會繼承任何東西,甚至不會繼承像toString()這樣的基本方法(這意味著它也無法與+運算子一起使用):

let o2 = Object.create(null);             // o2 inherits no props or methods.

如果要建立一個普通的空物件(類似於{}new Object()返回的物件),請傳遞Object.prototype

let o3 = Object.create(Object.prototype); // o3 is like {} or new Object().

使用具有任意原型的新物件的能力是強大的,我們將在本章的許多地方使用Object.create()。(Object.create()還接受一個可選的第二個引數,描述新物件的屬性。這個第二個引數是一個高階功能,涵蓋在§14.1 中。)

使用Object.create()的一個用途是當您想要防止透過您無法控制的庫函式意外(但非惡意)修改物件時。您可以傳遞一個從中繼承的物件而不是直接將物件傳遞給函式。如果函式讀取該物件的屬性,它將看到繼承的值。但是,如果它設定屬性,這些寫入將不會影響原始物件。

let o = { x: "don't change this value" };
library.function(Object.create(o));  // Guard against accidental modifications

要理解為什麼這樣做有效,您需要了解在 JavaScript 中如何查詢和設定屬性。這些是下一節的主題。

6.3 查詢和設定屬性

要獲取屬性的值,請使用§4.4 中描述的點號(.)或方括號([])運算子。左側應該是一個值為物件的表示式。如果使用點運算子,則右側必須是一個簡單的識別符號,用於命名屬性。如果使用方括號,則括號內的值必須是一個求值為包含所需屬性名稱的字串的表示式:

let author = book.author;       // Get the "author" property of the book.
let name = author.surname;      // Get the "surname" property of the author.
let title = book["main title"]; // Get the "main title" property of the book.

要建立或設定屬性,請像查詢屬性一樣使用點號或方括號,但將它們放在賦值表示式的左側:

book.edition = 7;                   // Create an "edition" property of book.
book["main title"] = "ECMAScript";  // Change the "main title" property.

在使用方括號表示法時,我們已經說過方括號內的表示式必須求值為字串。更精確的說法是,表示式必須求值為字串或可以轉換為字串或符號的值(§6.10.3)。例如,在第七章中,我們將看到在方括號內使用數字是常見的。

6.3.1 物件作為關聯陣列

如前一節所述,以下兩個 JavaScript 表示式具有相同的值:

object.property
object["property"]

第一種語法,使用點和識別符號,類似於在 C 或 Java 中訪問結構體或物件的靜態欄位的語法。第二種語法,使用方括號和字串,看起來像陣列訪問,但是是透過字串而不是數字索引的陣列。這種型別的陣列被稱為關聯陣列(或雜湊或對映或字典)。JavaScript 物件就是關聯陣列,本節解釋了為什麼這很重要。

在 C、C++、Java 等強型別語言中,一個物件只能擁有固定數量的屬性,並且這些屬性的名稱必須事先定義。由於 JavaScript 是一種弱型別語言,這個規則不適用:程式可以在任何物件中建立任意數量的屬性。然而,當你使用.運算子訪問物件的屬性時,屬性的名稱必須表示為識別符號。識別符號必須直接輸入到你的 JavaScript 程式中;它們不是一種資料型別,因此不能被程式操作。

另一方面,當你使用[]陣列表示法訪問物件的屬性時,屬性的名稱表示為字串。字串是 JavaScript 資料型別,因此它們可以在程式執行時被操作和建立。因此,例如,你可以在 JavaScript 中編寫以下程式碼:

let addr = "";
for(let i = 0; i < 4; i++) {
    addr += customer[`address${i}`] + "\n";
}

這段程式碼讀取並連線customer物件的address0address1address2address3屬性。

這個簡短的示例展示了使用陣列表示法訪問物件屬性時的靈活性。這段程式碼可以使用點表示法重寫,但有些情況下只有陣列表示法才能勝任。例如,假設你正在編寫一個程式,該程式使用網路資源計算使用者股票市場投資的當前價值。該程式允許使用者輸入他們擁有的每支股票的名稱以及每支股票的股數。你可以使用一個名為portfolio的物件來儲存這些資訊。物件的每個屬性都代表一支股票。屬性的名稱是股票的名稱,屬性值是該股票的股數。因此,例如,如果使用者持有 IBM 的 50 股,portfolio.ibm屬性的值為50

這個程式的一部分可能是一個用於向投資組合新增新股票的函式:

function addstock(portfolio, stockname, shares) {
    portfolio[stockname] = shares;
}

由於使用者在執行時輸入股票名稱,所以你無法提前知道屬性名稱。因為在編寫程式時你無法知道屬性名稱,所以無法使用.運算子訪問portfolio物件的屬性。然而,你可以使用[]運算子,因為它使用字串值(動態的,可以在執行時更改)而不是識別符號(靜態的,必須在程式中硬編碼)來命名屬性。

在第五章中,我們介紹了for/in迴圈(我們很快會再次看到它,在§6.6 中)。當你考慮它與關聯陣列一起使用時,這個 JavaScript 語句的強大之處就顯而易見了。下面是計算投資組合總價值時如何使用它的示例:

function computeValue(portfolio) {
    let total = 0.0;
    for(let stock in portfolio) {       // For each stock in the portfolio:
        let shares = portfolio[stock];  // get the number of shares
        let price = getQuote(stock);    // look up share price
        total += shares * price;        // add stock value to total value
    }
    return total;                       // Return total value.
}

JavaScript 物件通常被用作關聯陣列,如下所示,瞭解這是如何工作的很重要。然而,在 ES6 及以後的版本中,描述在§11.1.2 中的 Map 類通常比使用普通物件更好。

6.3.2 繼承

JavaScript 物件有一組“自有屬性”,它們還從它們的原型物件繼承了一組屬性。要理解這一點,我們必須更詳細地考慮屬性訪問。本節中的示例使用Object.create()函式建立具有指定原型的物件。然而,我們將在第九章中看到,每次使用new建立類的例項時,都會建立一個從原型物件繼承屬性的物件。

假設您查詢物件o中的屬性x。如果o沒有具有該名稱的自有屬性,則將查詢o的原型物件¹的屬性x。如果原型物件沒有具有該名稱的自有屬性,但具有自己的原型,則將在原型的原型上執行查詢。這將繼續,直到找到屬性x或直到搜尋具有null原型的物件。正如您所看到的,物件的prototype屬性建立了一個鏈或連結列表,從中繼承屬性:

let o = {};               // o inherits object methods from Object.prototype
o.x = 1;                  // and it now has an own property x.
let p = Object.create(o); // p inherits properties from o and Object.prototype
p.y = 2;                  // and has an own property y.
let q = Object.create(p); // q inherits properties from p, o, and...
q.z = 3;                  // ...Object.prototype and has an own property z.
let f = q.toString();     // toString is inherited from Object.prototype
q.x + q.y                 // => 3; x and y are inherited from o and p

現在假設您對物件o的屬性x進行賦值。如果o已經具有自己的(非繼承的)名為x的屬性,則賦值將簡單地更改此現有屬性的值。否則,賦值將在物件o上建立一個名為x的新屬性。如果o先前繼承了屬性x,那麼新建立的同名自有屬性將隱藏該繼承的屬性。

屬性賦值僅檢查原型鏈以確定是否允許賦值。例如,如果o繼承了一個名為x的只讀屬性,則不允許賦值。(有關何時可以設定屬性的詳細資訊,請參見§6.3.3。)然而,如果允許賦值,它總是在原始物件中建立或設定屬性,而不會修改原型鏈中的物件。查詢屬性時發生繼承,但在設定屬性時不會發生繼承是 JavaScript 的一個關鍵特性,因為它允許我們有選擇地覆蓋繼承的屬性:

let unitcircle = { r: 1 };         // An object to inherit from
let c = Object.create(unitcircle); // c inherits the property r
c.x = 1; c.y = 1;                  // c defines two properties of its own
c.r = 2;                           // c overrides its inherited property
unitcircle.r                       // => 1: the prototype is not affected

有一個例外情況,即屬性賦值要麼失敗,要麼在原始物件中建立或設定屬性。如果o繼承了屬性x,並且該屬性是一個具有 setter 方法的訪問器屬性(參見§6.10.6),那麼將呼叫該 setter 方法,而不是在o中建立新屬性x。然而,請注意,setter 方法是在物件o上呼叫的,而不是在定義屬性的原型物件上呼叫的,因此如果 setter 方法定義了任何屬性,它將在o上進行,而且它將再次不修改原型鏈。

6.3.3 屬性訪問錯誤

屬性訪問表示式並不總是返回或設定一個值。本節解釋了在查詢或設定屬性時可能出現的問題。

查詢不存在的屬性並不是錯誤的。如果在o的自有屬性或繼承屬性中找不到屬性x,則屬性訪問表示式o.x將求值為undefined。請記住,我們的書物件具有“子標題”屬性,但沒有“subtitle”屬性:

book.subtitle    // => undefined: property doesn't exist

然而,嘗試查詢不存在的物件的屬性是錯誤的。nullundefined值沒有屬性,查詢這些值的屬性是錯誤的。繼續前面的例子:

let len = book.subtitle.length; // !TypeError: undefined doesn't have length

如果.的左側是nullundefined,則屬性訪問表示式將失敗。因此,在編寫諸如book.author.surname的表示式時,如果不確定bookbook.author是否已定義,應謹慎。以下是防止此類問題的兩種方法:

// A verbose and explicit technique
let surname = undefined;
if (book) {
    if (book.author) {
        surname = book.author.surname;
    }
}

// A concise and idiomatic alternative to get surname or null or undefined
surname = book && book.author && book.author.surname;

要理解為什麼這種成語表示式可以防止 TypeError 異常,您可能需要回顧一下&&運算子的短路行為,詳情請參見§4.10.1。

如§4.4.1 中所述,ES2020 支援使用?.進行條件屬性訪問,這使我們可以將先前的賦值表示式重寫為:

let surname = book?.author?.surname;

嘗試在 nullundefined 上設定屬性也會導致 TypeError。在其他值上嘗試設定屬性也不總是成功:某些屬性是隻讀的,無法設定,某些物件不允許新增新屬性。在嚴格模式下(§5.6.3),每當嘗試設定屬性失敗時都會丟擲 TypeError。在非嚴格模式下,這些失敗通常是靜默的。

指定屬性賦值何時成功何時失敗的規則是直觀的,但難以簡潔表達。在以下情況下,嘗試設定物件 o 的屬性 p 失敗:

  • o 有一個自己的只讀屬性 p:無法設定只讀屬性。

  • o 具有一個繼承的只讀屬性 p:無法透過具有相同名稱的自有屬性隱藏繼承的只讀屬性。

  • o 沒有自己的屬性 po 沒有繼承具有 setter 方法的屬性 p,且 o可擴充套件 屬性(見 §14.2)為 false。由於 op 不存在,並且沒有 setter 方法可呼叫,因此必須將 p 新增到 o 中。但如果 o 不可擴充套件,則無法在其上定義新屬性。

6.4 刪除屬性

delete 運算子(§4.13.4)從物件中刪除屬性。其單個運算元應為屬性訪問表示式。令人驚訝的是,delete 不是作用於屬性的值,而是作用於屬性本身:

delete book.author;          // The book object now has no author property.
delete book["main title"];   // Now it doesn't have "main title", either.

delete 運算子僅刪除自有屬性,而不刪除繼承的屬性。(要刪除繼承的屬性,必須從定義該屬性的原型物件中刪除它。這會影響從該原型繼承的每個物件。)

delete 表示式在刪除成功刪除或刪除無效(例如刪除不存在的屬性)時求值為 true。當與非屬性訪問表示式一起使用時,delete 也會求值為 true(毫無意義地):

let o = {x: 1};    // o has own property x and inherits property toString
delete o.x         // => true: deletes property x
delete o.x         // => true: does nothing (x doesn't exist) but true anyway
delete o.toString  // => true: does nothing (toString isn't an own property)
delete 1           // => true: nonsense, but true anyway

delete 不會刪除具有 可配置 屬性為 false 的屬性。某些內建物件的屬性是不可配置的,變數宣告和函式宣告建立的全域性物件的屬性也是如此。在嚴格模式下,嘗試刪除不可配置屬性會導致 TypeError。在非嚴格模式下,此情況下 delete 簡單地求值為 false

// In strict mode, all these deletions throw TypeError instead of returning false
delete Object.prototype // => false: property is non-configurable
var x = 1;              // Declare a global variable
delete globalThis.x     // => false: can't delete this property
function f() {}         // Declare a global function
delete globalThis.f     // => false: can't delete this property either

在非嚴格模式下刪除全域性物件的可配置屬性時,可以省略對全域性物件的引用,只需跟隨 delete 運算子後面的屬性名:

globalThis.x = 1;       // Create a configurable global property (no let or var)
delete x                // => true: this property can be deleted

然而,在嚴格模式下,如果其運算元是像 x 這樣的未限定識別符號,delete 會引發 SyntaxError,並且您必須明確指定屬性訪問:

delete x;               // SyntaxError in strict mode
delete globalThis.x;    // This works

6.5 測試屬性

JavaScript 物件可以被視為屬性集合,通常有必要能夠測試是否屬於該集合——檢查物件是否具有給定名稱的屬性。您可以使用 in 運算子、hasOwnProperty()propertyIsEnumerable() 方法,或者簡單地查詢屬性來實現此目的。這裡顯示的示例都使用字串作為屬性名稱,但它們也適用於符號(§6.10.3)。

in 運算子在其左側期望一個屬性名,在其右側期望一個物件。如果物件具有該名稱的自有屬性或繼承屬性,則返回 true

let o = { x: 1 };
"x" in o         // => true: o has an own property "x"
"y" in o         // => false: o doesn't have a property "y"
"toString" in o  // => true: o inherits a toString property

物件的 hasOwnProperty() 方法測試該物件是否具有給定名稱的自有屬性。對於繼承屬性,它返回 false

let o = { x: 1 };
o.hasOwnProperty("x")        // => true: o has an own property x
o.hasOwnProperty("y")        // => false: o doesn't have a property y
o.hasOwnProperty("toString") // => false: toString is an inherited property

propertyIsEnumerable() 最佳化了 hasOwnProperty() 測試。只有在命名屬性是自有屬性且其可列舉屬性為 true 時才返回 true。某些內建屬性是不可列舉的。透過正常的 JavaScript 程式碼建立的屬性是可列舉的,除非你使用了 §14.1 中展示的技術之一使它們變為不可列舉。

let o = { x: 1 };
o.propertyIsEnumerable("x")  // => true: o has an own enumerable property x
o.propertyIsEnumerable("toString")  // => false: not an own property
Object.prototype.propertyIsEnumerable("toString") // => false: not enumerable

不必使用 in 運算子,通常只需查詢屬性並使用 !== 來確保它不是未定義的:

let o = { x: 1 };
o.x !== undefined        // => true: o has a property x
o.y !== undefined        // => false: o doesn't have a property y
o.toString !== undefined // => true: o inherits a toString property

in 運算子可以做到這裡展示的簡單屬性訪問技術無法做到的一件事。in 可以區分不存在的屬性和已設定為 undefined 的屬性。考慮以下程式碼:

let o = { x: undefined };  // Property is explicitly set to undefined
o.x !== undefined          // => false: property exists but is undefined
o.y !== undefined          // => false: property doesn't even exist
"x" in o                   // => true: the property exists
"y" in o                   // => false: the property doesn't exist
delete o.x;                // Delete the property x
"x" in o                   // => false: it doesn't exist anymore

6.6 列舉屬性

有時我們不想測試單個屬性的存在,而是想遍歷或獲取物件的所有屬性列表。有幾種不同的方法可以做到這一點。

for/in 迴圈在 §5.4.5 中有介紹。它會為指定物件的每個可列舉屬性(自有或繼承的)執行一次迴圈體,將屬性的名稱賦給迴圈變數。物件繼承的內建方法是不可列舉的,但你的程式碼新增到物件的屬性預設是可列舉的。例如:

let o = {x: 1, y: 2, z: 3};          // Three enumerable own properties
o.propertyIsEnumerable("toString")   // => false: not enumerable
for(let p in o) {                    // Loop through the properties
    console.log(p);                  // Prints x, y, and z, but not toString
}

為了防止使用 for/in 列舉繼承屬性,你可以在迴圈體內新增一個顯式檢查:

for(let p in o) {
    if (!o.hasOwnProperty(p)) continue;       // Skip inherited properties
}

for(let p in o) {
    if (typeof o[p] === "function") continue; // Skip all methods
}

作為使用 for/in 迴圈的替代方案,通常更容易獲得物件的屬性名稱陣列,然後使用 for/of 迴圈遍歷該陣列。有四個函式可以用來獲取屬性名稱陣列:

  • Object.keys() 返回一個物件的可列舉自有屬性名稱的陣列。它不包括不可列舉屬性、繼承屬性或名稱為 Symbol 的屬性(參見 §6.10.3)。

  • Object.getOwnPropertyNames() 的工作方式類似於 Object.keys(),但會返回一個非列舉自有屬性名稱的陣列,只要它們的名稱是字串。

  • Object.getOwnPropertySymbols() 返回那些名稱為 Symbol 的自有屬性,無論它們是否可列舉。

  • Reflect.ownKeys() 返回所有自有屬性名稱,包括可列舉和不可列舉的,以及字串和 Symbol。 (參見 §14.6.)

在 §6.7 中有關於使用 Object.keys()for/of 迴圈的示例。

6.6.1 屬性列舉順序

ES6 正式定義了物件自有屬性列舉的順序。Object.keys()Object.getOwnPropertyNames()Object.getOwnPropertySymbols()Reflect.ownKeys() 和相關方法如 JSON.stringify() 都按照以下順序列出屬性,受其自身關於是否列出非列舉屬性或屬性名稱為字串或 Symbol 的額外約束:

  • 名稱為非負整數的字串屬性首先按數字順序從小到大列出。這個規則意味著陣列和類陣列物件的屬性將按順序列舉。

  • 列出所有看起來像陣列索引的屬性後,所有剩餘的具有字串名稱的屬性也會被列出(包括看起來像負數或浮點數的屬性)。這些屬性按照它們新增到物件的順序列出。對於物件字面量中定義的屬性,這個順序與它們在字面量中出現的順序相同。

  • 最後,那些名稱為 Symbol 物件的屬性按照它們新增到物件的順序列出。

for/in 迴圈的列舉順序並沒有像這些列舉函式那樣嚴格規定,但通常的實現會按照剛才描述的順序列舉自有屬性,然後沿著原型鏈向上遍歷,對每個原型物件按照相同的順序列舉屬性。然而,請注意,如果同名屬性已經被列舉過,或者即使同名的不可列舉屬性已經被考慮過,該屬性將不會被列舉。

6.7 擴充套件物件

JavaScript 程式中的一個常見操作是需要將一個物件的屬性複製到另一個物件中。可以使用以下程式碼輕鬆實現這一操作:

let target = {x: 1}, source = {y: 2, z: 3};
for(let key of Object.keys(source)) {
    target[key] = source[key];
}
target  // => {x: 1, y: 2, z: 3}

但由於這是一個常見的操作,各種 JavaScript 框架已經定義了實用函式,通常命名為 extend(),來執行這種複製操作。最後,在 ES6 中,這種能力以 Object.assign() 的形式進入了核心 JavaScript 語言。

Object.assign() 期望兩個或更多物件作為其引數。它修改並返回第一個引數,即目標物件,但不會改變第二個或任何後續引數,即源物件。對於每個源物件,它將該物件的可列舉自有屬性(包括那些名稱為 Symbols 的屬性)複製到目標物件中。它按照引數列表順序處理源物件,因此第一個源物件中的屬性將覆蓋目標物件中同名的屬性,第二個源物件中的屬性(如果有的話)將覆蓋第一個源物件中同名的屬性。

Object.assign() 使用普通的屬性獲取和設定操作來複制屬性,因此如果源物件具有 getter 方法或目標物件具有 setter 方法,則它們將在複製過程中被呼叫,但它們本身不會被複制。

將一個物件的屬性分配到另一個物件中的一個原因是,當你有一個物件定義了許多屬性的預設值,並且希望將這些預設屬性複製到另一個物件中,如果該物件中不存在同名屬性。簡單地使用 Object.assign() 不會達到你想要的效果:

Object.assign(o, defaults);  // overwrites everything in o with defaults

相反,您可以建立一個新物件,將預設值複製到其中,然後用 o 中的屬性覆蓋這些預設值:

o = Object.assign({}, defaults, o);

我們將在 §6.10.4 中看到,您還可以使用 ... 展開運算子來表達這種物件複製和覆蓋操作,就像這樣:

o = {...defaults, ...o};

我們也可以透過編寫一個只在屬性缺失時才複製屬性的版本的 Object.assign() 來避免額外的物件建立和複製開銷:

// Like Object.assign() but doesn't override existing properties
// (and also doesn't handle Symbol properties)
function merge(target, ...sources) {
    for(let source of sources) {
        for(let key of Object.keys(source)) {
            if (!(key in target)) { // This is different than Object.assign()
                target[key] = source[key];
            }
        }
    }
    return target;
}
Object.assign({x: 1}, {x: 2, y: 2}, {y: 3, z: 4})  // => {x: 2, y: 3, z: 4}
merge({x: 1}, {x: 2, y: 2}, {y: 3, z: 4})          // => {x: 1, y: 2, z: 4}

編寫其他類似這個 merge() 函式的屬性操作實用程式是很簡單的。例如,restrict() 函式可以刪除物件的屬性,如果這些屬性在另一個模板物件中不存在。或者 subtract() 函式可以從另一個物件中刪除所有屬性。

6.8 序列化物件

物件序列化是將物件狀態轉換為一個字串的過程,以便以後可以恢復該物件。函式 JSON.stringify()JSON.parse() 可以序列化和恢復 JavaScript 物件。這些函式使用 JSON 資料交換格式。JSON 代表“JavaScript 物件表示法”,其語法與 JavaScript 物件和陣列文字非常相似:

let o = {x: 1, y: {z: [false, null, ""]}}; // Define a test object
let s = JSON.stringify(o);   // s == '{"x":1,"y":{"z":[false,null,""]}}'
let p = JSON.parse(s);       // p == {x: 1, y: {z: [false, null, ""]}}

JSON 語法是 JavaScript 語法的子集,它不能表示所有 JavaScript 值。支援並可以序列化和還原的有物件、陣列、字串、有限數字、truefalsenullNaNInfinity-Infinity被序列化為null。Date 物件被序列化為 ISO 格式的日期字串(參見Date.toJSON()函式),但JSON.parse()將它們保留為字串形式,不會還原原始的 Date 物件。Function、RegExp 和 Error 物件以及undefined值不能被序列化或還原。JSON.stringify()只序列化物件的可列舉自有屬性。如果屬性值無法序列化,則該屬性將簡單地從字串化輸出中省略。JSON.stringify()JSON.parse()都接受可選的第二個引數,用於透過指定要序列化的屬性列表來自定義序列化和/或還原過程,例如,在序列化或字串化過程中轉換某些值。這些函式的完整文件在§11.6 中。

6.9 物件方法

正如前面討論的,所有 JavaScript 物件(除了明確建立時沒有原型的物件)都從Object.prototype繼承屬性。這些繼承的屬性主要是方法,因為它們是普遍可用的,所以它們對 JavaScript 程式設計師特別感興趣。例如,我們已經看到了hasOwnProperty()propertyIsEnumerable()方法。(我們也已經涵蓋了Object建構函式上定義的許多靜態函式,比如Object.create()Object.keys()。)本節解釋了一些定義在Object.prototype上的通用物件方法,但是這些方法旨在被其他更專門的實現所取代。在接下來的章節中,我們將展示在單個物件上定義這些方法的示例。在第九章中,您將學習如何為整個物件類更普遍地定義這些方法。

6.9.1 toString() 方法

toString() 方法不接受任何引數;它返回一個表示呼叫它的物件的值的字串。JavaScript 在需要將物件轉換為字串時會呼叫這個方法。例如,當你使用+運算子將字串與物件連線在一起,或者當你將物件傳遞給期望字串的方法時,就會發生這種情況。

預設的toString()方法並不是很有資訊量(儘管它對於確定物件的類很有用,正如我們將在§14.4.3 中看到的)。例如,以下程式碼行簡單地評估為字串“[object Object]”:

let s = { x: 1, y: 1 }.toString();  // s == "[object Object]"

因為這個預設方法並不顯示太多有用資訊,許多類定義了它們自己的toString()版本。例如,當陣列轉換為字串時,你會得到一個陣列元素列表,它們各自被轉換為字串,當函式轉換為字串時,你會得到函式的原始碼。你可以像這樣定義自己的toString()方法:

let point = {
    x: 1,
    y: 2,
    toString: function() { return `(${this.x}, ${this.y})`; }
};
String(point)    // => "(1, 2)": toString() is used for string conversions

6.9.2 toLocaleString() 方法

除了基本的toString()方法外,所有物件都有一個toLocaleString()方法。這個方法的目的是返回物件的本地化字串表示。Object 定義的預設toLocaleString()方法不進行任何本地化:它只是呼叫toString()並返回該值。Date 和 Number 類定義了定製版本的toLocaleString(),試圖根據本地慣例格式化數字、日期和時間。Array 定義了一個toLocaleString()方法,工作方式類似於toString(),只是透過呼叫它們的toLocaleString()方法而不是toString()方法來格式化陣列元素。你可以像這樣處理point物件:

let point = {
    x: 1000,
    y: 2000,
    toString: function() { return `(${this.x}, ${this.y})`; },
    toLocaleString: function() {
        return `(${this.x.toLocaleString()}, ${this.y.toLocaleString()})`;
    }
};
point.toString()        // => "(1000, 2000)"
point.toLocaleString()  // => "(1,000, 2,000)": note thousands separators

在實現 toLocaleString() 方法時,§11.7 中記錄的國際化類可能會很有用。

6.9.3 valueOf() 方法

valueOf() 方法類似於 toString() 方法,但當 JavaScript 需要將物件轉換為除字串以外的某種原始型別時(通常是數字),就會呼叫它。如果物件在需要原始值的上下文中使用,JavaScript 會自動呼叫這個方法。預設的 valueOf() 方法沒有什麼有趣的功能,但一些內建類定義了自己的 valueOf() 方法。Date 類定義了 valueOf() 方法來將日期轉換為數字,這允許使用 <> 來對日期物件進行比較。你可以透過定義一個 valueOf() 方法來實現類似的功能,返回從原點到點的距離:

let point = {
    x: 3,
    y: 4,
    valueOf: function() { return Math.hypot(this.x, this.y); }
};
Number(point)  // => 5: valueOf() is used for conversions to numbers
point > 4      // => true
point > 5      // => false
point < 6      // => true

6.9.4 toJSON() 方法

Object.prototype 實際上並沒有定義 toJSON() 方法,但 JSON.stringify() 方法(參見 §6.8)會在要序列化的任何物件上查詢 toJSON() 方法。如果這個方法存在於要序列化的物件上,它就會被呼叫,返回值會被序列化,而不是原始物件。Date 類(§11.4)定義了一個 toJSON() 方法,返回日期的可序列化字串表示。我們可以為我們的 Point 物件做同樣的事情:

let point = {
    x: 1,
    y: 2,
    toString: function() { return `(${this.x}, ${this.y})`; },
    toJSON: function() { return this.toString(); }
};
JSON.stringify([point])   // => '["(1, 2)"]'

6.10 擴充套件物件字面量語法

JavaScript 的最新版本在物件字面量的語法上以多種有用的方式進行了擴充套件。以下小節解釋了這些擴充套件。

6.10.1 簡寫屬性

假設你有儲存在變數 xy 中的值,並且想要建立一個具有名為 xy 的屬性的物件,其中包含這些值。使用基本物件字面量語法,你將重複每個識別符號兩次:

let x = 1, y = 2;
let o = {
    x: x,
    y: y
};

在 ES6 及更高版本中,你可以省略冒號和一個識別符號的副本,從而得到更簡潔的程式碼:

let x = 1, y = 2;
let o = { x, y };
o.x + o.y  // => 3

6.10.2 計算屬性名

有時候你需要建立一個具有特定屬性的物件,但該屬性的名稱不是你可以在原始碼中直接輸入的編譯時常量。相反,你需要的屬性名稱儲存在一個變數中,或者是一個你呼叫的函式的返回值。你不能使用基本物件字面量來定義這種屬性。相反,你必須先建立一個物件,然後作為額外步驟新增所需的屬性:

const PROPERTY_NAME = "p1";
function computePropertyName() { return "p" + 2; }

let o = {};
o[PROPERTY_NAME] = 1;
o[computePropertyName()] = 2;

使用 ES6 功能中稱為計算屬性的功能,可以更簡單地設定一個物件,直接將前面程式碼中的方括號移到物件字面量中:

const PROPERTY_NAME = "p1";
function computePropertyName() { return "p" + 2; }

let p = {
    [PROPERTY_NAME]: 1,
    [computePropertyName()]: 2
};

p.p1 + p.p2 // => 3

使用這種新的語法,方括號限定了任意的 JavaScript 表示式。該表示式被評估,結果值(如有必要,轉換為字串)被用作屬性名。

一個情況下你可能想使用計算屬性的地方是當你有一個 JavaScript 程式碼庫,該庫期望傳遞具有特定屬性集的物件,並且這些屬性的名稱在該庫中被定義為常量。如果你正在編寫程式碼來建立將傳遞給該庫的物件,你可以硬編碼屬性名稱,但如果在任何地方輸入屬性名稱錯誤,就會出現錯誤,如果庫的新版本更改了所需的屬性名稱,就會出現版本不匹配的問題。相反,你可能會發現使用由庫定義的屬性名常量與計算屬性語法使你的程式碼更加健壯。

6.10.3 符號作為屬性名

計算屬性語法還啟用了另一個非常重要的物件字面量特性。在 ES6 及更高版本中,屬性名稱可以是字串或符號。如果將符號分配給變數或常量,那麼可以使用計算屬性語法將該符號作為屬性名:

const extension = Symbol("my extension symbol");
let o = {
    [extension]: { /* extension data stored in this object */ }
};
o[extension].x = 0; // This won't conflict with other properties of o

如§3.6 中所解釋的,符號是不透明的值。你不能對它們做任何操作,只能將它們用作屬性名稱。然而,每個符號都與其他任何符號都不同,這意味著符號非常適合建立唯一的屬性名稱。透過呼叫Symbol()工廠函式建立一個新符號。(符號是原始值,不是物件,因此Symbol()不是一個你使用new呼叫的建構函式。)Symbol()返回的值不等於任何其他符號或其他值。你可以向Symbol()傳遞一個字串,當你的符號轉換為字串時,將使用該字串。但這僅用於除錯:使用相同字串引數建立的兩個符號仍然彼此不同。

符號的作用不是安全性,而是為 JavaScript 物件定義一個安全的擴充套件機制。如果你從你無法控制的第三方程式碼中獲取一個物件,並且需要向該物件新增一些你自己的屬性,但又希望確保你的屬性不會與物件上可能已經存在的任何屬性發生衝突,那麼你可以安全地使用符號作為你的屬性名稱。如果你這樣做,你還可以確信第三方程式碼不會意外地更改你的以符號命名的屬性。(當然,該第三方程式碼可以使用Object.getOwnPropertySymbols()來發現你正在使用的符號,並可能更改或刪除你的屬性。這就是為什麼符號不是一種安全機制。)

6.10.4 展開運算子

在 ES2018 及更高版本中,你可以使用“展開運算子”...將現有物件的屬性複製到一個新物件中,寫在物件字面量內部:

let position = { x: 0, y: 0 };
let dimensions = { width: 100, height: 75 };
let rect = { ...position, ...dimensions };
rect.x + rect.y + rect.width + rect.height // => 175

在這段程式碼中,positiondimensions物件的屬性被“展開”到rect物件字面量中,就好像它們被直接寫在那些花括號內一樣。請注意,這種...語法通常被稱為展開運算子,但在任何情況下都不是真正的 JavaScript 運算子。相反,它是僅在物件字面量內部可用的特殊語法。 (在其他 JavaScript 上下文中,三個點用於其他目的,但物件字面量是唯一的上下文,其中這三個點會導致一個物件插入到另一個物件中。)

如果被展開的物件和被展開到的物件都有同名屬性,則該屬性的值將是最後一個出現的值:

let o = { x: 1 };
let p = { x: 0, ...o };
p.x   // => 1: the value from object o overrides the initial value
let q = { ...o, x: 2 };
q.x   // => 2: the value 2 overrides the previous value from o.

還要注意,展開運算子只展開物件的自有屬性,而不包括任何繼承的屬性:

let o = Object.create({x: 1}); // o inherits the property x
let p = { ...o };
p.x                            // => undefined

最後,值得注意的是,儘管展開運算子在你的程式碼中只是三個小點,但它可能代表 JavaScript 直譯器大量的工作。如果一個物件有n個屬性,將這些屬性展開到另一個物件中的過程可能是一個O(n)的操作。這意味著如果你發現自己在迴圈或遞迴函式中使用...來將資料累積到一個大物件中,你可能正在編寫一個效率低下的O(n²)演算法,隨著n的增大,它的效能將不會很好。

6.10.5 簡寫方法

當一個函式被定義為物件的屬性時,我們稱該函式為方法(我們將在第八章和第九章中詳細討論方法)。在 ES6 之前,你可以使用函式定義表示式在物件字面量中定義一個方法,就像你定義物件的任何其他屬性一樣:

let square = {
    area: function() { return this.side * this.side; },
    side: 10
};
square.area() // => 100

然而,在 ES6 中,物件字面量語法(以及我們將在第九章中看到的類定義語法)已經擴充套件,允許一種快捷方式,其中省略了function關鍵字和冒號,導致程式碼如下:

let square = {
    area() { return this.side * this.side; },
    side: 10
};
square.area() // => 100

兩種形式的程式碼是等價的:都向物件字面量新增了一個名為area的屬性,並將該屬性的值設定為指定的函式。簡寫語法使得area()是一個方法,而不是像side那樣的資料屬性。

當使用這種簡寫語法編寫方法時,屬性名稱可以採用物件字面量中合法的任何形式:除了像上面的area名稱一樣的常規 JavaScript 識別符號外,還可以使用字串文字和計算屬性名稱,其中可以包括 Symbol 屬性名稱:

const METHOD_NAME = "m";
const symbol = Symbol();
let weirdMethods = {
    "method With Spaces"(x) { return x + 1; },
    METHOD_NAME { return x + 2; },
    symbol { return x + 3; }
};
weirdMethods"method With Spaces"  // => 2
weirdMethodsMETHOD_NAME           // => 3
weirdMethodssymbol                // => 4

使用符號作為方法名並不像看起來那麼奇怪。為了使物件可迭代(以便與for/of迴圈一起使用),必須定義一個具有符號名稱Symbol.iterator的方法,第十二章中有這樣做的示例。

6.10.6 屬性的 getter 和 setter

到目前為止,在本章中討論的所有物件屬性都是具有名稱和普通值的資料屬性。JavaScript 還支援訪問器屬性,它們沒有單個值,而是具有一個或兩個訪問器方法:一個getter和/或一個setter

當程式查詢訪問器屬性的值時,JavaScript 會呼叫 getter 方法(不傳遞任何引數)。此方法的返回值成為屬性訪問表示式的值。當程式設定訪問器屬性的值時,JavaScript 會呼叫 setter 方法,傳遞賦值右側的值。該方法負責在某種意義上“設定”屬性值。setter 方法的返回值將被忽略。

如果一個屬性同時具有 getter 和 setter 方法,則它是一個讀/寫屬性。如果它只有 getter 方法,則它是一個只讀屬性。如果它只有 setter 方法,則它是一個只寫屬性(這是使用資料屬性不可能實現的),並且嘗試讀取它的值總是評估為undefined

訪問器屬性可以使用物件字面量語法的擴充套件來定義(與我們在這裡看到的其他 ES6 擴充套件不同,getter 和 setter 是在 ES5 中引入的):

let o = {
    // An ordinary data property
    dataProp: value,

    // An accessor property defined as a pair of functions.
    get accessorProp() { return this.dataProp; },
    set accessorProp(value) { this.dataProp = value; }
};

訪問器屬性被定義為一個或兩個方法,其名稱與屬性名稱相同。它們看起來像使用 ES6 簡寫定義的普通方法,只是 getter 和 setter 定義字首為getset。(在 ES6 中,當定義 getter 和 setter 時,也可以使用計算屬性名稱。只需在getset後用方括號中的表示式替換屬性名稱。)

上面定義的訪問器方法只是獲取和設定資料屬性的值,並沒有理由優先使用訪問器屬性而不是資料屬性。但作為一個更有趣的例子,考慮以下表示 2D 笛卡爾點的物件。它具有普通資料屬性來表示點的xy座標,並且具有訪問器屬性來給出點的等效極座標:

let p = {
    // x and y are regular read-write data properties.
    x: 1.0,
    y: 1.0,

    // r is a read-write accessor property with getter and setter.
    // Don't forget to put a comma after accessor methods.
    get r() { return Math.hypot(this.x, this.y); },
    set r(newvalue) {
        let oldvalue = Math.hypot(this.x, this.y);
        let ratio = newvalue/oldvalue;
        this.x *= ratio;
        this.y *= ratio;
    },

    // theta is a read-only accessor property with getter only.
    get theta() { return Math.atan2(this.y, this.x); }
};
p.r     // => Math.SQRT2
p.theta // => Math.PI / 4

注意在這個例子中,關鍵字this在 getter 和 setter 中的使用。JavaScript 將這些函式作為定義它們的物件的方法呼叫,這意味著在函式體內,this指的是點物件p。因此,r屬性的 getter 方法可以將xy屬性稱為this.xthis.y。更詳細地討論方法和this關鍵字在§8.2.2 中有介紹。

訪問器屬性是繼承的,就像資料屬性一樣,因此可以將上面定義的物件p用作其他點的原型。您可以為新物件提供它們自己的xy屬性,並且它們將繼承rtheta屬性:

let q = Object.create(p); // A new object that inherits getters and setters
q.x = 3; q.y = 4;         // Create q's own data properties
q.r                       // => 5: the inherited accessor properties work
q.theta                   // => Math.atan2(4, 3)

上面的程式碼使用訪問器屬性來定義一個 API,提供單組資料的兩種表示(笛卡爾座標和極座標)。使用訪問器屬性的其他原因包括對屬性寫入進行檢查和在每次屬性讀取時返回不同的值:

// This object generates strictly increasing serial numbers
const serialnum = {
    // This data property holds the next serial number.
    // The _ in the property name hints that it is for internal use only.
    _n: 0,

    // Return the current value and increment it
    get next() { return this._n++; },

    // Set a new value of n, but only if it is larger than current
    set next(n) {
        if (n > this._n) this._n = n;
        else throw new Error("serial number can only be set to a larger value");
    }
};
serialnum.next = 10;    // Set the starting serial number
serialnum.next          // => 10
serialnum.next          // => 11: different value each time we get next

最後,這裡是另一個示例,使用 getter 方法實現具有“神奇”行為的屬性:

// This object has accessor properties that return random numbers.
// The expression "random.octet", for example, yields a random number
// between 0 and 255 each time it is evaluated.
const random = {
    get octet() { return Math.floor(Math.random()*256); },
    get uint16() { return Math.floor(Math.random()*65536); },
    get int16() { return Math.floor(Math.random()*65536)-32768; }
};

6.11 總結

本章詳細記錄了 JavaScript 物件,涵蓋的主題包括:

  • 基本物件術語,包括諸如可列舉自有屬性等術語的含義。

  • 物件字面量語法,包括 ES6 及以後版本中的許多新特性。

  • 如何讀取、寫入、刪除、列舉和檢查物件的屬性是否存在。

  • JavaScript 中基於原型的繼承是如何工作的,以及如何使用Object.create()建立一個繼承自另一個物件的物件。

  • 如何使用Object.assign()將一個物件的屬性複製到另一個物件中。

所有非原始值的 JavaScript 值都是物件。這包括陣列和函式,它們是接下來兩章的主題。

¹ 記住;幾乎所有物件都有一個原型,但大多數物件沒有名為prototype的屬性。即使無法直接訪問原型物件,JavaScript 繼承仍然有效。但如果想學習如何做到這一點,請參見§14.3。

第七章:陣列

本章介紹了陣列,這是 JavaScript 和大多數其他程式語言中的一種基本資料型別。陣列是一個有序的值集合。每個值稱為一個元素,每個元素在陣列中有一個數值位置,稱為其索引。JavaScript 陣列是無型別的:陣列元素可以是任何型別,同一陣列的不同元素可以是不同型別。陣列元素甚至可以是物件或其他陣列,這使您可以建立複雜的資料結構,例如物件陣列和陣列陣列。JavaScript 陣列是基於零的,並使用 32 位索引:第一個元素的索引為 0,最大可能的索引為 4294967294(2³²−2),最大陣列大小為 4,294,967,295 個元素。JavaScript 陣列是動態的:它們根據需要增長或縮小,並且在建立陣列時無需宣告固定大小,也無需在大小更改時重新分配。JavaScript 陣列可能是稀疏的:元素不必具有連續的索引,可能存在間隙。每個 JavaScript 陣列都有一個length屬性。對於非稀疏陣列,此屬性指定陣列中的元素數量。對於稀疏陣列,length大於任何元素的最高索引。

JavaScript 陣列是 JavaScript 物件的一種特殊形式,陣列索引實際上只是整數屬性名。我們將在本章的其他地方更詳細地討論陣列的特殊性。實現通常會最佳化陣列,使得對數值索引的陣列元素的訪問通常比對常規物件屬性的訪問要快得多。

陣列從Array.prototype繼承屬性,該屬性定義了一組豐富的陣列操作方法,涵蓋在§7.8 中。這些方法大多是通用的,這意味著它們不僅適用於真實陣列,還適用於任何“類似陣列的物件”。我們將在§7.9 中討論類似陣列的物件。最後,JavaScript 字串的行為類似於字元陣列,我們將在§7.10 中討論這一點。

ES6 引入了一組被統稱為“型別化陣列”的新陣列類。與常規的 JavaScript 陣列不同,型別化陣列具有固定的長度和固定的數值元素型別。它們提供高效能和對二進位制資料的位元組級訪問,並在§11.2 中有所涉及。

7.1 建立陣列

有幾種建立陣列的方法。接下來的小節將解釋如何使用以下方式建立陣列:

  • 陣列字面量

  • 可迭代物件上的...展開運算子

  • Array()建構函式

  • Array.of()Array.from()工廠方法

7.1.1 陣列字面量

創造陣列最簡單的方法是使用陣列字面量,它只是方括號內以逗號分隔的陣列元素列表。例如:

let empty = [];                 // An array with no elements
let primes = [2, 3, 5, 7, 11];  // An array with 5 numeric elements
let misc = [ 1.1, true, "a", ]; // 3 elements of various types + trailing comma

陣列字面量中的值不必是常量;它們可以是任意表示式:

let base = 1024;
let table = [base, base+1, base+2, base+3];

陣列字面量可以包含物件字面量或其他陣列字面量:

let b = [[1, {x: 1, y: 2}], [2, {x: 3, y: 4}]];

如果陣列字面量中包含多個連續的逗號,且之間沒有值,那麼該陣列是稀疏的(參見§7.3)。省略值的陣列元素並不存在,但如果查詢它們,則看起來像是undefined

let count = [1,,3]; // Elements at indexes 0 and 2\. No element at index 1
let undefs = [,,];  // An array with no elements but a length of 2

陣列字面量語法允許有可選的尾隨逗號,因此[,,]的長度為 2,而不是 3。

7.1.2 展開運算子

在 ES6 及更高版本中,您可以使用“展開運算子”...將一個陣列的元素包含在一個陣列字面量中:

let a = [1, 2, 3];
let b = [0, ...a, 4];  // b == [0, 1, 2, 3, 4]

這三個點“展開”陣列a,使得它的元素成為正在建立的陣列字面量中的元素。就好像...a被陣列a的元素替換,字面上列為封閉陣列字面量的一部分。 (請注意,儘管我們稱這三個點為展開運算子,但這不是一個真正的運算子,因為它只能在陣列字面量中使用,並且正如我們將在本書後面看到的,函式呼叫。)

展開運算子是建立(淺層)陣列副本的便捷方式:

let original = [1,2,3];
let copy = [...original];
copy[0] = 0;  // Modifying the copy does not change the original
original[0]   // => 1

展開運算子適用於任何可迭代物件。(可迭代物件是for/of迴圈迭代的物件;我們首次在§5.4.4 中看到它們,並且我們將在第十二章中看到更多關於它們的內容。) 字串是可迭代的,因此您可以使用展開運算子將任何字串轉換為由單個字元字串組成的陣列:

let digits = [..."0123456789ABCDEF"];
digits // => ["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F"]

集合物件(§11.1.1)是可迭代的,因此從陣列中刪除重複元素的簡單方法是將陣列轉換為集合,然後立即使用展開運算子將集合轉換回陣列:

let letters = [..."hello world"];
[...new Set(letters)]  // => ["h","e","l","o"," ","w","r","d"]

7.1.3 Array() 建構函式

另一種建立陣列的方法是使用Array()建構函式。您可以以三種不同的方式呼叫此建構函式:

  • 不帶引數呼叫它:

    let a = new Array();
    

    此方法建立一個沒有元素的空陣列,等同於陣列字面量[]

  • 使用單個數字引數呼叫它,指定長度:

    let a = new Array(10);
    

    這種技術建立具有指定長度的陣列。當您事先知道將需要多少元素時,可以使用Array()建構函式的這種形式來預先分配陣列。請注意,陣列中不儲存任何值,並且陣列索引屬性“0”、“1”等甚至未為陣列定義。

  • 明確指定兩個或更多陣列元素或單個非數值元素:

    let a = new Array(5, 4, 3, 2, 1, "testing, testing");
    

    在這種形式中,建構函式引數成為新陣列的元素。幾乎總是比使用Array()建構函式更簡單的是使用陣列字面量。

7.1.4 Array.of()

當使用一個數值引數呼叫Array()建構函式時,它將該引數用作陣列長度。但是,當使用多個數值引數呼叫時,它將這些引數視為要建立的陣列的元素。這意味著Array()建構函式不能用於建立具有單個數值元素的陣列。

在 ES6 中,Array.of()函式解決了這個問題:它是一個工廠方法,使用其引數值(無論有多少個)作為陣列元素建立並返回一個新陣列:

Array.of()        // => []; returns empty array with no arguments
Array.of(10)      // => [10]; can create arrays with a single numeric argument
Array.of(1,2,3)   // => [1, 2, 3]

7.1.5 Array.from()

Array.from是 ES6 中引入的另一個陣列工廠方法。它期望一個可迭代或類似陣列的物件作為其第一個引數,並返回一個包含該物件元素的新陣列。對於可迭代引數,Array.from(iterable)的工作方式類似於展開運算子[...iterable]。這也是製作陣列副本的簡單方法:

let copy = Array.from(original);

Array.from()也很重要,因為它定義了一種使類似陣列物件的真陣列副本的方法。類似陣列的物件是具有數值長度屬性並且具有儲存值的屬性的非陣列物件,這些屬性的名稱恰好是整數。在使用客戶端 JavaScript 時,某些 Web 瀏覽器方法的返回值是類似陣列的,如果您首先將它們轉換為真陣列,那麼使用它們可能會更容易:

let truearray = Array.from(arraylike);

Array.from()還接受一個可選的第二個引數。如果將一個函式作為第二個引數傳遞,那麼在構建新陣列時,源物件的每個元素都將傳遞給您指定的函式,並且函式的返回值將儲存在陣列中,而不是原始值。(這非常類似於稍後將在本章介紹的陣列map()方法,但在構建陣列時執行對映比構建陣列然後將其對映到另一個新陣列更有效。)

7.2 讀取和寫入陣列元素

使用[]運算子訪問陣列元素。方括號左側應該是陣列的引用。方括號內應該是一個非負整數值的任意表示式。你可以使用這種語法來讀取和寫入陣列元素的值。因此,以下都是合法的 JavaScript 語句:

let a = ["world"];     // Start with a one-element array
let value = a[0];      // Read element 0
a[1] = 3.14;           // Write element 1
let i = 2;
a[i] = 3;              // Write element 2
a[i + 1] = "hello";    // Write element 3
a[a[i]] = a[0];        // Read elements 0 and 2, write element 3

陣列的特殊之處在於,當你使用非負整數且小於 2³²–1 的屬性名時,陣列會自動為你維護length屬性的值。例如,在前面的例子中,我們建立了一個只有一個元素的陣列a。然後我們在索引 1、2 和 3 處分配了值。隨著我們的操作,陣列的length屬性也發生了變化,因此:

a.length       // => 4

請記住,陣列是一種特殊型別的物件。用於訪問陣列元素的方括號與用於訪問物件屬性的方括號工作方式相同。JavaScript 將你指定的數值陣列索引轉換為字串——索引1變為字串"1"——然後將該字串用作屬性名。將索引從數字轉換為字串沒有什麼特殊之處:你也可以對常規物件這樣做:

let o = {};    // Create a plain object
o[1] = "one";  // Index it with an integer
o["1"]         // => "one"; numeric and string property names are the same

清楚地區分陣列索引物件屬性名是有幫助的。所有索引都是屬性名,但只有介於 0 和 2³²–2 之間的整數屬性名才是索引。所有陣列都是物件,你可以在它們上面建立任何名稱的屬性。然而,如果你使用的是陣列索引的屬性,陣列會根據需要更新它們的length屬性。

請注意,你可以使用負數或非整數的數字對陣列進行索引。當你這樣做時,數字會轉換為字串,並且該字串將用作屬性名。由於名稱不是非負整數,因此它被視為常規物件屬性,而不是陣列索引。此外,如果你使用恰好是非負整數的字串對陣列進行索引,它將表現為陣列索引,而不是物件屬性。如果你使用與整數相同的浮點數,情況也是如此:

a[-1.23] = true;  // This creates a property named "-1.23"
a["1000"] = 0;    // This the 1001st element of the array
a[1.000] = 1;     // Array index 1\. Same as a[1] = 1;

陣列索引只是物件屬性名的一種特殊型別,這意味著 JavaScript 陣列沒有“越界”錯誤的概念。當你嘗試查詢任何物件的不存在屬性時,你不會收到錯誤;你只會得到undefined。對於陣列和物件來說,這一點同樣適用:

let a = [true, false]; // This array has elements at indexes 0 and 1
a[2]                   // => undefined; no element at this index.
a[-1]                  // => undefined; no property with this name.

7.3 稀疏陣列

稀疏陣列是指元素的索引不是從 0 開始的連續索引。通常,陣列的length屬性指定陣列中元素的數量。如果陣列是稀疏的,length屬性的值將大於元素的數量。可以使用Array()建構函式建立稀疏陣列,或者簡單地透過分配給大於當前陣列length的陣列索引來建立稀疏陣列。

let a = new Array(5); // No elements, but a.length is 5.
a = [];               // Create an array with no elements and length = 0.
a[1000] = 0;          // Assignment adds one element but sets length to 1001.

我們稍後會看到,你也可以使用delete運算子使陣列變得稀疏。

具有足夠稀疏性的陣列通常以比密集陣列更慢、更節省記憶體的方式實現,查詢這種陣列中的元素將花費與常規物件屬性查詢相同的時間。

注意,當你在陣列字面量中省略一個值(使用重複逗號,如[1,,3]),結果得到的陣列是稀疏的,省略的元素簡單地不存在:

let a1 = [,];           // This array has no elements and length 1
let a2 = [undefined];   // This array has one undefined element
0 in a1                 // => false: a1 has no element with index 0
0 in a2                 // => true: a2 has the undefined value at index 0

理解稀疏陣列是理解 JavaScript 陣列真正本質的重要部分。然而,在實踐中,你將使用的大多數 JavaScript 陣列都不會是稀疏的。而且,如果你確實需要使用稀疏陣列,你的程式碼可能會像對待具有undefined元素的非稀疏陣列一樣對待它。

7.4 陣列長度

每個陣列都有一個length屬性,正是這個屬性使陣列與常規 JavaScript 物件不同。對於密集陣列(即非稀疏陣列),length屬性指定陣列中元素的數量。其值比陣列中最高索引多一:

[].length             // => 0: the array has no elements
["a","b","c"].length  // => 3: highest index is 2, length is 3

當陣列是稀疏的時,length屬性大於元素數量,我們只能說length保證大於陣列中每個元素的索引。換句話說,陣列(稀疏或非稀疏)永遠不會有索引大於或等於其length的元素。為了保持這個不變數,陣列有兩個特殊行為。我們上面描述的第一個:如果您為索引i大於或等於陣列當前length的陣列元素分配一個值,length屬性的值將設定為i+1

陣列為了保持長度不變的第二個特殊行為是,如果您將length屬性設定為小於當前值的非負整數n,則任何索引大於或等於n的陣列元素將從陣列中刪除:

a = [1,2,3,4,5];     // Start with a 5-element array.
a.length = 3;        // a is now [1,2,3].
a.length = 0;        // Delete all elements.  a is [].
a.length = 5;        // Length is 5, but no elements, like new Array(5)

您還可以將陣列的length屬性設定為大於當前值的值。這樣做實際上並不向陣列新增任何新元素;它只是在陣列末尾建立了一個稀疏區域。

7.5 新增和刪除陣列元素

我們已經看到向陣列新增元素的最簡單方法:只需為新索引分配值:

let a = [];      // Start with an empty array.
a[0] = "zero";   // And add elements to it.
a[1] = "one";

您還可以使用push()方法將一個或多個值新增到陣列的末尾:

let a = [];           // Start with an empty array
a.push("zero");       // Add a value at the end.  a = ["zero"]
a.push("one", "two"); // Add two more values.  a = ["zero", "one", "two"]

將值推送到陣列a上與將值分配給a[a.length]相同。您可以使用unshift()方法(在§7.8 中描述)在陣列的開頭插入一個值,將現有陣列元素移動到更高的索引。pop()方法是push()的相反操作:它刪除陣列的最後一個元素並返回它,將陣列的長度減少 1。類似地,shift()方法刪除並返回陣列的第一個元素,將長度減 1 並將所有元素向下移動到比當前索引低一個索引。有關這些方法的更多資訊,請參閱§7.8。

您可以使用delete運算子刪除陣列元素,就像您可以刪除物件屬性一樣:

let a = [1,2,3];
delete a[2];   // a now has no element at index 2
2 in a         // => false: no array index 2 is defined
a.length       // => 3: delete does not affect array length

刪除陣列元素與將undefined分配給該元素類似(但略有不同)。請注意,使用delete刪除陣列元素不會改變length屬性,並且不會將具有更高索引的元素向下移動以填補被刪除屬性留下的空白。如果從陣列中刪除一個元素,陣列將變得稀疏。

正如我們上面看到的,您也可以透過將length屬性設定為新的所需長度來從陣列末尾刪除元素。

最後,splice()是用於插入、刪除或替換陣列元素的通用方法。它改變length屬性並根據需要將陣列元素移動到更高或更低的索引。有關詳細資訊,請參閱§7.8。

7.6 遍歷陣列

從 ES6 開始,遍歷陣列(或任何可迭代物件)的最簡單方法是使用for/of迴圈,這在§5.4.4 中有詳細介紹:

let letters = [..."Hello world"];  // An array of letters
let string = "";
for(let letter of letters) {
    string += letter;
}
string  // => "Hello world"; we reassembled the original text

for/of迴圈使用的內建陣列迭代器按升序返回陣列的元素。對於稀疏陣列,它沒有特殊行為,只是對於不存在的陣列元素返回undefined

如果您想要使用for/of迴圈遍歷陣列並需要知道每個陣列元素的索引,請使用陣列的entries()方法,以及解構賦值,如下所示:

let everyother = "";
for(let [index, letter] of letters.entries()) {
    if (index % 2 === 0) everyother += letter;  // letters at even indexes
}
everyother  // => "Hlowrd"

另一種遍歷陣列的好方法是使用forEach()。這不是for迴圈的新形式,而是一種提供陣列迭代功能的陣列方法。您將一個函式傳遞給陣列的forEach()方法,forEach()在陣列的每個元素上呼叫您的函式一次:

let uppercase = "";
letters.forEach(letter => {  // Note arrow function syntax here
    uppercase += letter.toUpperCase();
});
uppercase  // => "HELLO WORLD"

正如你所期望的那樣,forEach()按順序迭代陣列,並將陣列索引作為第二個引數傳遞給你的函式,這有時很有用。與for/of迴圈不同,forEach()知道稀疏陣列,並且不會為不存在的元素呼叫你的函式。

§7.8.1 詳細介紹了forEach()方法。該部分還涵蓋了類似map()filter()的相關方法,執行特定型別的陣列迭代。

您還可以使用傳統的for迴圈遍歷陣列的元素(§5.4.3):

let vowels = "";
for(let i = 0; i < letters.length; i++) { // For each index in the array
    let letter = letters[i];              // Get the element at that index
    if (/[aeiou]/.test(letter)) {         // Use a regular expression test
        vowels += letter;                 // If it is a vowel, remember it
    }
}
vowels  // => "eoo"

在巢狀迴圈或其他效能關鍵的情況下,有時會看到基本的陣列迭代迴圈被寫成只查詢一次陣列長度而不是在每次迭代中查詢。以下兩種for迴圈形式都是慣用的,儘管不是特別常見,並且在現代 JavaScript 直譯器中,它們是否會對效能產生影響並不清楚:

// Save the array length into a local variable
for(let i = 0, len = letters.length; i < len; i++) {
    // loop body remains the same
}

// Iterate backwards from the end of the array to the start
for(let i = letters.length-1; i >= 0; i--) {
    // loop body remains the same
}

這些示例假設陣列是密集的,並且所有元素都包含有效資料。如果不是這種情況,您應該在使用陣列元素之前對其進行測試。如果要跳過未定義和不存在的元素,您可以這樣寫:

for(let i = 0; i < a.length; i++) {
    if (a[i] === undefined) continue; // Skip undefined + nonexistent elements
    // loop body here
}

7.7 多維陣列

JavaScript 不支援真正的多維陣列,但可以用陣列的陣列來近似實現。要訪問陣列中的值,只需簡單地兩次使用[]運算子。例如,假設變數matrix是一個包含數字陣列的陣列。matrix[x]中的每個元素都是一個數字陣列。要訪問這個陣列中的特定數字,你可以寫成matrix[x][y]。以下是一個使用二維陣列作為乘法表的具體示例:

// Create a multidimensional array
let table = new Array(10);               // 10 rows of the table
for(let i = 0; i < table.length; i++) {
    table[i] = new Array(10);            // Each row has 10 columns
}

// Initialize the array
for(let row = 0; row < table.length; row++) {
    for(let col = 0; col < table[row].length; col++) {
        table[row][col] = row*col;
    }
}

// Use the multidimensional array to compute 5*7
table[5][7]  // => 35

7.8 陣列方法

前面的部分重點介紹了處理陣列的基本 JavaScript 語法。然而,一般來說,Array 類定義的方法是最強大的。接下來的部分記錄了這些方法。在閱讀這些方法時,請記住其中一些方法會修改呼叫它們的陣列,而另一些方法則會保持陣列不變。其中一些方法會返回一個陣列:有時這是一個新陣列,原始陣列保持不變。其他時候,一個方法會就地修改陣列,並同時返回修改後的陣列的引用。

接下來的各小節涵蓋了一組相關的陣列方法:

  • 迭代方法迴圈遍歷陣列的元素,通常在每個元素上呼叫您指定的函式。

  • 棧和佇列方法向陣列的開頭和結尾新增和移除陣列元素。

  • 子陣列方法用於提取、刪除、插入、填充和複製較大陣列的連續區域。

  • 搜尋和排序方法用於在陣列中定位元素並對陣列元素進行排序。

以下小節還涵蓋了 Array 類的靜態方法以及一些用於連線陣列和將陣列轉換為字串的雜項方法。

7.8.1 陣列迭代方法

本節描述的方法透過將陣列元素按順序傳遞給您提供的函式來迭代陣列,並提供了方便的方法來迭代、對映、過濾、測試和減少陣列。

然而,在詳細解釋這些方法之前,值得對它們做一些概括。首先,所有這些方法都接受一個函式作為它們的第一個引數,併為陣列的每個元素(或某些元素)呼叫該函式。如果陣列是稀疏的,您傳遞的函式不會為不存在的元素呼叫。在大多數情況下,您提供的函式會被呼叫三個引數:陣列元素的值、陣列元素的索引和陣列本身。通常,您只需要第一個引數值,可以忽略第二和第三個值。

在下面的小節中描述的大多數迭代器方法都接受一個可選的第二個引數。如果指定了,函式將被呼叫,就好像它是第二個引數的方法一樣。也就是說,您傳遞的第二個引數將成為您作為第一個引數傳遞的函式內部的 this 關鍵字的值。您傳遞的函式的返回值通常很重要,但不同的方法以不同的方式處理返回值。這裡描述的方法都不會修改呼叫它們的陣列(儘管您傳遞的函式可以修改陣列,當然)。

每個這些函式都是以一個函式作為其第一個引數呼叫的,很常見的是在方法呼叫表示式中定義該函式內聯,而不是使用在其他地方定義的現有函式。箭頭函式語法(參見§8.1.3)與這些方法特別配合,我們將在接下來的示例中使用它。

forEach()

forEach() 方法遍歷陣列,為每個元素呼叫您指定的函式。正如我們所描述的,您將函式作為第一個引數傳遞給 forEach()。然後,forEach() 使用三個引數呼叫您的函式:陣列元素的值,陣列元素的索引和陣列本身。如果您只關心陣列元素的值,您可以編寫一個只有一個引數的函式——額外的引數將被忽略:

let data = [1,2,3,4,5], sum = 0;
// Compute the sum of the elements of the array
data.forEach(value => { sum += value; });          // sum == 15

// Now increment each array element
data.forEach(function(v, i, a) { a[i] = v + 1; }); // data == [2,3,4,5,6]

請注意,forEach() 不提供在所有元素被傳遞給函式之前終止迭代的方法。也就是說,您無法像在常規 for 迴圈中使用 break 語句那樣使用。

map()

map() 方法將呼叫它的陣列的每個元素傳遞給您指定的函式,並返回一個包含您函式返回的值的陣列。例如:

let a = [1, 2, 3];
a.map(x => x*x)   // => [1, 4, 9]: the function takes input x and returns x*x

傳遞給 map() 的函式的呼叫方式與傳遞給 forEach() 的函式相同。然而,對於 map() 方法,您傳遞的函式應該返回一個值。請注意,map() 返回一個新陣列:它不會修改呼叫它的陣列。如果該陣列是稀疏的,您的函式將不會為缺失的元素呼叫,但返回的陣列將與原始陣列一樣稀疏:它將具有相同的長度和相同的缺失元素。

filter()

filter() 方法返回一個包含呼叫它的陣列的元素子集的陣列。傳遞給它的函式應該是謂詞:一個返回 truefalse 的函式。謂詞的呼叫方式與 forEach()map() 相同。如果返回值為 true,或者可以轉換為 true 的值,則傳遞給謂詞的元素是子集的成員,並將新增到將成為返回值的陣列中。示例:

let a = [5, 4, 3, 2, 1];
a.filter(x => x < 3)         // => [2, 1]; values less than 3
a.filter((x,i) => i%2 === 0) // => [5, 3, 1]; every other value

請注意,filter() 跳過稀疏陣列中的缺失元素,並且其返回值始終是密集的。要填補稀疏陣列中的空白,您可以這樣做:

let dense = sparse.filter(() => true);

要填補空白並刪除未定義和空元素,您可以使用 filter,如下所示:

a = a.filter(x => x !== undefined && x !== null);

find() 和 findIndex()

find()findIndex() 方法類似於 filter(),它們遍歷陣列,尋找使謂詞函式返回真值的元素。然而,這兩種方法在謂詞第一次找到元素時停止遍歷。當這種情況發生時,find() 返回匹配的元素,而 findIndex() 返回匹配元素的索引。如果找不到匹配的元素,find() 返回 undefined,而 findIndex() 返回 -1

let a = [1,2,3,4,5];
a.findIndex(x => x === 3)  // => 2; the value 3 appears at index 2
a.findIndex(x => x < 0)    // => -1; no negative numbers in the array
a.find(x => x % 5 === 0)   // => 5: this is a multiple of 5
a.find(x => x % 7 === 0)   // => undefined: no multiples of 7 in the array

every() 和 some()

every()some() 方法是陣列謂詞:它們將您指定的謂詞函式應用於陣列的元素,然後返回 truefalse

every() 方法類似於數學中的“對於所有”量詞 ∀:僅當它的謂詞函式對陣列中的所有元素返回 true 時,它才返回 true

let a = [1,2,3,4,5];
a.every(x => x < 10)      // => true: all values are < 10.
a.every(x => x % 2 === 0) // => false: not all values are even.

some()方法類似於數學中的“存在”量詞∃:如果陣列中存在至少一個使謂詞返回true的元素,則返回true,如果謂詞對陣列的所有元素返回false,則返回false

let a = [1,2,3,4,5];
a.some(x => x%2===0)  // => true; a has some even numbers.
a.some(isNaN)         // => false; a has no non-numbers.

請注意,every()some()都會在他們知道要返回的值時停止迭代陣列元素。some()在您的謂詞第一次返回true時返回true,只有在您的謂詞始終返回false時才會遍歷整個陣列。every()則相反:當您的謂詞第一次返回false時返回false,只有在您的謂詞始終返回true時才會迭代所有元素。還要注意,按照數學約定,當在空陣列上呼叫every()時,every()返回true,而在空陣列上呼叫some時,some返回false

reduce()和 reduceRight()

reduce()reduceRight()方法使用您指定的函式組合陣列的元素,以產生單個值。這是函數語言程式設計中的常見操作,也稱為“注入”和“摺疊”。示例有助於說明它是如何工作的:

let a = [1,2,3,4,5];
a.reduce((x,y) => x+y, 0)          // => 15; the sum of the values
a.reduce((x,y) => x*y, 1)          // => 120; the product of the values
a.reduce((x,y) => (x > y) ? x : y) // => 5; the largest of the values

reduce()接受兩個引數。第一個是執行減少操作的函式。這個減少函式的任務是以某種方式將兩個值組合或減少為單個值,並返回該減少值。在我們這裡展示的示例中,這些函式透過相加、相乘和選擇最大值來組合兩個值。第二個(可選)引數是傳遞給函式的初始值。

使用reduce()的函式與forEach()map()中使用的函式不同。熟悉的值、索引和陣列值作為第二、第三和第四個引數傳遞。第一個引數是到目前為止減少的累積結果。在第一次呼叫函式時,這個第一個引數是您作為reduce()的第二個引數傳遞的初始值。在後續呼叫中,它是前一個函式呼叫返回的值。在第一個示例中,減少函式首先使用引數 0 和 1 進行呼叫。它將它們相加並返回 1。然後再次使用引數 1 和 2 呼叫它並返回 3。接下來,它計算 3+3=6,然後 6+4=10,最後 10+5=15。這個最終值 15 成為reduce()的返回值。

您可能已經注意到此示例中對reduce()的第三次呼叫只有一個引數:沒有指定初始值。當您像這樣呼叫reduce()而沒有初始值時,它將使用陣列的第一個元素作為初始值。這意味著減少函式的第一次呼叫將具有陣列的第一個和第二個元素作為其第一個和第二個引數。在求和和乘積示例中,我們可以省略初始值引數。

在空陣列上呼叫reduce()且沒有初始值引數會導致 TypeError。如果您只使用一個值呼叫它——要麼是一個具有一個元素且沒有初始值的陣列,要麼是一個空陣列和一個初始值——它將簡單地返回那個值,而不會呼叫減少函式。

reduceRight()的工作方式與reduce()完全相同,只是它從最高索引到最低索引(從右到左)處理陣列,而不是從最低到最高。如果減少操作具有從右到左的結合性,您可能希望這樣做,例如:

// Compute 2^(3⁴).  Exponentiation has right-to-left precedence
let a = [2, 3, 4];
a.reduceRight((acc,val) => Math.pow(val,acc)) // => 2.4178516392292583e+24

請注意,reduce()reduceRight()都不接受一個可選引數,該引數指定要呼叫減少函式的this值。可選的初始值引數代替了它。如果您需要將您的減少函式作為特定物件的方法呼叫,請參閱Function.bind()方法(§8.7.5)。

到目前為止所展示的示例都是為了簡單起見而是數值的,但reduce()reduceRight()並不僅僅用於數學計算。任何能將兩個值(如兩個物件)合併為相同型別值的函式都可以用作縮減函式。另一方面,使用陣列縮減表達的演算法可能很快變得複雜且難以理解,你可能會發現,如果使用常規的迴圈結構來處理陣列,那麼閱讀、編寫和推理程式碼會更容易。

7.8.2 使用 flat()和flatMap()展平陣列

在 ES2019 中,flat()方法建立並返回一個新陣列,其中包含呼叫它的陣列的相同元素,除了那些本身是陣列的元素被“展平”到返回的陣列中。例如:

[1, [2, 3]].flat()    // => [1, 2, 3]
[1, [2, [3]]].flat()  // => [1, 2, [3]]

當不帶引數呼叫時,flat()會展平一層巢狀。原始陣列中本身是陣列的元素會被展平,但那些陣列的元素不會被展平。如果你想展平更多層次,請向flat()傳遞一個數字:

let a = [1, [2, [3, [4]]]];
a.flat(1)   // => [1, 2, [3, [4]]]
a.flat(2)   // => [1, 2, 3, [4]]
a.flat(3)   // => [1, 2, 3, 4]
a.flat(4)   // => [1, 2, 3, 4]

flatMap()方法的工作方式與map()方法相同(參見map()),只是返回的陣列會自動展平,就像傳遞給flat()一樣。也就是說,呼叫a.flatMap(f)與(但更有效率)a.map(f).flat()相同:

let phrases = ["hello world", "the definitive guide"];
let words = phrases.flatMap(phrase => phrase.split(" "));
words // => ["hello", "world", "the", "definitive", "guide"];

你可以將flatMap()視為map()的一般化,允許輸入陣列的每個元素對映到輸出陣列的任意數量的元素。特別是,flatMap()允許你將輸入元素對映到一個空陣列,這在輸出陣列中展平為無內容:

// Map non-negative numbers to their square roots
[-2, -1, 1, 2].flatMap(x => x < 0 ? [] : Math.sqrt(x)) // => [1, 2**0.5]

7.8.3 使用 concat()新增陣列

concat()方法建立並返回一個新陣列,其中包含呼叫concat()的原始陣列的元素,後跟concat()的每個引數。如果其中任何引數本身是一個陣列,則連線的是陣列元素,而不是陣列本身。但請注意,concat()不會遞迴展平陣列的陣列。concat()不會修改呼叫它的陣列:

let a = [1,2,3];
a.concat(4, 5)          // => [1,2,3,4,5]
a.concat([4,5],[6,7])   // => [1,2,3,4,5,6,7]; arrays are flattened
a.concat(4, [5,[6,7]])  // => [1,2,3,4,5,[6,7]]; but not nested arrays
a                       // => [1,2,3]; the original array is unmodified

注意concat()會在呼叫時建立原始陣列的新副本。在許多情況下,這是正確的做法,但這是一個昂貴的操作。如果你發現自己寫的程式碼像a = a.concat(x),那麼你應該考慮使用push()splice()來就地修改陣列,而不是建立一個新陣列。

7.8.4 使用 push()、pop()、shift()和 unshift()實現棧和佇列

push()pop()方法允許你像處理棧一樣處理陣列。push()方法將一個或多個新元素附加到陣列的末尾,並返回陣列的新長度。與concat()不同,push()不會展平陣列引數。pop()方法則相反:它刪除陣列的最後一個元素,減少陣列長度,並返回它刪除的值。請注意,這兩種方法都會就地修改陣列。push()pop()的組合允許你使用 JavaScript 陣列來實現先進後出的棧。例如:

let stack = [];       // stack == []
stack.push(1,2);      // stack == [1,2];
stack.pop();          // stack == [1]; returns 2
stack.push(3);        // stack == [1,3]
stack.pop();          // stack == [1]; returns 3
stack.push([4,5]);    // stack == [1,[4,5]]
stack.pop()           // stack == [1]; returns [4,5]
stack.pop();          // stack == []; returns 1

push()方法不會展平你傳遞給它的陣列,但如果你想將一個陣列的所有元素推到另一個陣列中,你可以使用展開運算子(§8.3.4)來顯式展平它:

a.push(...values);

unshift()shift()方法的行為與push()pop()類似,只是它們是從陣列的開頭而不是末尾插入和刪除元素。unshift()在陣列開頭新增一個或多個元素,將現有陣列元素向較高的索引移動以騰出空間,並返回陣列的新長度。shift()移除並返回陣列的第一個元素,將所有後續元素向下移動一個位置以佔據陣列開頭的新空間。您可以使用unshift()shift()來實現堆疊,但與使用push()pop()相比效率較低,因為每次在陣列開頭新增或刪除元素時都需要將陣列元素向上或向下移動。不過,您可以透過使用push()在陣列末尾新增元素並使用shift()從陣列開頭刪除元素來實現佇列資料結構:

let q = [];            // q == []
q.push(1,2);           // q == [1,2]
q.shift();             // q == [2]; returns 1
q.push(3)              // q == [2, 3]
q.shift()              // q == [3]; returns 2
q.shift()              // q == []; returns 3

unshift()的一個值得注意的特點是,當向unshift()傳遞多個引數時,它們會一次性插入,這意味著它們以與逐個插入時不同的順序出現在陣列中:

let a = [];            // a == []
a.unshift(1)           // a == [1]
a.unshift(2)           // a == [2, 1]
a = [];                // a == []
a.unshift(1,2)         // a == [1, 2]

7.8.5 使用 slice()、splice()、fill()和 copyWithin()建立子陣列

陣列定義了一些在連續區域、子陣列或陣列的“切片”上工作的方法。以下部分描述了用於提取、替換、填充和複製切片的方法。

slice()

slice()方法返回指定陣列的切片或子陣列。它的兩個引數指定要返回的切片的起始和結束。返回的陣列包含由第一個引數指定的元素和直到第二個引數指定的元素之前的所有後續元素(不包括該元素)。如果只指定一個引數,則返回的陣列包含從起始位置到陣列末尾的所有元素。如果任一引數為負數,則它指定相對於陣列長度的陣列元素。例如,引數-1 指定陣列中的最後一個元素,引數-2 指定該元素之前的元素。請注意,slice()不會修改呼叫它的陣列。以下是一些示例:

let a = [1,2,3,4,5];
a.slice(0,3);    // Returns [1,2,3]
a.slice(3);      // Returns [4,5]
a.slice(1,-1);   // Returns [2,3,4]
a.slice(-3,-2);  // Returns [3]

splice()

splice()是一個通用的方法,用於向陣列中插入或刪除元素。與slice()concat()不同,splice()會修改呼叫它的陣列。請注意,splice()slice()的名稱非常相似,但執行的操作有很大不同。

splice()可以從陣列中刪除元素、向陣列中插入新元素,或同時執行這兩個操作。陣列中插入或刪除點之後的元素的索引會根據需要增加或減少,以使它們與陣列的其餘部分保持連續。splice()的第一個引數指定插入和/或刪除開始的陣列位置。第二個引數指定應從陣列中刪除的元素數量。(請注意,這是這兩種方法之間的另一個區別。slice()的第二個引數是結束位置。splice()的第二個引數是長度。)如果省略了第二個引數,則從起始元素到陣列末尾的所有陣列元素都將被刪除。splice()返回一個包含已刪除元素的陣列,如果沒有刪除元素,則返回一個空陣列。例如:

let a = [1,2,3,4,5,6,7,8];
a.splice(4)    // => [5,6,7,8]; a is now [1,2,3,4]
a.splice(1,2)  // => [2,3]; a is now [1,4]
a.splice(1,1)  // => [4]; a is now [1]

splice()的前兩個引數指定要刪除的陣列元素。這些引數後面可以跟任意數量的額外引數,這些引數指定要插入到陣列中的元素,從第一個引數指定的位置開始。例如:

let a = [1,2,3,4,5];
a.splice(2,0,"a","b")  // => []; a is now [1,2,"a","b",3,4,5]
a.splice(2,2,[1,2],3)  // => ["a","b"]; a is now [1,2,[1,2],3,3,4,5]

請注意,與concat()不同,splice()插入的是陣列本身,而不是這些陣列的元素。

填充()

fill()方法將陣列或陣列的一個片段的元素設定為指定的值。它會改變呼叫它的陣列,並返回修改後的陣列:

let a = new Array(5);   // Start with no elements and length 5
a.fill(0)               // => [0,0,0,0,0]; fill the array with zeros
a.fill(9, 1)            // => [0,9,9,9,9]; fill with 9 starting at index 1
a.fill(8, 2, -1)        // => [0,9,8,8,9]; fill with 8 at indexes 2, 3

fill()的第一個引數是要設定陣列元素的值。可選的第二個引數指定開始索引。如果省略,填充將從索引 0 開始。可選的第三個引數指定結束索引——將填充到該索引之前的陣列元素。如果省略此引數,則陣列將從開始索引填充到結束。您可以透過傳遞負數來指定相對於陣列末尾的索引,就像對slice()一樣。

copyWithin()

copyWithin()將陣列的一個片段複製到陣列內的新位置。它會就地修改陣列並返回修改後的陣列,但不會改變陣列的長度。第一個引數指定要複製第一個元素的目標索引。第二個引數指定要複製的第一個元素的索引。如果省略第二個引數,則使用 0。第三個引數指定要複製的元素片段的結束。如果省略,將使用陣列的長度。從開始索引到結束索引之前的元素將被複制。您可以透過傳遞負數來指定相對於陣列末尾的索引,就像對slice()一樣:

let a = [1,2,3,4,5];
a.copyWithin(1)       // => [1,1,2,3,4]: copy array elements up one
a.copyWithin(2, 3, 5) // => [1,1,3,4,4]: copy last 2 elements to index 2
a.copyWithin(0, -2)   // => [4,4,3,4,4]: negative offsets work, too

copyWithin()旨在作為一種高效能方法,特別適用於型別化陣列(參見§11.2)。它模仿了 C 標準庫中的memmove()函式。請注意,即使源區域和目標區域之間存在重疊,複製也會正確工作。

7.8.6 陣列搜尋和排序方法

陣列實現了indexOf()lastIndexOf()includes()方法,這些方法與字串的同名方法類似。還有sort()reverse()方法用於重新排列陣列的元素。這些方法將在接下來的小節中描述。

indexOf()和 lastIndexOf()

indexOf()lastIndexOf()搜尋具有指定值的元素的陣列,並返回找到的第一個這樣的元素的索引,如果找不到則返回-1indexOf()從開頭到結尾搜尋陣列,lastIndexOf()從結尾到開頭搜尋:

let a = [0,1,2,1,0];
a.indexOf(1)       // => 1: a[1] is 1
a.lastIndexOf(1)   // => 3: a[3] is 1
a.indexOf(3)       // => -1: no element has value 3

indexOf()lastIndexOf()使用等價於===運算子的方式將它們的引數與陣列元素進行比較。如果您的陣列包含物件而不是原始值,這些方法將檢查兩個引用是否確實指向完全相同的物件。如果您想要實際檢視物件的內容,請嘗試使用帶有自定義謂詞函式的find()方法。

indexOf()lastIndexOf()接受一個可選的第二個引數,該引數指定開始搜尋的陣列索引。如果省略此引數,indexOf()從開頭開始,lastIndexOf()從末尾開始。第二個引數允許使用負值,並被視為從陣列末尾的偏移量,就像slice()方法一樣:例如,-1 表示陣列的最後一個元素。

以下函式搜尋陣列中指定值的所有匹配索引,並返回一個所有匹配索引的陣列。這演示瞭如何使用indexOf()的第二個引數來查詢第一個之外的匹配項。

// Find all occurrences of a value x in an array a and return an array
// of matching indexes
function findall(a, x) {
    let results = [],            // The array of indexes we'll return
        len = a.length,          // The length of the array to be searched
        pos = 0;                 // The position to search from
    while(pos < len) {           // While more elements to search...
        pos = a.indexOf(x, pos); // Search
        if (pos === -1) break;   // If nothing found, we're done.
        results.push(pos);       // Otherwise, store index in array
        pos = pos + 1;           // And start next search at next element
    }
    return results;              // Return array of indexes
}

請注意,字串具有類似這些陣列方法的indexOf()lastIndexOf()方法,只是負的第二個引數被視為零。

includes()

ES2016 的includes()方法接受一個引數,如果陣列包含該值則返回true,否則返回false。它不會告訴您該值的索引,只會告訴您它是否存在。includes()方法實際上是用於陣列的集合成員測試。但是請注意,陣列不是集合的有效表示形式,如果您處理的元素超過幾個,應該使用真正的 Set 物件(§11.1.1)。

includes()方法與indexOf()方法在一個重要方面略有不同。indexOf()使用與===運算子相同的演算法進行相等性測試,該相等性演算法認為非數字值與包括它本身在內的每個其他值都不同。includes()使用略有不同的相等性版本,它確實認為NaN等於它本身。這意味著indexOf()不會在陣列中檢測到NaN值,但includes()會:

let a = [1,true,3,NaN];
a.includes(true)            // => true
a.includes(2)               // => false
a.includes(NaN)             // => true
a.indexOf(NaN)              // => -1; indexOf can't find NaN

sort()

sort()對陣列的元素進行原地排序並返回排序後的陣列。當不帶引數呼叫sort()時,它會按字母順序對陣列元素進行排序(如果需要,會臨時將它們轉換為字串進行比較):

let a = ["banana", "cherry", "apple"];
a.sort(); // a == ["apple", "banana", "cherry"]

如果陣列包含未定義的元素,則它們將被排序到陣列的末尾。

要將陣列按照字母順序以外的某種順序排序,您必須將比較函式作為引數傳遞給sort()。此函式決定哪個引數應該首先出現在排序後的陣列中。如果第一個引數應該出現在第二個引數之前,則比較函式應返回小於零的數字。如果第一個引數應該在排序後的陣列中出現在第二個引數之後,則函式應返回大於零的數字。如果兩個值相等(即,如果它們的順序無關緊要),則比較函式應返回 0。因此,例如,要將陣列元素按照數字順序而不是字母順序排序,您可以這樣做:

let a = [33, 4, 1111, 222];
a.sort();               // a == [1111, 222, 33, 4]; alphabetical order
a.sort(function(a,b) {  // Pass a comparator function
    return a-b;         // Returns < 0, 0, or > 0, depending on order
});                     // a == [4, 33, 222, 1111]; numerical order
a.sort((a,b) => b-a);   // a == [1111, 222, 33, 4]; reverse numerical order

作為對陣列項進行排序的另一個示例,您可以透過傳遞一個比較函式對字串陣列進行不區分大小寫的字母排序,該函式在比較之前將其兩個引數都轉換為小寫(使用toLowerCase()方法):

let a = ["ant", "Bug", "cat", "Dog"];
a.sort();    // a == ["Bug","Dog","ant","cat"]; case-sensitive sort
a.sort(function(s,t) {
    let a = s.toLowerCase();
    let b = t.toLowerCase();
    if (a < b) return -1;
    if (a > b) return 1;
    return 0;
});   // a == ["ant","Bug","cat","Dog"]; case-insensitive sort

reverse()

reverse()方法顛倒陣列的元素順序並返回顛倒的陣列。它在原地執行此操作;換句話說,它不會建立一個重新排列元素的新陣列,而是在已經存在的陣列中重新排列它們:

let a = [1,2,3];
a.reverse();   // a == [3,2,1]

7.8.7 陣列轉換為字串

Array 類定義了三種可以將陣列轉換為字串的方法,通常在建立日誌和錯誤訊息時可能會使用。 (如果要以文字形式儲存陣列的內容以供以後重用,請使用JSON.stringify() [§6.8]來序列化陣列,而不是使用這裡描述的方法。)

join()方法將陣列的所有元素轉換為字串並連線它們,返回生成的字串。您可以指定一個可選的字串,用於分隔生成的字串中的元素。如果未指定分隔符字串,則使用逗號:

let a = [1, 2, 3];
a.join()               // => "1,2,3"
a.join(" ")            // => "1 2 3"
a.join("")             // => "123"
let b = new Array(10); // An array of length 10 with no elements
b.join("-")            // => "---------": a string of 9 hyphens

join()方法是String.split()方法的反向操作,它透過將字串分割成片段來建立陣列。

陣列,就像所有 JavaScript 物件一樣,都有一個toString()方法。對於陣列,此方法的工作方式與沒有引數的join()方法相同:

[1,2,3].toString()          // => "1,2,3"
["a", "b", "c"].toString()  // => "a,b,c"
[1, [2,"c"]].toString()     // => "1,2,c"

請注意,輸出不包括方括號或任何其他型別的分隔符。

toLocaleString()toString()的本地化版本。它透過呼叫元素的toLocaleString()方法將每個陣列元素轉換為字串,然後使用特定於區域設定(和實現定義的)分隔符字串連線生成的字串。

7.8.8 靜態陣列函式

除了我們已經記錄的陣列方法之外,Array 類還定義了三個靜態函式,您可以透過Array建構函式而不是在陣列上呼叫這些函式。Array.of()Array.from()是用於建立新陣列的工廠方法。它們在§7.1.4 和§7.1.5 中有記錄。

另一個靜態陣列函式是Array.isArray(),用於確定未知值是否為陣列:

Array.isArray([])     // => true
Array.isArray({})     // => false

7.9 類似陣列物件

正如我們所見,JavaScript 陣列具有其他物件沒有的一些特殊功能:

  • 當向列表新增新元素時,length屬性會自動更新。

  • length設定為較小的值會截斷陣列。

  • 陣列從Array.prototype繼承了有用的方法。

  • 對於陣列,Array.isArray()返回true

這些是使 JavaScript 陣列與常規物件不同的特點。但它們並不是定義陣列的基本特徵。將任何具有數值length屬性和相應非負整數屬性的物件視為一種陣列通常是完全合理的。

這些“類似陣列”的物件實際上在實踐中偶爾會出現,儘管你不能直接在它們上面呼叫陣列方法或期望length屬性有特殊行為,但你仍然可以使用與真實陣列相同的程式碼迭代它們。事實證明,許多陣列演算法與類似陣列物件一樣有效,就像它們與真實陣列一樣有效一樣。特別是如果你的演算法將陣列視為只讀,或者至少保持陣列長度不變時,這一點尤為真實。

以下程式碼將常規物件轉換為類似陣列物件,然後遍歷生成的偽陣列的“元素”:

let a = {};  // Start with a regular empty object

// Add properties to make it "array-like"
let i = 0;
while(i < 10) {
    a[i] = i * i;
    i++;
}
a.length = i;

// Now iterate through it as if it were a real array
let total = 0;
for(let j = 0; j < a.length; j++) {
    total += a[j];
}

在客戶端 JavaScript 中,許多用於處理 HTML 文件的方法(例如document.querySelectorAll())返回類似陣列的物件。以下是您可能用於測試類似陣列物件的函式:

// Determine if o is an array-like object.
// Strings and functions have numeric length properties, but are
// excluded by the typeof test. In client-side JavaScript, DOM text
// nodes have a numeric length property, and may need to be excluded
// with an additional o.nodeType !== 3 test.
function isArrayLike(o) {
    if (o &&                            // o is not null, undefined, etc.
        typeof o === "object" &&        // o is an object
        Number.isFinite(o.length) &&    // o.length is a finite number
        o.length >= 0 &&                // o.length is non-negative
        Number.isInteger(o.length) &&   // o.length is an integer
        o.length < 4294967295) {        // o.length < 2³² - 1
        return true;                    // Then o is array-like.
    } else {
        return false;                   // Otherwise it is not.
    }
}

我們將在後面的部分看到字串的行為類似於陣列。然而,對於類似陣列物件的此類測試通常對字串返回false——最好將其處理為字串,而不是陣列。

大多數 JavaScript 陣列方法都故意定義為通用的,以便在應用於類似陣列物件時與真實陣列一樣正確工作。由於類似陣列物件不繼承自Array.prototype,因此不能直接在它們上呼叫陣列方法。但是,您可以間接使用Function.call方法呼叫它們(有關詳細資訊,請參見§8.7.4):

let a = {"0": "a", "1": "b", "2": "c", length: 3}; // An array-like object
Array.prototype.join.call(a, "+")                  // => "a+b+c"
Array.prototype.map.call(a, x => x.toUpperCase())  // => ["A","B","C"]
Array.prototype.slice.call(a, 0)   // => ["a","b","c"]: true array copy
Array.from(a)                      // => ["a","b","c"]: easier array copy

此程式碼倒數第二行在類似陣列物件上呼叫 Array slice()方法,以將該物件的元素複製到真實陣列物件中。這是一種成語技巧,存在於許多傳統程式碼中,但現在使用Array.from()更容易實現。

7.10 字串作為陣列

JavaScript 字串表現為 UTF-16 Unicode 字元的只讀陣列。您可以使用方括號而不是charAt()方法訪問單個字元:

let s = "test";
s.charAt(0)    // => "t"
s[1]           // => "e"

當然,對於字串,typeof運算子仍然返回“string”,如果您將其傳遞給Array.isArray()方法,則返回false

可索引字串的主要好處僅僅是我們可以用方括號替換charAt()的呼叫,這樣更簡潔、可讀,並且可能更高效。然而,字串表現得像陣列意味著我們可以將通用陣列方法應用於它們。例如:

Array.prototype.join.call("JavaScript", " ")  // => "J a v a S c r i p t"

請記住,字串是不可變的值,因此當它們被視為陣列時,它們是隻讀陣列。像push()sort()reverse()splice()這樣的陣列方法會就地修改陣列,不適用於字串。然而,嘗試使用陣列方法修改字串不會導致錯誤:它只是悄無聲息地失敗。

7.11 總結

本章深入討論了 JavaScript 陣列,包括稀疏陣列和類陣列物件的奇特細節。從本章中可以得出的主要觀點是:

  • 陣列字面量是用方括號括起來的逗號分隔的值列表編寫的。

  • 透過在方括號內指定所需的陣列索引來訪問單個陣列元素。

  • ES6 中引入的for/of迴圈和...擴充套件運算子是迭代陣列的特別有用的方式。

  • Array 類定義了一組豐富的方法來運算元組,你應該確保熟悉 Array API。

第八章:函式

本章涵蓋了 JavaScript 函式。函式是 JavaScript 程式的基本構建塊,也是幾乎所有程式語言中的常見特性。您可能已經熟悉了類似於子程式過程的函式概念。

函式是一段 JavaScript 程式碼塊,定義一次但可以執行或呼叫任意次數。JavaScript 函式是引數化的:函式定義可能包括一個識別符號列表,稱為引數,它們在函式體內作為區域性變數。函式呼叫為函式的引數提供值,或引數,函式通常使用它們的引數值來計算返回值,該返回值成為函式呼叫表示式的值。除了引數之外,每次呼叫還有另一個值—呼叫上下文—它是this關鍵字的值。

如果函式分配給物件的屬性,則稱為該物件的方法。當在物件上呼叫函式時,該物件是函式的呼叫上下文或this值。用於初始化新建立物件的函式稱為建構函式。建構函式在§6.2 中有描述,並將在第九章中再次介紹。

在 JavaScript 中,函式是物件,可以被程式操作。JavaScript 可以將函式分配給變數並將它們傳遞給其他函式,例如。由於函式是物件,您可以在它們上設定屬性,甚至在它們上呼叫方法。

JavaScript 函式定義可以巢狀在其他函式中,並且可以訪問在定義它們的作用域中的任何變數。這意味著 JavaScript 函式是閉包,並且它們可以實現重要且強大的程式設計技術。

8.1 定義函式

定義 JavaScript 函式最直接的方法是使用function關鍵字,可以用作宣告或表示式。ES6 定義了一種重要的新定義函式的方式,即“箭頭函式”沒有function關鍵字:箭頭函式具有特別簡潔的語法,並且在將一個函式作為另一個函式的引數傳遞時非常有用。接下來的小節將介紹這三種定義函式的方式。請注意,涉及函式引數的函式定義語法的一些細節將推遲到§8.3 中。

在物件字面量和類定義中,有一種方便的簡寫語法用於定義方法。這種簡寫語法在§6.10.5 中介紹過,相當於使用函式定義表示式並將其分配給物件屬性,使用基本的name:value物件字面量語法。在另一種特殊情況下,您可以在物件字面量中使用關鍵字getset來定義特殊的屬性獲取器和設定器方法。這種函式定義語法在§6.10.6 中介紹過。

請注意,函式也可以使用Function()建構函式來定義,這是§8.7.7 的主題。此外,JavaScript 定義了一些特殊型別的函式。function*定義生成器函式(參見第十二章),而async function定義非同步函式(參見第十三章)。

8.1.1 函式宣告

函式宣告由function關鍵字後跟這些元件組成:

  • 用於命名函式的識別符號。名稱是函式宣告的必需部分:它用作變數的名稱,並且新定義的函式物件分配給該變數。

  • 一對括號圍繞著一個逗號分隔的零個或多個識別符號列表。這些識別符號是函式的引數名稱,並且在函式體內部起到類似區域性變數的作用。

  • 一對大括號內包含零個或多個 JavaScript 語句。這些語句是函式的主體:每當呼叫函式時,它們都會被執行。

這裡是一些示例函式宣告:

// Print the name and value of each property of o.  Return undefined.
function printprops(o) {
    for(let p in o) {
        console.log(`${p}: ${o[p]}\n`);
    }
}

// Compute the distance between Cartesian points (x1,y1) and (x2,y2).
function distance(x1, y1, x2, y2) {
    let dx = x2 - x1;
    let dy = y2 - y1;
    return Math.sqrt(dx*dx + dy*dy);
}

// A recursive function (one that calls itself) that computes factorials
// Recall that x! is the product of x and all positive integers less than it.
function factorial(x) {
    if (x <= 1) return 1;
    return x * factorial(x-1);
}

關於函式宣告的重要事項之一是,函式的名稱成為一個變數,其值為函式本身。函式宣告語句被“提升”到封閉指令碼、函式或塊的頂部,以便以這種方式定義的函式可以從定義之前的程式碼中呼叫。另一種說法是,在 JavaScript 程式碼塊中宣告的所有函式將在該塊中定義,並且它們將在 JavaScript 直譯器開始執行該塊中的任何程式碼之前定義。

我們描述的 distance()factorial() 函式旨在計算一個值,並使用 return 將該值返回給呼叫者。return 語句導致函式停止執行並將其表示式的值(如果有)返回給呼叫者。如果 return 語句沒有關聯的表示式,則函式的返回值為 undefined

printprops() 函式有所不同:它的作用是輸出物件屬性的名稱和值。不需要返回值,並且函式不包括 return 語句。呼叫 printprops() 函式的值始終為 undefined。如果函式不包含 return 語句,它只是執行函式體中的每個語句,直到達到結尾,並將 undefined 值返回給呼叫者。

在 ES6 之前,只允許在 JavaScript 檔案的頂層或另一個函式內部定義函式宣告。雖然一些實現彎曲了規則,但在迴圈、條件語句或其他塊的主體內定義函式實際上是不合法的。然而,在 ES6 的嚴格模式下,允許在塊內部宣告函式。在塊內定義的函式僅存在於該塊內部,並且在塊外部不可見。

8.1.2 函式表示式

函式表示式看起來很像函式宣告,但它們出現在更大表示式或語句的上下文中,名稱是可選的。這裡是一些示例函式表示式:

// This function expression defines a function that squares its argument.
// Note that we assign it to a variable
const square = function(x) { return x*x; };

// Function expressions can include names, which is useful for recursion.
const f = function fact(x) { if (x <= 1) return 1; else return x*fact(x-1); };

// Function expressions can also be used as arguments to other functions:
[3,2,1].sort(function(a,b) { return a-b; });

// Function expressions are sometimes defined and immediately invoked:
let tensquared = (function(x) {return x*x;}(10));

請注意,對於定義為表示式的函式,函式名稱是可選的,我們展示的大多數前面的函式表示式都省略了它。函式宣告實際上 宣告 了一個變數,並將函式物件分配給它。另一方面,函式表示式不宣告變數:如果您需要多次引用它,您需要將新定義的函式物件分配給常量或變數。對於函式表示式,最好使用 const,這樣您不會意外地透過分配新值來覆蓋函式。

對於需要引用自身的函式(如階乘函式),允許為函式指定名稱。如果函式表示式包含名稱,則該函式的本地函式作用域將包括將該名稱繫結到函式物件。實際上,函式名稱成為函式內部的區域性變數。大多數作為表示式定義的函式不需要名稱,這使得它們的定義更加緊湊(儘管不像下面描述的箭頭函式那樣緊湊)。

使用函式宣告定義函式f()與在建立後將函式分配給變數f之間有一個重要的區別。當使用宣告形式時,函式物件在包含它們的程式碼開始執行之前就已經建立,並且定義被提升,以便您可以從出現在定義語句上方的程式碼中呼叫這些函式。然而,對於作為表示式定義的函式來說,情況並非如此:這些函式直到定義它們的表示式實際被評估之後才存在。此外,為了呼叫一個函式,您必須能夠引用它,而在將函式定義為表示式之前,您不能引用一個函式,因此使用表示式定義的函式在定義之前不能被呼叫。

8.1.3 箭頭函式

在 ES6 中,你可以使用一種特別簡潔的語法來定義函式,稱為“箭頭函式”。這種語法類似於數學表示法,並使用=>“箭頭”來分隔函式引數和函式主體。不使用function關鍵字,而且,由於箭頭函式是表示式而不是語句,因此也不需要函式名稱。箭頭函式的一般形式是用括號括起來的逗號分隔的引數列表,後跟=>箭頭,再後跟用花括號括起來的函式主體:

const sum = (x, y) => { return x + y; };

但是箭頭函式支援更緊湊的語法。如果函式的主體是一個單獨的return語句,您可以省略return關鍵字、與之配套的分號和花括號,並將函式主體寫成要返回其值的表示式:

const sum = (x, y) => x + y;

此外,如果箭頭函式只有一個引數,您可以省略引數列表周圍的括號:

const polynomial = x => x*x + 2*x + 3;

請注意,一個沒有任何引數的箭頭函式必須用一個空的括號對寫成:

const constantFunc = () => 42;

請注意,在編寫箭頭函式時,不要在函式引數和=>箭頭之間加入新行。否則,您可能會得到一行像const polynomial = x這樣的行,這是一個語法上有效的賦值語句。

此外,如果箭頭函式的主體是一個單獨的return語句,但要返回的表示式是一個物件字面量,則必須將物件字面量放在括號內,以避免在函式主體的花括號和物件字面量的花括號之間產生語法歧義:

const f = x => { return { value: x }; };  // Good: f() returns an object
const g = x => ({ value: x });            // Good: g() returns an object
const h = x => { value: x };              // Bad: h() returns nothing
const i = x => { v: x, w: x };            // Bad: Syntax Error

在此程式碼的第三行中,函式h()確實是模稜兩可的:您打算作為物件字面量的程式碼可以被解析為標記語句,因此建立了一個返回undefined的函式。然而,在第四行,更復雜的物件字面量不是一個有效的語句,這種非法程式碼會導致語法錯誤。

箭頭函式簡潔的語法使它們在需要將一個函式傳遞給另一個函式時非常理想,這在像map()filter()reduce()這樣的陣列方法中是常見的做法(參見§7.8.1):

// Make a copy of an array with null elements removed.
let filtered = [1,null,2,3].filter(x => x !== null); // filtered == [1,2,3]
// Square some numbers:
let squares = [1,2,3,4].map(x => x*x);               // squares == [1,4,9,16]

箭頭函式與其他方式定義的函式在一個關鍵方面有所不同:它們繼承自定義它們的環境中的this關鍵字的值,而不是像其他方式定義的函式那樣定義自己的呼叫上下文。這是箭頭函式的一個重要且非常有用的特性,我們將在本章後面再次回到這個問題。箭頭函式還與其他函式不同之處在於它們沒有prototype屬性,這意味著它們不能用作新類的建構函式(參見§9.2)。

8.1.4 巢狀函式

在 JavaScript 中,函式可以巢狀在其他函式中。例如:

function hypotenuse(a, b) {
    function square(x) { return x*x; }
    return Math.sqrt(square(a) + square(b));
}

巢狀函式的有趣之處在於它們的變數作用域規則:它們可以訪問巢狀在其中的函式(或函式)的引數和變數。例如,在這裡顯示的程式碼中,內部函式 square() 可以讀取和寫入外部函式 hypotenuse() 定義的引數 ab。巢狀函式的這些作用域規則非常重要,我們將在 §8.6 中再次考慮它們。

8.2 呼叫函式

JavaScript 函式體組成的程式碼在定義函式時不會執行,而是在呼叫函式時執行。JavaScript 函式可以透過五種方式呼叫:

  • 作為函式

  • 作為方法

  • 作為建構函式

  • 透過它們的 call()apply() 方法間接呼叫

  • 隱式地,透過 JavaScript 語言特性,看起來不像正常函式呼叫

8.2.1 函式呼叫

函式可以作為函式或方法透過呼叫表示式呼叫(§4.5)。呼叫表示式由一個求值為函式物件的函式表示式、一個開括號、一個逗號分隔的零個或多個參數列達式和一個閉括號組成。如果函式表示式是一個屬性訪問表示式——如果函式是物件的屬性或陣列的元素——那麼它就是一個方法呼叫表示式。這種情況將在下面的示例中解釋。以下程式碼包含了許多常規函式呼叫表示式:

printprops({x: 1});
let total = distance(0,0,2,1) + distance(2,1,3,5);
let probability = factorial(5)/factorial(13);

在呼叫中,每個參數列達式(括號之間的表示式)都會被求值,得到的值作為函式的引數。這些值被分配給函式定義中命名的引數。在函式體中,對引數的引用會求值為相應的引數值。

對於常規函式呼叫,函式的返回值成為呼叫表示式的值。如果函式返回是因為直譯器到達末尾,返回值是 undefined。如果函式返回是因為直譯器執行了 return 語句,則返回值是跟在 return 後面的表示式的值,如果 return 語句沒有值,則返回值是 undefined

在非嚴格模式下進行函式呼叫時,呼叫上下文(this 值)是全域性物件。然而,在嚴格模式下,呼叫上下文是 undefined。請注意,使用箭頭語法定義的函式行為不同:它們始終繼承在定義它們的地方生效的 this 值。

為了作為函式呼叫而編寫的函式(而不是作為方法呼叫),通常根本不使用 this 關鍵字。然而,可以使用該關鍵字來確定是否啟用了嚴格模式:

// Define and invoke a function to determine if we're in strict mode.
const strict = (function() { return !this; }());

8.2.2 方法呼叫

方法 只不過是儲存在物件屬性中的 JavaScript 函式。如果有一個函式 f 和一個物件 o,你可以用以下程式碼定義 o 的名為 m 的方法:

o.m = f;

定義了物件 o 的方法 m() 後,可以像這樣呼叫它:

o.m();

或者,如果 m() 預期有兩個引數,你可以這樣呼叫它:

o.m(x, y);

此示例中的程式碼是一個呼叫表示式:它包括一個函式表示式 o.m 和兩個參數列達式 xy。函式表示式本身是一個屬性訪問表示式,這意味著該函式被作為方法而不是作為常規函式呼叫。

方法呼叫的引數和返回值的處理方式與常規函式呼叫完全相同。然而,方法呼叫與函式呼叫有一個重要的區別:呼叫上下文。屬性訪問表示式由兩部分組成:一個物件(在本例中是 o)和一個屬性名(m)。在這樣的方法呼叫表示式中,物件 o 成為呼叫上下文,函式體可以透過關鍵字 this 引用該物件。以下是一個具體示例:

let calculator = { // An object literal
    operand1: 1,
    operand2: 1,
    add() {        // We're using method shorthand syntax for this function
        // Note the use of the this keyword to refer to the containing object.
        this.result = this.operand1 + this.operand2;
    }
};
calculator.add();  // A method invocation to compute 1+1.
calculator.result  // => 2

大多數方法呼叫使用點表示法進行屬性訪問,但使用方括號的屬性訪問表示式也會導致方法呼叫。例如,以下兩者都是方法呼叫:

o"m";   // Another way to write o.m(x,y).
a0        // Also a method invocation (assuming a[0] is a function).

方法呼叫也可能涉及更復雜的屬性訪問表示式:

customer.surname.toUpperCase(); // Invoke method on customer.surname
f().m();                        // Invoke method m() on return value of f()

方法和this關鍵字是物件導向程式設計正規化的核心。任何用作方法的函式實際上都會傳遞一個隱式引數——透過它被呼叫的物件。通常,方法在該物件上執行某種操作,而方法呼叫語法是一種優雅地表達函式正在操作物件的方式。比較以下兩行:

rect.setSize(width, height);
setRectSize(rect, width, height);

在這兩行程式碼中呼叫的假設函式可能對(假設的)物件rect執行完全相同的操作,但第一行中的方法呼叫語法更清楚地表明瞭物件rect是操作的主要焦點。

請注意this是一個關鍵字,不是變數或屬性名。JavaScript 語法不允許您為this賦值。

this關鍵字的作用域不同於變數,除了箭頭函式外,巢狀函式不會繼承包含函式的this值。如果巢狀函式被作為方法呼叫,其this值將是呼叫它的物件。如果巢狀函式(不是箭頭函式)被作為函式呼叫,那麼其this值將是全域性物件(非嚴格模式)或undefined(嚴格模式)。假設在方法內部定義的巢狀函式並作為函式呼叫時可以使用this獲取方法的呼叫上下文是一個常見的錯誤。以下程式碼演示了這個問題:

let o = {                 // An object o.
    m: function() {       // Method m of the object.
        let self = this;  // Save the "this" value in a variable.
        this === o        // => true: "this" is the object o.
        f();              // Now call the helper function f().

        function f() {    // A nested function f
            this === o    // => false: "this" is global or undefined
            self === o    // => true: self is the outer "this" value.
        }
    }
};
o.m();                    // Invoke the method m on the object o.

在巢狀函式f()內部,this關鍵字不等於物件o。這被廣泛認為是 JavaScript 語言的一個缺陷,因此重要的是要意識到這一點。上面的程式碼演示了一個常見的解決方法。在方法m內部,我們將this值分配給變數self,在巢狀函式f內部,我們可以使用self而不是this來引用包含的物件。

在 ES6 及更高版本中,另一個解決此問題的方法是將巢狀函式f轉換為箭頭函式,這樣將正確繼承this值。

const f = () => {
    this === o  // true, since arrow functions inherit this
};

將函式定義為表示式而不是語句的方式不會被提升,因此為了使這段程式碼正常工作,函式f的定義需要移動到方法m內部,以便在呼叫之前出現。

另一個解決方法是呼叫巢狀函式的bind()方法來定義一個新函式,該函式將隱式在指定物件上呼叫:

const f = (function() {
    this === o  // true, since we bound this function to the outer this
}).bind(this);

我們將在§8.7.5 中更詳細地討論bind()

8.2.3 建構函式呼叫

如果函式或方法呼叫之前帶有關鍵字new,那麼這是一個建構函式呼叫。(建構函式呼叫在§4.6 和§6.2.2 中介紹過,並且建構函式將在第九章中更詳細地討論。)建構函式呼叫在處理引數、呼叫上下文和返回值方面與常規函式和方法呼叫不同。

如果建構函式呼叫包括括號中的引數列表,則這些參數列達式將被計算並傳遞給函式,方式與函式和方法呼叫相同。雖然不常見,但您可以在建構函式呼叫中省略一對空括號。例如,以下兩行是等價的:

o = new Object();
o = new Object;

建構函式呼叫建立一個新的空物件,該物件繼承自建構函式的prototype屬性指定的物件。建構函式旨在初始化物件,這個新建立的物件被用作呼叫上下文,因此建構函式可以使用this關鍵字引用它。請注意,即使建構函式呼叫看起來像方法呼叫,新物件也被用作呼叫上下文。也就是說,在表示式new o.m()中,o不被用作呼叫上下文。

建構函式通常不使用return關鍵字。它們通常在初始化新物件後隱式返回,當它們到達函式體的末尾時。在這種情況下,新物件是建構函式呼叫表示式的值。然而,如果建構函式顯式使用return語句返回一個物件,則該物件成為呼叫表示式的值。如果建構函式使用沒有值的return,或者返回一個原始值,那麼返回值將被忽略,新物件將作為呼叫的值。

8.2.4 間接呼叫

JavaScript 函式是物件,和所有 JavaScript 物件一樣,它們有方法。其中兩個方法,call()apply(),間接呼叫函式。這兩種方法允許您明確指定呼叫的this值,這意味著您可以將任何函式作為任何物件的方法呼叫,即使它實際上不是該物件的方法。這兩種方法還允許您指定呼叫的引數。call()方法使用其自己的引數列表作為函式的引數,而apply()方法期望使用作為引數的值陣列。call()apply()方法在§8.7.4 中有詳細描述。

8.2.5 隱式函式呼叫

有各種 JavaScript 語言特性看起來不像函式呼叫,但會導致函式被呼叫。在編寫可能被隱式呼叫的函式時要特別小心,因為這些函式中的錯誤、副作用和效能問題比普通函式更難診斷和修復,因為從簡單檢查程式碼時可能不明顯它們何時被呼叫。

可能導致隱式函式呼叫的語言特性包括:

  • 如果物件定義了 getter 或 setter,則查詢或設定其屬性的值可能會呼叫這些方法。更多資訊請參見§6.10.6。

  • 當物件在字串上下文中使用(例如與字串連線時),會呼叫其toString()方法。類似地,當物件在數值上下文中使用時,會呼叫其valueOf()方法。詳細資訊請參見§3.9.3。

  • 當您遍歷可迭代物件的元素時,會發生許多方法呼叫。第十二章解釋了迭代器在函式呼叫級別上的工作原理,並演示瞭如何編寫這些方法,以便您可以定義自己的可迭代型別。

  • 標記模板字面量是一個偽裝成函式呼叫的函式。§14.5 演示瞭如何編寫可與模板字面量字串一起使用的函式。

  • 代理物件(在§14.7 中描述)的行為完全由函式控制。對這些物件的幾乎任何操作都會導致函式被呼叫。

8.3 函式引數和引數

JavaScript 函式定義不指定函式引數的預期型別,函式呼叫也不對傳遞的引數值進行任何型別檢查。事實上,JavaScript 函式呼叫甚至不檢查傳遞的引數數量。接下來的小節描述了當函式被呼叫時傳入的引數少於宣告的引數數量或多於宣告的引數數量時會發生什麼。它們還演示瞭如何顯式測試函式引數的型別,如果需要確保函式不會被不適當的引數呼叫。

8.3.1 可選引數和預設值

當函式被呼叫時傳入的引數少於宣告的引數數量時,額外的引數將被設定為它們的預設值,通常是undefined。編寫一些引數是可選的函式通常很有用。以下是一個例子:

// Append the names of the enumerable properties of object o to the
// array a, and return a.  If a is omitted, create and return a new array.
function getPropertyNames(o, a) {
    if (a === undefined) a = [];  // If undefined, use a new array
    for(let property in o) a.push(property);
    return a;
}

// getPropertyNames() can be invoked with one or two arguments:
let o = {x: 1}, p = {y: 2, z: 3};  // Two objects for testing
let a = getPropertyNames(o); // a == ["x"]; get o's properties in a new array
getPropertyNames(p, a);      // a == ["x","y","z"]; add p's properties to it

在這個函式的第一行使用if語句的地方,你可以以這種成語化的方式使用||運算子:

a = a || [];

回想一下§4.10.2 中提到的||運算子,如果第一個引數為真,則返回第一個引數,否則返回第二個引數。在這種情況下,如果將任何物件作為第二個引數傳遞,函式將使用該物件。但如果省略第二個引數(或傳遞null或另一個假值),則將使用一個新建立的空陣列。

注意,在設計具有可選引數的函式時,應確保將可選引數放在引數列表的末尾,以便可以省略它們。呼叫函式的程式設計師不能省略第一個引數並傳遞第二個引數:他們必須明確地將undefined作為第一個引數傳遞。

在 ES6 及更高版本中,你可以直接在函式引數列表中為每個引數定義預設值。只需在引數名稱後面加上等號和預設值,當沒有為該引數提供引數時將使用預設值:

// Append the names of the enumerable properties of object o to the
// array a, and return a.  If a is omitted, create and return a new array.
function getPropertyNames(o, a = []) {
    for(let property in o) a.push(property);
    return a;
}

引數預設表示式在呼叫函式時進行求值,而不是在定義函式時進行求值,因此每次呼叫getPropertyNames()函式時,都會建立一個新的空陣列並傳遞。² 如果引數預設值是常量(或類似[]{}的文字表示式),那麼函式的推理可能是最簡單的。但這不是必需的:你可以使用變數或函式呼叫來計算引數的預設值。一個有趣的情況是,對於具有多個引數的函式,可以使用前一個引數的值來定義其後引數的預設值:

// This function returns an object representing a rectangle's dimensions.
// If only width is supplied, make it twice as high as it is wide.
const rectangle = (width, height=width*2) => ({width, height});
rectangle(1)  // => { width: 1, height: 2 }

這段程式碼演示了引數預設值如何與箭頭函式一起使用。對於方法簡寫函式和所有其他形式的函式定義也是如此。

8.3.2 Rest 引數和可變長度引數列表

引數預設值使我們能夠編寫可以用比引數更少的引數呼叫的函式。Rest 引數使相反的情況成為可能:它們允許我們編寫可以用任意多個引數呼叫的函式。以下是一個期望一個或多個數字引數並返回最大值的示例函式:

function max(first=-Infinity, ...rest) {
    let maxValue = first; // Start by assuming the first arg is biggest
    // Then loop through the rest of the arguments, looking for bigger
    for(let n of rest) {
        if (n > maxValue) {
            maxValue = n;
        }
    }
    // Return the biggest
    return maxValue;
}

max(1, 10, 100, 2, 3, 1000, 4, 5, 6)  // => 1000

rest 引數由三個點前置,並且必須是函式宣告中的最後一個引數。當你使用 rest 引數呼叫函式時,你傳遞的引數首先被分配給非 rest 引數,然後任何剩餘的引數(即“剩餘”的引數)都儲存在一個陣列中,該陣列成為 rest 引數的值。這一點很重要:在函式體內,rest 引數的值始終是一個陣列。陣列可能為空,但 rest 引數永遠不會是undefined。(由此可知,為 rest 引數定義引數預設值從未有用過,也不合法。)

像前面的例子那樣可以接受任意數量引數的函式稱為可變引數函式可變引數函式vararg 函式。本書使用最口語化的術語varargs,這個術語可以追溯到 C 程式語言的早期。

不要混淆函式定義中定義 rest 引數的 ... 與 §8.3.4 中描述的展開運算子的 ...,後者可用於函式呼叫中。

8.3.3 Arguments 物件

Rest 引數是在 ES6 中引入 JavaScript 的。在該語言版本之前,可變引數函式是使用 Arguments 物件編寫的:在任何函式體內,識別符號 arguments 指的是該呼叫的 Arguments 物件。Arguments 物件是一個類似陣列的物件(參見 §7.9),允許按數字而不是名稱檢索傳遞給函式的引數值。以下是之前的 max() 函式,重寫以使用 Arguments 物件而不是 rest 引數:

function max(x) {
    let maxValue = -Infinity;
    // Loop through the arguments, looking for, and remembering, the biggest.
    for(let i = 0; i < arguments.length; i++) {
        if (arguments[i] > maxValue) maxValue = arguments[i];
    }
    // Return the biggest
    return maxValue;
}

max(1, 10, 100, 2, 3, 1000, 4, 5, 6)  // => 1000

Arguments 物件可以追溯到 JavaScript 最早的日子,並且攜帶一些奇怪的歷史包袱,使其在嚴格模式之外尤其難以最佳化和難以使用。你可能仍然會遇到使用 Arguments 物件的程式碼,但是在編寫任何新程式碼時應避免使用它。在重構舊程式碼時,如果遇到使用 arguments 的函式,通常可以用 ...args rest 引數來替換它。Arguments 物件的不幸遺產之一是,在嚴格模式下,arguments 被視為保留字,你不能使用該名稱宣告函式引數或區域性變數。

8.3.4 函式呼叫的展開運算子

展開運算子 ... 用於在期望單個值的上下文中解包或“展開”陣列(或任何其他可迭代物件,如字串)的元素。我們在 §7.1.2 中看到展開運算子與陣列文字一起使用。該運算子可以以相同的方式在函式呼叫中使用:

let numbers = [5, 2, 10, -1, 9, 100, 1];
Math.min(...numbers)  // => -1

請注意,... 不是真正的運算子,因為它不能被評估為產生一個值。相反,它是一種特殊的 JavaScript 語法,可用於陣列文字和函式呼叫中。

當我們在函式定義中而不是函式呼叫中使用相同的 ... 語法時,它的效果與展開運算子相反。正如我們在 §8.3.2 中看到的,使用 ... 在函式定義中將多個函式引數收集到一個陣列中。Rest 引數和展開運算子通常一起使用,如下面的函式,該函式接受一個函式引數,並返回一個用於測試的函式的版本:

// This function takes a function and returns a wrapped version
function timed(f) {
    return function(...args) {  // Collect args into a rest parameter array
        console.log(`Entering function ${f.name}`);
        let startTime = Date.now();
        try {
            // Pass all of our arguments to the wrapped function
            return f(...args);  // Spread the args back out again
        }
        finally {
            // Before we return the wrapped return value, print elapsed time.
            console.log(`Exiting ${f.name} after ${Date.now()-startTime}ms`);
        }
    };
}

// Compute the sum of the numbers between 1 and n by brute force
function benchmark(n) {
    let sum = 0;
    for(let i = 1; i <= n; i++) sum += i;
    return sum;
}

// Now invoke the timed version of that test function
timed(benchmark)(1000000) // => 500000500000; this is the sum of the numbers

8.3.5 將函式引數解構為引數

當你使用一系列引數值呼叫函式時,這些值最終被分配給函式定義中宣告的引數。函式呼叫的初始階段很像變數賦值。因此,我們可以使用解構賦值技術(參見 §3.10.3)與函式一起使用,這並不奇怪。

如果你定義一個帶有方括號內引數名稱的函式,那麼你告訴函式期望傳遞一個陣列值以用於每對方括號。在呼叫過程中,陣列引數將被解包到各個命名引數中。舉個例子,假設我們將 2D 向量表示為包含兩個數字的陣列,其中第一個元素是 X 座標,第二個元素是 Y 座標。使用這種簡單的資料結構,我們可以編寫以下函式來新增兩個向量:

function vectorAdd(v1, v2) {
    return [v1[0] + v2[0], v1[1] + v2[1]];
}
vectorAdd([1,2], [3,4])  // => [4,6]

如果我們將兩個向量引數解構為更清晰命名的引數,程式碼將更容易理解:

function vectorAdd([x1,y1], [x2,y2]) { // Unpack 2 arguments into 4 parameters
    return [x1 + x2, y1 + y2];
}
vectorAdd([1,2], [3,4])  // => [4,6]

同樣,如果你正在定義一個期望物件引數的函式,你可以解構該物件的引數。再次使用向量示例,假設我們將向量表示為具有xy引數的物件:

// Multiply the vector {x,y} by a scalar value
function vectorMultiply({x, y}, scalar) {
    return { x: x*scalar, y: y*scalar };
}
vectorMultiply({x: 1, y: 2}, 2)  // => {x: 2, y: 4}

將單個物件引數解構為兩個引數的示例相當清晰,因為我們使用的引數名稱與傳入物件的屬性名稱匹配。當你需要將具有一個名稱的屬性解構為具有不同名稱的引數時,語法會更冗長且更令人困惑。這裡是基於物件的向量的向量加法示例的實現:

function vectorAdd(
    {x: x1, y: y1}, // Unpack 1st object into x1 and y1 params
    {x: x2, y: y2}  // Unpack 2nd object into x2 and y2 params
)
{
    return { x: x1 + x2, y: y1 + y2 };
}
vectorAdd({x: 1, y: 2}, {x: 3, y: 4})  // => {x: 4, y: 6}

關於解構語法如{x:x1, y:y1},讓人難以記住哪些是屬性名稱,哪些是引數名稱。要記住解構賦值和解構函式呼叫的規則是,被宣告的變數或引數放在你期望值在物件字面量中的位置。因此,屬性名稱始終在冒號的左側,引數(或變數)名稱在右側。

你可以使用解構引數定義引數預設值。這裡是適用於 2D 或 3D 向量的向量乘法:

// Multiply the vector {x,y} or {x,y,z} by a scalar value
function vectorMultiply({x, y, z=0}, scalar) {
    return { x: x*scalar, y: y*scalar, z: z*scalar };
}
vectorMultiply({x: 1, y: 2}, 2)  // => {x: 2, y: 4, z: 0}

一些語言(如 Python)允許函式的呼叫者以name=value形式指定引數呼叫函式,當存在許多可選引數或引數列表足夠長以至於難以記住正確順序時,這是很方便的。JavaScript 不直接允許這樣做,但你可以透過將物件引數解構為函式引數來近似實現。考慮一個函式,它從一個陣列中複製指定數量的元素到另一個陣列中,併為每個陣列指定可選的起始偏移量。由於有五個可能的引數,其中一些具有預設值,並且呼叫者很難記住傳遞引數的順序,我們可以像這樣定義和呼叫arraycopy()函式:

function arraycopy({from, to=from, n=from.length, fromIndex=0, toIndex=0}) {
    let valuesToCopy = from.slice(fromIndex, fromIndex + n);
    to.splice(toIndex, 0, ...valuesToCopy);
    return to;
}
let a = [1,2,3,4,5], b = [9,8,7,6,5];
arraycopy({from: a, n: 3, to: b, toIndex: 4}) // => [9,8,7,6,1,2,3,5]

當你解構一個陣列時,你可以為被解構的陣列中的額外值定義一個剩餘引數。方括號內的剩餘引數與函式的真正剩餘引數完全不同:

// This function expects an array argument. The first two elements of that
// array are unpacked into the x and y parameters. Any remaining elements
// are stored in the coords array. And any arguments after the first array
// are packed into the rest array.
function f([x, y, ...coords], ...rest) {
    return [x+y, ...rest, ...coords];  // Note: spread operator here
}
f([1, 2, 3, 4], 5, 6)   // => [3, 5, 6, 3, 4]

在 ES2018 中,當你解構一個物件時,也可以使用剩餘引數。該剩餘引數的值將是一個物件,其中包含未被解構的任何屬性。物件剩餘引數通常與物件展開運算子一起使用,這也是 ES2018 的一個新功能:

// Multiply the vector {x,y} or {x,y,z} by a scalar value, retain other props
function vectorMultiply({x, y, z=0, ...props}, scalar) {
    return { x: x*scalar, y: y*scalar, z: z*scalar, ...props };
}
vectorMultiply({x: 1, y: 2, w: -1}, 2)  // => {x: 2, y: 4, z: 0, w: -1}

最後,請記住,除了解構引數物件和陣列外,你還可以解構物件陣列、具有陣列屬性的物件以及具有物件屬性的物件,實際上可以解構到任何深度。考慮表示圓的圖形程式碼,其中圓被表示為具有xy半徑顏色屬性的物件,其中顏色屬性是紅色、綠色和藍色顏色分量的陣列。你可以定義一個函式,該函式期望傳遞一個圓物件,但將該圓物件解構為六個單獨的引數:

function drawCircle({x, y, radius, color: [r, g, b]}) {
    // Not yet implemented
}

如果函式引數解構比這更復雜,我發現程式碼變得更難閱讀,而不是更簡單。有時,明確地訪問物件屬性和陣列索引會更清晰。

8.3.6 引數型別

JavaScript 方法引數沒有宣告型別,並且不對傳遞給函式的值執行型別檢查。透過為函式引數選擇描述性名稱並在每個函式的註釋中仔細記錄它們,可以幫助使程式碼自我描述。(或者,參見§17.8 中允許你在常規 JavaScript 之上新增型別檢查的語言擴充套件。)

如 §3.9 中所述,JavaScript 根據需要執行自由的型別轉換。因此,如果您編寫一個期望字串引數的函式,然後使用其他型別的值呼叫該函式,那麼當函式嘗試將其用作字串時,您傳遞的值將被簡單地轉換為字串。所有原始型別都可以轉換為字串,所有物件都有 toString() 方法(不一定是有用的),因此在這種情況下不會發生錯誤。

然而,這並不總是正確的。再次考慮之前顯示的 arraycopy() 方法。它期望一個或兩個陣列引數,並且如果這些引數的型別錯誤,則會失敗。除非您正在編寫一個只會從程式碼附近的部分呼叫的私有函式,否則值得新增程式碼來檢查引數的型別。當傳遞錯誤的值時,最好讓函式立即和可預測地失敗,而不是開始執行然後在稍後失敗並顯示可能不清晰的錯誤訊息。這裡有一個執行型別檢查的示例函式:

// Return the sum of the elements an iterable object a.
// The elements of a must all be numbers.
function sum(a) {
    let total = 0;
    for(let element of a) { // Throws TypeError if a is not iterable
        if (typeof element !== "number") {
            throw new TypeError("sum(): elements must be numbers");
        }
        total += element;
    }
    return total;
}
sum([1,2,3])    // => 6
sum(1, 2, 3);   // !TypeError: 1 is not iterable
sum([1,2,"3"]); // !TypeError: element 2 is not a number

8.4 函式作為值

函式最重要的特點是它們可以被定義和呼叫。函式的定義和呼叫是 JavaScript 和大多數其他程式語言的語法特性。然而,在 JavaScript 中,函式不僅僅是語法,還是值,這意味著它們可以被分配給變數,儲存在物件的屬性或陣列的元素中,作為函式的引數傳遞等。³

要理解函式如何既可以是 JavaScript 資料又可以是 JavaScript 語法,請考慮這個函式定義:

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

這個定義建立了一個新的函式物件並將其分配給變數 square。函式的名稱實際上並不重要;它只是一個指向函式物件的變數的名稱。該函式可以分配給另一個變數,仍然可以正常工作:

let s = square;  // Now s refers to the same function that square does
square(4)        // => 16
s(4)             // => 16

函式也可以被分配給物件屬性而不是變數。正如我們之前討論過的,當我們這樣做時,我們將這些函式稱為“方法”:

let o = {square: function(x) { return x*x; }}; // An object literal
let y = o.square(16);                          // y == 256

函式甚至不需要名稱,比如當它們被分配給陣列元素時:

let a = [x => x*x, 20]; // An array literal
a0              // => 400

最後一個示例的語法看起來很奇怪,但仍然是一個合法的函式呼叫表示式!

作為將函式視為值的有用性的一個例子,考慮 Array.sort() 方法。該方法對陣列的元素進行排序。由於有許多可能的排序順序(數字順序、字母順序、日期順序、升序、降序等),sort() 方法可以選擇接受一個函式作為引數,告訴它如何執行排序。這個函式的工作很簡單:對於傳遞給它的任何兩個值,它返回一個指定哪個元素在排序後的陣列中首先出現的值。這個函式引數使 Array.sort() 變得非常通用和無限靈活;它可以將任何型別的資料按照任何可想象的順序進行排序。示例在 §7.8.6 中展示。

示例 8-1 展示了當函式被用作值時可以做的事情。這個例子可能有點棘手,但註釋解釋了發生了什麼。

示例 8-1。將函式用作資料
// We define some simple functions here
function add(x,y) { return x + y; }
function subtract(x,y) { return x - y; }
function multiply(x,y) { return x * y; }
function divide(x,y) { return x / y; }

// Here's a function that takes one of the preceding functions
// as an argument and invokes it on two operands
function operate(operator, operand1, operand2) {
    return operator(operand1, operand2);
}

// We could invoke this function like this to compute the value (2+3) + (4*5):
let i = operate(add, operate(add, 2, 3), operate(multiply, 4, 5));

// For the sake of the example, we implement the simple functions again,
// this time within an object literal;
const operators = {
    add:      (x,y) => x+y,
    subtract: (x,y) => x-y,
    multiply: (x,y) => x*y,
    divide:   (x,y) => x/y,
    pow:      Math.pow  // This works for predefined functions too
};

// This function takes the name of an operator, looks up that operator
// in the object, and then invokes it on the supplied operands. Note
// the syntax used to invoke the operator function.
function operate2(operation, operand1, operand2) {
    if (typeof operators[operation] === "function") {
        return operatorsoperation;
    }
    else throw "unknown operator";
}

operate2("add", "hello", operate2("add", " ", "world")) // => "hello world"
operate2("pow", 10, 2)  // => 100

8.4.1 定義自己的函式屬性

在 JavaScript 中,函式不是原始值,而是一種特殊的物件,這意味著函式可以有屬性。當一個函式需要一個“靜態”變數,其值在呼叫之間保持不變時,通常方便使用函式本身的屬性。例如,假設你想編寫一個函式,每次呼叫時都返回一個唯一的整數。該函式可能兩次返回相同的值。為了管理這個問題,函式需要跟蹤它已經返回的值,並且這個資訊必須在函式呼叫之間保持不變。你可以將這個資訊儲存在一個全域性變數中,但這是不必要的,因為這個資訊只被函式本身使用。最好將資訊儲存在 Function 物件的屬性中。下面是一個示例,每次呼叫時都返回一個唯一的整數:

// Initialize the counter property of the function object.
// Function declarations are hoisted so we really can
// do this assignment before the function declaration.
uniqueInteger.counter = 0;

// This function returns a different integer each time it is called.
// It uses a property of itself to remember the next value to be returned.
function uniqueInteger() {
    return uniqueInteger.counter++;  // Return and increment counter property
}
uniqueInteger()  // => 0
uniqueInteger()  // => 1

舉個例子,考慮下面的factorial()函式,它利用自身的屬性(將自身視為陣列)來快取先前計算的結果:

// Compute factorials and cache results as properties of the function itself.
function factorial(n) {
    if (Number.isInteger(n) && n > 0) {           // Positive integers only
        if (!(n in factorial)) {                  // If no cached result
            factorial[n] = n * factorial(n-1);    // Compute and cache it
        }
        return factorial[n];                      // Return the cached result
    } else {
        return NaN;                               // If input was bad
    }
}
factorial[1] = 1;  // Initialize the cache to hold this base case.
factorial(6)  // => 720
factorial[5]  // => 120; the call above caches this value

8.5 函式作為名稱空間

在函式內宣告的變數在函式外部是不可見的。因此,有時候定義一個函式僅僅作為一個臨時的名稱空間是很有用的,你可以在其中定義變數而不會使全域性名稱空間混亂。

例如,假設你有一段 JavaScript 程式碼塊,你想在許多不同的 JavaScript 程式中使用(或者對於客戶端 JavaScript,在許多不同的網頁上使用)。假設這段程式碼,像大多數程式碼一樣,定義變數來儲存計算的中間結果。問題在於,由於這段程式碼將在許多不同的程式中使用,你不知道它建立的變數是否會與使用它的程式建立的變數發生衝突。解決方案是將程式碼塊放入一個函式中,然後呼叫該函式。這樣,原本將是全域性的變數變為函式的區域性變數:

function chunkNamespace() {
    // Chunk of code goes here
    // Any variables defined in the chunk are local to this function
    // instead of cluttering up the global namespace.
}
chunkNamespace();  // But don't forget to invoke the function!

這段程式碼只定義了一個全域性變數:函式名chunkNamespace。如果即使定義一個屬性也太多了,你可以在單個表示式中定義並呼叫一個匿名函式:

(function() {  // chunkNamespace() function rewritten as an unnamed expression.
    // Chunk of code goes here
}());          // End the function literal and invoke it now.

定義和呼叫一個函式的單個表示式的技術經常被使用,已經成為慣用語,並被稱為“立即呼叫函式表示式”。請注意前面程式碼示例中括號的使用。在function之前的開括號是必需的,因為沒有它,JavaScript 直譯器會嘗試將function關鍵字解析為函式宣告語句。有了括號,直譯器正確地將其識別為函式定義表示式。前導括號還有助於人類讀者識別何時定義一個函式以立即呼叫,而不是為以後使用而定義。

當我們在名稱空間函式內部定義一個或多個函式,並使用該名稱空間內的變數,然後將它們作為名稱空間函式的返回值傳遞出去時,函式作為名稱空間的用法變得非常有用。這樣的函式被稱為閉包,它們是下一節的主題。

8.6 閉包

像大多數現代程式語言一樣,JavaScript 使用詞法作用域。這意味著函式在定義時使用的變數作用域,而不是在呼叫時使用的變數作用域。為了實現詞法作用域,JavaScript 函式物件的內部狀態必須包括函式的程式碼以及函式定義所在的作用域的引用。在電腦科學文獻中,函式物件和作用域(一組變數繫結)的組合,用於解析函式變數的作用域,被稱為閉包

從技術上講,所有的 JavaScript 函式都是閉包,但由於大多數函式是從定義它們的同一作用域中呼叫的,通常並不重要閉包是否涉及其中。當閉包從與其定義所在不同的作用域中呼叫時,閉包就變得有趣起來。這種情況最常見於從定義它的函式中返回巢狀函式物件時。有許多強大的程式設計技術涉及到這種巢狀函式閉包,它們在 JavaScript 程式設計中的使用變得相對常見。當你第一次遇到閉包時,它們可能看起來令人困惑,但重要的是你要足夠了解它們以便舒適地使用它們。

理解閉包的第一步是複習巢狀函式的詞法作用域規則。考慮以下程式碼:

let scope = "global scope";          // A global variable
function checkscope() {
    let scope = "local scope";       // A local variable
    function f() { return scope; }   // Return the value in scope here
    return f();
}
checkscope()                         // => "local scope"

checkscope()函式宣告瞭一個區域性變數,然後定義並呼叫一個返回該變數值的函式。你應該清楚為什麼呼叫checkscope()會返回“local scope”。現在,讓我們稍微改變一下程式碼。你能告訴這段程式碼會返回什麼嗎?

let scope = "global scope";          // A global variable
function checkscope() {
    let scope = "local scope";       // A local variable
    function f() { return scope; }   // Return the value in scope here
    return f;
}
let s = checkscope()();              // What does this return?

在這段程式碼中,一對括號已經從checkscope()內部移到了外部。現在,checkscope()不再呼叫巢狀函式並返回其結果,而是直接返回巢狀函式物件本身。當我們在定義它的函式之外呼叫該巢狀函式(在程式碼的最後一行中的第二對括號中)時會發生什麼?

記住詞法作用域的基本規則:JavaScript 函式是在定義它們的作用域中執行的。巢狀函式f()是在一個作用域中定義的,該作用域中變數scope繫結到值“local scope”。當執行f時,這個繫結仍然有效,無論從哪裡執行。因此,前面程式碼示例的最後一行返回“local scope”,而不是“global scope”。這就是閉包的令人驚訝和強大的本質:它們捕獲了它們所定義的外部函式的區域性變數(和引數)繫結。

在§8.4.1 中,我們定義了一個uniqueInteger()函式,該函式使用函式本身的屬性來跟蹤下一個要返回的值。這種方法的一個缺點是,有錯誤或惡意程式碼可能會重置計數器或將其設定為非整數,導致uniqueInteger()函式違反其“unique”或“integer”部分的約定。閉包捕獲了單個函式呼叫的區域性變數,並可以將這些變數用作私有狀態。下面是我們如何使用立即呼叫函式表示式來重新編寫uniqueInteger(),以定義一個名稱空間和使用該名稱空間來保持其狀態私有的閉包:

let uniqueInteger = (function() {  // Define and invoke
    let counter = 0;               // Private state of function below
    return function() { return counter++; };
}());
uniqueInteger()  // => 0
uniqueInteger()  // => 1

要理解這段程式碼,你必須仔細閱讀它。乍一看,程式碼的第一行看起來像是將一個函式賦給變數uniqueInteger。實際上,程式碼正在定義並呼叫一個函式(第一行的開括號提示了這一點),因此將函式的返回值賦給了uniqueInteger。現在,如果我們研究函式體,我們會發現它的返回值是另一個函式。正是這個巢狀函式物件被賦給了uniqueInteger。巢狀函式可以訪問其作用域中的變數,並且可以使用外部函式中定義的counter變數。一旦外部函式返回,其他程式碼就無法看到counter變數:內部函式對其具有獨佔訪問許可權。

counter這樣的私有變數不一定是單個閉包的專有:完全可以在同一個外部函式中定義兩個或更多個巢狀函式並共享相同的作用域。考慮以下程式碼:

function counter() {
    let n = 0;
    return {
        count: function() { return n++; },
        reset: function() { n = 0; }
    };
}

let c = counter(), d = counter();   // Create two counters
c.count()                           // => 0
d.count()                           // => 0: they count independently
c.reset();                          // reset() and count() methods share state
c.count()                           // => 0: because we reset c
d.count()                           // => 1: d was not reset

counter()函式返回一個“計數器”物件。這個物件有兩個方法:count()返回下一個整數,reset()重置內部狀態。首先要理解的是,這兩個方法共享對私有變數n的訪問。其次要理解的是,每次呼叫counter()都會建立一個新的作用域——獨立於先前呼叫使用的作用域,並在該作用域內建立一個新的私有變數。因此,如果您兩次呼叫counter(),您將得到兩個具有不同私有變數的計數器物件。在一個計數器物件上呼叫count()reset()對另一個沒有影響。

值得注意的是,您可以將閉包技術與屬性的 getter 和 setter 結合使用。下面這個counter()函式的版本是§6.10.6 中出現的程式碼的變體,但它使用閉包來實現私有狀態,而不是依賴於常規物件屬性:

function counter(n) {  // Function argument n is the private variable
    return {
        // Property getter method returns and increments private counter var.
        get count() { return n++; },
        // Property setter doesn't allow the value of n to decrease
        set count(m) {
            if (m > n) n = m;
            else throw Error("count can only be set to a larger value");
        }
    };
}

let c = counter(1000);
c.count            // => 1000
c.count            // => 1001
c.count = 2000;
c.count            // => 2000
c.count = 2000;    // !Error: count can only be set to a larger value

注意,這個counter()函式的版本並沒有宣告一個區域性變數,而是隻是使用其引數n來儲存屬性訪問方法共享的私有狀態。這允許counter()的呼叫者指定私有變數的初始值。

示例 8-2 是透過我們一直在演示的閉包技術對共享私有狀態進行泛化的一個例子。這個示例定義了一個addPrivateProperty()函式,該函式定義了一個私有變數和兩個巢狀函式來獲取和設定該變數的值。它將這些巢狀函式作為您指定物件的方法新增。

示例 8-2. 使用閉包的私有屬性訪問方法
// This function adds property accessor methods for a property with
// the specified name to the object o. The methods are named get<name>
// and set<name>. If a predicate function is supplied, the setter
// method uses it to test its argument for validity before storing it.
// If the predicate returns false, the setter method throws an exception.
//
// The unusual thing about this function is that the property value
// that is manipulated by the getter and setter methods is not stored in
// the object o. Instead, the value is stored only in a local variable
// in this function. The getter and setter methods are also defined
// locally to this function and therefore have access to this local variable.
// This means that the value is private to the two accessor methods, and it
// cannot be set or modified except through the setter method.
function addPrivateProperty(o, name, predicate) {
    let value;  // This is the property value

    // The getter method simply returns the value.
    o[`get${name}`] = function() { return value; };

    // The setter method stores the value or throws an exception if
    // the predicate rejects the value.
    o[`set${name}`] = function(v) {
        if (predicate && !predicate(v)) {
            throw new TypeError(`set${name}: invalid value ${v}`);
        } else {
            value = v;
        }
    };
}

// The following code demonstrates the addPrivateProperty() method.
let o = {};  // Here is an empty object

// Add property accessor methods getName and setName()
// Ensure that only string values are allowed
addPrivateProperty(o, "Name", x => typeof x === "string");

o.setName("Frank");       // Set the property value
o.getName()               // => "Frank"
o.setName(0);             // !TypeError: try to set a value of the wrong type

現在我們已經看到了許多例子,其中兩個閉包在同一個作用域中定義並共享對相同私有變數或變數的訪問。這是一個重要的技術,但同樣重要的是要認識到閉包無意中共享對不應共享的變數的訪問。考慮以下程式碼:

// This function returns a function that always returns v
function constfunc(v) { return () => v; }

// Create an array of constant functions:
let funcs = [];
for(var i = 0; i < 10; i++) funcs[i] = constfunc(i);

// The function at array element 5 returns the value 5.
funcs[5]()    // => 5

在處理像這樣使用迴圈建立多個閉包的程式碼時,一個常見的錯誤是嘗試將迴圈移到定義閉包的函式內部。例如,考慮以下程式碼:

// Return an array of functions that return the values 0-9
function constfuncs() {
    let funcs = [];
    for(var i = 0; i < 10; i++) {
        funcs[i] = () => i;
    }
    return funcs;
}

let funcs = constfuncs();
funcs[5]()    // => 10; Why doesn't this return 5?

這段程式碼建立了 10 個閉包並將它們儲存在一個陣列中。這些閉包都在同一個函式呼叫中定義,因此它們共享對變數i的訪問。當constfuncs()返回時,變數i的值為 10,所有 10 個閉包都共享這個值。因此,返回的函式陣列中的所有函式都返回相同的值,這並不是我們想要的。重要的是要記住,與閉包相關聯的作用域是“活動的”。巢狀函式不會建立作用域的私有副本,也不會對變數繫結進行靜態快照。從根本上說,這裡的問題是使用var宣告的變數在整個函式中都被定義。我們的for迴圈使用var i宣告迴圈變數,因此變數i在整個函式中被定義,而不是更窄地限制在迴圈體內。這段程式碼展示了 ES5 及之前版本中常見的一類錯誤,但 ES6 引入的塊作用域變數解決了這個問題。如果我們只是用letconst替換var,問題就消失了。因為letconst是塊作用域的,迴圈的每次迭代都定義了一個獨立於所有其他迭代的作用域,並且每個作用域都有自己獨立的i繫結。

寫閉包時要記住的另一件事是,this是 JavaScript 關鍵字,而不是變數。正如前面討論的,箭頭函式繼承了包含它們的函式的this值,但使用function關鍵字定義的函式不會。因此,如果您編寫一個需要使用其包含函式的this值的閉包,您應該在返回之前使用箭頭函式或呼叫bind(),或將外部this值分配給閉包將繼承的變數:

const self = this;  // Make the this value available to nested functions

8.7 函式屬性、方法和建構函式

我們已經看到函式在 JavaScript 程式中是值。當應用於函式時,typeof運算子返回字串“function”,但函式實際上是 JavaScript 物件的一種特殊型別。由於函式是物件,它們可以像任何其他物件一樣具有屬性和方法。甚至有一個Function()建構函式來建立新的函式物件。接下來的小節記錄了lengthnameprototype屬性;call()apply()bind()toString()方法;以及Function()建構函式。

8.7.1 length 屬性

函式的只讀length屬性指定函式的arity——它在引數列表中宣告的引數數量,通常是函式期望的引數數量。如果函式有一個剩餘引數,那麼這個引數不會計入length屬性的目的。

8.7.2 名稱屬性

函式的只讀name屬性指定函式在定義時使用的名稱,如果它是用名稱定義的,或者在建立時未命名的函式表示式被分配給的變數或屬性的名稱。當編寫除錯或錯誤訊息時,此屬性非常有用。

8.7.3 prototype 屬性

所有函式,除了箭頭函式,都有一個prototype屬性,指向一個稱為原型物件的物件。每個函式都有一個不同的原型物件。當一個函式被用作建構函式時,新建立的物件會從原型物件繼承屬性。原型和prototype屬性在§6.2.3 中討論過,並將在第九章中再次涉及。

8.7.4 call()和 apply()方法

call()apply()允許您間接呼叫(§8.2.4)一個函式,就好像它是另一個物件的方法一樣。call()apply()的第一個引數是要呼叫函式的物件;這個引數是呼叫上下文,並在函式體內成為this關鍵字的值。要將函式f()作為物件o的方法呼叫(不傳遞引數),可以使用call()apply()

f.call(o);
f.apply(o);

這兩行程式碼中的任何一行與以下程式碼類似(假設o尚未具有名為m的屬性):

o.m = f;     // Make f a temporary method of o.
o.m();       // Invoke it, passing no arguments.
delete o.m;  // Remove the temporary method.

請記住,箭頭函式繼承了定義它們的上下文的this值。這不能透過call()apply()方法覆蓋。如果在箭頭函式上呼叫這些方法之一,第一個引數實際上會被忽略。

在第一個呼叫上下文引數之後的任何call()引數都是傳遞給被呼叫函式的值(對於箭頭函式,這些引數不會被忽略)。例如,要向函式f()傳遞兩個數字,並將其作為物件o的方法呼叫,可以使用以下程式碼:

f.call(o, 1, 2);

apply()方法類似於call()方法,只是要傳遞給函式的引數被指定為一個陣列:

f.apply(o, [1,2]);

如果一個函式被定義為接受任意數量的引數,apply() 方法允許你在任意長度的陣列內容上呼叫該函式。在 ES6 及更高版本中,我們可以直接使用擴充套件運算子,但你可能會看到使用 apply() 而不是擴充套件運算子的 ES5 程式碼。例如,要在不使用擴充套件運算子的情況下找到陣列中的最大數,你可以使用 apply() 方法將陣列的元素傳遞給 Math.max() 函式:

let biggest = Math.max.apply(Math, arrayOfNumbers);

下面定義的 trace() 函式類似於 §8.3.4 中定義的 timed() 函式,但它適用於方法而不是函式。它使用 apply() 方法而不是擴充套件運算子,透過這樣做,它能夠以與包裝方法相同的引數和 this 值呼叫被包裝的方法:

// Replace the method named m of the object o with a version that logs
// messages before and after invoking the original method.
function trace(o, m) {
    let original = o[m];         // Remember original method in the closure.
    o[m] = function(...args) {   // Now define the new method.
        console.log(new Date(), "Entering:", m);      // Log message.
        let result = original.apply(this, args);      // Invoke original.
        console.log(new Date(), "Exiting:", m);       // Log message.
        return result;                                // Return result.
    };
}

8.7.5 bind() 方法

bind() 的主要目的是將函式繫結到物件。當你在函式 f 上呼叫 bind() 方法並傳遞一個物件 o 時,該方法會返回一個新函式。呼叫新函式(作為函式)會將原始函式 f 作為 o 的方法呼叫。傳遞給新函式的任何引數都會傳遞給原始函式。例如:

function f(y) { return this.x + y; } // This function needs to be bound
let o = { x: 1 };                    // An object we'll bind to
let g = f.bind(o);                   // Calling g(x) invokes f() on o
g(2)                                 // => 3
let p = { x: 10, g };                // Invoke g() as a method of this object
p.g(2)                               // => 3: g is still bound to o, not p.

箭頭函式從定義它們的環境繼承它們的 this 值,並且該值不能被 bind() 覆蓋,因此如果前面程式碼中的函式 f() 被定義為箭頭函式,繫結將不起作用。然而,呼叫 bind() 最常見的用例是使非箭頭函式的行為類似箭頭函式,因此在實踐中,對繫結箭頭函式的限制並不是問題。

bind() 方法不僅僅是將函式繫結到物件,它還可以執行部分應用:在第一個引數之後傳遞給 bind() 的任何引數都與 this 值一起繫結。bind() 的這種部分應用特性適用於箭頭函式。部分應用是函數語言程式設計中的常見技術,有時被稱為柯里化。以下是 bind() 方法用於部分應用的一些示例:

let sum = (x,y) => x + y;      // Return the sum of 2 args
let succ = sum.bind(null, 1);  // Bind the first argument to 1
succ(2)  // => 3: x is bound to 1, and we pass 2 for the y argument

function f(y,z) { return this.x + y + z; }
let g = f.bind({x: 1}, 2);     // Bind this and y
g(3)     // => 6: this.x is bound to 1, y is bound to 2 and z is 3

bind() 返回的函式的 name 屬性是呼叫 bind() 的函式的名稱屬性,字首為“bound”。

8.7.6 toString() 方法

像所有 JavaScript 物件一樣,函式有一個 toString() 方法。ECMAScript 規範要求該方法返回一個遵循函式宣告語法的字串。實際上,大多數(但不是所有)實現這個 toString() 方法的實現會返回函式的完整原始碼。內建函式通常返回一個包含類似“[native code]”的字串作為函式體的字串。

8.7.7 Function() 建構函式

因為函式是物件,所以有一個 Function() 建構函式可用於建立新函式:

const f = new Function("x", "y", "return x*y;");

這行程式碼建立了一個新函式,它與使用熟悉語法定義的函式更或多少等效:

const f = function(x, y) { return x*y; };

Function() 建構函式期望任意數量的字串引數。最後一個引數是函式體的文字;它可以包含任意 JavaScript 語句,用分號分隔。建構函式的所有其他引數都是指定函式引數名稱的字串。如果你定義一個不帶引數的函式,你只需將一個字串(函式體)傳遞給建構函式。

注意 Function() 建構函式沒有傳遞任何指定建立的函式名稱的引數。與函式字面量一樣,Function() 建構函式建立匿名函式。

有幾點很重要需要了解關於 Function() 建構函式:

  • Function() 建構函式允許在執行時動態建立和編譯 JavaScript 函式。

  • Function()建構函式解析函式體並在每次呼叫時建立一個新的函式物件。如果建構函式的呼叫出現在迴圈中或在頻繁呼叫的函式內部,這個過程可能效率低下。相比之下,在迴圈中出現的巢狀函式和函式表示式在遇到時不會重新編譯。

  • 關於Function()建構函式的最後一個非常重要的觀點是,它建立的函式不使用詞法作用域;相反,它們總是被編譯為頂級函式,如下面的程式碼所示:

    let scope = "global";
    function constructFunction() {
        let scope = "local";
        return new Function("return scope");  // Doesn't capture local scope!
    }
    // This line returns "global" because the function returned by the
    // Function() constructor does not use the local scope.
    constructFunction()()  // => "global"
    

Function()建構函式最好被視為eval()的全域性作用域版本(參見§4.12.2),它在自己的私有作用域中定義新的變數和函式。你可能永遠不需要在你的程式碼中使用這個建構函式。

8.8 函數語言程式設計

JavaScript 不像 Lisp 或 Haskell 那樣是一種函數語言程式設計語言,但 JavaScript 可以將函式作為物件進行操作的事實意味著我們可以在 JavaScript 中使用函數語言程式設計技術。陣列方法如map()reduce()特別適合函數語言程式設計風格。接下來的部分演示了 JavaScript 中函數語言程式設計的技術。它們旨在探索 JavaScript 函式的強大功能,而不是規範良好的程式設計風格。

8.8.1 使用函式處理陣列

假設我們有一個數字陣列,我們想要計算這些值的均值和標準差。我們可以像這樣以非函式式的方式進行:

let data = [1,1,3,5,5];  // This is our array of numbers

// The mean is the sum of the elements divided by the number of elements
let total = 0;
for(let i = 0; i < data.length; i++) total += data[i];
let mean = total/data.length;  // mean == 3; The mean of our data is 3

// To compute the standard deviation, we first sum the squares of
// the deviation of each element from the mean.
total = 0;
for(let i = 0; i < data.length; i++) {
    let deviation = data[i] - mean;
    total += deviation * deviation;
}
let stddev = Math.sqrt(total/(data.length-1));  // stddev == 2

我們可以使用陣列方法map()reduce()以簡潔的函式式風格執行相同的計算,如下所示(參見§7.8.1 回顧這些方法):

// First, define two simple functions
const sum = (x,y) => x+y;
const square = x => x*x;

// Then use those functions with Array methods to compute mean and stddev
let data = [1,1,3,5,5];
let mean = data.reduce(sum)/data.length;  // mean == 3
let deviations = data.map(x => x-mean);
let stddev = Math.sqrt(deviations.map(square).reduce(sum)/(data.length-1));
stddev  // => 2

這個新版本的程式碼看起來與第一個版本非常不同,但仍然在物件上呼叫方法,因此仍然保留了一些物件導向的約定。讓我們編寫map()reduce()方法的函式式版本:

const map = function(a, ...args) { return a.map(...args); };
const reduce = function(a, ...args) { return a.reduce(...args); };

有了這些定義的map()reduce()函式,我們現在計算均值和標準差的程式碼如下:

const sum = (x,y) => x+y;
const square = x => x*x;

let data = [1,1,3,5,5];
let mean = reduce(data, sum)/data.length;
let deviations = map(data, x => x-mean);
let stddev = Math.sqrt(reduce(map(deviations, square), sum)/(data.length-1));
stddev  // => 2

8.8.2 高階函式

高階函式是一個操作函式的函式,它接受一個或多個函式作為引數並返回一個新函式。這裡有一個例子:

// This higher-order function returns a new function that passes its
// arguments to f and returns the logical negation of f's return value;
function not(f) {
    return function(...args) {             // Return a new function
        let result = f.apply(this, args);  // that calls f
        return !result;                    // and negates its result.
    };
}

const even = x => x % 2 === 0; // A function to determine if a number is even
const odd = not(even);         // A new function that does the opposite
[1,1,3,5,5].every(odd)         // => true: every element of the array is odd

這個not()函式是一個高階函式,因為它接受一個函式引數並返回一個新函式。再舉一個例子,考慮接下來的mapper()函式。它接受一個函式引數並返回一個使用該函式將一個陣列對映到另一個陣列的新函式。這個函式使用了之前定義的map()函式,你需要理解這兩個函式的不同之處很重要:

// Return a function that expects an array argument and applies f to
// each element, returning the array of return values.
// Contrast this with the map() function from earlier.
function mapper(f) {
    return a => map(a, f);
}

const increment = x => x+1;
const incrementAll = mapper(increment);
incrementAll([1,2,3])  // => [2,3,4]

這裡是另一個更一般的例子,它接受兩個函式fg,並返回一個計算f(g())的新函式:

// Return a new function that computes f(g(...)).
// The returned function h passes all of its arguments to g, then passes
// the return value of g to f, then returns the return value of f.
// Both f and g are invoked with the same this value as h was invoked with.
function compose(f, g) {
    return function(...args) {
        // We use call for f because we're passing a single value and
        // apply for g because we're passing an array of values.
        return f.call(this, g.apply(this, args));
    };
}

const sum = (x,y) => x+y;
const square = x => x*x;
compose(square, sum)(2,3)  // => 25; the square of the sum

在接下來的部分中定義的partial()memoize()函式是另外兩個重要的高階函式。

8.8.3 函式的部分應用

函式fbind()方法(參見§8.7.5)返回一個在指定上下文中呼叫f並帶有指定引數集的新函式。我們說它將函式繫結到一個物件並部分應用引數。bind()方法在左側部分應用引數,也就是說,你傳遞給bind()的引數被放在傳遞給原始函式的引數列表的開頭。但也可以在右側部分應用引數:

// The arguments to this function are passed on the left
function partialLeft(f, ...outerArgs) {
    return function(...innerArgs) { // Return this function
        let args = [...outerArgs, ...innerArgs]; // Build the argument list
        return f.apply(this, args);              // Then invoke f with it
    };
}

// The arguments to this function are passed on the right
function partialRight(f, ...outerArgs) {
    return function(...innerArgs) {  // Return this function
        let args = [...innerArgs, ...outerArgs]; // Build the argument list
        return f.apply(this, args);              // Then invoke f with it
    };
}

// The arguments to this function serve as a template. Undefined values
// in the argument list are filled in with values from the inner set.
function partial(f, ...outerArgs) {
    return function(...innerArgs) {
        let args = [...outerArgs]; // local copy of outer args template
        let innerIndex=0;          // which inner arg is next
        // Loop through the args, filling in undefined values from inner args
        for(let i = 0; i < args.length; i++) {
            if (args[i] === undefined) args[i] = innerArgs[innerIndex++];
        }
        // Now append any remaining inner arguments
        args.push(...innerArgs.slice(innerIndex));
        return f.apply(this, args);
    };
}

// Here is a function with three arguments
const f = function(x,y,z) { return x * (y - z); };
// Notice how these three partial applications differ
partialLeft(f, 2)(3,4)         // => -2: Bind first argument: 2 * (3 - 4)
partialRight(f, 2)(3,4)        // =>  6: Bind last argument: 3 * (4 - 2)
partial(f, undefined, 2)(3,4)  // => -6: Bind middle argument: 3 * (2 - 4)

這些部分應用函式使我們能夠輕鬆地從已定義的函式中定義有趣的函式。以下是一些示例:

const increment = partialLeft(sum, 1);
const cuberoot = partialRight(Math.pow, 1/3);
cuberoot(increment(26))  // => 3

當我們將部分應用與其他高階函式結合時,部分應用變得更加有趣。例如,以下是使用組合和部分應用定義前面剛剛展示的not()函式的一種方法:

const not = partialLeft(compose, x => !x);
const even = x => x % 2 === 0;
const odd = not(even);
const isNumber = not(isNaN);
odd(3) && isNumber(2)  // => true

我們還可以使用組合和部分應用來以極端函式式風格重新執行我們的均值和標準差計算:

// sum() and square() functions are defined above. Here are some more:
const product = (x,y) => x*y;
const neg = partial(product, -1);
const sqrt = partial(Math.pow, undefined, .5);
const reciprocal = partial(Math.pow, undefined, neg(1));

// Now compute the mean and standard deviation.
let data = [1,1,3,5,5];   // Our data
let mean = product(reduce(data, sum), reciprocal(data.length));
let stddev = sqrt(product(reduce(map(data,
                                     compose(square,
                                             partial(sum, neg(mean)))),
                                 sum),
                          reciprocal(sum(data.length,neg(1)))));
[mean, stddev]  // => [3, 2]

請注意,這段用於計算均值和標準差的程式碼完全是函式呼叫;沒有涉及運算子,並且括號的數量已經變得如此之多,以至於這段 JavaScript 程式碼開始看起來像 Lisp 程式碼。再次強調,這不是我推崇的 JavaScript 程式設計風格,但看到 JavaScript 程式碼可以有多函式式是一個有趣的練習。

8.8.4 Memoization

在§8.4.1 中,我們定義了一個階乘函式,它快取了先前計算的結果。在函數語言程式設計中,這種快取稱為memoization。接下來的程式碼展示了一個高階函式,memoize(),它接受一個函式作為引數,並返回該函式的一個記憶化版本:

// Return a memoized version of f.
// It only works if arguments to f all have distinct string representations.
function memoize(f) {
    const cache = new Map();  // Value cache stored in the closure.

    return function(...args) {
        // Create a string version of the arguments to use as a cache key.
        let key = args.length + args.join("+");
        if (cache.has(key)) {
            return cache.get(key);
        } else {
            let result = f.apply(this, args);
            cache.set(key, result);
            return result;
        }
    };
}

memoize()函式建立一個新物件用作快取,並將此物件分配給一個區域性變數,以便它對(在返回的函式的閉包中)是私有的。返回的函式將其引數陣列轉換為字串,並將該字串用作快取物件的屬性名。如果快取中存在值,則直接返回它。否則,呼叫指定的函式來計算這些引數的值,快取該值,並返回它。以下是我們如何使用memoize()

// Return the Greatest Common Divisor of two integers using the Euclidian
// algorithm: http://en.wikipedia.org/wiki/Euclidean_algorithm
function gcd(a,b) {  // Type checking for a and b has been omitted
    if (a < b) {           // Ensure that a >= b when we start
        [a, b] = [b, a];   // Destructuring assignment to swap variables
    }
    while(b !== 0) {       // This is Euclid's algorithm for GCD
        [a, b] = [b, a%b];
    }
    return a;
}

const gcdmemo = memoize(gcd);
gcdmemo(85, 187)  // => 17

// Note that when we write a recursive function that we will be memoizing,
// we typically want to recurse to the memoized version, not the original.
const factorial = memoize(function(n) {
    return (n <= 1) ? 1 : n * factorial(n-1);
});
factorial(5)      // => 120: also caches values for 4, 3, 2 and 1.

8.9 總結

關於本章的一些關鍵要點如下:

  • 您可以使用function關鍵字和 ES6 的=>箭頭語法定義函式。

  • 您可以呼叫函式,這些函式可以用作方法和建構函式。

  • 一些 ES6 功能允許您為可選函式引數定義預設值,使用 rest 引數將多個引數收集到一個陣列中,並將物件和陣列引數解構為函式引數。

  • 您可以使用...擴充套件運算子將陣列或其他可迭代物件的元素作為引數傳遞給函式呼叫。

  • 在封閉函式內部定義並返回的函式保留對其詞法作用域的訪問許可權,因此可以讀取和寫入外部函式中定義的變數。以這種方式使用的函式稱為closures,這是一種值得理解的技術。

  • 函式是 JavaScript 可以操作的物件,這使得函數語言程式設計成為可能。

¹ 這個術語是由 Martin Fowler 創造的。參見http://martinfowler.com/dslCatalog/methodChaining.html

² 如果你熟悉 Python,注意這與 Python 不同,其中每次呼叫都共享相同的預設值。

³ 這可能看起來不是特別有趣,除非您熟悉更靜態的語言,在這些語言中,函式是程式的一部分,但不能被程式操縱。

相關文章