由幾道JS筆試題引發的知識點探究十五——JS面向對象編程


  JS初學者大都沒有認識到其強大的面向對象編程的特性,只是把JS當作一門簡單實用的腳本語言來用。也正因如此,JS程序員往往處於程序員鄙視鏈的最低端,很多人覺得JS是HTML一類的語言,甚至連語言都稱不上。事實完全不是如此,你若也有這種想法,說明你對JS的認識太淺薄了。要想正真邁入JS的大門,你必須深入了解JS面向對象編程的特性。下面就讓我為大家一一道來。

  一、創建對象

  既然是面向對象,那肯定先得有對象吧,要有對象,肯定得知道對象是什么吧,那JS中的對象是什么呢?在C++里我們知道,對象就是類或結構體的實例,對象是由其模板實例化得到的。但是JS中連類都沒有,它是怎么定義對象的呢?很簡單,JS里的對象用個花括號括在一起的一大堆鍵值對而已。鍵稱為對象的屬性名,理論是string類型的,但實際上你加不加引號都無所謂,因為JS對數據類型的概念就是這么任性;值就是對象屬性名的屬性值,屬性值既可以是五大基本數據類型,也可以是另外的對象,這樣對象里面又可以有對象,就可以創造出豐富多彩的JS對象了。OK,知道什么是對象之后,讓我們着手造個對象出來。

  方法一:Object()

  方法二:對象字面量

1 var bitch = new Object();
2 bitch.boobs = 'huge';
3 bitch['bf'] = {name:Jhon,age:22};        

  這個例子結合使用了方法一和方法二來創建對象,這兩個方法都十分基礎,但還是有兩點需要提一下:一、訪問對象屬性有兩種形式,.和[]。前者相對來說簡便一些,因為它不用在屬性名上加引號;后者當然也有它的優勢,那就是當屬性名中有空格這類的特殊字符時,前者就不起作用了,這時便是后者的天下。二、在調用Object()構造函數時,new運算符實際上是可以省略的,這點可以推廣到其他很多構造函數上,但有兩個是例外:String() 和 Number()。對這兩個函數而言,如果不加new,只是作一次數據類型的轉換,得到的將是基本數據類型的值;而帶new運算符的話,得到的將是String/Number的實例。

  這兩種方法創建對象簡單直觀,但也存在問題,那就是無法批量生產對象。由此,工廠模式應運而生。

  方法三:工廠模式(注意區別於設計模式中的工廠模式)

function Bitch(boobs,bfName,bfAge){
    var bitch = new Object();
    bitch.boobs = boobs;
    bitch.bf = {name:bfName,age:bfAge};
    bitch.cry = function(){console.log('Crying');}
  return bitch;
}
  bitch=Bitch('huge','Jhon',22);

 

  實際上就是用函數封裝了特定借口的細節,避免在批量生產對象時出現太多重復代碼。工廠模式解決了對象的批量生產問題,但還有個問題沒有解決——對象的識別問題。也就是說,這樣創建出來的對象是獨立的個體,跟其他對象沒一毛錢關系,即使是用同一個函數創建出來的對象,也是互不相識的,而這顯然不是我們想要的。由此,構造函數模式應運而生。

  方法三:構造函數模式

function Bitch(boobs,bfName,bfAge){
    this.boobs=boobs;
    this.bf={name:bfName, age:bfAge};
   this.cry = function(){console.log('Crying');}
}
bitch=new Bitch('huge','Jhon',22);

 

  對比工廠模式可以發現構造函數模式的特點:一,沒有顯示創建對象,而是直接將對象屬性賦給了this指針;二,沒有return語句;三,調用時需要用new運算府。其實,最關鍵的地方在於這個new運算符,如果不加這個new,那構造函數就是個普通函數;而任何普通函數在調用時前面加new運算符的話就會變成構造函數。new運算符的作用下,構造函數的執行過程大致如下:創建一個新對象--->將這個對象賦給this指針--->執行函數中的代碼--->返回這個對象(注意,此處特指沒有任何返回值的構造函數,如果構造函數顯示的返回了值的話,情況有所不同,我會在《JavaScript構造函數》一文中詳細解釋)。如前所述,構造函數模式的存在是為了解決工廠模式的對象識別問題,那這個問題解決了嗎?

bitch instanceof Bitch // true
bitch instanceof Object // true
bitch.constructor === Bitch // true

  由以上代碼可見,對象識別是沒什么問題了,但接着又發現了一個問題——對象的所有函數屬性在每個對象上都要重新創建一遍,而這些函數實際上完全相同,這樣做豈不是暴殄天物。因此,構造函數還需要改進。下面是一種改進方法:

function cry(){console.log('Crying');}
function Bitch(boobs,bfName,bfAge){
    this.boobs=boobs;
    this.bf={name:bfName, age:bfAge};
   this.cry = cry;
}
bitch=new Bitch('huge','Jhon',22);

  初看起來,這種方法很好地解決了上述問題,但其實有一個致命的弊端——你把對象的函數屬性都寫成全局函數了,那全局環境豈不是被你無情地玷污了?看來這中方法是被PASS了,那又該怎么解決函數在每個對象上重復創建的問題呢?原型模式由此誕生!

  方法四:原型模式

function Bitch(){};
Bitch.prototype.boobs = 'huge';
Bitch.prototype.bf={name:'John', age:22}
Bitch.prototype.cry=function(){console.log('Crying');}

  這就是所謂的原型模式。當然,要看懂這段代碼得先弄明白原型究竟是個什么東西。說白了,原型其實不過是個屬性。不過不是我們自定義的屬性,而是在我們定義函數的時候解析器自動給函數添加的一個屬性,是函數與生俱來的屬性。這個屬性的名字叫做prototype,屬性值是個對象,我們就是要對這個名為prototype的對象動手腳。看上面的代碼,我們將原本應該在構造函數內部給this指針添加的對象統統添加到了函數的原型對象上,為什么要這樣做呢?因為當我們調用構造函數構造對象后,這個構造出來的對象會保存對構造函數原型對象的引用。而且,當我們訪問該對象的某個屬性的時候,JS解析器會先問這個對象:“嘿,你有這個屬性嗎?”,如果有,很好,直接返回;如果沒有,解析器不會就此善罷甘休,而是根據該對象保存的其構造函數的原型對象的引用,找到那個原型對象並問它:“嘿,你有這個屬性嗎”,如果有,很好,直接返回;如果沒有,解析器還是不會就此善罷甘休,而是再去找這個原型對象的構造函數的原型對象(這里感覺很繞口,其實很簡單,只要記住原型對象是構造函數的屬性,而不是構造函數構造出的對象的屬性,構造函數構造出的對象只是知道到那里去找這個原型對象罷了),然后一直這樣作着不懈的努力,直到在某個原型對象上找到這個屬性或是碰到原型鏈的頭為止。自然而然地,我們引出了原型鏈的概念。原型鏈的出現是因為對象都有個對應的原型對象,而原型對象也是對象,它也有自己對應的原型對象,這樣一來不久構成原型鏈了嗎?那原型鏈的頭在哪里呢?原型鏈的頭在Object.prototype。Object.prototype本身是Object對象,但這個對象有一點特殊,因為它沒有對應的原型對象。

  Ok,扯了一大段,繞得有點暈,剛開始我也不理解,不過見多了自然就明白了。回到上面的代碼,我們將屬性都添加在原型對象上,當我們構造對象時,實際上得到的是個空對象,但我們可以訪問它的相關屬性,因為解析器會不余遺力地去找原型對象。但原型對象只有一個(還是上面那句話,原型對象是構造函數的屬性),無論我們造多少對象出來,都不會重復地創建屬性——也就是說,構造函數模式的問題得到了很好地解決。但是,原型對象又帶來一個問題——所有屬性都是一樣的,那我創建的對象豈不是一點個性都毛有?這確實是個問題,不過這個問題很容易解決——組合使用構造函數模式和原型模式,前者負責個性,后者負責共性,二者取長補短相輔相成。最終代碼如下所示(所說是最終代碼,實際上還有很多創建對象的模式,不過應用並不廣泛,在此不作敘述):

function Bitch(boobs,bfName,bfAge){
    this.boobs=boobs;
    this.bf={name:bfName, age:bfAge};
}
Bitch.prototype.cry = function(){console.log('Crying');};

  二、私有屬性

  如前所述,我們最終以一個較為完美的方式創造出了對象,但還有問題——對象的所有屬性都是公開的,一點封裝性都沒有你也敢妄稱面向對象編程?下面就來解決封裝性的問題。

  首先我們要明確,JS是沒有類似public/private之類的訪問控制符的,JS甚至連塊級作用域都沒有,那JS如何實現封裝呢?這就要請出我們下一位大爺(上一位大爺是原型)——閉包。閉包何方神聖也?我個人簡單地理解,閉包就是個函數(這句話開始恐有爭議,不過這是我為了方便理解才如此妄加論斷,請不要死扣字眼),不過不是一般的函數,而是函數中的函數,姑且成為內部函數。不過這個內部函數有點牛,因為它可以隨意訪問它外部函數的變量,不僅如此,它還手握着那些外部函數變量的生殺予奪之權,這句話是什么意思呢?讓我們來看一個例子:

function husband(money){
    var car = 3,
        house = 2;
    function wife(){
        return {
            money:money,
            car:car,
            house:house
    }; }
return wife; } var wife = husband(1000), assets = wife(); console.log(assets); // Object {money: 1000, car: 3, house: 2}

  理論上說,當語句wife = husband() 執行完畢以后,husband()函數的作用域便被銷毀了,它里面那些個變量應該是不復存在了。但代碼執行結果告訴我們,這些變量並沒有在husband()執行完畢后被立即銷毀,因為husband()有個內部函數wife(),也就是閉包,導致husband()對自己內部的變量已經沒有生殺予奪之權了,這等大權如今掌握在wife()手里。因此,在wife()執行完畢之前,這些變量是不會被銷毀的。這就是前面那句話的含義所在。

  目前,我們知道什么什么是閉包,也知道了閉包掌握着外部函數的變量的生死大權,那該如何利用閉包的這種特性來創建私有屬性呢?

function Bitch(boo,bfName,bfAge){
    var boobs = boo; // 私有屬性
    this.bf={name:bfName, age:bfAge}; // 公共屬性
    this.watchBoobs = function(){ return 'huge'; }; // 私有屬性的訪問接口
}
Bitch.prototype.cry = function(){console.log('Crying');};

  這段代碼的關鍵在於沒有在對象上直接創建應該保持私有的屬性(因為直接添加在對象上的屬性無法保持私有),而是在函數中簡單的聲明一個私有變量,然后通過閉包(this.watchBoobs)的方式提供該私有變量對外的訪問接口。這樣以來,外部無法直接訪問boobs,只能通過閉包watchBoobs進行訪問,這只能遠觀而不能褻玩不正是私有屬性所要的特點嗎?至此,私有屬性已經成功實現,也就是說封裝性已經搞定了,下面來搞定面向對象編程的第二大特性——繼承。

  三、繼承

  在深入理解了原型和原型鏈的概念之后,我們不難發現原型是JS里實現繼承的最佳工具。下面看一段具體實現:

function Sup(a){
    this.a = a;
}
Sup.prototype.foo = function(){console.log('Function of Sup.prototype');}
function Sub(a,b){
    this.a = a;
    this.b = b;
}
Sub.prototype = new Sup();
Sub.prototype.bar = function(){console.log('Function of Sub.prototype');}

  利用原型實現繼承的本質就是重寫函數的原型。回想一下,函數的原型的是什么——是個對象(默認是Object對象);那又該怎樣重寫函數的原型——把一個新對象賦值給它;這個新原型有什么要求——它正是父類的對象。由此,我們才有了這種寫法:

Sub.prototype = new Sup();

  注意比較JS繼承與傳統OO語言繼承之間的差別,比如C++的繼承是這樣寫的:

class A:public B{}

  我覺得區別主要有兩點:一,傳統OO語言的繼承是類的繼承,是抽象概念之間的繼承,實現繼承並不需要父類的實例;而JS的繼承則是實例的繼承,子類繼承的是父類的一個實例。二、傳統OO語言的繼承分public/private/protected等不同的繼承方式,而JS本身連私有變量的概念都沒有,就更不可能區分共有繼承和私有繼承了(那JS可以實現類是功能嗎?有該如何實現呢?)。

  上述JS實現繼承的方式稱為“原型模式”,這種方式存在幾個缺點:

  一、在創建子類實例時,不能向父類的構造函數傳遞參數(如傳統OO語言有這種寫法:

A:A(int a,int b){
    B:B(b);
    this.a = a;
}

  二、當父類屬性存在引用類型值時,會造成致命問題。幾個例子來說:

function Sup(a){
    this.a = a;
}
Sup.prototype.foo = function(){console.log('Function of Sup.prototype');}
function Sub(b){
    this.b = b;
}
Sub.prototype = new Sup([1,2,3]);
Sub.prototype.bar = function(){console.log('Function of Sub.prototype');}

var sub1 = new Sub(1),
    sub2 = new Sub(2);
sub1.a.push(4);
console.log(sub2.a);    // 對sub1的改動影響到了sub2

  前面說過,JS的繼承實際上是繼承了父類的某個實例。當這個父類實例的某個屬性是引用類型值,而我們在這個值上調用一些方法改變這個值時,將會影響到所有子類的實例。但是,請注意這里改變這個引用類型值的方式(sub1.a.push(4)),如果我采用下面這種方式:

sub2.a = null;
console.log(sub1.a);    //  sub1沒有受到影響

  這是因為這種直接為屬性賦值實際上是在子類的實例上動態添加了一個屬性,而該屬性敲好與原型對象的屬性重名,則原型對象的屬性被覆蓋,因此其它子類實例實際上不會受到影響,這一點要區分清楚。

  OK,“原型模式”的缺點已經講完了,那該如何解決呢?很簡單,很創建對象時一樣,借用構造函數模式,將二者結合起來,取長補短即可。完整實現如下:

function Sup(a){
    this.a = a;
}
Sup.prototype.foo = function(){console.log('Function of Sup.prototype');}
function Sub(a,b){
    Sup.call(this,a);
    this.b = b;
}
Sub.prototype = new Sup();
Sub.prototype.construtor = Sub;
Sub.prototype.bar = function(){console.log('Function of Sub.prototype');}

  看似已經很完美了,但還存在兩個值得思考的問題:

  一、Sup.call(this,a);

  這句既解決了原型模式中創建子類的實例時無法向父類的構造函數傳遞參數的問題,又解決了因原型對象存在引用類型值的屬性導致子類實例可能互相影響的問題(因為在子類上將父類的屬性全部復制了一遍,父類屬性都被覆蓋了)。但是按照這種寫法,當我們要擴展父類時,比如父類變為function Sup(a,b)時,子類擴展起來不方便。因此,有如下改進寫法(這個想法我一位面試官告訴我的):

Sup.apply(this,arguments);

  二、Sub.prototype.construtor = Sub;

  這句話有必要寫嗎?仍然是上面那位一位面試官,他認為這樣寫有必要。因為如果不這樣寫,那在用instanceof操作符判斷實例類型時會出問題,即 sub1 instanceof Sub 會返回false。但在Chrome上測試,我發現即使沒有這句,sub1 instanceof Sub還是會返回true的。《JS高程》一書說,只要是在原型鏈中出現過的構造函數,都會返回true。那么我認為,那位面試官的說法是錯誤的,實際上這句話唯一的好處就在於 sub1.constructor 會指向 Sub。

 

  Ok,寫了三天終於完成了這篇文章。基本上將JS OOP各方面的基礎概念都解釋清楚了,對他人是一次很好的技術分享,對自己是一次很好的知識復習。


免責聲明!

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



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