1. 程式人生 > >JavaScript 函式作用域

JavaScript 函式作用域

函式作用域

每一個作用域都可以作為容器,其中包含了識別符號(變數、函式)的定義。這些作用域互相巢狀並且整齊地排列成蜂窩型(沒有交集),排列的結構在寫程式碼時定義。

【前提知識】:

【疑問】:究竟是什麼生成了一個作用域?只有函式會生成新的作用域嗎?JavaScript 中的其他結構能生成作用域嗎?

函式中的作用域

屬於這個函式的全部變數都可以在整個函式的範圍內使用及複用(事實上在巢狀的作用域中也可以使用)。

【優點】:這種設計方案非常有用,能充分利用 JavaScript 變數可以根據需要改變值型別的“動態”特性。

【注意】:如果不細心處理那些可以在整個作用域範圍內被訪問的變數,可能會帶來意想不到的問題。

隱藏內部實現

【前言】:對函式的傳統認知就是先宣告一個函式,然後再向裡面新增程式碼。但反過來想也可以帶來一些啟示:從所寫的程式碼中挑選出一個任意的片段,然後用函式宣告對它進行包裝,實際上就是把這些程式碼“隱藏”起來。

根據前言可以推斷出,開發者可以用函式將一些程式碼片段包裝起來。實際結果就是在程式碼片段周圍建立了一個作用域氣泡,也就是說這段程式碼中的任何宣告(變數或函式)都將繫結在這個新建立的包裝函式的作用域中,而不是先前所在的作用域中。那麼這些變數和函式外部就無法訪問(除非內部主動暴露出來),從而實現了“程式碼隱藏”。

【問】:為什麼“隱藏”變數和函式是一個有用的技術?

【答】:有很多原因促成了這種基於作用域的隱藏方法。它們大都是從最小特權原則中引申出來的,也叫最小授權或最小暴露原則。這個原則是指在軟體設計中,應該最小限度地暴露必要內容,而將其他內容都“隱藏”起來,比如某個模組或物件的 API 設計。這個原則可以延伸到如何選擇作用域來包含變數和函式。如果所有變數和函式都在全域性作用域中,當然可以在所有的內部巢狀作用域中訪問到它們。但這樣會破壞前面提到的最小特權原則,因為可能會暴露過多的變數或函式,而這些變數或函式本應該是私有的。正確的做法是要阻止對這些變數或函式進行訪問。

【示例】:

function doSomething(a) {
    b = a + doSomethingElse(a * 2);
    
    console.log(b * 3);
}

function doSomethingElse(a) {
    return a - 1;
}

var b;

doSomething(2); // 15
// 好的做法
function doSomething(a) {
    function doSomethingElse(a) {
        return a - 1;
    }
    
    var b;
    
    b = a + doSomethingElse(a);
    
    console.log(b * 3);
}

doSomething(2); // 15

【解釋】:之前的程式碼,變數 b 和函式 doSomethingElse() 放置在全域性作用域中,可能被有意或無意地以非預期的方式使用。合理的設計會將這些私有的具體內容隱藏在 doSomething() 內部。

規避衝突

“隱藏”作用域中的變數和函式所帶來的另一個好處:是可以避免同名識別符號之間的衝突,兩個識別符號可能具有相同的名字但用途卻不一樣,無意間可能造成命名衝突。衝突會導致變數的值被意外覆蓋。

【示例】:

function foo() {

    function bar(a) {
        i = 3;
        console.log(a + i);
    }
    
    for(var i = 0; i < 10; i++) {
        bar(i * 2);
    }
}
foo();

【說明】:bar() 內部的賦值表示式 i = 3 意外地覆蓋了宣告在 foo() 內部 for 迴圈中的 i,因此在這個例子中將會導致無限迴圈。

【解決】:

  1. bar() 內部的賦值操作宣告一個本地變數來使用,採用任何名字都可以,var i = 3; 也可以滿足這個需求(“遮蔽變數”)。
  2. 將 for 迴圈中宣告的 i 變數“隱藏”起來。
function foo() {

    function bar(a) {
        i = 3;
        console.log(a + i);
    }

    (function() {
        for(var i = 0; i < 10; i++) {
            bar(i * 2);
        }
    })()
}
foo();
1. 全域性名稱空間

變數衝突的一個典型例子存在於全域性作用域中。當程式中載入了多個第三方庫時,如果它們沒有妥善地將內部私有的函式或變數隱藏起來,就會很容易引發衝突。

這些庫通常會在全域性作用域中宣告一個名字足夠獨特的變數,通常是一個物件。這個物件被用作庫的名稱空間,所有需要暴露給外界的功能都會成為這個物件(名稱空間)的屬性,而不是將自己的識別符號暴露在頂級的詞法作用域中。

【示例】:

var MyReallyCoolLibrary = {
    awesome: "stuff",
    doSomething: function() {
        // ...
    },
    doAnotherThing: function() {
        // ...
    }
};
2. 模組管理

使用模組管理器工具。使用這些工具,任何庫都無需將識別符號加入到全域性作用域中,而是通過依賴管理器的機制將庫的識別符號顯式地匯入到另外一個特定的作用域中。

顯而易見,這些工具並沒有能夠違反詞法作用域規則的“神奇”功能。它們只是利用作用域的規則強制所有識別符號都不能注入到共享作用域中,而是保持在私有、無衝突的作用域中,這樣就可以有效規避所有的意外衝突。

函式作用域

通過在任意程式碼片段外部新增包裝函式,可以將內部的變數和函式定義“隱藏”起來,外部作用域無法訪問包裝函式內部的任何內容。

var a = 2;

function foo() { // 新增這一行
    var a = 3;
    
    console.log(a); // 3
} // 以及這一行
foo(); // 以及這一行
console.log(a); // 2

【額外的問題】:

  1. 必須宣告一個具名函式 foo(),意味著 foo 這個名稱本身“汙染”了所在作用域。
  2. 必須顯式地通過函式名 foo() 呼叫這個函式才能執行其中的程式碼。

【解決的辦法】:使用 IIFE。

匿名和具名

函式表示式和函式宣告的區別規則

  • 當 js 解析器看到 function 是這一行程式碼中第一個詞,function 被認為是宣告。
  • 當 function 作為語句的一部分出現的,都會是表示式。

【區別】:函式宣告會將其函式名繫結在函式所在的作用域中。而函式表示式的函式名被繫結在函式表示式自身的函式中而不是所在的作用域中。換句話說,(function() {…}) 作為函式表示式,意味著函式只能在 … 所代表的位置中被訪問,外部作用域則不行。

【注意】:函式表示式可以匿名,而函式宣告不可以是匿名,這在 JavaScript 的語法中是非法的。

【優點】:

  1. 書寫簡單快捷。
  2. 很多庫和工具也傾向鼓勵使用這種風格的程式碼。

【缺點】:

  1. 匿名函式在棧追蹤中不會顯示出有意義的函式名,使得除錯很困難。
  2. 如果沒有函式名,當函式需要引用自身時只能使用已經過期的 arguments.callee 引用,比如在遞迴中。另一個函式引用自身的例子是在事件觸發後事件監聽器需要解綁自身。
  3. 匿名函式省略了對於程式碼可讀性/可理解性很重要的函式名。一個描述性的名稱可以讓程式碼不言自明。

【建議】:使用行內函式表示式

setTimeout(function timeoutHandler() {
    console.log("I waited 1 second!");
}, 1000);

【解釋】:匿名和具名之間的區別並不會對函式有任何影響。給函式表示式指定一個函式名可以有效地解決匿名函式表示式存在的缺點。因此給函式表示式命名是一個最佳實踐。

立即執行函式表示式

在函式的外圍包裹一對()括號,將其轉換為函式表示式。通過在末尾加上另外一個()可以立即執行這個函式。也就是說第一對()將函式轉變為函式表示式,第二對()執行該函式。

var a = 2;

(function foo() {
    var a = 3;
    console.log(a);
})();

console.log(a);

這種模式被稱為 IIFE,代表立即執行函式表示式(Immediately Invoked Function Expression)。函式名對 IIFE 不是必須的,最常見的用法是使用匿名函式表示式。雖然使用具名函式的 IIFE 不常見,但它具有上述匿名函式表示式的所有優勢,因此也是一個值得推廣的實踐。

【形式】:功能上並無區別。

// 方式一
(function() {
    // ...
})()
// 方式二
(function() {
    // ...
}())

【進階用法】:將 IIFE 當作函式呼叫並傳遞引數進去。

var a = 2;
(function IIFE(global) {
    var a = 3;
    console.log(a); // 3
    console.log(global.a); // 2
})(window);
console.log(a); // 2

【其他用法】:

  1. 解決 undefined 識別符號的預設值被錯誤覆蓋導致的異常(不常見)。
(function IIFE(undefined) {
    var a;
    if(a === undefined) {
        console.log("Undefined is safe here!");
    }
})();
  1. 倒置程式碼的執行順序,將需要執行的函式放在第二位,在 IIFE 被執行之後當作引數傳遞進去。
var a = 2;
(function IIFE(def) {
    def(window);
})(function def(global) {
    var a = 3;
    console.log(a);
    console.log(global.a);
});

小結

  • 函式是 JavaScript 中最常見的作用域單元。本質上,宣告在一個函式內部的變數或函式會在所處的作用域中“隱藏”起來,這是有意為之的良好軟體的設計原則。