輕量函數式 JavaScript 第七章:閉包 vs 對象

感謝社區中各位的大力支持,譯者再次奉上一點點福利:阿里云產品券,享受所有官網優惠,并抽取幸運大獎:點擊這里領取

多年以前,Anton van Straaten 編寫了一個名聲顯赫而且廣為流傳的 禪家公案,描繪并挑起了閉包與對象之間一種重要的緊張狀態。

莊嚴的 Qc Na 大師在與他的學生 Anton 一起散步。Anto 希望促成一次與師傅的討論,他說:“師傅,我聽說對象是個非常好的東西 —— 真的嗎?” Qc Na 同情地看著他的學生回答道,“笨學生 —— 對象只不過是一種簡單的閉包。”

被訓斥的 Anton 告別他的師父返回自己的房間,開始有意地學習閉包。他仔細地閱讀了整部 “Lamda:終極……” 系列書籍以及其姊妹篇,并且使用一個基于閉包的對象系統實現了一個小的 Scheme 解釋器。他學到了很多,希望向他的師父報告自己的進步。

當他再次與 Qc Na 散步時,Anton 試圖給師傅一個好印象,說:“師父,經過勤奮的學習,現在我理解了對象確實是簡化的閉包。” Qc Na 用他的拐杖打了 Anton 作為回應,他說:“你到底什么時候才能明白?閉包只是簡化的對象。” 此時此刻,Anton 茅塞頓開。

Anton van Straaten 6/4/2003

http://people.csail.mit.edu/gregs/ll1-discuss-archive-html/msg03277.html

原版的文章,雖然簡短,但是擁有更多關于其起源與動機的背景內容,我強烈建議你閱讀這篇文章,來為本章的學習正確地設置你的思維模式。

我見過許多讀過這段公案的人都對它的聰明機智表現出一絲假笑,然后并沒有改變太多他們的想法就離開了。然而,一個公案的目的(從佛教禪的角度而言)就是刺激讀者對其中矛盾的真理上下求索。所以,回頭再讀一遍。然后再讀一遍。

它到底是什么?閉包是簡化的對象,或者對象是簡化的閉包?或都不是?或都是?難道唯一的重點是閉包和對象在某種意義上是等價的?

而且這與函數式編程有什么關系?拉一把椅子深思片刻。如果你樂意的話,這一章將是一次有趣的繞路遠足。

同一軌道

首先,讓我們確保當我們談到閉包和對象時我們都在同一軌道上。顯然我們的語境是 JavaScript 如何應對這兩種機制,而且具體來說談到的是簡單的函數閉包(參見第二章的“保持作用域”)與簡單對象(鍵值對的集合)。

一個簡單的函數閉包:

function outer() {
    var one = 1;
    var two = 2;

    return function inner(){
        return one + two;
    };
}

var three = outer();

three();            // 3

一個簡單的對象:

var obj = {
    one: 1,
    two: 2
};

function three(outer) {
    return outer.one + outer.two;
}

three( obj );       // 3

當你提到“閉包”時,很多人都會在腦中喚起許多額外的東西,比如異步回調,甚至是帶有封裝的模塊模式和信息隱藏。相似地,“對象”會把類帶到思維中,this、原型、以及一大堆其他的工具和模式。

隨著我們向前邁進,我們將小心地解說這種重要外部語境的一部分,但就目前來說,只要抓住“閉包”與“對象”的最簡單的解釋就好 —— 這將使我們的探索少一些困惑。

看起來很像

閉包與對象是如何聯系在一起的,這可能不太明顯。所以讓我們首先來探索一下它們的相似性。

為了框定這次討論,讓我簡要地斷言兩件事:

  1. 一個沒有閉包的編程語言可以使用對象來模擬閉包。
  2. 一個沒有對象的編程語言可以使用閉包來模擬對象。

換句話說,我們可以認為閉包和對象是同一種東西的兩種不同表現形式。

狀態

考慮這段從上面引用的代碼:

function outer() {
    var one = 1;
    var two = 2;

    return function inner(){
        return one + two;
    };
}

var obj = {
    one: 1,
    two: 2
};

inner() 和對象 obj 封閉的兩個作用域都包含兩個狀態元素:帶有值 1one,和帶有值 2two。在語法上和機制上,這些狀態的表現形式是不同的。而在概念上,它們其實十分相似。

事實上,將一個對象表示為一個閉包,或者將一個閉包表示為一個對象是相當直接了當的。去吧,自己試一下:

var point = {
    x: 10,
    y: 12,
    z: 14
};

你有沒有想到過這樣的東西?

function outer() {
    var x = 10;
    var y = 12;
    var z = 14;

    return function inner(){
        return [x,y,z];
    }
};

var point = outer();

注意: inner() 函數在每次被調用時創建并返回一個新數組(也就是一個對象!)。這是因為 JS 沒有給我們任何 return 多個值的能力,除非將它們封裝在一個對象中。從技術上講,這并不違背我們的閉包做對象的任務,因為這只是一個暴露/傳送值的實現細節;狀態追蹤本身依然是無對象的。使用 ES6+ 的數組解構,我們可以在另一側聲明式地忽略這個臨時中間數組:var [x,y,z] = point()。從一個開發者的人體工程學角度來說,這些值被分離地存儲而且是通過閉包而非對象追蹤的。

要是我們有一些嵌套的對象呢?

var person = {
    name: "Kyle Simpson",
    address: {
        street: "123 Easy St",
        city: "JS'ville",
        state: "ES"
    }
};

我們可以使用嵌套的閉包來表示同種狀態:

function outer() {
    var name = "Kyle Simpson";
    return middle();

    // ********************

    function middle() {
        var street = "123 Easy St";
        var city = "JS'ville";
        var state = "ES";

        return function inner(){
            return [name,street,city,state];
        };
    }
}

var person = outer();

讓我們實踐一下從另一個方向走,由閉包到對象:

function point(x1,y1) {
    return function distFromPoint(x2,y2){
        return Math.sqrt(
            Math.pow( x2 - x1, 2 ) +
            Math.pow( y2 - y1, 2 )
        );
    };
}

var pointDistance = point( 1, 1 );

pointDistance( 4, 5 );      // 5

distFromPoint(..) 閉合著 x1y1,但我們可以將這些值作為一個對象明確地傳遞:

function pointDistance(point,x2,y2) {
    return Math.sqrt(
        Math.pow( x2 - point.x1, 2 ) +
        Math.pow( y2 - point.y1, 2 )
    );
};

pointDistance(
    { x1: 1, y1: 1 },
    4,  // x2
    5   // y2
);
// 5

point 狀態對象被明確地傳入,取代了隱含地持有這個狀態的閉包。

行為也是!

對象和閉包不僅代表表達狀態集合的方式,它們還可以通過函數/方法包含行為。將數據與它的行為打包有一個炫酷的名字:封裝。

考慮如下代碼:

function person(name,age) {
    return happyBirthday(){
        age++;
        console.log(
            "Happy " + age + "th Birthday, " + name + "!"
        );
    }
}

var birthdayBoy = person( "Kyle", 36 );

birthdayBoy();          // Happy 37th Birthday, Kyle!

內部函數 happyBirthday() 閉合著 nameage,所以其中的功能和狀態一起保留了下來。

我們可以使用 this 與一個對象的綁定取得相同的能力:

var birthdayBoy = {
    name: "Kyle",
    age: 36,
    happyBirthday() {
        this.age++;
        console.log(
            "Happy " + this.age + "th Birthday, " + this.name + "!"
        );
    }
};

birthdayBoy.happyBirthday();
// Happy 37th Birthday, Kyle!

我們仍然使用 happyBirthday() 函數來表達狀態數據的封裝,但使用一個對象而不是閉包。而且我們不必向一個函數明確地傳入一個對象(比如前一個例子);JavaScript 的 this 綁定很容易地創建了一個隱含綁定。

另一種分析這種關系的方式是:一個閉包將一個函數與一組狀態聯系起來,而一個持有相同狀態的對象可以有任意多個操作這些狀態的函數。

事實上,你甚至可以使用一個閉包作為接口暴露多個方法。考慮一個帶有兩個方法的傳統對象:

var person = {
    firstName: "Kyle",
    lastName: "Simpson",
    first() {
        return this.firstName;
    },
    last()
        return this.lastName;
    }
}

person.first() + " " + person.last();
// Kyle Simpson

僅使用閉包而非對象,我們可以將這個程序表示為:

function createPerson(firstName,lastName) {
    return API;

    // ********************

    function API(methodName) {
        switch (methodName) {
            case "first":
                return first();
                break;
            case "last":
                return last();
                break;
        };
    }

    function first() {
        return firstName;
    }

    function last() {
        return lastName;
    }
}

var person = createPerson( "Kyle", "Simpson" );

person( "first" ) + " " + person( "last" );
// Kyle Simpson

雖然這些程序在人體工程學上的觀感不同,但它們實際上只是相同程序行為的不同種類實現。

(不)可變性

許多人一開始認為閉包和對象在可變性方面表現不同;閉包可以防止外部改變而對象不能。但是,事實表明,兩種形式具有完全相同的可變性行為。

這是因為我們關心的,正如第六章中所討論的,是 的可變性,而它是值本身的性質,與它在哪里以及如何被賦值無關。

function outer() {
    var x = 1;
    var y = [2,3];

    return function inner(){
        return [ x, y[0], y[1] ];
    };
}

var xyPublic = {
    x: 1,
    y: [2,3]
};

存儲在 outer() 內部的詞法變量 x 中的值是不可變的 —— 記住,2 這樣的基本類型根據定義就是不可變的。但是被 y 引用的值,一個數組,絕對是可變的。這對 xyPublic 上的屬性 xy 來說是完全一樣的。

我們可以佐證對象與閉包和不可變性無關:指出 y 本身就是一個數組,如此我們需要將這個例子進一步分解:

function outer() {
    var x = 1;
    return middle();

    // ********************

    function middle() {
        var y0 = 2;
        var y1 = 3;

        return function inner(){
            return [ x, y0, y1 ];
        };
    }
}

var xyPublic = {
    x: 1,
    y: {
        0: 2,
        1: 3
    }
};

如果你將它考慮為 “烏龜(也就是對象)背地球”,那么在最底下一層,所有的狀態數據都是基本類型,而所有的基本類型都是不可變的。

不管你是用嵌套的對象表示狀態,還是用嵌套的閉包表示狀態,被持有的值都是不可變的。

同構

如今 “同構” 這個詞經常被扔到 JavaScript 旁邊,它通常用來指代可以在服務器與瀏覽器中使用/共享的代碼。一段時間以前我寫過一篇博客,聲稱對 “同構” 這一詞的這種用法是一種捏造,它實際上有一種明確和重要的含義被掩蓋了。

同構意味著什么?好吧,我們可以從數學上,或社會學上,或生物學上討論它。同構的一般概念是,你有兩個東西,它們雖然不同但在結構上有相似之處。

在所有這些用法中,同構與等價以這樣的方式被區分開:如果兩個值在所有的方面都完全相等,那么它們就是等價的。但如果它們表現不同,卻仍然擁有 1 對 1 的、雙向的映射關系,那么它們就是同構的。

換言之,如果你能夠從 A 映射(轉換)到 B 而后又可以從用反向的映射從 B 走回到 A,那么 A 和 B 就是同構的。

回憶一下第二章的 “數學簡憶”,我們討論了函數的數學定義 —— 輸入與輸出之間的映射。我們指出這在技術上被稱為一種態射。同構是雙射(也就是兩個方向的)的一種特殊情況,它不僅要求映射必須能夠在兩個方向上進行,而且要求這兩種形式在行為上也完全一樣。

把對數字的思考放在一邊,讓我們將同構聯系到代碼上。再次引用我的博客:

如果 JS 中存在同構這樣的東西,它將會是什么樣子?好吧,它可能是這樣:你擁有這樣一套 JS 代碼,它可以被轉換為另一套 JS 代碼,而且(重要的是)如果你想這么做的話,你可將后者轉換回前者。

正如我們早先使用閉包即對象與對象即閉包的例子所主張的,這些表現形式可以從兩個方向轉換。以這種角度來說,它們互相是同構的。

簡而言之,閉包和對象是狀態(以及與之關聯的功能)的同構表現形式。

當一下次你聽到某些人說 “X 與 Y 是同構的”,那么他們的意思是,“X 和 Y 可以在兩個方向上從一者轉換為另一者,并保持相同的行為。”

底層

那么,從我們可以編寫的代碼的角度講,我們可以認為對象是閉包的一種同構表現形式。但我們還可以發現,一個閉包系統實際上可能 —— 而且很可能 —— 用對象來實現!

這樣考慮一下:在下面的代碼中,JS 如何在 outer() 已經運行過后,為了 inner() 保持變量 x 的引用而追蹤它?

function outer() {
    var x = 1;

    return function inner(){
        return x;
    };
}

我們可以想象,outer() 的作用域 —— 所有變量被定義的集合 —— 是用一個帶有屬性的對象實現的。那么,從概念上將,在內存的某處,有這樣一些東西:

scopeOfOuter = {
    x: 1
};

然后對于函數 inner() 來說,在它被創建時,它得到一個稱為 scopeOfInner 的(空的)作用域對象,這個作用域對象通過它的 [[Prototype]] 鏈接到 scopeOfOuter 對象上,有些像這樣:

scopeOfInner = {};
Object.setPrototypeOf( scopeOfInner, scopeOfOuter );

然后,在 inner() 內部,當它引用詞法變量 x 時,實際上更像是這樣:

return scopeOfInner.x;

scopeOfInner 沒有屬性 x,但它 [[Prototype]] 鏈接著擁有屬性 xscopeOfOuter。通過原型委托訪問 scopeOfOuter.x 的結果,于是值 1 被返回了。

以這種方式,我們可以看到為什么 outer() 即使是在運行完成之后它的作用域也會被(通過閉包)保留下來:因為對象 scopeOfInner 鏈接著對象 scopeOfOuter,因此這可以使這個對象和它的屬性完整地保留。

這都是概念上的。我沒說 JS 引擎使用了對象和原型。但這 可以 相似地工作是完全說得通的。

許多語言確實是通過對象實現閉包的。而另一些語言以閉包的形式實現對象。但至于它們如何工作,我們還是讓讀者發揮他們的想象力吧。

分道揚鑣

那么閉包和對象是等價的,對吧?不完全是。我打賭它們要比你在讀這一章之前看起來相似多了,但它們依然有重要的不同之處。

這些不同不應視為弱點或用法上的爭議;那是錯誤的視角。它們應當被視為使其中一者比另一者具有更適于(而且更合理!)某種特定任務的特性或優勢。

結構可變性

從概念上講,一個閉包的結構是不可變的。

換言之,你絕不可能向一個閉包添加或移除狀態。閉包是一種變量被聲明的位置(在編寫/編譯時固定)的性質,而且對任何運行時條件都不敏感 —— 當然,這假定你使用 strict 模式而且/或者沒有使用 eval(..) 這樣的東西作弊!

注意: JS 引擎在技術上可以加工一個閉包來剔除任何在它作用域中的不再被使用的變量,但這對于開發者來說是一個透明的高級優化。無論引擎實際上是否會做這些種類的優化,我想對于開發者來說最安全的做法是假定閉包是以作用域為單位的,而非以變量為單位的。如果你不想讓它存留下來,就不要閉包它!

然而,對象默認是相當可變的。只要這個對象還沒有被凍結(Object.freeze(..)),你就可以自由地向一個對象添加或移除(delete)屬性/下標。

能夠根據程序中運行時的條件來追蹤更多(或更少)的狀態,可能是代碼的一種優勢。

例如,讓我們想象一個游戲中對擊鍵事件的追蹤。幾乎可以肯定,你想要使用一個數組來這樣做:

function trackEvent(evt,keypresses = []) {
    return keypresses.concat( evt );
}

var keypresses = trackEvent( newEvent1 );

keypresses = trackEvent( newEvent2, keypresses );

注意: 你有沒有發現,為什么我使用 concat(..) 而不是直接向 keypressespush(..)?因為在 FP 中,我們總是想將數組視為一種不可變 —— 可以被重新創建并添加新元素 —— 的數據結構,而不是直接被改變的。我們用了一個明確的重新復制將副作用的惡果替換掉了(稍后有更多關于這一點的內容)。

雖然我們沒有改變數組的結構,但如果我們想的話就可以。待會兒會詳細說明這一點。

但數組并不是追蹤不斷增長的 evt 對象 “列表” 的唯一方式。我們可以使用閉包:

function trackEvent(evt,keypresses = () => []) {
    return function newKeypresses() {
        return [ ...keypresses(), evt ];
    };
}

var keypresses = trackEvent( newEvent1 );

keypresses = trackEvent( newEvent2, keypresses );

你發現這里發生了什么嗎?

每當我們向 “列表” 中添加一個新事件,我們就在既存的 keypresses() 函數(閉包) —— 她持有當前的 evt 對象 —— 周圍創建了一個新的閉包。當我們調用 keypresses() 函數時,它將依次調用所有嵌套著的函數,建立起一個所有分別被閉包的 evt 對象的中間數組。同樣,閉包是追蹤所有這些狀態的機制;你看到的數組只是為了從一個函數中返回多個值而出現的一個實現細節。

那么哪一個適合我們的任務?不出意料地,數組的方式可能要合適得多。閉包在結構上的不可變性意味著我們唯一的選擇是在它之上包裹更多的閉包。對象默認就是可擴展的,所我們只要按需要加長數組即可。

順帶一提,雖然我將這種結構上的(不)可變性作為閉包和對象間的一種明顯的不同,但是我們將對象作為一個不可變的值來使用的方式實際上更加像是一種相似性。

為每次數組的遞增創建一個新數組(通過 concat(..))就是講數組視為結構上不可變的,這與閉包是結構上不可變的設計初衷在概念上是平行的。

私有性

在分析閉包 vs 對象時,你可能想到的第一個不同就是閉包通過嵌套的詞法作用域提供了狀態的“私有性”,而對象將所有的東西都作為公共屬性暴露出來。這樣的私有性有一個炫酷的名字:信息隱藏。

考慮一下詞法閉包隱藏:

function outer() {
    var x = 1;

    return function inner(){
        return x;
    };
}

var xHidden = outer();

xHidden();          // 1

現在是公有的相同狀態:

var xPublic = {
    x: 1
};

xPublic.x;          // 1

對于一般的軟件工程原理來說這里有一些明顯的不同 —— 考慮到抽象,帶有公共和私有 API 的模塊模式,等等 —— 但是讓我們將我們的討論限定在 FP 的角度之上;畢竟,這是一本關于函數式編程的書!

可見性

隱藏信息的能力看起來似乎是一種人們渴望的狀態追蹤的特性,但是我相信 FP 程序員們可能會持反對意見。

將狀態作為一個對象上的公共屬性進行管理的一個好處是,枚舉(并迭代!)狀態中所有的數據更簡單。想象你想要處理每一個擊鍵事件(早先的一個例子)來將它存入數據庫,使用這樣一個工具:

function recordKeypress(keypressEvt) {
    // 數據庫工具
    DB.store( "keypress-events", keypressEvt );
}

如果你已經擁有了一個數組 —— 一個帶有數字命名屬性的對象 —— 那么使用一個 JS 內建的數組工具 forEach(..) 完成這個任務就非常直接了當:

keypresses.forEach( recordKeypress );

但是,如果擊鍵的列表被隱藏在閉包中的話,你就不得不在閉包的公共 API 上暴露一個工具,并使它擁有訪問隱藏數據的特權。

例如,我們可以給閉包的 keypresses 示例一個它自己的 forEach,就像數組擁有的內建函數一樣:

function trackEvent(
    evt,
    keypresses = {
        list() { return []; },
        forEach() {}
    }
) {
    return {
        list() {
            return [ ...keypresses.list(), evt ];
        },
        forEach(fn) {
            keypresses.forEach( fn );
            fn( evt );
        }
    };
}

// ..

keypresses.list();      // [ evt, evt, .. ]

keypresses.forEach( recordKeypress );

一對象的狀態數據的可見性使得它使用起來更直接,而閉包隱晦的狀態使我們不得不做更多的工作來處理它。

改變控制

如果詞法變量 x 隱藏在一個閉包中,那么唯一能夠對它進行重新賦值的代碼也一定在這個閉包中;從外部修改 x 是不可能的。

正如我們在第六章中看到的,僅這一點就改善了代碼的可讀性,它減小了讀者為了判定一個已知變量的行為而必須考慮的代碼的表面積。

詞法上重新賦值的局部接近性是我不覺得 const 是一個有用特性的一大原因。作用域(因此閉包也是)一般來說應當都很小,這意味著僅有幾行代碼可能會影響到重新賦值。在上面的 outer() 中,我們可以很快地檢視并看到沒有代碼對 x 進行重新賦值,所以對于一切目的和意圖來說它都是一個常數。

這種保證及大地增強了我們在函數的純粹性上的信心。

另一方面,xPublic.x 是一個公共屬性,程序中任何得到 xPublic 引用的部分都默認地有能力將 xPublic.x 重新賦值為其他的某些值。要考慮的代碼行數可要多多了!

這就是為什么在第六章中,我們看到 Object.freeze(..) 以一種簡單粗暴的方式將一個對象的所有屬性都設置為只讀(writable: false),這樣一來它們就不會不可預知地被重新賦值了。

不幸的是,Object.freeze(..) 會凍結所有屬性而且不可逆轉。

使用閉包,你讓一些代碼擁有改變的特權,而程序的其余部分依然受限。但你凍結一個對象時,程序中沒有任何部分能夠進行重新賦值。另外,一旦一個對象被凍結,它就不能再被解凍,于是它的屬性會在程序運行期間一直保持只讀狀態。

在那些我想允許重新賦值但限制它影響范圍的地方,閉包就是一種比對象更加方便而且靈活的方式。在我想要禁止重新賦值的地方,一個凍結的對象要比在我的函數中到處重復 const 聲明方便多了。

許多 FP 程序員對重新賦值采取了強硬的立場:它就不應當被使用。他們傾向于使用 const 將所有閉包變量都成為只讀,而且他們使用 Object.freeze(..) 或者完全不可變的數據結構來防止屬性被重新賦值。另外,他們還會盡可能地減少被明確聲明/追蹤的屬性的數量,使用值的傳送 —— 函數鏈,將 return 值作為參數傳遞,等等 —— 來取代值的臨時存儲。

這本書講的是 JavaScript 的“輕量函數式”編程,而這就是我與 FP 的核心人群意見相左的情況之一。

我認為變量的重新賦值可以十分有用,而且,如果使用得當,它的明確性相當易讀。而且從經驗上講,當你在調試中插入 debugger 或斷點,或者一個監視表達式的時候,將會更容易。

狀態克隆

正如我們在第六章中學到的,防止副作用損害我們代碼的可預見性的最佳方法之一,就是確保我們將所有狀態值都視為不可變的,而不管它們實際上是否真的是不可變(被凍結)的。

如果你沒有在使用一個專門為此建造的、提供了精巧的不可變數據結構的庫,那么最簡單的方法也夠了:在每次改變你的對象/數組之前復制它們。

數組很容易淺克隆:使用 slice() 方法就行:

var a = [ 1, 2, 3 ];

var b = a.slice();
b.push( 4 );

a;          // [1,2,3]
b;          // [1,2,3,4]

對象也可以相對容易地進行淺克隆:

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

// 在 ES2017+ 中,使用對象擴散操作:
var p = { ...o };
p.y = 3;

// 在 ES2015+ 中:
var p = Object.assign( {}, o );
p.y = 3;

如果在一個對象/數組中的值本身就是非基本類型(對象/數組),那么為了進行深度克隆,你就必須手動遍歷并克隆每一個被嵌套的對象。否則,你會得到那些字對象的共享引用的拷貝,而這很可能會在你的程序邏輯中造成災難。

你有沒有注意到,這種克隆之所以可能,僅僅是由于所有這些狀態值都可見,并因此可以很容易拷貝?那么包裝在一個閉包中的一組狀態呢?你如何拷貝那些狀態?

那可麻煩多了。事實上,你不得不做一些與我們之前自定義的 forEach API 方法相似的事情:在閉包的每一層內部都提供一個有權抽取/拷貝隱藏值的函數,一路創建新的等價閉包。

即便這在理論上是可能的 —— 給讀者的另一個練習! —— 但與你對任何真實的程序所作出的可能的調整相比,它也遠不切實際。

當對象用來表示我們想要克隆的狀態時,它具有明顯的好處。

性能

一個對象可能優于閉包的原因,從實現的角度講,是在 JavaScript 中對象在內存甚至計算的意義上更輕量。

但將之作為一個一般性的結論要小心:在你無視閉包并轉向基于對象的狀態追蹤時可能會得到一些性能的增益,但在你能對對象所做的事情中,有相當一部分可以抹除這些增益。

讓我們使用兩種實現考慮同一個場景。首先,閉包風格的實現:

function StudentRecord(name,major,gpa) {
    return function printStudent(){
        return `${name}, Major: ${major}, GPA: ${gpa.toFixed(1)}`;
    };
}

var student = StudentRecord( "Kyle Simpson", "kyle@some.tld", "CS", 4 );

// 稍后

student();
// Kyle Simpson, Major: CS, GPA: 4.0

內部函數 printStudent() 閉包著三個變量 namemajor、和 gpa。無論我們在何處傳送一個指向這個函數的引用,它都會維護這個狀態 —— 在這個例子中我們稱之為 student()

現在輪到對象(和 this)的方式了:

function StudentRecord(){
    return `${this.name}, Major: ${this.major}, GPA: ${this.gpa.toFixed(1)}`;
}

var student = StudentRecord.bind( {
    name: "Kyle Simpson",
    major: "CS",
    gpa: 4
} );

// 稍后

student();
// Kyle Simpson, Major: CS, GPA: 4.0

student() 函數 —— 技術上成為一個“被綁定函數” —— 擁有一個硬綁定的 this 引用,它指向我們傳入的對象字面量,這樣稍后對 student() 的調用將會使用這個對象作為 this,因此可以訪問它所封裝的狀態。

這兩種實現都有相同的結果:一個保留著狀態的函數。那么性能呢?會有什么不同?

注意: 準確地、可操作地判斷一段 JS 代碼的性能是一件非常棘手的事情。我們不會在此深入所有的細節,但我強烈建議你閱讀“你不懂 JS:異步與性能”一書,特別是第六章“基準分析與調優”,來了解更多細節。

如果你在編寫一個庫,它創建一個帶有函數的狀態 —— 要么是一個代碼段中對 StudentRecord(..) 的調用,要么是第二個代碼段中對 StudentRecord.bind(..) 的調用 —— 你最關心的很可能是它們兩個如何工作。檢視它們的代碼,我們可以發現前者不得不每次創建一個新的函數表達式。而第二個使用了 bind(..),這由于它的隱晦而不那么明顯。

考慮 bind(..) 在底層如何工作的一種方式是,它在函數之上創建了一個閉包,就像這樣:

function bind(orinFn,thisObj) {
    return function boundFn(...args) {
        return origFn.apply( thisObj, args );
    };
}

var student = bind( StudentRecord, { name: "Kyle.." } );

以這種方式,看起來我們這種場景的兩種實現都創建了閉包,因此它們的性能很可能是相同的。

然而,內建的 bind(..) 工具不必真的創建閉包來完成這個任務。它只是創建一個函數并手動地將它內部的 this 設置為指定的對象。這潛在地是一種比我們自己做的閉包更高效的操作。

我們在這里討論的這種性能提升在個別的操作中的影響微乎其微。但如果你的庫的關鍵路徑在成百上千次,或更多地重復這件事,那么這種提升的效果就會很快累加起來。許多的庫 —— 例如 Bluebird 就是一例 —— 都正是由于這個原因,最終通過移除閉包而使用對象來進行了優化。

在庫之外的用例當中,帶有自己函數的狀態通常只在一個應用程序的關鍵路徑上相對少地出現幾次。對比之下,函數 + 狀態的用法 —— 在兩個代碼段中對 student() 的調用 —— 通常更常見。

如果這正是你代碼中的某些已知情況,那么你可能應當更多地關心后者的性能與前者的對比。

長久以來被綁定的函數的性能通常都很爛,但是最近它已經被 JS 引擎進行了相當高度的優化。如果你在幾年前曾經對這些種類的函數進行過基準分析,那么你在最新的引擎上重復相同的測試的話,就完全有可能得到不同的結果。

如今,一個被綁定函數性能最差也能與它閉包函數的等價物相同。所以這是另一個首選對象而非閉包的理由。

我想要重申:這些性能上的觀測不是絕對的,而且對于一個已知場景判定什么對它最合適是非常復雜的。不要只是隨便地使用一些道聽途說的,或者你曾將在以前的項目中見過的東西。要仔細地檢查對象或閉包是否能恰當、高效地完成你當前的任務。

總結

這一章的真理是無法付諸筆頭的。你必須閱讀這一章來找出它的真理。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容