概述
JavaScript中的物件導向是基於原型鏈來實現的,這不同於其他語言複製拷貝的方式。我覺得原型鏈的好處是節約記憶體,提高效能,缺點可能就是不那麼容易理解。
下面我們就來循序漸進的通過原型鏈,來理解JavaScript中的物件導向。
物件導向的概念是為了解決什麼問題?
如果我們想建立一個具有一定功能的集合,在JavaScript中我們可以這樣寫:
var Animal = {
name: 'kitty',
sleep: function(){
console.log(this.name + " is sleeping");
}
};
複製程式碼
即通過物件的形式,將一些屬性(如name
)或方法(如sleep
)包裝在一起,這樣就形成了邏輯上的集合。
但是如果我們想再建立一個類似的物件呢?最直接的方式是這樣:
var Animal = {
name: 'kate',
sleep: function(){
console.log(this.name + " is sleeping");
}
};
複製程式碼
也就是重新再寫一個物件,修改其中的部分屬性。這種方式無疑是非常僵硬的,因此可以通過這種“工廠函式“的方法:
function createAnimal(name){
var obj = {}
obj.name = name;
obj.sleep = function(){
console.log(this.name + " is sleeping");
}
return obj;
}
複製程式碼
可以看到,”工廠函式“的作用,即在內部建立一個空的物件,然後將屬性和方法填充進去,最後再返回這個物件。
這樣看起來似乎沒什麼問題,但是仔細想想,其實我們只不過是想按照一定的模式建立一個物件,邏輯上來講,我們要做的應該只是提供模式,而新建一個空物件,返回一個空物件的操作其實沒有必要由我們來完成,因此JavaScript就提出了一個new
關鍵字。這個關鍵字的用法很簡單:
function Animal(name){
this.name = name;
this.sleep = function(){
console.log(this.name + " is sleeping");
}
}
var kitty = new Animal("kitty");
//如果我們在瀏覽器控制檯列印kitty,得到的是這樣的一個物件:
{
name: "kitty"
sleep: function sleep()
}
複製程式碼
即在呼叫Animal函式前,加上一個new關鍵字,告訴編譯器,這個函式是一個”建構函式“,我們應該呼叫這個函式,並且根據函式所指定的規則,包裝一個物件,並返回這個物件。
tips:後面在瞭解的原型鏈後我們會自己實現一個new
JavaScript中的原型鏈
前面我們已經知道了為什麼需要物件導向,下面我們就來看看JavaScript語言處理物件導向的巧妙之處——原型鏈
先來看一張圖
先來從上往下講解一下這張圖:
- 函式(
function
)都有一個prototype
屬性,指向一個原型物件。 - 原型物件都有一個
constructor
屬性,指向這個原型物件對應的建構函式 - 普通物件都有一個
__protor__
(ES並沒有規定這個屬性的標準名稱,而瀏覽器大多都用__proto__
訪問這個屬性),指向這個普通物件的原型物件。
我們還是拿上面的那個例子來看看:
function Animal(name){
this.name = name;
this.sleep = function(){
console.log(this.name + " is sleeping");
}
}
var kitty = new Animal("kitty");
複製程式碼
我們先在瀏覽器中列印出kitty:
可以看到kitty即一個物件,這個物件直接包含name屬性和sleep方法。可以看到還有一個灰色的屬性:<prototype>
物件,這就是前面我們提到的__proto__
,(完全是同一個東西,只不過火狐瀏覽器這樣顯示罷了,為了保持統一,我們在本文就叫它__proto__
)。
我們展開這個__proto__
看看它的組成:
很簡單嘛,也就是一個包含constructor屬性的物件,這個constructor屬性指向kitty的建構函式Animal。
注意,在這裡,__proto__
所指向的物件也有一個<prototype>
,這是理所當然的,因為這個屬性所有的物件都有(甚至函式也有,因為函式其實也是一種物件)
我們再在瀏覽器中列印出Animal
這個函式:
可以看到這個函式有一個prototype屬性,我們已經可以看到這個prototype屬性是一個物件(即原型物件)。我們再展開這個prototype屬性看看:
可以看到目前這個原型物件非常簡單,就是一個constructor,指向了Animal這個函式。
那麼如果我們想原型物件中新增一些東西呢?像這樣:
Animal.prototype.eat = function (food){
console.log("eat " + food);
}
複製程式碼
加上這段程式碼,我們重新整理一下頁面,再看看這時的原型物件是什麼:
果然,eat 方法被新增到了Animal的原型物件中。我們在kitty中呼叫這個方法試試看:
kitty.eat("shxt");
//列印結果:eat shxt
複製程式碼
好,到此為止,我們已經大致瞭解了原型鏈的存在形式:即通過原型物件來連結,下面我們總結一下:
- 建立一個函式的時候,會建立這個函式對應的原型物件,原型物件的
constructor
指向這個函式。 - 函式的
prototype
屬性,以及函式(通過new
)建立的例項物件的__proto__
屬性,都指向同一個原型物件。
而在原型鏈的作用在於:
比如上面我們在Animal的原型物件上定義了一個eat方法。我們有一個Animal的例項kitty,我們列印出kitty:
發現kitty物件中並沒有eat方法,那麼它是怎麼呼叫到eat的呢?沒錯,我們可以看到kitty物件的原型物件中有eat這個方法。所以在原型鏈機制中,物件的方法和屬性的呼叫過程是這樣的:
- 先在自己當前物件尋找這個屬性或方法,如果找到了就直接用
- 如果找不到,就去當前物件的
__proto__
屬性指向的原型物件中尋找,如果找到了,就使用 - 如果還找不到,就在當前原型物件的
__proto__
屬性指向的上一級原型物件尋找 - 如果沒有更上一級的原型物件,那麼
__proto__
屬性會指向Object
的原型物件(這個物件就沒有__proto__
屬性了) - 如果在Object的原型物件中還找不到,那麼就返回
undefined
Object
的原型物件長這樣:
是不是看著很熟悉,裡面有很多我們常用的方法。
那就順便再把function的原型物件放出來:
是不是更熟悉了?而且可以看到function的原型物件的__proto__
屬性,指向Object的原型物件。
所謂原型鏈,就是一個物件的__proto__
指向一個原型物件,而這個原型物件的__proto__
又指向另一個原型物件,依次串聯下去。
目前我們談論的原型鏈都是很短的,要想凸顯原型鏈的威力,就要講講繼承了。
JavaScript中的繼承
所謂繼承就是,在一個父類的基礎上,建立一個子類,子類擁有父類的屬性和方法,也有自己的屬性和方法。
在JavaScript中,實現繼承主要思想是:
拿到子類的建構函式的原型物件,將其__proto__
屬性指向父類建構函式的原型物件。
這樣子類建構函式生成的例項物件就可以先訪問到子類的原型物件,再訪問到父類的原型物件,就完成了繼承。
具體的實現方式呢,有多種:
一、實現子類建構函式,在子類建構函式上修改原型鏈
這種是最傳統的做法,步驟是:
- 建立一個子類建構函式
- 在這個建構函式內呼叫父類的建構函式
- 將子類建構函式的原型物件的
__proto__
屬性指向父類建構函式的原型物件
這樣,當子類建立的例項中尋找屬性或方法時,先找到子類的原型物件,找不到的話就去子類原型物件的__proto__
(即父類的原型物件)找,這樣就實現了繼承。
我們來實現一個例子:
function Animal(name) {
this.name = name;
this.sleep = function () {
console.log(this.name + " is sleeping");
}
}
Animal.prototype.eat = function (food) {
console.log(this.name + " is eating " + food);
}
function Cat(name, color) {
Animal.call(this, name); //獲得Animal內部定義的屬性和方法
this.color = color;
}
//Object.create()方法作用是,建立一個空的物件,將這個物件的__proto__屬性指向引數
Cat.prototype = Object.create(Animal.prototype); //獲得Animal原型物件上的方法和屬性
//這裡暫時先不考慮原型物件的constructor屬性的指向是否正確
//我們列印出來看看Cat建構函式
console.log(Cat);
//下面我們可以新建一個Cat例項
var kitty = new Cat("kitty","yellow");
console.log(kitty);
//試試使用父類的原型上的方法
kitty.eat("fish"); //console: kitty is eating fish
複製程式碼
你可以複製我的程式碼到瀏覽器的控制檯,看看列印出來的結果是怎樣的。
但是這種方法有一個缺點,即對於某些JavaScript內建物件(如Date),如果例項物件不是由它本身的建構函式生成的,不能訪問其內部的屬性和方法。所以通過這種方法繼承Date類,即便我們修改了原型鏈,但還是不能呼叫Date內的方法。不信?我們來驗證一下:
function MyDate(date) {
Date.call(this, date);
this.log = function () {
console.log("now is " + date);
}
}
MyDate.prototype = Object.create(Date.prototype);
var time = new MyDate("2018-08-23");
time.getDate(); //console: TypeError: getDate method called on incompatible Object
複製程式碼
雖然我們已經將MyDate的原型物件的屬性指向Date的原型物件了,但是還是不能呼叫Date中的方法。
二、建立父類例項,在父類例項上修改原型鏈
解決上面的問題其實也很簡單,即我們先用Date建構函式生成一個例項物件,然後將這個例項物件改造成子類例項物件,這樣就不會出現上面這種問題。所以這種方式的步驟是這樣的:
- 用父類建構函式建立例項
- 將例項的
__proto__
屬性指向子類的原型物件 - 將子類原型物件的
__proto__
指向父類的原型物件
這樣,父類原型物件又被連結到子類上了,而且我們的例項也是通過父類建立出來的,也就不會出現上面的那種限制了。
我們來實現一下:
//先來建立子類的建構函式
function MyDate() {}
MyDate.prototype.log = function () {
return this.getDate();
}
var time = new Date(); //建立父類例項
Object.setPrototypeOf(time, MyDate.prototype); //例項的__proto__指向子類原型物件
Object.setPrototypeOf(MyDate.prototype, Date.prototype); //子類原型物件指向父類原型物件
console.log(time.log());
複製程式碼
這個方法其實也有缺點,根據MDN文件,Object.setPrototypeOf()
方法是十分浪費效能的,所以除非迫不得已,還是少用這種方法。
擴充
關於JavaScript中的物件導向我們已經講完了,下面就來嘗試實現幾個原生的API:new,Object.create()
new
new是一個關鍵字,我們自然不能建立一個關鍵字,我們這裡就建立一個new函式:
function MyNew() {
//注意,這個實現沒有進行錯誤處理,僅考慮了引數合理的情況,為了理解new已經夠了
var obj = {};
//獲取建構函式和引數
var Cons = [].shift.call(arguments); //這個操作有的人可能看的有點暈,
//這麼寫是因為arguments其實不是一個陣列
//它僅有一個length屬性,所以沒有陣列的方法。
//將例項物件的__proto__屬性指向建構函式的prototype屬性
obj.__proto__ = Cons.prototype;
//呼叫建構函式
Cons.apply(obj, arguments);
return obj;
}
複製程式碼
我們來加上一些程式碼,測試一下:
function Animal(name) {
this.name = name;
}
Animal.prototype.eat = function (food) {
console.log(this.name + " is eating " + food);
}
//測試一下
var kitty = MyNew(Animal, "kitty");
console.log(kitty.name); //console: kitty
kitty.eat("fish"); //console: kitty is eating fish
複製程式碼
Object.create()
Object.prototype.myCreate = function (proto) {
//注意,這裡也省略了錯誤處理,並且真實的create方法有第二個引數
function F() {}; //建立一個空的建構函式
F.prototype = proto; //將該函式的prototype指向屬性proto(傳入的原型物件)
return new F(); //返回的物件的__proto__根據F建構函式的prototype設定,
//因此返回一個僅有__proto__屬性(指向傳入的原型物件)的空物件。
}
//測試
console.log(Object.myCreate(Date.prototype)); //在瀏覽器列印出來得到要一個物件
//物件的__proto__指向Date原型物件。
複製程式碼
參考資料:
InfoQ * Interview Map 《前端面試指南》