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

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

第九章:類

JavaScript 物件在第六章中有所涉及。該章將每個物件視為一組獨特的屬性,與其他物件不同。然而,通常有必要定義一種共享某些屬性的物件。類的成員或例項具有自己的屬性來儲存或定義它們的狀態,但它們還具有定義其行為的方法。這些方法由類定義,並由所有例項共享。例如,想象一個名為 Complex 的類,表示並對複數執行算術運算。Complex 例項將具有儲存複數的實部和虛部(狀態)的屬性。Complex 類將定義執行這些數字的加法和乘法(行為)的方法。

在 JavaScript 中,類使用基於原型的繼承:如果兩個物件從同一原型繼承屬性(通常是函式值屬性或方法),那麼我們說這些物件是同一類的例項。簡而言之,這就是 JavaScript 類的工作原理。JavaScript 原型和繼承在§6.2.3 和§6.3.2 中有所涉及,您需要熟悉這些部分的內容才能理解本章。本章在§9.1 中涵蓋了原型。

如果兩個物件從同一原型繼承,這通常(但不一定)意味著它們是由同一建構函式或工廠函式建立和初始化的。建構函式在§4.6、§6.2.2 和§8.2.3 中有所涉及,本章在§9.2 中有更多內容。

JavaScript 一直允許定義類。ES6 引入了全新的語法(包括class關鍵字),使得建立類變得更加容易。這些新的 JavaScript 類與舊式類的工作方式相同,本章首先解釋了建立類的舊方法,因為這更清楚地展示了在幕後使類起作用的原理。一旦我們解釋了這些基礎知識,我們將轉而開始使用新的簡化類定義語法。

如果您熟悉像 Java 或 C++這樣的強型別物件導向程式語言,您會注意到 JavaScript 類與這些語言中的類有很大不同。雖然有一些語法上的相似之處,並且您可以在 JavaScript 中模擬許多“經典”類的特性,但最好事先了解 JavaScript 的類和基於原型的繼承機制與 Java 和類似語言的類和基於類的繼承機制有很大不同。

9.1 類和原型

在 JavaScript 中,類是一組從同一原型物件繼承屬性的物件。因此,原型物件是類的核心特徵。第六章介紹了Object.create()函式,該函式返回一個從指定型別物件繼承的新建立物件。如果我們定義一個原型物件,然後使用Object.create()建立從中繼承的物件,我們就定義了一個 JavaScript 類。通常,類的例項需要進一步初始化,通常定義一個函式來建立和初始化新物件。示例 9-1 演示了這一點:它定義了一個代表值範圍的類的原型物件,並定義了一個工廠函式,用於建立和初始化類的新例項。

示例 9-1 一個簡單的 JavaScript 類
// This is a factory function that returns a new range object.
function range(from, to) {
    // Use Object.create() to create an object that inherits from the
    // prototype object defined below.  The prototype object is stored as
    // a property of this function, and defines the shared methods (behavior)
    // for all range objects.
    let r = Object.create(range.methods);

    // Store the start and end points (state) of this new range object.
    // These are noninherited properties that are unique to this object.
    r.from = from;
    r.to = to;

    // Finally return the new object
    return r;
}

// This prototype object defines methods inherited by all range objects.
range.methods = {
    // Return true if x is in the range, false otherwise
    // This method works for textual and Date ranges as well as numeric.
    includes(x) { return this.from <= x && x <= this.to; },

    // A generator function that makes instances of the class iterable.
    // Note that it only works for numeric ranges.
    *[Symbol.iterator]() {
        for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
    },

    // Return a string representation of the range
    toString() { return "(" + this.from + "..." + this.to + ")"; }
};

// Here are example uses of a range object.
let r = range(1,3);      // Create a range object
r.includes(2)            // => true: 2 is in the range
r.toString()             // => "(1...3)"
[...r]                   // => [1, 2, 3]; convert to an array via iterator

在示例 9-1 的程式碼中有一些值得注意的事項:

  • 此程式碼定義了一個用於建立新 Range 物件的工廠函式range()

  • 它使用了range()函式的methods屬性作為一個方便的儲存原型物件的地方,該原型物件定義了類。將原型物件放在這裡並沒有什麼特殊或成語化的地方。

  • range()函式在每個 Range 物件上定義了fromto屬性。這些是定義每個獨立 Range 物件的唯一狀態的非共享、非繼承屬性。

  • range.methods物件使用了 ES6 的簡寫語法來定義方法,這就是為什麼你在任何地方都看不到function關鍵字的原因。(檢視§6.10.5 來回顧物件字面量簡寫方法語法。)

  • 原型中的一個方法具有計算名稱(§6.10.2),即Symbol.iterator,這意味著它正在為 Range 物件定義一個迭代器。這個方法的名稱字首為*,表示它是一個生成器函式而不是常規函式。迭代器和生成器在第十二章中有詳細介紹。目前,要點是這個 Range 類的例項可以與for/of迴圈和...擴充套件運算子一起使用。

  • range.methods中定義的共享的繼承方法都使用了在range()工廠函式中初始化的fromto屬性。為了引用它們,它們使用this關鍵字來引用透過其呼叫的物件。這種對this的使用是任何類的方法的基本特徵。

9.2 類和建構函式

示例 9-1 演示了定義 JavaScript 類的一種簡單方法。然而,這並不是慣用的做法,因為它沒有定義建構函式。建構函式是為新建立的物件初始化而設計的函式。建構函式使用new關鍵字呼叫,如§8.2.3 所述。使用new呼叫建構函式會自動建立新物件,因此建構函式本身只需要初始化該新物件的狀態。建構函式呼叫的關鍵特徵是建構函式的prototype屬性被用作新物件的原型。§6.2.3 介紹了原型並強調,幾乎所有物件都有一個原型,但只有少數物件有一個prototype屬性。最後,我們可以澄清這一點:函式物件具有prototype屬性。這意味著使用相同建構函式建立的所有物件都繼承自同一個物件,因此它們是同一類的成員。示例 9-2 展示瞭如何修改示例 9-1 的 Range 類以使用建構函式而不是工廠函式。示例 9-2 展示了在不支援 ES6 class關鍵字的 JavaScript 版本中建立類的慣用方法。即使現在class得到了很好的支援,仍然有很多舊的 JavaScript 程式碼定義類的方式就像這樣,你應該熟悉這種習慣用法,這樣你就可以閱讀舊程式碼,並且當你使用class關鍵字時,你能理解發生了什麼“底層”操作。

示例 9-2。使用建構函式的 Range 類
// This is a constructor function that initializes new Range objects.
// Note that it does not create or return the object. It just initializes this.
function Range(from, to) {
    // Store the start and end points (state) of this new range object.
    // These are noninherited properties that are unique to this object.
    this.from = from;
    this.to = to;
}

// All Range objects inherit from this object.
// Note that the property name must be "prototype" for this to work.
Range.prototype = {
    // Return true if x is in the range, false otherwise
    // This method works for textual and Date ranges as well as numeric.
    includes: function(x) { return this.from <= x && x <= this.to; },

    // A generator function that makes instances of the class iterable.
    // Note that it only works for numeric ranges.
    [Symbol.iterator]: function*() {
        for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
    },

    // Return a string representation of the range
    toString: function() { return "(" + this.from + "..." + this.to + ")"; }
};

// Here are example uses of this new Range class
let r = new Range(1,3);   // Create a Range object; note the use of new
r.includes(2)             // => true: 2 is in the range
r.toString()              // => "(1...3)"
[...r]                    // => [1, 2, 3]; convert to an array via iterator

值得仔細比較示例 9-1 和 9-2,並注意這兩種定義類的技術之間的區別。首先,注意到我們將range()工廠函式重新命名為Range()當我們將其轉換為建構函式時。這是一個非常常見的編碼約定:建構函式在某種意義上定義了類,而類的名稱(按照約定)以大寫字母開頭。常規函式和方法的名稱以小寫字母開頭。

接下來,請注意在示例末尾使用new關鍵字呼叫Range()建構函式,而range()工廠函式在沒有使用new的情況下呼叫。示例 9-1 使用常規函式呼叫(§8.2.1)建立新物件,而示例 9-2 使用建構函式呼叫(§8.2.3)。因為使用new呼叫Range()建構函式,所以不需要呼叫Object.create()或採取任何操作來建立新物件。新物件在建構函式呼叫之前自動建立,並且可以作為this值訪問。Range()建構函式只需初始化this。建構函式甚至不必返回新建立的物件。建構函式呼叫會自動建立一個新物件,將建構函式作為該物件的方法呼叫,並返回新物件。建構函式呼叫與常規函式呼叫如此不同的事實是我們給建構函式名稱以大寫字母開頭的另一個原因。建構函式被編寫為以建構函式方式呼叫,並且如果以常規函式方式呼叫,它們通常不會正常工作。將建構函式函式與常規函式區分開的命名約定有助於程式設計師知道何時使用new

示例 9-1 和 9-2 之間的另一個關鍵區別是原型物件的命名方式。在第一個示例中,原型是range.methods。這是一個方便且描述性強的名稱,但是任意的。在第二個示例中,原型是Range.prototype,這個名稱是強制的。對Range()建構函式的呼叫會自動使用Range.prototype作為新 Range 物件的原型。

最後,還要注意示例 9-1 和 9-2 之間沒有變化的地方:兩個類的範圍方法的定義和呼叫方式是相同的。因為示例 9-2 演示了在 ES6 之前 JavaScript 版本中建立類的慣用方式,它沒有在原型物件中使用 ES6 的簡寫方法語法,並且明確用function關鍵字拼寫出方法。但你可以看到兩個示例中方法的實現是相同的。

重要的是,要注意兩個範圍示例在定義建構函式或方法時都沒有使用箭頭函式。回想一下§8.1.3 中提到的,以這種方式定義的函式沒有prototype屬性,因此不能用作建構函式。此外,箭頭函式從定義它們的上下文中繼承this關鍵字,而不是根據呼叫它們的物件設定它,這使它們對於方法是無用的,因為方法的定義特徵是它們使用this來引用被呼叫的例項。

幸運的是,新的 ES6 類語法不允許使用箭頭函式定義方法,因此在使用該語法時不會出現這種錯誤。我們很快將介紹 ES6 的class關鍵字,但首先,還有更多關於建構函式的細節需要討論。

9.2.1 建構函式、類標識和 instanceof

正如我們所見,原型物件對於類的標識是至關重要的:兩個物件只有在它們繼承自相同的原型物件時才是同一類的例項。初始化新物件狀態的建構函式並不是基本的:兩個建構函式可能具有指向相同原型物件的prototype屬性。然後,這兩個建構函式都可以用於建立同一類的例項。

儘管建構函式不像原型那樣基礎,但建構函式作為類的公共面孔。最明顯的是,建構函式的名稱通常被採用為類的名稱。例如,我們說 Range() 建構函式建立 Range 物件。然而,更根本的是,建構函式在測試物件是否屬於類時作為 instanceof 運算子的右運算元。如果我們有一個物件 r 並想知道它是否是 Range 物件,我們可以寫:

r instanceof Range   // => true: r inherits from Range.prototype

instanceof 運算子在 §4.9.4 中有描述。左運算元應該是正在測試的物件,右運算元應該是命名類的建構函式。表示式 o instanceof Co 繼承自 C.prototype 時求值為 true。繼承不必是直接的:如果 o 繼承自繼承自繼承自 C.prototype 的物件,表示式仍將求值為 true

從技術上講,在前面的程式碼示例中,instanceof 運算子並不是在檢查 r 是否實際由 Range 建構函式初始化。相反,它是在檢查 r 是否繼承自 Range.prototype。如果我們定義一個函式 Strange() 並將其原型設定為與 Range.prototype 相同,那麼使用 new Strange() 建立的物件在 instanceof 方面將被視為 Range 物件(但實際上它們不會像 Range 物件一樣工作,因為它們的 fromto 屬性尚未初始化):

function Strange() {}
Strange.prototype = Range.prototype;
new Strange() instanceof Range   // => true

即使 instanceof 無法實際驗證建構函式的使用,但它仍將建構函式作為其右運算元,因為建構函式是類的公共標識。

如果您想要測試物件的原型鏈以查詢特定原型而不想使用建構函式作為中介,可以使用 isPrototypeOf() 方法。例如,在 示例 9-1 中,我們定義了一個沒有建構函式的類,因此無法使用該類的 instanceof。然而,我們可以使用以下程式碼測試物件 r 是否是該無建構函式類的成員:

range.methods.isPrototypeOf(r);  // range.methods is the prototype object.

9.2.2 建構函式屬性

在 示例 9-2 中,我們將 Range.prototype 設定為一個包含我們類方法的新物件。雖然將這些方法表達為單個物件字面量的屬性很方便,但實際上並不需要建立一個新物件。任何常規的 JavaScript 函式(不包括箭頭函式、生成器函式和非同步函式)都可以用作建構函式,並且建構函式呼叫需要一個 prototype 屬性。因此,每個常規的 JavaScript 函式¹ 自動具有一個 prototype 屬性。該屬性的值是一個具有單個、不可列舉的 constructor 屬性的物件。constructor 屬性的值是函式物件:

let F = function() {}; // This is a function object.
let p = F.prototype;   // This is the prototype object associated with F.
let c = p.constructor; // This is the function associated with the prototype.
c === F                // => true: F.prototype.constructor === F for any F

具有預定義原型物件及其 constructor 屬性的存在意味著物件通常繼承一個指向其建構函式的 constructor 屬性。由於建構函式作為類的公共標識,這個建構函式屬性給出了物件的類:

let o = new F();      // Create an object o of class F
o.constructor === F   // => true: the constructor property specifies the class

圖 9-1 展示了建構函式、其原型物件、原型指向建構函式的反向引用以及使用建構函式建立的例項之間的關係。

js7e 0901

圖 9-1. 一個建構函式、其原型和例項

注意圖 9-1 使用我們的Range()建構函式作為示例。實際上,然而,在示例 9-2 中定義的 Range 類覆蓋了預定義的Range.prototype物件為自己的物件。並且它定義的新原型物件沒有constructor屬性。因此,如定義的 Range 類的例項沒有constructor屬性。我們可以透過顯式向原型新增建構函式來解決這個問題:

Range.prototype = {
    constructor: Range,  // Explicitly set the constructor back-reference

    /* method definitions go here */
};

另一種在舊版 JavaScript 程式碼中常見的技術是使用預定義的原型物件及其具有constructor屬性,並使用以下程式碼逐個新增方法:

// Extend the predefined Range.prototype object so we don't overwrite
// the automatically created Range.prototype.constructor property.
Range.prototype.includes = function(x) {
    return this.from <= x && x <= this.to;
};
Range.prototype.toString = function() {
    return "(" + this.from + "..." + this.to + ")";
};

9.3 使用class關鍵字的類

類自從語言的第一個版本以來就一直是 JavaScript 的一部分,但在 ES6 中,它們終於得到了自己的語法,引入了class關鍵字。示例 9-3 展示了使用這種新語法編寫的 Range 類的樣子。

示例 9-3. 使用class重寫的 Range 類
class Range {
    constructor(from, to) {
        // Store the start and end points (state) of this new range object.
        // These are noninherited properties that are unique to this object.
        this.from = from;
        this.to = to;
    }

    // Return true if x is in the range, false otherwise
    // This method works for textual and Date ranges as well as numeric.
    includes(x) { return this.from <= x && x <= this.to; }

    // A generator function that makes instances of the class iterable.
    // Note that it only works for numeric ranges.
    *[Symbol.iterator]() {
        for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
    }

    // Return a string representation of the range
    toString() { return `(${this.from}...${this.to})`; }
}

// Here are example uses of this new Range class
let r = new Range(1,3);   // Create a Range object
r.includes(2)             // => true: 2 is in the range
r.toString()              // => "(1...3)"
[...r]                    // => [1, 2, 3]; convert to an array via iterator

重要的是要理解,在示例 9-2 和 9-3 中定義的類的工作方式完全相同。引入class關鍵字到語言中並不改變 JavaScript 基於原型的類的基本性質。儘管示例 9-3 使用了class關鍵字,但生成的 Range 物件是一個建構函式,就像在示例 9-2 中定義的版本一樣。新的class語法乾淨方便,但最好將其視為對在示例 9-2 中顯示的更基本的類定義機制的“語法糖”。

注意示例 9-3 中類語法的以下幾點:

  • 使用class關鍵字宣告類,後面跟著類名和用大括號括起來的類體。

  • 類體包括使用物件字面量方法簡寫的方法定義(我們在示例 9-1 中也使用了),其中省略了function關鍵字。然而,與物件字面量不同,沒有逗號用於將方法彼此分隔開。 (儘管類體在表面上與物件字面量相似,但它們並不是同一回事。特別是,它們不支援使用名稱/值對定義屬性。)

  • 關鍵字constructor用於為類定義建構函式。但實際上定義的函式並不真正命名為constructorclass宣告語句定義了一個新變數Range,並將這個特殊的constructor函式的值賦給該變數。

  • 如果你的類不需要進行任何初始化,你可以省略constructor關鍵字及其主體,將為你隱式建立一個空的建構函式。

如果你想定義一個繼承自另一個類的類,你可以使用extends關鍵字和class關鍵字:

// A Span is like a Range, but instead of initializing it with
// a start and an end, we initialize it with a start and a length
class Span extends Range {
    constructor(start, length) {
        if (length >= 0) {
            super(start, start + length);
        } else {
            super(start + length, start);
        }
    }
}

建立子類是一個獨立的主題。我們將在§9.5 中返回並解釋這裡顯示的extendssuper關鍵字。

類宣告與函式宣告一樣,既有語句形式又有表示式形式。就像我們可以寫:

let square = function(x) { return x * x; };
square(3)  // => 9

我們也可以寫:

let Square = class { constructor(x) { this.area = x * x; } };
new Square(3).area  // => 9

與函式定義表示式一樣,類定義表示式可以包括一個可選的類名。如果提供了這樣的名稱,那個名稱僅在類體內部定義。

儘管函式表示式非常常見(特別是使用箭頭函式簡寫),在 JavaScript 程式設計中,類定義表示式不是你經常使用的東西,除非你發現自己正在編寫一個以類作為引數並返回子類的函式。

我們將透過提及一些重要的事項來結束對class關鍵字的介紹,這些事項從class語法中並不明顯:

  • class宣告體內的所有程式碼都隱式地處於嚴格模式中(§5.6.3),即使沒有出現"use strict"指令。這意味著,例如,你不能在類體內使用八進位制整數字面量或with語句,並且如果你忘記在使用之前宣告一個變數,你更有可能得到語法錯誤。

  • 與函式宣告不同,類宣告不會“被提升”。回想一下§8.1.1 中提到的函式定義行為,就好像它們已經被移動到了包含檔案或包含函式的頂部,這意味著你可以在實際函式定義之前的程式碼中呼叫函式。儘管類宣告在某些方面類似於函式宣告,但它們不共享這種提升行為:你不能在宣告類之前例項化它。

9.3.1 靜態方法

你可以透過在class體中的方法宣告前加上static關鍵字來定義一個靜態方法。靜態方法被定義為建構函式的屬性,而不是原型物件的屬性。

例如,假設我們在示例 9-3 中新增了以下程式碼:

static parse(s) {
    let matches = s.match(/^\((\d+)\.\.\.(\d+)\)$/);
    if (!matches) {
        throw new TypeError(`Cannot parse Range from "${s}".`)
    }
    return new Range(parseInt(matches[1]), parseInt(matches[2]));
}

這段程式碼定義的方法是Range.parse(),而不是Range.prototype.parse(),你必須透過建構函式呼叫它,而不是透過例項呼叫:

let r = Range.parse('(1...10)'); // Returns a new Range object
r.parse('(1...10)');             // TypeError: r.parse is not a function

有時你會看到靜態方法被稱為類方法,因為它們是使用類/建構函式的名稱呼叫的。當使用這個術語時,是為了將類方法與在類的例項上呼叫的常規例項方法進行對比。因為靜態方法是在建構函式上呼叫而不是在任何特定例項上呼叫,所以在靜態方法中幾乎不可能使用this關鍵字。

我們將在示例 9-4 中看到靜態方法的示例。

9.3.2 獲取器、設定器和其他方法形式

class體內,你可以像在物件字面量中一樣定義獲取器和設定器方法(§6.10.6),唯一的區別是在類體中,你不在獲取器或設定器後面加逗號。示例 9-4 包括了一個類中獲取器方法的實際示例。

一般來說,在物件字面量中允許的所有簡寫方法定義語法在類體中也是允許的。這包括生成器方法(用*標記)和方法的名稱是方括號中表示式的值的方法。事實上,你已經在示例 9-3 中看到了一個具有計算名稱的生成器方法,使得 Range 類可迭代:

*[Symbol.iterator]() {
    for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
}

9.3.3 公共、私有和靜態欄位

在這裡討論使用class關鍵字定義的類時,我們只描述了類體內的方法定義。ES6 標準只允許建立方法(包括獲取器、設定器和生成器)和靜態方法;它不包括定義欄位的語法。如果你想在類例項上定義一個欄位(這只是物件導向的“屬性”同義詞),你必須在建構函式中或在其中一個方法中進行定義。如果你想為一個類定義一個靜態欄位,你必須在類體之外,在類定義之後進行定義。示例 9-4 包括了這兩種欄位的示例。

然而,標準化正在進行中,允許擴充套件類語法來定義例項和靜態欄位,包括公共和私有形式。截至 2020 年初,本節其餘部分展示的程式碼尚不是標準 JavaScript,但已經在 Chrome 中得到支援,並在 Firefox 中部分支援(僅支援公共例項欄位)。公共例項欄位的語法已經被使用 React 框架和 Babel 轉譯器的 JavaScript 程式設計師廣泛使用。

假設你正在編寫一個像這樣的類,其中包含一個初始化三個欄位的建構函式:

class Buffer {
    constructor() {
        this.size = 0;
        this.capacity = 4096;
        this.buffer = new Uint8Array(this.capacity);
    }
}

使用可能會被標準化的新例項欄位語法,你可以這樣寫:

class Buffer {
    size = 0;
    capacity = 4096;
    buffer = new Uint8Array(this.capacity);
}

欄位初始化程式碼已經從建構函式中移出,現在直接出現在類體中。(當然,該程式碼仍然作為建構函式的一部分執行。如果你沒有定義建構函式,那麼欄位將作為隱式建立的建構函式的一部分進行初始化。)出現在賦值左側的this.字首已經消失,但請注意,即使在初始化賦值的右側,你仍然必須使用this.來引用這些欄位。以這種方式初始化例項欄位的優勢在於,這種語法允許(但不要求)你將初始化器放在類定義的頂部,清楚地告訴讀者每個例項的狀態將由哪些欄位儲存。你可以透過只寫欄位名稱後跟一個分號來宣告沒有初始化器的欄位。如果這樣做,欄位的初始值將為undefined。對於所有類欄位,始終明確指定初始值是更好的風格。

在新增此欄位語法之前,類體看起來很像使用快捷方法語法的物件文字,只是逗號已被移除。這種帶有等號和分號而不是冒號和逗號的欄位語法清楚地表明類體與物件文字完全不同。

與尋求標準化這些例項欄位的提案相同,還定義了私有例項欄位。如果你使用前面示例中顯示的例項欄位初始化語法來定義一個以#開頭的欄位(這在 JavaScript 識別符號中通常不是合法字元),那麼該欄位將可以在類體內(帶有#字首)使用,但對於類體外的任何程式碼來說是不可見和不可訪問的(因此是不可變的)。如果對於前面的假設的 Buffer 類,你希望確保類的使用者不能無意中修改例項的size欄位,那麼你可以使用一個私有的#size欄位,然後定義一個獲取器函式來提供只讀訪問許可權:

class Buffer {
    #size = 0;
    get size() { return this.#size; }
}

請注意,私有欄位必須在使用之前使用這種新欄位語法進行宣告。除非在類體中直接包含欄位的“宣告”,否則不能在類的建構函式中只寫this.#size = 0;

最後,一個相關的提案旨在標準化static關鍵字用於欄位。如果在公共或私有欄位宣告之前新增static,那麼這些欄位將作為建構函式的屬性而不是例項的屬性建立。考慮我們定義的靜態Range.parse()方法。它包含一個可能很好地分解為自己的靜態欄位的相當複雜的正規表示式。使用提議的新靜態欄位語法,我們可以這樣做:

static integerRangePattern = /^\((\d+)\.\.\.(\d+)\)$/;
static parse(s) {
    let matches = s.match(Range.integerRangePattern);
    if (!matches) {
        throw new TypeError(`Cannot parse Range from "${s}".`)
    }
    return new Range(parseInt(matches[1]), matches[2]);
}

如果我們希望這個靜態欄位只能在類內部訪問,我們可以使用類似#pattern的私有名稱。

9.3.4 示例:複數類

示例 9-4 定義了一個表示複數的類。這個類相對簡單,但包括例項方法(包括獲取器)、靜態方法、例項欄位和靜態欄位。它包含了一些被註釋掉的程式碼,演示了我們如何使用尚未標準化的語法來在類體內定義例項欄位和靜態欄位。

示例 9-4. Complex.js:一個複數類
/**
 * Instances of this Complex class represent complex numbers.
 * Recall that a complex number is the sum of a real number and an
 * imaginary number and that the imaginary number i is the square root of -1.
 */
class Complex {
    // Once class field declarations are standardized, we could declare
    // private fields to hold the real and imaginary parts of a complex number
    // here, with code like this:
    //
    // #r = 0;
    // #i = 0;

    // This constructor function defines the instance fields r and i on every
    // instance it creates. These fields hold the real and imaginary parts of
    // the complex number: they are the state of the object.
    constructor(real, imaginary) {
        this.r = real;       // This field holds the real part of the number.
        this.i = imaginary;  // This field holds the imaginary part.
    }

    // Here are two instance methods for addition and multiplication
    // of complex numbers. If c and d are instances of this class, we
    // might write c.plus(d) or d.times(c)
    plus(that) {
        return new Complex(this.r + that.r, this.i + that.i);
    }
    times(that) {
        return new Complex(this.r * that.r - this.i * that.i,
                           this.r * that.i + this.i * that.r);
    }

    // And here are static variants of the complex arithmetic methods.
    // We could write Complex.sum(c,d) and Complex.product(c,d)
    static sum(c, d) { return c.plus(d); }
    static product(c, d) { return c.times(d); }

    // These are some instance methods that are defined as getters
    // so they're used like fields. The real and imaginary getters would
    // be useful if we were using private fields this.#r and this.#i
    get real() { return this.r; }
    get imaginary() { return this.i; }
    get magnitude() { return Math.hypot(this.r, this.i); }

    // Classes should almost always have a toString() method
    toString() { return `{${this.r},${this.i}}`; }

    // It is often useful to define a method for testing whether
    // two instances of your class represent the same value
    equals(that) {
        return that instanceof Complex &&
            this.r === that.r &&
            this.i === that.i;
    }

    // Once static fields are supported inside class bodies, we could
    // define a useful Complex.ZERO constant like this:
    // static ZERO = new Complex(0,0);
}

// Here are some class fields that hold useful predefined complex numbers.
Complex.ZERO = new Complex(0,0);
Complex.ONE = new Complex(1,0);
Complex.I = new Complex(0,1);

使用示例 9-4 中定義的 Complex 類,我們可以使用建構函式、例項欄位、例項方法、類欄位和類方法的程式碼如下:

let c = new Complex(2, 3);     // Create a new object with the constructor
let d = new Complex(c.i, c.r); // Use instance fields of c
c.plus(d).toString()           // => "{5,5}"; use instance methods
c.magnitude                    // => Math.hypot(2,3); use a getter function
Complex.product(c, d)          // => new Complex(0, 13); a static method
Complex.ZERO.toString()        // => "{0,0}"; a static property

9.4 為現有類新增方法

JavaScript 的基於原型的繼承機制是動態的:一個物件從其原型繼承屬性,即使原型的屬性在物件建立後發生變化。這意味著我們可以透過簡單地向其原型物件新增新方法來增強 JavaScript 類。

例如,這裡是為計算複共軛新增一個方法到示例 9-4 的 Complex 類的程式碼:

// Return a complex number that is the complex conjugate of this one.
Complex.prototype.conj = function() { return new Complex(this.r, -this.i); };

JavaScript 內建類的原型物件也是開放的,這意味著我們可以向數字、字串、陣列、函式等新增方法。這對於在語言的舊版本中實現新的語言特性很有用:

// If the new String method startsWith() is not already defined...
if (!String.prototype.startsWith) {
    // ...then define it like this using the older indexOf() method.
    String.prototype.startsWith = function(s) {
        return this.indexOf(s) === 0;
    };
}

這裡是另一個例子:

// Invoke the function f this many times, passing the iteration number
// For example, to print "hello" 3 times:
//     let n = 3;
//     n.times(i => { console.log(`hello ${i}`); });
Number.prototype.times = function(f, context) {
    let n = this.valueOf();
    for(let i = 0; i < n; i++) f.call(context, i);
};

像這樣向內建型別的原型新增方法通常被認為是一個壞主意,因為如果 JavaScript 的新版本定義了同名方法,將會導致混亂和相容性問題。甚至可以向Object.prototype新增方法,使其對所有物件可用。但這絕不是一個好主意,因為新增到Object.prototype的屬性對for/in迴圈可見(儘管您可以透過使用Object.defineProperty()[§14.1]使新屬性不可列舉來避免這種情況)。

9.5 子類

在物件導向程式設計中,一個類 B 可以擴充套件子類化另一個類 A。我們說 A 是超類,B 是子類。B 的例項繼承 A 的方法。類 B 可以定義自己的方法,其中一些可能會覆蓋類 A 定義的同名方法。如果 B 的方法覆蓋了 A 的方法,那麼 B 中的覆蓋方法通常需要呼叫 A 中被覆蓋的方法。同樣,子類建構函式B()通常必須呼叫超類建構函式A(),以確保例項完全初始化。

本節首先展示瞭如何以舊的、ES6 之前的方式定義子類,然後迅速轉向演示使用classextends關鍵字以及使用super關鍵字呼叫超類構造方法的子類化。接下來是一個關於避免子類化,依靠物件組合而不是繼承的子節。本節以一個定義了一系列 Set 類的擴充套件示例結束,並演示瞭如何使用抽象類來將介面與實現分離。

9.5.1 子類和原型

假設我們想要定義一個 Span 子類,繼承自示例 9-2 的 Range 類。這個子類將像 Range 一樣工作,但不是用起始和結束來初始化,而是指定一個起始和一個距離,或者跨度。Span 類的一個例項也是 Range 超類的一個例項。跨度例項從Span.prototype繼承了一個定製的toString()方法,但為了成為 Range 的子類,它還必須從Range.prototype繼承方法(如includes())。

示例 9-5. Span.js:Range 的一個簡單子類
// This is the constructor function for our subclass
function Span(start, span) {
    if (span >= 0) {
        this.from = start;
        this.to = start + span;
    } else {
        this.to = start;
        this.from = start + span;
    }
}

// Ensure that the Span prototype inherits from the Range prototype
Span.prototype = Object.create(Range.prototype);

// We don't want to inherit Range.prototype.constructor, so we
// define our own constructor property.
Span.prototype.constructor = Span;

// By defining its own toString() method, Span overrides the
// toString() method that it would otherwise inherit from Range.
Span.prototype.toString = function() {
    return `(${this.from}... +${this.to - this.from})`;
};

為了使 Span 成為 Range 的一個子類,我們需要讓Span.prototypeRange.prototype繼承。在前面示例中的關鍵程式碼行是這一行,如果這對你有意義,你就理解了 JavaScript 中子類是如何工作的:

Span.prototype = Object.create(Range.prototype);

使用Span()建構函式建立的物件將從Span.prototype物件繼承。但我們建立該物件是為了從Range.prototype繼承,因此 Span 物件將同時從Span.prototypeRange.prototype繼承。

你可能注意到我們的Span()建構函式設定了與Range()建構函式相同的fromto屬性,因此不需要呼叫Range()建構函式來初始化新物件。類似地,Span 的toString()方法完全重新實現了字串轉換,而不需要呼叫 Range 的toString()版本。這使 Span 成為一個特殊情況,我們只能在瞭解超類的實現細節時才能這樣做。一個健壯的子類化機制需要允許類呼叫其超類的方法和建構函式,但在 ES6 之前,JavaScript 沒有簡單的方法來做這些事情。

幸運的是,ES6 透過super關鍵字作為class語法的一部分解決了這些問題。下一節將演示它是如何工作的。

9.5.2 使用 extends 和 super 建立子類

在 ES6 及更高版本中,你可以透過在類宣告中新增extends子句來簡單地建立一個超類,甚至可以對內建類這樣做:

// A trivial Array subclass that adds getters for the first and last elements.
class EZArray extends Array {
    get first() { return this[0]; }
    get last() { return this[this.length-1]; }
}

let a = new EZArray();
a instanceof EZArray  // => true: a is subclass instance
a instanceof Array    // => true: a is also a superclass instance.
a.push(1,2,3,4);      // a.length == 4; we can use inherited methods
a.pop()               // => 4: another inherited method
a.first               // => 1: first getter defined by subclass
a.last                // => 3: last getter defined by subclass
a[1]                  // => 2: regular array access syntax still works.
Array.isArray(a)      // => true: subclass instance really is an array
EZArray.isArray(a)    // => true: subclass inherits static methods, too!

這個 EZArray 子類定義了兩個簡單的 getter 方法。EZArray 的例項表現得像普通陣列,我們可以使用繼承的方法和屬性,比如push()pop()length。但我們也可以使用子類中定義的firstlast getter。不僅例項方法像pop()被繼承了,靜態方法像Array.isArray也被繼承了。這是 ES6 類語法啟用的一個新特性:EZArray()是一個函式,但它繼承自Array()

// EZArray inherits instance methods because EZArray.prototype
// inherits from Array.prototype
Array.prototype.isPrototypeOf(EZArray.prototype) // => true

// And EZArray inherits static methods and properties because
// EZArray inherits from Array. This is a special feature of the
// extends keyword and is not possible before ES6.
Array.isPrototypeOf(EZArray) // => true

我們的 EZArray 子類過於簡單,無法提供很多指導性。示例 9-6 是一個更加完整的示例。它定義了一個 TypedMap 的子類,繼承自內建的 Map 類,並新增了型別檢查以確保地圖的鍵和值是指定型別(根據typeof)。重要的是,這個示例演示了使用super關鍵字來呼叫超類的建構函式和方法。

示例 9-6. TypedMap.js:檢查鍵和值型別的 Map 子類
class TypedMap extends Map {
    constructor(keyType, valueType, entries) {
        // If entries are specified, check their types
        if (entries) {
            for(let [k, v] of entries) {
                if (typeof k !== keyType || typeof v !== valueType) {
                    throw new TypeError(`Wrong type for entry [${k}, ${v}]`);
                }
            }
        }

        // Initialize the superclass with the (type-checked) initial entries
        super(entries);

        // And then initialize this subclass by storing the types
        this.keyType = keyType;
        this.valueType = valueType;
    }

    // Now redefine the set() method to add type checking for any
    // new entries added to the map.
    set(key, value) {
        // Throw an error if the key or value are of the wrong type
        if (this.keyType && typeof key !== this.keyType) {
            throw new TypeError(`${key} is not of type ${this.keyType}`);
        }
        if (this.valueType && typeof value !== this.valueType) {
            throw new TypeError(`${value} is not of type ${this.valueType}`);
        }

        // If the types are correct, we invoke the superclass's version of
        // the set() method, to actually add the entry to the map. And we
        // return whatever the superclass method returns.
        return super.set(key, value);
    }
}

TypedMap()建構函式的前兩個引數是期望的鍵和值型別。這些應該是字串,比如“number”和“boolean”,這是typeof運算子返回的。你還可以指定第三個引數:一個包含[key,value]陣列的陣列(或任何可迭代物件),指定地圖中的初始條目。如果指定了任何初始條目,建構函式首先驗證它們的型別是否正確。接下來,建構函式使用super關鍵字呼叫超類建構函式,就像它是一個函式名一樣。Map()建構函式接受一個可選引數:一個包含[key,value]陣列的可迭代物件。因此,TypedMap()建構函式的可選第三個引數是Map()建構函式的可選第一個引數,我們使用super(entries)將其傳遞給超類建構函式。

在呼叫超類建構函式初始化超類狀態後,TypedMap()建構函式接下來透過設定this.keyTypethis.valueType來初始化自己的子類狀態。它需要設定這些屬性以便在set()方法中再次使用它們。

在建構函式中使用super()時,有一些重要的規則你需要知道:

  • 如果你用extends關鍵字定義一個類,那麼你的類的建構函式必須使用super()來呼叫超類建構函式。

  • 如果你在子類中沒有定義建構函式,系統會自動為你定義一個。這個隱式定義的建構函式簡單地接受傳遞給它的任何值,並將這些值傳遞給super()

  • 在呼叫super()之前,你不能在建構函式中使用this關鍵字。這強制了一個規則,即超類在子類之前初始化。

  • 特殊表示式new.target在沒有使用new關鍵字呼叫的函式中是未定義的。然而,在建構函式中,new.target是對被呼叫的建構函式的引用。當子類建構函式被呼叫並使用super()來呼叫超類建構函式時,那個超類建構函式將會把子類建構函式視為new.target的值。一個設計良好的超類不應該知道自己是否被子類化,但在日誌訊息中使用new.target.name可能會很有用。

在建構函式之後,示例 9-6 的下一部分是一個名為set()的方法。Map 超類定義了一個名為set()的方法來向地圖新增新條目。我們說這個 TypedMap 中的set()方法覆蓋了其超類的set()方法。這個簡單的 TypedMap 子類對於向地圖新增新條目一無所知,但它知道如何檢查型別,所以首先進行型別檢查,驗證要新增到地圖中的鍵和值是否具有正確的型別,如果不是則丟擲錯誤。這個set()方法沒有任何方法將鍵和值新增到地圖本身,但這就是超類set()方法的作用。因此,我們再次使用super關鍵字來呼叫超類的方法版本。在這個上下文中,super的工作方式很像this關鍵字:它引用當前物件但允許訪問在超類中定義的重寫方法。

在建構函式中,你必須在訪問this並自己初始化新物件之前呼叫超類建構函式。當你重寫一個方法時,沒有這樣的規則。重寫超類方法的方法不需要呼叫超類方法。如果它確實使用super來呼叫被重寫的方法(或超類中的任何方法),它可以在重寫方法的開始、中間或結尾進行呼叫。

最後,在我們離開 TypedMap 示例之前,值得注意的是,這個類是使用私有欄位的理想候選。目前這個類的寫法,使用者可以更改keyTypevalueType屬性以規避型別檢查。一旦支援私有欄位,我們可以將這些屬性更改為#keyType#valueType,這樣它們就無法從外部更改。

9.5.3 代理而非繼承

extends關鍵字使建立子類變得容易。但這並不意味著你應該建立大量子類。如果你想編寫一個共享某個其他類行為的類,你可以嘗試透過建立子類來繼承該行為。但通常更容易和更靈活的方法是透過讓你的類建立另一個類的例項並根據需要簡單地委託給該例項來獲得所需的行為。你建立一個新類不是透過子類化,而是透過包裝或“組合”其他類。這種委託方法通常被稱為“組合”,並且物件導向程式設計的一個經常引用的格言是應該“優先選擇組合而非繼承”。²

例如,假設我們想要一個直方圖類,其行為類似於 JavaScript 的 Set 類,但不僅僅是跟蹤值是否已新增到集合中,而是維護值已新增的次數。因為這個直方圖類的 API 類似於 Set,我們可以考慮繼承 Set 並新增一個count()方法。另一方面,一旦我們開始考慮如何實現這個count()方法,我們可能會意識到直方圖類更像是一個 Map 而不是一個 Set,因為它需要維護值和它們被新增的次數之間的對映關係。因此,我們可以建立一個定義了類似 Set API 的類,但透過委託給內部 Map 物件來實現這些方法。示例 9-7 展示了我們如何做到這一點。

示例 9-7. Histogram.js:使用委託實現的類似 Set 的類
/**
 * A Set-like class that keeps track of how many times a value has
 * been added. Call add() and remove() like you would for a Set, and
 * call count() to find out how many times a given value has been added.
 * The default iterator yields the values that have been added at least
 * once. Use entries() if you want to iterate [value, count] pairs.
 */
class Histogram {
    // To initialize, we just create a Map object to delegate to
    constructor() { this.map = new Map(); }

    // For any given key, the count is the value in the Map, or zero
    // if the key does not appear in the Map.
    count(key) { return this.map.get(key) || 0; }

    // The Set-like method has() returns true if the count is non-zero
    has(key) { return this.count(key) > 0; }

    // The size of the histogram is just the number of entries in the Map.
    get size() { return this.map.size; }

    // To add a key, just increment its count in the Map.
    add(key) { this.map.set(key, this.count(key) + 1); }

    // Deleting a key is a little trickier because we have to delete
    // the key from the Map if the count goes back down to zero.
    delete(key) {
        let count = this.count(key);
        if (count === 1) {
            this.map.delete(key);
        } else if (count > 1) {
            this.map.set(key, count - 1);
        }
    }

    // Iterating a Histogram just returns the keys stored in it
    [Symbol.iterator]() { return this.map.keys(); }

    // These other iterator methods just delegate to the Map object
    keys() { return this.map.keys(); }
    values() { return this.map.values(); }
    entries() { return this.map.entries(); }
}

示例 9-7 中的Histogram()建構函式只是建立了一個 Map 物件。大多數方法都只是簡單地委託給地圖的一個方法,使得實現非常簡單。因為我們使用了委託而不是繼承,一個 Histogram 物件不是 Set 或 Map 的例項。但 Histogram 實現了許多常用的 Set 方法,在像 JavaScript 這樣的無型別語言中,這通常已經足夠了:正式的繼承關係有時很好,但通常是可選的。

9.5.4 類層次結構和抽象類

示例 9-6 演示了我們如何繼承 Map。示例 9-7 演示了我們如何委託給一個 Map 物件而不實際繼承任何東西。使用 JavaScript 類來封裝資料和模組化程式碼通常是一個很好的技術,你可能會經常使用class關鍵字。但你可能會發現你更喜歡組合而不是繼承,並且很少需要使用extends(除非你使用要求你擴充套件其基類的庫或框架)。

然而,在某些情況下,多級子類化是合適的,我們將以一個擴充套件示例結束本章,該示例演示了代表不同型別集合的類的層次結構。(示例 9-8 中定義的集合類與 JavaScript 內建的 Set 類類似,但不完全相容。)

示例 9-8 定義了許多子類,但它還演示瞭如何定義抽象類——不包括完整實現的類——作為一組相關子類的共同超類。抽象超類可以定義所有子類繼承和共享的部分實現。然後,子類只需要透過實現超類定義但未實現的抽象方法來定義自己的獨特行為。請注意,JavaScript 沒有任何正式定義抽象方法或抽象類的規定;我在這裡僅僅是使用這個名稱來表示未實現的方法和未完全實現的類。

示例 9-8 有很好的註釋,可以獨立執行。我鼓勵你將其作為本章關於類的頂尖示例來閱讀。在示例 9-8 中的最終類使用了&|~運算子進行大量的位操作,你可以在§4.8.3 中複習。

示例 9-8. Sets.js:抽象和具體集合類的層次結構
/**
 * The AbstractSet class defines a single abstract method, has().
 */
class AbstractSet {
    // Throw an error here so that subclasses are forced
    // to define their own working version of this method.
    has(x) { throw new Error("Abstract method"); }
}

/**
 * NotSet is a concrete subclass of AbstractSet.
 * The members of this set are all values that are not members of some
 * other set. Because it is defined in terms of another set it is not
 * writable, and because it has infinite members, it is not enumerable.
 * All we can do with it is test for membership and convert it to a
 * string using mathematical notation.
 */
class NotSet extends AbstractSet {
    constructor(set) {
        super();
        this.set = set;
    }

    // Our implementation of the abstract method we inherited
    has(x) { return !this.set.has(x); }
    // And we also override this Object method
    toString() { return `{ x| x ∉ ${this.set.toString()} }`; }
}

/**
 * Range set is a concrete subclass of AbstractSet. Its members are
 * all values that are between the from and to bounds, inclusive.
 * Since its members can be floating point numbers, it is not
 * enumerable and does not have a meaningful size.
 */
class RangeSet extends AbstractSet {
    constructor(from, to) {
        super();
        this.from = from;
        this.to = to;
    }

    has(x) { return x >= this.from && x <= this.to; }
    toString() { return `{ x| ${this.from} ≤ x ≤ ${this.to} }`; }
}

/*
 * AbstractEnumerableSet is an abstract subclass of AbstractSet.  It defines
 * an abstract getter that returns the size of the set and also defines an
 * abstract iterator. And it then implements concrete isEmpty(), toString(),
 * and equals() methods on top of those. Subclasses that implement the
 * iterator, the size getter, and the has() method get these concrete
 * methods for free.
 */
class AbstractEnumerableSet extends AbstractSet {
    get size() { throw new Error("Abstract method"); }
    [Symbol.iterator]() { throw new Error("Abstract method"); }

    isEmpty() { return this.size === 0; }
    toString() { return `{${Array.from(this).join(", ")}}`; }
    equals(set) {
        // If the other set is not also Enumerable, it isn't equal to this one
        if (!(set instanceof AbstractEnumerableSet)) return false;

        // If they don't have the same size, they're not equal
        if (this.size !== set.size) return false;

        // Loop through the elements of this set
        for(let element of this) {
            // If an element isn't in the other set, they aren't equal
            if (!set.has(element)) return false;
        }

        // The elements matched, so the sets are equal
        return true;
    }
}

/*
 * SingletonSet is a concrete subclass of AbstractEnumerableSet.
 * A singleton set is a read-only set with a single member.
 */
class SingletonSet extends AbstractEnumerableSet {
    constructor(member) {
        super();
        this.member = member;
    }

    // We implement these three methods, and inherit isEmpty, equals()
    // and toString() implementations based on these methods.
    has(x) { return x === this.member; }
    get size() { return 1; }
    *[Symbol.iterator]() { yield this.member; }
}

/*
 * AbstractWritableSet is an abstract subclass of AbstractEnumerableSet.
 * It defines the abstract methods insert() and remove() that insert and
 * remove individual elements from the set, and then implements concrete
 * add(), subtract(), and intersect() methods on top of those. Note that
 * our API diverges here from the standard JavaScript Set class.
 */
class AbstractWritableSet extends  AbstractEnumerableSet {
    insert(x) { throw new Error("Abstract method"); }
    remove(x) { throw new Error("Abstract method"); }

    add(set) {
        for(let element of set) {
            this.insert(element);
        }
    }

    subtract(set) {
        for(let element of set) {
            this.remove(element);
        }
    }

    intersect(set) {
        for(let element of this) {
            if (!set.has(element)) {
                this.remove(element);
            }
        }
    }
}

/**
 * A BitSet is a concrete subclass of AbstractWritableSet with a
 * very efficient fixed-size set implementation for sets whose
 * elements are non-negative integers less than some maximum size.
 */
class BitSet extends AbstractWritableSet {
    constructor(max) {
        super();
        this.max = max;  // The maximum integer we can store.
        this.n = 0;      // How many integers are in the set
        this.numBytes = Math.floor(max / 8) + 1;   // How many bytes we need
        this.data = new Uint8Array(this.numBytes); // The bytes
    }

    // Internal method to check if a value is a legal member of this set
    _valid(x) { return Number.isInteger(x) && x >= 0 && x <= this.max; }

    // Tests whether the specified bit of the specified byte of our
    // data array is set or not. Returns true or false.
    _has(byte, bit) { return (this.data[byte] & BitSet.bits[bit]) !== 0; }

    // Is the value x in this BitSet?
    has(x) {
        if (this._valid(x)) {
            let byte = Math.floor(x / 8);
            let bit = x % 8;
            return this._has(byte, bit);
        } else {
            return false;
        }
    }

    // Insert the value x into the BitSet
    insert(x) {
        if (this._valid(x)) {               // If the value is valid
            let byte = Math.floor(x / 8);   // convert to byte and bit
            let bit = x % 8;
            if (!this._has(byte, bit)) {    // If that bit is not set yet
                this.data[byte] |= BitSet.bits[bit]; // then set it
                this.n++;                            // and increment set size
            }
        } else {
            throw new TypeError("Invalid set element: " + x );
        }
    }

    remove(x) {
        if (this._valid(x)) {              // If the value is valid
            let byte = Math.floor(x / 8);  // compute the byte and bit
            let bit = x % 8;
            if (this._has(byte, bit)) {    // If that bit is already set
                this.data[byte] &= BitSet.masks[bit];  // then unset it
                this.n--;                              // and decrement size
            }
        } else {
            throw new TypeError("Invalid set element: " + x );
        }
    }

    // A getter to return the size of the set
    get size() { return this.n; }

    // Iterate the set by just checking each bit in turn.
    // (We could be a lot more clever and optimize this substantially)
    *[Symbol.iterator]() {
        for(let i = 0; i <= this.max; i++) {
            if (this.has(i)) {
                yield i;
            }
        }
    }
}

// Some pre-computed values used by the has(), insert() and remove() methods
BitSet.bits = new Uint8Array([1, 2, 4, 8, 16, 32, 64, 128]);
BitSet.masks = new Uint8Array([~1, ~2, ~4, ~8, ~16, ~32, ~64, ~128]);

9.6 總結

本章已經解釋了 JavaScript 類的關鍵特性:

  • 同一類的物件從相同的原型物件繼承屬性。原型物件是 JavaScript 類的關鍵特性,可以僅使用Object.create()方法定義類。

  • 在 ES6 之前,類通常是透過首先定義建構函式來定義的。使用function關鍵字建立的函式具有一個prototype屬性,該屬性的值是一個物件,當使用new作為建構函式呼叫函式時,該物件被用作所有建立的物件的原型。透過初始化這個原型物件,您可以定義類的共享方法。雖然原型物件是類的關鍵特徵,但建構函式是類的公共標識。

  • ES6 引入了class關鍵字,使得定義類更容易,但在底層,建構函式和原型機制仍然保持不變。

  • 子類是在類宣告中使用extends關鍵字定義的。

  • 子類可以使用super關鍵字呼叫其父類的建構函式或重寫的方法。

¹ 除了 ES5 的Function.bind()方法返回的函式。繫結函式沒有自己的原型屬性,但如果作為建構函式呼叫它們,則它們使用基礎函式的原型。

² 例如,參見 Erich Gamma 等人的設計模式(Addison-Wesley Professional)或 Joshua Bloch 的Effective Java(Addison-Wesley Professional)。

第十章:模組

模組化程式設計的目標是允許從不同作者和來源的程式碼模組組裝大型程式,並且所有這些程式碼在各個模組作者未預料到的程式碼存在的情況下仍能正確執行。 從實際角度來看,模組化主要是關於封裝或隱藏私有實現細節,並保持全域性名稱空間整潔,以便模組不會意外修改其他模組定義的變數、函式和類。

直到最近,JavaScript 沒有內建模組支援,而在大型程式碼庫上工作的程式設計師盡力利用類、物件和閉包提供的弱模組化。基於閉包的模組化,結合程式碼捆綁工具的支援,形成了一種基於require()函式的實用模組化形式,這被 Node 所採用。 基於require()的模組是 Node 程式設計環境的基本組成部分,但從未被正式納入 JavaScript 語言的一部分。 相反,ES6 使用importexport關鍵字定義模組。 儘管importexport多年來一直是語言的一部分,但它們最近才被 Web 瀏覽器和 Node 實現。 作為一個實際問題,JavaScript 模組化仍然依賴於程式碼捆綁工具。

接下來的章節涵蓋:

  • 使用類、物件和閉包自行建立模組

  • 使用require()的 Node 模組

  • 使用exportimportimport()的 ES6 模組

10.1 使用類、物件和閉包的模組

儘管這可能是顯而易見的,但值得指出的是,類的一個重要特性是它們作為其方法的模組。 回想一下示例 9-8。 該示例定義了許多不同的類,所有這些類都有一個名為has()的方法。 但是,您可以毫無問題地編寫一個使用該示例中多個集合類的程式:例如,SingletonSet 的has()方法不會覆蓋 BitSet 的has()方法。

一個類的方法獨立於其他不相關類的方法的原因是,每個類的方法被定義為獨立原型物件的屬性。 類是模組化的原因是物件是模組化的:在 JavaScript 物件中定義屬性很像宣告變數,但向物件新增屬性不會影響程式的全域性名稱空間,也不會影響其他物件的屬性。 JavaScript 定義了相當多的數學函式和常量,但是不是將它們全部定義為全域性的,而是將它們作為單個全域性 Math 物件的屬性分組。 這種技術可以在示例 9-8 中使用。 該示例可以被編寫為僅定義一個名為 Sets 的全域性物件,其屬性引用各種類。 使用此 Sets 庫的使用者可以使用類似Sets.SingletonSets.Bit的名稱引用類。

在 JavaScript 程式設計中,使用類和物件進行模組化是一種常見且有用的技術,但這還不夠。 特別是,它沒有提供任何隱藏模組內部實現細節的方法。 再次考慮示例 9-8。 如果我們將該示例編寫為一個模組,也許我們希望將各種抽象類保留在模組內部,只將具體子類提供給模組的使用者。 同樣,在 BitSet 類中,_valid()_has()方法是內部實用程式,不應該真正暴露給類的使用者。 BitSet.bitsBitSet.masks是最好隱藏的實現細節。

正如我們在 §8.6 中看到的,函式內宣告的區域性變數和巢狀函式對該函式是私有的。這意味著我們可以使用立即呼叫的函式表示式透過將實現細節和實用函式隱藏在封閉函式中,使模組的公共 API 成為函式的返回值來實現一種模組化。對於 BitSet 類,我們可以將模組結構化如下:

const BitSet = (function() { // Set BitSet to the return value of this function
    // Private implementation details here
    function isValid(set, n) { ... }
    function has(set, byte, bit) { ... }
    const BITS = new Uint8Array([1, 2, 4, 8, 16, 32, 64, 128]);
    const MASKS = new Uint8Array([~1, ~2, ~4, ~8, ~16, ~32, ~64, ~128]);

    // The public API of the module is just the BitSet class, which we define
    // and return here. The class can use the private functions and constants
    // defined above, but they will be hidden from users of the class
    return class BitSet extends AbstractWritableSet {
        // ... implementation omitted ...
    };
}());

當模組中有多個項時,這種模組化方法變得更加有趣。例如,以下程式碼定義了一個迷你統計模組,匯出 mean()stddev() 函式,同時隱藏了實現細節:

// This is how we could define a stats module
const stats = (function() {
    // Utility functions private to the module
    const sum = (x, y) => x + y;
    const square = x => x * x;

    // A public function that will be exported
    function mean(data) {
        return data.reduce(sum)/data.length;
    }

    // A public function that we will export
    function stddev(data) {
        let m = mean(data);
        return Math.sqrt(
            data.map(x => x - m).map(square).reduce(sum)/(data.length-1)
        );
    }

    // We export the public function as properties of an object
    return { mean, stddev };
}());

// And here is how we might use the module
stats.mean([1, 3, 5, 7, 9])   // => 5
stats.stddev([1, 3, 5, 7, 9]) // => Math.sqrt(10)

10.1.1 自動化基於閉包的模組化

請注意,將 JavaScript 程式碼檔案轉換為這種模組的過程是一個相當機械化的過程,只需在檔案開頭和結尾插入一些文字即可。所需的只是一些約定,用於指示哪些值要匯出,哪些不要匯出。

想象一個工具,它接受一組檔案,將每個檔案的內容包裝在立即呼叫的函式表示式中,跟蹤每個函式的返回值,並將所有內容連線成一個大檔案。結果可能看起來像這樣:

const modules = {};
function require(moduleName) { return modules[moduleName]; }

modules["sets.js"] = (function() {
    const exports = {};

    // The contents of the sets.js file go here:
    exports.BitSet = class BitSet { ... };

    return exports;
}());

modules["stats.js"] = (function() {
    const exports = {};

    // The contents of the stats.js file go here:
    const sum = (x, y) => x + y;
    const square = x = > x * x;
    exports.mean = function(data) { ... };
    exports.stddev = function(data) { ... };

    return exports;
}());

將模組捆綁成一個單一檔案,就像前面示例中所示的那樣,你可以想象編寫以下程式碼來利用這些模組:

// Get references to the modules (or the module content) that we need
const stats = require("stats.js");
const BitSet = require("sets.js").BitSet;

// Now write code using those modules
let s = new BitSet(100);
s.insert(10);
s.insert(20);
s.insert(30);
let average = stats.mean([...s]); // average is 20

這段程式碼是對程式碼捆綁工具(如 webpack 和 Parcel)在 web 瀏覽器中的工作原理的粗略草圖,也是對類似於 Node 程式中使用的 require() 函式的簡單介紹。

10.2 Node 模組

在 Node 程式設計中,將程式分割為儘可能多的檔案是很正常的。這些 JavaScript 程式碼檔案都假定存在於一個快速的檔案系統上。與 web 瀏覽器不同,後者必須透過相對較慢的網路連線讀取 JavaScript 檔案,將 Node 程式捆綁成一個單一的 JavaScript 檔案既沒有必要也沒有好處。

在 Node 中,每個檔案都是具有私有名稱空間的獨立模組。在一個檔案中定義的常量、變數、函式和類對該檔案是私有的,除非檔案匯出它們。一個模組匯出的值只有在另一個模組明確匯入它們時才能看到。

Node 模組透過 require() 函式匯入其他模組,並透過設定 Exports 物件的屬性或完全替換 module.exports 物件來匯出它們的公共 API。

10.2.1 Node 匯出

Node 定義了一個全域性的 exports 物件,它總是被定義。如果你正在編寫一個匯出多個值的 Node 模組,你可以簡單地將它們分配給這個物件的屬性:

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

exports.mean = data => data.reduce(sum)/data.length;
exports.stddev = function(d) {
    let m = exports.mean(d);
    return Math.sqrt(d.map(x => x - m).map(square).reduce(sum)/(d.length-1));
};

然而,通常情況下,你可能只想定義一個僅匯出單個函式或類的模組,而不是一個充滿函式或類的物件。為此,你只需將要匯出的單個值分配給 module.exports

module.exports = class BitSet extends AbstractWritableSet {
    // implementation omitted
};

module.exports 的預設值是 exports 所指向的相同物件。在之前的 stats 模組中,我們可以將 mean 函式分配給 module.exports.mean 而不是 exports.mean。像 stats 模組這樣的模組的另一種方法是在模組末尾匯出一個單一物件,而不是在匯出函式時逐個匯出:

// Define all the functions, public and private
const sum = (x, y) => x + y;
const square = x => x * x;
const mean = data => data.reduce(sum)/data.length;
const stddev = d => {
    let m = mean(d);
    return Math.sqrt(d.map(x => x - m).map(square).reduce(sum)/(d.length-1));
};

// Now export only the public ones
module.exports = { mean, stddev };

10.2.2 Node 匯入

一個 Node 模組透過呼叫 require() 函式來匯入另一個模組。這個函式的引數是要匯入的模組的名稱,返回值是該模組匯出的任何值(通常是一個函式、類或物件)。

如果你想匯入 Node 內建的系統模組或透過包管理器在系統上安裝的模組,那麼你只需使用模組的未限定名稱,不需要任何將其轉換為檔案系統路徑的“/”字元:

// These modules are built in to Node
const fs = require("fs");           // The built-in filesystem module
const http = require("http");       // The built-in HTTP module

// The Express HTTP server framework is a third-party module.
// It is not part of Node but has been installed locally
const express = require("express");

當您想要匯入自己程式碼的模組時,模組名稱應該是包含該程式碼的檔案的路徑,相對於當前模組檔案。使用以/字元開頭的絕對路徑是合法的,但通常,當匯入屬於您自己程式的模組時,模組名稱將以*./ 或有時是../ *開頭,以指示它們相對於當前目錄或父目錄。例如:

const stats = require('./stats.js');
const BitSet = require('./utils/bitset.js');

(您也可以省略匯入檔案的.js字尾,Node 仍然可以找到這些檔案,但通常會看到這些副檔名明確包含在內。)

當一個模組只匯出一個函式或類時,您只需匯入它。當一個模組匯出一個具有多個屬性的物件時,您可以選擇:您可以匯入整個物件,或者只匯入您打算使用的物件的特定屬性(使用解構賦值)。比較這兩種方法:

// Import the entire stats object, with all of its functions
const stats = require('./stats.js');

// We've got more functions than we need, but they're neatly
// organized into a convenient "stats" namespace.
let average = stats.mean(data);

// Alternatively, we can use idiomatic destructuring assignment to import
// exactly the functions we want directly into the local namespace:
const { stddev } = require('./stats.js');

// This is nice and succinct, though we lose a bit of context
// without the 'stats' prefix as a namspace for the stddev() function.
let sd = stddev(data);

10.2.3 Web 上的 Node 風格模組

具有 Exports 物件和require()函式的模組內建於 Node 中。但是,如果您願意使用像 webpack 這樣的捆綁工具處理您的程式碼,那麼也可以將這種模組樣式用於旨在在 Web 瀏覽器中執行的程式碼。直到最近,這是一種非常常見的做法,您可能會看到許多仍在這樣做的基於 Web 的程式碼。

現在 JavaScript 有了自己的標準模組語法,然而,使用捆綁工具的開發人員更有可能使用帶有importexport語句的官方 JavaScript 模組。

10.3 ES6 中的模組

ES6 為 JavaScript 新增了importexport關鍵字,最終將真正的模組化支援作為核心語言特性。ES6 的模組化在概念上與 Node 的模組化相同:每個檔案都是自己的模組,檔案中定義的常量、變數、函式和類除非明確匯出,否則都是私有於該模組。從一個模組匯出的值可以在明確匯入它們的模組中使用。ES6 模組在匯出和匯入的語法以及在 Web 瀏覽器中定義模組的方式上與 Node 模組不同。接下來的部分將詳細解釋這些內容。

首先,請注意,ES6 模組在某些重要方面也與常規 JavaScript“指令碼”不同。最明顯的區別是模組化本身:在常規指令碼中,變數、函式和類的頂級宣告進入由所有指令碼共享的單個全域性上下文中。使用模組後,每個檔案都有自己的私有上下文,並且可以使用importexport語句,這畢竟是整個重點。但模組和指令碼之間還有其他區別。ES6 模組中的程式碼(就像 ES6 class定義內的程式碼一樣)自動處於嚴格模式(參見§5.6.3)。這意味著,當您開始使用 ES6 模組時,您將永遠不必再編寫"use strict"。這意味著模組中的程式碼不能使用with語句或arguments物件或未宣告的變數。ES6 模組甚至比嚴格模式稍微嚴格:在嚴格模式中,作為函式呼叫的函式中,thisundefined。在模組中,即使在頂級程式碼中,this也是undefined。(相比之下,Web 瀏覽器和 Node 中的指令碼將this設定為全域性物件。)

Web 上和 Node 中的 ES6 模組

多年來,藉助像 webpack 這樣的程式碼捆綁工具,ES6 模組已經在 Web 上得到了應用,這些工具將獨立的 JavaScript 程式碼模組組合成大型、非模組化的捆綁包,適合包含在網頁中。然而,在撰寫本文時,除了 Internet Explorer 之外,所有 Web 瀏覽器終於原生支援 ES6 模組。在原生支援時,ES6 模組透過特殊的<script type="module">標籤新增到 HTML 頁面中,本章後面將對此進行描述。

與此同時,作為 JavaScript 模組化的先驅,Node 發現自己處於一個尷尬的位置,必須支援兩種不完全相容的模組系統。Node 13 支援 ES6 模組,但目前,絕大多數 Node 程式仍然使用 Node 模組。

10.3.1 ES6 匯出

要從 ES6 模組中匯出常量、變數、函式或類,只需在宣告之前新增關鍵字export

export const PI = Math.PI;

export function degreesToRadians(d) { return d * PI / 180; }

export class Circle {
    constructor(r) { this.r = r; }
    area() { return PI * this.r * this.r; }
}

作為在模組中散佈export關鍵字的替代方案,你可以像通常一樣定義常量、變數、函式和類,不寫任何export語句,然後(通常在模組的末尾)寫一個單獨的export語句,宣告在一個地方精確地匯出了什麼。因此,與在前面的程式碼中寫三個單獨的匯出相反,我們可以在末尾寫一行等效的程式碼:

export { Circle, degreesToRadians, PI };

這個語法看起來像是export關鍵字後跟一個物件字面量(使用簡寫表示法)。但在這種情況下,花括號實際上並沒有定義一個物件字面量。這種匯出語法只需要在花括號內寫一個逗號分隔的識別符號列表。

編寫只匯出一個值(通常是函式或類)的模組很常見,在這種情況下,我們通常使用export default而不是export

export default class BitSet {
    // implementation omitted
}

預設匯出比非預設匯出稍微容易匯入,因此當只有一個匯出值時,使用export default會使使用你匯出值的模組更容易。

使用export進行常規匯出只能用於具有名稱的宣告。使用export default進行預設匯出可以匯出任何表示式,包括匿名函式表示式和匿名類表示式。這意味著如果你使用export default,你可以匯出物件字面量。因此,與export語法不同,如果你在export default後看到花括號,那麼實際上匯出的是一個物件字面量。

模組既有一組常規匯出又有預設匯出是合法的,但有些不太常見。如果一個模組有預設匯出,它只能有一個。

最後,請注意export關鍵字只能出現在你的 JavaScript 程式碼的頂層。你不能從類、函式、迴圈或條件語句中匯出值。(這是 ES6 模組系統的一個重要特性,它實現了靜態分析:模組的匯出在每次執行時都是相同的,並且可以在模組實際執行之前確定匯出的符號。)

10.3.2 ES6 匯入

你可以使用import關鍵字匯入其他模組匯出的值。最簡單的匯入形式用於定義預設匯出的模組:

import BitSet from './bitset.js';

這是import關鍵字,後面跟著一個識別符號,然後是from關鍵字,後面是一個字串字面量,命名了我們要匯入預設匯出的模組。指定模組的預設匯出值成為當前模組中指定識別符號的值。

匯入值分配給的識別符號是一個常量,就好像它已經用const關鍵字宣告過一樣。與匯出一樣,匯入只能出現在模組的頂層,不允許在類、函式、迴圈或條件語句中。根據普遍慣例,模組所需的匯入應放在模組的開頭。然而有趣的是,這並非必須:像函式宣告一樣,匯入被“提升”到頂部,所有匯入的值在模組的任何程式碼執行時都可用。

匯入值的模組被指定為一個常量字串文字,用單引號或雙引號括起來。(您不能使用值為字串的變數或其他表示式,也不能在反引號內使用字串,因為模板文字可以插入變數並且不總是具有常量值。)在 Web 瀏覽器中,此字串被解釋為相對於執行匯入操作的模組的位置的 URL。(在 Node 中,或者使用捆綁工具時,該字串被解釋為相對於當前模組的檔名,但在實踐中這幾乎沒有區別。)模組規範符字串必須是以“/”開頭的絕對路徑,或以“./”或“../”開頭的相對路徑,或具有協議和主機名的完整 URL。ES6 規範不允許未經限定的模組規範符字串,如“util.js”,因為不清楚這是否意味著要命名與當前目錄中的模組或某種安裝在某個特殊位置的系統模組。 (這個對“裸模組規範符”的限制不被像 webpack 這樣的程式碼捆綁工具所遵守,它可以很容易地配置為在您指定的庫目錄中找到裸模組。)語言的未來版本可能允許“裸模組規範符”,但目前不允許。如果要從與當前目錄相同的目錄匯入模組,只需在模組名稱前加上“./”,並從“./util.js”而不是“util.js”匯入。

到目前為止,我們只考慮了從使用export default的模組匯入單個值的情況。要從匯出多個值的模組匯入值,我們使用稍微不同的語法:

import { mean, stddev } from "./stats.js";

請記住,預設匯出在定義它們的模組中不需要名稱。相反,當我們匯入這些值時,我們提供一個本地名稱。但是,模組的非預設匯出在匯出模組中有名稱,當我們匯入這些值時,我們透過這些名稱引用它們。匯出模組可以匯出任意數量的命名值。引用該模組的import語句可以透過在花括號內列出它們的名稱來匯入這些值的任意子集。花括號使這種import語句看起來有點像解構賦值,實際上,解構賦值是這種匯入樣式在做的事情的一個很好的類比。花括號內的識別符號都被提升到匯入模組的頂部,並且行為像常量。

樣式指南有時建議您明確匯入模組將使用的每個符號。但是,當從定義許多匯出的模組匯入時,您可以輕鬆地使用像這樣的import語句匯入所有內容:

import * as stats from "./stats.js";

像這樣的import語句會建立一個物件,並將其賦值給名為stats的常量。被匯入模組的每個非預設匯出都成為這個stats物件的屬性。非預設匯出始終有名稱,並且這些名稱在物件內部用作屬性名稱。這些屬性實際上是常量:它們不能被覆蓋或刪除。在前面示例中顯示的萬用字元匯入中,匯入模組將透過stats物件使用匯入的mean()stddev()函式,呼叫它們為stats.mean()stats.stddev()

模組通常定義一個預設匯出或多個命名匯出。一個模組同時使用exportexport default是合法的,但有點不常見。但是當一個模組這樣做時,您可以使用像這樣的import語句同時匯入預設值和命名值:

import Histogram, { mean, stddev } from "./histogram-stats.js";

到目前為止,我們已經看到了如何從具有預設匯出的模組和具有非預設或命名匯出的模組匯入。但是還有一種import語句的形式,用於沒有任何匯出的模組。要將沒有任何匯出的模組包含到您的程式中,只需使用import關鍵字與模組規範符:

import "./analytics.js";

這樣的模組在第一次匯入時執行。(隨後的匯入不會執行任何操作。)一個僅定義函式的模組只有在匯出其中至少一個函式時才有用。但是,如果一個模組執行一些程式碼,那麼即使沒有符號,匯入它也是有用的。一個用於 Web 應用程式的分析模組可能會執行程式碼來註冊各種事件處理程式,然後在適當的時候使用這些事件處理程式將遙測資料傳送回伺服器。該模組是自包含的,不需要匯出任何內容,但我們仍然需要import它,以便它實際上作為我們程式的一部分執行。

請注意,即使有匯出的模組,您也可以使用這種匯入空內容的import語法。如果一個模組定義了獨立於其匯出值的有用行為,並且您的程式不需要任何這些匯出值,您仍然可以匯入該模組。只是為了那個預設行為。

10.3.3 匯入和重新命名匯出

如果兩個模組使用相同名稱匯出兩個不同的值,並且您想要匯入這兩個值,那麼在匯入時您將需要重新命名其中一個或兩個值。同樣,如果您想要匯入一個名稱已在您的模組中使用的值,那麼您將需要重新命名匯入的值。您可以使用帶有命名匯入的as關鍵字來重新命名它們:

import { render as renderImage } from "./imageutils.js";
import { render as renderUI } from "./ui.js";

這些行將兩個函式匯入當前模組。這兩個函式在定義它們的模組中都被命名為render(),但在匯入時使用更具描述性和消除歧義的名稱renderImage()renderUI()

請記住,預設匯出沒有名稱。匯入模組在匯入預設匯出時總是選擇名稱。因此,在這種情況下不需要特殊的重新命名語法。

話雖如此,但在匯入時重新命名的可能性提供了另一種從定義預設匯出和命名匯出的模組中匯入的方式。回想一下前一節中的“./histogram-stats.js”模組。以下是匯入該模組的預設匯出和命名匯出的另一種方式:

import { default as Histogram, mean, stddev } from "./histogram-stats.js";

在這種情況下,JavaScript 關鍵字default充當佔位符,並允許我們指示我們要匯入併為模組的預設匯出提供名稱。

也可以在匯出時重新命名值,但只能在使用花括號變體的export語句時。通常不需要這樣做,但如果您在模組內選擇了簡短、簡潔的名稱,您可能更喜歡使用更具描述性的名稱匯出值,這樣就不太可能與其他模組發生衝突。與匯入一樣,您可以使用as關鍵字來執行此操作:

export {
    layout as calculateLayout,
    render as renderLayout
};

請記住,儘管花括號看起來有點像物件文字,但它們並不是,export關鍵字在as之前期望一個識別符號,而不是一個表示式。這意味著不幸的是,您不能像這樣使用匯出重新命名:

export { Math.sin as sin, Math.cos as cos }; // SyntaxError

10.3.4 重新匯出

在本章中,我們討論了一個假設的“./stats.js”模組,該模組匯出mean()stddev()函式。如果我們正在編寫這樣一個模組,並且我們認為該模組的許多使用者只想要其中一個函式,那麼我們可能希望在“./stats/mean.js”模組中定義mean(),在“./stats/stddev.js”中定義stddev()。這樣,程式只需要匯入它們需要的函式,而不會因匯入不需要的程式碼而臃腫。

即使我們將這些統計函式定義在單獨的模組中,我們可能仍然希望有很多程式需要這兩個函式,並且希望有一個方便的“./stats.js”模組,可以在一行中匯入這兩個函式。

鑑於現在實現在單獨的檔案中,定義這個“./stat.js”模組很簡單:

import { mean } from "./stats/mean.js";
import { stddev } from "./stats/stddev.js";
export { mean, stdev };

ES6 模組預期這種用法併為其提供了特殊的語法。不再簡單地匯入一個符號再匯出它,你可以將匯入和匯出步驟合併為一個單獨的“重新匯出”語句,使用export關鍵字和from關鍵字:

export { mean } from "./stats/mean.js";
export { stddev } from "./stats/stddev.js";

請注意,此程式碼中實際上沒有使用meanstddev這兩個名稱。如果我們不選擇性地重新匯出並且只想從另一個模組匯出所有命名值,我們可以使用萬用字元:

export * from "./stats/mean.js";
export * from "./stats/stddev.js";

重新匯出語法允許使用as進行重新命名,就像常規的importexport語句一樣。假設我們想要重新匯出mean()函式,但同時為該函式定義average()作為另一個名稱。我們可以這樣做:

export { mean, mean as average } from "./stats/mean.js";
export { stddev } from "./stats/stddev.js";

該示例中的所有重新匯出都假定“./stats/mean.js”和“./stats/stddev.js”模組使用export而不是export default匯出它們的函式。實際上,由於這些是隻有一個匯出的模組,定義為export default是有意義的。如果我們這樣做了,那麼重新匯出語法會稍微複雜一些,因為它需要為未命名的預設匯出定義一個名稱。我們可以這樣做:

export { default as mean } from "./stats/mean.js";
export { default as stddev } from "./stats/stddev.js";

如果你想要將另一個模組的命名符號重新匯出為你的模組的預設匯出,你可以進行import,然後進行export default,或者你可以將這兩個語句結合起來,像這樣:

// Import the mean() function from ./stats.js and make it the
// default export of this module
export { mean as default } from "./stats.js"

最後,要將另一個模組的預設匯出重新匯出為你的模組的預設匯出(儘管不清楚為什麼要這樣做,因為使用者可以直接匯入另一個模組),你可以這樣寫:

// The average.js module simply re-exports the stats/mean.js default export
export { default } from "./stats/mean.js"

10.3.5 Web 上的 JavaScript 模組

前面的章節以一種相對抽象的方式描述了 ES6 模組及其importexport宣告。在本節和下一節中,我們將討論它們在 Web 瀏覽器中的實際工作方式,如果你還不是一名經驗豐富的 Web 開發人員,你可能會發現在閱讀第十五章之後更容易理解本章的其餘內容。

截至 2020 年初,使用 ES6 模組的生產程式碼仍然通常與類似 webpack 的工具捆綁在一起。這樣做存在一些權衡之處,¹但總體上,程式碼捆綁往往能提供更好的效能。隨著網路速度的增長和瀏覽器廠商繼續最佳化他們的 ES6 模組實現,這種情況可能會發生變化。

儘管捆綁工具在生產中仍然可取,但在開發中不再需要,因為所有當前的瀏覽器都提供了對 JavaScript 模組的原生支援。請記住,模組預設使用嚴格模式,this不指向全域性物件,並且頂級宣告預設情況下不會在全域性範圍內共享。由於模組必須以與傳統非模組程式碼不同的方式執行,它們的引入需要對 HTML 和 JavaScript 進行更改。如果你想在 Web 瀏覽器中原生使用import指令,你必須透過使用<script type="module">標籤告訴 Web 瀏覽器你的程式碼是一個模組。

ES6 模組的一個很好的特性是每個模組都有一個靜態的匯入集合。因此,給定一個起始模組,Web 瀏覽器可以載入所有匯入的模組,然後載入第一批模組匯入的所有模組,依此類推,直到完整的程式被載入。我們已經看到import語句中的模組指示符可以被視為相對 URL。<script type="module">標籤標記了模組化程式的起點。然而,它匯入的模組都不應該在<script>標籤中,而是按需作為常規 JavaScript 檔案載入,並像常規 ES6 模組一樣以嚴格模式執行。使用<script type="module">標籤來定義模組化 JavaScript 程式的主入口點可以像這樣簡單:

<script type="module">import "./main.js";</script>

內聯<script type="module">標籤中的程式碼是 ES6 模組,因此可以使用export語句。然而,這樣做沒有任何意義,因為 HTML <script>標籤語法沒有提供任何定義內聯模組名稱的方式,因此,即使這樣的模組匯出一個值,也沒有辦法讓另一個模組匯入它。

帶有type="module"屬性的指令碼會像帶有defer屬性的指令碼一樣載入和執行。程式碼載入會在 HTML 解析器遇到<script>標籤時開始(在模組的情況下,這個程式碼載入步驟可能是一個遞迴過程,載入多個 JavaScript 檔案)。但是程式碼執行直到 HTML 解析完成才開始。一旦 HTML 解析完成,指令碼(模組和非模組)將按照它們在 HTML 文件中出現的順序執行。

您可以使用async屬性修改模組的執行時間,這對於模組和常規指令碼的工作方式是相同的。async模組將在程式碼載入後立即執行,即使 HTML 解析尚未完成,即使這會改變指令碼的相對順序。

支援<script type="module">的 Web 瀏覽器也必須支援<script nomodule>。瞭解模組的瀏覽器會忽略帶有nomodule屬性的任何指令碼並且不會執行它。不支援模組的瀏覽器將不識別nomodule屬性,因此它們會忽略它並執行指令碼。這為處理瀏覽器相容性問題提供了一個強大的技術。支援 ES6 模組的瀏覽器還支援其他現代 JavaScript 特性,如類、箭頭函式和for/of迴圈。如果您編寫現代 JavaScript 並使用<script type="module">載入它,您知道它只會被支援的瀏覽器載入。作為 IE11 的備用方案(在 2020 年,實際上是唯一一個不支援 ES6 的瀏覽器),您可以使用類似 Babel 和 webpack 的工具將您的程式碼轉換為非模組化的 ES5 程式碼,然後透過<script nomodule>載入這些效率較低的轉換程式碼。

常規指令碼和模組指令碼之間的另一個重要區別與跨域載入有關。常規的<script>標籤將從網際網路上的任何伺服器載入 JavaScript 程式碼檔案,網際網路的廣告、分析和跟蹤程式碼基礎設施依賴於這一事實。但是<script type="module">提供了一種加強這一點的機會,模組只能從包含 HTML 文件的同一源載入,或者在適當的 CORS 標頭放置以安全地允許跨源載入時才能載入。這種新的安全限制的一個不幸副作用是,它使得使用file: URL 在開發模式下測試 ES6 模組變得困難。使用 ES6 模組時,您可能需要設定一個靜態 Web 伺服器進行測試。

一些程式設計師喜歡使用副檔名.mjs來區分他們的模組化 JavaScript 檔案和傳統.js副檔名的常規非模組化 JavaScript 檔案。對於 Web 瀏覽器和<script>標籤來說,副檔名實際上是無關緊要的。(但 MIME 型別是相關的,因此如果您使用.mjs檔案,您可能需要配置您的 Web 伺服器以相同的 MIME 型別提供它們,如.js檔案。)Node 對 ES6 的支援確實使用副檔名作為提示來區分它載入的每個檔案使用的模組系統。因此,如果您編寫 ES6 模組並希望它們能夠在 Node 中使用,採用.mjs命名約定可能會有所幫助。

10.3.6 使用 import()進行動態匯入

我們已經看到 ES6 的 importexport 指令是完全靜態的,並且使 JavaScript 直譯器和其他 JavaScript 工具能夠在載入模組時透過簡單的文字分析確定模組之間的關係,而無需實際執行模組中的任何程式碼。使用靜態匯入的模組,你可以確保匯入到模組中的值在你的模組中的任何程式碼開始執行之前就已經準備好供使用。

在 Web 上,程式碼必須透過網路傳輸,而不是從檔案系統中讀取。一旦傳輸,該程式碼通常在相對較慢的移動裝置上執行。這不是靜態模組匯入(需要在任何程式碼執行之前載入整個程式)有很多意義的環境。

Web 應用程式通常只載入足夠的程式碼來渲染使用者看到的第一頁。然後,一旦使用者有一些初步內容可以互動,它們就可以開始載入通常需要更多的程式碼來完成網頁應用程式的其餘部分。Web 瀏覽器透過使用 DOM API 將新的 <script> 標籤注入到當前 HTML 文件中,使動態載入程式碼變得容易,而 Web 應用程式多年來一直在這樣做。

儘管動態載入已經很久了,但它並不是語言本身的一部分。這在 ES2020 中發生了變化(截至 2020 年初,支援 ES6 模組的所有瀏覽器都支援動態匯入)。你將一個模組規範傳遞給 import(),它將返回一個代表載入和執行指定模組的非同步過程的 Promise 物件。當動態匯入完成時,Promise 將“完成”(請參閱 第十三章 瞭解有關非同步程式設計和 Promise 的完整細節),併產生一個物件,就像你使用靜態匯入語句的 import * as 形式一樣。

因此,我們可以像這樣靜態匯入“./stats.js”模組:

import * as stats from "./stats.js";

我們可以像這樣動態匯入並使用它:

import("./stats.js").then(stats => {
    let average = stats.mean(data);
})

或者,在一個 async 函式中(再次,你可能需要在理解這段程式碼之前閱讀 第十三章),我們可以用 await 簡化程式碼:

async analyzeData(data) {
    let stats = await import("./stats.js");
    return {
        average: stats.mean(data),
        stddev: stats.stddev(data)
    };
}

import() 的引數應該是一個模組規範,就像你會在靜態 import 指令中使用的那樣。但是使用 import(),你不受限於使用常量字串文字:任何表示式只要以正確形式評估為字串即可。

動態 import() 看起來像一個函式呼叫,但實際上不是。相反,import() 是一個運算子,括號是運算子語法的必需部分。這種不尋常的語法之所以存在是因為 import() 需要能夠將模組規範解析為相對於當前執行模組的 URL,這需要一些實現魔法,這是不合法的放在 JavaScript 函式中的。在實踐中,函式與運算子的區別很少有影響,但如果嘗試編寫像 console.log(import);let require = import; 這樣的程式碼,你會注意到這一點。

最後,請注意動態 import() 不僅適用於 Web 瀏覽器。程式碼打包工具如 webpack 也可以很好地利用它。使用程式碼捆綁器的最簡單方法是告訴它程式的主入口點,讓它找到所有靜態 import 指令並將所有內容組裝成一個大檔案。然而,透過策略性地使用動態 import() 呼叫,你可以將這個單一的龐大捆綁拆分成一組可以按需載入的較小捆綁。

10.3.7 import.meta.url

ES6 模組系統的最後一個特性需要討論。在 ES6 模組中(但不在常規的 <script> 或使用 require() 載入的 Node 模組中),特殊語法 import.meta 指的是一個包含有關當前執行模組的後設資料的物件。該物件的 url 屬性是載入模組的 URL。(在 Node 中,這將是一個 file:// URL。)

import.meta.url 的主要用例是能夠引用儲存在與模組相同目錄中(或相對於模組)的影像、資料檔案或其他資源。URL() 建構函式使得相對 URL 相對於絕對 URL(如 import.meta.url)容易解析。例如,假設你編寫了一個模組,其中包含需要本地化的字串,並且本地化檔案儲存在與模組本身相同目錄中的 l10n/ 目錄中。你的模組可以使用類似這樣的函式建立的 URL 載入其字串:

function localStringsURL(locale) {
    return new URL(`l10n/${locale}.json`, import.meta.url);
}

10.4 總結

模組化的目標是允許程式設計師隱藏其程式碼的實現細節,以便來自各種來源的程式碼塊可以組裝成大型程式,而不必擔心一個程式碼塊會覆蓋另一個的函式或變數。本章已經解釋了三種不同的 JavaScript 模組系統:

  • 在 JavaScript 的早期,模組化只能透過巧妙地使用立即呼叫的函式表示式來實現。

  • Node 在 JavaScript 語言之上新增了自己的模組系統。Node 模組透過 require() 匯入,並透過設定 Exports 物件的屬性或設定 module.exports 屬性來定義它們的匯出。

  • 在 ES6 中,JavaScript 終於擁有了自己的模組系統,使用 importexport 關鍵字,而 ES2020 正在新增對使用 import() 進行動態匯入的支援。

¹ 例如:經常進行增量更新並且使用者頻繁返回訪問的 Web 應用程式可能會發現,使用小模組而不是大捆綁包可以更好地利用使用者瀏覽器快取,從而導致更好的平均載入時間。

第十一章:JavaScript 標準庫

一些資料型別,如數字和字串(第三章)、物件(第六章)和陣列(第七章)對於 JavaScript 來說是如此基礎,以至於我們可以將它們視為語言本身的一部分。本章涵蓋了其他重要但不太基礎的 API,可以被視為 JavaScript 的“標準庫”:這些是內建於 JavaScript 中的有用類和函式,可供所有 Web 瀏覽器和 Node 中的 JavaScript 程式使用。¹

本章的各節相互獨立,您可以按任意順序閱讀它們。它們涵蓋了:

  • Set 和 Map 類,用於表示值的集合和從一個值集合到另一個值集合的對映。

  • 型別化陣列(TypedArrays)等類似陣列的物件,表示二進位制資料的陣列,以及用於從非陣列二進位制資料中提取值的相關類。

  • 正規表示式和 RegExp 類,定義文字模式,對文字處理很有用。本節還詳細介紹了正規表示式語法。

  • Date 類用於表示和操作日期和時間。

  • Error 類及其各種子類的例項,當 JavaScript 程式發生錯誤時丟擲。

  • JSON 物件,其方法支援對由物件、陣列、字串、數字和布林值組成的 JavaScript 資料結構進行序列化和反序列化。

  • Intl 物件及其定義的類,可幫助您本地化 JavaScript 程式。

  • Console 物件,其方法以特別有用的方式輸出字串,用於除錯程式和記錄程式的行為。

  • URL 類簡化了解析和操作 URL 的任務。本節還涵蓋了用於對 URL 及其元件進行編碼和解碼的全域性函式。

  • setTimeout()及相關函式用於指定在經過指定時間間隔後執行的程式碼。

本章中的一些部分,尤其是關於型別化陣列和正規表示式的部分,由於您需要理解的重要背景資訊較多,因此相當長。然而,其他許多部分很短:它們只是介紹一個新的 API 並展示其使用示例。

11.1 集合和對映

JavaScript 的 Object 型別是一種多功能的資料結構,可以用來將字串(物件的屬性名稱)對映到任意值。當被對映的值是像true這樣固定的值時,那麼物件實際上就是一組字串。

在 JavaScript 程式設計中,物件實際上經常被用作對映和集合,但由於限制為字串並且物件通常繼承具有諸如“toString”之類名稱的屬性,這使得使用起來有些複雜,通常這些屬性並不打算成為對映或集合的一部分。

出於這個原因,ES6 引入了真正的 Set 和 Map 類,我們將在接下來的子章節中介紹。

11.1.1 Set 類

集合是一組值,類似於陣列。但與陣列不同,集合沒有順序或索引,並且不允許重複:一個值要麼是集合的成員,要麼不是成員;無法詢問一個值在集合中出現多少次。

使用Set()建構函式建立一個 Set 物件:

let s = new Set();       // A new, empty set
let t = new Set([1, s]); // A new set with two members

Set()建構函式的引數不一定是陣列:任何可迭代物件(包括其他 Set 物件)都是允許的:

let t = new Set(s);                  // A new set that copies the elements of s.
let unique = new Set("Mississippi"); // 4 elements: "M", "i", "s", and "p"

集合的size屬性類似於陣列的length屬性:它告訴你集合包含多少個值:

unique.size        // => 4

建立集合時無需初始化。您可以隨時使用add()delete()clear()新增和刪除元素。請記住,集合不能包含重複項,因此向集合新增已包含的值不會產生任何效果:

let s = new Set();  // Start empty
s.size              // => 0
s.add(1);           // Add a number
s.size              // => 1; now the set has one member
s.add(1);           // Add the same number again
s.size              // => 1; the size does not change
s.add(true);        // Add another value; note that it is fine to mix types
s.size              // => 2
s.add([1,2,3]);     // Add an array value
s.size              // => 3; the array was added, not its elements
s.delete(1)         // => true: successfully deleted element 1
s.size              // => 2: the size is back down to 2
s.delete("test")    // => false: "test" was not a member, deletion failed
s.delete(true)      // => true: delete succeeded
s.delete([1,2,3])   // => false: the array in the set is different
s.size              // => 1: there is still that one array in the set
s.clear();          // Remove everything from the set
s.size              // => 0

關於這段程式碼有幾個重要的要點需要注意:

  • add()方法接受一個引數;如果傳遞一個陣列,它會將陣列本身新增到集合中,而不是單獨的陣列元素。但是,add()始終返回撥用它的集合,因此如果要向集合新增多個值,可以使用鏈式方法呼叫,如s.add('a').add('b').add('c');

  • delete()方法也僅一次刪除單個集合元素。但是,與add()不同,delete()返回一個布林值。如果您指定的值實際上是集合的成員,則delete()會將其刪除並返回true。否則,它不執行任何操作並返回false

  • 最後,非常重要的是要理解集合成員是基於嚴格的相等性檢查的,就像===運算子執行的那樣。集合可以包含數字1和字串"1",因為它認為它們是不同的值。當值為物件(或陣列或函式)時,它們也被視為使用===進行比較。這就是為什麼我們無法從此程式碼中的集合中刪除陣列元素的原因。我們向集合新增了一個陣列,然後嘗試透過向delete()方法傳遞一個不同的陣列(儘管具有相同元素)來刪除該陣列。為了使其工作,我們必須傳遞對完全相同的陣列的引用。

注意

Python 程式設計師請注意:這是 JavaScript 和 Python 集合之間的一個重要區別。Python 集合比較成員的相等性,而不是身份,但這樣做的代價是 Python 集合只允許不可變成員,如元組,並且不允許將列表和字典新增到集合中。

在實踐中,我們與集合最重要的事情不是向其中新增和刪除元素,而是檢查指定的值是否是集合的成員。我們使用has()方法來實現這一點:

let oneDigitPrimes = new Set([2,3,5,7]);
oneDigitPrimes.has(2)    // => true: 2 is a one-digit prime number
oneDigitPrimes.has(3)    // => true: so is 3
oneDigitPrimes.has(4)    // => false: 4 is not a prime
oneDigitPrimes.has("5")  // => false: "5" is not even a number

關於集合最重要的一點是它們被最佳化用於成員測試,無論集合有多少成員,has()方法都會非常快。陣列的includes()方法也執行成員測試,但所需時間與陣列的大小成正比,使用陣列作為集合可能比使用真正的 Set 物件慢得多。

Set 類是可迭代的,這意味著您可以使用for/of迴圈列舉集合的所有元素:

let sum = 0;
for(let p of oneDigitPrimes) { // Loop through the one-digit primes
    sum += p;                  // and add them up
}
sum                            // => 17: 2 + 3 + 5 + 7

因為 Set 物件是可迭代的,您可以使用...擴充套件運算子將它們轉換為陣列和引數列表:

[...oneDigitPrimes]         // => [2,3,5,7]: the set converted to an Array
Math.max(...oneDigitPrimes) // => 7: set elements passed as function arguments

集合經常被描述為“無序集合”。然而,對於 JavaScript Set 類來說,這並不完全正確。JavaScript 集合是無索引的:您無法像陣列那樣請求集合的第一個或第三個元素。但是 JavaScript Set 類始終記住元素插入的順序,並且在迭代集合時始終使用此順序:插入的第一個元素將是首個迭代的元素(假設您尚未首先刪除它),最近插入的元素將是最後一個迭代的元素。²

除了可迭代外,Set 類還實現了類似於陣列同名方法的forEach()方法:

let product = 1;
oneDigitPrimes.forEach(n => { product *= n; });
product     // => 210: 2 * 3 * 5 * 7

陣列的forEach()將陣列索引作為第二個引數傳遞給您指定的函式。集合沒有索引,因此 Set 類的此方法簡單地將元素值作為第一個和第二個引數傳遞。

11.1.2 Map 類

Map 物件表示一組稱為的值,其中每個鍵都有另一個與之關聯(或“對映到”)的值。在某種意義上,對映類似於陣列,但是不同於使用一組順序整數作為鍵,對映允許我們使用任意值作為“索引”。與陣列一樣,對映很快:查詢與鍵關聯的值將很快(儘管不像索引陣列那樣快),無論對映有多大。

使用Map()建構函式建立一個新的對映:

let m = new Map();  // Create a new, empty map
let n = new Map([   // A new map initialized with string keys mapped to numbers
    ["one", 1],
    ["two", 2]
]);

Map() 建構函式的可選引數應該是一個可迭代物件,產生兩個元素 [key, value] 陣列。在實踐中,這意味著如果你想在建立 map 時初始化它,你通常會將所需的鍵和關聯值寫成陣列的陣列。但你也可以使用 Map() 建構函式複製其他 map,或從現有物件複製屬性名和值:

let copy = new Map(n); // A new map with the same keys and values as map n
let o = { x: 1, y: 2}; // An object with two properties
let p = new Map(Object.entries(o)); // Same as new map([["x", 1], ["y", 2]])

一旦你建立了一個 Map 物件,你可以使用 get() 查詢與給定鍵關聯的值,並可以使用 set() 新增新的鍵/值對。但請記住,map 是一組鍵,每個鍵都有一個關聯的值。這與一組鍵/值對並不完全相同。如果你使用一個已經存在於 map 中的鍵呼叫 set(),你將改變與該鍵關聯的值,而不是新增一個新的鍵/值對映。除了 get()set(),Map 類還定義了類似 Set 方法的方法:使用 has() 檢查 map 是否包含指定的鍵;使用 delete() 從 map 中刪除一個鍵(及其關聯的值);使用 clear() 從 map 中刪除所有鍵/值對;使用 size 屬性查詢 map 包含多少個鍵。

let m = new Map();   // Start with an empty map
m.size               // => 0: empty maps have no keys
m.set("one", 1);     // Map the key "one" to the value 1
m.set("two", 2);     // And the key "two" to the value 2.
m.size               // => 2: the map now has two keys
m.get("two")         // => 2: return the value associated with key "two"
m.get("three")       // => undefined: this key is not in the set
m.set("one", true);  // Change the value associated with an existing key
m.size               // => 2: the size doesn't change
m.has("one")         // => true: the map has a key "one"
m.has(true)          // => false: the map does not have a key true
m.delete("one")      // => true: the key existed and deletion succeeded
m.size               // => 1
m.delete("three")    // => false: failed to delete a nonexistent key
m.clear();           // Remove all keys and values from the map

像 Set 的 add() 方法一樣,Map 的 set() 方法可以連結,這允許初始化 map 而不使用陣列的陣列:

let m = new Map().set("one", 1).set("two", 2).set("three", 3);
m.size        // => 3
m.get("two")  // => 2

與 Set 一樣,任何 JavaScript 值都可以用作 Map 中的鍵或值。這包括 nullundefinedNaN,以及物件和陣列等引用型別。與 Set 類一樣,Map 透過標識比較鍵,而不是透過相等性比較,因此如果你使用物件或陣列作為鍵,它將被認為與每個其他物件和陣列都不同,即使它們具有完全相同的屬性或元素:

let m = new Map();   // Start with an empty map.
m.set({}, 1);        // Map one empty object to the number 1.
m.set({}, 2);        // Map a different empty object to the number 2.
m.size               // => 2: there are two keys in this map
m.get({})            // => undefined: but this empty object is not a key
m.set(m, undefined); // Map the map itself to the value undefined.
m.has(m)             // => true: m is a key in itself
m.get(m)             // => undefined: same value we'd get if m wasn't a key

Map 物件是可迭代的,每個迭代的值都是一個包含兩個元素的陣列,第一個元素是鍵,第二個元素是與該鍵關聯的值。如果你使用展開運算子與 Map 物件一起使用,你將得到一個類似於我們傳遞給 Map() 建構函式的陣列的陣列。在使用 for/of 迴圈迭代 map 時,慣用的做法是使用解構賦值將鍵和值分配給單獨的變數:

let m = new Map([["x", 1], ["y", 2]]);
[...m]    // => [["x", 1], ["y", 2]]

for(let [key, value] of m) {
    // On the first iteration, key will be "x" and value will be 1
    // On the second iteration, key will be "y" and value will be 2
}

像 Set 類一樣,Map 類按插入順序進行迭代。迭代的第一個鍵/值對將是最近新增到 map 中的鍵/值對,而迭代的最後一個鍵/值對將是最近新增的鍵/值對。

如果你想僅迭代 map 的鍵或僅迭代關聯的值,請使用 keys()values() 方法:這些方法返回可迭代物件,按插入順序迭代鍵和值。(entries() 方法返回一個可迭代物件,按鍵/值對迭代,但這與直接迭代 map 完全相同。)

[...m.keys()]     // => ["x", "y"]: just the keys
[...m.values()]   // => [1, 2]: just the values
[...m.entries()]  // => [["x", 1], ["y", 2]]: same as [...m]

Map 物件也可以使用首次由 Array 類實現的 forEach() 方法進行迭代。

m.forEach((value, key) => {  // note value, key NOT key, value
    // On the first invocation, value will be 1 and key will be "x"
    // On the second invocation, value will be 2 and key will be "y"
});

可能會覺得上面的程式碼中值引數在鍵引數之前有些奇怪,因為在 for/of 迭代中,鍵首先出現。正如本節開頭所述,你可以將 map 視為一個廣義的陣列,其中整數陣列索引被任意鍵值替換。陣列的 forEach() 方法首先傳遞陣列元素,然後傳遞陣列索引,因此,類比地,map 的 forEach() 方法首先傳遞 map 值,然後傳遞 map 鍵。

11.1.3 WeakMap 和 WeakSet

WeakMap 類是 Map 類的變體(但不是實際的子類),不會阻止其鍵值被垃圾回收。垃圾回收是 JavaScript 直譯器回收不再“可達”的物件記憶體的過程,這些物件不能被程式使用。常規對映保持對其鍵值的“強”引用,它們透過對映保持可達性,即使所有對它們的其他引用都消失了。相比之下,WeakMap 對其鍵值保持“弱”引用,因此它們不可透過 WeakMap 訪問,它們在對映中的存在不會阻止其記憶體被回收。

WeakMap()建構函式與Map()建構函式完全相同,但 WeakMap 和 Map 之間存在一些重要的區別:

  • WeakMap 的鍵必須是物件或陣列;原始值不受垃圾回收的影響,不能用作鍵。

  • WeakMap 只實現了get()set()has()delete()方法。特別是,WeakMap 不可迭代,並且不定義keys()values()forEach()。如果 WeakMap 是可迭代的,那麼它的鍵將是可達的,它就不會是弱引用的。

  • 同樣,WeakMap 也不實現size屬性,因為 WeakMap 的大小隨時可能會隨著物件被垃圾回收而改變。

WeakMap 的預期用途是允許您將值與物件關聯而不會導致記憶體洩漏。例如,假設您正在編寫一個函式,該函式接受一個物件引數並需要對該物件執行一些耗時的計算。為了效率,您希望快取計算後的值以供以後重用。如果使用 Map 物件來實現快取,將阻止任何物件被回收,但使用 WeakMap,您可以避免這個問題。(您通常可以使用私有 Symbol 屬性直接在物件上快取計算後的值來實現類似的結果。參見§6.10.3。)

WeakSet 實現了一組物件,不會阻止這些物件被垃圾回收。WeakSet()建構函式的工作方式類似於Set()建構函式,但 WeakSet 物件與 Set 物件的區別與 WeakMap 物件與 Map 物件的區別相同:

  • WeakSet 不允許原始值作為成員。

  • WeakSet 只實現了add()has()delete()方法,並且不可迭代。

  • WeakSet 沒有size屬性。

WeakSet 並不經常使用:它的用例類似於 WeakMap。如果你想標記(或“品牌化”)一個物件具有某些特殊屬性或型別,例如,你可以將其新增到 WeakSet 中。然後,在其他地方,當你想檢查該屬性或型別時,可以測試該 WeakSet 的成員資格。使用常規集合會阻止所有標記物件被垃圾回收,但使用 WeakSet 時不必擔心這個問題。

11.2 型別化陣列和二進位制資料

常規 JavaScript 陣列可以具有任何型別的元素,並且可以動態增長或縮小。JavaScript 實現執行許多最佳化,使得 JavaScript 陣列的典型用法非常快速。然而,它們與低階語言(如 C 和 Java)的陣列型別仍然有很大不同。型別化陣列是 ES6 中的新功能,³它們更接近這些語言的低階陣列。型別化陣列在技術上不是陣列(Array.isArray()對它們返回false),但它們實現了§7.8 中描述的所有陣列方法以及一些自己的方法。然而,它們與常規陣列在一些非常重要的方面有所不同:

  • 型別化陣列的元素都是數字。然而,與常規 JavaScript 數字不同,型別化陣列允許您指定要儲存在陣列中的數字的型別(有符號和無符號整數和 IEEE-754 浮點數)和大小(8 位到 64 位)。

  • 建立型別化陣列時必須指定其長度,並且該長度永遠不會改變。

  • 型別化陣列的元素在建立陣列時始終初始化為 0。

11.2.1 型別化陣列型別

JavaScript 沒有定義 TypedArray 類。相反,有 11 種型別化陣列,每種具有不同的元素型別和建構函式:

建構函式 數值型別
Int8Array() 有符號位元組
Uint8Array() 無符號位元組
Uint8ClampedArray() 無溢位的無符號位元組
Int16Array() 有符號 16 位短整數
Uint16Array() 無符號 16 位短整數
Int32Array() 有符號 32 位整數
Uint32Array() 無符號 32 位整數
BigInt64Array() 有符號 64 位 BigInt 值(ES2020)
BigUint64Array() 無符號 64 位 BigInt 值(ES2020)
Float32Array() 32 位浮點值
Float64Array() 64 位浮點值:普通的 JavaScript 數字

名稱以 Int 開頭的型別儲存有符號整數,佔用 1、2 或 4 位元組(8、16 或 32 位)。名稱以 Uint 開頭的型別儲存相同長度的無符號整數。名稱為 “BigInt” 和 “BigUint” 的型別儲存 64 位整數,以 BigInt 值的形式表示在 JavaScript 中(參見 §3.2.5)。以 Float 開頭的型別儲存浮點數。Float64Array 的元素與普通的 JavaScript 數字相同型別。Float32Array 的元素精度較低,範圍較小,但只需一半的記憶體。 (在 C 和 Java 中,此型別稱為 float。)

Uint8ClampedArrayUint8Array 的特殊變體。這兩種型別都儲存無符號位元組,可以表示 0 到 255 之間的數字。對於 Uint8Array,如果將大於 255 或小於零的值儲存到陣列元素中,它會“環繞”,並且會得到其他值。這是計算機記憶體在低階別上的工作原理,因此速度非常快。Uint8ClampedArray 進行了一些額外的型別檢查,以便如果儲存大於 255 或小於 0 的值,則會“夾緊”到 255 或 0,而不會環繞。 (這種夾緊行為是 HTML <canvas> 元素的低階 API 用於操作畫素顏色所必需的。)

每個型別化陣列建構函式都有一個 BYTES_PER_ELEMENT 屬性,其值為 1、2、4 或 8,取決於型別。

11.2.2 建立型別化陣列

建立型別化陣列的最簡單方法是呼叫適當的建構函式,並提供一個數字引數,指定陣列中要包含的元素數量:

let bytes = new Uint8Array(1024);    // 1024 bytes
let matrix = new Float64Array(9);    // A 3x3 matrix
let point = new Int16Array(3);       // A point in 3D space
let rgba = new Uint8ClampedArray(4); // A 4-byte RGBA pixel value
let sudoku = new Int8Array(81);      // A 9x9 sudoku board

透過這種方式建立型別化陣列時,陣列元素都保證初始化為 00n0.0。但是,如果您知道要在型別化陣列中使用的值,也可以在建立陣列時指定這些值。每個型別化陣列建構函式都有靜態的 from()of() 工廠方法,類似於 Array.from()Array.of()

let white = Uint8ClampedArray.of(255, 255, 255, 0);  // RGBA opaque white

請記住,Array.from() 工廠方法的第一個引數應為類似陣列或可迭代物件。對於型別化陣列變體也是如此,只是可迭代或類似陣列的物件還必須具有數值元素。例如,字串是可迭代的,但將它們傳遞給型別化陣列的 from() 工廠方法是沒有意義的。

如果只使用 from() 的單引數版本,可以省略 .from 並直接將可迭代或類似陣列物件傳遞給建構函式,其行為完全相同。請注意,建構函式和 from() 工廠方法都允許您複製現有的型別化陣列,同時可能更改型別:

let ints = Uint32Array.from(white);  // The same 4 numbers, but as ints

當從現有陣列、可迭代物件或類似陣列物件建立新的型別化陣列時,值可能會被截斷以符合陣列的型別約束。當發生這種情況時,不會有警告或錯誤:

// Floats truncated to ints, longer ints truncated to 8 bits
Uint8Array.of(1.23, 2.99, 45000) // => new Uint8Array([1, 2, 200])

最後,還有一種使用 ArrayBuffer 型別建立 typed arrays 的方法。ArrayBuffer 是一個對一塊記憶體的不透明引用。你可以用建構函式建立一個,只需傳入你想要分配的記憶體位元組數:

let buffer = new ArrayBuffer(1024*1024);
buffer.byteLength   // => 1024*1024; one megabyte of memory

ArrayBuffer 類不允許你讀取或寫入你分配的任何位元組。但你可以建立使用 buffer 記憶體的 typed arrays,並且允許你讀取和寫入該記憶體。為此,呼叫 typed array 建構函式,第一個引數是一個 ArrayBuffer,第二個引數是陣列緩衝區內的位元組偏移量,第三個引數是陣列長度(以元素而不是位元組計算)。第二和第三個引數是可選的。如果兩者都省略,則陣列將使用陣列緩衝區中的所有記憶體。如果只省略長度引數,則你的陣列將使用從起始位置到陣列結束的所有可用記憶體。關於這種形式的 typed array 建構函式還有一件事要記住:陣列必須是記憶體對齊的,所以如果你指定了一個位元組偏移量,該值應該是你的型別大小的倍數。例如,Int32Array() 建構函式需要四的倍數,而 Float64Array() 需要八的倍數。

給定之前建立的 ArrayBuffer,你可以建立這樣的 typed arrays:

let asbytes = new Uint8Array(buffer);          // Viewed as bytes
let asints = new Int32Array(buffer);           // Viewed as 32-bit signed ints
let lastK = new Uint8Array(buffer, 1023*1024); // Last kilobyte as bytes
let ints2 = new Int32Array(buffer, 1024, 256); // 2nd kilobyte as 256 integers

這四種 typed arrays 提供了對由 ArrayBuffer 表示的記憶體的四種不同檢視。重要的是要理解,所有 typed arrays 都有一個底層的 ArrayBuffer,即使你沒有明確指定一個。如果你呼叫一個 typed array 建構函式而沒有傳遞一個 buffer 物件,一個適當大小的 buffer 將會被自動建立。正如後面所描述的,任何 typed array 的 buffer 屬性都指向它的底層 ArrayBuffer 物件。直接使用 ArrayBuffer 物件的原因是有時你可能想要有一個單一 buffer 的多個 typed array 檢視。

11.2.3 使用 Typed Arrays

一旦你建立了一個 typed array,你可以用常規的方括號表示法讀取和寫入它的元素,就像你對待任何其他類似陣列的物件一樣:

// Return the largest prime smaller than n, using the sieve of Eratosthenes
function sieve(n) {
    let a = new Uint8Array(n+1);         // a[x] will be 1 if x is composite
    let max = Math.floor(Math.sqrt(n));  // Don't do factors higher than this
    let p = 2;                           // 2 is the first prime
    while(p <= max) {                    // For primes less than max
        for(let i = 2*p; i <= n; i += p) // Mark multiples of p as composite
            a[i] = 1;
        while(a[++p]) /* empty */;       // The next unmarked index is prime
    }
    while(a[n]) n--;                     // Loop backward to find the last prime
    return n;                            // And return it
}

這裡的函式計算比你指定的數字小的最大質數。程式碼與使用常規 JavaScript 陣列完全相同,但在我的測試中使用 Uint8Array() 而不是 Array() 使程式碼執行速度超過四倍,並且使用的記憶體少了八倍。

Typed arrays 不是真正的陣列,但它們重新實現了大多數陣列方法,所以你可以幾乎像使用常規陣列一樣使用它們:

let ints = new Int16Array(10);       // 10 short integers
ints.fill(3).map(x=>x*x).join("")    // => "9999999999"

記住,typed arrays 有固定的長度,所以 length 屬性是隻讀的,而改變陣列長度的方法(如 push()pop()unshift()shift()splice())對 typed arrays 沒有實現。改變陣列內容而不改變長度的方法(如 sort()reverse()fill())是實現的。返回新陣列的 map()slice() 等方法返回與呼叫它們的 typed array 相同型別的 typed array。

11.2.4 Typed Array 方法和屬性

除了標準陣列方法外,typed arrays 也實現了一些自己的方法。set() 方法透過將常規或 typed array 的元素複製到 typed array 中一次設定多個元素:

let bytes = new Uint8Array(1024);        // A 1K buffer
let pattern = new Uint8Array([0,1,2,3]); // An array of 4 bytes
bytes.set(pattern);      // Copy them to the start of another byte array
bytes.set(pattern, 4);   // Copy them again at a different offset
bytes.set([0,1,2,3], 8); // Or just copy values direct from a regular array
bytes.slice(0, 12)       // => new Uint8Array([0,1,2,3,0,1,2,3,0,1,2,3])

set() 方法以陣列或 typed array 作為第一個引數,以元素偏移量作為可選的第二個引數,如果未指定則預設為 0。如果你從一個 typed array 複製值到另一個,這個操作可能會非常快。

Typed arrays 還有一個 subarray 方法,返回撥用它的陣列的一部分:

let ints = new Int16Array([0,1,2,3,4,5,6,7,8,9]);       // 10 short integers
let last3 = ints.subarray(ints.length-3, ints.length);  // Last 3 of them
last3[0]       // => 7: this is the same as ints[7]

subarray()接受與slice()方法相同的引數,並且似乎工作方式相同。但有一個重要的區別。slice()返回一個新的、獨立的型別化陣列,其中包含指定的元素,不與原始陣列共享記憶體。subarray()不復制任何記憶體;它只返回相同底層值的新檢視:

ints[9] = -1;  // Change a value in the original array and...
last3[2]       // => -1: it also changes in the subarray

subarray()方法返回現有陣列的新檢視,這讓我們回到了 ArrayBuffers 的話題。每個型別化陣列都有三個與底層緩衝區相關的屬性:

last3.buffer                 // The ArrayBuffer object for a typed array
last3.buffer === ints.buffer // => true: both are views of the same buffer
last3.byteOffset             // => 14: this view starts at byte 14 of the buffer
last3.byteLength             // => 6: this view is 6 bytes (3 16-bit ints) long
last3.buffer.byteLength      // => 20: but the underlying buffer has 20 bytes

buffer屬性是陣列的 ArrayBuffer。byteOffset是陣列資料在底層緩衝區中的起始位置。byteLength是陣列資料的位元組長度。對於任何型別化陣列a,這個不變式應該始終成立:

a.length * a.BYTES_PER_ELEMENT === a.byteLength  // => true

ArrayBuffer 只是不透明的位元組塊。您可以使用型別化陣列訪問這些位元組,但 ArrayBuffer 本身不是型別化陣列。但要小心:您可以像在任何 JavaScript 物件上一樣使用數字陣列索引訪問 ArrayBuffers。這樣做並不會讓您訪問緩衝區中的位元組,但可能會導致混亂的錯誤:

let bytes = new Uint8Array(8);
bytes[0] = 1;           // Set the first byte to 1
bytes.buffer[0]         // => undefined: buffer doesn't have index 0
bytes.buffer[1] = 255;  // Try incorrectly to set a byte in the buffer
bytes.buffer[1]         // => 255: this just sets a regular JS property
bytes[1]                // => 0: the line above did not set the byte

我們之前看到,您可以使用ArrayBuffer()建構函式建立一個 ArrayBuffer,然後建立使用該緩衝區的型別化陣列。另一種方法是建立一個初始型別化陣列,然後使用該陣列的緩衝區建立其他檢視:

let bytes = new Uint8Array(1024);            // 1024 bytes
let ints = new Uint32Array(bytes.buffer);    // or 256 integers
let floats = new Float64Array(bytes.buffer); // or 128 doubles

11.2.5 DataView 和位元組順序

型別化陣列允許您以 8、16、32 或 64 位的塊檢視相同的位元組序列。這暴露了“位元組序”:位元組被排列成更長字的順序。為了效率,型別化陣列使用底層硬體的本機位元組順序。在小端系統上,數字的位元組從最不重要到最重要的順序排列在 ArrayBuffer 中。在大端平臺上,位元組從最重要到最不重要的順序排列。您可以使用以下程式碼確定底層平臺的位元組順序:

// If the integer 0x00000001 is arranged in memory as 01 00 00 00, then
// we're on a little-endian platform. On a big-endian platform, we'd get
// bytes 00 00 00 01 instead.
let littleEndian = new Int8Array(new Int32Array([1]).buffer)[0] === 1;

如今,最常見的 CPU 架構是小端。然而,許多網路協議和一些二進位制檔案格式要求大端位元組順序。如果您正在使用來自網路或檔案的資料的型別化陣列,您不能僅僅假設平臺的位元組順序與資料的位元組順序相匹配。一般來說,在處理外部資料時,您可以使用 Int8Array 和 Uint8Array 將資料視為單個位元組的陣列,但不應使用其他具有多位元組字長的型別化陣列。相反,您可以使用 DataView 類,該類定義了用於從具有明確定義的位元組順序的 ArrayBuffer 中讀取和寫入值的方法:

// Assume we have a typed array of bytes of binary data to process. First,
// we create a DataView object so we can flexibly read and write
// values from those bytes
let view = new DataView(bytes.buffer,
                        bytes.byteOffset,
                        bytes.byteLength);

let int = view.getInt32(0);     // Read big-endian signed int from byte 0
int = view.getInt32(4, false);  // Next int is also big-endian
int = view.getUint32(8, true);  // Next int is little-endian and unsigned
view.setUint32(8, int, false);  // Write it back in big-endian format

DataView 為每個 10 個型別化陣列類定義了 10 個get方法(不包括 Uint8ClampedArray)。它們的名稱類似於getInt16()getUint32()getBigInt64()getFloat64()。第一個引數是 ArrayBuffer 中數值開始的位元組偏移量。除了getInt8()getUint8()之外,所有這些獲取方法都接受一個可選的布林值作為第二個引數。如果省略第二個引數或為false,則使用大端位元組順序。如果第二個引數為true,則使用小端順序。

DataView 還定義了 10 個相應的 Set 方法,用於將值寫入底層 ArrayBuffer。第一個引數是值開始的偏移量。第二個引數是要寫入的值。除了setInt8()setUint8()之外,每個方法都接受一個可選的第三個引數。如果省略引數或為false,則以大端格式寫入值,最重要的位元組在前。如果引數為true,則以小端格式寫入值,最不重要的位元組在前。

型別化陣列和 DataView 類為您提供了處理二進位制資料所需的所有工具,並使您能夠編寫執行諸如解壓縮 ZIP 檔案或從 JPEG 檔案中提取後設資料等操作的 JavaScript 程式。

11.3 使用正規表示式進行模式匹配

正規表示式是描述文字模式的物件。JavaScript RegExp 類表示正規表示式,String 和 RegExp 都定義了使用正規表示式執行強大的模式匹配和搜尋替換功能的方法。然而,為了有效地使用 RegExp API,您還必須學習如何使用正規表示式語法描述文字模式,這本質上是一種自己的迷你程式語言。幸運的是,JavaScript 正規表示式語法與許多其他程式語言使用的語法非常相似,因此您可能已經熟悉它。 (如果您不熟悉,學習 JavaScript 正規表示式所投入的努力可能也對您在其他程式設計環境中有所幫助。)

接下來的小節首先描述了正規表示式語法,然後,在解釋如何編寫正規表示式之後,它們解釋瞭如何使用它們與 String 和 RegExp 類的方法。

11.3.1 定義正規表示式

在 JavaScript 中,正規表示式由 RegExp 物件表示。當然,RegExp 物件可以使用RegExp()建構函式建立,但更常見的是使用特殊的字面量語法建立。正如字串字面量是在引號內指定的字元一樣,正規表示式字面量是在一對斜槓(/)字元內指定的字元。因此,您的 JavaScript 程式碼可能包含如下行:

let pattern = /s$/;

此行建立一個新的 RegExp 物件,並將其賦給變數pattern。這個特定的 RegExp 物件匹配任何以字母“s”結尾的字串。這個正規表示式也可以用RegExp()建構函式定義,就像這樣:

let pattern = new RegExp("s$");

正規表示式模式規範由一系列字元組成。大多數字符,包括所有字母數字字元,只是描述要匹配的字元。因此,正規表示式/java/匹配包含子字串“java”的任何字串。正規表示式中的其他字元不是字面匹配的,而是具有特殊意義。例如,正規表示式/s$/包含兩個字元。第一個“s”是字面匹配的。第二個“$”是一個特殊的元字元,匹配字串的結尾。因此,這個正規表示式匹配任何以字母“s”作為最後一個字元的字串。

正如我們將看到的,正規表示式也可以有一個或多個標誌字元,影響它們的工作方式。標誌是在 RegExp 文字的第二個斜槓字元後指定的,或者作為RegExp()建構函式的第二個字串引數。例如,如果我們想匹配以“s”或“S”結尾的字串,我們可以在正規表示式中使用i標誌,表示我們要進行不區分大小寫的匹配:

let pattern = /s$/i;

以下各節描述了 JavaScript 正規表示式中使用的各種字元和元字元。

字面字元

所有字母字元和數字在正規表示式中都以字面意義匹配自身。JavaScript 正規表示式語法還支援以反斜槓(\)開頭的轉義序列表示某些非字母字元。例如,序列\n在字串中匹配一個字面換行符。表 11-1 列出了這些字元。

表 11-1. 正規表示式字面字元

字元 匹配
字母數字字元 本身
\0 NUL 字元(\u0000
\t 製表符(\u0009
\n 換行符(\u000A
\v 垂直製表符(\u000B
\f 換頁符(\u000C
\r 回車符(\u000D
\xnn 十六進位制數字 nn 指定的拉丁字元;例如,\x0A 等同於 \n
\uxxxx 十六進位制數字 xxxx 指定的 Unicode 字元;例如,\u0009 等同於 \t
\u{n} 由程式碼點 n 指定的 Unicode 字元,其中 n 是 0 到 10FFFF 之間的一到六個十六進位制數字。請注意,此語法僅在使用 u 標誌的正規表示式中受支援。
\cX 控制字元 ^X;例如,\cJ 等同於換行符 \n

許多標點符號在正規表示式中具有特殊含義。它們是:

^ $ . * + ? = ! : | \ / ( ) [ ] { }

這些字元的含義將在接下來的章節中討論。其中一些字元僅在正規表示式的某些上下文中具有特殊含義,在其他上下文中被視為文字。然而,作為一般規則,如果要在正規表示式中字面包含任何這些標點符號,必須在其前面加上 \。其他標點符號,如引號和 @,沒有特殊含義,只是在正規表示式中字面匹配自身。

如果你記不清哪些標點符號需要用反斜槓轉義,你可以安全地在任何標點符號前面放置一個反斜槓。另一方面,請注意,許多字母和數字在前面加上反斜槓時具有特殊含義,因此任何你想字面匹配的字母或數字不應該用反斜槓轉義。要在正規表示式中字面包含反斜槓字元,當然必須用反斜槓轉義它。例如,以下正規表示式匹配包含反斜槓的任意字串:/\\/。(如果你使用 RegExp() 建構函式,請記住你的正規表示式中的任何反斜槓都需要加倍,因為字串也使用反斜槓作為跳脫字元。)

字元類

透過將單個文字字元組合到方括號中,可以形成字元類。字元類匹配其中包含的任意一個字元。因此,正規表示式 /[abc]/ 匹配字母 a、b 或 c 中的任意一個。也可以定義否定字元類;這些匹配除方括號中包含的字元之外的任意字元。否定字元類透過在左方括號內的第一個字元處放置插入符號(^)來指定。正規表示式 /[^abc]/ 匹配除 a、b 或 c 之外的任意一個字元。字元類可以使用連字元指示字元範圍。要匹配拉丁字母表中的任意一個小寫字母,請使用 /[a-z]/,要匹配拉丁字母表中的任意字母或數字,請使用 /[a-zA-Z0-9]/。(如果要在字元類中包含實際連字元,只需將其放在右方括號之前。)

由於某些字元類通常被使用,JavaScript 正規表示式語法包括特殊字元和轉義序列來表示這些常見類。例如,\s 匹配空格字元、製表符和任何其他 Unicode 空白字元;\S 匹配任何 Unicode 空白字元。表 11-2 列出了這些字元並總結了字元類語法。(請注意,其中幾個字元類轉義序列僅匹配 ASCII 字元,並未擴充套件為適用於 Unicode 字元。但是,你可以顯式定義自己的 Unicode 字元類;例如,/[\u0400-\u04FF]/ 匹配任意一個西裡爾字母字元。)

表格 11-2. 正規表示式字元類

字元 匹配
[...] 方括號內的任意一個字元。
[^...] 方括號內的任意一個字元。
. 除換行符或其他 Unicode 行終止符之外的任何字元。或者,如果 RegExp 使用 s 標誌,則句點匹配任何字元,包括行終止符。
\w 任何 ASCII 單詞字元。等同於 [a-zA-Z0-9_]
\W 任何不是 ASCII 單詞字元的字元。等同於 [^a-zA-Z0-9_]
\s 任何 Unicode 空白字元。
\S 任何不是 Unicode 空白字元的字元。
\d 任何 ASCII 數字。等同於 [0-9]
\D 任何 ASCII 數字之外的字元。等同於 [⁰-9]
[\b] 一個字面退格(特殊情況)。

請注意,特殊字元類轉義可以在方括號內使用。\s 匹配任何空白字元,\d 匹配任何數字,因此 /[\s\d]/ 匹配任何一個空白字元或數字。請注意有一個特殊情況。正如稍後將看到的,\b 轉義具有特殊含義。但是,在字元類中使用時,它表示退格字元。因此,要在正規表示式中字面表示退格字元,請使用具有一個元素的字元類:/[\b]/

重複

到目前為止,您學到的正規表示式語法可以將兩位數描述為 /\d\d/,將四位數描述為 /\d\d\d\d/。但是,您沒有任何方法來描述,例如,可以具有任意數量的數字或三個字母后跟一個可選數字的字串。這些更復雜的模式使用指定正規表示式元素可以重複多少次的正規表示式語法。

指定重複的字元始終跟隨其應用的模式。由於某些型別的重複非常常見,因此有特殊字元來表示這些情況。例如,+ 匹配前一個模式的一個或多個出現。

表 11-3 總結了重複語法。

表 11-3. 正規表示式重複字元

字元 含義
{n,m} 匹配前一個專案至少 n 次但不超過 m 次。
{n,} 匹配前一個專案 n 次或更多次。
{n} 匹配前一個專案的 n 次出現。
? 匹配前一個專案的零次或一次出現。也就是說,前一個專案是可選的。等同於 {0,1}
+ 匹配前一個專案的一個或多個出現。等同於 {1,}
* 匹配前一個專案的零次或多次。等同於 {0,}

以下行顯示了一些示例:

let r = /\d{2,4}/; // Match between two and four digits
r = /\w{3}\d?/;    // Match exactly three word characters and an optional digit
r = /\s+java\s+/;  // Match "java" with one or more spaces before and after
r = /[^(]*/;       // Match zero or more characters that are not open parens

請注意,在所有這些示例中,重複說明符應用於它們之前的單個字元或字元類。如果要匹配更復雜表示式的重複,您需要使用括號定義一個組,這將在以下部分中解釋。

使用 *? 重複字元時要小心。由於這些字元可能匹配前面的內容的零次,它們允許匹配空內容。例如,正規表示式 /a*/ 實際上匹配字串“bbbb”,因為該字串不包含字母 a 的任何出現!

非貪婪重複

表 11-3 中列出的重複字元儘可能多次匹配,同時仍允許正規表示式的任何後續部分匹配。我們說這種重複是“貪婪的”。還可以指定以非貪婪方式進行重複。只需在重複字元後面跟一個問號:??+?*?,甚至 {1,5}?。例如,正規表示式 /a+/ 匹配一個或多個字母 a 的出現。當應用於字串“aaa”時,它匹配所有三個字母。但是 /a+?/ 匹配一個或多個字母 a 的出現,儘可能少地匹配字元。當應用於相同字串時,此模式僅匹配第一個字母 a。

使用非貪婪重複可能不總是產生您期望的結果。考慮模式/a+b/,它匹配一個或多個 a,後跟字母 b。當應用於字串“aaab”時,它匹配整個字串。現在讓我們使用非貪婪版本:/a+?b/。這應該匹配由儘可能少的 a 前導的字母 b。當應用於相同字串“aaab”時,您可能希望它僅匹配一個 a 和最後一個字母 b。但實際上,此模式與貪婪版本的模式一樣匹配整個字串。這是因為正規表示式模式匹配是透過找到字串中可能發生匹配的第一個位置來完成的。由於從字串的第一個字元開始就可能發生匹配,因此從後續字元開始的較短匹配甚至不會被考慮。

備選項、分組和引用

正規表示式語法包括用於指定備選項、分組子表示式和引用先前子表示式的特殊字元。|字元分隔備選項。例如,/ab|cd|ef/匹配字串“ab”或字串“cd”或字串“ef”。而/\d{3}|[a-z]{4}/匹配三個數字或四個小寫字母中的任何一個。

請注意,備選項從左到右考慮,直到找到匹配項。如果左側備選項匹配,則右側備選項將被忽略,即使它可能產生“更好”的匹配。因此,當將模式/a|ab/應用於字串“ab”時,它僅匹配第一個字母。

括號在正規表示式中有幾個目的。一個目的是將單獨的專案分組為單個子表示式,以便可以透過|*+?等將專案視為單個單元。例如,/java(script)?/匹配“java”後跟可選的“script”。而/(ab|cd)+|ef/匹配字串“ef”或一個或多個重複的字串“ab”或“cd”中的任何一個。

正規表示式中括號的另一個目的是在完整模式內定義子模式。當正規表示式成功匹配目標字串時,可以提取匹配任何特定括號子模式的目標字串部分。(您將在本節後面看到如何獲取這些匹配的子字串。)例如,假設您正在尋找一個或多個小寫字母后跟一個或多個數字。您可能會使用模式/[a-z]+\d+/。但是假設您只關心每個匹配末尾的數字。如果將模式的這部分放在括號中(/[a-z]+(\d+)/),您可以提取任何找到的匹配中的數字,如後面所述。

括號子表示式的一個相關用途是允許您在同一正規表示式中稍後引用子表示式。這是透過在\字元後跟一個或多個數字來完成的。這些數字指的是正規表示式中括號子表示式的位置。例如,\1引用第一個子表示式,\3引用第三個。請注意,由於子表示式可以巢狀在其他子表示式中,因此計算的是左括號的位置。例如,在以下正規表示式中,巢狀的子表示式([Ss]cript)被稱為\2

/([Jj]ava([Ss]cript)?)\sis\s(fun\w*)/

對正規表示式的先前子表示式的引用是指該子表示式的模式,而是指匹配該模式的文字。因此,引用可用於強制要求字串的不同部分包含完全相同的字元。例如,以下正規表示式匹配單引號或雙引號內的零個或多個字元。但是,它不要求開頭和結尾引號匹配(即,都是單引號或雙引號):

/['"][^'"]*['"]/

要求引號匹配,請使用引用:

/(['"])[^'"]*\1/

\1匹配第一個括號子表示式匹配的內容。在此示例中,它強制約束閉合引號與開放引號匹配。此正規表示式不允許單引號在雙引號字串內部,反之亦然。(在字元類內部使用引用是不合法的,因此不能寫成:/(['"])[^\1]*\1/。)

當我們稍後討論 RegExp API 時,您會看到對括號子表示式的引用是正規表示式搜尋和替換操作的一個強大功能。

也可以在正規表示式中分組專案而不建立對這些專案的編號引用。不要簡單地在()內部分組專案,而是從(?:開始組,以)結束。考慮以下模式:

/([Jj]ava(?:[Ss]cript)?)\sis\s(fun\w*)/

在此示例中,子表示式(?:[Ss]cript)僅用於分組,因此?重複字元可以應用於該組。這些修改後的括號不生成引用,因此在此正規表示式中,\2指的是由(fun\w*)匹配的文字。

表格 11-4 總結了正規表示式的交替、分組和引用運算子。

表格 11-4. 正規表示式的交替、分組和引用字元

字元 含義
&#124; 交替:匹配左側子表示式或右側子表示式。
(...) 分組:將專案分組為一個單元,可以與*+?&#124;等一起使用。還要記住匹配此組的字元,以便後續引用。
(?:...) 僅分組:將專案分組為一個單元,但不記住匹配此組的字元。
\n 匹配在第一次匹配組號 n 時匹配的相同字元。組是括號內的子表示式(可能是巢狀的)。組號是透過從左到右計算左括號來分配的。使用(?:形成的組不編號。

指定匹配位置

正如前面所述,正規表示式的許多元素匹配字串中的單個字元。例如,\s匹配單個空白字元。其他正規表示式元素匹配字元之間的位置而不是實際字元。例如,\b匹配 ASCII 單詞邊界——\w(ASCII 單詞字元)和\W(非單詞字元)之間的邊界,或者 ASCII 單詞字元和字串的開頭或結尾之間的邊界。[⁴] 元素如\b不指定要在匹配的字串中使用的任何字元;但它們指定的是合法的匹配位置。有時這些元素被稱為正規表示式錨點,因為它們將模式錨定到搜尋字串中的特定位置。最常用的錨定元素是^,將模式繫結到字串的開頭,以及$,將模式錨定到字串的結尾。

例如,要匹配單獨一行的單詞“JavaScript”,可以使用正規表示式/^JavaScript$/。如果要搜尋“Java”作為單獨的單詞(而不是作為“JavaScript”中的字首),可以嘗試模式/\sJava\s/,這需要單詞前後有空格。但是這種解決方案有兩個問題。首先,它不匹配字串的開頭或結尾的“Java”,而只有在兩側有空格時才匹配。其次,當此模式找到匹配時,返回的匹配字串具有前導和尾隨空格,這不是所需的。因此,與其用\s匹配實際空格字元,不如用\b匹配(或錨定)單詞邊界。得到的表示式是/\bJava\b/。元素\B將匹配錨定到不是單詞邊界的位置。因此,模式/\B[Ss]cript/匹配“JavaScript”和“postscript”,但不匹配“script”或“Scripting”。

您還可以使用任意正規表示式作為錨定條件。如果在(?=)字元之間包含一個表示式,那麼這是一個前瞻斷言,並且它指定封閉字元必須匹配,而不實際匹配它們。例如,要匹配一個常見程式語言的名稱,但只有在後面跟著一個冒號時,您可以使用/[Jj]ava([Ss]cript)?(?=\:)/。這個模式匹配“JavaScript”中的單詞“JavaScript: The Definitive Guide”,但不匹配“Java in a Nutshell”中的“Java”,因為它後面沒有跟著冒號。

如果您使用(?!引入斷言,那麼這是一個負向前瞻斷言,指定接下來的字元不得匹配。例如,/Java(?!Script)([A-Z]\w*)/匹配“Java”後跟一個大寫字母和任意數量的其他 ASCII 單詞字元,只要“Java”後面不跟著“Script”。它匹配“JavaBeans”但不匹配“Javanese”,它匹配“JavaScrip”但不匹配“JavaScript”或“JavaScripter”。表 11-5 總結了正規表示式錨點。

表 11-5. 正規表示式錨點字元

字元 含義
^ 匹配字串的開頭或者在使用m標誌時,匹配行的開頭。
` 字元
--- ---
^ 匹配字串的開頭或者在使用m標誌時,匹配行的開頭。
匹配字串的結尾,並且在使用m標誌時,匹配行的結尾。
\b 匹配單詞邊界。也就是說,匹配\w字元和\W字元之間的位置,或者匹配\w字元和字串的開頭或結尾之間的位置。(但請注意,[\b]匹配退格鍵。)
\B 匹配不是單詞邊界的位置。
(?=p) 正向前瞻斷言。要求接下來的字元匹配模式p,但不包括這些字元在匹配中。
(?!p) 負向前瞻斷言。要求接下來的字元不匹配模式p

標誌

每個正規表示式都可以有一個或多個與之關聯的標誌,以改變其匹配行為。JavaScript 定義了六個可能的標誌,每個標誌由一個字母表示。標誌在正規表示式字面量的第二個/字元之後指定,或者作為傳遞給RegExp()建構函式的第二個引數的字串。支援的標誌及其含義如下:

g

g標誌表示正規表示式是“全域性”的,也就是說,我們打算在字串中找到所有匹配項,而不僅僅是找到第一個匹配項。這個標誌不會改變匹配模式的方式,但正如我們稍後將看到的,它確實以重要的方式改變了 String match()方法和 RegExp exec()方法的行為。

i

i標誌指定匹配模式時應該忽略大小寫。

m

m標誌指定匹配應該在“多行”模式下進行。它表示正規表示式將與多行字串一起使用,並且^$錨點應該匹配字串的開頭和結尾,以及字串中各行的開頭和結尾。

s

m標誌類似,s標誌在處理包含換行符的文字時也很有用。通常,正規表示式中的“.”匹配除行終止符之外的任何字元。但是,當使用s標誌時,“.”將匹配任何字元,包括行終止符。s標誌在 ES2018 中新增到 JavaScript 中,並且截至 2020 年初,在 Node、Chrome、Edge 和 Safari 中支援,但在 Firefox 中不支援。

u

u標誌代表 Unicode,它使正規表示式匹配完整的 Unicode 程式碼點,而不是匹配 16 位值。這個標誌是在 ES6 中引入的,你應該養成在所有正規表示式上使用它的習慣,除非你有某種理由不這樣做。如果你不使用這個標誌,那麼你的正規表示式將無法很好地處理包含表情符號和其他需要超過 16 位的字元(包括許多中文字元)的文字。沒有u標誌,"."字元匹配任何 1 個 UTF-16 16 位值。然而,有了這個標誌,"."匹配一個 Unicode 程式碼點,包括那些超過 16 位的程式碼點。在正規表示式上設定u標誌還允許你使用新的\u{...}轉義序列來表示 Unicode 字元,並且還啟用了\p{...}表示 Unicode 字元類。

y

y標誌表示正規表示式是“粘性”的,應該在字串的開頭或上一個匹配項後的第一個字元處匹配。當與旨在找到單個匹配項的正規表示式一起使用時,它有效地將該正規表示式視為以^開頭以將其錨定到字串開頭。這個標誌在重複使用用於在字串中找到所有匹配項的正規表示式時更有用。在這種情況下,它導致 String match()方法和 RegExp exec()方法的特殊行為,以強制每個後續匹配項都錨定到上一個匹配項結束的字串位置。

這些標誌可以以任何組合和任何順序指定。例如,如果你希望你的正規表示式能夠識別 Unicode 以進行不區分大小寫的匹配,並且打算在字串中查詢多個匹配項,你可以指定標誌uiggui或這三個字母的任何其他排列。

11.3.2 用於模式匹配的字串方法

到目前為止,我們一直在描述用於定義正規表示式的語法,但沒有解釋這些正規表示式如何在 JavaScript 程式碼中實際使用。我們現在轉而介紹使用 RegExp 物件的 API。本節首先解釋了使用正規表示式執行模式匹配和搜尋替換操作的字串方法。接下來的部分將繼續討論使用 JavaScript 正規表示式進行模式匹配,討論 RegExp 物件及其方法和屬性。

字串支援四種使用正規表示式的方法。最簡單的是search()。這個方法接受一個正規表示式引數,並返回第一個匹配子字串的起始字元位置,如果沒有匹配則返回-1:

"JavaScript".search(/script/ui)  // => 4
"Python".search(/script/ui)      // => -1

如果search()的引數不是正規表示式,則首先透過將其傳遞給RegExp建構函式將其轉換為正規表示式。search()不支援全域性搜尋;它會忽略其正規表示式引數的g標誌。

replace()

replace()方法執行搜尋替換操作。它將正規表示式作為第一個引數,替換字串作為第二個引數。它在呼叫它的字串中搜尋與指定模式匹配的內容。如果正規表示式設定了g標誌,replace()方法將在字串中替換所有匹配項為替換字串;否則,它只會替換找到的第一個匹配項。如果replace()的第一個引數是一個字串而不是正規表示式,該方法會直接搜尋該字串而不是像search()那樣將其轉換為正規表示式。例如,你可以使用replace()如下提供文字字串中“JavaScript”一詞的統一大寫格式:

// No matter how it is capitalized, replace it with the correct capitalization
text.replace(/javascript/gi, "JavaScript");

然而,replace()比這更強大。回想一下,正規表示式的括號子表示式從左到右編號,並且正規表示式記住了每個子表示式匹配的文字。如果替換字串中出現了$後跟一個數字,replace()將用指定子表示式匹配的文字替換這兩個字元。這是一個非常有用的功能。例如,你可以使用它將字串中的引號替換為其他字元:

// A quote is a quotation mark, followed by any number of
// nonquotation mark characters (which we capture), followed
// by another quotation mark.
let quote = /"([^"]*)"/g;
// Replace the straight quotation marks with guillemets
// leaving the quoted text (stored in $1) unchanged.
'He said "stop"'.replace(quote, '«$1»')  // => 'He said «stop»'

如果你的正規表示式使用了命名捕獲組,那麼你可以透過名稱而不是數字引用匹配的文字:

let quote = /"(?<quotedText>[^"]*)"/g;
'He said "stop"'.replace(quote, '«$<quotedText>»')  // => 'He said «stop»'

不需要將替換字串作為第二個引數傳遞給replace(),你也可以傳遞一個函式作為替換值的計算方法。替換函式會被呼叫並傳入多個引數。首先是整個匹配的文字。接下來,如果正規表示式有捕獲組,那麼被這些組捕獲的子字串將作為引數傳遞。下一個引數是匹配被找到的字串中的位置。之後,呼叫replace()的整個字串也會被傳遞。最後,如果正規表示式包含任何命名捕獲組,替換函式的最後一個引數是一個物件,其屬性名與捕獲組名匹配,值為匹配的文字。例如,這裡是使用替換函式將字串中的十進位制整數轉換為十六進位制的程式碼:

let s = "15 times 15 is 225";
s.replace(/\d+/gu, n => parseInt(n).toString(16))  // => "f times f is e1"

match()

match()方法是 String 正規表示式方法中最通用的。它將正規表示式作為唯一引數(或透過將其傳遞給RegExp()建構函式將其引數轉換為正規表示式)並返回一個包含匹配結果的陣列,如果沒有找到匹配則返回null。如果正規表示式設定了g標誌,該方法將返回出現在字串中的所有匹配項的陣列。例如:

"7 plus 8 equals 15".match(/\d+/g)  // => ["7", "8", "15"]

如果正規表示式沒有設定g標誌,match()不會進行全域性搜尋;它只是搜尋第一個匹配項。在這種非全域性情況下,match()仍然返回一個陣列,但陣列元素完全不同。沒有g標誌時,返回陣列的第一個元素是匹配的字串,任何剩餘的元素是正規表示式中括號捕獲組匹配的子字串。因此,如果match()返回一個陣列aa[0]包含完整匹配,a[1]包含匹配第一個括號表示式的子字串,依此類推。與replace()方法類比,a[1]$1相同,a[2]$2相同,依此類推。

例如,考慮使用以下程式碼解析 URL⁵:

// A very simple URL parsing RegExp
let url = /(\w+):\/\/([\w.]+)\/(\S*)/;
let text = "Visit my blog at http://www.example.com/~david";
let match = text.match(url);
let fullurl, protocol, host, path;
if (match !== null) {
    fullurl = match[0];   // fullurl == "http://www.example.com/~david"
    protocol = match[1];  // protocol == "http"
    host = match[2];      // host == "www.example.com"
    path = match[3];      // path == "~david"
}

在這種非全域性情況下,match()返回的陣列除了編號陣列元素外還有一些物件屬性。input屬性指的是呼叫match()的字串。index屬性是匹配開始的字串位置。如果正規表示式包含命名捕獲組,那麼返回的陣列還有一個groups屬性,其值是一個物件。這個物件的屬性與命名組的名稱匹配,值為匹配的文字。例如,我們可以像這樣重新編寫之前的 URL 解析示例:

let url = /(?<protocol>\w+):\/\/(?<host>[\w.]+)\/(?<path>\S*)/;
let text = "Visit my blog at http://www.example.com/~david";
let match = text.match(url);
match[0]               // => "http://www.example.com/~david"
match.input            // => text
match.index            // => 17
match.groups.protocol  // => "http"
match.groups.host      // => "www.example.com"
match.groups.path      // => "~david"

我們已經看到,match() 的行為在正規表示式是否設定了 g 標誌時會有很大不同。當設定了 y 標誌時,行為也會有重要但不那麼顯著的差異。請記住,y 標誌透過限制匹配開始的位置使正規表示式“粘滯”。如果一個正規表示式同時設定了 gy 標誌,那麼 match() 返回一個匹配字串的陣列,就像在設定了 g 而沒有設定 y 時一樣。但第一個匹配必須從字串的開頭開始,每個後續匹配必須從前一個匹配的字元緊隨其後開始。

如果設定了 y 標誌但沒有設定 g,那麼 match() 會嘗試找到單個匹配,並且預設情況下,此匹配受限於字串的開頭。然而,您可以透過設定 RegExp 物件的 lastIndex 屬性來更改此預設匹配開始位置,指定要匹配的索引位置。如果找到匹配,那麼 lastIndex 將自動更新為匹配後的第一個字元,因此如果再次呼叫 match(),它將尋找下一個匹配。(lastIndex 可能看起來是一個奇怪的屬性名稱,它指定開始 下一個 匹配的位置。當我們討論 RegExp exec() 方法時,我們將再次看到它,這個名稱在那種情況下可能更有意義。)

let vowel = /[aeiou]/y;  // Sticky vowel match
"test".match(vowel)      // => null: "test" does not begin with a vowel
vowel.lastIndex = 1;     // Specify a different match position
"test".match(vowel)[0]   // => "e": we found a vowel at position 1
vowel.lastIndex          // => 2: lastIndex was automatically updated
"test".match(vowel)      // => null: no vowel at position 2
vowel.lastIndex          // => 0: lastIndex gets reset after failed match

值得注意的是,將非全域性正規表示式傳遞給字串的 match() 方法與將字串傳遞給正規表示式的 exec() 方法是相同的:返回的陣列及其屬性在這兩種情況下都是相同的。

matchAll()

matchAll() 方法在 ES2020 中定義,並且在 2020 年初已被現代 Web 瀏覽器和 Node 實現。matchAll() 期望一個設定了 g 標誌的正規表示式。然而,與 match() 返回匹配子字串的陣列不同,它返回一個迭代器,該迭代器產生與使用非全域性 RegExp 時 match() 返回的匹配物件相同的物件。這使得 matchAll() 成為遍歷字串中所有匹配的最簡單和最通用的方法。

您可以使用 matchAll() 遍歷文字字串中的單詞,如下所示:

// One or more Unicode alphabetic characters between word boundaries
const words = /\b\p{Alphabetic}+\b/gu; // \p is not supported in Firefox yet
const text = "This is a naïve test of the matchAll() method.";
for(let word of text.matchAll(words)) {
    console.log(`Found '${word[0]}' at index ${word.index}.`);
}

您可以設定 RegExp 物件的 lastIndex 屬性,告訴 matchAll() 在字串中的哪個索引開始匹配。然而,與其他模式匹配方法不同,matchAll() 永遠不會修改您呼叫它的 RegExp 的 lastIndex 屬性,這使得它在您的程式碼中更不容易出錯。

split()

String 物件的正規表示式方法中的最後一個是 split()。這個方法將呼叫它的字串分割成一個子字串陣列,使用引數作為分隔符。它可以像這樣使用一個字串引數:

"123,456,789".split(",")           // => ["123", "456", "789"]

split() 方法也可以接受正規表示式作為引數,這樣可以指定更通用的分隔符。在這裡,我們使用一個包含任意數量空白的分隔符來呼叫它:

"1, 2, 3,\n4, 5".split(/\s*,\s*/)  // => ["1", "2", "3", "4", "5"]

令人驚訝的是,如果你使用包含捕獲組的正規表示式分隔符呼叫 split(),那麼匹配捕獲組的文字將包含在返回的陣列中。例如:

const htmlTag = /<([^>]+)>/;  // < followed by one or more non->, followed by >
"Testing<br/>1,2,3".split(htmlTag)  // => ["Testing", "br/", "1,2,3"]

11.3.3 RegExp 類

本節介紹了 RegExp() 建構函式、RegExp 例項的屬性以及 RegExp 類定義的兩個重要模式匹配方法。

RegExp() 建構函式接受一個或兩個字串引數,並建立一個新的 RegExp 物件。這個建構函式的第一個引數是一個包含正規表示式主體的字串——在正規表示式字面量中出現在斜槓內的文字。請注意,字串字面量和正規表示式都使用 \ 字元作為轉義序列,因此當您將正規表示式作為字串字面量傳遞給 RegExp() 時,必須將每個 \ 字元替換為 \\RegExp() 的第二個引數是可選的。如果提供,它表示正規表示式的標誌。它應該是 gimsuy,或這些字母的任意組合。

例如:

// Find all five-digit numbers in a string. Note the double \\ in this case.
let zipcode = new RegExp("\\d{5}", "g");

RegExp() 建構函式在動態建立正規表示式時非常有用,因此無法使用正規表示式字面量語法表示。例如,要搜尋使用者輸入的字串,必須在執行時使用 RegExp() 建立正規表示式。

除了將字串作為 RegExp() 的第一個引數傳遞之外,您還可以傳遞一個 RegExp 物件。這允許您複製正規表示式並更改其標誌:

let exactMatch = /JavaScript/;
let caseInsensitive = new RegExp(exactMatch, "i");

RegExp 屬性

RegExp 物件具有以下屬性:

source

這是正規表示式的源文字的只讀屬性:在 RegExp 字面量中出現在斜槓之間的字元。

flags

這是一個只讀屬性,指定表示 RegExp 標誌的字母集合的字串。

global

一個只讀的布林屬性,如果設定了 g 標誌,則為 true。

ignoreCase

一個只讀的布林屬性,如果設定了 i 標誌,則為 true。

multiline

一個只讀的布林屬性,如果設定了 m 標誌,則為 true。

dotAll

一個只讀的布林屬性,如果設定了 s 標誌,則為 true。

unicode

一個只讀的布林屬性,如果設定了 u 標誌,則為 true。

sticky

一個只讀的布林屬性,如果設定了 y 標誌,則為 true。

lastIndex

這個屬性是一個讀/寫整數。對於具有 gy 標誌的模式,它指定下一次搜尋開始的字元位置。它由 exec()test() 方法使用,這兩個方法在下面的兩個小節中描述。

test()

RegExp 類的 test() 方法是使用正規表示式的最簡單的方法。它接受一個字串引數,並在字串與模式匹配時返回 true,否則返回 false

test() 的工作原理是簡單地呼叫(更復雜的)下一節中描述的 exec() 方法,並在 exec() 返回非空值時返回 true。因此,如果您使用帶有 gy 標誌的 RegExp 來使用 test(),那麼它的行為取決於 RegExp 物件的 lastIndex 屬性的值,這個值可能會意外更改。有關更多詳細資訊,請參閱“lastIndex 屬性和 RegExp 重用”。

exec()

RegExp exec() 方法是使用正規表示式的最通用和強大的方式。它接受一個字串引數,並在該字串中查詢匹配項。如果找不到匹配項,則返回 null。但是,如果找到匹配項,則返回一個陣列,就像對於非全域性搜尋的 match() 方法返回的陣列一樣。陣列的第 0 個元素包含與正規表示式匹配的字串,任何後續的陣列元素包含與任何捕獲組匹配的子字串。返回的陣列還具有命名屬性:index 屬性包含匹配發生的字元位置,input 屬性指定被搜尋的字串,如果定義了 groups 屬性,則指的是一個儲存與任何命名捕獲組匹配的子字串的物件。

與 String 的 match() 方法不同,exec() 無論正規表示式是否有全域性 g 標誌,都返回相同型別的陣列。回想一下,當傳遞一個全域性正規表示式時,match() 返回一個匹配陣列。相比之下,exec() 總是返回一個單一匹配,並提供關於該匹配的完整資訊。當在具有全域性 g 標誌或粘性 y 標誌的正規表示式上呼叫 exec() 時,它會檢視 RegExp 物件的 lastIndex 屬性,以確定從哪裡開始查詢匹配。如果設定了 y 標誌,它還會限制匹配從該位置開始。對於新建立的 RegExp 物件,lastIndex 為 0,並且搜尋從字串的開頭開始。但每次 exec() 成功找到一個匹配時,它會更新 lastIndex 屬性為匹配文字後面的字元的索引。如果 exec() 未找到匹配,它會將 lastIndex 重置為 0。這種特殊行為允許你重複呼叫 exec() 以迴圈遍歷字串中的所有正規表示式匹配。例如,以下程式碼中的迴圈將執行兩次:

let pattern = /Java/g;
let text = "JavaScript > Java";
let match;
while((match = pattern.exec(text)) !== null) {
    console.log(`Matched ${match[0]} at ${match.index}`);
    console.log(`Next search begins at ${pattern.lastIndex}`);
}

11.4 日期和時間

Date 類是 JavaScript 用於處理日期和時間的 API。使用 Date() 建構函式建立一個 Date 物件。如果沒有引數,它會返回一個代表當前日期和時間的 Date 物件:

let now = new Date();     // The current time

如果你傳遞一個數字引數,Date() 建構函式會將該引數解釋為自 1970 年起的毫秒數:

let epoch = new Date(0);  // Midnight, January 1st, 1970, GMT

如果你指定兩個或更多整數引數,它們會被解釋為年、月、日、小時、分鐘、秒和毫秒,使用你的本地時區,如下所示:

let century = new Date(2100,         // Year 2100
                       0,            // January
                       1,            // 1st
                       2, 3, 4, 5);  // 02:03:04.005, local time

Date API 的一個怪癖是,一年中的第一個月是數字 0,但一個月中的第一天是數字 1。如果省略時間欄位,Date() 建構函式會將它們全部預設為 0,將時間設定為午夜。

請注意,當使用多個數字呼叫 Date() 建構函式時,它會使用本地計算機設定的任何時區進行解釋。如果你想在 UTC(協調世界時,又稱 GMT)中指定日期和時間,那麼你可以使用 Date.UTC()。這個靜態方法接受與 Date() 建構函式相同的引數,在 UTC 中解釋它們,並返回一個毫秒時間戳,你可以傳遞給 Date() 建構函式:

// Midnight in England, January 1, 2100
let century = new Date(Date.UTC(2100, 0, 1));

如果你列印一個日期(例如使用 console.log(century)),預設情況下會以你的本地時區列印。如果你想在 UTC 中顯示一個日期,你應該明確地將其轉換為字串,使用 toUTCString()toISOString()

最後,如果你將一個字串傳遞給 Date() 建構函式,它將嘗試將該字串解析為日期和時間規範。建構函式可以解析由 toString()toUTCString()toISOString() 方法生成的格式指定的日期:

let century = new Date("2100-01-01T00:00:00Z");  // An ISO format date

一旦你有了一個 Date 物件,各種獲取和設定方法允許你查詢和修改 Date 的年、月、日、小時、分鐘、秒和毫秒欄位。每個方法都有兩種形式:一種使用本地時間進行獲取或設定,另一種使用 UTC 時間進行獲取或設定。例如,要獲取或設定 Date 物件的年份,你可以使用 getFullYear()getUTCFullYear()setFullYear()setUTCFullYear()

let d = new Date();                  // Start with the current date
d.setFullYear(d.getFullYear() + 1);  // Increment the year

要獲取或設定 Date 的其他欄位,將方法名稱中的“FullYear”替換為“Month”、“Date”、“Hours”、“Minutes”、“Seconds”或“Milliseconds”。一些日期設定方法允許你一次設定多個欄位。setFullYear()setUTCFullYear() 還可選擇設定月份和日期。而 setHours()setUTCHours() 還允許你指定分鐘、秒和毫秒欄位,除了小時欄位。

請注意,查詢日期的方法是getDate()getUTCDate()。更自然的函式getDay()getUTCDay()返回星期幾(星期日為 0,星期六為 6)。星期幾是隻讀的,因此沒有相應的setDay()方法。

11.4.1 時間戳

JavaScript 將日期內部表示為整數,指定自 1970 年 1 月 1 日午夜(或之前)以來的毫秒數。支援的整數最大為 8,640,000,000,000,000,因此 JavaScript 在 270,000 年後不會用盡毫秒。

對於任何日期物件,getTime()方法返回內部值,而setTime()方法設定它。因此,您可以像這樣為日期新增 30 秒:

d.setTime(d.getTime() + 30000);

這些毫秒值有時被稱為時間戳,直接使用它們而不是 Date 物件有時很有用。靜態的Date.now()方法返回當前時間作為時間戳,當您想要測量程式碼執行時間時很有幫助:

let startTime = Date.now();
reticulateSplines(); // Do some time-consuming operation
let endTime = Date.now();
console.log(`Spline reticulation took ${endTime - startTime}ms.`);

11.4.2 日期算術

可以使用 JavaScript 的標準<<=>>=比較運算子比較日期物件。您可以從一個日期物件中減去另一個日期物件以確定兩個日期之間的毫秒數。(這是因為 Date 類定義了一個返回時間戳的valueOf()方法。)

如果要從日期中新增或減去指定數量的秒、分鐘或小時,通常最簡單的方法是修改時間戳,就像前面示例中新增 30 秒到日期一樣。如果要新增天數,這種技術變得更加繁瑣,對於月份和年份則根本不起作用,因為它們的天數不同。要進行涉及天數、月份和年份的日期算術,可以使用setDate()setMonth()setYear()。例如,以下是將三個月和兩週新增到當前日期的程式碼:

let d = new Date();
d.setMonth(d.getMonth() + 3, d.getDate() + 14);

即使溢位,日期設定方法也能正常工作。當我們向當前月份新增三個月時,可能得到大於 11 的值(代表 12 月)。setMonth()透過根據需要遞增年份來處理這一點。同樣,當我們將月份的日期設定為大於該月份天數的值時,月份會適當遞增。

11.4.3 格式化和解析日期字串

如果您使用 Date 類實際跟蹤日期和時間(而不僅僅是測量時間間隔),那麼您可能需要向程式碼的使用者顯示日期和時間。Date 類定義了許多不同的方法來將 Date 物件轉換為字串。以下是一些示例:

let d = new Date(2020, 0, 1, 17, 10, 30); // 5:10:30pm on New Year's Day 2020
d.toString()  // => "Wed Jan 01 2020 17:10:30 GMT-0800 (Pacific Standard Time)"
d.toUTCString()         // => "Thu, 02 Jan 2020 01:10:30 GMT"
d.toLocaleDateString()  // => "1/1/2020": 'en-US' locale
d.toLocaleTimeString()  // => "5:10:30 PM": 'en-US' locale
d.toISOString()         // => "2020-01-02T01:10:30.000Z"

這是 Date 類的字串格式化方法的完整列表:

toString()

此方法使用本地時區,但不以區域感知方式格式化日期和時間。

toUTCString()

此方法使用 UTC 時區,但不以區域感知方式格式化日期。

toISOString()

此方法以 ISO-8601 標準的標準年-月-日小時:分鐘:秒.ms 格式列印日期和時間。字母“T”將輸出的日期部分與時間部分分開。時間以 UTC 表示,並且最後一個字母“Z”表示這一點。

toLocaleString()

此方法使用本地時區和適合使用者區域的格式。

toDateString()

此方法僅格式化日期部分並省略時間。它使用本地時區,不進行區域適當的格式化。

toLocaleDateString()

此方法僅格式化日期。它使用本地時區和適合區域的日期格式。

toTimeString()

此方法僅格式化時間並省略日期。它使用本地時區,但不以區域感知方式格式化時間。

toLocaleTimeString()

這種方法以區域感知方式格式化時間,並使用本地時區。

當將日期和時間格式化為向終端使用者顯示時,這些日期轉換為字串的方法都不是理想的。檢視 §11.7.2 以獲取更通用且區域感知的日期和時間格式化技術。

最後,除了這些將 Date 物件轉換為字串的方法之外,還有一個靜態的 Date.parse() 方法,它以字串作為引數,嘗試將其解析為日期和時間,並返回表示該日期的時間戳。Date.parse() 能夠解析 Date() 建構函式可以解析的相同字串,並且保證能夠解析 toISOString()toUTCString()toString() 的輸出。

11.5 錯誤類

JavaScript 的 throwcatch 語句可以丟擲和捕獲任何 JavaScript 值,包括原始值。沒有必須用於訊號錯誤的異常型別。但是,JavaScript 確實定義了一個 Error 類,並且在使用 throw 訊號錯誤時傳統上使用 Error 的例項或子類。使用 Error 物件的一個很好的理由是,當您建立一個 Error 時,它會捕獲 JavaScript 堆疊的狀態,如果異常未被捕獲,堆疊跟蹤將顯示在錯誤訊息中,這將幫助您除錯問題。(請注意,堆疊跟蹤顯示 Error 物件的建立位置,而不是 throw 語句丟擲它的位置。如果您總是在使用 throw new Error() 丟擲之前建立物件,這將不會引起任何混淆。)

Error 物件有兩個屬性:messagename,以及一個 toString() 方法。message 屬性的值是您傳遞給 Error() 建構函式的值,必要時轉換為字串。對於使用 Error() 建立的錯誤物件,name 屬性始終為“Error”。toString() 方法簡單地返回 name 屬性的值,後跟一個冒號和空格,以及 message 屬性的值。

儘管它不是 ECMAScript 標準的一部分,但 Node 和所有現代瀏覽器也在 Error 物件上定義了一個 stack 屬性。該屬性的值是一個多行字串,其中包含 JavaScript 呼叫堆疊在建立 Error 物件時的堆疊跟蹤。當捕獲到意外錯誤時,這可能是有用的資訊進行記錄。

除了 Error 類之外,JavaScript 還定義了一些子類,用於訊號 ECMAScript 定義的特定型別的錯誤。這些子類包括 EvalError、RangeError、ReferenceError、SyntaxError、TypeError 和 URIError。如果看起來合適,您可以在自己的程式碼中使用這些錯誤類。與基本 Error 類一樣,這些子類的每個都有一個接受單個訊息引數的建構函式。並且每個這些子類的例項都有一個 name 屬性,其值與建構函式名稱相同。

您可以隨意定義最能封裝您自己程式的錯誤條件的 Error 子類。請注意,您不僅限於 namemessage 屬性。如果建立一個子類,您可以定義新屬性以提供錯誤詳細資訊。例如,如果您正在編寫解析器,可能會發現定義一個具有指定解析失敗確切位置的 linecolumn 屬性的 ParseError 類很有用。或者,如果您正在處理 HTTP 請求,可能希望定義一個具有儲存失敗請求的 HTTP 狀態碼(例如 404 或 500)的 status 屬性的 HTTPError 類。

例如:

class HTTPError extends Error {
    constructor(status, statusText, url) {
        super(`${status} ${statusText}: ${url}`);
        this.status = status;
        this.statusText = statusText;
        this.url = url;
    }

    get name() { return "HTTPError"; }
}

let error = new HTTPError(404, "Not Found", "http://example.com/");
error.status        // => 404
error.message       // => "404 Not Found: http://example.com/"
error.name          // => "HTTPError"

11.6 JSON 序列化和解析

當程式需要儲存資料或需要將資料透過網路連線傳輸到另一個程式時,它必須將其記憶體中的資料結構轉換為一串位元組或字元,這些位元組或字元可以被儲存或傳輸,然後稍後被解析以恢復原始的記憶體中的資料結構。將資料結構轉換為位元組流或字元流的過程稱為序列化(或編組甚至醃製)。

在 JavaScript 中序列化資料的最簡單方法使用了一種稱為 JSON 的序列化格式。這個首字母縮寫代表“JavaScript 物件表示法”,正如名稱所示,該格式使用 JavaScript 物件和陣列文字語法將由物件和陣列組成的資料結構轉換為字串。JSON 支援原始數字和字串,以及值truefalsenull,以及由這些原始值構建的陣列和物件。JSON 不支援 Map、Set、RegExp、Date 或型別化陣列等其他 JavaScript 型別。儘管如此,它已被證明是一種非常多才多藝的資料格式,即使在非基於 JavaScript 的程式中也被廣泛使用。

JavaScript 支援使用兩個函式JSON.stringify()JSON.parse()進行 JSON 序列化和反序列化,這兩個函式在§6.8 中簡要介紹過。給定一個不包含任何非可序列化值(如 RegExp 物件或型別化陣列)的物件或陣列(任意深度巢狀),您可以透過將其傳遞給JSON.stringify()來簡單地序列化物件。正如名稱所示,此函式的返回值是一個字串。並且給定JSON.stringify()返回的字串,您可以透過將字串傳遞給JSON.parse()來重新建立原始資料結構:

let o = {s: "", n: 0, a: [true, false, null]};
let s = JSON.stringify(o);  // s == '{"s":"","n":0,"a":[true,false,null]}'
let copy = JSON.parse(s);   // copy == {s: "", n: 0, a: [true, false, null]}

如果我們忽略序列化資料儲存到檔案或透過網路傳送的部分,我們可以將這對函式用作建立物件的深層副本的一種效率較低的方式:

// Make a deep copy of any serializable object or array
function deepcopy(o) {
    return JSON.parse(JSON.stringify(o));
}

JSON 是 JavaScript 的一個子集

當資料序列化為 JSON 格式時,結果是一個有效的 JavaScript 原始碼,用於評估為原始資料結構的副本。如果您在 JSON 字串前面加上var data =並將結果傳遞給eval(),您將獲得將原始資料結構的副本分配給變數data的結果。但是,您絕對不應該這樣做,因為這是一個巨大的安全漏洞——如果攻擊者可以將任意 JavaScript 程式碼注入 JSON 檔案中,他們可以使您的程式執行他們的程式碼。只需使用JSON.parse()來解碼 JSON 格式化資料,這樣更快速和安全。

JSON 有時被用作人類可讀的配置檔案格式。如果您發現自己手動編輯 JSON 檔案,請注意 JSON 格式是 JavaScript 的一個非常嚴格的子集。不允許註釋,屬性名稱必須用雙引號括起來,即使 JavaScript 不需要這樣做。

通常,您只向JSON.stringify()JSON.parse()傳遞單個引數。這兩個函式都接受一個可選的第二個引數,允許我們擴充套件 JSON 格式,接下來將對此進行描述。JSON.stringify()還接受一個可選的第三個引數,我們將首先討論這個引數。如果您希望您的 JSON 格式化字串可讀性強(例如用作配置檔案),那麼應將null作為第二個引數傳遞,並將數字或字串作為第三個引數傳遞。第三個引數告訴JSON.stringify()應該將資料格式化為多個縮排行。如果第三個引數是一個數字,則它將使用該數字作為每個縮排級別的空格數。如果第三個引數是一個空格字串(例如'\t'),它將使用該字串作為每個縮排級別。

let o = {s: "test", n: 0};
JSON.stringify(o, null, 2)  // => '{\n  "s": "test",\n  "n": 0\n}'

JSON.parse()會忽略空格,因此向JSON.stringify()傳遞第三個引數對我們將字串轉換回資料結構的能力沒有影響。

11.6.1 JSON 自定義

如果JSON.stringify()被要求序列化一個 JSON 格式不支援的值,它會檢視該值是否有一個toJSON()方法,如果有,它會呼叫該方法,然後將返回值序列化以替換原始值。Date 物件實現了toJSON():它返回與toISOString()方法相同的字串。這意味著如果序列化包含 Date 的物件,日期將自動轉換為字串。當您解析序列化的字串時,重新建立的資料結構將不會與您開始的完全相同,因為它將在原始物件有 Date 的地方有一個字串。

如果需要重新建立 Date 物件(或以任何其他方式修改解析的物件),可以將“恢復器”函式作為第二個引數傳遞給JSON.parse()。如果指定了,這個“恢復器”函式將被用於從輸入字串解析的每個原始值(但不包含這些原始值的物件或陣列)。該函式被呼叫時帶有兩個引數。第一個是屬性名稱—一個物件屬性名稱或轉換為字串的陣列索引。第二個引數是該物件屬性或陣列元素的原始值。此外,該函式作為包含原始值的物件或陣列的方法被呼叫,因此您可以使用this關鍵字引用該包含物件。

恢復函式的返回值將成為命名屬性的新值。如果它返回其第二個引數,則屬性將保持不變。如果返回undefined,則在JSON.parse()返回給使用者之前,命名屬性將從物件或陣列中刪除。

作為示例,這裡是一個呼叫JSON.parse()的示例,使用恢復器函式來過濾一些屬性並重新建立 Date 物件:

let data = JSON.parse(text, function(key, value) {
    // Remove any values whose property name begins with an underscore
    if (key[0] === "_") return undefined;

    // If the value is a string in ISO 8601 date format convert it to a Date.
    if (typeof value === "string" &&
        /^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d.\d\d\dZ$/.test(value)) {
        return new Date(value);
    }

    // Otherwise, return the value unchanged
    return value;
});

除了前面描述的toJSON()的使用,JSON.stringify()還允許透過將陣列或函式作為可選的第二個引數來自定義其輸出。

如果作為第二個引數傳遞的是字串陣列(或數字—它們會被轉換為字串),那麼這些將被用作物件屬性(或陣列元素)的名稱。任何名稱不在陣列中的屬性都將被省略。此外,返回的字串將按照它們在陣列中出現的順序包括屬性(在編寫測試時非常有用)。

如果傳遞一個函式,它是一個替換函式—實際上是您可以傳遞給JSON.parse()的可選恢復函式的反函式。如果指定了替換函式,那麼替換函式將被用於要序列化的每個值。替換函式的第一個引數是該物件中值的物件屬性名稱或陣列索引,第二個引數是值本身。替換函式作為包含要序列化值的物件或陣列的方法被呼叫。替換函式的返回值將被序列化以替換原始值。如果替換函式返回undefined或根本沒有返回任何內容,則該值(及其陣列元素或物件屬性)將被省略在序列化中。

// Specify what fields to serialize, and what order to serialize them in
let text = JSON.stringify(address, ["city","state","country"]);

// Specify a replacer function that omits RegExp-value properties
let json = JSON.stringify(o, (k, v) => v instanceof RegExp ? undefined : v);

這裡的兩個JSON.stringify()呼叫以一種良性的方式使用第二個引數,產生的序列化輸出可以在不需要特殊恢復函式的情況下反序列化。然而,一般來說,如果為型別定義了toJSON()方法,或者使用一個實際上用可序列化值替換不可序列化值的替換函式,那麼通常需要使用自定義恢復函式與JSON.parse()一起來獲取原始資料結構。如果這樣做,你應該明白你正在定義一種自定義資料格式,並犧牲了與大量 JSON 相容工具和語言的可移植性和相容性。

11.7 國際化 API

JavaScript 國際化 API 由三個類 Intl.NumberFormat、Intl.DateTimeFormat 和 Intl.Collator 組成,允許我們以區域設定適當的方式格式化數字(包括貨幣金額和百分比)、日期和時間,並以區域設定適當的方式比較字串。這些類不是 ECMAScript 標準的一部分,但作為ECMA402 標準的一部分定義,並得到 Web 瀏覽器的良好支援。Intl API 也受 Node 支援,但在撰寫本文時,預構建的 Node 二進位制檔案不包含所需的本地化資料,以使它們能夠與除美國英語以外的區域設定一起使用。因此,為了在 Node 中使用這些類,您可能需要下載一個單獨的資料包或使用自定義構建的 Node。

國際化中最重要的部分之一是顯示已翻譯為使用者語言的文字。有各種方法可以實現這一點,但這些方法都不在此處描述的 Intl API 的範圍內。

11.7.1 格式化數字

世界各地的使用者期望以不同的方式格式化數字。小數點可以是句點或逗號。千位分隔符可以是逗號或句點,並且並非在所有地方每三位數字都使用。一些貨幣被分成百分之一,一些被分成千分之一,一些沒有細分。最後,儘管所謂的“阿拉伯數字”0 到 9 在許多語言中使用,但這並非普遍,一些國家的使用者期望看到使用其自己指令碼中的數字編寫的數字。

Intl.NumberFormat 類定義了一個format()方法,考慮到所有這些格式化可能性。建構函式接受兩個引數。第一個引數指定應為其格式化數字的區域設定,第二個是一個物件,指定有關如何格式化數字的更多詳細資訊。如果省略或undefined第一個引數,則將使用系統區域設定(我們假設為使用者首選區域設定)。如果第一個引數是字串,則指定所需的區域設定,例如"en-US"(美國使用的英語)、"fr"(法語)或"zh-Hans-CN"(中國使用簡體漢字書寫系統)。第一個引數也可以是區域設定字串陣列,在這種情況下,Intl.NumberFormat 將選擇最具體且受支援的區域設定。

如果指定了Intl.NumberFormat()建構函式的第二個引數,則應該是一個定義一個或多個以下屬性的物件:

style

指定所需的數字格式化型別。預設值為"decimal"。指定"percent"將數字格式化為百分比,或指定"currency"將數字格式化為貨幣金額。

currency

如果樣式為"currency",則需要此屬性來指定所需貨幣的三個字母 ISO 貨幣程式碼(例如"USD"表示美元或"GBP"表示英鎊)。

currencyDisplay

如果樣式為"currency",則此屬性指定貨幣的顯示方式。預設值"symbol"使用貨幣符號(如果貨幣有符號)。值"code"使用三個字母 ISO 程式碼,值"name"以長形式拼寫貨幣名稱。

useGrouping

將此屬性設定為false,如果您不希望數字具有千位分隔符(或其相應的區域設定等價物)。

minimumIntegerDigits

用於顯示數字整數部分的最小位數。如果數字的位數少於此值,則將在左側用零填充。預設值為 1,但可以使用高達 21 的值。

minimumFractionDigitsmaximumFractionDigits

這兩個屬性控制數字的小數部分的格式。如果一個數字的小數位數少於最小值,它將在右側用零填充。如果小數位數超過最大值,那麼小數部分將被四捨五入。這兩個屬性的合法值介於 0 和 20 之間。預設最小值為 0,最大值為 3,除了在格式化貨幣金額時,小數部分的長度會根據指定的貨幣而變化。

minimumSignificantDigitsmaximumSignificantDigits

這些屬性控制在格式化數字時使用的有效數字位數,使其適用於格式化科學資料等情況。如果指定了這些屬性,它們將覆蓋先前列出的整數和小數位數屬性。合法值介於 1 和 21 之間。

一旦您使用所需的區域設定和選項建立了一個 Intl.NumberFormat 物件,您可以透過將數字傳遞給其format()方法來使用它,該方法將返回一個適當格式化的字串。例如:

let euros = Intl.NumberFormat("es", {style: "currency", currency: "EUR"});
euros.format(10)    // => "10,00 €": ten euros, Spanish formatting

let pounds = Intl.NumberFormat("en", {style: "currency", currency: "GBP"});
pounds.format(1000) // => "£1,000.00": One thousand pounds, English formatting

Intl.NumberFormat(以及其他 Intl 類)的一個有用功能是它的format()方法繫結到它所屬的 NumberFormat 物件。因此,您可以將format()方法分配給一個變數,並像獨立函式一樣使用它,而不是定義一個引用格式化物件的變數,然後在該變數上呼叫format()方法,就像這個例子中一樣:

let data = [0.05, .75, 1];
let formatData = Intl.NumberFormat(undefined, {
    style: "percent",
    minimumFractionDigits: 1,
    maximumFractionDigits: 1
}).format;

data.map(formatData)   // => ["5.0%", "75.0%", "100.0%"]: in en-US locale

一些語言,比如阿拉伯語,使用自己的指令碼來表示十進位制數字:

let arabic = Intl.NumberFormat("ar", {useGrouping: false}).format;
arabic(1234567890)   // => "١٢٣٤٥٦٧٨٩٠"

其他語言,比如印地語,使用自己的數字字符集,但預設情況下傾向於使用 ASCII 數字 0-9。如果要覆蓋用於數字的預設字符集,請在區域設定中新增-u-nu-,然後跟上簡寫的字符集名稱。例如,您可以這樣格式化數字,使用印度風格的分組和天城數字:

let hindi = Intl.NumberFormat("hi-IN-u-nu-deva").format;
hindi(1234567890)    // => "१,२३,४५,६७,८९०"

在區域設定中的-u-指定接下來是一個 Unicode 擴充套件。nu是編號系統的副檔名稱,deva是 Devanagari 的縮寫。Intl API 標準為許多其他編號系統定義了名稱,主要用於南亞和東南亞的印度語言。

11.7.2 格式化日期和時間

Intl.DateTimeFormat 類與 Intl.NumberFormat 類非常相似。Intl.DateTimeFormat()建構函式接受與Intl.NumberFormat()相同的兩個引數:區域設定或區域設定陣列以及格式選項物件。使用 Intl.DateTimeFormat 例項的方法是呼叫其format()方法,將 Date 物件轉換為字串。

如§11.4 中所述,Date 類定義了簡單的toLocaleDateString()toLocaleTimeString()方法,為使用者的區域設定生成適當的輸出。但是這些方法不會讓您控制顯示的日期和時間欄位。也許您想省略年份,但在日期格式中新增一個工作日。您希望月份是以數字形式表示還是以名稱拼寫出來?Intl.DateTimeFormat 類根據傳遞給建構函式的第二個引數中的選項物件中的屬性提供對輸出的細粒度控制。但是,請注意,Intl.DateTimeFormat 不能總是精確顯示您要求的內容。如果指定了格式化小時和秒的選項但省略了分鐘,您會發現格式化程式仍然會顯示分鐘。這個想法是您使用選項物件指定要向使用者呈現的日期和時間欄位以及您希望如何格式化這些欄位(例如按名稱或按數字),然後格式化程式將查詢最接近您要求的內容的適合區域設定的格式。

可用的選項如下。只為您希望出現在格式化輸出中的日期和時間欄位指定屬性。

使用"numeric"表示完整的四位數年份,或使用"2-digit"表示兩位數縮寫。

使用"numeric"表示可能的短數字,如“1”,或"2-digit"表示始終有兩位數字的數字表示,如“01”。使用"long"表示全名,如“January”,"short"表示縮寫,如“Jan”,"narrow"表示高度縮寫,如“J”,不保證唯一。

day

使用"numeric"表示一位或兩位數字,或"2-digit"表示月份的兩位數字。

weekday

使用"long"表示全名,如“Monday”,"short"表示縮寫,如“Mon”,"narrow"表示高度縮寫,如“M”,不保證唯一。

era

此屬性指定日期是否應以時代(如 CE 或 BCE)格式化。如果您正在格式化很久以前的日期或使用日本日曆,則可能很有用。合法值為"long""short""narrow"

hourminutesecond

這些屬性指定您希望如何顯示時間。使用"numeric"表示一位或兩位數字欄位,或"2-digit"強制將單個數字左側填充為 0。

timeZone

此屬性指定應為其格式化日期的所需時區。如果省略,將使用本地時區。實現始終識別“UTC”,並且還可以識別網際網路分配的數字管理局(IANA)時區名稱,例如“America/Los_Angeles”。

timeZoneName

此屬性指定應如何在格式化的日期或時間中顯示時區。使用"long"表示完全拼寫的時區名稱,"short"表示縮寫或數字時區。

hour12

這個布林屬性指定是否使用 12 小時制。預設是與地區相關的,但你可以用這個屬性來覆蓋它。

hourCycle

此屬性允許您指定午夜是寫作 0 小時、12 小時還是 24 小時。預設是與地區相關的,但您可以用此屬性覆蓋預設值。請注意,hour12優先於此屬性。使用值"h11"指定午夜為 0,午夜前一小時為 11pm。使用"h12"指定午夜為 12。使用"h23"指定午夜為 0,午夜前一小時為 23。使用"h24"指定午夜為 24。

以下是一些示例:

let d = new Date("2020-01-02T13:14:15Z");  // January 2nd, 2020, 13:14:15 UTC

// With no options, we get a basic numeric date format
Intl.DateTimeFormat("en-US").format(d) // => "1/2/2020"
Intl.DateTimeFormat("fr-FR").format(d) // => "02/01/2020"

// Spelled out weekday and month
let opts = { weekday: "long", month: "long", year: "numeric", day: "numeric" };
Intl.DateTimeFormat("en-US", opts).format(d) // => "Thursday, January 2, 2020"
Intl.DateTimeFormat("es-ES", opts).format(d) // => "jueves, 2 de enero de 2020"

// The time in New York, for a French-speaking Canadian
opts = { hour: "numeric", minute: "2-digit", timeZone: "America/New_York" };
Intl.DateTimeFormat("fr-CA", opts).format(d) // => "8 h 14"

Intl.DateTimeFormat 可以使用除基於基督教時代的預設儒略曆之外的其他日曆顯示日期。儘管一些地區可能預設使用非基督教日曆,但您始終可以透過在地區後新增-u-ca-並在其後跟日曆名稱來明確指定要使用的日曆。可能的日曆名稱包括“buddhist”、“chinese”、“coptic”、“ethiopic”、“gregory”、“hebrew”、“indian”、“islamic”、“iso8601”、“japanese”和“persian”。繼續前面的示例,我們可以確定各種非基督教曆法中的年份:

let opts = { year: "numeric", era: "short" };
Intl.DateTimeFormat("en", opts).format(d)                // => "2020 AD"
Intl.DateTimeFormat("en-u-ca-iso8601", opts).format(d)   // => "2020 AD"
Intl.DateTimeFormat("en-u-ca-hebrew", opts).format(d)    // => "5780 AM"
Intl.DateTimeFormat("en-u-ca-buddhist", opts).format(d)  // => "2563 BE"
Intl.DateTimeFormat("en-u-ca-islamic", opts).format(d)   // => "1441 AH"
Intl.DateTimeFormat("en-u-ca-persian", opts).format(d)   // => "1398 AP"
Intl.DateTimeFormat("en-u-ca-indian", opts).format(d)    // => "1941 Saka"
Intl.DateTimeFormat("en-u-ca-chinese", opts).format(d)   // => "36 78"
Intl.DateTimeFormat("en-u-ca-japanese", opts).format(d)  // => "2 Reiwa"

11.7.3 比較字串

將字串按字母順序排序(或對於非字母指令碼的更一般“排序順序”)的問題比英語使用者通常意識到的更具挑戰性。英語使用相對較小的字母表,沒有重音字母,並且我們有字元編碼(ASCII,已合併到 Unicode 中)的好處,其數值完全匹配我們的標準字串排序順序。在其他語言中情況並不那麼簡單。例如,西班牙語將ñ視為一個獨立的字母,位於 n 之後和 o 之前。立陶宛語將 Y 排在 J 之前,威爾士語將 CH 和 DD 等二合字母視為單個字母,CH 排在 C 之後,DD 排在 D 之後。

如果要按使用者自然順序顯示字串,僅使用陣列字串的sort()方法是不夠的。但是,如果建立 Intl.Collator 物件,可以將該物件的compare()方法傳遞給sort()方法,以執行符合區域設定的字串排序。Intl.Collator 物件可以配置為使compare()方法執行不區分大小寫的比較,甚至只考慮基本字母並忽略重音和其他變音符號的比較。

Intl.NumberFormat()Intl.DateTimeFormat()一樣,Intl.Collator()建構函式接受兩個引數。第一個指定區域設定或區域設定陣列,第二個是一個可選物件,其屬性精確指定要執行的字串比較型別。支援的屬性如下:

usage

此屬性指定如何使用排序器物件。預設值為"sort",但也可以指定"search"。想法是,在對字串進行排序時,通常希望排序器儘可能區分多個字串以產生可靠的排序。但是,在比較兩個字串時,某些區域設定可能希望進行較不嚴格的比較,例如忽略重音。

sensitivity

此屬性指定比較字串時,排序器是否對大小寫和重音敏感。值為"base"會忽略大小寫和重音,只考慮每個字元的基本字母。(但請注意,某些語言認為某些帶重音的字元是不同的基本字母。)"accent"考慮重音但忽略大小寫。"case"考慮大小寫但忽略重音。"variant"執行嚴格的比較,考慮大小寫和重音。當usage"sort"時,此屬性的預設值為"variant"。如果usage"search",則預設靈敏度取決於區域設定。

ignorePunctuation

將此屬性設定為true以在比較字串時忽略空格和標點符號。將此屬性設定為true後,例如,字串“any one”和“anyone”將被視為相等。

numeric

如果要比較的字串是整數或包含整數,並且希望它們按數字順序而不是按字母順序排序,請將此屬性設定為true。設定此選項後,例如,字串“Version 9”將在“Version 10”之前排序。

caseFirst

此屬性指定哪種大小寫應該優先。如果指定為"upper",則“A”將在“a”之前排序。如果指定為"lower",則“a”將在“A”之前排序。無論哪種情況,請注意相同字母的大寫和小寫變體將按順序排列在一起,這與 Unicode 詞典排序(陣列sort()方法的預設行為)不同,在該排序中,所有 ASCII 大寫字母都排在所有 ASCII 小寫字母之前。此屬性的預設值取決於區域設定,並且實現可能會忽略此屬性並不允許您覆蓋大小寫排序順序。

一旦為所需區域設定和選項建立了 Intl.Collator 物件,就可以使用其compare()方法比較兩個字串。此方法返回一個數字。如果返回值小於零,則第一個字串在第二個字串之前。如果大於零,則第一個字串在第二個字串之後。如果compare()返回零,則這兩個字串在此排序器的意義上相等。

此接受兩個字串並返回小於、等於或大於零的數字的compare()方法正是陣列sort()方法期望的可選引數。此外,Intl.Collator 會自動將compare()方法繫結到其例項,因此可以直接將其傳遞給sort(),而無需編寫包裝函式並透過排序器物件呼叫它。以下是一些示例:

// A basic comparator for sorting in the user's locale.
// Never sort human-readable strings without passing something like this:
const collator = new Intl.Collator().compare;
["a", "z", "A", "Z"].sort(collator)      // => ["a", "A", "z", "Z"]

// Filenames often include numbers, so we should sort those specially
const filenameOrder = new Intl.Collator(undefined, { numeric: true }).compare;
["page10", "page9"].sort(filenameOrder)  // => ["page9", "page10"]

// Find all strings that loosely match a target string
const fuzzyMatcher = new Intl.Collator(undefined, {
    sensitivity: "base",
    ignorePunctuation: true
}).compare;
let strings = ["food", "fool", "Føø Bar"];
strings.findIndex(s => fuzzyMatcher(s, "foobar") === 0)  // => 2

一些地區有多種可能的排序順序。例如,在德國,電話簿使用的排序順序比字典稍微更加語音化。在西班牙,在 1994 年之前,“ch” 和 “ll” 被視為單獨的字母,因此該國現在有現代排序順序和傳統排序順序。在中國,排序順序可以基於字元編碼、每個字元的基本部首和筆畫,或者基於字元的拼音羅馬化。這些排序變體不能透過 Intl.Collator 選項引數進行選擇,但可以透過在區域設定字串中新增 -u-co- 並新增所需變體的名稱來選擇。例如,在德國使用 "de-DE-u-co-phonebk" 進行電話簿排序,在臺灣使用 "zh-TW-u-co-pinyin" 進行拼音排序。

// Before 1994, CH and LL were treated as separate letters in Spain
const modernSpanish = Intl.Collator("es-ES").compare;
const traditionalSpanish = Intl.Collator("es-ES-u-co-trad").compare;
let palabras = ["luz", "llama", "como", "chico"];
palabras.sort(modernSpanish)      // => ["chico", "como", "llama", "luz"]
palabras.sort(traditionalSpanish) // => ["como", "chico", "luz", "llama"]

11.8 控制檯 API

你在本書中看到了 console.log() 函式的使用:在網頁瀏覽器中,它會在瀏覽器的開發者工具窗格的“控制檯”選項卡中列印一個字串,這在除錯時非常有幫助。在 Node 中,console.log() 是一個通用輸出函式,將其引數列印到程序的 stdout 流中,在終端視窗中通常會顯示給使用者作為程式輸出。

控制檯 API 除了 console.log() 外還定義了許多有用的函式。該 API 不是任何 ECMAScript 標準的一部分,但受到瀏覽器和 Node 的支援,並已經正式編寫和標準化在 https://console.spec.whatwg.org

控制檯 API 定義了以下函式:

console.log()

這是控制檯函式中最為人熟知的。它將其引數轉換為字串並將它們輸出到控制檯。它在引數之間包含空格,並在輸出所有引數後開始新的一行。

console.debug(), console.info(), console.warn(), console.error()

這些函式幾乎與 console.log() 完全相同。在 Node 中,console.error() 將其輸出傳送到 stderr 流而不是 stdout 流,但其他函式是 console.log() 的別名。在瀏覽器中,每個函式生成的輸出訊息可能會以指示其級別或嚴重性的圖示為字首,並且開發者控制檯還可以允許開發者按級別過濾控制檯訊息。

console.assert()

如果第一個引數為真值(即如果斷言透過),則此函式不執行任何操作。但如果第一個引數為 false 或其他假值,則剩餘的引數將被列印,就像它們已經被傳遞給帶有“Assertion failed”字首的 console.error() 一樣。請注意,與典型的 assert() 函式不同,當斷言失敗時,console.assert() 不會丟擲異常。

console.clear()

此函式在可能的情況下清除控制檯。這在瀏覽器和在 Node 將其輸出顯示到終端時有效。但是,如果 Node 的輸出已被重定向到檔案或管道,則呼叫此函式沒有效果。

console.table()

這個函式是一個非常強大但鮮為人知的功能,用於生成表格輸出,特別適用於需要總結資料的 Node 程式。console.table()嘗試以表格形式顯示其引數(儘管如果無法做到這一點,它會使用常規的console.log()格式)。當引數是一個相對較短的物件陣列,並且陣列中的所有物件具有相同(相對較小)的屬性集時,這種方法效果最佳。在這種情況下,陣列中的每個物件被格式化為表格的一行,每個屬性是表格的一列。您還可以將屬性名稱陣列作為可選的第二個引數傳遞,以指定所需的列集。如果傳遞的是物件而不是物件陣列,則輸出將是一個具有屬性名稱列和屬性值列的表格。或者,如果這些屬性值本身是物件,則它們的屬性名稱將成為表格中的列。

console.trace()

這個函式像console.log()一樣記錄其引數,並且在輸出後跟隨一個堆疊跟蹤。在 Node 中,輸出會傳送到 stderr 而不是 stdout。

console.count()

這個函式接受一個字串引數,並記錄該字串,然後記錄呼叫該字串的次數。在除錯事件處理程式時,這可能很有用,例如,如果需要跟蹤事件處理程式被觸發的次數。

console.countReset()

這個函式接受一個字串引數,並重置該字串的計數器。

console.group()

這個函式將其引數列印到控制檯,就像它們已被傳遞給console.log()一樣,然後設定控制檯的內部狀態,以便所有後續的控制檯訊息(直到下一個console.groupEnd()呼叫)將相對於剛剛列印的訊息進行縮排。這允許將一組相關訊息視覺上分組並縮排。在 Web 瀏覽器中,開發者控制檯通常允許將分組訊息摺疊和展開為一組。console.group()的引數通常用於為組提供解釋性名稱。

console.groupCollapsed()

這個函式與console.group()類似,但在 Web 瀏覽器中,預設情況下,該組將“摺疊”,並且它包含的訊息將被隱藏,除非使用者點選以展開該組。在 Node 中,此函式是console.group()的同義詞。

console.groupEnd()

這個函式不接受任何引數。它不產生自己的輸出,但結束了由最近呼叫的console.group()console.groupCollapsed()引起的縮排和分組。

console.time()

這個函式接受一個字串引數,記錄呼叫該字串的時間,並不產生輸出。

console.timeLog()

這個函式將一個字串作為其第一個引數。如果該字串之前已傳遞給console.time(),則列印該字串,然後是自console.time()呼叫以來經過的時間。如果console.timeLog()有任何額外的引數,它們將被列印,就像它們已被傳遞給console.log()一樣。

console.timeEnd()

這個函式接受一個字串引數。如果之前已將該引數傳遞給console.time(),則列印該引數和經過的時間。在呼叫console.timeEnd()之後,再次呼叫console.timeLog()而不先呼叫console.time()是不合法的。

11.8.1 使用控制檯進行格式化輸出

類似console.log()列印其引數的控制檯函式有一個鮮為人知的功能:如果第一個引數是包含%s%i%d%f%o%O%c的字串,則此第一個引數將被視為格式字串,⁶,並且後續引數的值將替換兩個字元%序列的位置。

序列的含義如下:

%s

引數被轉換為字串。

%i%d

引數被轉換為數字,然後截斷為整數。

%f

引數被轉換為數字

%o%O

引數被視為物件,並顯示屬性名稱和值。(在 Web 瀏覽器中,此顯示通常是互動式的,使用者可以展開和摺疊屬性以探索巢狀的資料結構。)%o%O 都顯示物件的詳細資訊。大寫變體使用一個依賴於實現的輸出格式,被認為對軟體開發人員最有用。

%c

在 Web 瀏覽器中,引數被解釋為一串 CSS 樣式,並用於為接下來的任何文字設定樣式(直到下一個 %c 序列或字串結束)。在 Node 中,%c 序列及其對應的引數會被簡單地忽略。

請注意,通常不需要在控制檯函式中使用格式字串:通常只需將一個或多個值(包括物件)傳遞給函式,讓實現以有用的方式顯示它們即可。例如,請注意,如果將 Error 物件傳遞給 console.log(),它將自動列印出其堆疊跟蹤。

11.9 URL API

由於 JavaScript 在 Web 瀏覽器和 Web 伺服器中被廣泛使用,JavaScript 程式碼通常需要操作 URL。URL 類解析 URL 並允許修改(例如新增搜尋引數或更改路徑)現有的 URL。它還正確處理了 URL 的各個元件的轉義和解碼這一複雜主題。

URL 類不是任何 ECMAScript 標準的一部分,但它在 Node 和除了 Internet Explorer 之外的所有網際網路瀏覽器中都可以使用。它在 https://url.spec.whatwg.org 上標準化。

使用 URL() 建構函式建立一個 URL 物件,將絕對 URL 字串作為引數傳遞。或者將相對 URL 作為第一個引數傳遞,將其相對的絕對 URL 作為第二個引數傳遞。一旦建立了 URL 物件,它的各種屬性允許您查詢 URL 的各個部分的未轉義版本:

let url = new URL("https://example.com:8000/path/name?q=term#fragment");
url.href        // => "https://example.com:8000/path/name?q=term#fragment"
url.origin      // => "https://example.com:8000"
url.protocol    // => "https:"
url.host        // => "example.com:8000"
url.hostname    // => "example.com"
url.port        // => "8000"
url.pathname    // => "/path/name"
url.search      // => "?q=term"
url.hash        // => "#fragment"

儘管不常用,URL 可以包含使用者名稱或使用者名稱和密碼,URL 類也可以解析這些 URL 元件:

let url = new URL("ftp://admin:1337!@ftp.example.com/");
url.href       // => "ftp://admin:1337!@ftp.example.com/"
url.origin     // => "ftp://ftp.example.com"
url.username   // => "admin"
url.password   // => "1337!"

這裡的 origin 屬性是 URL 協議和主機(包括指定的埠)的簡單組合。因此,它是一個只讀屬性。但前面示例中演示的每個其他屬性都是讀/寫的:您可以設定這些屬性中的任何一個來設定 URL 的相應部分:

let url = new URL("https://example.com");  // Start with our server
url.pathname = "api/search";               // Add a path to an API endpoint
url.search = "q=test";                     // Add a query parameter
url.toString()  // => "https://example.com/api/search?q=test"

URL 類的一個重要特性是在需要時正確新增標點符號並轉義 URL 中的特殊字元:

let url = new URL("https://example.com");
url.pathname = "path with spaces";
url.search = "q=foo#bar";
url.pathname  // => "/path%20with%20spaces"
url.search    // => "?q=foo%23bar"
url.href      // => "https://example.com/path%20with%20spaces?q=foo%23bar"

這些示例中的 href 屬性是一個特殊的屬性:讀取 href 等同於呼叫 toString():它將 URL 的所有部分重新組合成 URL 的規範字串形式。將 href 設定為新字串會重新執行 URL 解析器,就好像再次呼叫 URL() 建構函式一樣。

在前面的示例中,我們一直使用 search 屬性來引用 URL 的整個查詢部分,該部分由問號到 URL 結尾的第一個井號字元組成。有時,將其視為單個 URL 屬性就足夠了。然而,HTTP 請求通常使用 application/x-www-form-urlencoded 格式將多個表單欄位或多個 API 引數的值編碼到 URL 的查詢部分中。在此格式中,URL 的查詢部分是一個問號,後面跟著一個或多個名稱/值對,它們之間用和號分隔。同一個名稱可以出現多次,導致具有多個值的命名搜尋引數。

如果你想將這些名稱/值對編碼到 URL 的查詢部分中,那麼searchParams屬性比search屬性更有用。search屬性是一個可讀/寫的字串,允許你獲取和設定 URL 的整個查詢部分。searchParams屬性是一個只讀引用,指向一個 URLSearchParams 物件,該物件具有用於獲取、設定、新增、刪除和排序編碼到 URL 查詢部分的引數的 API:

let url = new URL("https://example.com/search");
url.search                            // => "": no query yet
url.searchParams.append("q", "term"); // Add a search parameter
url.search                            // => "?q=term"
url.searchParams.set("q", "x");       // Change the value of this parameter
url.search                            // => "?q=x"
url.searchParams.get("q")             // => "x": query the parameter value
url.searchParams.has("q")             // => true: there is a q parameter
url.searchParams.has("p")             // => false: there is no p parameter
url.searchParams.append("opts", "1"); // Add another search parameter
url.search                            // => "?q=x&opts=1"
url.searchParams.append("opts", "&"); // Add another value for same name
url.search                            // => "?q=x&opts=1&opts=%26": note escape
url.searchParams.get("opts")          // => "1": the first value
url.searchParams.getAll("opts")       // => ["1", "&"]: all values
url.searchParams.sort();              // Put params in alphabetical order
url.search                            // => "?opts=1&opts=%26&q=x"
url.searchParams.set("opts", "y");    // Change the opts param
url.search                            // => "?opts=y&q=x"
// searchParams is iterable
[...url.searchParams]                 // => [["opts", "y"], ["q", "x"]]
url.searchParams.delete("opts");      // Delete the opts param
url.search                            // => "?q=x"
url.href                              // => "https://example.com/search?q=x"

searchParams屬性的值是一個 URLSearchParams 物件。如果你想將 URL 引數編碼到查詢字串中,可以建立一個 URLSearchParams 物件,追加引數,然後將其轉換為字串並設定在 URL 的search屬性上:

let url = new URL("http://example.com");
let params = new URLSearchParams();
params.append("q", "term");
params.append("opts", "exact");
params.toString()               // => "q=term&opts=exact"
url.search = params;
url.href                        // => "http://example.com/?q=term&opts=exact"

11.9.1 傳統 URL 函式

在之前描述的 URL API 定義之前,已經有多次嘗試在核心 JavaScript 語言中支援 URL 轉義和解碼。第一次嘗試是全域性定義的escape()unescape()函式,現在已經被棄用,但仍然被廣泛實現。不應該使用它們。

escape()unescape()被棄用時,ECMAScript 引入了兩對替代的全域性函式:

encodeURI()decodeURI()

encodeURI()以字串作為引數,返回一個新字串,其中非 ASCII 字元和某些 ASCII 字元(如空格)被轉義。decodeURI()則相反。需要轉義的字元首先被轉換為它們的 UTF-8 編碼,然後該編碼的每個位元組被替換為一個%xx轉義序列,其中xx是兩個十六進位制數字。因為encodeURI()旨在對整個 URL 進行編碼,它不會轉義 URL 分隔符字元,如/?#。但這意味著encodeURI()無法正確處理 URL 中包含這些字元的各個元件的 URL。

encodeURIComponent()decodeURIComponent()

這一對函式的工作方式與encodeURI()decodeURI()完全相同,只是它們旨在轉義 URI 的各個元件,因此它們還會轉義用於分隔這些元件的字元,如/?#。這些是傳統 URL 函式中最有用的,但請注意,encodeURIComponent()會轉義路徑名中的/字元,這可能不是你想要的。它還會將查詢引數中的空格轉換為%20,儘管在 URL 的這部分中應該用+轉義空格。

所有這些傳統函式的根本問題在於,它們試圖對 URL 的所有部分應用單一的編碼方案,而事實上 URL 的不同部分使用不同的編碼。如果你想要一個格式正確且編碼正確的 URL,解決方案就是簡單地使用 URL 類來進行所有的 URL 操作。

11.10 定時器

自 JavaScript 誕生以來,Web 瀏覽器就定義了兩個函式——setTimeout()setInterval()——允許程式要求瀏覽器在指定的時間過去後呼叫一個函式,或者在指定的時間間隔內重複呼叫函式。這些函式從未作為核心語言的一部分標準化,但它們在所有瀏覽器和 Node 中都有效,並且是 JavaScript 標準庫的事實部分。

setTimeout()的第一個引數是一個函式,第二個引數是一個數字,指定在呼叫函式之前應該經過多少毫秒。在指定的時間過去後(如果系統繁忙可能會稍長一些),函式將被呼叫,不帶任何引數。這裡,例如,是三個setTimeout()呼叫,分別在一秒、兩秒和三秒後列印控制檯訊息:

setTimeout(() => { console.log("Ready..."); }, 1000);
setTimeout(() => { console.log("set..."); }, 2000);
setTimeout(() => { console.log("go!"); }, 3000);

請注意,setTimeout()在返回之前不會等待時間過去。這個示例中的三行程式碼幾乎立即執行,但在經過 1,000 毫秒後才會發生任何事情。

如果省略setTimeout()的第二個引數,則預設為 0。然而,這並不意味著您指定的函式會立即被呼叫。相反,該函式被註冊為“儘快”呼叫。如果瀏覽器忙於處理使用者輸入或其他事件,可能需要 10 毫秒或更長時間才能呼叫該函式。

setTimeout()註冊一個函式,該函式將在一次呼叫後被呼叫。有時,該函式本身會呼叫setTimeout()以安排在將來的某個時間再次呼叫。然而,如果要重複呼叫一個函式,通常更簡單的方法是使用setInterval()setInterval()接受與setTimeout()相同的兩個引數,但每當指定的毫秒數(大約)過去時,它會重複呼叫函式。

setTimeout()setInterval()都會返回一個值。如果將此值儲存在變數中,您隨後可以使用它透過傳遞給clearTimeout()clearInterval()來取消函式的執行。返回的值在 Web 瀏覽器中通常是一個數字,在 Node 中是一個物件。實際型別並不重要,您應該將其視為不透明值。您可以使用此值的唯一操作是將其傳遞給clearTimeout()以取消使用setTimeout()註冊的函式的執行(假設尚未呼叫)或停止使用setInterval()註冊的函式的重複執行。

這是一個示例,演示瞭如何使用setTimeout()setInterval()clearInterval()來顯示一個簡單的數字時鐘與 Console API:

// Once a second: clear the console and print the current time
let clock = setInterval(() => {
    console.clear();
    console.log(new Date().toLocaleTimeString());
}, 1000);

// After 10 seconds: stop the repeating code above.
setTimeout(() => { clearInterval(clock); }, 10000);

當我們討論非同步程式設計時,我們將再次看到setTimeout()setInterval(),詳見第十三章。

11.11 總結

學習一門程式語言不僅僅是掌握語法。同樣重要的是研究標準庫,以便熟悉語言附帶的所有工具。本章記錄了 JavaScript 的標準庫,其中包括:

  • 重要的資料結構,如 Set、Map 和型別化陣列。

  • 用於處理日期和 URL 的 Date 和 URL 類。

  • JavaScript 的正規表示式語法及其用於文字模式匹配的 RegExp 類。

  • JavaScript 的國際化庫,用於格式化日期、時間和數字以及對字串進行排序。

  • 用於序列化和反序列化簡單資料結構的JSON物件和用於記錄訊息的console物件。

¹ 這裡記錄的並非 JavaScript 語言規範定義的所有內容:這裡記錄的一些類和函式首先是在 Web 瀏覽器中實現的,然後被 Node 採用,使它們成為 JavaScript 標準庫的事實成員。

² 這種可預測的迭代順序是 JavaScript 集合中的另一件事,可能會讓 Python 程式設計師感到驚訝。

³ 當 Web 瀏覽器新增對 WebGL 圖形的支援時,型別化陣列首次引入到客戶端 JavaScript 中。ES6 中的新功能是它們已被提升為核心語言特性。

⁴ 除了在字元類(方括號)內部,\b匹配退格字元。

⁵ 使用正規表示式解析 URL 並不是一個好主意。請參見§11.9 以獲取更健壯的 URL 解析器。

⁶ C 程式設計師將從printf()函式中認出許多這些字元序列。

相關文章