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
可以看到,目前PrimaryStudent
和Student
並沒有任何關聯,僅僅是藉助Student.call(this, props);
聲明瞭name
屬性。
要想繼承Student
必須要實現如下的原型鏈:
new PrimaryStudent() -> PrimaryStudent.prototype -> Student.prototype -> Object.prototype -> null
當然可以直接進行如下賦值:
PrimaryStudent.prototype = Student.prototype
但這樣其實沒有任何意義,如此一來,所以在PrimaryStudent
上掛載的方法都是直接掛載到Student
的原型上了,PrimaryStudent
就顯得可有可無了。
那如何才能將方法掛載到PrimaryStudent
而不是Student
上呢?其實很簡單,在PrimaryStudent
和Student
之間插入一個新的物件作為兩者之間的橋樑:
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關鍵字也利用了原型繼承來實現物件建立。