翻譯連載 | JavaScript輕量級函數語言程式設計-第7章: 閉包vs物件 |《你不知道的JS》姊妹篇

iKcamp發表於2017-09-11

關於譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的樑柱;分享,是 CSS 裡最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。經過捶打磨練,成就了本書的中文版。本書包含了函數語言程式設計之精髓,希望可以幫助大家在學習函數語言程式設計的道路上走的更順暢。比心。

譯者團隊(排名不分先後):阿希bluekenbrucechamcfanlifedailkyoko-dfl3velilinsLittlePineappleMatildaJin冬青pobusamaCherry蘿蔔vavd317vivaxy萌萌zhouyao

第 7 章: 閉包 vs 物件

數年前,Anton van Straaten 創造了一個非常有名且被常常引用的 禪理 來舉例和證實一個閉包和物件之間重要的關係。

德高望重的大師 Qc Na 曾經和他的學生 Anton 一起散步。Anton 希望引導大師到一個討論裡,說到:大師,我曾聽說物件是一個非常好的東西,是這樣麼?Qc Na 同情地看著他的學生回答到, “愚笨的弟子,物件只不過是可憐人的閉包”

被批評後,Anton 離開他的導師並回到了自己的住處,致力於學習閉包。他認真的閱讀整個“匿名函式:終極……”系列論文和它的姐妹篇,並且實踐了一個基於閉包系統的小的 Scheme 解析器。他學了很多,盼望展現給他導師他的進步。

當他下一次與 Qc Na 一同散步時,Anton 試著提醒他的導師,說到 “導師,我已經勤奮地學習了這件事,我現在明白了物件真的是可憐人的閉包。” ,Qc Na 用棍子戳了戳 Anton 迴應到,“你什麼時候才能學會,閉包才是可憐人的物件”。在那一刻, Anton 明白了什麼。

Anton van Straaten 6/4/2003

people.csail.mit.edu/gregs/ll1-d…

原帖儘管簡短,卻有更多關於起源和動機的內容,我強烈推薦為了理解本章去閱讀原帖來調整你的觀念。

我觀察到很多人讀完這個會對其中的聰明智慧傻笑,卻繼續不改變他們的想法。但是,這個禪理(來自 Bhuddist Zen 觀點)促使讀者進入其中對立真相的辯駁中。所以,返回並且再讀一遍。

到底是哪個?是閉包是可憐的物件,還是物件是可憐的閉包?或都不是?或都是?或者這只是為了說明閉包和物件在某些方面是相同的方式?

還有它們中哪個與函數語言程式設計相關?拉一把椅子過來並且仔細考慮一會兒。如果你願意,這一章將是一個精彩的迂迴之路,一個遠足。

達成共識

先確定一點,當我們談及閉包和物件我們都達成了共識。我們顯然是在 JavaScript 如何處理這兩種機制的上下文中進行討論的,並且特指的是討論簡單函式閉包(見第 2 章的“保持作用域”)和簡單物件(鍵值對的集合)。

一個簡單的函式閉包:

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 不提供返回多個資料卻不包裝在一個物件中的能力。這並不是嚴格意義上的一個違反我們物件類似閉包的說明的任務,因為這只是一個暴露/運輸具體值的實現,狀態追蹤本身仍然是基於物件的。使用 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!複製程式碼

我們仍然通過 happyBrithday() 函式來表達對狀態資料的封裝,但是用物件代替了閉包。同時我們沒有顯式給函式傳遞一個物件(如同先前的例子);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複製程式碼

儘管這些程式看起來感覺有點反人類,但它們實際上只是相同程式的不同實現。

(不)可變

許多人最初都認為閉包和物件行為的差別源於可變性;閉包會阻止來自外部的變化而物件則不然。但是,結果是,這兩種形式都有典型的可變行為。

正如第 6 章討論的,這是因為我們關心的是的可變性,值可變是值本身的特性,不在於在哪裡或者如何被賦值的。

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 圈經常被提出,它通常被用來指程式碼可以同時被服務端和瀏覽器端使用/分享。我不久以前寫了一篇博文說明這種對同構這個詞的使用是錯誤的,隱藏了它實際上確切和重要的意思。

這裡我是博文部分的節選:

同構的意思是什麼?當然,我們可以用數學詞彙,社會學或者生物學討論它。同構最普遍的概念是你有兩個類似但是不相同的結構。

在這些所有的慣用法中,同構和相等的區別在這裡:如果兩個值在各方面完全一致那麼它們相等,但是如果它們表現不一致卻仍有一對一或者雙向對映的關係那麼它們是同構。

換而言之,兩件事物A和B如果你能夠對映(轉化)A 到 B 並且能夠通過反向對映回到A那麼它們就是同構。

回想第 2 章的簡單數學回顧,我們討論了函式的數學定義是一個輸入和輸出之間的對映。我們指出這在學術上稱為態射。同構是雙映(雙向)態射的特殊案例,它需要對映不僅僅必須可以從任意一邊完成,而且在任一方式下反應完全一致。

不去思考這些關於數字的問題,讓我們將同構關聯到程式碼。再一次引用我的博文:

如果 JS 有同構的話是怎麼樣的?它可能是一集合的 JS 程式碼轉化為了另一集合的 JS 程式碼,並且(重要的是)如果你原意的話,你可以把轉化後的程式碼轉為之前的。

正如我們之前通過閉包如同物件和物件如同閉包為例聲稱的一樣,它們的表達可以任意替換。就這一點來說,它們互為同構。

簡而言之,閉包和物件是狀態的同構表示(及其相關功能)。

下次你聽到誰說 “X 與 Y 是同構的”,他們的意思是,“X 和 Y 可以從兩者中的任意一方轉化到另一方,並且無論怎樣都保持了相同的特性。”

內部結構

所以,我們可以從我們寫的程式碼角度想象物件是閉包的一種同構展示。但我們也可以觀察到閉包系統可以被實現,並且很可能是用物件實現的!

這樣想一下:在如下的程式碼中, 在 outer() 已經執行後,JS 如何為了 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]] 連線到擁有 x 屬性的 scopeOfOuter時。通過原型委託訪問 scopeOfOuter.x 返回值是 1

這樣,我們可以近似認為為什麼 outer() 的作用域甚至在當它執行完都被保留(通過閉包),這是因為 scopeOfInner 物件連線到 scopeOfOuter 物件,因此,使這個物件和它的屬性完整的被儲存下來。

現在,這都只是概念。我沒有從字面上說 JS 引擎使用物件和原型。但它完全有道理,它可以同樣地工作。

許多語言實際上通過物件實現了閉包。另一些語言用閉包的概念實現了物件。但我們讓讀者使用他們的想象力思考這是如何工作的。

同根異枝

所以閉包和物件是等價的,對嗎?不完全是,我打賭它們比你在讀本章前想的更加相似,但是它們仍有重要的區別點。

這些區別點不應當被視作缺點或者不利於使用的論點;這是錯誤的觀點。對於給定的任務,它們應該被視為使一個或另一個更適合(和可讀)的特點和優勢。

結構可變性

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

換而言之,你永遠不能從閉包新增或移除狀態。閉包是一個表示物件在哪裡宣告的特性(被固定在編寫/編譯時間),並且不受任何條件的影響 —— 當然假設你使用嚴格模式並且/或者沒有使用作弊手段例如 eval(..)

注意: JS 引擎可以從技術上過濾一個物件來清除其作用域中不再被使用的變數,但是這是一個對於開發者透明的高階的優化。無論引擎是否實際做了這類優化,我認為對於開發者來說假設閉包是作用域優先而不是變數優先是最安全的。如果你不想保留它,就不要封閉它(在閉包裡)!

但是,物件預設是完全可變的,你可以自由的新增或者移除(delete)一個物件的屬性/索引,只要物件沒有被凍結(Object.freeze(..)

這或許是程式碼可以根據程式中執行時條件追蹤更多(或更少)狀態的優勢。

舉個例子,讓我們思考追蹤遊戲中的按鍵事件。幾乎可以肯定,你會考慮使用一個陣列來做這件事:

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

var keypresses = trackEvent( newEvent1 );

keypresses = trackEvent( newEvent2, keypresses );複製程式碼

注意:你能否認出為什麼我使用 concat(..) 而不是直接對 keypresses 陣列使用 push(..) 操作?因為在函數語言程式設計中,我們通常希望對待陣列如同不可變資料結構,可以被建立和新增,但不能直接改變。我們剔除了顯式重新賦值帶來的邪惡副作用(稍後再作說明)。

儘管我們不在改變陣列的結構,但當我們希望時我們也可以。稍後詳細介紹。

陣列不是記錄這個 evt 物件的增長“列表”的僅有的方式。。我們可以使用閉包:

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

var keypresses = trackEvent( newEvent1 );

keypresses = trackEvent( newEvent2, keypresses );複製程式碼

你看出這裡發生了什麼嗎?

每次我們新增一個新的事件到這個“列表”,我們建立了一個包裝了現有 keypresses() 方法(閉包)的新閉包,這個新閉包捕獲了當前的 evt 。當我們呼叫 keypresses() 函式,它將成功地呼叫所有的內部方法,並建立一個包含所有獨立封裝的 evt 物件的中間陣列。再次說明,閉包是一個追蹤所有狀態的機制;這個你看到的陣列只是一個對於需要一個方法來返回函式中多個值的具體實現。

所以哪一個更適合我們的任務?毫無意外,陣列方法可能更合適一些。閉包的不可變結構意味著我們的唯一選項是封裝更多的閉包在裡面。物件預設是可擴充套件的,所以我們需要增長這個陣列就足夠了。

順便一提,儘管我們表現出結構不可變或可變是一個閉包和物件之間的明顯區別,然而我們使用物件作為一個不可變資料的方法實際上使之更相似而非不同。

陣列每次新增就創造一個新陣列(通過 concat(..))就是把陣列對待為結構不可變,這個概念上對等於通過適當的設計使閉包結構上不可變。

私有

當對比分析閉包和物件時可能你思考的第一個區分點就是閉包通過詞法作用域提供“私有”狀態,而物件將一切做為公共屬性暴露。這種私有有一個精緻的名字:資訊隱藏。

考慮詞法閉包隱藏:

function outer() {
    var x = 1;

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

var xHidden = outer();

xHidden();            // 1複製程式碼

現在同樣的狀態公開:

var xPublic = {
    x: 1
};

xPublic.x;            // 1複製程式碼

這裡有一些在常規的軟體工程原理方面明顯的區別 —— 考慮下抽象,這種模組模式有著公有和私有 API 等等。但是讓我們試著把我們的討論侷限於函數語言程式設計的觀點,畢竟,這是一本關於函數語言程式設計的書!

可見性

似乎隱藏資訊的能力是一種理想狀態的跟蹤特性,但是我認為函數語言程式設計者可能持反對觀點。

在一個物件中管理狀態作為公開屬性的一個優點是這使你狀態中的所有資料更容易列舉(迭代)。思考下你想訪問每一個按鍵事件(從之前的那個例子)並且儲存到一個資料庫,使用一個這樣的工具:

function recordKeypress(keypressEvt) {
    // 資料庫實用程式
    DB.store( "keypress-events", keypressEvt );
}複製程式碼

If you already have an array -- just an object with public numerically-named properties -- this is very straightforward using a built-in JS array utility forEach(..):

如果你已經有一個陣列,正好是一個擁有公開的用數字命名屬性的物件 —— 非常直接地使用 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 );複製程式碼

物件狀態資料的可見性讓我們能更直接地使用它,而閉包遮掩狀態讓我們更艱難地處理它。

Change Control

變更控制

如果詞法變數被隱藏在一個閉包中,只有閉包內部的程式碼才能自由的重新賦值,在外部修改 x 是不可能的。

正如我們在第 6 章看到的,提升程式碼可讀性的唯一真相就是減少表面掩蓋,讀者必須可以預見到每一個給定變數的行為。

詞法(作用域)在重新賦值上的區域性就近原則是為什麼我不認為 const 是一個有幫助的特性的一個重要原因。作用域(例如閉包)通常應該儘可能小,這意味著重新賦值只會影響少許程式碼。在上面的 outer()中,我們可以快速地檢查到沒有一行程式碼重設了 x,至此(x 的)所有意圖和目的表現地像一個常量。

這類保證對於我們對函式純淨的信任是一個強有力的貢獻,例如。

換而言之,xPublic.x 是一個公開屬性,程式的任何部分都能引用 xPublic ,預設有重設 xPublic.x 到別的值的能力。這會讓很多行程式碼需要被考慮。

這是為什麼在第 6 章, 我們視 Object.freeze(..) 為使所有的物件屬性只讀(writable: false)的一個快速而凌亂的方式,讓它們不能被不可預測的重設。

不幸的是,Object.freeze(..) 是極端且不可逆的。

使有了閉包,你就有了一些可以更改程式碼的許可權,而剩餘的程式是受限的。當我們凍結一個物件,程式碼中沒有任何部分可以被重設。此外,一旦一個物件被凍結,它不能被解凍,所以所有屬性在程式執行期間都保持只讀。

在我想允許重新賦值但是在表層限制的地方,閉包比起物件更方便和靈活。在我不想重新賦值的地方,一個凍結的物件比起重複 const 宣告在我所有的函式中更方便一些。

許多函數語言程式設計者在重新賦值上採取了一個強硬的立場:它不應該被使用。他們傾向使用 const 來使用所有閉包變數只讀,並且他們使用 Ojbect.freeze(..) 或者完全不可變資料結構來防止屬性被重新賦值。此外,他們儘量在每個可能的地方減少顯式地宣告的/追蹤的變數,更傾向於值傳遞 —— 函式鏈,作為引數被傳遞的 return 值,等等 —— 替代中間值儲存。

這本書是關於 JavaScript 中的輕量級函數語言程式設計,這是一個我與核心函數語言程式設計群體有分歧的情況。

我認為變數重新賦值當被合理的使用時是相當有用的,它的明確性具有相當有可讀性。從經驗來看,在插入 debugger 或斷點或跟蹤表表示式時,除錯工作要容易得多。

狀態拷貝

正如我們在第 6 章學習的,防止副作用侵蝕程式碼可預測性的最好方法之一是確保我們將所有狀態值視為不可變的,無論他們是否真的可變(凍結)與否。

如果你沒有使用特別定製的庫來提供複雜的不可變資料結構,最簡單滿足要求的方法:在每次變化前複製你的物件或者陣列。

陣列淺拷貝很容易:只要使用 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複製程式碼

內部函式 printStudeng() 封裝了三個變數:namemajorgpa。它維護這個狀態無論我們是否傳遞引用給這個函式,在這個例子我們稱它為 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:非同步和效能》這本書,特別是第 6 章“效能測試和調優”,來了解細節。

如果你寫過一個庫來創造持有配對狀態的函式,要麼在第一個片段中呼叫 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 引擎高度優化。如果你在幾年前檢測過這些變化,很可能跟你現在用最近的引擎重複測試的結果完全不一致。

邊界函式現在看起來至少跟同樣的封裝函式表現的一樣好。所以這是另一個支援物件比閉包好的點。

我只想重申:效能觀察結果不是絕對的,在一個給定場景下決定什麼是最好的是非常複雜的。不要隨意使用你從別人那裡聽到的或者是你從之前一些專案中看到的。小心的決定物件還是閉包更適合這個任務。

總結

本章的真理無法被直述。必須閱讀本章來尋找它的真理。

【上一章】翻譯連載 | JavaScript輕量級函數語言程式設計-第6章:值的不可變性 |《你不知道的JS》姊妹篇

【下一章】翻譯連載 | JavaScript輕量級函數語言程式設計-第8章:列表操作 |《你不知道的JS》姊妹篇

iKcamp原創新書《移動Web前端高效開發實戰》已在亞馬遜、京東、噹噹開售。

簽名贈書 | 滬江Web前端技術團隊撰寫的《移動Web前端高效開發實戰》免費大放送
>> 滬江Web前端上海團隊招聘【Web前端架構師】,有意者簡歷至:zhouyao@hujiang.com <<

相關文章