JS 疫情宅在家,學習不能停,七千字長文助你徹底弄懂原型與原型鏈,武漢加油!!中國加油!!(破音)


壹 ❀ 引

原型與原型鏈屬於老生常談的問題,也是面試高頻問題,但對於很多前端開發者來說,組織語言去解釋清楚是較為困難的事情,並不是原型有多難,稍微了解的同學都知道原型這一塊涉及太多知識。比如我們可以靈魂提問自己的同事以下問題:

  • 什么是原型和原型鏈,原型鏈頂端是什么?
  • 原型鏈和作用域鏈有何區別?
  • 構造函數與普通函數有什么區別?
  • 能否判斷當前函數是普通調用或new構造調用?
  • prototype__proto_是什么?
  • 怎么判斷對象是否包含某條屬性?
  • 怎么判斷某條屬性是否為對象自身屬性而非原型屬性?
  • constructorinstanceOf有何區別?
  • 能不能手動實現new方法?
  • 能否創建嚴格意義上的空對象?
  • ....

我想問題沒問完你應該要被錘了。我們言歸正傳,上述問題你能回答多少呢?帶着問題,讓我們重新梳理原型相關知識。

貳 ❀ 從構造函數說起

與java基於類不同,JavaScript是一門基於原型prototype的語言,至少在ES6之前JavaScript並無類的概念,但卻有類的模擬實現,也就是我們常說的構造函數。

什么是構造函數呢?構造函數其實就是一個普通函數,只是我們為了區分普通函數,通常建議構造函數name首字母大寫,比如:

// 這是一個構造函數
function Parent(){};

你說我就不首字母大寫,那也不影響一個函數是構造函數的事實:

// 這也是一個構造函數
function parent(){
    this.name = '聽風';
};
let child = new parent();
console.log(child);//parent {name: "聽風"}

有同學就納悶了,這普通函數居然也能使用new操作符構造調用,沒錯,不僅普通函數能new調用,構造函數同樣也能普通調用:

// 這是一個構造函數
function Parent() {
    console.log(1);
};
Parent() //1

其實到這里,我們已經解釋了 構造函數與普通函數有什么區別 這個問題,構造函數其實就是一個普通函數,且函數都支持new調用與普通調用。也正因如此導致了ES5中構造函數沒有區別於普通函數的尷尬局面,這也是為何在ES6中JavaScript正式推出Class類的原因,你會發現Class只支持new調用,如果直接調用會報錯:

class Parent {
    sayName() {
        console.log('聽風');
    };
};
var child = new Parent();
child.sayName(); //聽風
var child = Parent();//報錯,必須使用new調用

解釋了構造函數,那么構造函數能用來做什么呢?最基本的就是屬性繼承了,我們先不聊繼承模式,就從最基本的繼承說起。

假設現在我們要定制一批藍色的杯子,杯口直徑與高度可互不相同,那么我們可以用構造函數表示:

//定制杯子
function CupCustom(diameter, height) {
    this.diameter = diameter;
    this.height = height;
};
CupCustom.prototype.color = 'blue';
var cup1 = new CupCustom(8, 15);
var cup2 = new CupCustom(5, 10);
console.log(cup1.height);//15
console.log(cup2.color);//blue

那么我們可以將構造函數CupCustom理解成一個制作杯子的模具,cup1與cup2是模具制作出來的杯子,我們稱之為實例。大家可以嘗試輸出實例,可以看到兩個實例都繼承了構造函數的構造器屬性(直徑,高)與原型屬性(顏色),顏色存放的地方還有點不同,它放在__proto__中,說到這咱們解釋了為什么實例能讀取height與color兩個屬性。

出於好奇,咱們也輸出打印了構造函數的屬性,有同學不知道怎么打印查看函數的屬性,這里可以借用console.dir(函數),打印結果如下圖:

對比圖1與圖2可以發現,構造函數除了自身屬性與__proto__屬性外還多出了一個prototype屬性,這里我們其實能先給出一個結論:

所有的對象都有__proto__屬性,但只有函數擁有prototype屬性。

細心的同學應該還能發現,兩者都有一個constructor屬性指向了構造函數CupCustom。那么問題來了,prototype是啥,和__proto__有什么區別?constructor又是什么?為什么__proto__屬性展開還包含了__proto_?別急,咱們從對象說起。

叄 ❀ JavaScript萬物皆對象

叄 ❀ 壹 神奇的__proto__

了解JavaScript的同學一定聽過這樣兩句話:

  • JavaScript中萬物皆對象。
  • JavaScript是基於原型的語言。

通過這兩句話,其實我們可以得出這樣一個結論:

  • JavaScript中萬物皆為對象,對象皆有原型。

光是看到萬物皆對象這句話,脾氣不好的同學已經要握緊砂鍋大的拳頭教會我什么是社會的毒打了,別慌,我們來論證這個結論。

我們知道JavaScript中數據類型分類基本數據類型與引用數據類型:

  • 基本數據類型:Number,String,Boolean,Undefined,Null,Symbol。
  • 引用數據類型:Object,Function,Date,Array,RegExp等。

引用數據類型也就是我們熟知的對象類型且種類繁多,大家最為熟悉的應該就是普通對象{},數組[]以及函數Function了。

我們來看看基本數據類型,不知道大家有沒有想過這樣一件事,為什么隨便聲明一段字符串就能使用字符串的方法?如果字符串真的就是簡單類型,方法又是從哪來的呢?

'echo'.toUpperCase();//"ECHO"

經過試驗可以發現,基本類型中除了undefined與null之外,任意數字,字符,布爾以及symbol值都有__proto__屬性,以字符串為例,我們打印它的__ptoto__並展開,如下可以看到大量我們日常使用的字符串方法均在其中:

我們前面已經說了,所有的對象都有__ptoto__屬性,而字符串居然也有__proto__屬性,__proto__是一個訪問器屬性,它指向創建它的構造函數的原型prototype。還記得前面做杯子的構造函數嗎?每實例個杯子其實只有直徑與高度屬性,但通過實例的__proto__屬性我們找到了構造函數CupCustom的原型prototype,從而成功訪問了prototype上的color屬性。

你看,咱們說萬物皆為對象,對象皆有原型,字符串都能通過__proto__屬性找到自己的原型,它還能不是一個對象嗎?

借此我們回答上面杯子構造函數留下來的問題,每個對象都有__proto__屬性,你可以理解成是用來訪問創建此對象的構造函數prototype的接口。函數最為特殊,它除了有__proto__屬性外還有prototype屬性,所以我們能直接通過prototype給函數添加原型屬性,而實例能通過__proto__訪問構造函數的原型屬性或方法。

那為什么函數的prototype屬性下還有一個__proto__屬性呢?

我們知道函數有函數表達式,函數聲明以及new創建三種模式,而函數聲明其實等同於new Function(),我們定義的任意函數本質上也屬於原始構造函數Function的實例,那么函數有一個__proto__屬性指向構造函數Function的原型不是理所應當的事情么。所以這里我們又得出了一個結論:

每一個函數都屬於原始構造函數Function的實例,而每一個函數又能做為構造函數生產屬於自己的實例。

還是以函數CupCustom為例,它屬於構造函數Function的實例,而它自己又作為構造函數生產了cup1這樣的實例,為啥只有函數有prototype屬性?就因為函數特殊身份,任性,這下總明白了吧。

叄 ❀ 貳 JavaScript中的包裝對象

我在上文解釋字符串屬於對象時,有同學可能也想到了,對象都能添加屬性,字符串怎么不能添加屬性,比如:

var person = {};
person.name = 'echo';
console.log(person.name); //echo

'聽風是風'.age = 26;
console.log('聽風是風'.age); //undefined

我們直接書寫一個字符串這叫字符串直接量,是較為推薦的字符串創建形式,同樣的字符串我們也能使用new創建,比如:

new String('聽風是風');

如上圖,這也解釋了為什么字符串能擁有__proto__屬性。

JavaScript有一個概念叫 包裝對象,字符串,數字,布爾值均屬於包裝對象。包裝對象的一大特點就是,當我們創建一個基本類型數據時,JavaScript在底層會對應創建一個基於此數據的包裝類型對象,比如一段很常見的字符串轉大寫,可以拆分成如下步驟:

var name = 'echo';
var name_ = name.toUpperCase();

// 創建String實例,將實例賦予變量name
var string = new String('echo')
var name = string;
// 在實例上調用指定的方法
var name_ = name.toUpperCase();
// 銷毀這個實例
string = null;

你看,JavaScript隱性做了額外的兩件事,假設實例不被銷毀,你會驚奇的發現,原來字符串上真的可以添加屬性:

var string = new String('echo')
string.age = 26;
console.log(string.age); //26

我們又解鎖了一個額外獎勵結論:

String、Number、Boolen屬於包裝對象,包裝對象是一種聲明周期只有一瞬的對象,創建與銷毀都由底層實現。

那么到這關於基本類型數據屬於對象的結論算是說清楚了。

好奇心重的同學馬上想到了基本數據類型中的undefined與null,這兩兄弟是不是對象?

undefined與null均沒有__proto__屬性,且都不是對象。undefined表示未定義,它不是一個確切的值,不是對象也沒有原型很正常。不對啊,typeof null明明是Object啊,怎么不是對象呢?這一點是JavaScript早期設計遺留下來的BUG且一直未得到修復,具體原因可查看MDN中關於typeof的附加信息。其次,有個小結論咱們要提前透露:

原型鏈的頂端是null。

所以null不是對象,身為原型頂點的null沒有__proto__這很正常,因為它找不到自己的原型了,這點我們在下文介紹原型時會具體論證。

OK,我們花了較大的篇幅重新認知了對象,並介紹完了__proto__,是該介紹原型了,咱們接着聊。

肆 ❀ 認識原型prototype

肆 ❀ 壹 關於prototype

JavaScript中萬物皆對象,且每個對象都有自己的原型,這是我們在上文得出的結論。說直白點就是,每個對象都有__proto__屬性,對象都能通過此屬性找到創建自己構造函數的原型。那么什么是原型呢?原型其實就是一個對象。

你想想原型能添加屬性方法,而只有對象才擁有添加屬性方法的特性。再如我們查看函數prototype下的__proto__屬性,可以看到它的constructor屬性指向是構造函數Object,還記得__proto__指向誰嗎?所以說原型妥妥的是一個對象。

為什么這么說呢,這里又需要透露一個結論:

在不修改構造函數prototype前提下,所有實例__proto__屬性中的constructor屬性都指向創建自己的構造函數。

實例的__proto__指向的是創建自己的構造函數的prototype,這個prototype是一個對象,咱們先記住這一點。

我們知道java是基於類的語言,每一個實例都能找到自己對應的類。JavaScript語言在設計上借鑒了java,盡管在ES6之前沒有類,但是你會驚奇的發現,JavaScript中眼見的數據類型基本都有對應創建自己的構造函數,比如:

數字 123 本質上由構造函數Number()創建,所以數字123通過__proto__訪問構造函數Number()原型上的方法屬性。

字符串 abc 本質上由構造函數 String()創建,所以abc也能通過__proto__訪問構造函數String()原型上的方法屬性。

函數本質上由原始構造函數Function創建,所以函數也能通過__proto__訪問原始構造函數Function上的原型屬性方法,別忘了,我們任意創建的函數都能使用call、apply等方法,不然你以為這些方法是哪來的呢。

上文也說了,我們自己創建構造函數其實和普通函數沒任何區別,畢竟每個函數都能使用new調用用於創建屬於自己的實例,這種繼承方式是不是神似java的類,只是在JavaScript中改用原型prototype了。每一個函數都有作為構造函數的潛力,所以每一個函數都自帶了prototype原型。

為了加深印象,還是以杯子的構造函數為例,我們抽象代碼:

// 模擬代碼,並不能真正執行
// 原始構造函數
function Function(){};
Function.prototype = {
    call:function () {},
    apply:function () {},
    bind:function () {},
};

//由原始構造函數得到實例構造函數CupCustom
var CupCustom = new Function();
CupCustom.prototype = {
    color:'blue'
};
// 由構造函數CupCustom最終得到實例
var cup1 = new CupCustom();

原始構造函數上有prototype原型對象,上面的call、apply每個函數都可以通過原型訪問,而函數又可以作為構造函數調用,所以自定義構造函數又產生了屬於自己的實例。通過這里我們可以知道:

原始構造函數Function()扮演着創世主女媧的角色,她創造了Object()、Number()、String()、Date()、function fn(){}等第一批人類(也就是構造函數),而人類同樣具備了繁衍的能力(使用new操作符),於是Number()繁衍出了數據類型數據,String()誕生了字符串,function fn(){}作為構造函數也誕生了各種各樣的對象后代。

我們可以通過如下代碼論證這一點:

// 所有函數對象的__proto__都指向Function.prototype,包括Function本身
Number.__proto__ === Function.prototype //true
Number.constructor === Function //true

String.__proto__ === Function.prototype //true
String.constructor === Function //true

Object.__proto__ === Function.prototype //true
Object.constructor === Function //true

Array.__proto__ === Function.prototype //true
Array.constructor === Function //true

Function.__proto__ === Function.prototype //true
Function.constructor === Function //true

為啥說函數是JavaScript中的一等公民?女媧一般的存在,神仙啊!!!現在大家明白了沒,萬物都由函數產生啊,悟到了沒?

所以當實例訪問某個屬性時,會先查找自己有沒有,如果沒有就通過__proto__訪問自己構造函數的prototype有沒有,前面說構造函數的原型是一個對象,如果原型對象也沒有,就繼續順着構造函數prototype中的__proto__繼續查找到構造函數Object()的原型,再看有沒有,如果還沒有,就返回undefined,因為再往上就是null了,這個過程就是我們熟知的原型鏈,說的再准確點,就是__proto__訪問過程構成了原型鏈。

其實到這我們得到了兩個結論,結論一:

在不修改構造函數原型的前提下,實例的__proto__與構造函數的prototype是對等關系。

比如下面這個例子:

function Parent() {};
var son = new Parent();
son.__proto__ === Parent.prototype;//true

原因很簡單,上文解釋了很多遍了,實例通過訪問器屬性__proto__訪問創建自己的構造函數原型,相等是很正常的。

第二個結論上文提前給出了,原型鏈的頂點是null。我們來看個例子:

function Parent() {};
var son = new Parent();
console.log(son.__proto__); //找到了構造函數Parent的原型
console.log(son.__proto__.__proto__); //原型是對象,它的__proto__指向構造函數Object的原型
console.log(son.__proto__.__proto__.__proto__); //null,到頭了,null不是對象,沒有原型,所以不會繼續往上了

結合代碼注釋以及上文原型鏈的解釋,上文中三段__proto__分別是什么大家應該很清楚了吧。那么到這里,我們原型與原型鏈說的算是非常清楚透徹了。

肆 ❀ 貳 關於constructor

最后我們來說說constructor,我在上文已經給出了結論,這也是我想糾正的一個概念。很多人說實例的constructor指向創建自己的構造函數,但通過打印我們可以發現,實例自己並沒有constructor屬性,而是通過__proto__屬性,找到了構造函數的原型,而構造函數的原型中有一個constructor屬性指向自己。

如果你覺得有點繞,你可以這樣理解,原型有很多,構造函數也有很多,我怎么知道這個原型是哪個構造函數的呢,constructor就起到了標識作用,函數的prototype指向自己,就是怕你弄糊塗。

這里借用其他博主一張直觀的關系圖。

伍 ❀ 問題解答

文章開頭提出了很多問題,部分問題我們在文中已經給出了答案,大家可以嘗試先回答看看,下文我們整理下統一給出答案。

1.什么是原型和原型鏈,原型鏈頂端是什么?

JavaScript中萬物皆對象,且對象皆可通過__proto__屬性訪問創建自己構造函數的原型對象,說直白點,原型就是一個包含了諸多屬性方法的對象,原型對象的__proto__指向構造函數Object()的原型。當一個對象訪問某個屬性時,它會先查找自己有沒有,如果沒有就順着__proto__往上查找創建自己構造函數的原型有沒有,這個過程就是原型鏈,原型鏈的頂端是null。

關於這個問題我印象非常深刻,17年10月我辭掉了武漢的工作奔赴了深圳,當時也就10個月工作經驗,只會點JQ,自己又不是本專業出身,基礎薄弱。群里大佬說招人能內推,而且是高級前端開發,當時接近年底工作真的難找(主要是自己菜),也算是想明白自己和高級到底有多少差距,還是去面試了。沒有筆試題,面對面拿着簡歷聊,就問到了這個問題,從介紹對象到我回答說萬物皆對象,一步步接着追問,可以說是把我虐的體無完膚,這也是為什么在本文中我要着重解釋萬物皆對象的原因。只不過當時面試官舉得例子是亞當夏娃,我在文中換成了女媧。

2.原型鏈和作用域鏈有何區別?

這個算是第一個問題的拓展,所謂作用域鏈是在當前作用域查找某個變量時,如果沒有就追溯到上層作用域,如果還沒有則一直找到全局作用域,這個過程就是作用域鏈。區別就是,原型鏈頂端是null,作用域頂端是全局對象,原型鏈沒找到某個屬性返回undefined,而作用域鏈沒找到會直接報錯,告訴你未聲明。

3.構造函數與普通函數有什么區別?

文中已經給出了答案,沒有區別。函數均為被普通調用和new調用,所以你可以說函數都是構造函數,也正因如此,ES6才推出了Class類,算是給了構造函數一個真正的名分。

4.能否判斷當前函數是普通調用或new構造調用?

這個問題我在 js 手動實現bind方法,超詳細思路分析!這篇文中有給出答案,其實就是區分函數執行時this指向。如果是普通調用,this綁定屬於默認綁定,一定會指向全局window(非嚴格模式)。如果是new調用,那自然是指向構造函數內創建的實例了,而上文我們知道實例可以通過__proto__找到構造函數的原型,原型的constructor屬性又指向構造函數自身,來看個例子:

// 非嚴格模式
function fn() {
    if (this === window) {
        console.log('現在是普通調用');
    } else if (this.constructor === fn) {
        // 因為原型鏈自己會找,所以我們直接通過this.constructor訪問constructor屬性,不加__proto__了
        console.log('現在是new調用');
    };
};
fn();//現在是普通調用
new fn();//現在是new調用

2020.5.9新增 我們還可以使用new.target字段判斷是否為new調用,如果為new調用,此字段將指向函數本身。

function fn() {
    console.log(new.target === fn);
};
fn(); //false
new fn(); //true

5.prototype__proto_是什么?

prototype是原型對象,__proto__是訪問器屬性,對象就是通過這個家伙訪問構造函數的原型對象。

6.判斷對象是否有某條屬性?

有些同學馬上想到了使用obj.的方式判斷,沒有就是undefined,那假設我這個屬性值就是undefined那不就完蛋了。

var obj = {
    name: undefined
};
obj.nam; //undefined

推薦做法是使用in:

var obj = {
    name: undefined
};
console.log('name' in obj);//true
console.log('age' in obj);//false

7.怎么判斷某條屬性是否為對象自身屬性而非原型屬性?

算是問題6的衍生問題,in只能判斷有沒有某條屬性,不能判斷此屬性是不是對象自身屬性,如果要判斷這一點,就要借用hsaOwnProperty()方法了,看個例子:

function Fn() {
    this.name = '聽風是風';
};
Fn.prototype.age = 26;
var obj = new Fn();
console.log('name' in obj);//true
console.log('age' in obj);//true
console.log(obj.hasOwnProperty('name'));//true
console.log(obj.hasOwnProperty('age'));//false

對於實例obj來說,name是它自己擁有的屬性,而age是原型上借來的屬性,所以在上述例子中有所區分。

8.constructorinstanceOf有何區別?

在判斷對象類型時,有時我們會用到instanceOf,而通過本身,其實constructor也能做到類型判斷,那么這兩點有何區別呢:

constructor是原型對象中一個屬性,instanceOf是一個運算符,且constructor返回的是創建實例的構造函數,是一個方法,而instanceOf返回的時一個布爾值;最重要的,instanceOf可以判斷是否屬於原型鏈的任意一層,constructor則是找上一層。

而在判斷實例是否由某個構造函數創建時,特殊情況下,instanceOf比constructor更為准確,看個例子:

function Person() {
    this.name = '聽風是風';
};

Person.prototype = {
    name: 'echo',
    age: '26'
};

var person = new Person();
console.log(person.constructor === Person); //false
console.log(person instanceof Person); //true

這也是為什么在上文中,我們在介紹__proto__與constructor時一定要加上在未修改構造函數原型為前提條件的原因。constructor說到底是原型屬性,你把原型改了,它就找不到自己的構造函數了,但instanceOf並非如此。

9.能不能手動實現new方法?

我不僅能手動實現new,我還能實現call,apply與bind,來波內連推薦:

js new一個對象的過程,實現一個簡單的new方法

js 實現call和apply方法,超詳細思路分析

js 手動實現bind方法,超詳細思路分析!

10.能否創建嚴格意義上的空對象?

我們隨便創建一個空對象{}嚴格意義上說並不是真正的空對象,因為它本質上還是new Object()出來的,所以具有__proto__以及一對原型屬性。那么怎么創建嚴格意義上的空呢?JavaScript中誰沒有原型?請大聲告訴我!沒錯,就是null,咱們可以這樣:

//方法一
var obj = Object.create(null);
console.log(obj)//{}  真正的空對象

//方法二
var obj = {};
Object.setPrototypeOf(obj,null); //參數一 將被設置原型的對象.  參數二 該對象新的原型鏈
console.log(obj)//{} 真正的空對象

undefined雖然也沒有原型,但是不能這樣用,會報錯。

陸 ❀ 總

其實讀完這篇文章,我想各位一定和我有同樣的想法,我知道了原型,開發中我好像也用不上。沒錯,但這也不影響你這次真的知道了什么是原型和原型鏈,不影響你在面試時對於是對象,函數的理解又上了一個台階。我們知道原型繼承有花里胡哨賊多種方式,如果我們不懂原型,又從何理解這些繼承模式呢?你說對吧。

這篇文章前前后后花了我一周時間,光是周六,我就從中午十二點一直寫到了下午五點,我需要整合語言,我需要解答自己的疑惑,當然我最想的就是把大家都整的明明白白。

時隔這么久才發了這一篇博客,主要原因還是受疫情影響,我家在湖北仙桃,這次回來也沒帶電腦,封城至今出不去,也買不了電腦,物流不配送,只能去我哥家借電腦遠程上班苟延殘喘...心里還是希望國家能快點戰勝新冠病毒,大家都健健康康,醫護人員也能盡快休息。

有問題請留言,我會在第一時間回復大家,那么到這里,本文正式結束了。近七千字,算是我寫過最有耐心的一篇文章了...

有興趣可以閱讀博主下面這篇文章,這篇文章介紹了Function.prototype與Object.prototype究竟是什么東西,誰更早出現之類的有趣問題。

2020.2.29更新

JS 究竟是先有雞還是有蛋,Object與Function究竟誰出現的更早,Function算不算Function的實例等問題雜談

2020.7.2更新

今天同事問了我一個很有的問題,代碼如下,問我分別輸出什么?

var a = 2;
console.log(a instanceof Number);
console.log(a.constructor === Number);

當我看到代碼,腦袋里第一想到了兩個true。我就對他說,如果是2個true你就不會考我了,於是在控制台輸出了一下,發現第一個是false,第二個是true。

同事就提出了疑問,說這個2照理來說也是Number構造函數的實例,怎么instanceof還為false,感覺之前的理解被顛覆了...

於是我打開百度,輸入1 instanceof Number定位找到了原因,理由很簡單,instanceof語法其實是object instanceof constructor,左邊必須是一個對象,不是對象就直接false了。

如果我們是通過new的數字,像這樣,你看它就沒問題:

var a = new Number(1);// 此時是個包裝對象
typeof a;//Object
a instanceof Number;// true

最后補充一點的是,JS總是會將包裝對象轉變為基礎類型,比如下面這個例子:

var a = new Number(1);
typeof (a+1);// number

柒 ❀ 參考

最詳盡的 JS 原型與原型鏈終極詳解,沒有「可能是」。(一)

你還認為JS中萬物皆對象?

[重新認識構造函數、原型和原型鏈](https://www.muyiy.cn/blog/5/5.1.html#引言]


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM