我對JavaScript物件的理解

莫凡_Tcg發表於2019-03-04

前言

JavaScript這門語言除了基本型別都是物件,可以說JavaScript核心就是物件,因此理解JavaScript物件及其種種特性至關重要,這是內功。本文介紹了我對es5物件,原型, 原型鏈,以及繼承的理解

注意(這篇文章特別長)這篇文章僅僅是我個人對於JavaScript物件的理解,並不是教程。這篇文章寫於我剛瞭解js物件之後。文章肯定有錯誤之處,還望讀者費心指出,在下方評論即可^-^

什麼是JavaScript物件

var person = {   //person就是物件,物件都有各種屬性,每個屬性又都對應著自己的值
    //鍵值對形式
    name: "Mofan",//可以包含字串
    age: 20,//數字
    parents: [  //陣列
        "Daddy",
        "Mami",
    ]
    sayName: function(){  //函式
        console.log(this.name);
    },
    features: {   //物件
        height: "178cm",
        weight: "60kg",
    }
}複製程式碼

js裡除了基本型別外所有事物都是物件:

  • 函式是物件function sayName(){} ——sayName是函式物件

  • 陣列是物件var arr = new Array() ——arr是陣列物件

為什麼JavaScript要這麼設計呢?我覺得首先這樣一來,統一了資料結構,使JavaScript成為一門程式設計風格非常自由化的指令碼語言:無論定義什麼變數,統統var;其次,JavaScript物件都有屬性和方法,函式陣列都是物件,呼叫引用就會非常靈活方便;再者,為了構建原型鏈?

建立物件的幾種方式

  • Object()模式使用物件字面量:var obj={...}就像上面那樣或者使用原生建構函式Object():

    var person = new Object();
    person.name = "Mofan";
    person.sayName = function(){
        console.log(this.name);
        };
    console.log(person.name);//Mofan
    obj.sayName();//Mofan
    複製程式碼
  • 利用函式作用域使用自定義建構函式模式模仿類(構造器模式):

    function Person(name,age){
        this.name = name;
        this.age = age;
        this.print = function(){
            console.log(this.name + this.age)
            };
    }
    var person = new Person("Mofan",19);
    console.log(person.name+person.age);//Mofan19
    person.print();//Mofan19複製程式碼
  • 原型模式:

    function Person(){}
    //可以這樣寫
    /*Person.prototype.name = "Mofan";
    Person.prototype.age = 19;
    Person.prototype.print = function(){
        console.log(this.name+this.age);
    }*/
    //推薦下面這樣寫,但兩種方式不能混用!因為下面這種方式實際上重寫了
    //Person原型物件,如果兩者混用,後面賦值方式會覆蓋前面賦值方式
    Person.prototype = {
        name:"Mofan",
        age:19,
        print:function(){
            console.log(this.name+this.age);
        }
    }
    var person = new Person();
    console.log(person.name+person.age);//Mofan19
    person.print();//Mofan19複製程式碼
  • 組合建構函式模式和原型模式:

    function Person(name,age){
        //這裡面初始化屬性
        this.name = name;
        this.age = age;
        ...
    }
    Person.prototype = {
        //這裡面定義公有方法
        print:function(){
            console.log(this.name+this.age);
        },
        ...
    }
    var person = new Person("Mofan",19);
    console.log(person.name+person.age);//Mofan19
    person.print();//Mofan19複製程式碼
  • 動態建立原型模式:

    function Person(name,age){
        //初始化屬性
        this.name = name;
        this.age = age;
        //在建立第一個物件(第一次被呼叫)時定義所有公有方法,以後不再呼叫
        if(typeof this.print !="function"){
            Person.prototype.print =function(){
                    console.log(this.name+this.age);
                };
            Person.prototype.introduction=function(){
                    console.log("Hi!I`m "+this.name+",I`m "+this.age);
                };
                //如果採用物件字面量對原型新增方法的話,第一次建立的物件將不會有這些方法
            };
            
        
    }
    var person = new Person("Mofan",19);
    person.print();//Mofan19
    person.introduction();//Hi!I`m Mofan,I`m 19複製程式碼

還有一些模式用的場景比較少

這些模式的應用場景

怎麼會有這麼多的建立模式?其實是因為js語言太靈活了,因此前輩們總結出這幾種建立方式以應對不同的場景,它們各有利弊。

  • 第一種方式,使用字面量或者使用建構函式Object()常用於建立普通物件儲存資料等。它們的原型都是Object,彼此之間沒有什麼關聯。事實上,下面建立方式都是一樣的:

    var o1 = {};//字面量的表現形式
    var o2 = new Object;
    var o3 = new Object();
    var o4 = new Object(null);
    var o5 = new Object(undefined);
    var o6 = Object.create(Object.prototype);//等價於 var o = {};//即以 Object.prototype 物件為一個原型模板,新建一個以這個原型模板為原型的物件複製程式碼

我對JavaScript物件的理解

  • 第二種方式,利用函式作用域模仿類,這樣就可以在建立物件時傳參了,可以建立不同屬性值得物件,實現物件定製。不過print方法也定義在了建構函式裡面,如果要把它當做公有方法的話,這樣每new一個物件,都會有這個方法,太浪費記憶體了。可以這樣修改一下構造器模式:

    //構造器方法2
    function print(){      //定義一個全域性的 Function 物件,把要公有的方法拿出來
         console.log(this.name + this.age);
    }
    
    function Person(name,age){
        this.name = name;
        this.age = age;
   
        this.print = print.bind(this);//每個 Person 物件共享同一個print 方法版本(方法有自己的作用域,不用擔心變數被共享)
    }
    var person = new Person("Mofan",19);
    console.log(person.name+person.age);//Mofan19
    person.print();//Mofan19
    複製程式碼

然而這樣看起來很亂,也談不上類的封裝性。還是使用原型吧

  • 第三種方式,純原型模式,不管是屬性還是方法都新增到原型裡面去了,這樣做好處是很省記憶體,但是應用範圍就少了,更多的物件 內部的屬性是需要定製的,而且一旦更改原型,所有這個原型例項都會跟著改變。因此可以結合建構函式方式來實現對物件的定製,於是就有了第四種方式——組合建構函式模式與原型模式,可以定製的放在構造器裡,共有的放在原型裡,這也符合構造器和原型的特性。“這是es5中使用最廣泛、認同度最高的建立自定義型別的方法”—《JavaScript高階程式設計》第三版

  • 第五種方式,動態原型模式,出現這種方式是因為有些物件導向開發人員習慣了類建構函式,於是對這種獨立出來的建構函式和原型感到困惑和不習慣。於是,就出現了把定義原型也寫進建構函式裡的動態原型模式。上面在動態原型模式程式裡面講“如果採用物件字面量對原型新增方法的話,第一次建立的物件將不會有這些方法”這是因為在if語句執行以前,第一個物件已經被建立了,然後執行if裡面的語句,如果採用物件字面量給原型賦值,就會導致原型在例項建立之後被重寫,建立的第一個例項就會失去與原型的連結,也就沒有原型裡的方法了。不過以後建立的物件就可以使用原型裡的方法了,因為它們都是原型被修改後建立的。

原型是什麼

在JavaScript中,原型就是一個物件,沒必要把原型和其他物件區別對待,只是通過它可以實現物件之間屬性的繼承。任何一個物件也可以成為原型。之所以經常說物件的原型,實際上就是想找物件繼承的上一級物件。物件與原型的稱呼是相對的,也就是說,一個物件,它稱呼繼承的上一級物件為原型,它自己也可以稱作原型鏈下一級物件的原型。

一個物件內部的[[Prototype]]屬性生來就被建立,它指向繼承的上一級物件,稱為原型。函式物件內部的prototype屬性也是生來就被建立(只有函式物件有prototype屬性),它指向函式的原型物件(不是函式的原型!)。當使用var instance = new Class();這樣每new一個函式(函式被當做建構函式來使用)建立例項時,JavaScript就會把這個原型的引用賦值給例項的原型屬性,於是例項內部的[[Prototype]]屬性就指向了函式的原型物件,也就是prototype屬性。

原型真正意義上指的是一個物件內部的[[Prototype]]屬性,而不是函式物件內部的prototype屬性,這兩者之間沒有關係!對於一個物件內部的[[Prototype]]屬性,不同瀏覽器有不同的實現:

     var a = {}; 
 
     //Firefox 3.6+ and Chrome 5+ 
     Object.getPrototypeOf(a); //[object Object]   
     
     //Firefox 3.6+, Chrome 5+ and Safari 4+ 
    a.__proto__; //[object Object]   
     
     //all browsers 
     a.constructor.prototype; //[object Object]複製程式碼

之所以函式物件內部存在prototype屬性,並且可以用這個屬性建立一個原型,是因為這樣以來,每new一個這樣的函式(函式被當做建構函式來使用)建立例項,JavaScript就會把這個原型的引用賦值給例項的原型屬性,這樣以來,在原型中定義的方法等都會被所有例項共用,而且,一旦原型中的某個屬性被定義,就會被所有例項所繼承(就像上面的例子)。這種操作在效能和維護方面其意義是不言自明的。這也正是建構函式存在的意義(JavaScript並沒有定義建構函式,更沒有區分建構函式和普通函式,是開發人員約定俗成)。下面是一些例子:

    var a = {}    //一個普通的物件
    function fun(){}   //一個普通的函式
    //普通物件沒有prototype屬性
    console.log(a.prototype);//undefined
    console.log(a.__proto__===Object.prototype);//true
    
    //只有函式物件有prototype屬性
    console.log(fun.prototype);//Object
    console.log(fun.__proto__===Function.prototype);//trueconsole.log(fun.prototype.__proto__===Object.prototype);//true
    console.log(fun.__proto__.__proto__===Object.prototype);//true
    console.log(Function.prototype.__proto__===Object.prototype);//true
    console.log(Object.prototype.__proto__);//null複製程式碼

當執行console.log(fun.prototype);輸出為我對JavaScript物件的理解可以看到,每建立一個函式,就會建立prototype屬性,這個屬性指向函式的原型物件(不是函式的原型),並且這個原型物件會自動獲得constructor屬性,這個屬性是指向prototype屬性所在函式的指標。而__proto__屬性是每個物件都有的。

接著上面再看:

    function Person(){}//建構函式,約定首字母大寫
    var person1 = new Person();//person1為Person的例項console.log(person1.prototype);//undefined
    console.log(person1.__proto__===Person.prototype);//true
    console.log(person1.__proto__.__proto__===Object.prototype);//true
    console.log(person1.constructor);//function Person(){}
    
    //函式Person是Function建構函式的例項
    console.log(Person.__proto__===Function.prototype);//true
    //Person的原型物件是建構函式Object的例項
    console.log(Person.prototype.__proto__===Object.prototype);//true複製程式碼

person1和上面那個普通的物件a有區別,它是建構函式Person的例項。前面講過:

當使用var instance = new Class();這樣每new一個函式(函式被當做建構函式來使用)建立例項時,JavaScript就會把這個原型的引用賦值給例項的原型屬性,於是例項內部的[[Prototype]]屬性就指向了函式的原型物件,也就是prototype屬性。

因此person1內部的[[Prototype]]屬性就指向了Person的原型物件,然後Person的原型物件內部的[[Prototype]]屬性再指向Object.prototype,相當於在原型鏈中加了一個物件。通過這種操作,person1就有了建構函式的原型物件裡的方法。

另外,上面程式碼console.log(person1.constructor);//function Person(){}中,person1內部並沒有constructor屬性,它只是順著原型鏈往上找,在person1.__proto__裡面找到的。

可以用下面這張圖理清原型、建構函式、例項之間的關係:我對JavaScript物件的理解

繼承

JavaScript並沒有繼承這一現有的機制,但可以利用函式、原型、原型鏈模仿。下面是三種繼承方式:

類式繼承

    //父類
    function SuperClass(){
        this.superValue = "super";
    }
    SuperClass.prototype.getSuperValue = function(){
        return this.superValue;
​
    };
    //子類
    function SubClass(){
        this.subValue = "sub";
    }
    //類式繼承,將父類例項賦值給子類原型,子類原型和子類例項可以訪問到父類原型上以及從父類建構函式中複製的屬性和方法
    SubClass.prototype = new SuperClass();
    //為子類新增方法
    SubClass.prototype.getSubValue = function(){
        return this.subValue;
    }
    
    //使用
    var instance = new SubClass();
    console.log(instance.getSuperValue);//super
    console.log(instance.getSubValue);//sub複製程式碼

這種繼承方式有很明顯的兩個缺點:

  • 例項化子類時無法向父類建構函式傳參

  • 如果父類中的共有屬性有引用型別,就會在子類中被所有例項所共用,那麼任何一個子類的例項更改這個引用型別就會影響其他子類例項,可以使用建構函式繼承方式解決這一問題

建構函式繼承

     //父類
    function SuperClass(id){
        this.superValue = ["big","large"];//引用型別
        this.id = id;
    }
    SuperClass.prototype.getSuperValue = function(){
        return this.superValue;
​
    };
    //子類
    function SubClass(id){
        SuperClass.call(this,id);//呼叫父類建構函式並傳參
        this.subValue = "sub";
    }
     var instance1 = new SubClass(10);//可以向父類傳參
     var instance2 = new SubClass(11);
     
    instance1.superValue.push("super");
    console.log(instance1.superValue);//["big", "large", "super"]
    console.log(instance1.id);//10
    console.log(instance2.superValue);["big", "large"]
    console.log(instance2.id);//11
    console.log(instance1.getSuperValue());//error複製程式碼

這種方式是解決了類式繼承的缺點,不過在程式碼的最後一行你也看到了,沒有涉及父類原型,因此違背了程式碼複用的原則。所以組合它們:

組合繼承

     function SuperClass(id){
        this.superValue = ["big","large"];//引用型別
        this.id = id;
    }
    SuperClass.prototype.getSuperValue = function(){
        return this.superValue;
​
    };
    //子類
    function SubClass(id,subValue){
        SuperClass.call(this,id);//呼叫父類建構函式並傳參
        this.subValue = subValue;
    }
     SubClass.prototype = new SuperClass();
      SubClass.prototype.getSubValue = function(){
        return this.subValue;
    }
    
     var instance1 = new SubClass(10,"sub");//可以向父類傳參
     var instance2 = new SubClass(11,"sub-sub");
​
    instance1.superValue.push("super");
    console.log(instance1.superValue);//["big", "large", "super"]
    console.log(instance1.id);//10
    console.log(instance2.superValue);["big", "large"]
    console.log(instance2.id);//11
    console.log(instance1.getSuperValue());["big", "large", "super"]
    console.log(instance1.getSubValue());//sub
    console.log(instance2.getSuperValue());//["big", "large"]
    console.log(instance2.getSubValue());//sub-sub複製程式碼

嗯,比較完美了,但是有一點,父類建構函式被呼叫了兩次,這就導致第二次呼叫也就是建立例項時重寫了原型屬性,原型和例項都有這些屬性,顯然效能並不好。先來看看克羅克福德的寄生式繼承:

    function object(o){
        function F(){};
        F.prototype = o;
        return new F();
   }
    function createAnnther(original){
        var clone = object(original);
        clone.sayName = function(){
            console.log(this.name);
        }
        return clone;
   }
    var person = {
        name:"Mofan",
        friends:["xiaoM","Alice","Neo"],
   };
    var anotherPerson = createAnnther(person);
    anotherPerson.sayName();//"Mofan"
}複製程式碼

就是讓一個已有物件變成新物件的原型,然後再在createAnother函式里加強。你也看到了,person就是一個普通物件,所以這種寄生式繼承適合於根據已有物件建立一個加強版的物件,在主要考慮通過已有物件來繼承而不是建構函式的情況下,這種方式的確很方便。但缺點也是明顯的,createAnother函式不能複用,我如果想給另外一個新建立的物件定義其他方法,還得再寫一個函式。仔細觀察一下,其實寄生模式就是把原型給了新物件,物件再加強。

等等,寫到這個地方,我腦子有點亂,讓我們回到原點:繼承的目的是什麼?應該繼承父類哪些東西?我覺得取決於我們想要父類的什麼,我想要父類全部的共有屬性(原型裡)並且可以自定義繼承的父類私有屬性(建構函式裡)!前面那麼多模式它們的缺點主要是因為這個:

    SubClass.prototype = new SuperClass();複製程式碼

那為什麼要寫這一句呢?是隻想要繼承父類的原型嗎?如果是為什麼不這麼寫:

    SubClass.prototype = SuperClass.prototype;複製程式碼

這樣寫是可以繼承父類原型,但是風險極大:SuperClass.prototype屬性它是一個指標,指向SuperClass的原型,如果把這個指標賦給子類prototype屬性,那麼子類prototype也會指向父類原型。對SubClass.prototype任何更改,就是對父類原型的更改,這顯然是不行的。

寄生組合式繼承

但出發點沒錯,可以換種繼承方式,看看上面的寄生式繼承裡的object()函式,如果把父類原型作為引數,它返回的物件實現了對父類原型的繼承,沒有呼叫父類建構函式,也不會對父類原型產生影響,堪稱完美。

    function object(o){
        function F(){};
        F.prototype = o;
        return new F();
   }
    function inheritPrototype(subType,superType){
        var proto = object(superType.prototype);
        proto.constructor = subType;//矯正一下construcor屬性
        subType.prototype = proto;
   }
​
   function SuperClass(id){
        this.superValue = ["big","large"];//引用型別
        this.id = id;
    }
    SuperClass.prototype.getSuperValue = function(){
        return this.superValue;
​
    };
    //子類
    function SubClass(id,subValue){
        SuperClass.call(this,id);//呼叫父類建構函式並傳參
        this.subValue = subValue;
    }
   inheritPrototype(SubClass,SuperClass);//繼承父類原型
    SubClass.prototype.getSubValue = function(){
        return this.subValue;
    }
    var instance1 = new SubClass(10,"sub");//可以向父類傳參
     var instance2 = new SubClass(11,"sub-sub");
​
    instance1.superValue.push("super");
    console.log(instance1.superValue);//["big", "large", "super"]
    console.log(instance1.id);//10
    console.log(instance2.superValue);//["big", "large"]
    console.log(instance2.id);//11
    console.log(instance1.getSuperValue());//["big", "large", "super"]
    console.log(instance1.getSubValue());//sub
    console.log(instance2.getSuperValue());//["big", "large"]
    console.log(instance2.getSubValue());//sub-sub複製程式碼

解決了組合繼承的問題,只呼叫了一次父類建構函式,而且還能保持原型鏈不變,為什麼這麼說,看對寄生組合的測試:

    console.log(SubClass.prototype.__proto__===SuperClass.prototype);//ture
    console.log(SubClass.prototype.hasOwnProperty("getSuperValue"));//false複製程式碼

因此,這是引用型別最理想的繼承方式。

總結

建立用於繼承的物件最理想的方式是組合建構函式模式和原型模式(或者動態原型模式),就是讓可定義的私有屬性放在建構函式裡,共有的放在原型裡;繼承最理想的方式是寄生式組合,就是讓子類的原型的[[prototype]]屬性指向父類原型,然後在子類建構函式裡呼叫父類建構函式實現自定義繼承的父類屬性。

JavaScript物件總有一些讓我困惑的地方,不過我還會繼續探索。我在此先把我瞭解的記錄下來,與各位共勉。錯誤的地方請費心指出,我將感謝您的批評指正。

本文為作者原創,轉載請註明本文連結,作者保留權利。

參考文獻:[1] www.cnblogs.com/chuaWeb/p/5…[2] www.cnblogs.com/xjser/p/496…[3] javascriptweblog.wordpress.com/2010/06/07/…

相關文章