深入淺出JS的封裝與繼承


    JS雖然是一個面向對象的語言,但是不是典型的面向對象語言。Java/C++的面向對象是object - class的關系,而JS是object - object的關系,中間通過原型prototype連接,父類和子類形成一條原型鏈。本文通過分析JS的對象的封裝,再探討正確實現繼承的方式,然后討論幾個問題,最后再對ES6新引入的類class關鍵字作一個簡單的說明。

    JS的類其實是一個函數function,由於不是典型的OOP的類,因此也叫偽類。理解JS的類,需要對JS里的function有一個比較好的認識。首先,function本身就是一個object,可以當作函數的參數,也可以當作返回值,跟普通的object無異。然后function可以當作一個類來使用,例如要實現一個String類

1 var MyString = function(str){ 2     this.content = str; 3 }; 4 
5 var name = new MyString("hanMeimei"); 6 var addr = new MyString("China"); 7 console.log(name.content + " live in " + addr.content);

    第一行聲明了一個MyString的函數,得到一個MyString類,同時這個函數也是MyString的構造函數。第5行new一個對象,會去執行構造函數,this指向新產生的對象,第2行給這個對象添加一個content的屬性,然后將新對象的地址賦值給name。第6行又去新建一object,注意這里的this指向了新的對象,因此新產生的content和前面是不一樣的。

    上面的代碼在瀏覽器運行有一點問題,因為這段代碼是在全局作用域下運行,定義的name變量也是全局的,因此實際上執行var name = new MyString("")等同於window.name = new MyString(""),由於name是window已經存在的一個變量,作為window.open的第二個參數,可用來跨域的時候傳數據。但由於window.name不支持設置成自定義函數的實例,因此設置無效,還是保持默認值:值為"[object Object]"的String。解決辦法是把代碼的運行環境改成局部的,也就是說用一個function包起來:

(function(){ var name = new MyString("hanMeimei"); console.log(name.content); //正確,輸出hanMeimei
})(); 

    所以從此處看到,代碼用一個function包起來,不去污染全局作用域,還是挺有必要的。接下來,回到正題。

    JS里的每一個function都有一個prototype屬性,這個屬性指向一個普通的object,即存放了這個object的地址。這個function new出來的每個實例都會被帶上一個指針(通常為__proto__)指向prototype指向的那個object。其過程類似於:

var name = new MyString();             //產生一個對象,執行構造函數
name.__proto__ = MyString.prototype;   //添加一個__proto__屬性,指向類的prototype(這行代碼僅為說明)

    如下圖所示,name和addr的__proto__指向MyString的prototype對象:

    可以看出在JS里,將類的方法放在function的prototype里面,它的每個實例都將獲得類的方法。       

    現在為MyString添加一個toString的方法:

MyString.prototype.toString = function(){ return this.content; };

   MyString的prototype對象(object)將會添加一個新的屬性。

   這個時候實例name和addr就擁有了這個方法,調用這個方法:

console.log(name.toString()); //輸出hanMeimei
console.log(name + " lives in " + addr); //“+”連接字符時,自動調用toString,輸出hanMeimei lives in China

    這樣就實現了基本的封裝——類的屬性在構造函數里定義,如MyString的content;而類的方法在函數的prototype里添加,如MyString的toString方法。

    這個時候,考慮一個基礎的問題,為什么在原型上添加的方法就可以被類的對象引用到呢?因為JS首先會在該對象上查找該方法,如果沒有找到就會去它的原型上查找。例如執行name.toString(),第一步name這個object本身沒有toString(只有一個content屬性),於是向name的原型對象查找,即__proto__指向的那個object,發現有toString這個屬性,因此就找到了。

    要是沒有為MyString添加toString方法呢?由於MyString實際上是一個Function對象,上面定義MyString語法作用等效於:

//只是為了示例,應避免使用這種語法形式,因為會導致兩次編譯,影響效率
var MyString = new Function("str", "this.content = str");

    通過比較MyString和Function的__proto__,可以從側面看出MyString其實是Function的一個實例:

console.log(MyString.__proto__); //輸出[Function: Empty]
console.log(Function.__proto__); //輸出[Function: Empty]

    MyString的__proto__的指針,指向Function的prototype,通過瀏覽器的調試功能,可以看到,這個原型就是Object的原型,如下圖所示:

 

 

    因為Object是JS里面的根類,所有其它的類都繼承於它,這個根類提供了toString、valueOf等6個方法。

    因此,找到了Object原型的toString方法,查找完成並執行:

console.log(name.toString()); //輸出{ content: 'hanMeimei' }

    到這里可以看到,JS里的繼承就是讓function(如MyString)的原型的__proto__指向另一個function(如Object)的原型。基於此,寫一個自定義的類UnicodeString繼承於MyString

var UString = function(){ };

    實現繼承:

UString.prototype = MyString.prototype; //錯誤實現

    注意上面的繼承方法是錯誤的,這樣只是簡單的將UString的原型指向了MyString的原型,即UString和MyString使用了相同的原型,子類UString增刪改原型的方法,MyString也會相應地變化,另外一個繼承MyString如AsciiString的類也會相應地變化。依照上文分析,應該是讓UString的原型里的的__proto__屬性指向MyString的原型,而不是讓UString的原型指向MyString。也就是說,得讓UString有自己的獨立的原型,在它的原型上添加一個指針指向父類的原型:

UString.prototype.__proto__ = MyString.prototype;  //不是正確的實現

    因為__proto__不是一個標准的語法,在有些瀏覽器上是不可見的,如果在Firefox上運行上面這段代碼,Firefox會給出警告:

mutating the [[Prototype]] of an object will cause your code to run very slowly; instead create the object with the correct initial [[Prototype]] value using Object.create

    合理的做法應該是讓prototype等於一個object,這個object的__proto__指向父類的原型,因此這個object須要是一個function的實例,而這個function的prototype指向父類的原型,所以得出以下實現:

1 Object.create = function(o){ 2     var F = function(){}; 3     F.prototype = o; 4     return new F(); 5 }; 6 
7 UString.prototype = Object.create(MyString.prototype);

   代碼第2行,定義一個臨時的function,第3行讓這個function的原型指向父類的原型,第4行返回一個實例,這個實例的__proto__就指向了父類的prototype,第7行再把這個實例賦值給子類的prototype。繼承的實現到這里基本上就完成了。

   但是還有一個小問題。正常的prototype里面會有一個constructor指向構造函數function本身,例如上面的MyString:

    這個constructor的作用就在於,可在原型里面調用構造函數,例如給MyString類增加一個copy拷貝函數:

1 MyString.prototype.copy = function(){ 2 // return MyString(this.content); //這樣實現有問題,下面再作分析
3     return new this.constructor(this.content); //正確實現 4 }; 5 
6 var anotherName = name.copy(); 7 console.log(anotherName.toString());            //輸出hanMeimei
8 console.log(anotherName instanceof MyString);   //輸出true

   問題就於:Object.create的那段代碼里第7行,完全覆蓋掉了UString的prototype,取代的是一個新的object,這個object的__proto__指向父類即MyString的原型,因此UString.prototype.constructor在查找的時候,UString.prototype沒有constructor這個屬性,於是向它指向的__proto__查找,找到了MyString的constructor,因此UString的constructor實際上是MyString的constuctor,如下所示,ustr2實際上是MyString的實例,而不是期望的UString。而不用constructor,直接使用名字進行調用(上面代碼第2行)也會有這個問題。

var ustr = new UString(); var ustr2 = ustr.copy(); console.log(ustr instanceof UString); //輸出true
console.log(ustr2 instanceof UString); //輸出false
console.log(ustr2 instanceof Mystring); //輸出true

    所以實現繼承后需要加多一步操作,將子類UString原型里的constructor指回它自己:

UString.prototype.constructor = UString;

    在執行copy函數里的this.constructor()時,實際上就是UString()。這時候再做instanseof判斷就正常了:

console.log(ustr2 instanceof Ustring); //輸出true

    可以把相關操作封裝成一個函數,方便復用。

    基本的繼承核心的地方到這里就結束了,接下來還有幾個問題需要考慮。

    第一個是子類構造函數里如何調用父類的構造函數,直接把父類的構造函數當作一個普通的函數用,同時傳一個子類的this指針:

1 var UString = function(str){ 2 // MyString(str); //不正確的實現
3     MyString.call(this, str); 4 }; 5 
6 var ustr = new UString("hanMeimei"); 7 console.log(ustr + "");  //輸出hanMeimei

    注意第3行傳了一個this指針,在調用MyString的時候,這個this就指向了新產生的UString對象,如果直接使用第2行,那么執行的上下文是window,this將會指向window,this.content = str等價於window.content = str。

    第二個問題是私有屬性的實現,在最開始的構造函數里定義的變量,其實例是公有的,可以直接訪問,如下:

var MyString = function(str){ this.content = str; }; var str = new MyString("hello"); console.log(str.content);  //直接訪問,輸出hello

    但是典型的面向對象編程里,屬性應該是私有的,操作屬性應該通過類提供的方法/接口進行訪問,這樣才能達到封裝的目的。在JS里面要實現私有,得借助function的作用域:

var MyString = function(str){ this.sayHi = function(){ return "hi " + str; } }; var str = new MyString("hanMeimei"); console.log(str.sayHi()); //輸出hi, hanMeimei

     但是這樣的一個問題是,必須將函數的定義放在構造函數里,而不是之前討論的原型,導致每生成一個實例,就會給這個實例添加一個一模一樣的函數,造成內存空間的浪費。所以這樣的實現是內存為代價的。如果產生很多實例,內存空間會大幅增加,這個問題是不可忽略的,因此在JS里面實現屬性私有不太現實,即使在ES6的class語法也沒有實現。但是可以給類添加靜態的私有成員變量,這個私有的變量為類的所有實例所共享,如下面的案例:

var Worker;
(function(){
    var id = 1000;
    Worker = function(){
        id++;
    };
    Worker.prototype.getId = function(){
        return id;
    };
})();

var worker1 = new Worker();
console.log(worker1.getId());   //輸出1001
var worker2 = new Worker();
console.log(worker2.getId());   //輸出1002

    上面的例子使用了類的靜態變量,給每個worker產生唯一的id。同時這個id是不允許worker實例直接修改的。

    第三個問題是虛函數,在JS里面討論虛函數是沒有太大的意義的。虛函數的一個很大的作用是實現運行時的動態,這個運行時的動態是根據子類的類型決定的,但是JS是一種弱類型的語言,子類的類型都是var,只要子類有相應的方法,就可以傳參“多態”運行了。比強類型的語言如C++/Java作了很大的簡化。

    最后再簡單說下ES6新引入的class關鍵字

 1 //需要在strict模式運行
 2 'use strict';  3 class MyString{  4  constructor(str){  5         this.content = str;  6  }  7  toString(){  8         return this.content;  9  } 10     //添加了static靜態函數關鍵字
11  static concat(str1, str2){ 12         return str1 + str2; 13  } 14 } 15 
16 //extends繼承關鍵字
17 class UString extends MyString{ 18  constructor(str){ 19     //使用super調用父類的方法
20  super(str); 21  } 22 } 23 
24 var str1 = new MyString("hello"), 25     str2 = new MyString(" world"); 26 console.log(str1);                       //輸出MyString {content: "hello"}
27 console.log(str1.content);               //輸出hello
28 console.log(str1.toString());            //輸出hello
29 console.log(MyString.concat(str1, str2));//輸出hello world
30
31 var ustr = new UString("ustring"); 32 console.log(ustr); //輸出MyString {content: "ustring"} 33 console.log(ustr.toString()); //輸出ustring

    從輸出的結果來看,新的class還是沒有實現屬性私有的功能,見第27行。並且從第26行看出,所謂的class其實就是編譯器幫我們實現了上面復雜的過程,其本質是一樣的,但是讓代碼變得更加簡化明了。一個不同點是,多了static關鍵字,直接用類名調用類的函數。ES6的支持度還不高,最新的chrome和safari已經支持class,firefox的支持性還不太好。

    最后,雖然一般的網頁的JS很多都是小工程,看似不需要封裝、繼承這些技術,但是如果如果能夠用面向對象的思想編寫代碼,不管工程大小,只要應用得當,甚至結合一些設計模式的思想,會讓代碼的可維護性和擴展性更高。所以平時可以嘗試着這樣寫。

 

個人博客: http://yincheng.site/js-prototype

 

參考:

1. Professional Javascript for web developers(JavaScript高級程序設計) 第6章 Object - Oriented Programming

2. The Node Craftsman Book第一部分 Object-oriented JavaScript

3. Why is it necessary to set the prototype constructor Stackoverflow


免責聲明!

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



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