JS 的原型繼承(方法1) - 函數建構子與「new」

前言

在前幾篇的筆記中,我們已經對於物件(object)原型(prototype)繼承(inheritance)原型鏈等等有更多的了解,現在讓我們來更深入的談論一下 JavaScript中 建立物件的方法

JavaScript語法本身其實已經有物件實體這個快速好用的建立物件方法,但追溯到早期 JavaScript剛被創造出來時,參考了其他語言的古典繼承的概念和 new 關鍵字,而與 new一起出現的用法就是 function constructor函數建構子


函數建構子 Function constructor

能用來新建物件的一種函式,透過與 new運算子一起使用,能創建出新物件並設定該物件的屬性與方法

1
2
3
4
5
6
7
function Person (){
this.firstName = 'John';
this.lastName = 'Doe';
}

var john = new Person();
console.log(john);

結果

變數john指向了new person()創造出來的物件,印出john發現裏頭的屬性和Person()一致。

運算子表格裡,可以得知new是一種運算子。


補充知識 - instance 物件實體
函數建構子創造出來的物件,稱為instance,由上例我可以說,物件 john函數建構子 Person物件實體


當使用new時背後發生什麼事 ?

  1. JS直譯器 syantax parsor會先建立一個空物件{}
  2. 接著呼叫new後面的函式建構子,當函式被呼叫時,創造函式執行環境 Excusion contentthis關鍵字也隨之被創造出來。
  3. 由於this被寫在new的後面,JS直譯器知道你在用函數建構子創造物件,因此this指向了剛被創造出來的空物件{},所以函式建構子內的this.xxx被創造在這個空物件中。

我們先前知道,全域函式的this會指向全域物件,現在因為new關鍵字,this指向了john,我們可以將程式碼改成這樣,多加一個console.log(this);,看看會印出window還是john指向的這個物件。

1
2
3
4
5
6
7
8
function Person (){
console.log(this) // Person {}
this.firstName = 'John';
this.lastName = 'Doe';
}

var john = new Person();
console.log(john); // Person {firstName: "John", lastName: "Doe"}

結果

由此可以了解new會改變this的對象


如果在函數建構子使用 return

使用new的狀況下,new後方的函式預設不會回傳值,若故意在函式裡寫上return的話,函式反而會因為return回傳東西,依狀況決定是否把this創造的物件屬性蓋掉,看看例子

retrun一個字串

1
2
3
4
5
6
7
8
9
function Person (){
console.log(this)
this.firstName = 'John';
this.lastName = 'Doe';
return 'this is return value' //**
}

var john = new Person();
console.log(john);

結果

雖然加了return 'this is return value'但是物件輸出結果不受影響

retrun一個物件

1
2
3
4
5
6
7
8
9
function Person (){
this.firstName = 'John';
this.lastName = 'Doe';
console.log(this) //**
return { name: 'this is return object test' } //**
}

var john = new Person();
console.log(john);

函式的最後若 return其他物件,則前面設定的物件內容會被覆蓋。


容易出錯的地方

需要注意的是,若是使用使用函數建構子不用new,就會變成一般的呼叫函式。

1
2
var john = new Person();
var john = Person();
  • 有用new
    • 建立物件,且 this指向變數john;
  • 忘了用new
    • 單純呼叫函式 Person,且這時 this指向全域物件,因此設定的屬性會被設定到全域物件(window)裡。

因此,如果使用函數建構子忘了放上 new可能會造成預期以外的錯誤,且它的樣子與一般的函數表示式 function ecpression長得無異,會非常難以 debug,因此人們習慣將所有函數建構子的首字以大寫表示


函數建構子和prototype的建立

函式就是物件,它有一些隱藏屬性,像是函數的名稱(Name)函數的內容(Code),其中還有一個專屬於function 的屬性叫做.prototype,這個屬性會以空物件形式呈現。

除非你是把 function 當做 function constructor 來使用,否則這個屬性就沒有特別的用途;但如果你是把它當做 function constructor,透過 new 這個關鍵字來執行這個 function 的話,它就有特別的意義了。


圖片來源: PJCHENder那些沒告訴你的小細節

要設定這個 function 的 prototype 屬性只要透過 .prototype 就可以了。

然而,有一點很容易讓人困惑的地方,我們會以為使用 .prototype 時,就可以進入這個函數的原型,但實際上則不是這樣。

函數當中的 prototype 屬性並不是指這個函數本身的 prototype,他指的是透過這個函數建構子所建立出來的物件 __proto__


function 中的 prototype 屬性一開始是空物件

看看以下程式碼

1
2
3
4
5
6
7
8
9
10
function Person(firstName, lastName){
this.firstName = firstName;
this.lastName = lastName;
}

var john = new Person('John', 'Doe');
console.log(john);

var jane = new Person('Jane', 'Doe');
console.log(jane);

到 Google Chrome 的 console 中,輸入Person.prototype 會得到一個空物件。


透過 function constructor 所建立的物件會繼承該 function 中 prototype 的內容

接著,讓我們在 Person.prototype 裡面增加一個 getFullName 的函數

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Person(firstName, lastName){
this.firstName = firstName;
this.lastName = lastName;
}

Person.prototype.getFullName = function() { //**
return this.firstName + ' ' + this.lastName;
}

var john = new Person('John', 'Doe');
console.log(john);

var jane = new Person('Jane', 'Doe');
console.log(jane);

我們為 Person.prototype 添加了一個函式,所以當我們在 Google Chrome 的 console 視窗中呼叫 Person.prototype 時,會多了這個函式在內:

剛剛,我們有提到很重要的一句話,「函式當中 prototype 這個屬性並不是這個函式本身的原型,它指的是所有透過這個它所建立出來之 物件實體的原型

用程式概念可能比較好說明,這句話的意思是說 Person.prototype 並不是 Person.__proto__,但是所有透過 Person 這個所建立的物件實體,在該物件實例的 __proto__ 中,會包含 Person.prototype 的內容。

也就是說,當我們使用 new 這個運算子來執行函式建構式時,它會先建立一個空物件,同時將該建構子中 prototype,設置到該物件實例 john.__proto__ 中。

因此,當我們在 Google Chrome 的 console 中輸入 john.__proto__ 時,我們就可以看到剛剛在Person.prototype 所建立的函式 getFullName 已經繼承在裡面了。


實際運用

由於 Person.prototype 中的方法已經被繼承到由 Person 這個 function constructor 所建立的物件實例 john 中,所以這時侯,我們就可以順利的使用 john.getFullName 來呼叫這個方法。

1
2
3
4
5
6
7
8
9
10
11
12
function Person(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}

Person.prototype.getFullName = function(){
return this.firstName + ' ' + this.lastName;
}

var john = new Person('John', 'Doe');
console.log(john);
console.log(john.getFullName());

如此,可以正確的執行 getFullName 這個函數並得到如下的結果


我們不該把方法放在 function constructor 中。

透過以上的方法,我們可以讓所有根據這個函式建構式 function constructor 所建立的物件都包含有某些我們想要使用的方法。如果我們有 1000 個物件是根據這個函式建構式所建立,那麼我們只需要使用 .prototype 這樣的方法,就可以讓這 1000 個物件都可以使用到我們想要執行的某個 method

有的人可能會好奇說,為什麼我們不要把 getFullName 這個方法直接寫在函式建構式當中呢?


method 放在函數建構子內雖然程式仍可執行,但是如果今天有1000個物件都是根據這個函數建構子所建立,那麼這1000個方法將會各自佔據記憶體空間,相反的如果建立在 prototype 中,就只會有一個記憶體空間被佔據。

所以,為了效能上的考量,最好的做法是 屬性 property放在建構子當中, 方法 method則放在 prototype 中。

原型繼承

沿用上一個例子,當我們希望有一個新的建構子 可以繼承另一個已存在的建構子的屬性及方法,能怎麼做

1
2
3
4
5
6
7
8
function Person(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}

Person.prototype.getFullName = function(){
return this.firstName + ' ' + this.lastName;
}

現在我們要創造一個新的建構子 Teacher,老師會有名字也會有姓,但和其他人不一樣的是,老師可能還包含教授科目的資訊

1
2
3
4
function Teacher(firstName, lastName, subject) {
Person.call(this, firstName, lastName)
this.subject = subject
}

設定 Teacher() 的原型與建構子參考

到這部其實還沒結束,目前 Teacher 所繼承的只有來自 Person 的屬性 ( firsName 和 lastName ),還沒有繼承來自 Person 的方法 (getFullName)

來看看目前 Teacher 的原型確實還沒有 getFullName 方法

我們透過 Object.crete() 並搭配等同於 Person.prototype 的原型,建立新的 prototype 屬性值 (它本身就是物件,包含屬性與函式) ,並將之設定為 Teacher.prototype 的值。也就是說 Teacher.prototype 現在會繼承 Person.prototype 上的所有可用函式

1
Teacher.prototype = Object.create(Person.prototype)

Object.create() 詳細的使用方法可以參考


現在 Teacher 的原型鏈中已經可以找到 getFullName 方法了!


到目前為止看起來好像很完美了,但其實還沒有結束,現在如果試著輸入

1
Teacher.prototype.constructor


這樣可能會產生問題,所以要設定正確。你可回到自己的原始碼並在最下方加入下列程式碼:

1
Teacher.prototype.constructor = Teacher

到這就算是完整的做完了一個原型繼承的流程


如果要給 Teacher 建構子擴充新的函式 …

1
Teacher.prototype.greeting = function() { ... }

ES5 原型繼承 DEMO

補充

建立物件實體後,再在函數建構子裡新增方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function today(food1,food2){  // new出物件前設定
this.lunch = food1;
this.dinner = food2;
}
today.prototype.eat = function(){
return this.lunch + ' ' + this.dinner;
}

var Dylan = new today('雞絲麵', '麵包');

today.prototype.eat2 = function(){ // new出物件後,再設定一個eat2()
return this.lunch + ' ' + this.dinner;
}

console.log(Dylan);

結果

即使在使 new創造一個物件之後,才使用.prototype創造eat2(),物件實體的__proto__中依然可以找到該屬性,而這和JS的傳參考特性有關。


封裝成函數,避免在使用函數建構子時忘記寫new

在上面容易出錯的地方提到,在使用函數建構子如果忘了在前面加上 new 可能會造成一些很難發現的問題。這邊提供一個方式可以有效避免這個情況。

1
2
3
4
5
6
7
8
function A(name, age) {
this.name = name,
this.age = age
}

var $ = function(name, age) {
return new A(name, age)
}

我們設定了一個函數 $ ,在這個函數中把創造物件的動作 new在裡面執行完畢後並且回傳,如此一來我們就不需要在每次創造物件實體 instance的時候都加上一次new,也可以不用擔心忘了加上new產生不好的結果。

1
2
var person = $('dylan', 18);
console.log(person)


資料來源