前端基礎

-

前端 JavaScript 超重要的觀念 - 物件的原型 (prototye) 與繼承 (inherit)

this.web

我們在物件教學的文章中提到構造函數的問題,就是會在實例中重複宣告同樣的方法,所以需要原型 prototype 來解決。

今天來讓我們更深入看看 prototype 以及期延伸的繼承 (inherit)

原型 prototype 是什麼

每一個函數都會自動內建一個 prototype 屬性,不信你可以去試試:

function test() {
  console.log('test')
}
console.log(test.prototype)

而這個自動內建的屬性是一個物件,也就是常常聽到的原型物件,原型物件就像一個大倉庫,每個實例都可以來這個倉庫拿東西,所以只要把方法宣告放在原型物件裡,就解決了重複宣告的問題了。

function Person(name) {
  this.name = name;
}

Person.prototype.sayName = function() {
  console.log(this.name)
}

const p1 = new Person('Jack');
const p2 = new Person('Rose');

p1.sayName() // Jack
p1.sayName === p2.sayName // true

constructor 從原型回到構造函式

而在這個原型物件裡面也會有一個內建的屬性 constructor,指回原本的函式:

function Person() {}
console.log(Person.prototype.constructor === Person) // true

所以函式和原型物件是相通的,畢竟要相通才拿來倉庫拿東西,那你可能會有個疑問,構造函式和原型物件相通,那實例也可以通到原型物件嗎?

當然可以囉,不然怎麼去這個大倉庫(原型物件)拿東西呢?只不過不是透過prototype這個屬性來找到倉庫,而是用__proto__這個屬於實例的屬性(兩個下滑線代表不希望被修改和取值,所以一般不會直接操作這個屬性)。

所有的實例都有這個屬性:

function Person() {}
let p1 = new Person();
console.log(p1.__proto__ === Person.prototype) // true

所以實例是用__proto__這個屬性來通往倉庫(原型物件)的。

所有函數都有 prototype 屬性,那 Object 也有嗎?

記得前面提到創建物件的方法有一個是用 let person = new Object() 嗎,不知道你會不會好奇,這個 Object 也是構造函數嗎?那它也有 prototype 屬性嗎?

你想的沒有錯,它是構造函數也有 prototype 屬性,而且有趣的是,所有原型鏈最後都會指到 Object 這個構造函式的 prototype
換句話說,Person 的原型物件也是物件實例,所以他也有 __proto__ 屬性,指向 Object.prototype:

console.log(p1.__proto__ === Person.prototype)
console.log(Person.prototype.__proto__ === Object.prototype)

補充

構造函數、原型對象、實例是三個完全不同的對象

console.log(p1 === Person) // false
console.log(p1 === Person.prototype) // false
console.log(Person.prototype === Person) // false

且同一個構造函數創建的兩個實例,共享同一個原型對象(和前面說的一樣,共享一個大倉庫)

而 constructor 屬性只存在於原型對象中。

原型層級

現在來問你一個問題,如果實例裡面有和倉庫(原型)裡有一樣的屬性,會發生甚麼事呢?

function Person(name) {
  this.name = name;
  Person.prototype.name = 'prototype'
}

let p1 = new Person('thisWeb')
console.log(p1.name) // ?

答案是實例本身的屬性喔。

在訪問屬性時,會先看實例本身有沒有,沒有就往倉庫找,這就是所謂的原型層級。

如何判斷這個屬性是存在於實例還是原型物件呢?

在判斷實例有無屬性時,可以用 in 操作符:

console.log('name' in p1) // true

不過 in 會透過__proto__去原型(倉庫)找。

如果不希望找到倉庫,可以用這個函數 Object.hasOwnProperty(object) 它只會訪問實例本身,所以可以透過這個方法判斷屬性使否存在於原型物件:

function hasPrototypeProperty(object,name) {
  return !object.hasOwnProperty(name) && (name in object)
}
// 實例有原型沒有 -> true

原型物件的問題 - 共同屬性

原型物件也不是完美的,比如原型物件內有一個陣列,若有一個實例更改這個陣列,其他實例也會被影響到,沒辦法針對單獨的實例去修改:

function Person() {} 
Person.prototype = { 
  constructor: Person, 
  name: "Nicholas", 
  age: 29, 
  friends: ["Shelby", "Court"]
}; 
let person1 = new Person(); 
let person2 = new Person(); 
person1.friends.push("Van"); 
console.log(person1.friends); // "Shelby,Court,Van" 
console.log(person2.friends); // "Shelby,Court,Van" 
console.log(person1.friends === person2.friends); // true

怎麼辦呢?就需要用繼承解決喔。接下來就讓我們講講繼承。

甚麼是繼承?

繼承就是一個構造函數的原型是另一個構造函數,就會繼承另一個構造函數的屬性和函數。

之前說每個構造函數都會有一個原型物件,那如果這個原型物件也是另一個構造函數的實例呢?

假設現在有很多個叫做小王的人,但每個小王的 id 不一樣

function Person() {
  this.name = "小王";
}
Person.prototype.sayName = function() {
    console.log(this.name)
}
function Wang(id) {
  this.id = id;
}

// Wang 繼承 Person
Wang.prototype = new Person();
// 記得設置 constructor 屬性指回構造函式本身
Wang.prototype.constructor = Wang;

let wang1 = new Wang(1);
wang1.sayName(); // 小王
console.log(wang1.id) // 1

小王1號在找名字時會先找自己的構造函式(Wang),找不到就往自己的原型物件找 (Wang.prototype === Person)。

又找不到就只好找原型物件的原型物件 (Person.prototype),最後找到了,這就是原型鏈繼承

Wang 繼承了 Person 的屬性和方法。

所以就像前面說的,繼承就是一個構造函數的原型是另一個構造函數,就會繼承其屬性和函數。

不過要記得手動設置 Wang.prototype.constructor = Wang;,不然 可能會導致預期之外的錯誤。

實例與繼承關係

原型與實例的關係可以通過用 instanceof 操作符來確定

如果一個實例的原型鏈中出現過相應的構造函數,則 instanceof 也會返回 true:

console.log(wang1 instanceof Wang); // true 
console.log(wang1 instanceof Person); // true

默認原型

那你會不會好奇,這個原型鏈的最後一個是甚麼,其實就是前面提到的 Object 的 prototype,所以它就是所有物件的默認原型。

所以為什麼 JS 裡所所有東西都可以用 toString()、valueOf() 等方法,就是因為它們放在 Object 的 prototype 裡面

function Test() {}
console.log(Test.prototype.__proto__ === Object.prototype); // true
console.log(Test.prototype.__proto__.constructor === Object); // true

可以想像 Object.prototype 是最古老的祖先,所有物件都會繼承他。

如何用繼承解決共同屬性問題?

居然原型鏈沒辦法解決共同屬性的問題,那繼承可以怎麼解決呢?最常用的方法是,利用 call() 改變 this 的指向:

function Person(age){  
  this.age = age;
  this.name = '小王'
  this.hobbies = ['coding', 'design']
} 
Person.prototype.sayName = function() { 
 console.log(this.name); 
}

function Wang(age, id){ 
 // 繼承屬性
 Person.call(this, age); 
 this.id = id; 
} 

// 繼承函數
Wang.prototype = new Person(); 
// 記得設置 constructor 屬性指回構造函式本身
Wang.prototype.constructor = Wang;

let Wang1 = new Wang(20, 1); 
Wang1.hobbies.push('game') 
console.log(Wang1.hobbies); // coding design game 
Wang1.sayName(); // 小王; 

let wang2 = new Wang(23, 2); 
console.log(wang2.hobbies); // coding design 
wang2.sayName(); // 小王

這樣就可以只繼承函式而彼此的屬性是互不干擾的(簡單說,call()的第一個參數可以改變this的指向,以這裡為例 call的第一個參數是指Wang)

小結

今天講了原型、原型鏈、繼承的觀念,簡單總結一下:

  • 原型: 就像一個大倉庫,所有實例都可以到原型裡拿東西
  • 原型鏈: 原型物件最終都會指到 Object 這個 JS 內建的構造函式
  • 繼承: 當一個原型物件,是另一個構造函式的實例時,就會繼承其方法和屬性。

但為了解決屬性共用的問題,我們會用 call() 來繼承屬性。

今天就這樣,下篇貼文見了喔!

相關系列文章