壹 ❀ 引
Foo.getName算是一道比較老的面試題了,大致百度了一下在17年就有相關文章在介紹它,遺憾的是我在19年才遇到,比較奇妙的是現在仍有公司會使用這道題。相關解析網上是有的,這里我站在自己的理解做個記錄,也算是相關知識的一次復習,題目如下,輸出過程也直接標出來了:
function Foo() { getName = function () { console.log(1); }; return this; }; Foo.getName = function () { console.log(2); }; Foo.prototype.getName = function () { console.log(3); }; var getName = function () { console.log(4); }; function getName() { console.log(5); }; Foo.getName(); //2 getName(); //4 Foo().getName(); //1 getName(); //1 new Foo.getName(); //2 new Foo().getName(); //3 new new Foo().getName(); //3
如果大家搜這個題,那說明肯定是對於某一部分執行是有疑慮,那么現在就跟着我的思路重新理一遍,本文開始:
貳 ❀ 分析
1.Foo.getName()
為什么輸出2,不是3?這就得說說構造函數的靜態屬性與實例屬性。
我們都知道函數屬於對象,而對象擁有可以自由添加屬性的特性,函數也不例外,構造函數也是函數:
function Fn() {}; Fn.person = '聽風是風'; Fn.sayName = function () { console.log(this.person); }; Fn.sayName(); // 聽風是風
比如這個例子中,我為構造函數Fn添加了靜態屬性person與靜態方法sayName,我們可以通過構造函數Fn直接訪問。在JS中,我們將綁定在構造函數自身上的屬性方法稱為靜態成員,靜態成員可通過構造函數自身訪問,而實例無法訪問。
let people = new Fn(); people.sayName();// 報錯,實例無法訪問構造函數的靜態屬性/方法
那有什么屬性是實例可以訪問而構造函數自身無法訪問的呢,當然有,比如實例屬性。這里我將實例屬性細分為構造器屬性與原型屬性兩種,看下面的例子:
function Fn() { // 構造器屬性 this.name = '聽風是風'; this.age = 26; }; // 原型屬性 Fn.prototype.sayName = function () { console.log(this.name); }; let people = new Fn(); people.sayName(); // 聽風是風
在這個例子中,我們在構造函數Fn中添加了兩條構造器屬性this.name與this.age,此外還在函數外面通過原型添加了一個原型方法sayName 。
當我們new一個實例后,實例可以直接訪問這些構造器屬性與原型屬性,所以這里將兩種屬性統稱為實例屬性,實例屬性只有實例才能訪問,構造函數自身無法訪問:
Fn.sayName()// 報錯,找不到此方法
說到這大家有沒有覺得靜態屬性與實例屬性像一對歡喜冤家,靜態屬性只有構造函數自身可以使用,而實例屬性呢只有實例可以使用,兩者看似划清界限,但都由構造函數產生。
那么大家可能又有疑問了,你說實例屬性好歹可以用在繼承上,這靜態屬性取了個高大上的名字也感覺有什么大作用啊,其實是有的,比如JS的Memoization(記憶化)模式:
//Memoization模式 const myFunc = function (param) { //do something if (!myFunc.cache[param]) { myFunc.cache[param] = param * 100; }; }; //在函數上添加了一個用於儲存的對象 myFunc.cache = {}; //調用函數 myFunc(1); //訪問存儲 myFunc.cache[1]; //100
這個例子中,我們為函數添加了一個用於存儲執行結果的對象cache,將每次調用函數的參數作為對象的key,執行結果作為value,對於執行特別復雜的操作,這樣只用執行一次之后就可以直接通過參數訪問到最終結果。
如果對於靜態屬性有興趣,想了解更多可以閱讀博主這篇文章 精讀JavaScript模式(七),命名空間模式,私有成員與靜態成員
2.getName()
為什么輸出4而不是5,這里考的是變量提升與函數聲明提升。我們知道使用var聲明變量會存在變量提升的情況,比如下面的例子中,即使在聲明前使用變量a也不會報錯
console.log(a)// undefined var a = 1; console.log(a)// 1
這是因為聲明提前會讓聲明提升到代碼的最上層,而賦值操作停留在原地,所以上面代碼等同於:
var a console.log(a)// undefined a = 1; console.log(a)// 1
而函數聲明(注意是函數聲明,不是函數表達式或者構造函數創建函數)也會存在聲明提前的情況,即我們可以在函數聲明前調用函數:
fn() // 1 function fn() { console.log(1); }; fn() // 1 //因為函數聲明提前,導致函數聲明也會被提到代碼頂端,所以等同於 function fn() { console.log(1); }; fn() // 1 fn() // 1
那這樣就存在一個問題了,變量聲明會提升,函數聲明也會提升,誰提升的更高呢?在你不知道的JavaScript中明確指出,函數聲明會被優先提升,也就是說都是提升,但是函數比變量提升更高,所以題目中的兩個函數順序可以改寫成:
function getName() { console.log(5); }; var getName; getName = function () { console.log(4); };
這樣就解釋了為什么輸出4而不是5了。想更詳細了解變量提升,函數提升規則,可以閱讀博主這篇文章 【JS點滴】聲明提前,變量聲明提前,函數聲明提前,聲明提前的先后順序
3.Foo().getName()
這里考了全局變量與window的關系以及this指向的問題。
我們知道使用var聲明的全局變量等同於給window添加屬性,以及函數聲明的函數也會成為window屬性:
var a = 1; // window上可以找到這條屬性 window.a; //1 function acfun() { console.log(1); }; // window上可以找到這個方法 window.acfun(); //1
了解了這一點后,我們再來看函數執行過程,第一步執行Foo(),在分析第二個執行時我們知道了getName是全局變量,所以在函數Foo內也能直接訪問,於是getName被修改成了輸出1的函數,之后返回了一個this。
由於Foo().getName()等同於window.Foo().getName(),所以this指向window,這里返回的this其實就是window。
現在執行第二步window.getName(),前面已經說了全局變量等同於給window添加屬性,而且全局變量getName的值在執行Foo()時被修改,所以這里輸出1。
4.getName()
這里輸出1已經毫無懸念,上一分析中,getName的值在Foo執行時被修改了,所以再調用getName一樣等同於window.getName(),同樣是輸出1。
5.new Foo.getName()
在分析一中我們已經知道了Foo.getName是Foo的靜態方法,這里的getName雖然是Foo的靜態方法,但是既沒有繼承Foo的原型,自身內部也沒提供任何構造器屬性(this.name這樣的),所以new這個靜態方法只能得到一個空屬性的實例。
因此這里new的過程就相當於單純把Foo.getName執行了一遍輸出2,然后返回了一個空的實例,我們可以嘗試打印這個執行結果,一個啥都沒繼承的實例:
6.new Foo().getName()
這里考了new基本概念,首先這個調用分為兩步,第一步new Foo()得到一個實例,第二步調用實例的getName方法。
我們知道new一個構造函數的過程大致為,以構造函數原型創建一個對象(繼承原型鏈),調用構造函數並將this指向這個新建的對象,好讓對象繼承構造函數中的構造器屬性,如果構造函數沒有手動返回一個對象,則返回這個新建的對象。
所以在執行new Foo()時,先以Foo原型創建了一個對象,由於Foo.prototype上事先設置了一個getName方法(輸出3的那個),所以這個對象可通過原型訪問到這個方法,其次由於Foo內部也沒提供什么構造器屬性,最終返回了一個this(這個this指向實例),因此這里的this還是等同於我們前面概念提到的以Foo原型創建的對象,可以嘗試輸出這個實例,除了原型上有一個getName方法就沒有其它任何屬性,因此這里輸出3。
我們可以將Foo函數改寫成下面這樣,其它不變,猜猜new Foo().getName()輸出什么:
function Foo() { getName = function () { console.log(1); }; return { getName: function () { console.log(6); } }; };
如果你對於new一個函數過程以及new函數返回值規則不太了解,我在上面的分析應該是會讀的不太理解。如果你存在疑問,可以閱讀博主這兩篇文章:
精讀JavaScript模式(三),new一個構造函數究竟發生了什么? 這篇文章直接看第四、五節的知識。
js new一個對象的過程,實現一個簡單的new方法 這篇文章關於new的過程介紹更為精確。
7.new new Foo().getName()
老實說這個執行給出來真的就是滿滿的惡意,先不說new不new什么的,怎么執行都把人難住,第一眼也是看的我很懵,我們知道new一個函數都是new fn(),函數帶括號的。所以這里其實可以拆分成這樣:
var a = new Foo(); new a.getName();
那這樣就好說了,第一步執行上面已經有分析過了,由於構造函數Foo自身啥構造器屬性都沒有,只有原型上有一個輸出3的原型方法,所以實例a是一個原型上有輸出3的函數getName,除此之外的光桿司令。
那么第二步,由於原型上的getName方法也沒提供構造器屬性,自身原型上也沒屬性,所以第二步也算是單純執行a.getName()輸出3,然后得到了一個什么自定義屬性都沒有實例。
我們可以嘗試輸出這兩步得到的實例:
叄 ❀ 總
那么到這里這道面試題就分析完了,通過本文,我們知道了構造函數靜態屬性與實例屬性的概念,其中靜態屬性只有構造函數自身可以訪問,實例無權訪問;實例屬性由構造器屬性與原型屬性組成,實例可以繼承訪問,而構造函數卻無權訪問。
其次,我們知道了變量提升與函數聲明提升,而且函數聲明提升比變量提升更高。
還有呢,通過var聲明的全局變量或者函數聲明的函數,都等同於給window添加屬性,我們可以通過window訪問這些屬性,這也是為什么說調用一個Foo()等同於window.Foo()的原因。
最后,我們簡單了解了new一個構造函數的過程,原來new中間發生了這么多有趣的事情。
一道看似普通的面試題,居然涵蓋了不少知識點,我想這也是為何19年還有公司願意使用它的原因吧,不過看過本文的你應該無所畏懼了。
那么到這里本文結束,如有看不懂的地方歡迎留言,我會第一時間回復。