【機制】JavaScript的原型、原型鏈、繼承

木子草明發表於2021-01-05

1.原型和原型鏈的概念

js在建立一個物件時,比如叫 obj,都會給他偷偷的加上一個引用,這個引用指向的是一個物件,比如叫 yuanxing
這個物件可以給引用它的物件提供屬性共享,比如:yuanxing上有個屬性name,可以被 obj.name訪問到,
這個可以提供屬性共享的物件,就稱為前面物件的原型

而原型本身也是一個物件,所以它也會有一個自己的原型,這一層一層的延續下去,直到最後指向 null,這就形成的 原型鏈

那js的這一種是原型機制是怎麼實現的呢?

2.原型的實現

我們先從一個例子來看:

//code-01
let obj = new Object({name:'xiaomin'})
console.log(obj.name)
console.log(obj.toString())

// xiaomin
// [object Object]

我們首先建立了一個物件obj,它有一個屬性name
屬性name是我們建立的,但是在建立obj的時候,並沒有給它建立toString屬性,為什麼obj.toString()可以訪問的到呢?

prototype 屬性
我們先來看一下Object.prototype屬性

我們發現這裡有toString屬性,所以其實Object.prototype就是obj的原型,按照原型的概念,它能夠為obj提供屬性共享
所以當obj.toString()的時候,先在obj的屬性裡找,沒找到,就在obj的原型Object.prototype的屬性上找,可以找到,所以呼叫成功

proto 屬性
那麼obj是怎麼找到原型的呢?我們列印obj屬性看一下:

我們發現obj除了我們建立的name以外,還有一個__proto__屬性,它的值是一個物件,那麼它等於什麼呢?

我們發現obj.__proto__指向的就是Object.prototype

到此為止,我們可以簡單總結一下js語言實現原型的基本機制了

  • 在建立一個物件obj時,會給它加上一個__proto__屬性
  • __proto__屬性 指向obj建構函式prototype物件
  • 當訪問obj的某個屬性時,會先在它自己的屬性上找,如果沒找到,就在它的原型(其實就是__proto__指向的物件)的屬性上找

建構函式
這個有一個建構函式的概念,其實建構函式也就是普通函式,當這個函式被用來 new一個新物件時,它就被稱為新物件的 建構函式
就上面的例子而言,Objec就是obj的建構函式,
這裡要區別一下Objectobject的區別,前者是一個js內建的一個函式,後者是js的基本資料型別(number,string,function,object,undefined)

3.new 實際上做了什麼

上面有說到new關鍵字,那麼在它實際上做了什麼呢?
上面程式碼code-01使用系統內建的函式Object來建立物件的,那麼我們現在用自己建立的函式來建立一個新物件看看:

//code-02

function human(name){
  this.name = name
}
human.prototype.say = function(){
  alert('我叫'+this.name)
}
let xiaomin = new human('xiaoming')

console.log(xiaomin.name)
xiaomin.say()

這裡的human就是新物件xiaoming的建構函式
我們把新建立的物件xiaoming列印出來看看:

我們看到xiaoming有一個屬性name,並且xiaoming.__proto__完全等於建構函式的human.prototype,這就是它的原型
從這裡我們可以總結一下new的基本功能:

  • 建構函式的this指向新建立的物件xiaoming
  • 為新物件建立原型,其實就是把新物件的__proto__指向建構函式的prototype

手寫new
上面我們瞭解了new具體做了什麼事情,那麼它是怎麼實現這些功能的呢?下面我們手寫一個函式myNew來模擬一下new的效果:

//code-03
    function human(name, age) {
      this.name = name;
      this.age = age;
    }
    human.prototype.say = function () {
      console.log("my name is " + this.name);
    };

    xiaoming = myNew(human, "xiaoming", 27);

    function myNew() {
      let obj = new Object();
      //取出函式的第一個引數,其實就是 human函式
      let argArr = Array.from(arguments);
      const constructor = argArr.shift();
      // 指定原型
      obj.__proto__ = constructor.prototype;
      //改變函式執行環境
      constructor.apply(obj, argArr);
      return obj;
    }

    xiaoming.say();

我們先把新物件xiaoming列印出來看一下:

我們發現這和上面的程式碼code-02的效果是一樣的
上面程式碼code-03裡面如果對apply的作用不太熟悉的,可以另外瞭解一下,其實也很簡單,意思就是:在obj的環境下,執行constructor函式,argArr是函式執行時的引數,也就是指定了函式的this

4.繼承的實現

其實就上面的內容,就可以對js的原型機制有個基本的瞭解,但是一般面試的時候,如果有問到原型,接下來就會問 能不能實現 繼承的功能,所以我們來手寫一下 原型的繼承,其實所用到的知識點都是上面有提到的

繼承的概念
我們先來說下繼承的概念:
繼承其實就是 一個建構函式(子類)可以使用另一個建構函式(父類)的屬性和方法,這裡有幾點注意的:

  • 繼承是 建構函式 對 另一個建構函式而言
  • 需要實現屬性的繼承,即 this的轉換
  • 需要實現方法的繼承,一般就是指 原型鏈的構建

繼承的實現
基於上面的3點要素,我們先直接來看程式碼:

// code-04
   // 父級 函式
    function human(name) {
      this.name = name;
    }
    human.prototype.sayName = function () {
      console.log("我的名字是:", this.name);
    };

    // 子級 函式
    function user(args) {
      this.age = args.age;
      //1.私有屬性的繼承
      human.call(this, args.name); 
      //2.原型的繼承
      Object.setPrototypeOf(user.prototype, human.prototype); //原型繼承-方法1
      // user.prototype.__proto__ = human.prototype; // 原型繼承-方法2
    }
    // 因為重新賦值了prototype,所以放置 user 外部
    // user.prototype = new human();//原型繼承-方法3
    // user.prototype = Object.create(human.prototype);//原型繼承-方法4

    user.prototype.sayAge = function () {
      console.log("我的年齡是:", this.age);
    };
    let person = new human("人類");
    let xiaoming = new user({ name: "xiaoming", age: 27 });

    console.log("----父類-----");
    console.log(person);
    person.sayName();

    console.log("----子類-----");
    console.log(xiaoming);
    xiaoming.sayName();
    xiaoming.sayAge();

我們先來看下列印的結果:

從列印結果,我們可以看到xiaoming擁有person的屬性和方法(name,sayName),又有自己私有的屬性方法(age,sayAge),這是因為建構函式user實現了對human的繼承。
其實實現的方法無非也就是我們前面有說到的 作用域的改變和原型鏈的構造,其中作用域的改變(this指向的改變)主要是兩個方法:call和apply,原型鏈的構造原理只有一個,就是物件的原型等於其建構函式的prototype屬性,但是實現方法有多種,程式碼code-04中有列出4種。
從上面的例子來看原型鏈的指向是:xiaoming->user.prototype->human.prototype

5.class和extends

我們可能有看到一些程式碼直接用 classextends關鍵字來實現類和繼承,其實這是ES6的語法,其實是一種語法糖,本質上的原理也是相同的。我們先來看看基本用法:
用法

//code-05
   class human {
      //1.必須要有建構函式
      constructor(name) {
        this.name = name;
      }//2.不能有逗號`,`
      sayName() {
        console.log("sayName:", this.name);
      }
    }

    class user extends human {
      constructor(params) {
        //3.子類必須用`super`,呼叫父類的建構函式
        super(params.name);
        this.age = params.age;
      }
      sayAge() {
        console.log("sayAge:", this.age);
      }
    }

    let person = new human("人類");
    let xiaoming = new user({ name: "xiaoming", age: 27 });

    console.log("----<human> person-----");
    console.log(person);
    person.sayName();

    console.log("----<user> xiaoming-----");
    console.log(xiaoming);
    xiaoming.sayName();
    xiaoming.sayAge();

執行結果:

我們看到執行的結果和上面的程式碼code-04是一樣的,但是程式碼明顯清晰了很多。幾個注意的地方:

  • class類中必須要有建構函式constructor,
  • class類中的函式不能用 ,分開
  • 如果要繼承父類的話,在子類的建構函式中,必須先執行 super來呼叫的父類的建構函式

相同
上面有說class的寫法其實原理上和上面是一樣的,我們來驗證一下

  1. 首先看看userhuman是什麼型別

    這裡看出來了,所以雖然被class修飾,本質上還是函式,和程式碼code-04中的user,human函式是一樣的

  2. 再來看看prototype屬性

    這裡看出來sayName,sayAge都是定義在human.prototypeuser.prototype上,和程式碼code-04中也是一樣的

  3. 我們再來看看原型鏈

    這與程式碼code-04中的原型鏈的指向也是一樣:xiaoming->user.prototype->human.prototype

差異
看完相同點,現在我們來看看不同點:

  1. 首先寫法上的不同
  2. class宣告的函式,必須要用new呼叫
  3. class內部的成員函式沒有prototype屬性,不可以用new呼叫
  4. class 內的程式碼自動是嚴格模式
  5. class宣告不存在變數提升,這一點和 let一樣,比如:
    //code-06
    console.log(name_var);
    var name_var = "xiaoming";
    //undefined,不會報錯,var宣告存在變數提升
    
    console.log(name_let);
    let name_let = "xiaoming";
    // Uncaught ReferenceError: Cannot access 'name_let' before initialization
    //報錯,let宣告不存在變數提升
    
    new user();
    class user {}
    // Uncaught ReferenceError: Cannot access 'user' before initialization
    //報錯,class宣告不存在變數提升
    
    
  6. class內的方法都是不可列舉的,比如:
      //code-07
      class human_class {
      constructor(name) {
        this.name = name;
      }
      sayName() {
        console.log("sayName:", this.name);
      }
    }
    function human_fun(name) {
      this.name = name;
    }
    human_fun.prototype.sayName = function () {
      console.log("sayName:", this.name);
    };
    console.log("----------human_class-----------");
    console.log("prototype屬性", human_class.prototype);
    console.log("prototype 列舉", Object.keys(human_class.prototype));
    
    console.log("----------human_fun-----------");
    console.log("prototype屬性", human_fun.prototype);
    console.log("prototype 列舉", Object.keys(human_fun.prototype));
    
    執行結果:

6.總結

簡單總結一下:

  • 每個物件在建立的時候,會被賦予一個__proto__屬性,它指向建立這個物件的建構函式的prototype,而prototype本身也是物件,所以也有自己的__proto__,這就形成了原型鏈,最終的指向是 Object.prototype.__proto__ == null
  • 可以通過new,Object.create(),Object.setPrototypeOf(),直接賦值__proto__等方法為一個物件指定原型
  • new操作符實際做的工作是:建立一個物件,把這個物件作為建構函式的this環境,並把這個物件的原型(proto)指向建構函式的prototype,最後返回這個物件
  • 繼承主要實現的功能是:this指向的繫結,原型鏈的構建
  • ES6的語法classextends可以提供更為清晰簡潔的寫法,但是本質上的原理大致相同

相關文章