原型、原型鏈與繼承

ihengshuai發表於2024-12-03

眾所周知js是基於原型的程式語言,相對於傳統的OOP物件導向程式設計還是有一點區別的。在JS中每個物件都會擁有一個原型物件,自己可以從原型那裡獲得額外的屬性、方法等等(可以看做繼承),這些屬性和方法都是定義在其建構函式的prototype(即原型)屬性上,可以透過屬性(__proto__)進行獲取。而對於傳統的OOP,則會定義對應的類,然後在例項化物件時,將屬性和方法複製到例項上。

本文將會全面介紹原型、原型鏈和繼承。

文章首發公眾號,掃碼檢視更多優質內容

什麼是原型與原型鏈

Javascript有stringnumberbooleanundefinednullsymbolbigint等幾種基本型別,其它都可以看做object型別,只有object物件才會有原型。

<u>JS中每個物件內部都會包含一個隱藏的[[Prototype]]屬性,這個屬性就是原型,它指向它的建構函式的prototype屬性,即原型物件為建構函式的[[prototype]]屬性。</u>由於歷史原因,通常情況下大家都喜歡用__proto__訪問和修改原型,但它不是ES標準,而是一些瀏覽器實現了這個非標準的__proto__,而後來官方推出Object.get/setPrototypeOf(obj)進行操作原型,取代非標準的__proto__屬性進行訪問。

__proto__[[Prototype]]的歷史原因,是原型物件的getter/setter,雖然官方不推薦,不過__proto__已被瀏覽器包括服務端都已支援,因此不用擔心使用,以下都以__proto__作為原型屬性介紹。由於修改原型是個極其耗時的工作,所以不推薦頻繁修改它,一般都是在繼承時初始化值後儘量減少修改。

原型也是一個普通物件,指向建構函式的prototype屬性,物件自己和原型之間透過連結的方式引用,當改變原型物件時,所有的子物件的原型都會同步改變。

關於建構函式或prototype屬性不是很清楚後面會介紹,先記住概念,後面就會解釋
// 1. 建立原型物件
const parent = { parent: "parent" };
const user = { name: "Tom" };

// 2. 建立原型物件為user的u1、u2, 此時 u1和u2 自身都是空物件
const u1 = Object.create(user); // 建立原型為 user 的物件 u1 (Object.create後面會講)
u1.age = 1; // 為 u1&u2 新增 age 屬性
const u2 = Object.create(user);
u2.age = 2;
console.log(u1.__proto__ === user); // => true
console.log(u2.__proto__ === user); // => true
console.log(u1.__proto__ === u2.__proto__); // => true
console.log(u1.name, u2.name); // => Tom, Tom
console.log(u1.parent, u2.parent); // => parent, parent

// 3. 改變原型物件
user.name = "Jerry";
user.foo = "bar";
console.log(u1.name, u1.foo); // => Jerry, bar
console.log(u2.name, u2.foo); // => Jerry, bar

:::warning 需要注意
原型物件不能形成閉環,原型只能是物件或者null,其它值都會被忽略

__proto__不等於[[prototype]]

修改原型是個非常耗時的操作,避免頻繁修改原型
:::

以上程式碼可以看出原型物件和普通物件沒有什麼區別,物件本身和原型物件以引用的關係存在(如上12、18和19行)。原型大家理解了後,那原型鏈也很快就懂了,這也是以上u1/u2可以訪問到自身不存在的屬性關鍵所在。

<u>JS中每個物件都有一個[[Protype]]__proto__屬性指向它的建構函式prototype屬性也就是原型,原型物件也是個普通物件,它也有自己的原型即__proto__屬性,也指向到它的建構函式的prototype屬性,就這樣層層向上,直到Object的原型null,也稱為原型鏈的頂端,這就是原型鏈。</u>

關於建構函式或prototype屬性不是很清楚後面會介紹,先記住概念,後面就會解釋

JS中當訪問物件的屬性時,會先檢視當前物件是否存在此屬性,如果不存在會從當前物件的原型鏈中查詢屬性,直到原型鏈的頂端,這也就解釋了u1/u2為什麼可以訪問到自身不存在的屬性nameparent等等。

這是一張非常經典的原型鏈圖,如果你已經掌握了原型的知識,相信看懂它想必並不難。如果有看不懂的,彆著急接著下面的內容閱讀完後再回頭試試。

proto-chain.png

首先設定原型的方式有多種,下面介紹多種方式加深大家的理解。

屬性訪問器

前面介紹了__proto__其實是[[Prototype]]原型的getter/setter,可以直接物件屬性賦值的方式改變原型,這種方式最好理解。

const cat = { name: 'cat', food: 'mouse' };

console.log(cat.__proto__ === Object.prototype); // true

// 透過 __proto__ 方式給原型新增方法 eat
cat.__proto__.eat = function () {
  console.log("eat: ", this.food); // this指向 cat
};

console.log(cat.__proto__ === Object.prototype); // true

// 執行原型中的eat方法
cat.eat(); // => mouse

從程式碼可以看出cat的原始原型就是Object.prototype(10行),當手動給原型新增eat方法時,並沒有覆蓋原型,而是在Object.prototype的基礎上新增了eat方法,因此cat的原型還是Object.prototype物件,看下此時的cat結構

QQ截圖20221021105110.png

接著上面程式碼,直接設定原型物件而不是新增屬性:

const cat = { name: 'cat', food: 'mouse' };
// 直接覆蓋了原型物件
cat.__proto__ = {
  eat: function () {
    console.log("eat: ", this.food); // this指向 cat
  }
};
console.log(cat.__proto__ === Object.prototype); // false
console.log(cat.__proto__.__proto__ === Object.prototype); // true
console.dir(cat);

上面高亮那行程式碼,透過直接覆蓋原型物件的方式新增原型不再是Object.prototype,而原型的原型才會是Object.prototype,現在看下cat的結構。

QQ截圖20221021111204.png

以上便是__proto__方式新增原型,對於prototype屬性接下來看看構造器。

建構函式

在JS世界裡只有函式才會有構造器(儘管ES6有class這種類似Java的語法,其本質還是函式),如前面的Object是個建立物件的構造器,可以用o = new Object([...args])來構造一個普通物件。每個函式都有prototype屬性,它和原型物件[[Prototype]]不是一個概念,可以認為是個普通的物件,<u>預設只有一個屬性constructor,它指向函式本身。</u>

QQ截圖20221021121302.png

QQ截圖20221021113431.png

函式也可以看做是一個特殊的物件也有自己的原型,<u>JS中所有函式的原型指向它的建構函式Function的prototype屬性</u>如:App.__proto__ === Function.prototypeObject.__proto__ === Function.prototypeFunction.__proto__ === Function.prototype

Function.__proto__ === Function.prototype // true
Object.__proto__ === Function.prototype // true
Array.__proto__ === Function.prototype // true
String.__proto__ === Function.prototype // true
Boolean.__proto__ === Function.prototype // true
Number.__proto__ === Function.prototype // true
Date.__proto__ === Function.prototype // true
RegExp.__proto__ === Function.prototype // true
Blob.__proto__ === Function.prototype // true
ArrayBuffer.__proto__ === Function.prototype // true

瞭解了建構函式的原型後,下面來說函式特有屬性prototype的作用。

函式都有個特殊的屬性prototype,預設情況下改屬性物件只有constructor屬性執行函式本身,<u>在函式作為建構函式使用時new 建構函式會生成一個新的物件,這個物件的原型會指向建構函式的prototype屬性</u>,而作為普通函式使用時,prototype和普通的物件屬性沒有區別。

function User() {};
// 作為建構函式
u1 = new User();
console.log(u1.__proto__ === User.prototype); // true

// 作為普通函式
u2 = User(); // undefined

上面第4行高亮處已經證明了,當函式作為建構函式時,函式的prototype屬性,將會作為物件的原型,那原型的物件是不是就是prototype的原型,上面已經介紹過原型直接透過連結的方式引用,接下來證明上面的概念:

function User(name) { this.name = name; };
User.prototype.say = function() { console.log("my name is ", this.name);};
u1 = new User('Tom');
u1.say(); // my name is Tom
console.log(u1.__proto__.__proto__ === User.prototype.__proto__) // true

// 修改prototype原型
const customProto = { name: "custom proto" };
Object.setPrototypeOf(User.prototype, customProto);
console.log(u1.__proto__.__proto__ === User.prototype.__proto__) // true
console.log(u1.__proto__.__proto__ === customProto) // true
console.log(User.prototype.__proto__ === customProto) // true

// 現在修改prototype的屬性和原型
User.prototype.run = () => console.log('I am running...');
customProto.length = 1;
console.dir(u1);

QQ截圖20221021125128.png

從上圖和程式碼中可以證明原型鏈都是以引用的方式存在,修改原型的屬性會同步改變,函式prototype的改變也會影響到物件的原型。<u>如果將prototype設定為null,將不會影響到已有的物件,為什麼呢?已生成的物件已經對原型物件做了引用,當賦值prototype為null時,原型物件的引用數將會變成已經存在的物件的數量。如果再次給原型物件賦值新的值,也不會影響到原有的物件。</u>

在原型的定義中我們知道原型指向它的的建構函式的prototype屬性,透過上面建構函式的知識我們知道以new的形式生成的物件如何指向prototype屬性,那可能有人好奇,字面量定義的物件為什麼符合這樣的邏輯,那是因為字面量建立的物件<u>js內部會隱式的以new Object建立物件。</u>

u1 = { name: "susi", age: 10 };
// js內部會以 new Object()的形式建立,如下沒有區別
u2 = new Object({ name: "susi", age: 10 });
console.log(u1.__proto__ === Object.prototype); // true
console.log(u2.__proto__ === Object.prototype) // true
console.log(u1.__proto__ === u2.__proto__); // true

// 不過一般不會這樣建立物件,都會以字面量的方式建立,簡單方便易懂

Object.create

這個方法也常來定義一個指定原型的物件,所以只能提前定義好原型物件,除了生成並沒有提供對應的獲取原型的方法,來看使用。

u1 = Object.create({ name: "Tom" });
console.log(u1.__proto__.name); // Tom

// 定義原型為null的物件
u2 = Object.create(null);

更多詳見MDN

Object.setPrototypeOf/getPrototypeOf

這兩個方法是ES6新新增的,setPrototypeOfgetPrototypeOf分別用來設定和獲取原型,相對於Object.create方式更加完善。

user = { name: "Tom" };
proto = {
  say: function() { console.log("I am ", this.name); }
}
// 設定原型
Object.setPrototypeOf(user, proto);
user.say(); // I am Tom

// 獲取原型
console.log(Object.getPrototypeOf(user) === proto); // true
console.log(Object.getPrototypeOf(user));

QQ截圖20221021142342.png

講了這麼多原型和原型鏈大家應該已經明白是怎麼回事了,那它到底有什麼用呢?我們知道js訪問物件的屬性時會先訪問物件本身是否存在該屬性,如果不存在則會從它的原型鏈上去查詢。假如已經定義了一個People物件,內部有很多屬性和方法,現在要求新建一個Student物件,它不但會有People的所有方法還會有自定義的方法。所以能從People那邊將方法移植到Student上,將會大大減小程式碼量,這也是個非常好的程式碼架構方式。

在JS中都是以原型為基礎進行繼承的,透過上面原型的學習,接下來讓我們看看JS繼承吧。

new內幕

在瞭解繼承前,先來看看new操作。

function User(name) { this.name = name; }
User.prototype.say = function() { console.log("I'm ", this.name) };
const user = new User("Lucky"); // User{ name: "Lucky" } => 具有User原型方法的物件

// 顯式返回一個物件
function User(name) {
  this.name = name;
  return { age: 1 };
}
const user = new User("Lucky"); // { age: 1} => 普通物件(沒意義)

前面講了當new建構函式時會產生一個全新的物件,正常情況下內部會涉及到以下步驟:

  • 生成一個全新物件,如果沒有顯式返回物件,這個物件會繼承User的原型prototype
  • 函式的this將會指向生成的物件上
  • 生成的物件的原型指向函式的prototype物件
  • 如果函式顯式返回物件型別的值(Object/Array/Function/RegExp...),new和普通函式呼叫將沒有任何區別
  • 如果返回非物件型別的值,將會忽略顯式返回值並返回內部生成的新物件

我們知道了new做了什麼後,其實可以自己手動實現new的操作,這裡簡單實現原理:

// new implement
function newOperator(Ctor, ...args) {
  if (typeof Ctor !== "function") {
    throw TypeError(`${Ctor} is not a function!`)
  }
  const newObj = Object.create(Ctor.prototype);
  const ctorReturn = Ctor.apply(newObj, args || []);
  if (Object.prototype.toString.call(ctorReturn) !== "[object Null]" && typeof ctorReturn === "object") {
    return ctorReturn;
  }
  return newObj;
}

上面高亮程式碼判斷如果函式返回的是object並且不是null型別的資料,就返回函式返回的值,反之返回自定義的物件。

new生成的物件可以用來做什麼:

  • 判斷型別,獲取物件型別
  • 繼承
function Fruit() {};
f = new Fruit();

const getFuntionName = (func: Function): string => func.toString().match(/function\s+(\w+)/i)?.[1];

console.log(f.constructor === Fruit); // true
console.log(getFuntionName(f.constructor)); // 'Fruit'

接下來看JS繼承

原型鏈繼承

原型鏈繼承是最基本的繼承,這和所有物件都有__proto__屬性概念一樣。這裡就是讓子類的prototype屬性指向例項化後的父類例項,這樣子類就會擁有父類的的屬性和prototype中的屬性方法。為什麼呢?前面講了new操作,正常情況會生成一個全新的內部物件,作為函式內部的this指向,並且原型指向建構函式的prototype屬性,擁有建構函式prototype物件中的屬性和方法。

function Parent(name) {
  this.name = name;
  this.sons = ["Tom", "Jerry"];
}
Parent.prototype.say = function() {
  console.log("my name:", this.name);
}
function Child(food) {
  this.food = food;
}
// 將Parent的例項作為Child的prototype
Child.prototype = new Parent();  // 將會擁有parent例項屬性和方法,也會擁有prototype物件中的屬性和方法
Child.prototype.eat = function() {  // Child prototype物件現在是parent例項,也是個普通物件,可以新增屬性和方法
  console.log("I eat:", this.food);
}

const c1 = new Child('noodles');
c1.say(); // my name: undefinded
c1.eat(); // I eat noodles
console.log(c1.sons); // // ['Tom', 'Jerry']

const c2 = new Child("rice");
c2.eat(); // I eat rice
console.log(c2.sons); // ['Tom', 'Jerry']

c1.sons.push("Lucky");
console.log(c1.sons, c2.sons); //  ['Tom', 'Jerry', 'Lucky'], ['Tom', 'Jerry', 'Lucky']
console.log(c1.__proto__ === c2.__proto__); // true

以上就是最簡單原型鏈繼承,當你瞭解原型鏈和new的原理相信一看就懂。這樣繼承子類可以拿到父類的屬性還有prototype上的方法,但很明顯的缺點就是,父類是個例項物件,那麼所有子類對於父類的繼承都是引用(28行已經證明了引用),當一個子類修改父類中的屬性或方法時,都會影響到其它的子類(程式碼26,27行);還有一個缺點無法對父類進行傳參。一圖勝千言:
iShot_2022-10-29_16.38.34.png

  • 優點:<u>可以繼承父類的屬性和原型方法;</u>
  • 缺點:<u>父類在子類之間共享,會造成資料之間的汙染和篡改;無法給父類傳參;</u>

建構函式繼承

所謂的建構函式繼承是在例項化子類時,對父類建構函式透過call/apply改變內部的this指向,讓其內部的this的屬性可以轉嫁給子類,來看下面程式碼:

function Parent(name) {
  this.name = name;
  this.run = () => console.log("I am running...");
}
Parent.prototype.say = function() {
  console.log("hello");
}
function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}
const child = new Child("Tom", 10);
console.log(child.name, child.age); // Tom, 10
child.run(); // I am running...
child.say(); // 報錯 => child.say is not a function

在例項化Child時,內部會執行Parent方法(9行)並將child繫結為this,並將name傳遞給Parent,然後parent內部的this上繫結的屬性轉移到child上,這樣child就會擁有namerun屬性,這就是建構函式繼承。再來看下child的原型鏈:
iShot_2022-10-29_16.13.47.png
從圖中可以看到child的原型為Child的prototype屬性,卻沒有Parent的prototype相關方法(say),父類只有將所有的屬性定義在函式體內才能得到繼承,所以這種繼承並不能繼承父類的prototype中的屬性。

  • 優點:<u>可以繼承父類函式體內的屬性和方法,並且向父類傳參,並且多個例項不共享,不會造成汙染</u>
  • 缺點:<u>無法繼承父類的prototype中的屬性和方法</u>

原型式繼承

原型式繼承透過修改建構函式的prototype為目標物件,並例項化一個空物件達到繼承的目的,來看下面這段程式碼:

function inherit(target) {
  function F() {};
  F.prototype = target; // 改變prototype
  return new F();
}
const parent = { name: 'parent', children: ['child1', 'child2'], say() { console.log('my name is parent.'); } };

const c1 = inherit(parent);
console.log(c1.name, c1.children); // parent, ['child1', 'child2']
c1.say(); // my name is parent.

const c2 = inherit(parent);
console.log(c2.name, c2.children); // parent, ['child1', 'child2']
c2.say(); // my name is parent.

console.log(c1.__proto__ === c2.__proto__); // true
c1.children.push('push by c1');
console.log(c2.children); // ['child1', 'child2', 'push by c1']

c1.name = 'c1';
console.log(c1.name, c2.name); // c1, parent

首先從上面16行看到子類的原型都指向同一個父類物件parent;在17-18行c1往children中新增了一個資料,c2中的children也發生了變化;同樣的最後兩行看到,c1改變了name後,c2卻沒有改變,這是為什麼呢?當執行c1.children獲取屬性時,由於c1本身是不存在的,就會往原型鏈中找,所以會在parent中找到然後執行push方法,當然會改變其中的值,由於parent也是c2原型鏈,所以也會被改掉。而當執行c1.name=c1時,只要記住賦值操作=不會查詢原型鏈,只會在當前物件中修改或新增屬性,所以c1修改name時,只是在自己身上新增了name屬性,並沒有改變原型鏈中的name屬性,所以c2的name還是parent中的不會發生變化。

來看下這種繼承的原型結構:
iShot_2022-10-29_17.18.34.png
一般這種繼承應用場景如上面程式碼那樣,繼承物件是個物件的形式(parent),而子類也是個簡單的物件。但這種方式可以用原型鏈繼承代替,如:Object.create、__proto__、setPrototypeOf(前面講的設定原型的幾種方式),不用這麼麻煩囉嗦。

  • 優點:<u>繼承操作簡單容易理解</u>
  • 缺點:<u>子類例項共享父類狀態易篡改,無法給父類傳參</u>

組合式繼承

組合式繼承則是結合原型鏈繼承和建構函式繼承,透過原型鏈對建構函式的原型進行繼承,再透過建構函式對例項屬性和方法進行繼承,看下面程式碼:

function Parent(name) { this.name = name; };
Parent.prototype.children = ["child1", "child2"];
Parent.prototype.say = function() { console.log('my name is', this.name); };
function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}
Child.prototype = new Parent();
Child.prototype.intro = function() { console.log(`my name is ${this.name}, ${this.age} age`); };

const c1 = new Child('小明', 18);
const c2 = new Child("小紅", 17);

console.log(c1.name, c2.name); // 小明, 小紅
console.log(c2.age, c2.age); // 18, 17
c1.say(); // my name is 小明
c2.say(); // my name is 小紅
c1.intro(); // my name is 小明, 18 age
c2.intro(); // my name is 小紅, 17 age

上面組合繼承看上去沒什麼問題,拿來看下原型圖:
iShot_2022-10-29_17.41.34.png
從圖中可以明顯看到,子類例項會擁有父類相同的屬性的例項屬性,由於原型鏈的作用父類中的例項屬性並沒有作用,為什麼呢?從上面第8行中看到,會執行一個例項化parent的操作,前面我們知道new時會生成一個許可權的物件作為this指向,所以就會例項屬性或方法,但由於在例項child時,借用了parent的建構函式(第5行),child例項也會擁有parent內部的例項屬性和方法,對於原型鏈查詢的規則,parent例項中的屬性永遠訪問不到,很明顯多餘。

  • 優點:<u>可以給父類構造器傳參,擁有父類的例項屬性和原型方法</u>
  • 缺點:<u>會擁有父類相同的例項屬性,但父類中的例項屬性並沒有被用到</u>

寄生式繼承

寄生式繼承是對原型式繼承的加強版,由於原型式繼承返回的是空物件,空物件中並沒有屬性和方法,那麼寄生式就是在空物件中再新增一些屬性和方法,就是所謂的加強版(沒啥區別啊),來看下面程式碼:

function inherit(target, custom?: Record<string, any>) {
  function F() {};
  F.prototype = target; // 改變prototype
  const newObj = new F();
  newObj.show = () => console.log('call show...');
  // 新增自定義方法屬性...
  custom && Object.keys(custom).forEach(k => (newObj[k] = custom[k]));
  return newObj;
}
const parent = { name: 'parent', children: ["child1", "child2"], say() { console.log('I\'m parent'); } };
const c1 = inherit(parent, { appendChildren(name) { this.children.push(name + "c1"); } });
const c2 = inherit(parent, { privateAttr: 'c2', appendChildren(name) { this.children.push(name + "c2"); } });

console.log(c1.name, c2.name); // parent, parent
console.log(c2.children === c2.chilrent); // true
console.log(c2.privateAttr, c1.privateAttr); // c2, undefinded
c1.appendChildren('pushed by c1');
c2.appendChildren('pushed by c2');
console.log(c1.children, c2.children); // ['child1', 'child2', 'pushed by c1c1', 'pushed by c2c2'], ['child1', 'child2', 'pushed by c1c1', 'pushed by c2c2']

如上寄生式和原型式繼承一樣,只不過新增了一些相同的屬性和方法,還可以新增自定義屬性或方法(5-7行)。上面給c2新增了屬性privateAttr屬性,c1並沒有該屬性。但是缺點也很明顯和原型式繼承一樣(19行),這裡就不說了,看下原型圖:

iShot_2022-10-29_18.04.53.png

  • 優點:<u>原型式繼承加強,可以新增自定義屬性和方法</u>
  • 缺點:<u>子類例項共享父類狀態易篡改,無法給父類傳參</u>

寄生組合式繼承

到這裡已經講了5中繼承方式了,每種方式都有自己的優點和缺點。在這裡我們來看繼承的本質,所謂繼承這裡都指函式的繼承,像一些面嚮物件語言如Java、C#等等,透過class繼承。在JS裡對應的就是function,透過new操作來生成物件繼承父類的屬性和方法。

那在JS中前面講了new的過程和本質,生成的物件會擁有例項屬性和原型物件(prototype)的屬性,例項屬性是在建構函式中在new的時候自動新增到內部生成的物件上,而原型prototype物件會作為物件的原型__proto__,這樣new生成的物件就會擁有例項屬性和原型屬性了。那麼子類函式是不是隻要在函式內部和原型物件上prototype擁有父類的屬性和方法就行了,也就是new的時候也會擁有父類的例項屬性和方法,然後生成的物件指向子類函式的prototype物件,只要prototype擁有父類的的prorotype屬性和方法就可以了。

繼承組合式繼承就是來實現上面的操作的,其實也是平衡前面幾種繼承的優點和缺點,下面來簡單實現下:

// 寄生組合式繼承
function Parent(name) {
  this.name = name;
  this.children = [];
}
Parent.prototype.say = function() {
  console.log(`my name is ${this.name}`);
}
Parent.prototype.appendChildren = function(child) { this.children.push(child); };
function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}
// 將Parent原型上的方法和屬性移植到Child的原型上
Child.prototype = Object.create(Parent.prototype);
// 按照new規範將構造器指向Child自己
Child.prototype.constructor = Child;
// 新增Child自定義的原型方法
Child.prototype.intro = function() {
  console.log(`my name is ${this.name}, ${this.age} age.`);
}

const c1 = new Child("小明", 18);
const c2 = new Child("小紅", 17);
console.log(c1.name, c1.age); // 小明, 18
console.log(c2.name, c2.age); // 小紅, 17
c1.appendChildren('小明同學');
c2.appendChildren('小紅同學');
console.log(c1.children, c2.children); // ['小明同學'] ['小紅同學']

console.log(c1 instanceof Child); // true

以上透過將Child的prototype物件的原型設定為Parent的prototype物件,這樣首先繼承了Parent的prototype物件中的屬性和方法,然後將Child的prototype的constructor指向Child自己,這是正常情況,然後新增Child自己的屬性和方法intro。在Child函式體內借用Parent建構函式繼承例項屬性和方法(11行),這樣在建立Child例項時,不會例項化父類的建構函式。

這種方法巧妙的將父類原型方法拿到自己身上,例項方法也會在new時巧妙借用,而且父類構造器只會執行一次,在修改子類原型後,透過改變prototype.constructor屬性為自己,又可以冒充正常的原型物件,並且instanceof等判斷原型的方法也可以正常工作,這其實就是組合繼承和寄生繼承的加強版,這種方式比較成熟,通常情況下以這個作為繼承版本。來看看原型結構:
iShot_2022-10-30_08.01.14.png
從上圖可以很明顯的看到繼承關係,推薦使用。

  • 優點:<u>不會例項化父類構造器,巧妙的借用父類例項和原型屬性</u>

    class繼承

    ES6也引進了classextends關鍵字,用於類似Java等面嚮物件語言類的實現和繼承,但在JS中其本質還是function,看下使用:

    class Parent {
    constructor(name) {
      this.name = name;
      this.children = [];
    }
    say() {
      console.log(`my name is ${this.name}`);
    }
    appendChildren(child) {
      this.children.push(child);
    }
    
    static getUUid() {
      return "parent";
    }
    }
    
    class Child extends Parent {
    constructor(name, age) {
      super(name);
      this.age = age;
    }
    
    intro() {
      console.log(`my name is ${this.name}, ${this.age} age.`);
    }
    }
    const c1 = new Child("小明", 18);
    const c2 = new Child("小紅", 17);
    console.log(c1.name, c1.age); // 小明, 18
    console.log(c2.name, c2.age); // 小紅, 17
    c1.appendChildren("小明同學");
    c2.appendChildren("小紅同學");
    console.log(c1.children, c2.children); // ['小明同學'] ['小紅同學']
    console.log(Child.getUUid()); // parent

    以上就是class的繼承使用方式,從中可看到Child繼承了父類的例項屬性,也繼承了父類的原型,Child類也繼承了父類的靜態屬性(程式碼13,35行)。

需要注意的是在Child的構造器中呼叫了super(name)(20行),這個是什麼?這其實就是執行了父類的建構函式,如果你瞭解過如Java的繼承,那這個一定不陌生。那在JS中如何解釋這個呢,其實在寄生組合繼承中借用父類例項屬性時會執行父類建構函式並將this指向子類,而這裡super方法其實就是這個邏輯,並向Parent傳遞了引數name,這樣子類就會擁有父類的例項屬性name、children,並且規定:在class繼承中若子類執行constructor構造器時必須執行super方法。另外,<u>super在子類建構函式中必須先於this屬性相關操作呼叫</u>,這又是為什麼?其實就是讓子類的屬效能夠覆蓋父類的例項屬性,這樣子類可以更加靈活的修改父類的例項屬性。

來看下例項的原型結構:
iShot_2022-10-30_09.04.01.png
基本和我們自己寫的寄生組合式繼承的物件一致,在途中可以很明顯看到class關鍵字繼承。

上面給Parent新增了靜態屬性getUUid,Child也會擁有相同的靜態屬性,那什麼是靜態屬性呢?在class中用static來標識這是類的靜態屬性或方法,而以函式的角度去看,其實就是函式的一個普通屬性而已,來看下面程式碼:

function App() {}
// 設定App靜態方法 getUUid
App.getUUid = () => console.log('static method');

透過上面程式碼瞭解到靜態屬性就是一個函式的普通屬性而已,而class其實也是這個道理(本質也是函式),在上面的繼承中,Child也會繼承Parent的靜態屬性,那不就是Child.__proto__ = Parent嗎?我們來證明下:
iShot_2022-10-30_09.11.43.png
果然是這樣,那我們就明白了class繼承時,也會將構造器本身作為子類構造器的原型來讓其擁有其靜態屬性,即Child.__proto__=Parent,來看下Child本身的原型:
iShot_2022-10-30_09.22.04.png
這樣Child也會擁有Parent自身上其他的屬性。在寄生組合繼承中,也來稍微改造,讓其支援靜態屬性這個特點:

Child.__proto__ = Parent;
// 或
Object.setPrototypeOf(Child, Parent);

以上便是class繼承,那class真正的實現方式是如何的呢,這裡我們可以對class這種語法進行降級處理看看其實現方式,你可以使用typescript編譯器直接編譯或使用babel,這裡為了方便使用tsc直接編譯:

var __extends = (this && this.__extends) || (function () {
  var extendStatics = function (d, b) {
    extendStatics = Object.setPrototypeOf ||
      ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
      function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
    return extendStatics(d, b);
  };
  return function (d, b) {
    if (typeof b !== "function" && b !== null)
      throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
    extendStatics(d, b);
    function __() { this.constructor = d; }
    d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
  };
})();
var Parent = /** @class */ (function () {
  function Parent(name) {
    this.name = name;
    this.children = [];
  }
  Parent.prototype.say = function () {
    console.log("my name is " + this.name);
  };
  Parent.prototype.appendChildren = function (child) {
    this.children.push(child);
  };
  Parent.getUUid = function () {
    return "parent";
  };
  return Parent;
}());
var Child = /** @class */ (function (_super) {
  __extends(Child, _super);
  function Child(name, age) {
    var _this = _super.call(this, name) || this;
    _this.age = age;
    return _this;
  }
  Child.prototype.intro = function () {
    console.log("my name is " + this.name + ", " + this.age + " age.");
  };
  return Child;
}(Parent));

其實也沒那麼難,感興趣的可以自己看看。

總結

本篇主要講解了什麼是原型、原型鏈,JS是如何從原型鏈中查詢屬性的,學到了設定原型的幾種方法,以及new的本質和作用。透過原型延伸到JS的繼承方式,對比不同的繼承讓原型及原型鏈的知識進一步鞏固。

相關文章