很多編程語言在執行的時候都是自上而下執行,但實際上這種想法在JavaScript中並不完全正確, 有一種特殊情況會導致這個假設是錯誤的。來看看下面的代碼,
a = 2; var a; console.log( a );
console.log(a) 會輸出什么呢?
有些人可能會認為是 undefined,因為 var a 聲明在 a = 2 之后,他們自然而然地認為變量被重新賦值了,因此會被賦予默認值 undefined。但是,真正的輸出結果是 2。
先不急為什么,我們再繼續看另外一段代碼,
console.log( a ); var a = 2;
鑒於上一個代碼片段所表現出來的某種非自上而下的行為特點,你可能會認為這個代碼片段也會有同樣的行為而輸出 2。還有人可能會認為,由於變量 a 在使用前沒有先進行聲明,因此會拋出 ReferenceError 異常。
其實不然,兩種猜測都是不對的。輸出來的會是 undefined。
提升
引擎會在解釋 JavaScript 代碼之前首先對其進行編譯,簡單地說,任何 JavaScript 代碼片段在執行前都要進行編譯(通常就在執行前,說通常是因為JavaScript 中存在兩個機制可以“欺騙” 詞法作用域: eval(..) 和 with)。編譯階段中的一部分工作就是找到所有的聲明,並用合適的作用域將它們關聯起來,包括變量和函數在內的所有聲明都會在任何代碼被執行前首先被處理。這就是我們通常說的“提升”。
注:只有聲明本身會被提升, 而賦值或其他運行邏輯會留在原地。
foo(); function foo() { console.log( a ); // undefined var a = 2; }
每個作用域都會進行提升操作。所以 foo(..)函數自身也會在內部對 var a 進行提升(顯然並不是提升到了整個程序的最上方)。在這里,你或許會發現,為什么代碼里面是先調用 foo() ,再聲明 foo() 這樣的順序,卻不會報錯。這是因為除了變量聲明會在其作用域內提升之外,函數聲明也具有相似的特效。因此這段代碼可以暫時理解為下面的形式:
function foo() { var a; console.log( a ); // undefined a = 2; } foo();
可以看到,函數聲明會被提升在作用域的頂部。但是有一點需要和變量聲明提升做區別的是:變量提升只是提升了變量的聲明,而變量賦值並沒有被提升。但是,函數的聲明有點不一樣,函數體也會一同被提升。
所以上面的一段暫時性的代碼實際上可以這樣理解:
var foo = { var a; console.log( a ); // undefined a = 2; } foo();
foo 函數的聲明(這個例子還包括實際函數的隱含值)被提升了,因此第一行中的調用可以正常執行。
然而並不是所有的函數都能提升!函數聲明會被提升,但是函數表達式卻不會被提升。
foo(); // 不是 ReferenceError, 而是 TypeError! var foo = function bar() { // ... };
上面這段程序中的變量標識符 foo() 被提升並分配給所在作用域,因此 foo() 不會導致 ReferenceError。但是 foo 此時並沒有賦值(如果它是一個函數聲明而不是函數表達式,那么就會賦值)。foo() 由於對 undefined 值進行函數調用而導致非法操作,因此拋出 TypeError 異常。
同時也要記住,即使是具名的函數表達式,名稱標識符在賦值之前也無法在所在作用域中使用:
foo(); // TypeError bar(); // ReferenceError var foo = function bar() { // ... };
這個代碼片段經過提升后,實際上會被理解為以下形式:
var foo; foo(); // TypeError bar(); // ReferenceError foo = function() { var bar = ...self... // ... }
這里我們說到具名函數表達式,就順便插如一點具名函數表達式的知識點。我們看看下面的例子:
function test() { var fn = function fn1() { log(fn === fn1); // true log(fn == fn1); // true } fn(); log(fn === fn1); // Uncaught ReferenceError: fn1 is not defined log(fn == fn1); // Uncaught ReferenceError: fn1 is not defined } test();
看上面這例子,是不是很疑惑?
具名函數表達式,是帶名字的函數賦值給一個變量,這個名字只在新定義的函數作用域內有效,因為規范規定了標示符不能在外圍的作用域內有效。也就是說,這個函數名只能在此函數內部使用,可以理解為這個函數名成了函數體內部的一個變量。
這里還有一點需要注意的,函數定義了一個非標准的name屬性,通過這個屬性可以訪問到給定函數指定的名字,這個屬性的值永遠等於跟在function關鍵字后面的標識符,匿名函數的name屬性為空,而具名的函數表達式會修改到這個屬性。
var foo = function(){ //... }; console.log(foo.name); //foo var bar = function foobar(){ //... }; console.log(bar.name); //foobar name值被修改
函數優先
函數聲明和變量聲明都會被提升。但是一個值得注意的細節(這個細節可以出現在有多個“重復” 聲明的代碼中)是函數會首先被提升,然后才是變量。
看一下下面的代碼:
foo(); // 1 var foo; function foo() { console.log( 1 ); } foo = function() { console.log( 2 ); };
會輸出 1 而不是 2 ! 這個代碼片段會被引擎理解為如下形式:
function foo() { console.log( 1 ); } foo(); // 1 foo = function() { console.log( 2 ); };
var foo 盡管出現在 function foo()... 的聲明之前,但它是重復的聲明(因此被忽略了),因為函數聲明會被提升到普通變量之前。盡管重復的 var 聲明會被忽略掉, 但出現在后面的函數聲明還是可以覆蓋前面的。
foo(); // 3 function foo() { console.log( 1 ); } var foo = function() { console.log( 2 ); }; function foo() { console.log( 3 ); }
我們來看看下面這個,
function text1() { var a = 1; function b() { a = 10; return; function a() {} } b(); console.log(a); // ? } text1(); function text2() { var a = 1; function b() { a = 10; function a() {} } b(); console.log(a); // ? } text2();
想一想,這兩段代碼輸出的結果會是什么?
結果都是1!為啥???
這里需要注意的是,在 function b() 中,function a() 由於存在函數提升,上述代碼實際上的運行代碼是這樣子的,
function text{ var a = 1; function b() { var a = function(){}; a = 10; //return; //這個return對這段代碼沒有任何影響 } b(); console.log(a); 1 }
是不是很神奇~~~~所以在寫代碼的時候,就要特別注意了,不要因為 JavaScript 的提升機制導致很多莫名其妙的bug出來。
最后還有一個要強調一下,由於一個普通塊內部的函數聲明通常會被提升到所在作用域的頂部,這個過程不會像下面的代碼暗示的那樣可以被條件判斷所控制:
foo(); // "b" var a = true; if (a) { function foo() { console.log("a"); } } else { function foo() { console.log("b"); } }
function hoistVariable() { if (!foo) { var foo = 5; } console.log(foo); // 5 } hoistVariable();
小結:
我們習慣將 var a = 2; 看作一個聲明,而實際上 JavaScript 引擎並不這么認為。它將 var a和 a = 2 當作兩個單獨的聲明, 第一個是編譯階段的任務,而第二個則是執行階段的任務。這意味着無論作用域中的聲明出現在什么地方,都將在代碼本身被執行前首先進行處理。可以將這個過程形象地想象成所有的聲明(變量和函數)都會被“移動”到各自作用域的最頂端,這個過程被稱為提升。
聲明本身會被提升,而包括函數表達式的賦值在內的賦值操作並不會提升。
要注意避免重復聲明,特別是當普通的 var 聲明和函數聲明混合在一起的時候,否則會引起很多危險的問題!
理解變量提升和函數提升可以使我們更了解這門語言,更好地駕馭它,但是在開發中,我們不應該使用這些技巧,而是要規范我們的代碼,做到可讀性和可維護性。具體的做法是:無論變量還是函數,都必須先聲明后使用。
如果對於新的項目,可以使用let替換var,會變得更可靠,可維護性更高。值得一提的是,ES6中的class聲明也存在提升,不過它和let、const一樣,被約束和限制了,其規定,如果再聲明位置之前引用,則是不合法的,會拋出一個異常。
所以,無論是早期的代碼,還是ES6中的代碼,我們都需要遵循一點,先聲明,后使用。