翻譯 | let 的正確開啟方式

,發表於2017-10-09

原文連結:https://davidwalsh.name/for-and-against-let

在這篇文章中,我將要梳理一下 JavaScript ES6 中獲得人們鼓吹(或者還有反對?)的新特性: let 關鍵字。let 引入了一個先前並不廣為 JS 開發者所接受的作用域實現形式——塊作用域。

函式作用域

讓我們簡單回顧一下函式作用域的知識——如果你想要了解更多,請移步我寫的 “You Don't Know JS” 系列中的 《You Don't Know JS: Scope & Closures》。

看一下下面這段程式碼:

foo();    // 42

function foo() {
    var bar = 2;
    if (bar > 1 || bam) {
        var baz = bar * 10;
    }

    var bam = (baz * 2) + 2;

    console.log( bam );
}

你可能聽說過“提升(hoisting)”這個詞,它描述了 JS 程式碼中使用 var 關鍵字宣告的變數在作用域內是如何被處理的。這並非嚴格意義上的技術表述,而更多是一種比喻。但是就我們這裡討論的問題來說,已經夠用了。上述程式碼片段的處理方式與下面的程式碼類似:

function foo() {
    var bar, baz, bam;

    bar = 2;

    if (bar > 1 || bam) {
        baz = bar * 10;
    }

    bam = (baz * 2) + 2;

    console.log( bam );
}

foo();  // 42

如上所示,函式 foo() 的宣告被移動(或者稱為“提升(hoisted)”)到了作用域的頂部,同樣 barbazbam 等變數也被提升到了它們的作用域頂部。

因為 JS 中的變數總是表現出提升行為,許多開發者傾向於將變數宣告在(函式)作用域的頂部,來讓程式碼風格與其實際行為一致。這樣做沒有任何毛病。

但是你是否見過前後風格不一致的程式碼嗎:

for (var i=0; i<10; i++) {
    // ..
}

類似於上面的程式碼非常常見。另外一個常見的例子是:

var a, b;

// other code

// later, swap `a` and `b`
if (a && b) {
    var tmp = a;
    a = b;
    b = tmp;
}

if 語句塊裡的 var tmp 違背了所謂的“將所有宣告語句移動到頂部”的編碼風格。前面程式碼片段裡 for 迴圈裡的 var i 也是這樣。

兩個例子中,變數都會被“提升”,那麼為什麼開發者依然把這些變數宣告語句寫到作用域很裡面而不是頂部,尤其是在其它變數已經被有意移到頂部的情況下?

塊作用域

最主要的原因是開發者想將部分變數限制在作用域的某個更小的範圍內。換句話說,開發者將 var i 宣告在 for 迴圈裡面是為了在形式上告知他人——包括未來的自己!——變數 i 只應該在 for 迴圈中使用。 if 語句中的 var tmp 也是如此。tmp 是一個臨時變數,只在 if 語句執行期間存在。

通過書寫形式,我們宣稱:“在且僅在這裡使用這個變數”。

最小許可權原則

在軟體開發領域,有一個“最小許可權(暴露)原則”,該原則指出,恰當的軟體設計會隱藏細節,除非有暴露的必要。在模組設計中,我們恰恰是這麼做的——將私有變數與函式隱藏在閉包中,僅僅將一小部分函式或屬性暴露出來作為公共 API 。

塊作用域就是這種思想的延伸。我們的建議是,合適的軟體設計應該是,變數的使用位置與其宣告位置越近越好,並且儘可能地宣告在作用域的內部。

憑直覺你已經很明瞭這個原則。你知道我們不會把所有的變數都宣告為全域性變數,儘管在某些情況下這樣做會更簡單一些。為什麼呢?因為這是一種糟糕的設計。這種設計會導致意外衝突,衝突會引發 bug

因此你會把變數控制在使用它們的函式中。當需要在函式中嵌入其他函式時,在必要且合適的情況下,你會把變數嵌入這些內層函式中。如此種種。

塊作用域僅僅是說,我想把一個 { .. } 塊當作一個作用域,而不需要使用一個新的函式來封裝出一個來。

當你想要表達“如果我只需要在 for 迴圈中使用變數 i,那麼我就將其宣告在 for 迴圈的定義中”的時候,你其實就是遵循了這樣一個原則。

JS 中塊作用域的缺失

不幸的是,在歷史上,JS 從未提出一種切實可行的方式來強化這種作用域模式,需要人們自覺遵守來維護這種風格。當然了,缺乏強制性意味著這些東西已然被打破了,有時候會有效,而另外一些時候則會導致 bug 。

其他程式語言(如 Java 、C++)確實擁有塊作用域,你可以宣告一個變數,使其屬於一個特定的塊而不是外圍的作用域或函式。從其他語言轉過來的開發者深切地知道在進行某些變數的宣告的時候使用塊作用域的好處。

他們常常覺察到,由於缺少一種使用 { .. } 而不是重量級的行內函數定義(又稱為IIFE,立即執行函式表示式)建立內聯作用域的方法,JS 缺乏一定的表現力。

他們說的一點沒錯。 一直以來,JavaScript 就缺少塊作用域能力。更確切的說,我們缺少一種語法層面的支援來強化我們已經習慣的表達風格。

這還不是全部

即便是在支援塊作用域的語言中,並非所有的變數都會最終處於塊作用域中。

從該語言中隨便拿出一個書寫良好的程式碼庫,你肯定會發現一些變數宣告處於函式層面,而另外一些處於更小範圍的塊作用域內。為什麼呢?

因為這是編寫軟體的一個相當自然的要求。有時候我們需要一個在函式各處都可以使用的變數,有時候卻需要一個僅在有限範圍內使用的變數。這當然不是一個二選一的命題。

證據何在?函式引數。它們是在整個函式作用域內都可以訪問的變數。據我所知,沒有人會真的去鼓吹函式不應該擁有明確命名的引數,而原因是它們“不存在於某個塊作用域中”。因為絕大多數開發者都會同意我在這裡的論斷:

塊作用域與函式作用域都是合法的,且都有用途,並非不能共存。下面的程式碼就顯得很傻:

function foo() {    // <-- Look ma, no named parameters!
    // ..

    {
        var x = arguments[0];
        var y = arguments[1];

        // do something with `x` and `y`
    }

    // ..
}

幾乎可以肯定的是,你不會僅僅因為堅信在程式碼結構中“只能使用塊作用域”而寫出這樣的程式碼,就像是雖然你堅信“只能使用全域性作用域”而不會將 xy 宣告為全域性變數一樣。

你不會那麼做的,你只會將引數命名為 xy,然後在函式中任何需要的地方使用。

其他你想要在整個函式作用域使用的變數也是如此。你要做的很可能是將一個使用 var 修飾的變數放置在函式頂部,然後該幹嘛幹嘛。

let 出場

既然你已經知道塊作用域的重要性了,更重要的是認識到它是對函式或全域性作用域的補充而非替代,我們很高興地告訴大家 ES6 規範終於引入了一個直接支援塊作用域的機制,那就是使用 let 關鍵字。

從它的最基本的形式來看,letvar 的兄弟。但是使用 let 宣告的變數會被限定在它們被宣告的塊中,而不是像 var 一樣被“提升”到將其包裹的函式的作用域:

function foo() {
    a = 1;                  // careful, `a` has been hoisted!

    if (a) {
        var a;              // hoisted to function scope!
        let b = a + 2;      // `b` block-scoped to `if` block!

        console.log( b );   // 3
    }

    console.log( a );       // 1
    console.log( b );       // ReferenceError: `b` is not defined
}

耶!let 不僅僅表達了而且實現了塊作用域。

大體來說,在任何塊出現的地方(像是 {..}),let關鍵字都可以建立一個作用域範圍為其所在塊的變數宣告。因此,在任何地方,當你需要建立有限範圍作用域的時候,使用 let 即可。

注意:嗯,在 ES6 之前,let 並不存在。但是存在相當數量的從 ES6 到 ES5 的轉化器——例如 traceur6to5 以及 Continuum —— 這些轉換器會將 ES6 中使用了 let 的程式碼(以及其他大多數 ES6 新特性!)轉換為可以在所有相關瀏覽器中執行的 ES5(有時是 ES3 程式碼)。鑑於以後 JS 會以特性為單位進行快速迭代演進,將轉換器納入標準構建流程會成為 JS 開發的“新常態”。這意味著你應在從現在就該開始使用 JS 中的最新、最好的特性,讓工具來處理與(舊)瀏覽器相容的問題。等待數年後直到先前的瀏覽器退出歷史舞臺才去使用新特性的日子一去不復返了。

隱式還是顯式

let 是一種隱式作用域建立機制,很容易讓人迷失在這種興奮中。它劫持了一個現存的塊,在其原本語義基礎上新增了作為一個作用域的語義。

if (a) {
    let b = a + 2;
}

在上面的程式碼片段中,塊是一個 if 塊,但是僅僅因為 let 關鍵字出現於其中就使得它成為一個作用域。否則,如果裡面不包含 let 的話,{..} 塊就不是一個作用域。

這一點為什麼這麼重要呢?

一般來說,開發者更喜歡顯式機制(explicit mechanisms)而不是隱式機制(implicit mechanisms),因為通常這樣會使程式碼更加易讀、易懂和易維護。

例如在 JS 型別轉化領域,很多開發者更喜歡顯式轉化(explicit coercion)而不是隱式轉化(implicit coercion)

var a = "21";

var b = a * 2;          // <-- implicit coercion -- yuck :(
b;                      // 42

var c = Number(a) * 2;  // <-- explicit coercion -- much better :)
c;                      // 42

注意:更多關於隱式/顯式型別轉化的話題請參考我寫的《You Don't Know JS: Types & Grammar》,尤其是第四章 Coercion

當在示例程式碼中,塊中沒有幾行程式碼的時候,很容易就可以看出這個塊是否成為了一個作用域:

if (a) {    // block is obviously scoped
    let b;
}

但是真實場景是,一個單一的塊可能會有幾十行乃至上百行程式碼。先不管這樣的程式碼塊是否應該存在——實際上確實存在——假如 let 深藏在這些程式碼之中,判斷一個塊是否同時是一個作用域會變得異常困難

反過來說,當你發現在程式碼中存在一個 let 宣告的時候,你需要知道它屬於哪個塊,與使用目光向上掃描直到找到最近的 function 關鍵字不同,現在你需要仔細查詢最近的左大括號 { 。相比之下難度會更大。不一定困難很多,但肯定會更困難。

這更像是一種精神稅。

潛在的坑

但是這還不僅僅是一種精神稅。儘管使用 var 宣告的變數會被“提升”到將其包裹的函式的頂部,使用 let 宣告的變數卻沒有被“提升”到塊頂部的待遇。如果你不小心在其宣告之前使用了擁有塊作用域的變數,那麼會報錯:

if (a) {
    b = a + 2;      // ReferenceError: `b` is not defined

    // more code

    let b = ..

    // more code
}

在技術上, {let b 之間的這段“時間”被稱作“暫時性死區” (TDZ)——這個詞可不是我杜撰出來的——變數在其暫時性死區中是不能使用的。從技術的角度看,每一個變數都有自己的暫時性死區,有些會重疊,再次重申,TDZ 始於塊的開端,結束於正式的變數宣告及初始化的位置。

由於我們先前把宣告語句 let b = .. 安排在了塊的腹地,後期又想在更早的地方使用這個變數,那麼就會遇到坑——一種自廢武功的設計——我們忘記查詢 let 關鍵字,把它移動到變數 b 首次使用之前的地方。

開發者十有八九會被 TDZ "bug" 咬到,最後痛定思動,每次都把 let 宣告放置到塊的最頂端。

let 關鍵字會隱式生成一個作用域的行為會導致另外一個坑:重構坑。

看下面這段程式碼:

if (a) {
    // more code

    let b = 10;

    // more code

    let c = 1000;

    // more code

    if (b > 3) {
        // more code

        console.log( b + c );

        // more code
    }

    // more code
}

假如說不久之後,出於某種原因,你認為程式碼中的 if (b > 3) 部分需要移到 if (a) { .. 這個塊的外面。與此同時,你注意到 let b = .. 這個宣告語句需要與之一起移動。

但是當時你並沒有意識到這個程式碼塊也依賴變數 c——因此它隱藏的比較深——這樣的話,c 的作用域是程式碼塊 if (a) { ..。一旦你將 if (b > 3) { .. 移出來,程式碼就不能執行了,你不得不找到 let c = .. 語句,驗證它是否可以移出來,諸如此類。

我還可以舉出很多其他情景——當然是假設的,但是也深受我或他人寫的大量實戰程式碼的影響——不過我認為你應該已經理解了我要表達的意思。我們太容易讓自己的陷入這些危險坑了。

如果變數 bc 有著更加明確的作用域,確認需要進行什麼樣的重構或許會更容易一些,而不是焦頭爛額地將其找出來。

顯式為王?

實際上,使用顯式方式寫程式碼如此重要以至於我能想到的唯一例外是樂於使用 for (let i=0; .. ) ..。這種寫法到底屬於顯式還是隱式寫法尚且存在爭議。我認為其中顯式成分多一點。不過比起 { let i; for (i=0; ..) .. } 這樣的寫法還是要差一些。

儘管如此,還是有充分的理由說明 for (let i=0; ..) .. 這樣的寫法更好一些。它利用了作用域閉包的特性,非常好用,也非常強大。

{ let i;
    for (i=1; i<=5; i++) {
        setTimeout(function(){
            console.log("i:",i);
        },i*1000);
    }
}

上述程式碼與它的近親 var 一樣,是無法輸出正確結果的——它會輸出 5 次 i: 6 。但是下面的程式碼是可以輸出正確結果的:

for (let i=1; i<=5; i++) {
    setTimeout(function(){
        console.log("i:",i);
    },i*1000);
}

它會輸出 i: 1, i: 2, i: 3,等等。原因何在?

因為在 ES6 規範中明確說明了在 for 迴圈頭部,let i 中的 i 的作用域不僅僅位於 for 迴圈,而且位於** for 迴圈的每一次迭代**中。換句話說,它的行為與下面的程式碼類似:

{ let k;
    for (k=1; k<=5; k++) {
        let i = k; // <-- new `i` for each iteration!
        setTimeout(function(){
            console.log("i:",i);
        },i*1000);
    }
}

太棒了,它解決了開發者們經常遇到的關於閉包與迴圈的難題!

let 的作用域更明確一些

或許到現在我已經讓你確信顯式作用域會更好一些。但是還有一個缺陷,那就是它沒有強制要求開發者遵循 { let b, c; .. } 這樣的程式碼風格(或慣例),也就意味著你自己或者是團隊中的其他人可能會選擇不遵守規範而把程式碼搞亂。

其實還有另外一種方式。即使用“ let 塊形式” 而不是使用 “let 宣告形式”:

if (a) {
    // make an explicit scope block!
    let (b, c) {
        // ..
    }
}

儘管只做了些許改動,但是瞧仔細了:let (b, c) { .. } 語句為變數 bc 建立了一個顯式的作用域塊。這種寫法從語法上要求 bc 寫在塊的頂部,而這個塊除了建立作用域外並不承擔其他職責。

我認為這是使用基於 let 的塊作用域的最好的方式。

不過這裡有個問題。TC39 委員會在投票中並未將 let 的這種特殊形式納入 ES6 。它或許會在不久的將來被納入進來,或許永遠不會,唯一確定的是,它肯定不在 ES6 的規範中。

所以,我們只能使用前一種形式了嗎?

或許還有希望。我構建了一個叫作 let-er 的小工具,相當於“let 塊形式”的編譯器。預設情況下它處於 ES6 模式,輸入程式碼如下:

let (b, c) {
    ..
}

輸出程式碼如下:

{ let b, c;
    ..
}

看起來還不算壞吧?它僅僅進行了非常簡單的轉化,把非標準化的 “let 塊形式(let block form)” 轉化為“let 宣告形式(let declaration form)”。在使用 let-er 進行轉化後,就可以使用常規的 ES6 轉換器再將其轉化為前 ES6 環境了(如瀏覽器等)。

假如你打算只使用 let-er 來進行 let 相關的轉化,還可以設定 ES3 選項,這樣就可以產生如下程式碼了(一堆充滿了奇技淫巧的程式碼):

try{throw void 0}catch( b ){try{throw void 0}catch( c ){
    ..
}}

沒錯,上面的程式碼應用了這樣一個冷知識,即 try..catch 語句中的 catch 部分具有塊作用域。

沒有人會想寫這樣的程式碼,也沒有人喜歡由此帶來的效能下降。但是要記住,這是編譯後的程式碼,它只用於非常古老的瀏覽器,比如說 IE6 。效能低是令人遺憾的(在我的測試中大約會下降 10%),但是既然你的程式碼要執行在效能這麼低的 IE6 中,所以就......

不管怎樣,let-er 預設的編譯目標是標準 ES6,因此與其他 ES6 工具——如標準編譯器——搭配很好。

唯一的要選擇的就是你更願意寫 let (b, c) { .. } 風格的程式碼,還是覺得 { let b, c; .. } 已經夠用了?

現在我會在自己的專案中使用 let-er 。我認為這種寫法更好。我希望或許在 ES7 中,TC39 的委員們會認識到“let 塊形式(let block form)”的重要性,從而把它納入 JS 的標準中,這樣的話,let-er 也就可以退役啦!

總之呢,顯式塊作用域會比隱式的要好。請認真對待塊作用域。

letvar 的替代嗎?

JS 社群中的一些重量級任務,以及 TC39 委員們喜歡說這樣一句話:“let 是新的 var”。實際上,有些人還真的建議人們(希望是開玩笑的)進行全域性查詢-替換操作,將 var 替換為 let

對於這種愚蠢的建議我就無語了。

首先,我們上面提到的坑很可能會在你的程式碼中大面積出現,尤其是當你的程式碼沒有嚴格按照 var 的最佳實踐去寫的時候。例如,類似下面的程式碼是很常見的:

if ( .. ) {
    var foo = 42;
}
else {
    var foo = "Hello World";
}

而人們一致同意的寫法應該是這樣的:

var foo;

if ( .. ) {
    foo = 42;
}
else {
    foo = "Hello World";
}

但是有的程式碼確實沒有那樣寫。再或者,你不小心這麼寫了:

b = 1;

// ..

var b;

又或者,你不小心在迴圈中利用了不存在塊作用域的閉包特性:

for (var i=0; i<10; i++) {
    if (i == 2) {
        setTimeout(function(){
            if (i == 10) {
                console.log("Loop finished");
            }
        },100);
    }
}

因此如果你盲目地把現有程式碼中的 var 替換為 let,有極大的可能性某些程式碼會突然失效。僅僅把 var 替換為 let 而不作其他改動,上面列舉的幾種情形都會失效。

如果你打算對現有的程式碼進行改進,使其支援塊作用域,你需要一步步進行,而且要非常小心,需要評估和論證塊作用域是否適合用在那個地方。

當然會有一些地方,曾經使用了 var,現在使用 let 會更好一些。很好。而我依然不喜歡隱式地使用,但是假如那是你的菜的話,就用吧。

但是還是會有一些地方,經過你的分析之後發現,程式碼存在結構性問題,使用 let 會顯得怪怪的,或者會使得程式碼晦澀難懂。在這種地方,你可能會選擇重構程式碼,但是可能也會經過審慎評估決定依然使用 var

let 是新的 var”這句話,它假設了——不管說這話的人是否承認——一種優越感,即所有的 JS 程式碼都應該是完美的,遵循了合適的規則的。當你拿上述這些先前已有的程式碼舉例的時候,它的鼓吹者會反擊:“這些程式碼本身就是錯的”。

好吧。不過這只是一個非重點。這句話基本上等於在說:“只有使用了 let 你的程式碼才是完美的,否則你需要準備將其重寫使其完美,並保持之”。

另外一些鼓吹者會溫和一些:“在新寫程式碼的時候使用 let 就可以了。”

這基本上等同於精英主義,因為它再一次假設你一旦學會 let 並決定在程式碼中使用,那麼人們就會期待你新寫出的程式碼不會遭遇前面說的那些坑。

我敢說 TC39 委員會的人可以做到。他們都是一些聰明人,並且深諳 JS 的奧妙。但是像我輩凡夫俗子就沒有那麼幸運了。

letvar 的新搭檔

最理性也最務實的觀點是,擁抱程式碼重構,循序漸進而不是一下子提升程式碼質量。

你當然可以在學到關於作用域的最佳實踐後,每次在寫程式碼的時候使其變得更好一些,新寫下的程式碼當然理應比舊有的程式碼質量更高一些。但是你不會在閱讀完一本書或者一篇文章之後,突然之間打通了任督二脈,寫出的程式碼都是完美無缺的程式碼了。

相反,我希望你應該同時擁抱新生代的 let 和老生代的 var,在程式碼中將它們作為有意義的標記。

在需要使用塊作用域的地方使用 let,同時確保你知道它們的出現意味著什麼。而在不是那麼容易或者不應該使用塊作用域的地方依然使用 var 。在現實世界的程式碼中,有些變數確實需要將其作用域設定為整個函式範圍,對於它們來說,var 是很好的標記。

function foo() {
    var a = 10;

    if (a > 2) {
        let b = a * 3;
        console.log(b);
    }

    if (a > 5) {
        let c = a / 2;
        console.log(c);
    }

    console.log(a);
}

在上面的程式碼中,let 衝我大喊大叫:“嘿,我的作用域範圍是一個塊”。它引起了我的注意,我會更加小心的留意它。而 var 僅僅在說:“嘿,我是原來的縱跨整個函式作用域的變數,你的老朋友,我可以跨越多個作用域。”

如果在函式的頂部使用 let a = 10 會怎麼樣?你可以這樣做,沒問題。

但是我並不認為這是一個好主意。為什麼呢?

首先,你讓 varlet 之間的顯著差異不那麼明顯了,失去了訊號的功用。這樣的話,只有位置可以標記差異,但是而不是語法。

其次,依然存在潛在的坑。有沒有遇到過當程式中出現了詭異的 bug,需要使用 try..catch 語句將其包裹起來除錯的場景?我當然遇到過。

艾瑪:

function foo() {
    try {
        let a = 10;

        if (a > 2) {
            let b = a * 3;
            console.log(b);
        }
    }
    catch (err) {
        // ..
    }

    if (a > 5) {
        let c = a / 2;
        console.log(c);
    }

    console.log(a);
}

塊作用域是個好東西,但是不是銀彈,並非任何場合都適用。在某些場景下, var 宣告的變數縱跨整個函式作用域的特性,以及變數“提升”行為,是非常有用的。它們並非語言設計中的敗筆而理應被移除,而是應該被合理地使用,一如 let

下面是更好的表述方式:“let 是新的擁有塊作用域var”。這句話強調了只有在 var 被用作標記擁有塊作用域的訊號的時候應該用 let 將其代替。其他情形下,就不要動 var 了。它在自己適用的領域表現還是相當不錯的!!

總結

擁有塊作用域相當棒,let 將其帶給了我們。但是一定要顯式地構造塊作用域。避免將 let 宣告寫的到處都是。

let + var,而不是 s/var/let/(大概是二選一的意思?)。當你遇到再有人對你說“let 是新生的 var ”這句話的時候,皺皺眉,表示微笑就好了。

let 對 JS 的作用域特性進行了擴充,而不是替代。var 依然可以很好的用來標記縱貫整個函式作用域的變數。擁有了這哼哈二將,並且妥善使用,可以讓作用域設定意圖清晰易懂,容易維護,以及進行規範化。這是一件大好事!

相關文章