深刻理解JS原型與繼承

前言

最近在總體地複習一遍現代前端必備的核心知識點,將會整理成一個前端分析總結文章系列。這篇是其中的第三篇,主要是總結下JS中原型與繼承等核心知識點。(另外,此係列文章也能夠在語雀專欄——硬核前端系列查看)。javascript

本文首發自 迪諾筆記,轉載請註明出處😁

1、原型機制

「每一個構造函數都有一個原型對象,原型對象都包含一個指向構造函數的指針,實例都包含一個指向原型對象的內部指針。」
——《JavaScript高級程序設計》

核心總結

實例對象是經過 new 操做符來操做構造函數 constructor 生成的。實例對象具備 __proto__ 屬性,構造函數具備 prototype 屬性。html

原型(prototype)自己也是一個對象,稱爲原型對象。構造函數上的屬性 prototype 指向原型對象,實例上的屬性 __proto__ 指向原型對象。前端

JS原型.png
prototype___proto___ 的指向關係圖java

藉助原型實現繼承的核心思想是:對象在查找屬性和方法時首先看對象自身是否存在,不存在則去原型對象上查找,若未找到則繼續到原型對象的原型對象上查找,依次進行下去直到查找到內置對象Object。即,實例屬性訪問是沿着原型鏈向上遞歸查找git

原型鏈與屬性訪問.png
實例屬性訪問時沿原型鏈遞歸查找es6

原型對象也是對象也存在本身的原型對象,這裏的原型對象造成的鏈條就是原型鏈。內置對象 Object 的原型對象是 nullObject 對象是全部對象最頂層的原型對象。github

完整的構造函數、原型、原型鏈組成及之間的關係以下圖所示:typescript

實例分析

如下面這個基礎的繼承實現爲例:數組

function constructorFn (state, data) {
    this.state = state;
    this.data = data;
    this.isPlay = function () {
        return this.state + ' is ' + this.data;
    }
}
var instance1 = new constructorFn ('1', 'doing');
var instance2 = new constructorFn ('2', 'done');
console.log(instance1.isPlay()); // 1 is doing
console.log(instance2.isPlay()); // 2 is done

這裏分別生成了兩個實例:instance1instance2,其構造函數經過對 this 進行賦值使得各自實例有各自的獨立屬性和方法。app

這種狀況下的實例、原型對象、構造函數之間的關係圖以下:

實例、原型對象、構造函數之間的關係圖

上面例子中的 isPlay 方法在各自的實例對象上進行了重複定義,方法的邏輯是同樣的能夠進行復用,下面採用 prototype 來優化。

function constructorFn (state, data) {
    this.state = state;
    this.data = data;
}
constructorFn.prototype.isPlay = function () {
    return this.state + ' is ' + this.data;
}
var instance1 = new constructorFn ('1', 'doing');
var instance2 = new constructorFn ('2', 'done');
instance1.isDoing = 'nonono!';
instance2.isDoing = 'nonono!';
console.log(instance1.isPlay()); // 1 is doing
console.log(instance2.isPlay()); // 2 is done
console.log(instance1.isDoing); // nonono!
console.log(instance2.isDoing); // nonono!

這裏將 isPlay 方法存到構造函數的原型對象上面,而後分別給兩個實例對象添加 isDoing 屬性。此時實例對象的 isPlay 方法是經過其 __proto__ 指向的原型對象而訪問到構造函數原型 prototype 上的 isPlay 方法,而實例對象的 isDoing 屬性是其實例對象自己的屬性。

這種狀況下的實例、原型對象、構造函數之間的關係圖以下:

實例、原型對象、構造函數之間的關係圖

這時候經過構造函數的 prototype 修改原型對象屬性,全部繼承自原型的屬性都被修改,而實例對象自身的屬性不會改變。

constructorFn.prototype.isDoing = 'yesyesyes!';
console.log(instance1.isDoing); // yesyesyes!
console.log(instance2.isDoing); // yesyesyes!

一樣,經過實例的 __proto__ 修改原型對象屬性,全部繼承自原型的屬性都被修改,而實例對象自身的屬性不會改變。

- constructorFn.prototype.isDoing = 'yesyesyes!';
+ instance1.__proto__.isDoing = 'yesyesyes!';
console.log(instance1.isDoing); // yesyesyes!
console.log(instance2.isDoing); // yesyesyes!

上述狀況下的內存模型以下:

上述實例部份內存模型

多個實例對象的 ___proto___ 屬性經過指針指向同一個原型對象——構造函數的原型對象;而實例自己的屬性則是指向存儲在對象自己。

若是直接修改實例自身的屬性 isDoing ,則另外一個實例的屬性不會跟着修改。

- constructorFn.prototype.isDoing = 'yesyesyes!';
+ instance1.isDoing = 'yesyesyes!';
console.log(instance1.isDoing); // yesyesyes!
console.log(instance2.isDoing); // nonono!

屬性方法的查找過程

從對象自己開始,沿着原型組成的原型鏈逐級往上查找所訪問的屬性,找到相應的屬性就返回,若直到 Object.prototype 還未找到則返回 undefined。這裏屬性方法沿原型鏈的查找過程就是所謂的屬性繼承、方法重寫以及繼承方案的本質。

屬性沿着原型鏈查找示意圖

2、new的本質

var obj = {};
obj.__proto__ =  constructorFn.prototype;
constructorFn.call(obj);

new 主要作了如下四件事情

  • 建立一個空對象
  • 將上面建立對象的原型 __proto__ 指向構造函數的原型 prototype
  • 將構造函數上下文 this 指向建立的對象而後執行
  • 返回上面建立的對象

實例分析

仍是使用原型機制中用過的例子進行分析

function constructorFn (state, data) {
    this.state = state;
    this.data = data;
}
constructorFn.prototype.isPlay = function () {
    return this.state + ' is ' + this.data;
}
constructorFn.prototype.isDoing = 'nonono!';
var instance1 = new constructorFn ('1', 'doing');
var instance2 = new constructorFn ('2', 'done');
console.log(instance1.isPlay()); // 1 is doing
console.log(instance2.isPlay()); // 2 is done
console.log(instance1.isDoing); // nonono!
console.log(instance2.isDoing); // nonono!

對於 var instance1 = new constructorFn ('1', 'doing'); 執行過程以下圖所示。

new 的執行過程圖示

3、this 的指向問題

  • this 永遠指向函數的直接調用者
  • 若是存在 new 關鍵字,則 this 指向 new 出來的那個對象
  • 在事件中,this 指向觸發這個事件的對象,特殊的是,IE 中的 attachEvent 中的 this 老是指向全局對象 window
function constructorFn (state, data) {
    this.data = data;
    this.state = state;
      console.log(this);
}
var obj = {
    constructorFn
};
constructorFn('a', 'b'); // Window
obj.constructorFn('a', 'b'); // obj
var instance1 = new constructorFn('a', 'b'); // instance1

對於 constructorFn('a', 'b'); 函數體內部 this 指向 Window 對象;對於 obj.constructorFn('a', 'b'); 函數體內部 this 綁定到了 obj;對於 var instance1 = new constructorFn('a', 'b'); 函數體內部 this 綁定到了 new 出來的那個對象 instance1。

函數嵌套的狀況

function a() {
  return () => {
    return () => {
      console.log(this)
    }
  }
}
console.log(a()()())

首先箭頭函數實際上是沒有 this 的,箭頭函數中的 this 只取決包裹箭頭函數的第一個普通函數的 this。在這個例子中,由於包裹箭頭函數的第一個普通函數是 a,因此此時的 this 是 window。另外對箭頭函數使用 bind這類函數是無效的。

最後種狀況也就是 bind 這些改變上下文的 API 了,對於這些函數來講,this 取決於第一個參數,若是第一個參數爲空,那麼就是 window。

call、apply、bind

三者都是將第一個參數做爲上下文綁定到其調用者的上下文 this 上,若是第一個參數不存在則默認綁定 Window 到調用者的上下文 this。

call 以散列值的形式將函數參數傳入前面調用函數並執行;apply 以數組的形式將函數參數傳入前面調用函數並執行;bind 僅綁定前面函數的上下分 this 不執行前面函數。

使用屢次 bind 綁定 this

let a = {}
let fn = function () { console.log(this) }
fn.bind().bind(a)() // Window

上述代碼等價於:

let fn2 = function fn1() {
  return function() {
    return fn.apply()
  }.apply(a)
}
fn2()

就是說:屢次使用 bind 綁定 this 只有第一次綁定 this 生效

若是存在屢次綁定函數的上下文 this,則按照優先級進行判斷

首先,new 的方式優先級最高,接下來是 bind 這些函數,而後是 obj.foo() 這種調用方式,最後是 foo 這種調用方式,同時,箭頭函數的 this 一旦被綁定,就不會再被任何方式所改變。

4、類型判斷

instanceof

主要用法以下:

function constructorFn (state, data) {
    this.data = data;
    this.state = state;
}
var instance1 = new constructorFn('a', 'b')
console.log(instance1 instanceof constructorFn) // true || false

內部原理以下:

instanceof 主要的實現原理就是隻要右邊變量的 prototype 在左邊變量的原型鏈上便可。所以,instanceof 在查找的過程當中會遍歷左邊變量的原型鏈,直到找到右邊變量的 prototype,若是查找失敗,則會返回 false。

Object.prototype.toString.call()

主要用法以下:

function constructorFn (state, data) {
    this.data = data;
    this.state = state;
}
var instance1 = new constructorFn('a', 'b');
var array1 = [1, 2, 3];
var boolean1 = true;
var string1 = 'a';
var number1 = 123;
var set1 = new Set();
var map1 = new Map();
class Class1 {};
var symbol1 = new Symbol();
console.log(Object.prototype.toString.call(instance1));     // [object Object]
console.log(Object.prototype.toString.call(array1));             // [object Array]
console.log(Object.prototype.toString.call(boolean1));         // [object Boolean]
console.log(Object.prototype.toString.call(string1));         // [object String]
console.log(Object.prototype.toString.call(number1));         // [object Number]
console.log(Object.prototype.toString.call(constructorFn));// [object Function]
console.log(Object.prototype.toString.call(null));                 // [object Null]
console.log(Object.prototype.toString.call(set1));                 // [object Set]
console.log(Object.prototype.toString.call(map1));                 // [object Map]
console.log(Object.prototype.toString.call(Class1));             // [object Function]
console.log(Object.prototype.toString.call(symbol1));         // [object Symbol]

用法原理以下:

對象能夠有本身的 toString 方法,也能夠由從父類繼承過來的 toString 方法,這些方法的執行邏輯可能被更改過,估計直接經過 instance1.toString() 方法不能準確獲取對象類型信息。

這種用法的思路是將 Object.prototype.toString 方法內部 this 綁定到當前的對象上調用,這樣無論當前的對象有沒有提供或繼承別的 toString 方法只會執行 Object.prototype 上的 toString 方法,確保了能夠準確打印對象的類型信息。

5、經常使用繼承方案

原型鏈繼承

將子類的原型 prototype 指向父類的實例對象來實現父類屬性和方法的繼承;由於父類實例對象的構造函數 constructor 指向了父類原型,因此須要將子類原型構造函數 constructor 指向子類構造函數。

function Animal (name) {
    this.name = name;
}
Animal.prototype = {
    canRun: function () {
        console.log('it can run!');
    }
}
function Cat () {
    this.speak = '喵!';
} 
Cat.prototype = new Animal('miao');
Cat.prototype.constructor = Cat;

call、apply 實現繼承

經過 call、apply 改變函數的 this 指向,來將子類的 this 指向父類,在父類構造函數用當前子類 this 執行完成後,當前子類 this 即有了父類定義的屬性和方法。

function Animal (name) {
    this.name = name;
}
Animal.prototype = {
  canRun: function () {
    console.log('it can run!');
  }
}
function Cat (name) {
  Animal.call(this, name);
  this.speak = '喵!';
}

組合繼承

原型鏈繼承與 call、apply 實現繼承的結合應用

核心是在子類的構造函數中經過 Animal.call(this) 繼承父類的屬性,而後改變子類的原型爲父類實例對象來繼承父類的方法。

function Animal(name) {
  this.name = name
}
Animal.prototype.getName = function() {
  console.log(this.name)
}
function Cat(name) {
  Animal.call(this, name)
}
Cat.prototype = new Animal()

const cat1 = new Cat(1)

cat1.getName() // 1
cat1 instanceof Animal // true

這種繼承方式優勢在於構造函數能夠傳參,不會與父類引用屬性共享,能夠複用父類的函數,可是也存在一個缺點就是在繼承父類函數的時候調用了父類構造函數,致使子類的原型上多了不須要的父類屬性,存在內存上的浪費。

class 實現繼承

說明下: es6 中的 class 類其實只是語法糖,上面打印 class 的類型信息能夠發現其本質仍是函數,只不過經過 extends、super等關鍵字對原型和構造函數的操做進行了簡化。
class Animal {
  constructor(name) {
    this.name = name
  }
  getValue() {
    console.log(this.name)
  }
}
class Cat extends Animal {
  constructor(name) {
    super(name)
    this.name = name
  }
}
let cat1 = new Cat(1)
cat1.getName() // 1
cat1 instanceof Animal // true

class 實現繼承的核心在於使用 extends 代表繼承自哪一個父類,而且在子類構造函數中必須調用 super,由於這段代碼能夠當作 Animal.call(this, value)

TS 中 class 實現繼承

TS 中的 class 繼承實際上是向 ECMAScript 規範靠近的,二者用法並沒有二致。

class Animal {
  public name: string | null = null
  constructor(name: string) {
    this.name = name
  }
}
class Cat extends Animal {
  constructor(name: string) {
    super(name)
  }
  getName () {
    console.log(this.name)
  }
}
let cat1 = new Cat('1')
cat1.getName() // 1
cat1 instanceof Animal // true

寫在最後

既然看到這裏了不妨點個贊鼓勵下做者唄 :)

做者博客: https://blog.lessing.online
做者github: https://github.com/johniexu

【全面分析總結前端系列】

參考文章

相關文章
相關標籤/搜索