1. 程式人生 > >JavaScript 函式原型鏈解析

JavaScript 函式原型鏈解析

JavaScript中,函式原型鏈是最強大也是最容易讓人迷惑的特性。長期以來對於prototype__proto__的一知半解導致在實際開發中經常遇到難以排查的問題,所以有必要將JavaScript中的原型概念理解清楚。

1. __proto__ vs prototype

1.1 __proto__

JavaScript中所有物件都擁有一個__proto__用來表示其原型繼承,所謂的原型鏈也就是根據__proto__一層層向上追溯。JavaScript中有一個內建屬性[[prototype]](注意不是prototype)來表徵其原型物件,大多數瀏覽器支援通過__proto__

來對齊進行訪問。一個普通物件的__proto__Object.prototype:

var a = {
    'h' : 1
}

// output: true
a.__proto__ === Object.prototype

1.2 prototype

prototype是隻有函式才有的屬性。

當建立函式時,JavaScript會自動給函式建立一個prototype屬性,並指向原型物件functionname.prototype

JavaScript可以通過prototype__proto__在兩個物件之間建立一個原型關係,實現方法和屬性的共享,從而實現繼承。

1.3 建構函式建立物件例項

JavaScript中的函式物件有兩個不同的內部方法:[[Call]]Construct

如果不通過new關鍵字來呼叫函式(比如call,apply等),則執行[[Call]]方法,該種方式只是單純地執行函式體,並不建立函式物件。

如果通過new關鍵字來呼叫函式,執行的是[[Constrcut]]方法,該方法會建立一個例項物件,同時將該物件的__proto__屬性執行建構函式的prototype也即functionname.prototype,從而繼承該建構函式下的所有例項和方法。

有了以上概念後,來看一個例子:

function Foo(firstName, lastName)
{
this.firstName = firstName; this.lastName = lastName; } Foo.prototype.logName = function(){ Foo.combineName(); console.log(this.fullName); } Foo.prototype.combineName = function(){ this.fullName = `${this.firstName} ${this.lastName}` } var foo = new Foo('Sanfeng', 'Zhang'); foo.combineName(); console.log(foo.fullName); // Sanfeng Zhang foo.logName(); // Uncaught TypeError: Foo.combineName is not a function

明明聲明瞭Foo.prototype.logName,但是Foo.combineName卻出錯,其原因在於原型鏈理解出錯。

首先來看下foo的原型鏈:

var foo = new Foo('Sanfeng', 'Zhang'):

通過new建立一個函式物件,此時JavaScript會給創建出來物件的__proto__賦值為functionname.protoye也即Foo.prototype,所以foo.combineName可以正常訪問combineName。其完整原型鏈為:

foo.__proto__ === Foo.prototype
foo.__proto__.__proto__ === Foo.prototype.__proto__ === Object.prototype
foo.__proto__.__proto__.__proto__ === Foo.prototype.__proto__.__proto__ === Object.prototype.__proto__ === null

接下來看下Foo的原型鏈:

直接通過Foo.combineName呼叫時,JavaScript會從Foo.__proto__找起,而Foo.__proto__指向Function.prototype,所以根本無法找到掛載在Foo.prototype上的combineName方法。

其完整原型鏈為:

Foo.__proto__ = Function.prototype;
Foo.__proto__.__proto__ = Function.prototype.__proto__;
Foo.__proto__.__proto__.__proto__ = Function.prototype.__proto__.__proto__ = Object.prototype.__proto__ = null;

接下來做一下變形:

function Foo(firstName, lastName){
    this.firstName = firstName;
    this.lastName = lastName; 
}

Foo.__proto__.combineName = function() {
    console.log('combine name');
}

Foo.combineName(); // combine name
Funciton.combineName(); // combine name
var foo = new Foo('Sanfeng', 'Zhang');
foo.combineName(); // foo.combineName is not a function

這次是在Foo.__proto__上註冊的combineName,所以例項物件foo無法訪問到,但是Function Foo可以訪問到,另外我們看到因為Foo.__proto__指向Function.prototype,所以可以直接通過Function.combineName訪問。

2 原型繼承

理解清楚了__proto__prototype的聯絡和區別後,我們來看下如何利用兩者實現原型繼承。首先來看一個例子:

function Student(props) {
    this.name = props.name || 'unamed';
}

Student.prototype.hello = function () {
    console.log('Hello ' + this.name);
}

var xiaoming = new Student({name: 'xiaoming'}); // Hello xiaoming

這個很好理解:

xiaoming -> Student.prototype -> Object.prototype -> null

接下來,我們來建立一個PrimaryStudent:

function PrimaryStudent(props) {
   Student.call(this, props);
   this.grade = props.grade || 1;
}

其中Student.call(this, props);僅僅執行Student方法,不建立物件,參考1.3節中的[[Call]]

此時的原型鏈為:

new PrimaryStudent() -> PrimaryStudent.prototype -> Object.prototype -> null

可以看到,目前PrimaryStudentStudent並沒有任何關聯,僅僅是藉助Student.call(this, props);聲明瞭name屬性。

要想繼承Student必須要實現如下的原型鏈:

new PrimaryStudent() -> PrimaryStudent.prototype -> Student.prototype -> Object.prototype -> null

當然可以直接進行如下賦值:

PrimaryStudent.prototype = Student.prototype

但這樣其實沒有任何意義,如此一來,所以在PrimaryStudent上掛載的方法都是直接掛載到Student的原型上了,PrimaryStudent就顯得可有可無了。

那如何才能將方法掛載到PrimaryStudent而不是Student上呢?其實很簡單,在PrimaryStudentStudent之間插入一個新的物件作為兩者之間的橋樑:

function F() {}
F.prototype = Student.prototype;
PrimaryStudent.prototype = new F();
PrimaryStudent.prototype.constructor = PrimaryStudent;

// 此時就相當於在new F()物件上新增方法
PrimaryStudent.prototype.getGrade = function() {

}

如此一來就實現了PrimaryStudent與Student的繼承:

new PrimaryStudent() -> new PrimaryStudent().__proto__ -> PrimaryStudent.prototype -> new F() -> new F().__proto__ -> F.prototype -> Student.prototype -> Object.prototype -> null

3 關鍵字new

實際開發中,我們總是通過一個new來建立物件。那麼為什麼new可以建立一個我們需要的物件?其與普通的函式執行有什麼不同呢?
來看下下面這段程式碼:

function fun() {
    console.log('fun');
}
fun();
var f = new fun();

其對應的輸出都是一樣的:

fun
fun

但實際上,兩者有著本質的區別,前者是普通的函式執行,也即在當前活躍物件執行環境內直接執行函式fun。
new fun()的實質卻是建立了一個fun物件,其含義等同於下文程式碼:

function new(constructor) {
 var obj = {}
 Object.setPrototypeOf(obj, constructor.prototype);
 return constructor.apply(obj, [...arguments].slice(1)) || obj
} 

可以看到,當我們執行new fun()時,實際執行了如下操作:

  • 建立了一個新的物件。
  • 新物件的原型繼承自建構函式的原型。
  • 以新物件的 this 執行建構函式。
  • 返回新的物件。如果建構函式返回了一個物件,那麼這個物件會取代整個 new 出來的結果

從中也可以看到,其實new關鍵字也利用了原型繼承來實現物件建立。