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

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

第十二章:迭代器和生成器

可迭代物件及其相關的迭代器是 ES6 的一個特性,在本書中我們已經多次見到。陣列(包括 TypedArrays)、字串以及 Set 和 Map 物件都是可迭代的。這意味著這些資料結構的內容可以被迭代——使用for/of迴圈遍歷,就像我們在§5.4.4 中看到的那樣:

let sum = 0;
for(let i of [1,2,3]) { // Loop once for each of these values
    sum += i;
}
sum   // => 6

迭代器也可以與...運算子一起使用,將可迭代物件展開或“擴充套件”到陣列初始化程式或函式呼叫中,就像我們在§7.1.2 中看到的那樣:

let chars = [..."abcd"]; // chars == ["a", "b", "c", "d"]
let data = [1, 2, 3, 4, 5];
Math.max(...data)        // => 5

迭代器可以與解構賦值一起使用:

let purpleHaze = Uint8Array.of(255, 0, 255, 128);
let [r, g, b, a] = purpleHaze; // a == 128

當你迭代 Map 物件時,返回的值是[key, value]對,這與for/of迴圈中的解構賦值很好地配合使用:

let m = new Map([["one", 1], ["two", 2]]);
for(let [k,v] of m) console.log(k, v); // Logs 'one 1' and 'two 2'

如果你只想迭代鍵或值而不是鍵值對,可以使用keys()values()方法:

[...m]            // => [["one", 1], ["two", 2]]: default iteration
[...m.entries()]  // => [["one", 1], ["two", 2]]: entries() method is the same
[...m.keys()]     // => ["one", "two"]: keys() method iterates just map keys
[...m.values()]   // => [1, 2]: values() method iterates just map values

最後,一些常用於 Array 物件的內建函式和建構函式實際上(在 ES6 及更高版本中)被編寫為接受任意迭代器。Set()建構函式就是這樣一個 API:

// Strings are iterable, so the two sets are the same:
new Set("abc") // => new Set(["a", "b", "c"])

本章解釋了迭代器的工作原理,並演示瞭如何建立自己的可迭代資料結構。在解釋基本迭代器之後,本章涵蓋了生成器,這是 ES6 的一個強大新功能,主要用作一種特別簡單的建立迭代器的方法。

12.1 迭代器的工作原理

for/of迴圈和展開運算子與可迭代物件無縫配合,但值得理解實際上是如何使迭代工作的。在理解 JavaScript 中的迭代過程時,有三種不同的型別需要理解。首先是可迭代物件:這些是可以被迭代的型別,如 Array、Set 和 Map。其次,是執行迭代的迭代器物件本身。第三,是儲存迭代每一步結果的迭代結果物件。

可迭代物件是任何具有特殊迭代器方法的物件,該方法返回一個迭代器物件。迭代器是任何具有返回迭代結果物件的next()方法的物件。而迭代結果物件是具有名為valuedone的屬性的物件。要迭代可迭代物件,首先呼叫其迭代器方法以獲取一個迭代器物件。然後,重複呼叫迭代器物件的next()方法,直到返回的值的done屬性設定為true為止。關於這一點的棘手之處在於,可迭代物件的迭代器方法沒有傳統的名稱,而是使用符號Symbol.iterator作為其名稱。因此,對可迭代物件iterable進行簡單的for/of迴圈也可以以較困難的方式編寫,如下所示:

let iterable = [99];
let iterator = iterable[Symbol.iterator]();
for(let result = iterator.next(); !result.done; result = iterator.next()) {
    console.log(result.value)  // result.value == 99
}

內建可迭代資料型別的迭代器物件本身也是可迭代的。(也就是說,它有一個名為Symbol.iterator的方法,該方法返回自身。)這在以下程式碼中偶爾會有用,當你想要遍歷“部分使用過”的迭代器時:

let list = [1,2,3,4,5];
let iter = list[Symbol.iterator]();
let head = iter.next().value;  // head == 1
let tail = [...iter];          // tail == [2,3,4,5]

12.2 實現可迭代物件

在 ES6 中,可迭代物件非常有用,因此當它們表示可以被迭代的內容時,你應該考慮使自己的資料型別可迭代。在第 9-2 和第 9-3 示例中展示的 Range 類是可迭代的。這些類使用生成器函式使自己可迭代。我們稍後會介紹生成器,但首先,我們將再次實現 Range 類,使其可迭代而不依賴於生成器。

要使類可迭代,必須實現一個方法,其名稱為符號Symbol.iterator。該方法必須返回具有next()方法的迭代器物件。而next()方法必須返回具有value屬性和/或布林done屬性的迭代結果物件。示例 12-1 實現了一個可迭代的 Range 類,並演示瞭如何建立可迭代、迭代器和迭代結果物件。

示例 12-1. 一個可迭代的數字範圍類
/*
 * A Range object represents a range of numbers {x: from <= x <= to}
 * Range defines a has() method for testing whether a given number is a member
 * of the range. Range is iterable and iterates all integers within the range.
 */
class Range {
    constructor (from, to) {
        this.from = from;
        this.to = to;
    }

    // Make a Range act like a Set of numbers
    has(x) { return typeof x === "number" && this.from <= x && x <= this.to; }

    // Return string representation of the range using set notation
    toString() { return `{ x | ${this.from} ≤ x ≤ ${this.to} }`; }

    // Make a Range iterable by returning an iterator object.
    // Note that the name of this method is a special symbol, not a string.
    [Symbol.iterator]() {
        // Each iterator instance must iterate the range independently of
        // others. So we need a state variable to track our location in the
        // iteration. We start at the first integer >= from.
        let next = Math.ceil(this.from);  // This is the next value we return
        let last = this.to;               // We won't return anything > this
        return {                          // This is the iterator object
            // This next() method is what makes this an iterator object.
            // It must return an iterator result object.
            next() {
                return (next <= last)   // If we haven't returned last value yet
                    ? { value: next++ } // return next value and increment it
                    : { done: true };   // otherwise indicate that we're done.
            },

            // As a convenience, we make the iterator itself iterable.
            [Symbol.iterator]() { return this; }
        };
    }
}

for(let x of new Range(1,10)) console.log(x); // Logs numbers 1 to 10
[...new Range(-2,2)]                          // => [-2, -1, 0, 1, 2]

除了使您的類可迭代之外,定義返回可迭代值的函式也非常有用。考慮這些基於迭代的替代方案,用於 JavaScript 陣列的map()filter()方法:

// Return an iterable object that iterates the result of applying f()
// to each value from the source iterable
function map(iterable, f) {
    let iterator = iterable[Symbol.iterator]();
    return {     // This object is both iterator and iterable
        [Symbol.iterator]() { return this; },
        next() {
            let v = iterator.next();
            if (v.done) {
                return v;
            } else {
                return { value: f(v.value) };
            }
        }
    };
}

// Map a range of integers to their squares and convert to an array
[...map(new Range(1,4), x => x*x)]  // => [1, 4, 9, 16]

// Return an iterable object that filters the specified iterable,
// iterating only those elements for which the predicate returns true
function filter(iterable, predicate) {
    let iterator = iterable[Symbol.iterator]();
    return { // This object is both iterator and iterable
        [Symbol.iterator]() { return this; },
        next() {
            for(;;) {
                let v = iterator.next();
                if (v.done || predicate(v.value)) {
                    return v;
                }
            }
        }
    };
}

// Filter a range so we're left with only even numbers
[...filter(new Range(1,10), x => x % 2 === 0)]  // => [2,4,6,8,10]

可迭代物件和迭代器的一個關鍵特性是它們本質上是惰性的:當需要計算下一個值時,該計算可以推遲到實際需要該值時。例如,假設您有一個非常長的文字字串,您希望將其標記為以空格分隔的單詞。您可以簡單地使用字串的split()方法,但如果這樣做,那麼必須在使用第一個單詞之前處理整個字串。並且您最終會為返回的陣列及其中的所有字串分配大量記憶體。以下是一個函式,允許您惰性迭代字串的單詞,而無需一次性將它們全部儲存在記憶體中(在 ES2020 中,使用返回迭代器的matchAll()方法更容易實現此函式,該方法在 §11.3.2 中描述):

function words(s) {
    var r = /\s+|$/g;                     // Match one or more spaces or end
    r.lastIndex = s.match(/[^ ]/).index;  // Start matching at first nonspace
    return {                              // Return an iterable iterator object
        [Symbol.iterator]() {             // This makes us iterable
            return this;
        },
        next() {                          // This makes us an iterator
            let start = r.lastIndex;      // Resume where the last match ended
            if (start < s.length) {       // If we're not done
                let match = r.exec(s);    // Match the next word boundary
                if (match) {              // If we found one, return the word
                    return { value: s.substring(start, match.index) };
                }
            }
            return { done: true };        // Otherwise, say that we're done
        }
    };
}

[...words(" abc def  ghi! ")] // => ["abc", "def", "ghi!"]

12.2.1 “關閉”迭代器:返回方法

想象一個(伺服器端)JavaScript 變體的words()迭代器,它不是以源字串作為引數,而是以檔案流作為引數,開啟檔案,從中讀取行,並迭代這些行中的單詞。在大多數作業系統中,開啟檔案以從中讀取的程式在完成讀取後需要記住關閉這些檔案,因此這個假設的迭代器將確保在next()方法返回其中的最後一個單詞後關閉檔案。

但迭代器並不總是執行到結束:for/of迴圈可能會被breakreturn或異常終止。同樣,當迭代器與解構賦值一起使用時,next()方法只會被呼叫足夠次數以獲取每個指定變數的值。迭代器可能有更多值可以返回,但它們永遠不會被請求。

如果我們假設的檔案中的單詞迭代器從未完全執行到結束,它仍然需要關閉開啟的檔案。因此,迭代器物件可能會實現一個return()方法,與next()方法一起使用。如果在next()返回具有done屬性設定為true的迭代結果之前迭代停止(通常是因為您透過break語句提前離開了for/of迴圈),那麼直譯器將檢查迭代器物件是否具有return()方法。如果存在此方法,直譯器將以無引數呼叫它,使迭代器有機會關閉檔案,釋放記憶體,並在完成後進行清理。return()方法必須返回一個迭代結果物件。物件的屬性將被忽略,但返回非物件值是錯誤的。

for/of迴圈和展開運算子是 JavaScript 的非常有用的特性,因此在建立 API 時,儘可能使用它們是一個好主意。但是,必須使用可迭代物件、其迭代器物件和迭代器的結果物件來處理過程有些複雜。幸運的是,生成器可以極大地簡化自定義迭代器的建立,我們將在本章的其餘部分中看到。

12.3 生成器

生成器是一種使用強大的新 ES6 語法定義的迭代器;當要迭代的值不是資料結構的元素,而是計算結果時,它特別有用。

要建立一個生成器,你必須首先定義一個生成器函式。生成器函式在語法上類似於普通的 JavaScript 函式,但是用關鍵字function*而不是function來定義。(從技術上講,這不是一個新關鍵字,只是在關鍵字function之後和函式名之前加上一個*。)當你呼叫一個生成器函式時,它實際上不會執行函式體,而是返回一個生成器物件。這個生成器物件是一個迭代器。呼叫它的next()方法會導致生成器函式的主體從頭開始執行(或者從當前位置開始),直到達到一個yield語句。yield在 ES6 中是新的,類似於return語句。yield語句的值成為迭代器上next()呼叫返回的值。透過示例可以更清楚地理解這一點:

// A generator function that yields the set of one digit (base-10) primes.
function* oneDigitPrimes() { // Invoking this function does not run the code
    yield 2;                 // but just returns a generator object. Calling
    yield 3;                 // the next() method of that generator runs
    yield 5;                 // the code until a yield statement provides
    yield 7;                 // the return value for the next() method.
}

// When we invoke the generator function, we get a generator
let primes = oneDigitPrimes();

// A generator is an iterator object that iterates the yielded values
primes.next().value          // => 2
primes.next().value          // => 3
primes.next().value          // => 5
primes.next().value          // => 7
primes.next().done           // => true

// Generators have a Symbol.iterator method to make them iterable
primes[Symbol.iterator]()    // => primes

// We can use generators like other iterable types
[...oneDigitPrimes()]        // => [2,3,5,7]
let sum = 0;
for(let prime of oneDigitPrimes()) sum += prime;
sum                          // => 17

在這個例子中,我們使用了function*語句來定義一個生成器。然而,和普通函式一樣,我們也可以以表示式形式定義生成器。再次強調,我們只需在function關鍵字後面加上一個星號:

const seq = function*(from,to) {
    for(let i = from; i <= to; i++) yield i;
};
[...seq(3,5)]  // => [3, 4, 5]

在類和物件字面量中,我們可以使用簡寫符號來完全省略定義方法時的function關鍵字。在這種情況下定義生成器,我們只需在方法名之前使用一個星號,而不是使用function關鍵字:

let o = {
    x: 1, y: 2, z: 3,
    // A generator that yields each of the keys of this object
    *g() {
        for(let key of Object.keys(this)) {
            yield key;
        }
    }
};
[...o.g()] // => ["x", "y", "z", "g"]

請注意,沒有辦法使用箭頭函式語法編寫生成器函式。

生成器通常使得定義可迭代類變得特別容易。我們可以用一個更簡短的*Symbol.iterator&rbrack;()生成器函式來替換[示例 12-1 中展示的[Symbol.iterator]()方法,程式碼如下:

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

檢視第九章中的示例 9-3 以檢視上下文中基於生成器的迭代器函式。

12.3.1 生成器示例

如果生成器實際上生成它們透過進行某種計算來產生的值,那麼生成器就更有趣了。例如,這裡是一個產生斐波那契數的生成器函式:

function* fibonacciSequence() {
    let x = 0, y = 1;
    for(;;) {
        yield y;
        [x, y] = [y, x+y];  // Note: destructuring assignment
    }
}

注意,這裡的fibonacciSequence()生成器函式有一個無限迴圈,並且永遠產生值而不返回。如果這個生成器與...擴充套件運算子一起使用,它將迴圈直到記憶體耗盡並且程式崩潰。然而,經過謹慎處理,可以在for/of迴圈中使用它:

// Return the nth Fibonacci number
function fibonacci(n) {
    for(let f of fibonacciSequence()) {
        if (n-- <= 0) return f;
    }
}
fibonacci(20)   // => 10946

這種無限生成器與這樣的take()生成器結合使用更有用:

// Yield the first n elements of the specified iterable object
function* take(n, iterable) {
    let it = iterable[Symbol.iterator](); // Get iterator for iterable object
    while(n-- > 0) {           // Loop n times:
        let next = it.next();  // Get the next item from the iterator.
        if (next.done) return; // If there are no more values, return early
        else yield next.value; // otherwise, yield the value
    }
}

// An array of the first 5 Fibonacci numbers
[...take(5, fibonacciSequence())]  // => [1, 1, 2, 3, 5]

這裡是另一個有用的生成器函式,它交錯多個可迭代物件的元素:

// Given an array of iterables, yield their elements in interleaved order.
function* zip(...iterables) {
    // Get an iterator for each iterable
    let iterators = iterables.map(i => i[Symbol.iterator]());
    let index = 0;
    while(iterators.length > 0) {       // While there are still some iterators
        if (index >= iterators.length) {    // If we reached the last iterator
            index = 0;                      // go back to the first one.
        }
        let item = iterators[index].next(); // Get next item from next iterator.
        if (item.done) {                    // If that iterator is done
            iterators.splice(index, 1);     // then remove it from the array.
        }
        else {                              // Otherwise,
            yield item.value;               // yield the iterated value
            index++;                        // and move on to the next iterator.
        }
    }
}

// Interleave three iterable objects
[...zip(oneDigitPrimes(),"ab",[0])]     // => [2,"a",0,3,"b",5,7]

12.3.2 yield* 和遞迴生成器

除了在前面的示例中定義的zip()生成器之外,可能還有一個類似的生成器函式很有用,它按順序而不是交錯地產生多個可迭代物件的元素。我們可以這樣編寫這個生成器:

function* sequence(...iterables) {
    for(let iterable of iterables) {
        for(let item of iterable) {
            yield item;
        }
    }
}

[...sequence("abc",oneDigitPrimes())]  // => ["a","b","c",2,3,5,7]

在生成器函式中產生其他可迭代物件的元素的過程在生成器函式中是很常見的,ES6 為此提供了特殊的語法。yield*關鍵字類似於yield,不同之處在於,它不是產生單個值,而是迭代一個可迭代物件併產生每個結果值。我們使用的sequence()生成器函式可以用yield*簡化如下:

function* sequence(...iterables) {
    for(let iterable of iterables) {
        yield* iterable;
    }
}

[...sequence("abc",oneDigitPrimes())]  // => ["a","b","c",2,3,5,7]

陣列的forEach()方法通常是遍歷陣列元素的一種優雅方式,因此你可能會嘗試像這樣編寫sequence()函式:

function* sequence(...iterables) {
    iterables.forEach(iterable => yield* iterable );  // Error
}

然而,這是行不通的。yieldyield*只能在生成器函式內部使用,但是這段程式碼中的巢狀箭頭函式是一個普通函式,而不是function*生成器函式,因此不允許使用yield

yield*可以與任何型別的可迭代物件一起使用,包括使用生成器實現的可迭代物件。這意味著yield*允許我們定義遞迴生成器,你可以使用這個特性來允許對遞迴定義的樹結構進行簡單的非遞迴迭代,例如。

12.4 高階生成器功能

生成器函式最常見的用途是建立迭代器,但生成器的基本特性是允許我們暫停計算,產生中間結果,然後稍後恢復計算。這意味著生成器具有超出迭代器的功能,並且我們將在以下部分探討這些功能。

12.4.1 生成器函式的返回值

到目前為止,我們看到的生成器函式沒有return語句,或者如果有的話,它們被用來導致早期返回,而不是返回一個值。不過,與任何函式一樣,生成器函式可以返回一個值。為了理解在這種情況下會發生什麼,回想一下迭代的工作原理。next()函式的返回值是一個具有value屬性和/或done屬性的物件。對於典型的迭代器和生成器,如果value屬性被定義,則done屬性未定義或為false。如果donetrue,則value為未定義。但是對於返回值的生成器,最後一次呼叫next會返回一個同時定義了valuedone的物件。value屬性儲存生成器函式的返回值,done屬性為true,表示沒有更多的值可迭代。這個最終值被for/of迴圈和展開運算子忽略,但對於手動使用顯式呼叫next()的程式碼是可用的:

function *oneAndDone() {
    yield 1;
    return "done";
}

// The return value does not appear in normal iteration.
[...oneAndDone()]   // => [1]

// But it is available if you explicitly call next()
let generator = oneAndDone();
generator.next()           // => { value: 1, done: false}
generator.next()           // => { value: "done", done: true }
// If the generator is already done, the return value is not returned again
generator.next()           // => { value: undefined, done: true }

12.4.2 yield 表示式的值

在前面的討論中,我們將yield視為接受值但沒有自身值的語句。實際上,yield是一個表示式,它可以有一個值。

當呼叫生成器的next()方法時,生成器函式執行直到達到yield表示式。yield關鍵字後面的表示式被評估,該值成為next()呼叫的返回值。此時,生成器函式在評估yield表示式的過程中停止執行。下次呼叫生成器的next()方法時,傳遞給next()的引數成為暫停的yield表示式的值。因此,生成器透過yield向其呼叫者返回值,呼叫者透過next()向生成器傳遞值。生成器和呼叫者是兩個獨立的執行流,來回傳遞值(和控制)。以下程式碼示例:

function* smallNumbers() {
    console.log("next() invoked the first time; argument discarded");
    let y1 = yield 1;    // y1 == "b"
    console.log("next() invoked a second time with argument", y1);
    let y2 = yield 2;    // y2 == "c"
    console.log("next() invoked a third time with argument", y2);
    let y3 = yield 3;    // y3 == "d"
    console.log("next() invoked a fourth time with argument", y3);
    return 4;
}

let g = smallNumbers();
console.log("generator created; no code runs yet");
let n1 = g.next("a");   // n1.value == 1
console.log("generator yielded", n1.value);
let n2 = g.next("b");   // n2.value == 2
console.log("generator yielded", n2.value);
let n3 = g.next("c");   // n3.value == 3
console.log("generator yielded", n3.value);
let n4 = g.next("d");   // n4 == { value: 4, done: true }
console.log("generator returned", n4.value);

當執行這段程式碼時,會產生以下輸出,展示了兩個程式碼塊之間的來回互動:

generator created; no code runs yet
next() invoked the first time; argument discarded
generator yielded 1
next() invoked a second time with argument b
generator yielded 2
next() invoked a third time with argument c
generator yielded 3
next() invoked a fourth time with argument d
generator returned 4

注意這段程式碼中的不對稱性。第一次呼叫next()啟動了生成器,但傳遞給該呼叫的值對生成器不可訪問。

12.4.3 生成器的 return()和 throw()方法

我們已經看到可以接收生成器函式產生的值。您可以透過在呼叫生成器的next()方法時傳遞這些值來向正在執行的生成器傳遞值。

除了使用next()向生成器提供輸入外,還可以透過呼叫其return()throw()方法來更改生成器內部的控制流。如其名稱所示,呼叫這些方法會導致生成器返回一個值或丟擲異常,就好像生成器中的下一條語句是returnthrow一樣。

在本章的前面提到,如果迭代器定義了一個return()方法並且迭代提前停止,那麼直譯器會自動呼叫return()方法,以便讓迭代器有機會關閉檔案或進行其他清理工作。對於生成器來說,你不能定義一個自定義的return()方法來處理清理工作,但你可以結構化生成器程式碼以使用try/finally語句,在生成器返回時確保必要的清理工作已完成(在finally塊中)。透過強制生成器返回,生成器的內建return()方法確保在生成器不再使用時執行清理程式碼。

就像生成器的next()方法允許我們向正在執行的生成器傳遞任意值一樣,生成器的throw()方法給了我們一種向生成器傳送任意訊號(以異常的形式)的方法。呼叫throw()方法總是在生成器內部引發異常。但如果生成器函式編寫了適當的異常處理程式碼,異常不必是致命的,而可以是改變生成器行為的手段。例如,想象一個計數器生成器,產生一個不斷增加的整數序列。這可以被編寫成使用throw()傳送的異常將計數器重置為零。

當生成器使用yield*從其他可迭代物件中產生值時,那麼對生成器的next()方法的呼叫會導致對可迭代物件的next()方法的呼叫。return()throw()方法也是如此。如果生成器在可迭代物件上使用yield*,那麼在生成器上呼叫return()throw()會導致依次呼叫迭代器的return()throw()方法。所有迭代器必須有一個next()方法。需要在不完整迭代後進行清理的迭代器應該定義一個return()方法。任何迭代器可以定義一個throw()方法,儘管我不知道任何實際原因這樣做。

12.4.4 關於生成器的最後說明

生成器是一種非常強大的通用控制結構。它們使我們能夠使用yield暫停計算,並在任意後續時間點以任意輸入值重新啟動。可以使用生成器在單執行緒 JavaScript 程式碼中建立一種協作執行緒系統。也可以使用生成器掩蓋程式中的非同步部分,使你的程式碼看起來是順序和同步的,儘管你的一些函式呼叫實際上是非同步的並依賴於網路事件。

嘗試用生成器做這些事情會導致程式碼難以理解或解釋。然而,已經做到了,唯一真正實用的用例是管理非同步程式碼。然而,JavaScript 現在有asyncawait關鍵字(見第十三章)用於這個目的,因此不再有任何濫用生成器的理由。

12.5 總結

在本章中,你學到了:

  • for/of迴圈和...擴充套件運算子適用於可迭代物件。

  • 如果一個物件有一個名為[Symbol.iterator]的方法返回一個迭代器物件,那麼它就是可迭代的。

  • 迭代器物件有一個next()方法返回一個迭代結果物件。

  • 迭代結果物件有一個value屬性,儲存下一個迭代的值(如果有的話)。如果迭代已完成,則結果物件必須將done屬性設定為true

  • 你可以透過定義一個[Symbol.iterator]()方法返回一個具有next()方法返回迭代結果物件的物件來實現自己的可迭代物件。你也可以實現接受迭代器引數並返回迭代器值的函式。

  • 生成器函式(使用function*而不是function定義的函式)是定義迭代器的另一種方式。

  • 當呼叫生成器函式時,函式體不會立即執行;相反,返回值是一個可迭代的迭代器物件。每次呼叫迭代器的next()方法時,生成器函式的另一個塊會執行。

  • 生成器函式可以使用yield運算子指定迭代器返回的值。每次呼叫next()都會導致生成器函式執行到下一個yield表示式。該yield表示式的值然後成為迭代器返回的值。當沒有更多的yield表示式時,生成器函式返回,迭代完成。

第十三章:非同步 JavaScript

一些計算機程式,如科學模擬和機器學習模型,是計算密集型的:它們持續執行,不間斷,直到計算出結果為止。然而,大多數現實世界的計算機程式都是顯著非同步的。這意味著它們經常需要在等待資料到達或某個事件發生時停止計算。在 Web 瀏覽器中,JavaScript 程式通常是事件驅動的,這意味著它們等待使用者點選或輕觸才會實際執行任何操作。而基於 JavaScript 的伺服器通常在等待客戶端請求透過網路到達之前不會執行任何操作。

這種非同步程式設計在 JavaScript 中很常見,本章記錄了三個重要的語言特性,幫助簡化處理非同步程式碼。Promise 是 ES6 中引入的物件,表示尚未可用的非同步操作的結果。關鍵字asyncawait是在 ES2017 中引入的,透過允許你將基於 Promise 的程式碼結構化為同步的形式,簡化了非同步程式設計的語法。最後,在 ES2018 中引入了非同步迭代器和for/await迴圈,允許你使用看似同步的簡單迴圈處理非同步事件流。

具有諷刺意味的是,儘管 JavaScript 提供了這些強大的功能來處理非同步程式碼,但核心語法本身沒有非同步特性。因此,為了演示 Promise、asyncawaitfor/await,我們將首先進入客戶端和伺服器端 JavaScript,解釋 Web 瀏覽器和 Node 的一些非同步特性。(你可以在第十五章和第十六章瞭解更多關於客戶端和伺服器端 JavaScript 的內容。)

13.1 使用回撥進行非同步程式設計

在 JavaScript 中,非同步程式設計的最基本層次是透過回撥完成的。回撥是你編寫並傳遞給其他函式的函式。當滿足某些條件或發生某些(非同步)事件時,另一個函式會呼叫(“回撥”)你的函式。你提供的回撥函式的呼叫會通知你條件或事件,並有時,呼叫會包括提供額外細節的函式引數。透過一些具體的例子更容易理解,接下來的小節演示了使用客戶端 JavaScript 和 Node 進行基於回撥的非同步程式設計的各種形式。

13.1.1 定時器

最簡單的非同步之一是當你想在一定時間後執行一些程式碼時。正如我們在§11.10 中看到的,你可以使用setTimeout()函式來實現:

setTimeout(checkForUpdates, 60000);

setTimeout()的第一個引數是一個函式,第二個是以毫秒為單位的時間間隔。在上述程式碼中,一個假設的checkForUpdates()函式將在setTimeout()呼叫後的 60,000 毫秒(1 分鐘)後被呼叫。checkForUpdates()是你的程式可能定義的回撥函式,setTimeout()是你呼叫以註冊回撥函式並指定在何種非同步條件下呼叫它的函式。

setTimeout()呼叫指定的回撥函式一次,不傳遞任何引數,然後忘記它。如果你正在編寫一個真正檢查更新的函式,你可能希望它重複執行。你可以使用setInterval()而不是setTimeout()來實現這一點:

// Call checkForUpdates in one minute and then again every minute after that
let updateIntervalId = setInterval(checkForUpdates, 60000);

// setInterval() returns a value that we can use to stop the repeated
// invocations by calling clearInterval(). (Similarly, setTimeout()
// returns a value that you can pass to clearTimeout())
function stopCheckingForUpdates() {
    clearInterval(updateIntervalId);
}

13.1.2 事件

客戶端 JavaScript 程式幾乎普遍是事件驅動的:而不是執行某種預定的計算,它們通常等待使用者執行某些操作,然後響應使用者的動作。當使用者在鍵盤上按鍵、移動滑鼠、點選滑鼠按鈕或觸控觸控式螢幕裝置時,Web 瀏覽器會生成一個事件。事件驅動的 JavaScript 程式在指定的上下文中為指定型別的事件註冊回撥函式,當指定的事件發生時,Web 瀏覽器會呼叫這些函式。這些回撥函式稱為事件處理程式事件監聽器,並使用addEventListener()進行註冊:

// Ask the web browser to return an object representing the HTML
// <button> element that matches this CSS selector
let okay = document.querySelector('#confirmUpdateDialog button.okay');

// Now register a callback function to be invoked when the user
// clicks on that button.
okay.addEventListener('click', applyUpdate);

在這個例子中,applyUpdate()是一個我們假設在其他地方實現的虛構回撥函式。呼叫document.querySelector()返回一個表示 Web 頁面中單個指定元素的物件。我們在該元素上呼叫addEventListener()來註冊我們的回撥。然後addEventListener()的第一個引數是一個字串,指定我們感興趣的事件型別——在這種情況下是滑鼠點選或觸控式螢幕點選。如果使用者點選或觸控 Web 頁面的特定元素,那麼瀏覽器將呼叫我們的applyUpdate()回撥函式,傳遞一個包含有關事件的詳細資訊(如時間和滑鼠指標座標)的物件。

13.1.3 網路事件

JavaScript 程式設計中另一個常見的非同步來源是網路請求。在瀏覽器中執行的 JavaScript 可以使用以下程式碼從 Web 伺服器獲取資料:

function getCurrentVersionNumber(versionCallback) { // Note callback argument
    // Make a scripted HTTP request to a backend version API
    let request = new XMLHttpRequest();
    request.open("GET", "http://www.example.com/api/version");
    request.send();

    // Register a callback that will be invoked when the response arrives
    request.onload = function() {
        if (request.status === 200) {
            // If HTTP status is good, get version number and call callback.
            let currentVersion = parseFloat(request.responseText);
            versionCallback(null, currentVersion);
        } else {
            // Otherwise report an error to the callback
            versionCallback(response.statusText, null);
        }
    };
    // Register another callback that will be invoked for network errors
    request.onerror = request.ontimeout = function(e) {
        versionCallback(e.type, null);
    };
}

客戶端 JavaScript 程式碼可以使用 XMLHttpRequest 類加上回撥函式來進行 HTTP 請求,並在伺服器響應到達時非同步處理。¹ 這裡定義的getCurrentVersionNumber()函式(我們可以想象它被假設的checkForUpdates()函式使用,我們在§13.1.1 中討論過)發出 HTTP 請求,並定義在接收到伺服器響應或超時或其他錯誤導致請求失敗時將被呼叫的事件處理程式。

請注意,上面的程式碼示例不像我們之前的示例那樣呼叫addEventListener()。對於大多數 Web API(包括此示例),可以透過在生成事件的物件上呼叫addEventListener()並傳遞感興趣的事件名稱以及回撥函式來定義事件處理程式。通常,您也可以透過將其直接分配給物件的屬性來註冊單個事件監聽器。這就是我們在這個示例程式碼中所做的,將函式分配給onloadonerrorontimeout屬性。按照慣例,像這樣的事件監聽器屬性總是以on開頭的名稱。addEventListener()是更靈活的技術,因為它允許註冊多個事件處理程式。但在確保沒有其他程式碼需要為相同的物件和事件型別註冊監聽器的情況下,直接將適當的屬性設定為您的回撥可能更簡單。

在這個示例程式碼中關於getCurrentVersionNumber()函式的另一點需要注意的是,由於它發出了一個非同步請求,它無法同步返回撥用者感興趣的值(當前版本號)。相反,呼叫者傳遞一個回撥函式,當結果準備就緒或發生錯誤時呼叫。在這種情況下,呼叫者提供了一個期望兩個引數的回撥函式。如果 XMLHttpRequest 正常工作,那麼getCurrentVersionNumber()會用null作為第一個引數,版本號作為第二個引數呼叫回撥。或者,如果發生錯誤,那麼getCurrentVersionNumber()會用錯誤詳細資訊作為第一個引數,null作為第二個引數呼叫回撥。

13.1.4 Node 中的回撥和事件

Node.js 伺服器端 JavaScript 環境是深度非同步的,並定義了許多使用回撥和事件的 API。例如,讀取檔案內容的預設 API 是非同步的,並在檔案內容被讀取後呼叫回撥函式:

const fs = require("fs"); // The "fs" module has filesystem-related APIs
let options = {           // An object to hold options for our program
    // default options would go here
};

// Read a configuration file, then call the callback function
fs.readFile("config.json", "utf-8", (err, text) => {
    if (err) {
        // If there was an error, display a warning, but continue
        console.warn("Could not read config file:", err);
    } else {
        // Otherwise, parse the file contents and assign to the options object
        Object.assign(options, JSON.parse(text));
    }

    // In either case, we can now start running the program
    startProgram(options);
});

Node 的fs.readFile()函式將一個兩引數回撥作為其最後一個引數。它非同步讀取指定的檔案,然後呼叫回撥。如果檔案成功讀取,它將檔案內容作為第二個回撥引數傳遞。如果出現錯誤,它將錯誤作為第一個回撥引數傳遞。在這個例子中,我們將回撥錶達為箭頭函式,這是一種簡潔和自然的語法,適用於這種簡單操作。

Node 還定義了許多基於事件的 API。以下函式展示瞭如何在 Node 中請求 URL 的內容。它有兩層透過事件監聽器處理的非同步程式碼。請注意,Node 使用on()方法來註冊事件監聽器,而不是addEventListener()

const https = require("https");

// Read the text content of the URL and asynchronously pass it to the callback.
function getText(url, callback) {
    // Start an HTTP GET request for the URL
    request = https.get(url);

    // Register a function to handle the "response" event.
    request.on("response", response => {
        // The response event means that response headers have been received
        let httpStatus = response.statusCode;

        // The body of the HTTP response has not been received yet.
        // So we register more event handlers to to be called when it arrives.
        response.setEncoding("utf-8");  // We're expecting Unicode text
        let body = "";                  // which we will accumulate here.

        // This event handler is called when a chunk of the body is ready
        response.on("data", chunk => { body += chunk; });

        // This event handler is called when the response is complete
        response.on("end", () => {
            if (httpStatus === 200) {   // If the HTTP response was good
                callback(null, body);   // Pass response body to the callback
            } else {                    // Otherwise pass an error
                callback(httpStatus, null);
            }
        });
    });

    // We also register an event handler for lower-level network errors
    request.on("error", (err) => {
        callback(err, null);
    });
}

13.2 承諾

現在我們已經在客戶端和伺服器端 JavaScript 環境中看到了回撥和基於事件的非同步程式設計的示例,我們可以介紹承諾,這是一個旨在簡化非同步程式設計的核心語言特性。

承諾是表示非同步計算結果的物件。該結果可能已經準備好,也可能尚未準備好,承諾 API 故意對此保持模糊:沒有同步獲取承諾值的方法;您只能要求承諾在值準備好時呼叫回撥函式。如果您正在定義一個類似前一節中的getText()函式的非同步 API,但希望將其基於承諾,省略回撥引數,而是返回一個承諾物件。呼叫者可以在這個承諾物件上註冊一個或多個回撥,當非同步計算完成時,它們將被呼叫。

因此,在最簡單的層面上,承諾只是一種與回撥一起工作的不同方式。然而,使用它們有實際的好處。基於回撥的非同步程式設計的一個真正問題是,通常會出現回撥內嵌在回撥內嵌在回撥中的情況,程式碼行縮排如此之深,以至於難以閱讀。承諾允許將這種巢狀回撥重新表達為更線性的承諾鏈,這樣更容易閱讀和推理。

回撥函式的另一個問題是,它們可能會使處理錯誤變得困難。如果非同步函式(或非同步呼叫的回撥)丟擲異常,那麼這個異常就無法傳播回非同步操作的發起者。這是關於非同步程式設計的一個基本事實:它破壞了異常處理。另一種方法是透過回撥引數和返回值來細緻地跟蹤和傳播錯誤,但這樣做很繁瑣,很難做到正確。承諾在這裡有所幫助,透過標準化處理錯誤的方式,並提供一種讓錯誤正確傳播透過一系列承諾的方法。

請注意,承諾代表單個非同步計算的未來結果。然而,它們不能用於表示重複的非同步計算。在本章的後面,我們將編寫一個基於承諾的setTimeout()函式的替代方案。但我們不能使用承諾來替代setInterval(),因為該函式會重複呼叫回撥函式,而這是承諾設計上不支援的。同樣地,我們可以使用承諾來替代 XMLHttpRequest 物件的“load”事件處理程式,因為該回撥只會被呼叫一次。但通常情況下,我們不會使用承諾來替代 HTML 按鈕物件的“click”事件處理程式,因為我們通常希望允許使用者多次點選按鈕。

接下來的小節將:

  • 解釋承諾術語並展示基本承諾用法

  • 展示 Promises 如何被鏈式呼叫

  • 展示如何建立自己的基於 Promise 的 API

重要

起初,Promise 似乎很簡單,事實上,Promise 的基本用例確實簡單明瞭。但是,對於超出最簡單用例的任何情況,它們可能變得令人驚訝地令人困惑。Promise 是非同步程式設計的強大習語,但你需要深入理解才能正確自信地使用它們。然而,花時間深入瞭解是值得的,我敦促你仔細研究這一長章節。

13.2.1 使用 Promises

隨著 Promises 在核心 JavaScript 語言中的出現,Web 瀏覽器已經開始實現基於 Promise 的 API。在前一節中,我們實現了一個getText()函式,該函式發起了一個非同步的 HTTP 請求,並將 HTTP 響應的主體作為字串傳遞給指定的回撥函式。想象一個這個函式的變體,getJSON(),它將 HTTP 響應的主體解析為 JSON,並返回一個 Promise,而不是接受一個回撥引數。我們將在本章後面實現一個getJSON()函式,但現在,讓我們看看如何使用這個返回 Promise 的實用函式:

getJSON(url).then(jsonData => {
    // This is a callback function that will be asynchronously
    // invoked with the parsed JSON value when it becomes available.
});

getJSON()啟動一個非同步的 HTTP 請求,請求指定的 URL,然後,在該請求掛起期間,它返回一個 Promise 物件。Promise 物件定義了一個then()例項方法。我們不直接將回撥函式傳遞給getJSON(),而是將其傳遞給then()方法。當 HTTP 響應到達時,該響應的主體被解析為 JSON,並將解析後的值傳遞給我們傳遞給then()的函式。

你可以將then()方法看作是一個回撥註冊方法,類似於用於在客戶端 JavaScript 中註冊事件處理程式的addEventListener()方法。如果多次呼叫 Promise 物件的then()方法,每個指定的函式都將在承諾的計算完成時被呼叫。

與許多事件偵聽器不同,Promise 代表一個單一的計算,每個註冊到then()的函式只會被呼叫一次。值得注意的是,無論何時呼叫then(),你傳遞給then()的函式都會非同步呼叫,即使非同步計算在呼叫then()時已經完成。

在簡單的語法層面上,then()方法是 Promise 的獨特特徵,習慣上直接將.then()附加到返回 Promise 的函式呼叫上,而不是將 Promise 物件分配給變數的中間步驟。

習慣上,將返回 Promises 的函式和使用 Promises 結果的函式命名為動詞,這些習慣導致的程式碼特別易於閱讀:

// Suppose you have a function like this to display a user profile
function displayUserProfile(profile) { /* implementation omitted */ }

// Here's how you might use that function with a Promise.
// Notice how this line of code reads almost like an English sentence:
getJSON("/api/user/profile").then(displayUserProfile);

使用 Promises 處理錯誤

非同步操作,特別是涉及網路的操作,通常會以多種方式失敗,必須編寫健壯的程式碼來處理不可避免發生的錯誤。

對於 Promises,我們可以透過將第二個函式傳遞給then()方法來實現:

getJSON("/api/user/profile").then(displayUserProfile, handleProfileError);

Promise 代表在 Promise 物件建立後發生的非同步計算的未來結果。因為計算是在 Promise 物件返回給我們後執行的,所以傳統上計算無法返回一個值或丟擲我們可以捕獲的異常。我們傳遞給then()的函式提供了替代方案。當同步計算正常完成時,它只是將其結果返回給呼叫者。當基於 Promise 的非同步計算正常完成時,它將其結果傳遞給作為then()的第一個引數的函式。

當同步計算出現問題時,它會丟擲一個異常,該異常會向上傳播到呼叫堆疊,直到有一個catch子句來處理它。當非同步計算執行時,其呼叫者不再在堆疊上,因此如果出現問題,就不可能將異常拋回給呼叫者。

相反,基於 Promise 的非同步計算將異常(通常作為某種型別的 Error 物件,儘管這不是必需的)傳遞給then()的第二個函式。因此,在上面的程式碼中,如果getJSON()正常執行,它會將結果傳遞給displayUserProfile()。如果出現錯誤(使用者未登入、伺服器當機、使用者的網際網路連線中斷、請求超時等),那麼getJSON()會將一個 Error 物件傳遞給handleProfileError()

在實踐中,很少看到兩個函式傳遞給then()。在處理 Promise 時,有一種更好的、更符合習慣的處理錯誤的方式。要理解這一點,首先考慮一下如果getJSON()正常完成,但displayUserProfile()中出現錯誤會發生什麼。當getJSON()返回時,回撥函式會非同步呼叫,因此它也是非同步的,不能有意義地丟擲異常(因為沒有程式碼在呼叫堆疊上處理它)。

在這段程式碼中處理錯誤的更符合習慣的方式如下:

getJSON("/api/user/profile").then(displayUserProfile).catch(handleProfileError);

使用這段程式碼,getJSON()的正常結果仍然會傳遞給displayUserProfile(),但是getJSON()displayUserProfile()中的任何錯誤(包括displayUserProfile丟擲的任何異常)都會傳遞給handleProfileError()catch()方法只是呼叫then()的一種簡寫形式,第一個引數為null,第二個引數為指定的錯誤處理函式。

當我們討論下一節的 Promise 鏈時,我們將會更多地談到catch()和這種錯誤處理習慣。

13.2.2 鏈式 Promise

Promise 最重要的好處之一是它們提供了一種自然的方式來將一系列非同步操作表達為then()方法呼叫的線性鏈,而無需將每個操作巢狀在前一個操作的回撥中。例如,這裡是一個假設的 Promise 鏈:

fetch(documentURL)                      // Make an HTTP request
    .then(response => response.json())  // Ask for the JSON body of the response
    .then(document => {                 // When we get the parsed JSON
        return render(document);        // display the document to the user
    })
    .then(rendered => {                 // When we get the rendered document
        cacheInDatabase(rendered);      // cache it in the local database.
    })
    .catch(error => handle(error));     // Handle any errors that occur

這段程式碼說明了一系列 Promise 如何簡單地表達一系列非同步操作的過程。然而,我們不會討論這個特定的 Promise 鏈。不過,我們將繼續探討使用 Promise 鏈進行 HTTP 請求的想法。

在本章的前面,我們看到了在 JavaScript 中使用 XMLHttpRequest 物件進行 HTTP 請求。這個奇怪命名的物件具有一個古老且笨拙的 API,它已經大部分被新的、基於 Promise 的 Fetch API(§15.11.1)所取代。在其最簡單的形式中,這個新的 HTTP API 就是函式fetch()。你傳遞一個 URL 給它,它會返回一個 Promise。當 HTTP 響應開始到達並且 HTTP 狀態和頭部可用時,這個 Promise 就會被實現:

fetch("/api/user/profile").then(response => {
    // When the promise resolves, we have status and headers
    if (response.ok &&
        response.headers.get("Content-Type") === "application/json") {
        // What can we do here? We don't actually have the response body yet.
    }
});

fetch()返回的 Promise 被實現時,它會將一個 Response 物件傳遞給您傳遞給其then()方法的函式。這個響應物件讓您可以訪問請求狀態和頭部,並且還定義了像text()json()這樣的方法,分別以文字和 JSON 解析形式訪問響應主體。但是儘管初始 Promise 被實現,響應主體可能尚未到達。因此,用於訪問響應主體的這些text()json()方法本身返回 Promise。以下是使用fetch()response.json()方法獲取 HTTP 響應主體的一種天真的方法:

fetch("/api/user/profile").then(response => {
    response.json().then(profile => {  // Ask for the JSON-parsed body
        // When the body of the response arrives, it will be automatically
        // parsed as JSON and passed to this function.
        displayUserProfile(profile);
    });
});

這是一種天真地使用 Promise 的方式,因為我們像回撥一樣巢狀它們,這違背了初衷。更好的習慣是使用 Promise 在一個順序鏈中編寫程式碼,就像這樣:

fetch("/api/user/profile")
    .then(response => {
        return response.json();
    })
    .then(profile => {
        displayUserProfile(profile);
    });

讓我們看一下這段程式碼中的方法呼叫,忽略傳遞給方法的引數:

fetch().then().then()

當在一個表示式中呼叫多個方法時,我們稱之為方法鏈。我們知道fetch()函式返回一個 Promise 物件,我們可以看到這個鏈中的第一個.then()呼叫在返回的 Promise 物件上呼叫一個方法。但是鏈中還有第二個.then(),這意味著then()方法的第一次呼叫本身必須返回一個 Promise。

有時,當設計 API 以使用這種方法鏈時,只有一個物件,並且該物件的每個方法都返回物件本身以便於連結。然而,這並不是 Promise 的工作方式。當我們編寫一系列.then()呼叫時,我們並不是在單個 Promise 物件上註冊多個回撥。相反,then()方法的每次呼叫都會返回一個新的 Promise 物件。直到傳遞給then()的函式完成,新的 Promise 物件才會被實現。

讓我們回到上面原始fetch()鏈的簡化形式。如果我們在其他地方定義傳遞給then()呼叫的函式,我們可以重構程式碼如下:

fetch(theURL)          // task 1; returns promise 1
    .then(callback1)   // task 2; returns promise 2
    .then(callback2);  // task 3; returns promise 3

讓我們詳細討論一下這段程式碼:

  1. 在第一行,使用一個 URL 呼叫fetch()。它為該 URL 發起一個 HTTP GET 請求並返回一個 Promise。我們將這個 HTTP 請求稱為“任務 1”,將 Promise 稱為“promise 1”。

  2. 在第二行,我們呼叫 promise 1 的then()方法,傳遞我們希望在 promise 1 實現時呼叫的callback1函式。then()方法將我們的回撥儲存在某個地方,然後返回一個新的 Promise。我們將在這一步返回的新 Promise 稱為“promise 2”,並且我們將說“任務 2”在呼叫callback1時開始。

  3. 在第三行,我們呼叫 promise 2 的then()方法,傳遞我們希望在 promise 2 實現時呼叫的callback2函式。這個then()方法記住我們的回撥並返回另一個 Promise。我們將說“任務 3”在呼叫callback2時開始。我們可以稱這個最新的 Promise 為“promise 3”,但實際上我們不需要為它命名,因為我們根本不會使用它。

  4. 前三個步驟都是在表示式首次執行時同步發生的。現在,在 HTTP 請求在步驟 1 中發出並透過網際網路傳送時,我們有一個非同步暫停。

  5. 最終,HTTP 響應開始到達。fetch()呼叫的非同步部分將 HTTP 狀態和標頭包裝在一個 Response 物件中,並使用該 Response 物件作為值來實現 promise 1。

  6. 當 promise 1 被實現時,它的值(Response 物件)被傳遞給我們的callback1()函式,任務 2 開始。這個任務的工作是,給定一個 Response 物件作為輸入,獲取響應主體作為 JSON 物件。

  7. 讓我們假設任務 2 正常完成,並且能夠解析 HTTP 響應的主體以生成一個 JSON 物件。這個 JSON 物件用於實現 promise 2。

  8. 實現 promise 2 的值成為傳遞給callback2()函式時任務 3 的輸入。當任務 3 完成(假設它正常完成)時,promise 3 將被實現。但因為我們從未對 promise 3 做任何操作,當該 Promise 完成時什麼也不會發生,非同步計算鏈在這一點結束。

13.2.3 解決 Promise

在上一節中解釋了與列表中的 URL 獲取 Promise 鏈相關的內容時,我們談到了 promise 1、2 和 3。但實際上還涉及第四個 Promise 物件,這將引出我們對 Promise“解決”意味著什麼的重要討論。

請記住,fetch()返回一個 Promise 物件,當實現時,將傳遞一個 Response 物件給我們註冊的回撥函式。這個 Response 物件有.text().json()和其他方法以各種形式請求 HTTP 響應的主體。但是由於主體可能尚未到達,這些方法必須返回 Promise 物件。在我們一直在研究的示例中,“任務 2”呼叫.json()方法並返回其值。這是第四個 Promise 物件,也是callback1()函式的返回值。

讓我們再次以冗長和非成語化的方式重寫 URL 獲取程式碼,使回撥和 Promises 明確:

function c1(response) {               // callback 1
    let p4 = response.json();
    return p4;                        // returns promise 4
}

function c2(profile) {                // callback 2
    displayUserProfile(profile);
}

let p1 = fetch("/api/user/profile");  // promise 1, task 1
let p2 = p1.then(c1);                 // promise 2, task 2
let p3 = p2.then(c2);                 // promise 3, task 3

為了使 Promise 鏈有用地工作,任務 2 的輸出必須成為任務 3 的輸入。在我們正在考慮的示例中,任務 3 的輸入是獲取的 URL 主體,解析為 JSON 物件。但是,正如我們剛才討論的,回撥c1的返回值不是 JSON 物件,而是該 JSON 物件的 Promisep4。這似乎是一個矛盾,但實際上不是:當p1被實現時,c1被呼叫,任務 2 開始。當p2被實現時,c2被呼叫,任務 3 開始。但是僅僅因為在呼叫c1時任務 2 開始,並不意味著任務 2 在c1返回時必須結束。畢竟,Promises 是關於管理非同步任務的,如果任務 2 是非同步的(在這種情況下是),那麼在回撥返回時該任務將尚未完成。

現在我們準備討論您需要真正掌握 Promises 的最後一個細節。當您將回撥c傳遞給then()方法時,then()返回一個 Promisep並安排在稍後的某個時間非同步呼叫c。回撥執行一些計算並返回一個值v。當回撥返回時,p解析為值v。當一個 Promise 被解析為一個不是 Promise 的值時,它會立即被實現為該值。因此,如果c返回一個非 Promise,那麼返回值就成為p的值,p被實現,我們完成了。但是如果返回值v本身是一個 Promise,那麼p解析但尚未實現。在這個階段,p不能解決,直到 Promisev解決。如果v被實現,那麼p將被實現為相同的值。如果v被拒絕,那麼p將因同樣的原因被拒絕。這就是 Promise“解析”狀態的含義:Promise 已經與另一個 Promise 關聯或“鎖定”。我們還不知道p是否會被實現或被拒絕,但是我們的回撥c不再控制這一點。p“解析”意味著它的命運現在完全取決於 Promisev的發生。

讓我們回到我們的 URL 獲取示例。當c1返回p4時,p2被解析。但被解析並不意味著被實現,所以任務 3 還沒有開始。當完整的 HTTP 響應主體可用時,.json()方法可以解析它並使用解析後的值來實現p4。當p4被實現時,p2也會自動被實現,具有相同的解析 JSON 值。此時,解析後的 JSON 物件被傳遞給c2,任務 3 開始。

這可能是 JavaScript 中最難理解的部分之一,您可能需要閱讀本節不止一次。圖 13-1 以視覺化形式呈現了這個過程,可能有助於為您澄清。

js7e 1301

圖 13-1. 使用 Promises 獲取 URL

13.2.4 更多關於 Promises 和錯誤

在本章的前面,我們看到您可以將第二個回撥函式傳遞給.then()方法,並且如果 Promise 被拒絕,則將呼叫此第二個函式。當發生這種情況時,傳遞給此第二個回撥函式的引數是一個值—通常是代表拒絕原因的 Error 物件。我們還了解到,透過向 Promise 鏈中新增.catch()方法呼叫來處理 Promise 相關的錯誤是不常見的(甚至是不成文的)。現在我們已經檢查了 Promise 鏈,我們可以回到錯誤處理並更詳細地討論它。在討論之前,我想強調的是,在進行非同步程式設計時,仔細處理錯誤非常重要。對於同步程式碼,如果您省略了錯誤處理程式碼,您至少會得到一個異常和堆疊跟蹤,以便您可以找出出了什麼問題。對於非同步程式碼,未處理的異常通常不會被報告,錯誤可能會悄無聲息地發生,使得除錯變得更加困難。好訊息是,.catch()方法使得在處理 Promise 時處理錯誤變得容易。

catch 和 finally 方法

Promise 的.catch()方法只是一種使用null作為第一個引數並將錯誤處理回撥作為第二個引數呼叫.then()的簡寫方式。給定任何 Promisep和回撥c,以下兩行是等效的:

p.then(null, c);
p.catch(c);

.catch()簡寫更受歡迎,因為它更簡單,並且名稱與try/catch異常處理語句中的catch子句匹配。正如我們討論過的,普通異常在非同步程式碼中不起作用。Promise 的.catch()方法是一種適用於非同步程式碼的替代方法。當同步程式碼出現問題時,我們可以說異常“沿著呼叫堆疊上升”直到找到catch塊。對於 Promise 鏈的非同步鏈,類似的隱喻可能是錯誤“沿著鏈路下滑”,直到找到.catch()呼叫。

在 ES2018 中,Promise 物件還定義了一個.finally()方法,其目的類似於try/catch/finally語句中的finally子句。如果您在 Promise 鏈中新增一個.finally()呼叫,那麼您傳遞給.finally()的回撥將在您呼叫它的 Promise 完成時被呼叫。如果 Promise 完成或拒絕,都會呼叫您的回撥,並且不會傳遞任何引數,因此您無法找出它是完成還是拒絕。但是,如果您需要在任一情況下執行某種清理程式碼(例如關閉開啟的檔案或網路連線),則.finally()回撥是執行此操作的理想方式。與.then().catch()一樣,.finally()返回一個新的 Promise 物件。.finally()回撥的返回值通常被忽略,而由.finally()返回的 Promise 通常將使用與呼叫.finally()的 Promise 解析或拒絕的相同值解析或拒絕。但是,如果.finally()回撥引發異常,則由.finally()返回的 Promise 將以該值拒絕。

我們在前幾節中學習的 URL 獲取程式碼沒有進行任何錯誤處理。現在讓我們透過程式碼的更實際版本來糾正這一點:

fetch("/api/user/profile")    // Start the HTTP request
    .then(response => {       // Call this when status and headers are ready
        if (!response.ok) {   // If we got a 404 Not Found or similar error
            return null;      // Maybe user is logged out; return null profile
        }

        // Now check the headers to ensure that the server sent us JSON.
        // If not, our server is broken, and this is a serious error!
        let type = response.headers.get("content-type");
        if (type !== "application/json") {
            throw new TypeError(`Expected JSON, got ${type}`);
        }

        // If we get here, then we got a 2xx status and a JSON content-type
        // so we can confidently return a Promise for the response
        // body as a JSON object.
        return response.json();
    })
    .then(profile => {        // Called with the parsed response body or null
        if (profile) {
            displayUserProfile(profile);
        }
        else { // If we got a 404 error above and returned null we end up here
            displayLoggedOutProfilePage();
        }
    })
    .catch(e => {
        if (e instanceof NetworkError) {
            // fetch() can fail this way if the internet connection is down
            displayErrorMessage("Check your internet connection.");
        }
        else if (e instanceof TypeError) {
            // This happens if we throw TypeError above
            displayErrorMessage("Something is wrong with our server!");
        }
        else {
            // This must be some kind of unanticipated error
            console.error(e);
        }
    });

讓我們透過分析當事情出錯時會發生什麼來分析這段程式碼。我們將使用之前使用的命名方案:p1fetch()呼叫返回的 Promise。p2是第一個.then()呼叫返回的 Promise,c1是我們傳遞給該.then()呼叫的回撥。p3是第二個.then()呼叫返回的 Promise,c2是我們傳遞給該呼叫的回撥。最後,c3是我們傳遞給.catch()呼叫的回撥。(該呼叫返回一個 Promise,但我們不需要透過名稱引用它。)

可能失敗的第一件事是 fetch() 請求本身。如果網路連線斷開(或由於某種原因無法進行 HTTP 請求),那麼 Promise p1 將被拒絕,並帶有一個 NetworkError 物件。我們沒有將錯誤處理回撥函式作為第二個引數傳遞給 .then() 呼叫,因此 p2 也將以相同的 NetworkError 物件被拒絕。(如果我們向第一個 .then() 呼叫傳遞了錯誤處理程式,錯誤處理程式將被呼叫,如果它正常返回,p2 將被解析和/或完成,並帶有該處理程式的返回值。)然而,沒有處理程式,p2 被拒絕,然後 p3 由於相同原因被拒絕。此時,c3 錯誤處理回撥被呼叫,並其中的 NetworkError 特定程式碼執行。

我們的程式碼可能失敗的另一種方式是,如果我們的 HTTP 請求返回 404 Not Found 或其他 HTTP 錯誤。這些是有效的 HTTP 響應,因此 fetch() 呼叫不認為它們是錯誤。fetch() 將 404 Not Found 封裝在一個 Response 物件中,並用該物件完成 p1,導致呼叫 c1。我們在 c1 中的程式碼檢查 Response 物件的 ok 屬性,以檢測是否收到了正常的 HTTP 響應,並透過簡單返回 null 處理這種情況。因為這個返回值不是一個 Promise,它立即完成 p2,並用這個值呼叫 c2。我們在 c2 中明確檢查和處理 falsy 值,透過向使用者顯示不同的結果來處理這種情況。這是一個我們將異常條件視為非錯誤並在不使用錯誤處理程式的情況下處理它的案例。

如果我們得到一個正常的 HTTP 響應程式碼,但 Content-Type 頭部未正確設定,c1 中會發生一個更嚴重的錯誤。我們的程式碼期望一個 JSON 格式的響應,所以如果伺服器傳送給我們 HTML、XML 或純文字,我們將會遇到問題。c1 包含了檢查 Content-Type 頭部的程式碼。如果頭部錯誤,它將把這視為一個不可恢復的問題並丟擲一個 TypeError。當傳遞給 .then()(或 .catch())的回撥丟擲一個值時,作為 .then() 呼叫的返回值的 Promise 將被拒絕,並帶有該丟擲的值。在這種情況下,引發 TypeError 的 c1 中的程式碼導致 p2 被拒絕,並帶有該 TypeError 物件。由於我們沒有為 p2 指定錯誤處理程式,p3 也將被拒絕。c2 將不會被呼叫,並且 TypeError 將傳遞給 c3,它具有明確檢查和處理這種型別錯誤的程式碼。

關於這段程式碼有幾點值得注意。首先,請注意,使用常規的同步 throw 語句丟擲的錯誤物件最終會在 Promise 鏈中的 .catch() 方法呼叫中非同步處理。這應該清楚地說明為什麼這種簡寫方法優先於向 .then() 傳遞第二個引數,並且為什麼在 Promise 鏈末尾使用 .catch() 呼叫是如此習慣化的。

在我們離開錯誤處理的話題之前,我想指出,雖然習慣於在每個 Promise 鏈的末尾使用 .catch() 來清理(或至少記錄)鏈中發生的任何錯誤,但在 Promise 鏈的其他地方使用 .catch() 也是完全有效的。如果你的 Promise 鏈中的某個階段可能會因錯誤而失敗,並且如果錯誤是某種可恢復的錯誤,不應該阻止鏈的其餘部分執行,那麼你可以在鏈中插入一個 .catch() 呼叫,程式碼可能看起來像這樣:

startAsyncOperation()
    .then(doStageTwo)
    .catch(recoverFromStageTwoError)
    .then(doStageThree)
    .then(doStageFour)
    .catch(logStageThreeAndFourErrors);

請記住,您傳遞給 .catch() 的回撥只有在前一個階段的回撥丟擲錯誤時才會被呼叫。如果回撥正常返回,那麼 .catch() 回撥將被跳過,並且前一個回撥的返回值將成為下一個 .then() 回撥的輸入。還要記住,.catch() 回撥不僅用於報告錯誤,還用於處理和恢復錯誤。一旦錯誤傳遞給 .catch() 回撥,它就會停止在 Promise 鏈中傳播。.catch() 回撥可以丟擲新錯誤,但如果它正常返回,那麼返回值將用於解析和/或實現相關的 Promise,並且錯誤將停止傳播。

讓我們具體說明一下:在前面的程式碼示例中,如果 startAsyncOperation()doStageTwo() 丟擲錯誤,則將呼叫 recoverFromStageTwoError() 函式。如果 recoverFromStageTwoError() 正常返回,則其返回值將傳遞給 doStageThree(),非同步操作將繼續正常進行。另一方面,如果 recoverFromStageTwoError() 無法恢復,則它將丟擲錯誤(或重新丟擲傳遞給它的錯誤)。在這種情況下,doStageThree()doStageFour() 都不會被呼叫,並且由 recoverFromStageTwoError() 丟擲的錯誤將傳遞給 logStageThreeAndFourErrors()

有時,在複雜的網路環境中,錯誤可能更多或更少地隨機發生,透過簡單地重試非同步請求來處理這些錯誤可能是合適的。想象一下,您已經編寫了一個基於 Promise 的操作來查詢資料庫:

queryDatabase()
    .then(displayTable)
    .catch(displayDatabaseError);

現在假設瞬時網路負載問題導致失敗率約為 1%。一個簡單的解決方案可能是使用 .catch() 呼叫重試查詢:

queryDatabase()
    .catch(e => wait(500).then(queryDatabase))  // On failure, wait and retry
    .then(displayTable)
    .catch(displayDatabaseError);

如果假設的故障確實是隨機的,那麼新增這一行程式碼應該將您的錯誤率從 1% 降低到 0.01%。

13.2.5 並行的 Promises

我們花了很多時間討論 Promise 鏈,用於順序執行更大非同步操作的非同步步驟。但有時,我們希望並行執行多個非同步操作。函式 Promise.all() 可以做到這一點。Promise.all() 接受一個 Promise 物件陣列作為輸入,並返回一個 Promise。如果任何輸入 Promise 被拒絕,則返回的 Promise 將被拒絕。否則,它將以每個輸入 Promise 的實現值陣列實現。因此,例如,如果您想獲取多個 URL 的文字內容,您可以使用以下程式碼:

// We start with an array of URLs
const urls = [ /* zero or more URLs here */ ];
// And convert it to an array of Promise objects
promises = urls.map(url => fetch(url).then(r => r.text()));
// Now get a Promise to run all those Promises in parallel
Promise.all(promises)
    .then(bodies => { /* do something with the array of strings */ })
    .catch(e => console.error(e));

Promise.all() 稍微比之前描述的更靈活。輸入陣列可以包含 Promise 物件和非 Promise 值。如果陣列的元素不是 Promise,則會被視為已實現 Promise 的值,並且會被簡單地複製到輸出陣列中。

Promise.all() 返回的 Promise 在任何輸入 Promise 被拒絕時也會被拒絕。這會立即發生在第一個拒絕時,而其他輸入 Promise 仍在等待的情況下也可能發生。在 ES2020 中,Promise.allSettled() 接受一個輸入 Promise 陣列並返回一個 Promise,就像 Promise.all() 一樣。但是 Promise.allSettled() 永遠不會拒絕返回的 Promise,並且在所有輸入 Promise 都已完成之前不會實現該 Promise。該 Promise 解析為一個物件陣列,每個輸入 Promise 都有一個物件。每個返回的物件都有一個 status 屬性,設定為“fulfilled”或“rejected”。如果狀態是“fulfilled”,那麼物件還將有一個 value 屬性,給出實現值。如果狀態是“rejected”,那麼物件還將有一個 reason 屬性,給出相應 Promise 的錯誤或拒絕值:

Promise.allSettled([Promise.resolve(1), Promise.reject(2), 3]).then(results => {
    results[0]  // => { status: "fulfilled", value: 1 }
    results[1]  // => { status: "rejected", reason: 2 }
    results[2]  // => { status: "fulfilled", value: 3 }
});

有時,您可能希望同時執行多個 Promise,但可能只關心第一個實現的值。在這種情況下,您可以使用Promise.race()而不是Promise.all()。它返回一個 Promise,當輸入陣列中的 Promise 中的第一個實現或拒絕時,該 Promise 將實現或拒絕。(或者,如果輸入陣列中有任何非 Promise 值,則簡單地返回其中的第一個。)

13.2.6 建立 Promises

在許多先前的示例中,我們使用了返回 Promise 的函式fetch(),因為它是內建到 Web 瀏覽器中的最簡單的返回 Promise 的函式之一。我們對 Promises 的討論還依賴於假設的返回 Promise 的函式getJSON()wait()。編寫返回 Promises 的函式確實非常有用,本節展示瞭如何建立基於 Promise 的 API。特別是,我們將展示getJSON()wait()的實現。

基於其他 Promises 的 Promises

如果您有其他返回 Promise 的函式作為起點,編寫返回 Promise 的函式就很容易。給定一個 Promise,您可以透過呼叫.then()來建立(並返回)一個新的 Promise。因此,如果我們使用現有的fetch()函式作為起點,我們可以這樣編寫getJSON()

function getJSON(url) {
    return fetch(url).then(response => response.json());
}

程式碼很簡單,因為fetch()API 的 Response 物件具有預定義的json()方法。json()方法返回一個 Promise,我們從回撥中返回該 Promise(回撥是一個帶有單表示式主體的箭頭函式,因此返回是隱式的),因此getJSON()返回的 Promise 解析為response.json()返回的 Promise。當該 Promise 實現時,由getJSON()返回的 Promise 也實現為相同的值。請注意,此getJSON()實現中沒有錯誤處理。我們不檢查response.ok和 Content-Type 頭,而是允許json()方法拒絕返回的 Promise,如果響應主體無法解析為 JSON,則會引發 SyntaxError。

讓我們再寫一個返回 Promise 的函式,這次使用getJSON()作為初始 Promise 的來源。

function getHighScore() {
    return getJSON("/api/user/profile").then(profile => profile.highScore);
}

我們假設這個函式是某種基於 Web 的遊戲的一部分,並且 URL“/api/user/profile”返回一個包含highScore屬性的 JSON 格式資料結構。

基於同步值的 Promises

有時,您可能需要實現現有的基於 Promise 的 API,並從函式返回一個 Promise,即使要執行的計算實際上不需要任何非同步操作。在這種情況下,靜態方法Promise.resolve()Promise.reject()將實現您想要的效果。Promise.resolve()以其單個引數作為值,並返回一個將立即(但非同步地)實現為該值的 Promise。類似地,Promise.reject()接受一個引數,並返回一個將以該值為原因拒絕的 Promise。(要明確:這些靜態方法返回的 Promises 在返回時並未已實現或已拒絕,但它們將在當前同步程式碼塊執行完畢後立即實現或拒絕。通常,除非有許多待處理的非同步任務等待執行,否則這將在幾毫秒內發生。)

請回顧§13.2.3 中的內容,已解決的 Promise 與已實現的 Promise 不是同一回事。當我們呼叫Promise.resolve()時,通常會傳遞實現值以建立一個 Promise 物件,該物件將很快實現為該值。但是該方法的名稱不是Promise.fulfill()。如果將 Promisep1傳遞給Promise.resolve(),它將返回一個新的 Promisep2,該 Promise 立即解決,但直到p1實現或拒絕之前,它才會實現或拒絕。

可以編寫一個基於 Promise 的函式,其中值是同步計算的,並使用Promise.resolve()非同步返回,儘管這種情況可能不太常見。然而,在非同步函式中有同步特殊情況是相當常見的,你可以使用Promise.resolve()Promise.reject()來處理這些特殊情況。特別是,如果在開始非同步操作之前檢測到錯誤條件(例如錯誤的引數值),你可以透過返回使用Promise.reject()建立的 Promise 來報告該錯誤。(在這種情況下,你也可以同步丟擲錯誤,但這被認為是不好的做法,因為呼叫者需要同時編寫同步的catch子句和使用非同步的.catch()方法來處理錯誤。)最後,Promise.resolve()有時用於在 Promise 鏈中建立初始 Promise。我們將看到一些以這種方式使用它的示例。

從頭開始的 Promises

對於getJSON()getHighScore(),我們首先呼叫現有函式以獲取初始 Promise,並透過呼叫該初始 Promise 的.then()方法建立並返回一個新 Promise。但是,當你無法使用另一個返回 Promise 的函式作為起點時,如何編寫返回 Promise 的函式呢?在這種情況下,你可以使用Promise()建構函式建立一個全新的 Promise 物件,你可以完全控制它。操作如下:你呼叫Promise()建構函式並將一個函式作為其唯一引數傳遞。你傳遞的函式應該預期兩個引數,按照慣例,應該命名為resolvereject。建構函式會同步呼叫帶有resolvereject引數的函式。在呼叫你的函式後,Promise()建構函式會返回新建立的 Promise。返回的 Promise 受你傳遞給建構函式的函式控制。該函式應執行一些非同步操作,然後呼叫resolve函式以解析或實現返回的 Promise,或呼叫reject函式以拒絕返回的 Promise。你的函式不必是非同步的:如果這樣做,即使你同步呼叫resolvereject,Promise 仍將非同步解析、實現或拒絕。

透過閱讀關於將函式傳遞給建構函式的函式的功能可能很難理解,但希望一些示例能夠澄清這一點。以下是如何編寫基於 Promise 的wait()函式的方法,我們在本章的早期示例中使用過:

function wait(duration) {
    // Create and return a new Promise
    return new Promise((resolve, reject) => { // These control the Promise
        // If the argument is invalid, reject the Promise
        if (duration < 0) {
            reject(new Error("Time travel not yet implemented"));
        }
        // Otherwise, wait asynchronously and then resolve the Promise.
        // setTimeout will invoke resolve() with no arguments, which means
        // that the Promise will fulfill with the undefined value.
        setTimeout(resolve, duration);
    });
}

請注意,用於控制使用Promise()建構函式建立的 Promise 的命運的一對函式的名稱分別為resolve()reject(),而不是fulfill()reject()。如果將一個 Promise 傳遞給resolve(),則返回的 Promise 將解析為該新 Promise。然而,通常情況下,你會傳遞一個非 Promise 值,這將用該值實現返回的 Promise。

示例 13-1 是另一個使用Promise()建構函式的示例。這個示例實現了我們的getJSON()函式,用於在 Node 中使用,因為fetch()API 沒有內建。請記住,我們在本章一開始討論了非同步回撥和事件。這個示例同時使用了回撥和事件處理程式,因此很好地演示了我們如何在其他型別的非同步程式設計風格之上實現基於 Promise 的 API。

示例 13-1. 一個非同步的 getJSON() 函式
const http = require("http");

function getJSON(url) {
    // Create and return a new Promise
    return new Promise((resolve, reject) => {
        // Start an HTTP GET request for the specified URL
        request = http.get(url, response => { // called when response starts
            // Reject the Promise if the HTTP status is wrong
            if (response.statusCode !== 200) {
                reject(new Error(`HTTP status ${response.statusCode}`));
                response.resume();  // so we don't leak memory
            }
            // And reject if the response headers are wrong
            else if (response.headers["content-type"] !== "application/json") {
                reject(new Error("Invalid content-type"));
                response.resume();  // don't leak memory
            }
            else {
                // Otherwise, register events to read the body of the response
                let body = "";
                response.setEncoding("utf-8");
                response.on("data", chunk => { body += chunk; });
                response.on("end", () => {
                    // When the response body is complete, try to parse it
                    try {
                        let parsed = JSON.parse(body);
                        // If it parsed successfully, fulfill the Promise
                        resolve(parsed);
                    } catch(e) {
                        // If parsing failed, reject the Promise
                        reject(e);
                    }
                });
            }
        });
        // We also reject the Promise if the request fails before we
        // even get a response (such as when the network is down)
        request.on("error", error => {
            reject(error);
        });
    });
}

13.2.7 順序執行的 Promises

Promise.all() 讓並行執行任意數量的 Promises 變得容易。Promise 鏈使得表達一系列固定數量的 Promises 變得容易。然而,按順序執行任意數量的 Promises 就比較棘手了。例如,假設你有一個要獲取的 URL 陣列,但為了避免過載網路,你希望一次只獲取一個。如果陣列長度和內容未知,你無法提前編寫 Promise 鏈,因此需要動態構建一個,程式碼如下:

function fetchSequentially(urls) {
    // We'll store the URL bodies here as we fetch them
    const bodies = [];

    // Here's a Promise-returning function that fetches one body
    function fetchOne(url) {
        return fetch(url)
            .then(response => response.text())
            .then(body => {
                // We save the body to the array, and we're purposely
                // omitting a return value here (returning undefined)
                bodies.push(body);
            });
    }

    // Start with a Promise that will fulfill right away (with value undefined)
    let p = Promise.resolve(undefined);

    // Now loop through the desired URLs, building a Promise chain
    // of arbitrary length, fetching one URL at each stage of the chain
    for(url of urls) {
        p = p.then(() => fetchOne(url));
    }

    // When the last Promise in that chain is fulfilled, then the
    // bodies array is ready. So let's return a Promise for that
    // bodies array. Note that we don't include any error handlers:
    // we want to allow errors to propagate to the caller.
    return p.then(() => bodies);
}

有了定義的 fetchSequentially() 函式,我們可以一次獲取一個 URL,程式碼與我們之前用來演示 Promise.all() 的並行獲取程式碼類似:

fetchSequentially(urls)
    .then(bodies => { /* do something with the array of strings */ })
    .catch(e => console.error(e));

fetchSequentially() 函式首先建立一個 Promise,在返回後立即實現。然後,它基於該初始 Promise 構建一個長的線性 Promise 鏈,並返回鏈中的最後一個 Promise。這就像設定一排多米諾骨牌,然後推倒第一個。

我們可以採取另一種(可能更優雅)的方法。與其提前建立 Promises,我們可以讓每個 Promise 的回撥建立並返回下一個 Promise。也就是說,我們不是建立和連結一堆 Promises,而是建立解析為其他 Promises 的 Promises。我們不是建立一條多米諾般的 Promise 鏈,而是建立一個巢狀在另一個內部的 Promise 序列,就像一組套娃一樣。採用這種方法,我們的程式碼可以返回第一個(最外層)Promise,知道它最終會實現(或拒絕!)與序列中最後一個(最內層)Promise 相同的值。接下來的 promiseSequence() 函式編寫為通用的,不特定於 URL 獲取。它在我們討論 Promises 的最後,因為它很複雜。然而,如果你仔細閱讀了本章,希望你能理解它是如何工作的。特別要注意的是,promiseSequence() 中的巢狀函式似乎遞迴呼叫自身,但因為“遞迴”呼叫是透過 then() 方法進行的,實際上並沒有傳統的遞迴發生:

// This function takes an array of input values and a "promiseMaker" function.
// For any input value x in the array, promiseMaker(x) should return a Promise
// that will fulfill to an output value. This function returns a Promise
// that fulfills to an array of the computed output values.
//
// Rather than creating the Promises all at once and letting them run in
// parallel, however, promiseSequence() only runs one Promise at a time
// and does not call promiseMaker() for a value until the previous Promise
// has fulfilled.
function promiseSequence(inputs, promiseMaker) {
    // Make a private copy of the array that we can modify
    inputs = [...inputs];

    // Here's the function that we'll use as a Promise callback
    // This is the pseudorecursive magic that makes this all work.
    function handleNextInput(outputs) {
        if (inputs.length === 0) {
            // If there are no more inputs left, then return the array
            // of outputs, finally fulfilling this Promise and all the
            // previous resolved-but-not-fulfilled Promises.
            return outputs;
        } else {
            // If there are still input values to process, then we'll
            // return a Promise object, resolving the current Promise
            // with the future value from a new Promise.
            let nextInput = inputs.shift(); // Get the next input value,
            return promiseMaker(nextInput)  // compute the next output value,
                // Then create a new outputs array with the new output value
                .then(output => outputs.concat(output))
                // Then "recurse", passing the new, longer, outputs array
                .then(handleNextInput);
        }
    }

    // Start with a Promise that fulfills to an empty array and use
    // the function above as its callback.
    return Promise.resolve([]).then(handleNextInput);
}

這個 promiseSequence() 函式是故意通用的。我們可以用它來獲取 URL,程式碼如下:

// Given a URL, return a Promise that fulfills to the URL body text
function fetchBody(url) { return fetch(url).then(r => r.text()); }
// Use it to sequentially fetch a bunch of URL bodies
promiseSequence(urls, fetchBody)
    .then(bodies => { /* do something with the array of strings */ })
    .catch(console.error);

13.3 async 和 await

ES2017 引入了兩個新關鍵字——asyncawait——代表了 JavaScript 非同步程式設計的正規化轉變。這些新關鍵字極大地簡化了 Promises 的使用,並允許我們編寫基於 Promise 的非同步程式碼,看起來像阻塞的同步程式碼,等待網路響應或其他非同步事件。雖然理解 Promises 如何工作仍然很重要,但當與 asyncawait 一起使用時,它們的複雜性(有時甚至是它們的存在本身!)會消失。

正如我們在本章前面討論的那樣,非同步程式碼無法像常規同步程式碼那樣返回值或丟擲異常。這就是 Promises 設計的原因。已實現的 Promise 的值就像同步函式的返回值一樣。而拒絕的 Promise 的值就像同步函式丟擲的值一樣。後者的相似性透過 .catch() 方法的命名得到明確體現。asyncawait 採用高效的基於 Promise 的程式碼,並隱藏了 Promises,使得你的非同步程式碼可以像低效、阻塞的同步程式碼一樣易於閱讀和推理。

13.3.1 await 表示式

await關鍵字接受一個 Promise 並將其轉換為返回值或丟擲異常。給定一個 Promise 物件p,表示式await p會等待直到p完成。如果p成功,那麼await p的值就是p的成功值。另一方面,如果p被拒絕,那麼await p表示式會丟擲p的拒絕值。我們通常不會使用一個儲存 Promise 的變數來使用await;相反,我們會在返回 Promise 的函式呼叫之前使用它:

let response = await fetch("/api/user/profile");
let profile = await response.json();

立即理解await關鍵字不會導致程式阻塞並直到指定的 Promise 完成。程式碼仍然是非同步的,await只是掩飾了這一事實。這意味著任何使用await的程式碼本身都是非同步的

13.3.2 async 函式

因為任何使用await的程式碼都是非同步的,有一個關鍵規則:只能在使用async關鍵字宣告的函式內部使用await關鍵字。以下是本章前面提到的getHighScore()函式的一個使用asyncawait重寫的版本:

async function getHighScore() {
    let response = await fetch("/api/user/profile");
    let profile = await response.json();
    return profile.highScore;
}

宣告一個函式為async意味著函式的返回值將是一個 Promise,即使函式體中沒有任何與 Promise 相關的程式碼。如果一個async函式看起來正常返回,那麼作為真正返回值的 Promise 物件將解析為該表面返回值。如果一個async函式看起來丟擲異常,那麼它返回的 Promise 物件將被拒絕並帶有該異常。

getHighScore()函式被宣告為async,因此它返回一個 Promise。由於它返回一個 Promise,我們可以使用await關鍵字:

displayHighScore(await getHighScore());

但請記住,那行程式碼只有在另一個async函式內部才能起作用!你可以無限巢狀await表示式在async函式內部。但如果你在頂層²或者由於某種原因在一個非async函式內部,那麼你就不能使用await,而必須以常規方式處理返回的 Promise:

getHighScore().then(displayHighScore).catch(console.error);

你可以在任何型別的函式中使用async關鍵字。它可以與function關鍵字一起作為語句或表示式使用。它可以與箭頭函式一起使用,也可以與類和物件字面量中的方法快捷形式一起使用。(有關編寫函式的各種方式,請參見第八章。)

13.3.3 等待多個 Promises

假設我們使用async編寫了我們的getJSON()函式:

async function getJSON(url) {
    let response = await fetch(url);
    let body = await response.json();
    return body;
}

現在假設我們想要使用這個函式獲取兩個 JSON 值:

let value1 = await getJSON(url1);
let value2 = await getJSON(url2);

這段程式碼的問題在於它是不必要的順序執行:第二個 URL 的獲取將等到第一個 URL 的獲取完成後才開始。如果第二個 URL 不依賴於從第一個 URL 獲取的值,那麼我們可能應該嘗試同時獲取這兩個值。這是async函式的基於 Promise 的特性的一個案例。為了等待一組併發執行的async函式,我們使用Promise.all(),就像直接使用 Promises 一樣:

let [value1, value2] = await Promise.all([getJSON(url1), getJSON(url2)]);

13.3.4 實現細節

最後,為了理解async函式的工作原理,可能有助於思考底層發生了什麼。

假設你寫了一個這樣的async函式:

async function f(x) { /* body */ }

你可以將這看作是一個包裝在原始函式體周圍的返回 Promise 的函式:

function f(x) {
    return new Promise(function(resolve, reject) {
        try {
            resolve((function(x) { /* body */ })(x));
        }
        catch(e) {
            reject(e);
        }
    });
}

用這種方式來表達await關鍵字比較困難。但可以將await關鍵字看作是一個標記,將函式體分解為單獨的同步塊。ES2017 直譯器可以將函式體分解為一系列單獨的子函式,每個子函式都會傳遞給前面的await標記的 Promise 的then()方法。

13.4 非同步迭代

我們從回撥和基於事件的非同步性討論開始了本章,當我們介紹 Promise 時,我們注意到它們對於單次非同步計算很有用,但不適用於重複非同步事件的源,比如setInterval()、Web 瀏覽器中的“click”事件或 Node 流上的“data”事件。因為單個 Promise 不能用於序列的非同步事件,所以我們也不能使用常規的async函式和await語句來處理這些事情。

然而,ES2018 提供了一個解決方案。非同步迭代器類似於第十二章中描述的迭代器,但它們是基於 Promise 的,並且旨在與一種新形式的for/of迴圈一起使用:for/await

13.4.1 for/await迴圈

Node 12 使其可讀流非同步可迭代。這意味著您可以使用像這樣的for/await迴圈從流中讀取連續的資料塊:

const fs = require("fs");

async function parseFile(filename) {
    let stream = fs.createReadStream(filename, { encoding: "utf-8"});
    for await (let chunk of stream) {
        parseChunk(chunk); // Assume parseChunk() is defined elsewhere
    }
}

像常規的await表示式一樣,for/await迴圈是基於 Promise 的。粗略地說,非同步迭代器生成一個 Promise,for/await迴圈等待該 Promise 實現,將實現值分配給迴圈變數,並執行迴圈體。然後它重新開始,從迭代器獲取另一個 Promise 並等待該新 Promise 實現。

假設您有一個 URL 陣列:

const urls = [url1, url2, url3];

您可以對每個 URL 呼叫fetch()以獲取 Promise 陣列:

const promises = urls.map(url => fetch(url));

我們在本章的前面看到,現在我們可以使用Promise.all()等待陣列中所有 Promise 被實現。但假設我們希望在第一個 fetch 的結果變為可用時獲取結果,並且不想等待所有 URL 被獲取。 (當然,第一個 fetch 可能比其他任何 fetch 都要花費更長的時間,因此這不一定比使用Promise.all()更快。)陣列是可迭代的,因此我們可以使用常規的for/of迴圈遍歷 Promise 陣列:

for(const promise of promises) {
    response = await promise;
    handle(response);
}

這個示例程式碼使用了一個常規的for/of迴圈和一個常規的迭代器。但由於這個迭代器返回的是 Promise,我們也可以使用新的for/await來編寫稍微更簡單的程式碼:

for await (const response of promises) {
    handle(response);
}

在這種情況下,for/await迴圈只是將await呼叫嵌入到迴圈中,使我們的程式碼稍微更加緊湊,但這兩個例子實際上做的事情是完全一樣的。重要的是,這兩個例子只有在宣告為async的函式內部才能工作;for/await迴圈在這方面與常規的await表示式沒有區別。

然而,重要的是要意識到,在這個例子中我們使用for/await與一個常規迭代器。使用完全非同步迭代器會更有趣。

13.4.2 非同步迭代器

讓我們回顧一下第十二章中的一些術語。可迭代物件是可以與for/of迴圈一起使用的物件。它定義了一個符號名稱為Symbol.iterator的方法。該方法返回一個迭代器物件。迭代器物件具有一個next()方法,可以重複呼叫以獲取可迭代物件的值。迭代器物件的next()方法返回迭代結果物件。迭代結果物件具有一個value屬性和/或一個done屬性。

非同步迭代器與常規迭代器非常相似,但有兩個重要的區別。首先,非同步可迭代物件實現了一個符號名稱為Symbol.asyncIterator的方法,而不是Symbol.iterator。 (正如我們之前看到的,for/await與常規可迭代物件相容,但它更喜歡非同步可迭代物件,並在嘗試Symbol.iterator方法之前嘗試Symbol.asyncIterator方法。)其次,非同步迭代器的next()方法返回一個解析為迭代器結果物件的 Promise,而不是直接返回迭代器結果物件。

注意

在前一節中,當我們在常規的同步可迭代的 Promise 陣列上使用for/await時,我們正在處理同步迭代器結果物件,其中value屬性是一個 Promise 物件,但done屬性是同步的。真正的非同步迭代器會返回 Promise 以進行迭代結果物件,並且valuedone屬性都是非同步的。區別是微妙的:使用非同步迭代器時,關於何時結束迭代的選擇可以非同步進行。

13.4.3 非同步生成器

正如我們在第十二章中看到的,實現迭代器的最簡單方法通常是使用生成器。對於非同步迭代器也是如此,我們可以使用宣告為async的生成器函式來實現。非同步生成器具有非同步函式和生成器的特性:你可以像在常規非同步函式中一樣使用await,也可以像在常規生成器中一樣使用yield。但你yield的值會自動包裝在 Promise 中。甚至非同步生成器的語法也是一個組合:async functionfunction *組合成async function *。下面是一個示例,展示瞭如何使用非同步生成器和for/await迴圈以迴圈語法而不是setInterval()回撥函式重複在固定間隔執行程式碼:

// A Promise-based wrapper around setTimeout() that we can use await with.
// Returns a Promise that fulfills in the specified number of milliseconds
function elapsedTime(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// An async generator function that increments a counter and yields it
// a specified (or infinite) number of times at a specified interval.
async function* clock(interval, max=Infinity) {
    for(let count = 1; count <= max; count++) { // regular for loop
        await elapsedTime(interval);            // wait for time to pass
        yield count;                            // yield the counter
    }
}

// A test function that uses the async generator with for/await
async function test() {                       // Async so we can use for/await
    for await (let tick of clock(300, 100)) { // Loop 100 times every 300ms
        console.log(tick);
    }
}

13.4.4 實現非同步迭代器

除了使用非同步生成器來實現非同步迭代器外,還可以透過定義一個具有返回一個返回解析為迭代器結果物件的 Promise 的next()方法的物件的Symbol.asyncIterator()方法來直接實現它們。在下面的程式碼中,我們重新實現了前面示例中的clock()函式,使其不是一個生成器,而是隻返回一個非同步可迭代物件。請注意,在這個示例中,next()方法並沒有顯式返回一個 Promise;相反,我們只是宣告next()為 async:

function clock(interval, max=Infinity) {
    // A Promise-ified version of setTimeout that we can use await with.
    // Note that this takes an absolute time instead of an interval.
    function until(time) {
        return new Promise(resolve => setTimeout(resolve, time - Date.now()));
    }

    // Return an asynchronously iterable object
    return {
        startTime: Date.now(),  // Remember when we started
        count: 1,               // Remember which iteration we're on
        async next() {          // The next() method makes this an iterator
            if (this.count > max) {     // Are we done?
                return { done: true };  // Iteration result indicating done
            }
            // Figure out when the next iteration should begin,
            let targetTime = this.startTime + this.count * interval;
            // wait until that time,
            await until(targetTime);
            // and return the count value in an iteration result object.
            return { value: this.count++ };
        },
        // This method means that this iterator object is also an iterable.
        [Symbol.asyncIterator]() { return this; }
    };
}

這個基於迭代器的clock()函式版本修復了基於生成器的版本中的一個缺陷。請注意,在這個更新的程式碼中,我們針對每次迭代應該開始的絕對時間,並從中減去當前時間以計算傳遞給setTimeout()的間隔。如果我們在for/await迴圈中使用clock(),這個版本將更精確地按照指定的間隔執行迴圈迭代,因為它考慮了實際執行迴圈體所需的時間。但這個修復不僅僅是關於時間精度。for/await迴圈總是在開始下一次迭代之前等待一個迭代返回的 Promise 被實現。但如果你在沒有for/await迴圈的情況下使用非同步迭代器,就沒有任何阻止你在想要的任何時候呼叫next()方法。使用基於生成器的clock()版本,如果連續呼叫next()方法三次,你將得到三個幾乎在同一時間實現的 Promise,這可能不是你想要的。我們在這裡實現的基於迭代器的版本沒有這個問題。

非同步迭代器的好處在於它們允許我們表示非同步事件或資料流。之前討論的clock()函式相對簡單,因為非同步性的源是我們自己進行的setTimeout()呼叫。但是當我們嘗試處理其他非同步源時,比如觸發事件處理程式,實現非同步迭代器就變得相當困難——通常我們有一個響應事件的單個事件處理程式函式,但是迭代器的每次呼叫next()方法必須返回一個不同的 Promise 物件,並且在第一個 Promise 解析之前可能會多次呼叫next()。這意味著任何非同步迭代器方法必須能夠維護一個內部 Promise 佇列,以便按順序解析非同步事件。如果我們將這種 Promise 佇列行為封裝到一個 AsyncQueue 類中,那麼基於 AsyncQueue 編寫非同步迭代器就會變得更容易。³

接下來的 AsyncQueue 類具有enqueue()dequeue()方法,就像你期望的佇列類一樣。然而,dequeue()方法返回一個 Promise 而不是實際值,這意味著在呼叫enqueue()之前呼叫dequeue()是可以的。AsyncQueue 類也是一個非同步迭代器,並且旨在與一個for/await迴圈一起使用,其主體在每次非同步排隊新值時執行一次。 (AsyncQueue 有一個close()方法。一旦呼叫,就不能再排隊更多的值。當一個關閉的佇列為空時,for/await迴圈將停止迴圈。)

請注意,AsyncQueue 的實現不使用asyncawait,而是直接使用 Promises。這段程式碼有些複雜,你可以用它來測試你對我們在這一長章節中涵蓋的內容的理解。即使你不完全理解 AsyncQueue 的實現,也請看一下後面的簡短示例:它在 AsyncQueue 的基礎上實現了一個簡單但非常有趣的非同步迭代器。

/**
 * An asynchronously iterable queue class. Add values with enqueue()
 * and remove them with dequeue(). dequeue() returns a Promise, which
 * means that values can be dequeued before they are enqueued. The
 * class implements [Symbol.asyncIterator] and next() so that it can
 * be used with the for/await loop (which will not terminate until
 * the close() method is called.)
 */
class AsyncQueue {
    constructor() {
        // Values that have been queued but not dequeued yet are stored here
        this.values = [];
        // When Promises are dequeued before their corresponding values are
        // queued, the resolve methods for those Promises are stored here.
        this.resolvers = [];
        // Once closed, no more values can be enqueued, and no more unfulfilled
        // Promises returned.
        this.closed = false;
    }

    enqueue(value) {
        if (this.closed) {
            throw new Error("AsyncQueue closed");
        }
        if (this.resolvers.length > 0) {
            // If this value has already been promised, resolve that Promise
            const resolve = this.resolvers.shift();
            resolve(value);
        }
        else {
            // Otherwise, queue it up
            this.values.push(value);
        }
    }

    dequeue() {
        if (this.values.length > 0) {
            // If there is a queued value, return a resolved Promise for it
            const value = this.values.shift();
            return Promise.resolve(value);
        }
        else if (this.closed) {
            // If no queued values and we're closed, return a resolved
            // Promise for the "end-of-stream" marker
            return Promise.resolve(AsyncQueue.EOS);
        }
        else {
            // Otherwise, return an unresolved Promise,
            // queuing the resolver function for later use
            return new Promise((resolve) => { this.resolvers.push(resolve); });
        }
    }

    close() {
        // Once the queue is closed, no more values will be enqueued.
        // So resolve any pending Promises with the end-of-stream marker
        while(this.resolvers.length > 0) {
            this.resolvers.shift()(AsyncQueue.EOS);
        }
        this.closed = true;
    }

    // Define the method that makes this class asynchronously iterable
    [Symbol.asyncIterator]() { return this; }

    // Define the method that makes this an asynchronous iterator. The
    // dequeue() Promise resolves to a value or the EOS sentinel if we're
    // closed. Here, we need to return a Promise that resolves to an
    // iterator result object.
    next() {
        return this.dequeue().then(value => (value === AsyncQueue.EOS)
                                   ? { value: undefined, done: true }
                                   : { value: value, done: false });
    }
}

// A sentinel value returned by dequeue() to mark "end of stream" when closed
AsyncQueue.EOS = Symbol("end-of-stream");

因為這個 AsyncQueue 類定義了非同步迭代的基礎,我們可以透過非同步排隊值來建立自己的更有趣的非同步迭代器。下面是一個示例,它使用 AsyncQueue 生成一個可以用for/await迴圈處理的 web 瀏覽器事件流:

// Push events of the specified type on the specified document element
// onto an AsyncQueue object, and return the queue for use as an event stream
function eventStream(elt, type) {
    const q = new AsyncQueue();                  // Create a queue
    elt.addEventListener(type, e=>q.enqueue(e)); // Enqueue events
    return q;
}

async function handleKeys() {
    // Get a stream of keypress events and loop once for each one
    for await (const event of eventStream(document, "keypress")) {
        console.log(event.key);
    }
}

13.5 總結

在本章中,你已經學到了:

  • 大多數真實世界的 JavaScript 程式設計是非同步的。

  • 傳統上,非同步性是透過事件和回撥函式來處理的。然而,這可能會變得複雜,因為你可能會得到多層巢狀在其他回撥內部的回撥,並且很難進行健壯的錯誤處理。

  • Promises 提供了一種新的組織回撥函式的方式。如果使用正確(不幸的是,Promises 很容易被錯誤使用),它們可以將原本巢狀的非同步程式碼轉換為then()呼叫的線性鏈,其中一個計算的非同步步驟跟隨另一個。此外,Promises 允許你將錯誤處理程式碼集中到一條catch()呼叫中,放在then()呼叫鏈的末尾。

  • asyncawait關鍵字允許我們編寫基於 Promise 的非同步程式碼,但看起來像同步程式碼。這使得程式碼更容易理解和推理。如果一個函式宣告為async,它將隱式返回一個 Promise。在async函式內部,你可以像同步計算 Promise 值一樣await一個 Promise(或返回 Promise 的函式)。

  • 可以使用for/await迴圈處理非同步可迭代物件。你可以透過實現[Symbol.asyncIterator]()方法或呼叫async function *生成器函式來建立非同步可迭代物件。非同步迭代器提供了一種替代 Node 中“data”事件的方式,並可用於表示客戶端 JavaScript 中使用者輸入事件的流。

¹ XMLHttpRequest 類與 XML 無關。在現代客戶端 JavaScript 中,它大部分被fetch() API 取代,該 API 在§15.11.1 中有介紹。這裡展示的程式碼示例是本書中僅剩的基於 XMLHttpRequest 的示例。

² 通常可以在瀏覽器的開發者控制檯中的頂層使用await。而且有一個未決提案,允許在未來版本的 JavaScript 中使用頂層await

³ 我從https://2ality.com部落格中瞭解到了這種非同步迭代的方法,作者是 Axel Rauschmayer 博士。

第十四章:超程式設計

本章介紹了一些高階 JavaScript 功能,這些功能在日常程式設計中並不常用,但對於編寫可重用庫的程式設計師可能很有價值,並且對於任何想要深入瞭解 JavaScript 物件行為細節的人也很有趣。

這裡描述的許多功能可以寬泛地描述為“超程式設計”:如果常規程式設計是編寫程式碼來運算元據,那麼超程式設計就是編寫程式碼來操作其他程式碼。在像 JavaScript 這樣的動態語言中,程式設計和超程式設計之間的界限模糊——甚至簡單地使用for/in迴圈迭代物件的屬性的能力對更習慣於更靜態語言的程式設計師來說可能被認為是“超程式設計”。

本章涵蓋的超程式設計主題包括:

  • §14.1 控制物件屬性的可列舉性、可刪除性和可配置性

  • §14.2 控制物件的可擴充套件性,並建立“封閉”和“凍結”物件

  • §14.3 查詢和設定物件的原型

  • §14.4 使用眾所周知的符號微調型別的行為

  • §14.5 使用模板標籤函式建立 DSL(領域特定語言)

  • §14.6 使用reflect方法探查物件

  • §14.7 使用代理控制物件行為

14.1 屬性特性

JavaScript 物件的屬性當然有名稱和值,但每個屬性還有三個關聯屬性,指定該屬性的行為方式以及您可以對其執行的操作:

  • 可寫 屬性指定屬性的值是否可以更改。

  • 可列舉 屬性指定屬性是否由for/in迴圈和Object.keys()方法列舉。

  • 可配置 屬性指定屬性是否可以被刪除,以及屬性的屬性是否可以更改。

在物件字面量中定義的屬性或透過普通賦值給物件的屬性是可寫的、可列舉的和可配置的。但是,JavaScript 標準庫中定義的許多屬性並非如此。

本節解釋了查詢和設定屬性特性的 API。這個 API 對於庫作者尤為重要,因為:

  • 它允許他們向原型物件新增方法並使它們不可列舉,就像內建方法一樣。

  • 它允許它們“鎖定”它們的物件,定義不能被更改或刪除的屬性。

請回顧§6.10.6,在那裡提到,“資料屬性”具有值,“訪問器屬性”則具有 getter 和/或 setter 方法。對於本節的目的,我們將考慮訪問器屬性的 getter 和 setter 方法為屬性特性。按照這種邏輯,我們甚至會說資料屬性的值也是一個屬性。因此,我們可以說屬性有一個名稱和四個屬性。資料屬性的四個屬性是可寫可列舉可配置。訪問器屬性沒有屬性或可寫屬性:它們的可寫性取決於是否存在 setter。因此,訪問器屬性的四個屬性是獲取設定可列舉可配置

JavaScript 用於查詢和設定屬性的方法使用一個稱為屬性描述符的物件來表示四個屬性的集合。屬性描述符物件具有與其描述的屬性相同名稱的屬性。因此,資料屬性的屬性描述符物件具有名為valuewritableenumerableconfigurable的屬性。訪問器屬性的描述符具有getset屬性,而不是valuewritablewritableenumerableconfigurable屬性是布林值,getset屬性是函式值。

要獲取指定物件的命名屬性的屬性描述符,請呼叫Object.getOwnPropertyDescriptor()

// Returns {value: 1, writable:true, enumerable:true, configurable:true}
Object.getOwnPropertyDescriptor({x: 1}, "x");

// Here is an object with a read-only accessor property
const random = {
    get octet() { return Math.floor(Math.random()*256); },
};

// Returns { get: /*func*/, set:undefined, enumerable:true, configurable:true}
Object.getOwnPropertyDescriptor(random, "octet");

// Returns undefined for inherited properties and properties that don't exist.
Object.getOwnPropertyDescriptor({}, "x")        // => undefined; no such prop
Object.getOwnPropertyDescriptor({}, "toString") // => undefined; inherited

如其名稱所示,Object.getOwnPropertyDescriptor()僅適用於自有屬性。要查詢繼承屬性的屬性,必須顯式遍歷原型鏈。(參見§14.3 中的Object.getPrototypeOf());另請參閱§14.6 中的類似Reflect.getOwnPropertyDescriptor()函式。

要設定屬性的屬性或使用指定屬性建立新屬性,請呼叫Object.defineProperty(),傳遞要修改的物件、要建立或更改的屬性的名稱和屬性描述符物件:

let o = {};  // Start with no properties at all
// Add a non-enumerable data property x with value 1.
Object.defineProperty(o, "x", {
    value: 1,
    writable: true,
    enumerable: false,
    configurable: true
});

// Check that the property is there but is non-enumerable
o.x            // => 1
Object.keys(o) // => []

// Now modify the property x so that it is read-only
Object.defineProperty(o, "x", { writable: false });

// Try to change the value of the property
o.x = 2;      // Fails silently or throws TypeError in strict mode
o.x           // => 1

// The property is still configurable, so we can change its value like this:
Object.defineProperty(o, "x", { value: 2 });
o.x           // => 2

// Now change x from a data property to an accessor property
Object.defineProperty(o, "x", { get: function() { return 0; } });
o.x           // => 0

你傳遞給Object.defineProperty()的屬性描述符不必包含所有四個屬性。如果你正在建立一個新屬性,那麼被省略的屬性被視為falseundefined。如果你正在修改一個現有屬性,那麼你省略的屬性將保持不變。請注意,此方法會更改現有的自有屬性或建立新的自有屬性,但不會更改繼承的屬性。另請參閱§14.6 中的非常相似的Reflect.defineProperty()函式。

如果要一次建立或修改多個屬性,請使用Object.defineProperties()。第一個引數是要修改的物件。第二個引數是將要建立或修改的屬性的名稱對映到這些屬性的屬性描述符的物件。例如:

let p = Object.defineProperties({}, {
    x: { value: 1, writable: true, enumerable: true, configurable: true },
    y: { value: 1, writable: true, enumerable: true, configurable: true },
    r: {
        get() { return Math.sqrt(this.x*this.x + this.y*this.y); },
        enumerable: true,
        configurable: true
    }
});
p.r  // => Math.SQRT2

這段程式碼從一個空物件開始,然後向其新增兩個資料屬性和一個只讀訪問器屬性。它依賴於Object.defineProperties()返回修改後的物件(Object.defineProperty()也是如此)。

Object.create() 方法是在§6.2 中引入的。我們在那裡學到,該方法的第一個引數是新建立物件的原型物件。該方法還接受第二個可選引數,與Object.defineProperties()的第二個引數相同。如果你向Object.create()傳遞一組屬性描述符,那麼它們將用於向新建立的物件新增屬性。

如果嘗試建立或修改屬性不被允許,Object.defineProperty()Object.defineProperties()會丟擲 TypeError。如果你嘗試向不可擴充套件的物件新增新屬性,就會發生這種情況(參見§14.2)。這些方法可能丟擲 TypeError 的其他原因與屬性本身有關。可寫屬性控制對屬性的更改嘗試。可配置屬性控制對其他屬性的更改嘗試(並指定屬性是否可以被刪除)。然而,規則並不完全直觀。例如,如果屬性是可配置的,那麼即使該屬性是不可寫的,也可以更改該屬性的值。此外,即使屬性是不可配置的,也可以將屬性從可寫更改為不可寫。以下是完整的規則。呼叫Object.defineProperty()Object.defineProperties()嘗試違反這些規則會丟擲 TypeError:

  • 如果一個物件不可擴充套件,你可以編輯其現有的自有屬性,但不能向其新增新屬性。

  • 如果一個屬性不可配置,你就無法改變它的可配置或可列舉屬性。

  • 如果一個訪問器屬性不可配置,你就無法更改其 getter 或 setter 方法,也無法將其更改為資料屬性。

  • 如果一個資料屬性不可配置,你就無法將其更改為訪問器屬性。

  • 如果一個資料屬性不可配置,你就無法將其可寫屬性從false更改為true,但你可以將其從true更改為false

  • 如果一個資料屬性不可配置且不可寫,你就無法改變它的值。但是,如果一個屬性是可配置但不可寫的,你可以改變它的值(因為這與使其可寫,然後改變值,然後將其轉換回不可寫是一樣的)。

§6.7 描述了Object.assign()函式,它將一個或多個源物件的屬性值複製到目標物件中。Object.assign()只複製可列舉屬性和屬性值,而不是屬性屬性。這通常是我們想要的,但這意味著,例如,如果一個源物件具有一個訪問器屬性,那麼複製到目標物件的是 getter 函式返回的值,而不是 getter 函式本身。示例 14-1 演示瞭如何使用Object.getOwnPropertyDescriptor()Object.defineProperty()建立Object.assign()的變體,該變體複製整個屬性描述符而不僅僅是複製屬性值。

示例 14-1. 從一個物件複製屬性及其屬性到另一個物件
/*
 * Define a new Object.assignDescriptors() function that works like
 * Object.assign() except that it copies property descriptors from
 * source objects into the target object instead of just copying
 * property values. This function copies all own properties, both
 * enumerable and non-enumerable. And because it copies descriptors,
 * it copies getter functions from source objects and overwrites setter
 * functions in the target object rather than invoking those getters and
 * setters.
 *
 * Object.assignDescriptors() propagates any TypeErrors thrown by
 * Object.defineProperty(). This can occur if the target object is sealed
 * or frozen or if any of the source properties try to change an existing
 * non-configurable property on the target object.
 *
 * Note that the assignDescriptors property is added to Object with
 * Object.defineProperty() so that the new function can be created as
 * a non-enumerable property like Object.assign().
 */
Object.defineProperty(Object, "assignDescriptors", {
    // Match the attributes of Object.assign()
    writable: true,
    enumerable: false,
    configurable: true,
    // The function that is the value of the assignDescriptors property.
    value: function(target, ...sources) {
        for(let source of sources) {
            for(let name of Object.getOwnPropertyNames(source)) {
                let desc = Object.getOwnPropertyDescriptor(source, name);
                Object.defineProperty(target, name, desc);
            }

            for(let symbol of Object.getOwnPropertySymbols(source)) {
                let desc = Object.getOwnPropertyDescriptor(source, symbol);
                Object.defineProperty(target, symbol, desc);
            }
        }
        return target;
    }
});

let o = {c: 1, get count() {return this.c++;}}; // Define object with getter
let p = Object.assign({}, o);                   // Copy the property values
let q = Object.assignDescriptors({}, o);        // Copy the property descriptors
p.count   // => 1: This is now just a data property so
p.count   // => 1: ...the counter does not increment.
q.count   // => 2: Incremented once when we copied it the first time,
q.count   // => 3: ...but we copied the getter method so it increments.

14.2 物件的可擴充套件性

物件的可擴充套件屬性指定了是否可以向物件新增新屬性。普通的 JavaScript 物件預設是可擴充套件的,但你可以透過本節描述的函式來改變這一點。

要確定一個物件是否可擴充套件,請將其傳遞給Object.isExtensible()。要使物件不可擴充套件,請將其傳遞給Object.preventExtensions()。一旦這樣做,任何嘗試向物件新增新屬性的操作在嚴格模式下都會丟擲 TypeError,在非嚴格模式下會靜默失敗而不會報錯。此外,嘗試更改不可擴充套件物件的原型(參見§14.3)將始終丟擲 TypeError。

請注意,一旦將物件設定為不可擴充套件,就沒有辦法再使其可擴充套件。另外,請注意,呼叫Object.preventExtensions()隻影響物件本身的可擴充套件性。如果向不可擴充套件物件的原型新增新屬性,那麼不可擴充套件物件將繼承這些新屬性。

有兩個類似的函式,Reflect.isExtensible()Reflect.preventExtensions(),在§14.6 中描述。

可擴充套件屬性的目的是能夠將物件“鎖定”到已知狀態,並防止外部篡改。物件的可擴充套件屬性通常與屬性的可配置可寫屬性一起使用,JavaScript 定義了使設定這些屬性變得容易的函式:

  • Object.seal()的作用類似於Object.preventExtensions(),但除了使物件不可擴充套件外,它還使該物件的所有自有屬性不可配置。這意味著無法向物件新增新屬性,也無法刪除或配置現有屬性。但是,可寫的現有屬性仍然可以設定。無法取消密封的物件。你可以使用Object.isSealed()來確定物件是否被密封。

  • Object.freeze() 更加嚴格地鎖定物件。除了使物件不可擴充套件和其屬性不可配置外,它還使物件的所有自有資料屬性變為只讀。(如果物件具有具有 setter 方法的訪問器屬性,則這些屬性不受影響,仍然可以透過對屬性賦值來呼叫。)使用 Object.isFrozen() 來確定物件是否被凍結。

需要理解的是 Object.seal()Object.freeze() 只會影響它們所傳遞的物件:它們不會影響該物件的原型。如果你想完全鎖定一個物件,可能需要同時封閉或凍結原型鏈中的物件。

Object.preventExtensions(), Object.seal(), 和 Object.freeze() 都會返回它們所傳遞的物件,這意味著你可以在巢狀函式呼叫中使用它們:

// Create a sealed object with a frozen prototype and a non-enumerable property
let o = Object.seal(Object.create(Object.freeze({x: 1}),
                                  {y: {value: 2, writable: true}}));

如果你正在編寫一個 JavaScript 庫,將物件傳遞給庫使用者編寫的回撥函式,你可能會在這些物件上使用 Object.freeze() 來防止使用者的程式碼修改它們。這樣做很容易和方便,但也存在一些權衡:凍結的物件可能會干擾常見的 JavaScript 測試策略,例如。

14.3 原型屬性

一個物件的 prototype 屬性指定了它繼承屬性的物件。(檢視 §6.2.3 和 §6.3.2 瞭解更多關於原型和屬性繼承的內容。)這是一個非常重要的屬性,我們通常簡單地說“o 的原型”而不是“oprototype 屬性”。還要記住,當 prototype 出現在程式碼字型中時,它指的是一個普通物件屬性,而不是 prototype 屬性:第九章 解釋了建構函式的 prototype 屬性指定了使用該建構函式建立的物件的 prototype 屬性。

prototype 屬性在物件建立時設定。透過物件字面量建立的物件使用 Object.prototype 作為它們的原型。透過 new 建立的物件使用它們建構函式的 prototype 屬性的值作為它們的原型。透過 Object.create() 建立的物件使用該函式的第一個引數(可能為 null)作為它們的原型。

你可以透過將物件傳遞給 Object.getPrototypeOf() 來查詢任何物件的原型:

Object.getPrototypeOf({})      // => Object.prototype
Object.getPrototypeOf([])      // => Array.prototype
Object.getPrototypeOf(()=>{})  // => Function.prototype

一個非常相似的函式 Reflect.getPrototypeOf() 在 §14.6 中描述。

要確定一個物件是否是另一個物件的原型(或是原型鏈的一部分),使用 isPrototypeOf() 方法:

let p = {x: 1};                   // Define a prototype object.
let o = Object.create(p);         // Create an object with that prototype.
p.isPrototypeOf(o)                // => true: o inherits from p
Object.prototype.isPrototypeOf(p) // => true: p inherits from Object.prototype
Object.prototype.isPrototypeOf(o) // => true: o does too

注意,isPrototypeOf() 執行類似於 instanceof 運算子的功能(參見 §4.9.4)。

物件的 prototype 屬性在物件建立時設定並通常保持不變。但是,你可以使用 Object.setPrototypeOf() 改變物件的原型:

let o = {x: 1};
let p = {y: 2};
Object.setPrototypeOf(o, p); // Set the prototype of o to p
o.y      // => 2: o now inherits the property y
let a = [1, 2, 3];
Object.setPrototypeOf(a, p); // Set the prototype of array a to p
a.join   // => undefined: a no longer has a join() method

通常不需要使用 Object.setPrototypeOf()。JavaScript 實現可能會基於物件原型是固定且不變的假設進行激進的最佳化。這意味著如果你呼叫 Object.setPrototypeOf(),使用修改後的物件的任何程式碼可能比通常執行得慢得多。

一個類似的函式 Reflect.setPrototypeOf() 在 §14.6 中描述。

一些早期的瀏覽器實現暴露了物件的prototype屬性,透過__proto__屬性(以兩個下劃線開頭和結尾)。儘管這種做法早已被棄用,但網路上存在大量依賴__proto__的現有程式碼,因此 ECMAScript 標準要求所有在 Web 瀏覽器中執行的 JavaScript 實現都必須支援它(Node 也支援,儘管標準不要求 Node 支援)。在現代 JavaScript 中,__proto__是可讀寫的,你可以(儘管不應該)將其用作Object.getPrototypeOf()Object.setPrototypeOf()的替代方法。然而,__proto__的一個有趣用途是定義物件字面量的原型:

let p = {z: 3};
let o = {
    x: 1,
    y: 2,
    __proto__: p
};
o.z  // => 3: o inherits from p

14.4 眾所周知的符號

Symbol 型別是在 ES6 中新增到 JavaScript 中的,這樣做的一個主要原因是可以安全地向語言新增擴充套件,而不會破壞已部署在 Web 上的程式碼的相容性。我們在第十二章中看到了一個例子,我們學到可以透過實現一個方法,其“名稱”是符號Symbol.iterator,使一個類可迭代。

Symbol.iterator是“眾所周知的符號”中最為人熟知的例子。這些是一組作為Symbol()工廠函式屬性儲存的符號值,用於允許 JavaScript 程式碼控制物件和類的某些低階行為。接下來的小節描述了每個這些眾所周知的符號,並解釋了它們的用途。

14.4.1 Symbol.iterator 和 Symbol.asyncIterator

Symbol.iteratorSymbol.asyncIterator 符號允許物件或類使自己可迭代或非同步可迭代。它們在第十二章和§13.4.2 中有詳細介紹,這裡僅為完整性而提及。

14.4.2 Symbol.hasInstance

當描述instanceof運算子時,在§4.9.4 中我們說右側必須是一個建構函式,並且表示式o instanceof f透過查詢o的原型鏈中的值f.prototype來進行評估。這仍然是正確的,但在 ES6 及更高版本中,Symbol.hasInstance提供了一種替代方法。在 ES6 中,如果instanceof的右側是具有[Symbol.hasInstance]方法的任何物件,則該方法將以其引數作為左側值呼叫,並且方法的返回值,轉換為布林值,成為instanceof運算子的值。當然,如果右側的值沒有[Symbol.hasInstance]方法但是一個函式,則instanceof運算子會按照其普通方式行為。

Symbol.hasInstance意味著我們可以使用instanceof運算子來進行通用型別檢查,只需定義適當的偽型別物件即可。例如:

// Define an object as a "type" we can use with instanceof
let uint8 = {
    Symbol.hasInstance {
        return Number.isInteger(x) && x >= 0 && x <= 255;
    }
};
128 instanceof uint8     // => true
256 instanceof uint8     // => false: too big
Math.PI instanceof uint8 // => false: not an integer

請注意,這個例子很巧妙但令人困惑,因為它使用了一個非類物件,而通常期望的是一個類。對於你的程式碼讀者來說,編寫一個isUint8()函式而不依賴於Symbol.hasInstance行為會更容易理解。

14.4.3 Symbol.toStringTag

如果呼叫基本 JavaScript 物件的toString()方法,你會得到字串“[object Object]”:

{}.toString()  // => "[object Object]"

如果將相同的Object.prototype.toString()函式作為內建型別的例項方法呼叫,你會得到一些有趣的結果:

Object.prototype.toString.call([])     // => "[object Array]"
Object.prototype.toString.call(/./)    // => "[object RegExp]"
Object.prototype.toString.call(()=>{}) // => "[object Function]"
Object.prototype.toString.call("")     // => "[object String]"
Object.prototype.toString.call(0)      // => "[object Number]"
Object.prototype.toString.call(false)  // => "[object Boolean]"

使用Object.prototype.toString().call()技術可以獲取任何 JavaScript 值的“類屬性”,其中包含了否則無法獲取的型別資訊。下面的classof()函式可能比typeof運算子更有用,因為它可以區分不同型別的物件:

function classof(o) {
    return Object.prototype.toString.call(o).slice(8,-1);
}

classof(null)       // => "Null"
classof(undefined)  // => "Undefined"
classof(1)          // => "Number"
classof(10n**100n)  // => "BigInt"
classof("")         // => "String"
classof(false)      // => "Boolean"
classof(Symbol())   // => "Symbol"
classof({})         // => "Object"
classof([])         // => "Array"
classof(/./)        // => "RegExp"
classof(()=>{})     // => "Function"
classof(new Map())  // => "Map"
classof(new Set())  // => "Set"
classof(new Date()) // => "Date"

在 ES6 之前,Object.prototype.toString()方法的這種特殊行為僅適用於內建型別的例項,如果你在自己定義的類的例項上呼叫classof()函式,它將簡單地返回“Object”。然而,在 ES6 中,Object.prototype.toString()會在其引數上查詢一個名為Symbol.toStringTag的符號名稱屬性,如果存在這樣的屬性,它將在輸出中使用屬性值。這意味著如果你定義了自己的類,你可以輕鬆地使其與classof()等函式一起工作:

class Range {
    get [Symbol.toStringTag]() { return "Range"; }
    // the rest of this class is omitted here
}
let r = new Range(1, 10);
Object.prototype.toString.call(r)   // => "[object Range]"
classof(r)                          // => "Range"

14.4.4 Symbol.species

在 ES6 之前,JavaScript 沒有提供任何真正的方法來建立 Array 等內建類的強大子類。然而,在 ES6 中,你可以簡單地使用classextends關鍵字來擴充套件任何內建類。§9.5.2 演示了這個簡單的 Array 子類:

// 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 e = new EZArray(1,2,3);
let f = e.map(x => x * x);
e.last  // => 3: the last element of EZArray e
f.last  // => 9: f is also an EZArray with a last property

Array 定義了concat()filter()map()slice()splice()等方法,它們返回陣列。當我們建立一個像 EZArray 這樣繼承這些方法的陣列子類時,繼承的方法應該返回 Array 的例項還是 EZArray 的例項?對於任一選擇都有充分的理由,但 ES6 規範表示(預設情況下)這五個返回陣列的方法將返回子類的例項。

它是如何工作的:

  • 在 ES6 及以後的版本中,Array()建構函式有一個名為Symbol.species的符號屬性。(請注意,此 Symbol 用作建構函式的屬性名稱。這裡描述的大多數其他知名 Symbols 用作原型物件的方法名稱。)

  • 當我們使用extends建立一個子類時,結果子類建構函式會繼承自超類建構函式的屬性。(這是正常繼承的一種,其中子類的例項繼承自超類的方法。)這意味著每個 Array 的子類建構函式也會繼承一個名為Symbol.species的繼承屬性。(或者子類可以定義自己的具有此名稱的屬性,如果需要的話。)

  • 在 ES6 及以後的版本中,像map()slice()這樣建立並返回新陣列的方法稍作調整。它們(實際上)呼叫new this.constructor[Symbol.species]()來建立新陣列。

現在這裡有趣的部分。假設Array[Symbol.species]只是一個常規的資料屬性,像這樣定義:

Array[Symbol.species] = Array;

在這種情況下,子類建構函式將以Array()建構函式作為其“species”繼承,並在陣列子類上呼叫map()將返回超類的例項而不是子類的例項。然而,ES6 實際上並不是這樣行為的。原因是Array[Symbol.species]是一個只讀的訪問器屬性,其 getter 函式簡單地返回this。子類建構函式繼承了這個 getter 函式,這意味著預設情況下,每個子類建構函式都是其自己的“species”。

然而,有時這種預設行為並不是你想要的。如果你希望 EZArray 的返回陣列方法返回常規的 Array 物件,你只需要將EZArray[Symbol.species]設定為Array。但由於繼承的屬性是隻讀訪問器,你不能簡單地使用賦值運算子來設定它。不過,你可以使用defineProperty()

EZArray[Symbol.species] = Array; // Attempt to set a read-only property fails

// Instead we can use defineProperty():
Object.defineProperty(EZArray, Symbol.species, {value: Array});

最簡單的選擇可能是在建立子類時明確定義自己的Symbol.speciesgetter:

class EZArray extends Array {
    static get [Symbol.species]() { return Array; }
    get first() { return this[0]; }
    get last() { return this[this.length-1]; }
}

let e = new EZArray(1,2,3);
let f = e.map(x => x - 1);
e.last  // => 3
f.last  // => undefined: f is a regular array with no last getter

建立有用的 Array 子類是引入Symbol.species的主要用例,但這並不是這個眾所周知的 Symbol 被使用的唯一場合。Typed array 類使用 Symbol 的方式與 Array 類相同。類似地,ArrayBuffer 的slice()方法檢視this.constructorSymbol.species屬性,而不是簡單地建立一個新的 ArrayBuffer。而像then()這樣返回新 Promise 物件的 Promise 方法也透過這種 species 協議建立這些物件。最後,如果您發現自己正在對 Map(例如)進行子類化並定義返回新 Map 物件的方法,您可能希望為了您的子類的好處自己使用Symbol.species

14.4.5 Symbol.isConcatSpreadable

Array 方法concat()是前一節描述的使用Symbol.species確定返回的陣列要使用的建構函式之一。但concat()還使用Symbol.isConcatSpreadable。回顧§7.8.3,陣列的concat()方法將其this值和其陣列引數與非陣列引數區別對待:非陣列引數簡單地附加到新陣列,但this陣列和任何陣列引數被展平或“展開”,以便將陣列的元素連線起來而不是陣列引數本身。

在 ES6 之前,concat()只是使用Array.isArray()來確定是否將一個值視為陣列。在 ES6 中,演算法略有改變:如果傳遞給concat()的引數(或this值)是一個物件,並且具有符號名稱Symbol.isConcatSpreadable的屬性,則該屬性的布林值用於確定是否應“展開”引數。如果不存在這樣的屬性,則像語言的早期版本一樣使用Array.isArray()

有兩種情況下您可能想使用這個 Symbol:

  • 如果您建立一個類似陣列的物件(參見§7.9),並希望在傳遞給concat()時表現得像真正的陣列,您可以簡單地向您的物件新增這個符號屬性:

    let arraylike = {
        length: 1,
        0: 1,
        [Symbol.isConcatSpreadable]: true
    };
    [].concat(arraylike)  // => [1]: (would be [[1]] if not spread)
    
  • 預設情況下,陣列子類是可展開的,因此,如果您正在定義一個陣列子類,而不希望在使用concat()時表現得像陣列,那麼您可以在子類中新增一個類似這樣的 getter:

    class NonSpreadableArray extends Array {
        get [Symbol.isConcatSpreadable]() { return false; }
    }
    let a = new NonSpreadableArray(1,2,3);
    [].concat(a).length // => 1; (would be 3 elements long if a was spread)
    

14.4.6 模式匹配符號

§11.3.2 記載了使用 RegExp 引數執行模式匹配操作的 String 方法。在 ES6 及更高版本中,這些方法已被泛化,可以與 RegExp 物件或任何定義了透過具有符號名稱的屬性進行模式匹配行為的物件一起使用。對於每個字串方法match()matchAll()search()replace()split(),都有一個對應的眾所周知的 Symbol:Symbol.matchSymbol.search等等。

正規表示式是描述文字模式的一種通用且非常強大的方式,但它們可能會很複雜,不太適合模糊匹配。透過泛化的字串方法,您可以使用眾所周知的 Symbol 方法定義自己的模式類,以提供自定義匹配。例如,您可以使用 Intl.Collator(參見§11.7.3)執行字串比較,以在匹配時忽略重音。或者您可以基於 Soundex 演算法定義一個模式類,根據其近似音調匹配單詞或寬鬆匹配給定的 Levenshtein 距離內的字串。

通常,當您在模式物件上呼叫這五個 String 方法之一時:

string.method(pattern, arg)

那個呼叫會變成在您的模式物件上呼叫一個以符號命名的方法:

patternsymbol

舉個例子,考慮下一個示例中的模式匹配類,它使用簡單的*?萬用字元實現模式匹配,你可能從檔案系統中熟悉這些萬用字元。這種模式匹配風格可以追溯到 Unix 作業系統的早期,這些模式通常被稱為globs

class Glob {
    constructor(glob) {
        this.glob = glob;

        // We implement glob matching using RegExp internally.
        // ? matches any one character except /, and * matches zero or more
        // of those characters. We use capturing groups around each.
        let regexpText = glob.replace("?", "([^/])").replace("*", "([^/]*)");

        // We use the u flag to get Unicode-aware matching.
        // Globs are intended to match entire strings, so we use the ^ and $
        // anchors and do not implement search() or matchAll() since they
        // are not useful with patterns like this.
        this.regexp = new RegExp(`^${regexpText}$`, "u");
    }

    toString() { return this.glob; }

    Symbol.search { return s.search(this.regexp); }
    Symbol.match  { return s.match(this.regexp); }
    Symbol.replace {
        return s.replace(this.regexp, replacement);
    }
}

let pattern = new Glob("docs/*.txt");
"docs/js.txt".search(pattern)   // => 0: matches at character 0
"docs/js.htm".search(pattern)   // => -1: does not match
let match = "docs/js.txt".match(pattern);
match[0]     // => "docs/js.txt"
match[1]     // => "js"
match.index  // => 0
"docs/js.txt".replace(pattern, "web/$1.htm")  // => "web/js.htm"

14.4.7 Symbol.toPrimitive

§3.9.3 解釋了 JavaScript 有三種稍有不同的演算法來將物件轉換為原始值。粗略地說,對於期望或偏好字串值的轉換,JavaScript 首先呼叫物件的toString()方法,如果未定義或未返回原始值,則回退到valueOf()方法。對於偏好數值的轉換,JavaScript 首先嚐試valueOf()方法,如果未定義或未返回原始值,則回退到toString()。最後,在沒有偏好的情況下,它讓類決定如何進行轉換。日期物件首先使用toString()進行轉換,而所有其他型別首先嚐試valueOf()

在 ES6 中,著名的 Symbol Symbol.toPrimitive 允許你重寫預設的物件到原始值的行為,並完全控制你自己類的例項將如何轉換為原始值。為此,請定義一個具有這個符號名稱的方法。該方法必須返回某種代表物件的原始值。你定義的方法將被呼叫一個字串引數,告訴你 JavaScript 正在嘗試對你的物件進行什麼樣的轉換:

  • 如果引數是"string",這意味著 JavaScript 在一個期望或偏好(但不是必須)字串的上下文中進行轉換。例如,當你將物件插入模板文字中時會發生這種情況。

  • 如果引數是"number",這意味著 JavaScript 在一個期望或偏好(但不是必須)數字值的上下文中進行轉換。當你使用物件與<>運算子或使用-*等算術運算子時會發生這種情況。

  • 如果引數是"default",這意味著 JavaScript 在一個數字或字串值都可以使用的上下文中轉換你的物件。這發生在+==!=運算子中。

許多類可以忽略引數,並在所有情況下簡單地返回相同的原始值。如果你希望你的類的例項可以使用<>進行比較和排序,那麼這是定義[Symbol.toPrimitive]方法的一個很好的理由。

14.4.8 Symbol.unscopables

我們將在這裡介紹的最後一個著名 Symbol 是一個晦澀的 Symbol,它是為了解決由於已棄用的with語句引起的相容性問題而引入的。回想一下,with語句接受一個物件,並執行其語句體,就好像它在物件的屬性是變數的作用域中執行一樣。當向 Array 類新增新方法時,這導致了相容性問題,並且破壞了一些現有程式碼。Symbol.unscopables 就是解決方案。在 ES6 及更高版本中,with語句已經稍作修改。當與物件o一起使用時,with語句計算Object.keys(o[Symbol.unscopables]||{}),並在建立模擬作用域以執行其語句體時忽略其名稱在生成的陣列中的屬性。ES6 使用這個方法向Array.prototype新增新方法,而不會破壞網路上的現有程式碼。這意味著你可以透過評估來找到最新的 Array 方法列表:

let newArrayMethods = Object.keys(Array.prototype[Symbol.unscopables]);

14.5 模板標籤

反引號內的字串稱為“模板字面量”,在 §3.3.4 中有介紹。當一個值為函式的表示式後面跟著一個模板字面量時,它變成了一個函式呼叫,並且我們稱之為“標記模板字面量”。為標記模板字面量定義一個新的標記函式可以被視為超程式設計,因為標記模板經常用於定義 DSL—領域特定語言—並且定義一個新的標記函式就像向 JavaScript 新增新的語法。標記模板字面量已經被許多前端 JavaScript 包採用。GraphQL 查詢語言使用 gql 標籤函式來允許查詢嵌入到 JS 程式碼中。以及,Emotion 庫使用css標記函式來使 CSS 樣式嵌入到 JavaScript 中。本節演示瞭如何編寫自己的類似這樣的標記函式。

標記函式沒有什麼特別之處:它們是普通的 JavaScript 函式,不需要特殊的語法來定義它們。當一個函式表示式後面跟著一個模板字面量時,該函式被呼叫。第一個引數是一個字串陣列,然後是零個或多個額外引數,這些引數可以是任何型別的值。

引數的數量取決於插入到模板字面量中的值的數量。如果模板字面量只是一個沒有插值的常量字串,那麼標記函式將被呼叫一個包含那個字串的陣列和沒有額外引數的陣列。如果模板字面量包含一個插入值,那麼標記函式將被呼叫兩個引數。第一個是包含兩個字串的陣列,第二個是插入的值。初始陣列中的字串是插入值左側的字串和右側的字串,其中任何一個都可能是空字串。如果模板字面量包含兩個插入值,那麼標記函式將被呼叫三個引數:一個包含三個字串和兩個插入值的陣列。這三個字串(任何一個或全部可能為空)是第一個值左側的文字、兩個值之間的文字和第二個值右側的文字。在一般情況下,如果模板字面量有 n 個插入值,那麼標記函式將被呼叫 n+1 個引數。第一個引數將是一個包含 n+1 個字串的陣列,其餘引數是 n 個插入值,按照它們在模板字面量中出現的順序。

模板字面量的值始終是一個字串。但是標記模板字面量的值是標記函式返回的任何值。這可能是一個字串,但是當標記函式用於實現 DSL 時,返回值通常是一個非字串資料結構,它是字串的解析表示。

作為一個返回字串的模板標籤函式的示例,考慮以下 html 模版,當你打算將值安全插入 HTML 字串時比較實用。在使用它構建最終字串之前,該標籤在每個值上執行 HTML 轉義:


function html(strings, ...values) {
    // 將每個值轉換為字串並轉義特殊的 HTML 字元
    let escaped = values.map(v => String(v)
                                .replace("&", "&amp;")
                                .replace("<", "&lt;")
                                .replace(">", "&gt;");
                                .replace('"', "&quot;")
                                .replace("'", "&#39;"));
    // 返回連線的字串和轉義的值
    let result = strings[0];
    for(let i = 0; i < escaped.length; i++) {
        result += escaped[i] + strings[i+1];
    }
    return result;
}
let operator = "<";
html`<b>x ${operator} y</b>`             // => "<b>x &lt; y</b>"
let kind = "game", name = "D&D";
html`<div class="${kind}">${name}</div>` // =>'<div class="game">D&amp;D</div>'

對於不返回字串而是返回字串的解析表示的標記函式的示例,回想一下 14.4.6 中定義的 Glob 模式類。由於Glob()建構函式採用單個字串引數,因此我們可以定義一個標記函式來建立新的 Glob 物件:


function glob(strings, ...values) {
    // 將字串和值組裝成一個字串
    let s = strings[0];
    for(let i = 0; i < values.length; i++) {
        s += values[i] + strings[i+1];
    }
    // 返回該字串的解析表示
    return new Glob(s);
}
let root = "/tmp";
let filePattern = glob`${root}/*.html`;  // 一個 RegExp 替代方案
"/tmp/test.html".match(filePattern)[1]   // => "test"

3.3.4 節中提到的特性之一是String.raw標籤函式,以其“原始”形式返回一個字串,而不解釋任何反斜槓轉義序列。這是使用我們尚未討論過的標籤函式呼叫的一個特性實現的。當呼叫標籤函式時,我們已經看到它的第一個引數是一個字串陣列。但是這個陣列還有一個名為 raw 的屬性,該屬性的值是另一個具有相同數量元素的字串陣列。引數陣列包含已解釋轉義序列的字串。原始陣列包含未解釋轉義序列的字串。如果要定義使用反斜槓的語法的語法的 DSL,這個晦澀的特性是重要的。例如,如果我們想要我們的 glob 標籤函式支援 Windows 風格路徑上的模式匹配(它使用反斜槓而不是正斜槓),並且我們不希望標籤的使用者必須雙寫每個反斜槓,我們可以重寫該函式來使用strings.raw[]而不是strings[]。 當然,缺點可能是我們不能再在我們的 glob 字面值中使用轉義,例如\u

14.6 Reflect API

Reflect 物件不是一個類;像 Math 物件一樣,其屬性只是定義了一組相關函式。這些函式在 ES6 中新增,定義了一個 API,用於“反射”物件及其屬性。這裡幾乎沒有新功能:Reflect 物件定義了一組方便的函式,全部在一個名稱空間中,模仿核心語言語法的行為並複製各種現有 Object 函式的功能。

儘管 Reflect 函式不提供任何新功能,但它們將功能組合在一個便利的 API 中。而且,重要的是,Reflect 函式集與我們將在§14.7 中學習的 Proxy 處理程式方法一一對應。

Reflect API 包括以下函式:

Reflect.apply(f, o, args)

此函式將函式f作為o的方法呼叫(如果onull,則作為沒有this值的函式呼叫),並將args陣列中的值作為引數傳遞。它等效於f.apply(o, args)

Reflect.construct(c, args, newTarget)

此函式呼叫建構函式c,就像使用new關鍵字一樣,並將陣列args的元素作為引數傳遞。如果指定了可選的newTarget引數,則它將用作建構函式呼叫中的new.target值。如果未指定,則new.target值將為c

Reflect.defineProperty(o, name, descriptor)

此函式在物件o上定義屬性,使用name(字串或符號)作為屬性的名稱。描述符物件應該定義屬性的值(或 getter 和/或 setter)和屬性的屬性。Reflect.defineProperty()Object.defineProperty()非常相似,但成功時返回true,失敗時返回false。(Object.defineProperty()成功時返回o,失敗時丟擲 TypeError。)

Reflect.deleteProperty(o, name)

此函式從物件o中刪除具有指定字串或符號名稱的屬性,如果成功(或不存在此屬性),則返回true,如果無法刪除屬性,則返回false。呼叫此函式類似於編寫delete o[name]

Reflect.get(o, name, receiver)

此函式返回具有指定名稱(字串或符號)的物件o的屬性的值。如果屬性是具有 getter 的訪問器方法,並且指定了可選的receiver引數,則 getter 函式將作為receiver的方法呼叫,而不是作為o的方法呼叫。呼叫此函式類似於評估o[name]

Reflect.getOwnPropertyDescriptor(o, name)

此函式返回描述物件,描述物件描述了物件o的名為name的屬性的屬性,如果不存在此屬性,則返回undefined。此函式與Object.getOwnPropertyDescriptor()幾乎相同,只是 Reflect API 版本的函式要求第一個引數是物件,如果不是則丟擲 TypeError。

Reflect.getPrototypeOf(o)

此函式返回物件o的原型,如果物件沒有原型則返回null。如果o是原始值而不是物件,則丟擲 TypeError。此函式與Object.getPrototypeOf()幾乎相同,只是Object.getPrototypeOf()僅對nullundefined引數丟擲 TypeError,並將其他原始值強制轉換為其包裝物件。

Reflect.has(o, name)

如果物件o具有指定的name屬性(必須是字串或符號),則此函式返回true。呼叫此函式類似於評估name in o

Reflect.isExtensible(o)

此函式返回true如果物件o是可擴充套件的(§14.2),如果不可擴充套件則返回false。如果o不是物件,則丟擲 TypeError。Object.isExtensible()類似,但當傳遞一個不是物件的引數時,它只返回false

Reflect.ownKeys(o)

此函式返回物件o的屬性名稱的陣列,如果o不是物件則丟擲 TypeError。返回的陣列中的名稱將是字串和/或符號。呼叫此函式類似於呼叫Object.getOwnPropertyNames()Object.getOwnPropertySymbols()並組合它們的結果。

Reflect.preventExtensions(o)

此函式將物件o可擴充套件屬性(§14.2)設定為false,並返回true以指示成功。如果o不是物件,則丟擲 TypeError。Object.preventExtensions()具有相同的效果,但返回o而不是true,並且不會為非物件引數丟擲 TypeError。

Reflect.set(o, name, value, receiver)

此函式將物件o的指定name屬性設定為指定的value。成功時返回true,失敗時返回false(如果屬性是隻讀的,則可能失敗)。如果o不是物件,則丟擲 TypeError。如果指定的屬性是具有 setter 函式的訪問器屬性,並且傳遞了可選的receiver引數,則將呼叫 setter 作為receiver的方法,而不是作為o的方法。呼叫此函式通常與評估o[name] = value相同。

Reflect.setPrototypeOf(o, p)

此函式將物件o的原型設定為p,成功時返回true,失敗時返回false(如果o不可擴充套件或操作會導致迴圈原型鏈)。如果o不是物件或p既不是物件也不是null,則丟擲 TypeError。Object.setPrototypeOf()類似,但成功時返回o,失敗時丟擲 TypeError。請記住,呼叫這些函式之一可能會使您的程式碼變慢,因為它會破壞 JavaScript 直譯器的最佳化。

14.7 代理物件

ES6 及更高版本中提供的 Proxy 類是 JavaScript 最強大的超程式設計功能。它允許我們編寫改變 JavaScript 物件基本行為的程式碼。在§14.6 中描述的 Reflect API 是一組函式,它們直接訪問 JavaScript 物件上的一組基本操作。Proxy 類的作用是允許我們自己實現這些基本操作並建立行為與普通物件不同的物件。

建立代理物件時,我們指定另外兩個物件,目標物件和處理程式物件:


let proxy = new Proxy(target, handlers);

生成的代理物件沒有自己的狀態或行為。每當對其執行操作(讀取屬性、寫入屬性、定義新屬性、查詢原型、將其作為函式呼叫),它都將這些操作分派到處理程式物件或目標物件。

代理物件支援的操作與 Reflect API 定義的操作相同。假設p是代理物件,您寫delete p.xReflect.deleteProperty()函式的行為與delete運算子相同。當使用delete運算子刪除代理物件的屬性時,它會在處理程式物件上查詢deleteProperty()方法。如果存在這樣的方法,則呼叫它。如果處理程式物件上不存在這樣的方法,則代理物件將在目標物件上執行屬性刪除。

對於所有基本操作,代理物件都是這樣工作的:如果處理程式物件上存在適當的方法,則呼叫該方法執行操作。(方法名稱和簽名與§14.6 中涵蓋的 Reflect 函式相同。)如果處理程式物件上不存在該方法,則代理物件將在目標物件上執行基本操作。這意味著代理物件可以從目標物件或處理程式物件獲取其行為。如果處理程式物件為空,則代理基本上是目標物件周圍的透明包裝器:


let t = { x: 1, y: 2 };
let p = new Proxy(t, {});
p.x          // => 1
delete p.y   // => true: 刪除代理的屬性 y
t.y          // => undefined: 這也會在目標中刪除它
p.z = 3;     // 在代理上定義一個新屬性
t.z          // => 3: 在目標上定義屬性

這種透明包裝代理本質上等同於底層目標物件,這意味著沒有理由使用它而不是包裝物件。然而,當建立為“可撤銷代理”時,透明包裝器可以很有用。你可以使用 Proxy.revocable() 工廠函式,而不是使用 Proxy() 建構函式來建立代理。這個函式返回一個物件,其中包括一個代理物件和一個 revoke() 函式。一旦呼叫 revoke() 函式,代理立即停止工作:


function accessTheDatabase() { /* 實現省略 */ return 42; }
let {proxy, revoke} = Proxy.revocable(accessTheDatabase, {});
proxy()   // => 42: 代理提供對基礎目標函式的訪問
revoke(); // 但是訪問可以隨時關閉
proxy();  // !TypeError: 我們不能再呼叫這個函式

注意,除了演示可撤銷代理之外,上述程式碼還演示了代理可以與目標函式以及目標物件一起使用。但這裡的主要觀點是可撤銷代理是一種程式碼隔離的基礎,當你處理不受信任的第三方庫時,可能會用到它們。例如,如果你必須將一個函式傳遞給一個你無法控制的庫,你可以傳遞一個可撤銷代理,然後在完成與庫的互動後撤銷代理。這可以防止庫保留對你的函式的引用,並在意想不到的時候呼叫它。這種防禦性程式設計在 JavaScript 程式中並不典型,但 Proxy 類至少使其成為可能。

如果我們將非空處理程式物件傳遞給 Proxy() 建構函式,那麼我們不再定義一個透明的包裝器物件,而是為我們的代理實現自定義行為。透過正確設定處理程式,底層目標物件基本上變得無關緊要。

例如,以下程式碼展示瞭如何實現一個看起來具有無限只讀屬性的物件,其中每個屬性的值與屬性的名稱相同:


// 我們使用代理建立一個物件, 看起來擁有每個
// 可能的屬性, 每個屬性的值都等於其名稱
let identity = new Proxy({}, {
    // 每個屬性都以其名稱作為其值
    get(o, name, target) { return name; },
    // 每個屬性名稱都已定義
    has(o, name) { return true; },
    // 有太多屬性要列舉, 所以我們只是丟擲
    ownKeys(o) { throw new RangeError("屬性數量無限"); },
    // 所有屬性都存在且不可寫, 不可配置或可列舉。
    getOwnPropertyDescriptor(o, name) {
        return {
            value: name,
            enumerable: false,
            writable: false,
            configurable: false
        };
    },
    // 所有屬性都是隻讀的, 因此無法設定
    set(o, name, value, target) { return false; },
    // 所有屬性都是不可配置的, 因此它們無法被刪除
    deleteProperty(o, name) { return false; },
    // 所有屬性都存在且不可配置, 因此我們無法定義更多
    defineProperty(o, name, desc) { return false; },
    // 實際上, 這意味著物件不可擴充套件
    isExtensible(o) { return false; },
    // 所有屬性已在此物件上定義, 因此無法
    // 即使它有原型物件, 也不會繼承任何東西。
    getPrototypeOf(o) { return null; },
    // 物件不可擴充套件, 因此我們無法更改原型
    setPrototypeOf(o, proto) { return false; },
});
identity.x                // => "x"
identity.toString         // => "toString"
identity[0]               // => "0"
identity.x = 1;           // 設定屬性沒有效果
identity.x                // => "x"
delete identity.x         // => false: 也無法刪除屬性
identity.x                // => "x"
Object.keys(identity);    // !RangeError: 無法列出所有鍵
for(let p of identity) ;  // !RangeError

代理物件可以從目標物件和處理程式物件派生其行為,到目前為止我們看到的示例都使用了一個物件或另一個物件。但通常更有用的是定義同時使用兩個物件的代理。

例如,下面的程式碼使用 Proxy 建立了一個目標物件的只讀包裝器。當程式碼嘗試從物件中讀取值時,這些讀取會正常轉發到目標物件。但如果任何程式碼嘗試修改物件或其屬性,處理程式物件的方法會丟擲 TypeError。這樣的代理可能有助於編寫測試:假設你編寫了一個接受物件引數的函式,並希望確保你的函式不會嘗試修改輸入引數。如果你的測試傳入一個只讀包裝器物件,那麼任何寫入操作都會丟擲異常,導致測試失敗:


function readOnlyProxy(o) {
    function readonly() { throw new TypeError("只讀"); }
    return new Proxy(o, {
        set: readonly,
        defineProperty: readonly,
        deleteProperty: readonly,
        setPrototypeOf: readonly,
    });
}
let o = { x: 1, y: 2 };    // 普通可寫物件
let p = readOnlyProxy(o);  // 它的只讀版本
p.x                        // => 1: 讀取屬性有效
p.x = 2;                   // !TypeError: 無法更改屬性
delete p.y;                // !TypeError: 無法刪除屬性
p.z = 3;                   // !TypeError: 無法新增屬性
p.__proto__ = {};          // !TypeError: 無法更改原型

寫代理時的另一種技術是定義處理程式方法,攔截物件上的操作,但仍將操作委託給目標物件。Reflect API(§14.6)的函式具有與處理程式方法完全相同的簽名,因此它們使得執行這種委託變得容易。

例如,這是一個代理,它將所有操作委託給目標物件,但使用處理程式方法記錄操作:


/*
 * 返回一個代理物件, 包裝 o, 將所有操作委託給
 * 在記錄每個操作後, 該物件的名稱是一個字串
 * 將出現在日誌訊息中以標識物件。如果 o 具有自有
 * 其值為物件或函式, 則如果您查詢
 * 這些屬性的值是物件或函式, 則返回代理而不是
 * 此代理的記錄行為是“傳染性的”。
 */
function loggingProxy(o, objname) {
    // 為我們的記錄代理物件定義處理程式。
    // 每個處理程式記錄一條訊息, 然後委託給目標物件。
    const handlers = {
        // 這個處理程式是一個特例, 因為對於自有屬性
        // 其值為物件或函式, 則返回代理而不是值本身。
        // 返回值本身。
        get(target, property, receiver){
            // 記錄獲取操作
            console.log(`Handler get(${objname}, ${property.toString()})`);
            // 使用 Reflect API 獲取屬性值
            let value = Reflect.get(target, property, receiver);
            // 如果屬性是目標的自有屬性且
            // 值是物件或函式, 則返回其代理。
            if(Reflect.ownKeys(target).includes(property)&&
                (typeof value === "object" || typeof value === "function")){
                return loggingProxy(value, `${objname}.${property.toString()}`);
            }
            // 否則返回未修改的值。
            返回值;
        },
        // 以下三種方法沒有特殊之處:
        // 它們記錄操作並委託給目標物件。
        // 它們是一個特例, 只是為了避免記錄
        // 可能導致無限遞迴的接收器物件。
        set(target, prop, value, receiver){
            console.log(`Handler set(${objname}, ${prop.toString()}, ${value})`);
            return Reflect.set(target, prop, value, receiver);
        },
        apply(target, receiver, args){
            console.log(`Handler ${objname}(${args})`);
            return Reflect.apply(target, receiver, args);
        },
        構造(target, args, receiver){
            console.log(`Handler ${objname}(${args})`);
            return Reflect.construct(target, args, receiver);
        }
    };
    // 我們可以自動生成其餘的處理程式。
    // 超程式設計 FTW!
    Reflect.ownKeys(Reflect).forEach(handlerName => {
        if(!(handlerName in handlers)){
            handlers[handlerName] = function(target, ...args){
                // 記錄操作
                console.log(`Handler ${handlerName}(${objname}, ${args})`);
                // 委託操作
                return Reflect[handlerName](target, ...args);
            };
        }
    });
    // 返回一個物件的代理, 使用這些記錄處理程式
    return new Proxy(o, handlers);

}

之前定義的 loggingProxy() 函式建立了記錄其使用方式的代理。如果你試圖瞭解一個未記錄的函式如何使用你傳遞給它的物件,使用記錄代理可以幫助。

考慮以下示例,這些示例對陣列迭代產生了一些真正的見解:


// 定義一個資料陣列和一個具有函式屬性的物件
let data = [10,20];
let methods = { square: x => x*x };
// 為陣列和具有函式屬性的物件建立記錄代理
let proxyData = loggingProxy(data, "data");
let proxyMethods = loggingProxy(methods, "methods");
// 假設我們想要了解 Array.map()方法的工作原理
data.map(methods.square)        // => [100, 400]
// 首先, 讓我們嘗試使用一個記錄代理陣列
proxyData.map(methods.square)   // => [100, 400]
// 它產生以下輸出:
// Handler get(data, map)
// Handler get(data, length)
// Handler get(data, constructor)
// Handler has(data, 0)
// Handler get(data, 0)
// Handler has(data, 1)
// Handler get(data, 1)
// 現在讓我們嘗試使用一個代理方法物件
data.map(proxyMethods.square)   // => [100, 400]
// 記錄輸出:
// Handler get(methods, square)
// Handler methods.square(10, 0, 10, 20)
// Handler methods.square(20, 1, 10, 20)
// 最後, 讓我們使用一個記錄代理來了解迭代協議
for(let x of proxyData) console.log("data", x);
// 記錄輸出:
// Handler get(data, Symbol(Symbol.iterator))
// Handler get(data, length)
// Handler get(data, 0)
// data 10
// Handler get(data, length)
// Handler get(data, 1)
// data 20
// Handler get(data, length)

從第一塊日誌輸出中,我們瞭解到 Array.map() 方法在實際讀取元素值之前明確檢查每個陣列元素的存在性(導致呼叫 has() 處理程式),然後讀取元素值(觸發 get() 處理程式)。這可能是為了區分不存在的陣列元素和存在但為 undefined 的元素。

第二塊日誌輸出可能會提醒我們,我們傳遞給 Array.map() 的函式會使用三個引數呼叫:元素的值、元素的索引和陣列本身。(我們的日誌輸出中存在問題:Array.toString() 方法在其輸出中不包含方括號,並且如果在引數列表中包含它們,日誌訊息將更清晰 (10,0,[10,20])。)

第三塊日誌輸出向我們展示了 for/of 迴圈是透過查詢具有符號名稱 [Symbol.iterator] 的方法來工作的。它還演示了 Array 類對此迭代器方法的實現在每次迭代時都會檢查陣列長度,並且不假設陣列長度在迭代過程中保持不變。

14.7.1 代理不變性

之前定義的 readOnlyProxy() 函式建立了實際上是凍結的代理物件:任何嘗試更改屬性值、屬性屬性或新增或刪除屬性的嘗試都會引發異常。但只要目標物件未被凍結,我們會發現如果我們可以使用 Reflect.isExtensible()Reflect.getOwnPropertyDescriptor() 查詢代理,它會告訴我們應該能夠設定、新增和刪除屬性。因此,readOnlyProxy() 建立了處於不一致狀態的物件。我們可以透過新增 isExtensible()getOwnPropertyDescriptor() 處理程式來解決這個問題,或者我們可以接受這種輕微的不一致性。

代理處理程式 API 允許我們定義具有主要不一致性的物件,但在這種情況下,代理類本身將阻止我們建立不良不一致的代理物件。在本節開始時,我們將代理描述為沒有自己行為的物件,因為它們只是將所有操作轉發到處理程式物件和目標物件。但這並不完全正確:在轉發操作後,代理類會對結果執行一些檢查,以確保不違反重要的 JavaScript 不變性。如果檢測到違規,代理將丟擲 TypeError,而不是讓操作繼續執行。

例如,如果為不可擴充套件物件建立代理,則如果 isExtensible() 處理程式返回 true,代理將丟擲 TypeError:


let target = Object.preventExtensions({});

let proxy = new Proxy(target, { isExtensible(){ return true; });

Reflect.isExtensible(proxy);  // !TypeError:不變違規

相關地,對於不可擴充套件目標的代理物件可能沒有返回除目標的真實原型物件之外的 getPrototypeOf() 處理程式。此外,如果目標物件具有不可寫、不可配置的屬性,則代理類將在 get() 處理程式返回除實際值之外的任何內容時丟擲 TypeError:


let target = Object.freeze({x: 1});
let proxy = new Proxy(target, { get(){ return 99; });
proxy.x;         // !TypeError:get()返回的值與目標不匹配

代理強制執行許多附加不變性,幾乎所有這些不變性都與不可擴充套件的目標物件和目標物件上的不可配置屬性有關。

14.8 總結

在本章中,您已經學到了:

  • JavaScript 物件具有可擴充套件屬性,物件屬性具有可寫可列舉可配置屬性,以及值和 getter 和/或 setter 屬性。您可以使用這些屬性以各種方式“鎖定”您的物件,包括建立“密封”和“凍結”物件。

  • JavaScript 定義了一些函式,允許您遍歷物件的原型鏈,甚至更改物件的原型(儘管這樣做可能會使您的程式碼變慢)。

  • Symbol物件的屬性具有“眾所周知的符號”值,您可以將其用作您定義的物件和類的屬性或方法名稱。這樣做可以讓您控制物件與 JavaScript 語言特性和核心庫的互動方式。例如,眾所周知的符號允許您使您的類可迭代,並控制將例項傳遞給Object.prototype.toString()時顯示的字串。在 ES6 之前,這種定製僅適用於內建到實現中的本機類。

  • 標記模板字面量是一種函式呼叫語法,定義一個新的標籤函式有點像向語言新增新的文字語法。定義一個解析其模板字串引數的標籤函式允許您在 JavaScript 程式碼中嵌入 DSL。標籤函式還提供對原始、未轉義的字串文字的訪問,其中反斜槓沒有特殊含義。

  • 代理類和相關的 Reflect API 允許對 JavaScript 物件的基本行為進行低階控制。代理物件可以用作可選撤銷包裝器,以改善程式碼封裝,並且還可以用於實現非標準物件行為(例如早期 Web 瀏覽器定義的一些特殊情況 API)。

¹ V8 JavaScript 引擎中的一個錯誤意味著這段程式碼在 Node 13 中無法正常工作。

相關文章