JavaScript中塊級作用域與函數作用域深入剖析
面試官必問系列:深入理解JavaScript塊和函數作用域
在 JavaScript 中,究竟是什么會生成一個新的作用域,只有函數才會生成新的作用域嗎?那 JavaScript 其他結構能生成新的作用域嗎?
函數中的作用域
在之前的詞法作用域中可見 JavaScript 具有基于函數的作用域,這也就意味著一個函數都會創(chuàng)建一個新的作用域。但其實并不完全正確,看以下例子:
function foo(a) { var b = 2; function bar() { // ... } var c = 3; }
- 以上代碼片段中,foo() 的作用域中包含了標識符 a, b, c 和 bar。無論表示聲明出現(xiàn)在作用域中的何處,這個標識符所代表的變量和函數都附屬于所處作用域的作用域中。
- bar() 中也擁有屬于自己的作用域,全局作用域也有屬于自己的作用域,它只包含了一個標識符: foo()
由于標識符 a, b, c 和 bar 都附屬于 foo() 的作用域內,因此無法從 foo() 的外部對它們進行訪問。也就是說,這些標識符在全局作用域中是無法被訪問到的,因此如下代碼會拋出 ReferenceError:
bar(); // ReferenceError: bar is not defined console.log(a, b, c); // 全都拋出 ReferenceError
- 但標識符 a, b, c 和 bar 可在 foo() 的內部被訪問的。
- 函數作用域的含義:屬于這個函數的全部變量都可以在整個函數的范圍內使用及復用(在嵌套的作用域中也可以使用)。這種設計方案可根據需要改變值類型的 "動態(tài)" 特性。
隱藏內部實現(xiàn)
- 我們對函數的傳統(tǒng)認知就是先聲明一個函數,然后再向里面添加代碼,但反過來可帶來一些啟示:從所寫的代碼中挑選出一個任意片段,然后就用函數聲明的方式對它進行包裝,實際上就是把這些代碼 "隱藏" 起來了。
- 實際的結果就是在這個代碼片段的周圍創(chuàng)建了一個新的作用域,也就是說這段代碼中的任何聲明(變量或函數)都將綁定在這個新創(chuàng)建的函數作用域中,而不是先前所在的作用域中。換句話說,可把變量和函數包裹在一個函數的作用域中,然后用這個作用域來 "隱藏" 他們。
為什么 "隱藏" 變量和函數是一個有用的技術?
function doSomething(a) { b = a + doSomethingElse( a * 2 ); console.log( b * 3 ); } function doSomethingElse(a) { return a - 1; } var b; doSomething( 2 ); // 15
- 上述代碼片段中,變量 b 和函數 doSomethingElse(..) 應該是 doSomething(..) 內部具體實現(xiàn)的 "私有" 內容。而上述代碼將變量 b 和函數 doSomethingElse(..) 的訪問權限放在了外部作用域中,這可能是 "危險" 的。更 "合理" 的設計應該是將這些私有內容放在 doSomething(...) 的內部。
如下:
function doSomething(a) { function doSomethingElse(a) { return a - 1; } var b; b = a + doSomethingElse( a * 2 ); console.log( b * 3 ); } doSomething( 2 ); // 15
規(guī)避沖突
- "隱藏" 作用域中的變量和函數的另一個好處是可避免同名標識符的沖突,兩個標識符名字相同但用途不同,無意間可能會造成命名沖突,而沖突會導致變量的值被意外覆蓋。
例如:
function foo() { function bar(a) { i = 3; // 修改for 循環(huán)所屬作用域中的i console.log( a + i ); } for (var i=0; i<10; i++) { bar( i * 2 ); // 糟糕,無限循環(huán)了! } } foo();
bar(...) 內部的賦值表達式 i = 3 意外地覆蓋了聲明在 foo(..) 內部 for 循環(huán)中的 i。在這個例子中將會導致無限循環(huán),因為 i 被固定設置為 3,永遠滿足小于 10 這個條件。
規(guī)則沖突的方式:
全局命名空間:在全局作用域中聲明一個足夠獨特的變量,通常為一個對象,如下:
var MyReallyCoolLibrary = { awesome: "stuff", doSomething: function() { // ... }, doAnotherThing: function() { // ... } }
模塊管理
函數作用域
- 現(xiàn)在知道,在任意代碼片段外部添加包裝函數,可將內部的變量和函數定義 "隱藏" 起來,外部作用域無法訪問包裝函數內部的任何內容。
如下:
var a = 2; function foo() { // <-- 添加這一行 var a = 3; console.log( a ); // 3 } // <-- 以及這一行 foo(); // <-- 以及這一行 console.log( a ); // 2
- 上述代碼會導致一些額外的問題,首先,必需先聲明一個具名函數 foo(), 這就意味著 foo 這個名稱本身 "污染" 了所在作用域(上述代碼為全局作用域)。其次,必須顯式地通過 foo() 來調用這個函數。
- 如果函數不需要函數名(或者至少函數名可以不污染所在作用域),且能夠自行運行,這將會更理想。
JavaScript 提供了兩種方案來解決:
var a = 2; (function foo() { // <-- 添加這一行 var a = 3; console.log(a); // 3 })(); // <-- 以及這一行 console.log(a); // 2
- 在上述代碼中,包裝函數的聲明以 (function... 而不僅是以 function... 開始。函數會被當做函數表達式而不是一個標準的函數聲明來處理。
如何區(qū)分函數聲明和表達式?
- 最簡單的方式就是看 function 關鍵字出現(xiàn)在聲明中的位置(不僅僅是一行代碼,而是整個聲明中的位置)。如果 function 為聲明中的第一個關鍵字,那它就是一個函數聲明,否則就是一個函數表達式。
- 函數聲明和函數表達式之間最重要的區(qū)別就是他們的名稱標識符將會綁定在何處。
- 比較一下前面兩個代碼片段。第一個片段中 foo 被綁定在所在作用域中,可以直接通過 foo() 來調用它。第二個片段中foo 被綁定在函數表達式自身的函數中而不是所在作用域中。
- 換句話說,(function foo(){...}) 作為函數表達式意味著 foo 只能在 ... 所代表的位置中被訪問,外部作用域則不行。
匿名和具名
對于函數表達式最熟悉的就是回調參數了,如下:
setTimeout(function () { console.log("I waited 1 second!"); }, 1000);
- 這叫作匿名函數表達式,因為 function().. 沒有名稱標識符。函數表達式可以是匿名的,而函數聲明則不可以省略函數名——在JavaScript 的語法中這是非法的。
匿名函數表達式的缺點:
匿名函數在棧追蹤中不會顯示出有意義的函數名,這使調試很困難。
如果沒有函數名,當函數需要引用自身時只能通過已經過期的 arguments.callee 來引用。
匿名函數對代碼可讀性不是很友好。
上述代碼的改造結果:
setTimeout(function timeoutHandler() { console.log("I waited 1 second!"); }, 1000);
立即執(zhí)行函數表達式
var a = 2; (function IIFE() { var a = 3; console.log(a); // 3 })(); console.log(a); // 2
- 由于函數被包含在一對( ) 括號內部,因此成為了一個表達式,通過在末尾加上另外一個( ) 可以立即執(zhí)行這個函數,比如(function foo(){ .. })()。第一個( ) 將函數變成表達式,第二個( ) 執(zhí)行了這個函數。
- 立即執(zhí)行函數表達式的術語為:IIFE(Immediately Invoked Function Expression);
IIFE 的應用場景:
除了上述傳統(tǒng)的 IIFE 方式,還有另一個方式,如下:
var a = 2; (function IIFE() { var a = 3; console.log(a); // 3 }()); console.log(a); // 2
第一種形式中函數表達式被包含在 ( ) 中,然后在后面用另一個 () 括號來調用。第二種形式中用來調用的 () 括號被移進了用來包裝的 ( ) 括號中。
這兩種方式的選擇全憑個人喜好。
IIFE 還有一種進階用法,就是把他們當做函數調用并傳遞參數進去,如下:
var a = 2; (function IIFE(global) { var a = 3; console.log(a); // 3 console.log(global.a); // 2 })(window); console.log(a); // 2
IIFE 的另一個應用場景是解決 undefined 標識符的默認值被錯誤覆蓋導致的異常。
IIFE 的另一種變化的用途是倒置代碼的運行順序,將需要運行的函數放在第二位,在IIFE執(zhí)行之后當做參數傳遞進去。
var a = 2; (function IIFE(def) { def(window); })(function def(global) { var a = 3; console.log(a); // 3 console.log(global.a); // 2 });
函數表達式 def 定義在片段的第二部分,然后當做參數(這個參數也叫做 def)被傳遞 IIFE 函數定義的第一部分中。最后,參數 def(也就是傳遞進去的函數)被調用,并將 window 傳入當做 global 參數的值。
塊作用域
將一個參數命名為 undefined, 但在對應的位置不傳入任何值,這樣就可以就保證在代碼塊中 undefined 標識符的值為 undefined
undefined = true; // 給其他代碼挖了一個大坑!絕對不要這樣做! (function IIFE(undefined) { var a; if (a === undefined) { console.log("Undefined is safe here!"); } })();
如下:
for (var i = 0; i < 5; i++){ console.log(i); }
- 在 for 循環(huán)中定義了變量 i,通常是想在 for 循環(huán)內部的上下文中使用 i, 而忽略 i 會綁定在外部作用域(函數或全局)中。
修改后:
var foo = true; if(foo) { var bar = foo * 2; bar = something(bar); console.log(bar); }
- 上述代碼中,變量 bar 僅在 if 的上下文中使用,將它聲明在 if 內部中式非常一個清晰的結構。
- 當使用 var 聲明變量時,它寫在哪里都是一樣的,因為它最終都會屬于外部作用域。(這也就是變量提升)
with
- 在詞法作用域中介紹了 with 關鍵字,它不僅是一個難于理解的結構,同是也是一塊作用域的一個例子(塊作用域的一種形式),用 with 從對象中創(chuàng)建出的作用域僅在 with 所處作用域中有效。
try/catch
很少有人注意,JavaScript 在 ES3 規(guī)范 try/catch 的 catch 分句會創(chuàng)建一個塊作用域,其中聲明的變量僅會在 catch 內部有效。
try { undefined(); // 目的是讓他拋出一個異常 } catch (error) { console.log("error ------>", error); // TypeError: undefined is not a function } console.log("error ------>", error); // ReferenceError: error is not defined
- error 僅存在于 catch 分句內部,當視圖從別處引用它時會拋出錯誤。
- 關于 catch 分句看起來只是一些理論,但還是會有一些有用的信息的,后續(xù)文章會提到。
let
- JavaScript 在 ES6 中引入了 let 關鍵字。
let 關鍵字將變量綁定到所處的任意作用域中(通常是 { ... } 內部)。換句話說,let 聲明的變量隱式地了所在的塊作用域。
var foo = true; if(foo) { var bar = foo * 2; bar = something(bar); console.log(bar); } console.log(bar); // ReferenceError: bar is not defined
使用 let 進行的聲明不會再塊作用域中進行提升。聲明的代碼被運行前,聲明并不 "存在"。
{ console.log(bar); // ReferenceError let bar = 2; }
1. 垃圾收集
- 另一個塊作用域很有用的原因和閉包中的內存垃圾回收機制相關。
如下代碼:
function process(data) { // do something } var someObj = {}; process(someObj); var btn = document.getElementById('my_button'); btn.addEventListener('click', function click(evt) { console.log('clicked'); }, /*capturingPhase=*/false);
- click 函數的點擊回調并不需要 someReallyBigData 變量。理論上這意味著當 process(..) 執(zhí)行后,在內存中占用大量空間的數據結構就可以被垃圾回收了。但是,由于 click函數形成了一個覆蓋整個作用域的閉包,JavaScript 引擎極有可能依然保存著這個結構(取決于具體實現(xiàn))。
修改后:
function process(data) { // do something } // 在這個塊中定義內容就可以銷毀了 { var someObj = {}; process(someObj); } var btn = document.getElementById('my_button'); btn.addEventListener('click', function click(evt) { console.log('clicked'); }, /*capturingPhase=*/false);
2. let循環(huán)
代碼如下:
for(let i = 0; i < 10; i++) { console.log(i); }; console.log(i); // ReferenceError
- for 循環(huán)中的 let 不僅將 i 綁定了for 循環(huán)內部的塊中,事實上他將其重新綁定到了循環(huán)的每一次迭代中,確保使用上一個循環(huán)迭代結束時的值重新進行賦值。
下面通過另一種方式來說明每次迭代時進行重新綁定的行為;
{ let i; for(i = 0; i < 10; i++) { let j = i; // 每次迭代中重新綁定 console.log(j); }; }
- let 聲明附屬與一個新的作用域而不是當前的函數作用域(也不屬于全局作用域)。
考慮一下代碼:
var foo = true, baz = 10; if (foo) { var bar = 3; if (baz > bar) { console.log( baz ); } // ... }
這段代碼可以簡單地被重構成下面的同等形式:
var foo = true, baz = 10; if (foo) { var bar = 3; // ... } if (baz > bar) { console.log( baz ); }
但是在使用塊級作用域的變量時需要注意以下變化:
var foo = true, baz = 10; if (foo) { let bar = 3; if (baz > bar) { // <-- 移動代碼時不要忘了 bar! console.log( baz ); } }
const
ES6 還引入了 const, 同樣可用來創(chuàng)建塊級作用域,但其值是固定的(常量), 不可修改。
var foo = true; if (foo) { var a = 2; const b = 3; // 包含在 if 中的塊作用域常量 a = 3; // 正常 ! b = 4; // 錯誤 ! } console.log( a ); // 3 console.log( b ); // ReferenceError!
小結
- 函數時 JavaScript 中最常見的作用域單元。
- 塊作用域值的是變量和函數布局可以屬于所處的作用域,也可以屬于某個代碼塊(通常指 {...} 內部)
- 從 ES3 開始, try/catch 結構在 catch 分句中具有塊作用域。
- 從 ES6 引入了 let,const 關鍵字來創(chuàng)建塊級作用域。
以上就是JavaScript中塊級作用域與函數作用域的詳細內容,更多關于JavaScript塊級函數作用域的資料請關注腳本之家其它相關文章!