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