前端同學經常忽視的一個 JavaScript 面試題
題目
function Foo() { getName = function () { alert (1); }; return this; } Foo.getName = function () { alert (2);}; Foo.prototype.getName = function () { alert (3);}; var getName = function () { alert (4);}; function getName() { alert (5);} //請寫出以下輸出結果: Foo.getName(); getName(); Foo().getName(); getName(); new Foo.getName(); new Foo().getName(); new new Foo().getName();
這幾天面試上幾次碰上這道經典的題目,特地從頭到尾來分析一次答案,這道題的經典之處在於它綜合考察了面試者的JavaScript的綜合能力,包含了變量定義提升、this指針指向、運算符優先級、原型、繼承、全局變量污染、對象屬性及原型屬性優先級等知識,此題在網上也有部分相關的解釋,當然我覺得有部分解釋還欠妥,不夠清晰,特地重頭到尾來分析一次,當然我們會把最終答案放在後面,並把此題再改高一點點難度,改進版也放在最後,即食麵試官在出題的時候有個參考,更多詳情可關注本文作者@Wscats
第一問
先看此題的上半部分做了什麼,首先定義了一個叫Foo的函數,之後為Foo創建了一個叫getName的靜態屬性存儲了一個匿名函數,之後為Foo的原型對象新創建了一個叫getName的匿名函數。之後又通過函數變量表達式創建了一個getName的函數,最後再聲明一個叫getName函數。
第一問的Foo.getName自然是訪問Foo函數上存儲的靜態屬性,答案自然是2,這裡就不需要解釋太多的,一般來說第一問對於稍微懂JS基礎的同學來說應該是沒問題的,當然我們可以用下面的代碼來回顧一下基礎,先加深一下了解
function User(name) { var name = name; //私有屬性 this.name = name; //公有屬性 function getName() { //私有方法 return name; } } User.prototype.getName = function() { //公有方法 return this.name; } User.name = 'Wscats'; //靜態屬性 User.getName = function() { //靜態方法 return this.name; } var Wscat = new User('Wscats'); //實例化
注意下面這幾點:
- 調用公有方法,公有屬性,我們必需先實例化對象,也就是用new操作符實化對象,就可構造函數實例化對象的方法和屬性,並且公有方法是不能調用私有方法和靜態方法的
- 靜態方法和靜態屬性就是我們無需實例化就可以調用
- 而對象的私有方法和屬性,外部是不可以訪問的
第二問
第二問,直接調用getName函數。既然是直接調用那麼就是訪問當前上文作用域內的叫getName的函數,所以這裡應該直接把關注點放在4和5上,跟1 2 3都沒什麼關係。當然後來我問了我的幾個同事他們大多數回答了5。此處其實有兩個坑,一是變量聲明提升,二是函數表達式和函數聲明的區別。
我們來看看為什麼,可參考(1)關於Javascript的函數聲明和函數表達式 (2)關於JavaScript的變量提升
在Javascript中,定義函數有兩種類型
函數聲明
// 函數聲明 function wscat(type) { return type === "wscat"; }
函數表達式
// 函數表達式 var oaoafly = function(type) { return type === "oaoafly"; }
先看下面這個經典問題,在一個程序裏面同時用函數聲明和函數表達式定義一個名為getName的函數
getName() //oaoafly var getName = function() { console.log('wscat') } getName() //wscat function getName() { console.log('oaoafly') } getName() //wscat
上面的代碼看起來很類似,感覺也沒什麼太大差別。但實際上,Javascript函數上的一個「陷阱」就體現在Javascript兩種類型的函數定義上。
- JavaScript 解釋器中存在一種變量聲明被提升的機制,也就是說函數聲明會被提升到作用域的最前面,即使寫代碼的時候是寫在最後面,也還是會被提升至最前面。
- 而用函數表達式創建的函數是在運行時進行賦值,且要等到表達式賦值完成後才能調用
var getName //變量被提升,此時為undefined getName() //oaoafly 函數被提升 這裡受函數聲明的影響,雖然函數聲明在最後可以被提升到最前面了 var getName = function() { console.log('wscat') } //函數表達式此時才開始覆蓋函數聲明的定義 getName() //wscat function getName() { console.log('oaoafly') } getName() //wscat 這裡就執行了函數表達式的值
所以可以分解為這兩個簡單的問題來看清楚區別的本質
var getName; console.log(getName) //undefined getName() //Uncaught TypeError: getName is not a function var getName = function() { console.log('wscat') } var getName; console.log(getName) //function getName() {console.log('oaoafly')} getName() //oaoafly function getName() { console.log('oaoafly') }
這個區別看似微不足道,但在某些情況下確實是一個難以察覺並且「致命「的陷阱。出現這個陷阱的本質原因體現在這兩種類型在函數提升和運行時機(解析時/運行時)上的差異。
當然我們給一個總結:Javascript中函數聲明和函數表達式是存在區別的,函數聲明在JS解析時進行函數提升,因此在同一個作用域內,不管函數聲明在哪裡定義,該函數都可以進行調用。而函數表達式的值是在JS運行時確定,並且在表達式賦值完成後,該函數才能調用。
所以第二問的答案就是4,5的函數聲明被4的函數表達式覆蓋了
第三問
Foo().getName(); 先執行了Foo函數,然後調用Foo函數的返回值對象的getName屬性函數。
Foo函數的第一句getName = function () { alert (1); };是一句函數賦值語句,注意它沒有var聲明,所以先向當前Foo函數作用域內尋找getName變量,沒有。再向當前函數作用域上層,即外層作用域內尋找是否含有getName變量,找到了,也就是第二問中的alert(4)函數,將此變量的值賦值為function(){alert(1)}。
此處實際上是將外層作用域內的getName函數修改了。
注意:此處若依然沒有找到會一直向上查找到window對象,若window對象中也沒有getName屬性,就在window對象中創建一個getName變量。
之後Foo函數的返回值是this,而JS的this問題已經有非常多的文章介紹,這裡不再多說。
簡單的講,this的指向是由所在函數的調用方式決定的。而此處的直接調用方式,this指向window對象。
遂Foo函數返回的是window對象,相當於執行window.getName(),而window中的getName已經被修改為alert(1),所以最終會輸出1
此處考察了兩個知識點,一個是變量作用域問題,一個是this指向問題
我們可以利用下面代碼來回顧下這兩個知識點
var name = "Wscats"; //全局變量 window.name = "Wscats"; //全局變量 function getName() { name = "Oaoafly"; //去掉var變成了全局變量 var privateName = "Stacsw"; return function() { console.log(this); //window return privateName } } var getPrivate = getName("Hello"); //當然傳參是局部變量,但函數裏面我沒有接受這個參數 console.log(name) //Oaoafly console.log(getPrivate()) //Stacsw
因為JS沒有塊級作用域,但是函數是能產生一個作用域的,函數內部不同定義值的方法會直接或者間接影響到全局或者局部變量,函數內部的私有變量可以用閉包獲取,函數還真的是第一公民呀~
而關於this,this的指向在函數定義的時候是確定不了的,只有函數執行的時候才能確定this到底指向誰,實際上this的最終指向的是那個調用它的對象
所以第三問中實際上就是window在調用**Foo()**函數,所以this的指向是window
window.Foo().getName(); //->window.getName();
第四問
直接調用getName函數,相當於window.getName(),因為這個變量已經被Foo函數執行時修改了,遂結果與第三問相同,為1,也就是說Foo執行後把全局的getName函數給重寫了一次,所以結果就是Foo()執行重寫的那個getName函數
第五問
第五問new Foo.getName();此處考察的是JS的運算符優先級問題,我覺得這是這題靈魂的所在,也是難度比較大的一題
下面是JS運算符的優先級表格,從高到低排列。可參考MDN運算符優先級
yield*從右到左yield* …1展開運算符n/a… …0逗號從左到右… , …
這題首先看優先級的第18和第17都出現關於new的優先級,new (帶參數列表)比new (無參數列表)高比函數調用高,跟成員訪問同級
new Foo.getName();的優先級是這樣的
相當於是:
new (Foo.getName)();
- 點的優先級(18)比new無參數列表(17)優先級高
- 當點運算完後又因為有個括號(),此時就是變成new有參數列表(18),所以直接執行new,當然也可能有朋友會有疑問為什麼遇到()不函數調用再new呢,那是因為函數調用(17)比new有參數列表(18)優先級低
.成員訪問(18)->new有參數列表(18)
所以這裡實際上將getName函數作為了構造函數來執行,遂彈出2。
第六問
這一題比上一題的唯一區別就是在Foo那裡多出了一個括號,這個有括號跟沒括號我們在第五問的時候也看出來優先級是有區別的
(new Foo()).getName()
那這裡又是怎麼判斷的呢?首先new有參數列表(18)跟點的優先級(18)是同級,同級的話按照從左向右的執行順序,所以先執行new有參數列表(18)再執行點的優先級(18),最後再函數調用(17)
new有參數列表(18)->.成員訪問(18)->()函數調用(17)
這裡還有一個小知識點,Foo作為構造函數有返回值,所以這裡需要說明下JS中的構造函數返回值問題。
構造函數的返回值
在傳統語言中,構造函數不應該有返回值,實際執行的返回值就是此構造函數的實例化對象。
而在JS中構造函數可以有返回值也可以沒有。
- 沒有返回值則按照其他語言一樣返回實例化對象。
function Foo(name) { this.name = name } console.log(new Foo('wscats'))
- 若有返回值則檢查其返回值是否為引用類型。如果是非引用類型,如基本類型(String,Number,Boolean,Null,Undefined)則與無返回值相同,實際返回其實例化對象。
function Foo(name) { this.name = name return 520 } console.log(new Foo('wscats'))
- 若返回值是引用類型,則實際返回值為這個引用類型。
function Foo(name) { this.name = name return { age: 16 } } console.log(new Foo('wscats'))
原題中,由於返回的是this,而this在構造函數中本來就代表當前實例化對象,最終Foo函數返回實例化對象。
之後調用實例化對象的getName函數,因為在Foo構造函數中沒有為實例化對象添加任何屬性,當前對象的原型對象(prototype)中尋找getName函數。
當然這裡再拓展個題外話,如果構造函數和原型鏈都有相同的方法,如下面的代碼,那麼默認會拿構造函數的公有方法而不是原型鏈,這個知識點在原題中沒有表現出來,後面改進版我已經加上。
function Foo(name) { this.name = name this.getName = function() { return this.name } } Foo.prototype.name = 'Oaoafly'; Foo.prototype.getName = function() { return 'Oaoafly' } console.log((new Foo('Wscats')).name) //Wscats console.log((new Foo('Wscats')).getName()) //Wscats
第七問
new new Foo().getName();同樣是運算符優先級問題。做到這一題其實我已經覺得答案沒那麼重要了,關鍵只是考察面試者是否真的知道面試官在考察我們什麼。
最終實際執行為:
new ((new Foo()).getName)();
new有參數列表(18)->new有參數列表(18)
先初始化Foo的實例化對象,然後將其原型上的getName函數作為構造函數再次new,所以最終結果為3
答案
function Foo() { getName = function () { alert (1); }; return this; } Foo.getName = function () { alert (2);}; Foo.prototype.getName = function () { alert (3);}; var getName = function () { alert (4);}; function getName() { alert (5);} //答案: Foo.getName();//2 getName();//4 Foo().getName();//1 getName();//1 new Foo.getName();//2 new Foo().getName();//3 new new Foo().getName();//3
後續
後續我把這題的難度再稍微加大一點點(附上答案),在Foo函數裏面加多一個公有方法getName,對於下面這題如果用在面試題上那通過率可能就更低了,因為難度又大了一點,又多了兩個坑,但是明白了這題的原理就等同於明白了上面所有的知識點了
function Foo() { this.getName = function() { console.log(3); return { getName: getName //這個就是第六問中涉及的構造函數的返回值問題 } }; //這個就是第六問中涉及到的,JS構造函數公有方法和原型鏈方法的優先級 getName = function() { console.log(1); }; return this } Foo.getName = function() { console.log(2); }; Foo.prototype.getName = function() { console.log(6); }; var getName = function() { console.log(4); }; function getName() { console.log(5); } //答案: Foo.getName(); //2 getName(); //4 console.log(Foo()) Foo().getName(); //1 getName(); //1 new Foo.getName(); //2 new Foo().getName(); //3 //多了一問 new Foo().getName().getName(); //3 1 new new Foo().getName(); //3
最後,其實我是不建議把這些題作為考察面試者的唯一評判,但是作為一名合格的前端工程師我們不應該因為浮躁忽略了我們的一些最基本的基礎知識,當然我也祝願所有面試者找到一份理想的工作,祝願所有面試官找到心中那匹千里馬~