天天看點

前端同學經常忽視的一個 JavaScript 面試題

前端同學經常忽視的一個 JavaScript 面試題

作者 |  Wscats

題目

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

前端同學經常忽視的一個 JavaScript 面試題

第一問

先看此題的上半部分做了什麼,首先定義了一個叫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 ... …
逗号 從左到右 … , …

這題首先看優先級的第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      

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

本文完〜

繼續閱讀