1. 程式人生 > >前端程式設計師經常忽視的一個JavaScript面試題

前端程式設計師經常忽視的一個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運算子優先順序

優先順序 運算型別 關聯性 運算子
19 圓括號 n/a ( … )
18 成員訪問 從左到右 … . …
需計算的成員訪問 從左到右 … [ … ]
new (帶引數列表) n/a new … ( … )
17 函式呼叫 從左到右 … ( … )
new (無引數列表) 從右到左 new …
16 後置遞增(運算子在後) n/a … ++
後置遞減(運算子在後) n/a … --
15 邏輯非 從右到左 ! …
按位非 從右到左 ~ …
一元加法 從右到左 + …
一元減法 從右到左 - …
前置遞增 從右到左 ++ …
前置遞減 從右到左 -- …
typeof 從右到左 typeof …
void 從右到左 void …
delete 從右到左 delete …
14 乘法 從左到右 … * …
除法 從左到右 … / …
取模 從左到右 … % …
13 加法 從左到右 … + …
減法 從左到右 … - …
12 按位左移 從左到右 … << …
按位右移 從左到右 … >> …
無符號右移 從左到右 … >>> …
11 小於 從左到右 … < …
小於等於 從左到右 … <= …
大於 從左到右 … > …
大於等於 從左到右 … >= …
in 從左到右 … in …
instanceof 從左到右 … instanceof …
10 等號 從左到右 … == …
非等號 從左到右 … != …
全等號 從左到右 … === …
非全等號 從左到右 … !== …
9 按位與 從左到右 … & …
8 按位異或 從左到右 … ^ …
7 按位或 從左到右 … 按位或 …
6 邏輯與 從左到右 … && …
5 邏輯或 從左到右 … 邏輯或 …
4 條件運算子 從右到左 … ? … : …
3 賦值 從右到左 … = …
… += …
… -= …
… *= …
… /= …
… %= …
… <<= …
… >>= …
… >>>= …
… &= …
… ^= …
… 或= …
2 yield 從右到左 yield …
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中建構函式可以有返回值也可以沒有。

  1. 沒有返回值則按照其他語言一樣返回例項化物件。
function Foo(name) {
    this.name = name
}
console.log(new Foo('wscats'))

  1. 若有返回值則檢查其返回值是否為引用型別。如果是非引用型別,如基本型別(String,Number,Boolean,Null,Undefined)則與無返回值相同,實際返回其例項化物件。
function Foo(name) {
    this.name = name
    return 520
}
console.log(new Foo('wscats'))

  1. 若返回值是引用型別,則實際返回值為這個引用型別。
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             

參考

最後,其實我是不建議把這些題作為考察面試者的唯一評判,但是作為一名合格的前端工程師我們不應該因為浮躁忽略了我們的一些最基本的基礎知識,當然我也祝願所有面試者找到一份理想的工作,祝願所有面試官找到心中那匹千里馬~
@Wscats 原題最初版來源:

  • 前端程式設計師經常忽視的一個JavaScript面試題