隔壁小孩也能看懂的 7 種 JavaScript 繼承實現

本文首發自騰訊IMWEB社區:imweb.io/javascript

JavaScript 沒有類,原型鏈來實現繼承

由於我在學校接觸的第一門語言是cpp,是一個靜態類型語言,而且實現面向對象直接就有class關鍵字,並且只講了面向對象一種設計思想,致使我一直很難理解javascript語言的繼承機制。java

JavaScript沒有」子類「和」父類「的概念,也沒有」類「(class)和」實例「(instance)的區分,全靠」原型鏈「(prototype chain)實現繼承。c++

學的時候就很想吐槽,費了這麼大的勁去模擬類,那js幹嗎不一開始就設計class關鍵字而是最開始僅將class做爲保留字呢?(ES6以後有了class關鍵字,是原型的語法糖)es6

當時我一直懷疑,「js沒有class是一種設計缺陷嗎?」web

原來,JavaScript設計之初,設計裏面全部的數據類型都是對象(object),最開始,JavaScript只想被設計成一種簡易的腳本語言,設計者JavaScript裏面都是對象,必需要有一種機制將全部對象聯繫起來,但若是引入「類」(class)的概念,那麼就太「正式」了,增長了上手難度。編程

要實現繼承,但又不想用類,那該怎麼辦呢?bash

JavaScript 的設計者Brendan Eich發現,能夠像c++和Java語言中使用new命令生成實例。數據結構

因而new命令被引入到JavaScript,用來從原型對象生成一個實例對象。可是JavaScript沒有「類」,原型對象該如何表示呢?編程語言

這時,他想到c++和java使用new命令時,都會調用「類」的構造函數(constructor),因而他作了個簡化設計,在JavaScript中,new命令後面跟的不是類而是構造函數。函數

用構造函數生成實例對象,有一個缺點就是沒法共享屬性和方法。

每個實例對象,都有本身的屬性和方法的副本。這不只沒法作到數據共享,也是極大的資源浪費。

考慮到這一點,brendan Eich決定爲構造函數設置一個prototype屬性

這個屬性包含一個prototype對象(是的,prototype屬性的值是prototype對象),全部的實例對象須要共享的屬性和方法,都放在這個對象裏面,那些不須要共享的屬性和方法,就放在構造函數裏。

實例對象一旦建立,將自動引用prototype對象的屬性和方法,也就是說,實例對象的屬性和方法,分紅兩種,一種是本地的,另外一種是引用的。

因爲全部的實例對象共享同一個prototype對象,那麼從外界看起來,prototype對象就好像是實例對象的原型,而實例對象則好像"繼承"了prototype對象同樣。

若是沒了解過c++、java或者其餘的編程語言,我相信你看完上面這段內容應該會看睡着了吧!好的,咱們仍是直接來看看代碼吧~

原型鏈繼承

//原型鏈繼承

// 父類
// 擁有屬性 name
function parents(){
    this.name = "JoseyDong";
}

// 在父類的原型對象上添加一個getName方法
parents.prototype.getName = function(){
    console.log(this.name);
}

//子類
function child(){
}

//子類的原型對象 指向 父類的實例對象
child.prototype = new parents()

// 建立一個子類的實例對象,若是它有父類的屬性和方法,那麼就證實繼承實現了
let child1 = new child();

child1.getName(); // => JoseyDong
複製代碼

在只有一個 子類實例對象的時候,咱們貌似看不出什麼問題。然而在實際場景中,咱們會建立不少實例對象來繼承父類,畢竟繼承得越多,被複寫的代碼量就越多嘛~

//原型鏈繼承

// 父類
// 擁有屬性 name
function parents(){
    this.name = ["JoseyDong"];
}

// 在父類的原型對象上添加一個getName方法
parents.prototype.getName = function(){
    console.log(this.name);
}

//子類
function child(){
}

//子類的原型對象 指向 父類的實例對象
child.prototype = new parents()

// 建立一個子類的實例對象,若是它有父類的屬性和方法,那麼就證實繼承實現了
let child1 = new child();

child1.getName(); // => ["JoseyDong"]

// 建立一個子類的實例對象,在child1修改name前實現繼承
let child2 = new child();

// 修改子類的實例對象child1的name屬性
child1.name.push("xixi");

// 建立子類的另外一個實例對象,在child1修改name後實現繼承
let child3 = new child();

child1.getName();// => ["JoseyDong", "xixi"]
child2.getName();// => ["JoseyDong", "xixi"]
child3.getName();// => ["JoseyDong", "xixi"]
複製代碼

當不少時候,咱們的實例對象裏的值是會雖具體場景而改變的。好比這個時候,咱們的child1除了joseydong之外,她的朋友又給她取了個新名字xixi,咱們改變了child1的name值。而child一、child二、child3是三個獨立的個體,可是最後發現三個孩子都有了新名字!

這就表示,原型鏈繼承裏面,使用的都是同一個內存裏的值,這樣修改該內存裏的值,其餘繼承的子類實例裏的值都會變化。

這可不是咱們想要的效果,畢竟只有child1被賦予了新名字。而且,若是我想經過子類實例對象傳遞參數給父類,也是作不到的。

借用構造函數

// 構造函數繼承


function parents(){
    this.name = ["JoseyDong"];
}

// 在子類中,使用call方法構造函數,實現繼承
function child(){
    parents.call(this);
}

let child1 = new child();
let child2 = new child();

child1.name.push("xixi");

let child3 = new child();

console.log(child1.name);// => ["JoseyDong", "xixi"]
console.log(child2.name);// => ["JoseyDong"]
console.log(child3.name);// => ["JoseyDong"]
複製代碼

咱們使用構造函數的方法,就只修改了child1的名字,而child2和child3的name屬性並無受影響~

同時,因爲call()支持傳遞參數,咱們也能夠在child中向parent傳參啦~

// 構造函數實現繼承
//子類向父類傳參

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

//call方法支持傳遞參數
function child(name){
    parents.call(this,name)
}

let child1 = new child("I am child1");

let child2 = new child("I am child2");

console.log(child1.name);// => I am child1
console.log(child2.name);// => I am child2
複製代碼

好了,如今咱們經過構造函數實現繼承彌補了用原型鏈實現繼承的缺點,同時也是經過構造函數實現繼承的優勢:

1.避免了引用類型的屬性被全部實例共享

2.能夠在child中向parent傳參

可是,這種方式也有缺點,由於方法都在構造函數中定義,每次建立實例都會建立一遍方法。

組合繼承

咱們發現,經過原型鏈實現的繼承,都是複用同一個屬性和方法;經過構造函數實現的繼承,都是獨立的屬性和方法。因而咱們大打算利用這一點,將兩種方式組合起來:經過在原型上定義方法實現對函數的複用,經過構造函數的方式保證每一個實例都有它本身的屬性

下面我再舉個栗子,讓你們感覺下組合繼承的好處~

//組合繼承

// 偶像練習生大賽開始報名了
// 初賽,咱們找了一類練習生
// 這類練習生都有名字這個屬性,但名字的值不一樣,而且都有愛好,而愛好是相同的
// 只有會唱跳rap的練習生纔可進入初賽
function student(name){
    this.name = name;
    this.hobbies = ["sing","dance","rap"];
}

// 咱們在student那類裏面找到更特殊的一類進入複賽
// 固然,咱們已經知道初賽時有了name屬性了,而不一樣練習生名字的值不一樣,因此使用構造函數方法繼承
// 同時,咱們想再讓練習生們再介紹下本身的年齡,每一個子類還能夠本身新增屬性
// 固然啦,具體的名字年齡就由每一個練習生實例來定
// 類只告訴你,有這個屬性

function greatStudent(name,age){
    student.call(this,name);
    this.age = age;
}

// 而你們的愛好值都相同,這個時候用原型鏈繼承就好啦
// 每一個對象都有構造函數,原型對象也是對象,也有構造函數,這裏簡單的把構造函數理解爲誰的構造函數就要指向誰
// 第一句將子類的原型對象指向父類的實例對象時,同時也把子類的構造函數指向了父類
// 咱們須要手動的將子類原型對象的構造函數指回子類
greatStudent.prototype = new student();
greatStudent.prototype.constructor = greatStudent;

// 決賽 kunkun和假kunkun進入了決賽
let kunkun = new greatStudent('kunkun','18');
let fakekun = new greatStudent('fakekun','28');

// 有請兩位選手介紹下本身的屬性值
console.log(kunkun.name,kunkun.age,kunkun.hobbies) // => kunkun 18 ["sing", "dance", "rap"]
console.log(fakekun.name,fakekun.age,fakekun.hobbies) // => fakekunkun 28 ["sing", "dance", "rap"]

// 這個時候,kunkun選手說本身還有個隱藏技能是打籃球
kunkun.hobbies.push("basketball");

console.log(kunkun.name,kunkun.age,kunkun.hobbies) // => kunkun 18 ["sing", "dance", "rap", "basketball"]
console.log(fakekun.name,fakekun.age,fakekun.hobbies)// => fakekun 28 ["sing", "dance", "rap"]

// 咱們能夠看到,假kunkun並無抄襲到kunkun的打籃球技能
// 而且若是這個時候新來一位選手,從初賽複賽闖進來的一匹黑馬
// 能夠看到黑馬並無學習到kunkun的隱藏技能
let heima = new greatStudent('heima','20')
console.log(heima.name,heima.age,heima.hobbies) // => heima 20 ["sing", "dance", "rap"]
複製代碼

能夠看到,組合繼承避開了原型鏈繼承和構造函數繼承的缺點,結合了二者的優勢,成爲了javascript中最經常使用的繼承方式。

原型式繼承

這種繼承的思想是將傳入的對象做爲建立的對象的原型。

function createObj(o){
  function F(){};
  F.prototype = o;
  return new F();
}
複製代碼

咱們來實現下原型式繼承,看看會不會有什麼問題

// 原型式繼承

function createObj(o){
    function F(){};
    F.prototype = o;
    return new F();
}

let person = {
    name:'JoseyDong',
    hobbies:['sing','dance','rap']
}

let person1 = createObj(person);
let person2 = createObj(person);

console.log(person1.name,person1.hobbies) // => JoseyDong ["sing", "dance", "rap"]
console.log(person2.name,person2.hobbies) // => JoseyDong ["sing", "dance", "rap"]

person1.name = "xixi";
person1.hobbies.push("basketball");

console.log(person1.name,person1.hobbies) //xixi ["sing", "dance", "rap", "basketball"]
console.log(person2.name,person2.hobbies) //JoseyDong ["sing", "dance", "rap", "basketball"]
複製代碼

這個時候咱們發現,修改了person1的hobbies的值,person2的hobbies的值也變了。

這是由於包含引用類型的屬性值始終會共享相應的值,這點跟原型鏈繼承同樣~

而修改了person1.name的值,person2.name的值並未發生改變,並非由於person1和person2有獨立的name值,而是由於person1.name = "xixi"這條語句是給person1實例對象添加了一個name屬性,而它的原型對象上name值並無被修改,因此person2的name沒有變化。由於咱們找對象上的屬性時,老是先找實例對象,沒有找到的話再找原型對象上的屬性。實例對象和原型對象上若是有同名屬性,老是先取實例對象上的值。

ESMAScript5新增了Object.create()方法規範化了原型式繼承~

寄生式繼承

建立一個僅用於封裝繼承過程的函數,該函數在內部以某種形式來作加強對象,最後返回對象。

//寄生式繼承

function createObj(o){
    let clone = Object.create(o);
    clone.sayName = function(){
        console.log('hi');
    }
    return clone
}

let person = {
    name:"JoseyDong",
    hobbies:["sing","dance","rap"]
}

let anotherPerson = createObj(person);
anotherPerson.sayName(); // => hi
複製代碼

固然,用寄生式繼承來爲對象添加函數,和借用構造函數模式同樣,每次建立對象都會建立一遍方法。

寄生組合式繼承

前面咱們說了,組合繼承是javascript最經常使用的繼承模式。這裏咱們先來回顧下組合式繼承的代碼:

//組合繼承

function student(name){
    this.name = name;
    this.hobbies = ["sing","dance","rap"];
}

function greatStudent(name,age){
    student.call(this,name);
    this.age = age;
}

greatStudent.prototype = new student();
greatStudent.prototype.constructor = greatStudent;

let kunkun = new greatStudent('kunkun','18');
複製代碼

組合繼承最大的缺點是最調用兩次父構造函數

一次是設置子類實例的原型的時候:

greatStudent.prototype = new student();
複製代碼

一次是在建立子類型實例的時候:

let kunkun = new greatStudent('kunkun','18');
複製代碼

在這個例子中,若是咱們打印一下kunkun這個對象,咱們就會發現greatStudent.prototype和kunkun都有一個屬性爲hobbies。

這其實就是實例對象和原型對象上的屬性值重複了,而再找屬性值的時候,在實例對象上找到了屬性值就不會在原型對象上找了,而這部分原型對象上的值就實打實的浪費了存儲空間。

那麼咱們該如何精益求精,避免這一次重複調用呢?

若是咱們不使用greatStudent.prototype = new student(),而是直接讓greatStudent.prototype訪問到student.prototype呢?

看看如何實現:

// 寄生組合式繼承

function student(name){
    this.name = name;
    this.hobbies = ["sing","dance","rap"];
}

function greatStudent(name,age){
    student.call(this,name);
    this.age = age;
}

//關鍵的三步 實現繼承
// 使用F空函數當子類和父類的媒介 是爲了防止修改子類的原型對象影響到父類的原型對象
let F = function(){};
F.prototype = student.prototype;
greatStudent.prototype = new F();

let kunkun = new greatStudent('kunkun','18');
console.log(kunkun);
複製代碼

打印結果:

能夠看到,kunkun實例的原型對象上再也不有hobbies屬性了。

最後,咱們封裝下這個繼承方法:

function object(o) {
    function F() {}
    F.prototype = o;
    return new F();
}

function prototype(child, parent) {
    let prototype = object(parent.prototype);
    prototype.constructor = child;
    child.prototype = prototype;
}

// 當咱們使用的時候:
prototype(Child, Parent);
複製代碼

引用《JavaScript高級程序設計》中對寄生組合式繼承的誇讚就是:

這種方式的高效率體現它只調用了一次 Parent 構造函數,而且所以避免了在 Parent.prototype 上面建立沒必要要的、多餘的屬性。與此同時,原型鏈還能保持不變;所以,還可以正常使用 instanceof 和 isPrototypeOf。開發人員廣泛認爲寄生組合式繼承是引用類型最理想的繼承範式。

總而言之就是,這種js實現繼承的方式是最佳的。

ES6實現繼承

然而,ES6以後經過extends關鍵字實現了繼承。

// ES6 

class parents {
    constructor(){
        this.grandmather = 'rose';
        this.grandfather = 'jack';
    }
}

class children extends parents{
    constructor(mather,father){
    //super 關鍵字,它在這裏表示父類的構造函數,用來新建父類的 this 對象。
        super();
        this.mather = mather;
        this.father = father;
    }
}

let child = new children('mama','baba');
console.log(child) // =>
// father: "baba"
// grandfather: "jack"
// grandmather: "rose"
// mather: "mama"
複製代碼

子類必須在 constructor 方法中調用 super方法,不然新建實例時會報錯。這是由於子類沒有本身的this 對象,而是繼承父類的 this 對象,而後對其進行加工。

只有調用 super 以後,纔可使用 this 關鍵字,不然會報錯。這是由於子類實例的構建,是基於對父類實例加工,只有 super 方法才能返回父類實例。

ES5 的繼承實質是先創造子類的實例對象 this,而後再將父類的方法添加到 this 上面(Parent.call(this))。

ES6 的繼承機制實質是先創造父類的實例對象 this (因此必須先調用 super() 方法),而後再用子類的構造函數修改 this。

es6實現繼承的核心代碼以下:

function _inherits(subType, superType) {
  subType.prototype = Object.create(superType && superType.prototype, {
    constructor: {
      value: subType,
      enumerable: false,
      writable: true,
      configurable: true
    }
  });
  if (superType) {
    Object.setPrototypeOf 
    ? Object.setPrototypeOf(subType, superType) 
    : subType.__proto__ = superType;
  }
}
複製代碼

子類的 proto 屬性:表示構造函數的繼承,老是指向父類。 子類 prototype 屬性的 proto 屬性:表示方法的繼承,老是指向父類的 prototype 屬性。

除此以外,ES6 能夠自定義原生數據結構(好比Array、String等)的子類,這是 ES5 沒法作到的。

相關文章
相關標籤/搜索