[[Prototype]]
JavaScript中的物件(函式也是物件)有一個特殊的[[Prototype]]內建屬性,所謂的原型鏈就是由它“鏈”起來的。
屬性查詢
當引用物件的屬性時會觸發[[Get]]操作,可以理解為會執行[[Get]]()
,其邏輯是先查詢當前物件是否存在該屬性,如果存在就使用它。否則就去遞迴遍歷,查詢[[Prototype]]屬性所引用的物件中是否存在要查詢的屬性,如果找到則返回,否則直到[[Prototype]]=null時查詢結束,此時返回undefined。
在使用for in
遍歷物件時原理和查詢[[Prototype]]鏈類似,任何可以通過原型鏈訪問到並且是enumerable
的屬性都會被列舉。使用in操作符來檢查屬性在物件中是否存在時,會查詢物件的整條原型鏈。
屬性設定和遮蔽
下面以myObject.foo = 'bar'
為例來說明所有可能出現的情況:
- 如果myObject自身存在foo屬性,不管其[[Prototype]]鏈上層是否存在,都會發生遮蔽現象,會重新賦值。
- 如果myObject自身不存在,[[Prototype]]鏈上也不存在,則foo屬性會被新增到myObject上。
- 如果myObject自身不存在,[[Prototype]]鏈上存在,會出現如下三種情況:
- 如果[[Prototype]]鏈上的foo為普通資料訪問屬性,並且沒有被標記為只讀(writable: false),那就會在myObject上新增foo屬性,它是遮蔽屬性。
- 如果[[Prototype]]鏈上的foo被標記為只讀(writable: true),如果執行在嚴格模式下,會丟擲一個錯誤。否則,這條賦值語句被忽略,並不會發生屬性遮蔽。
- 如果[[Prototype]]鏈上的foo是一個setter,那麼一定會呼叫這個setter。foo不會新增到myObject,也不會重新定義foo這個setter。
如果希望在第二種和第三種下也遮蔽foo,那就不能使用=
操作符來賦值,而是使用Object.defineProperty()
來向myObject新增foo。
下面來個例子體會一下,同時為原型繼承做一個鋪墊
var person = {name: 'a'};
var fn = {
getName: function() {
return this.name;
}
};
複製程式碼
- 先想一下person為什麼可以呼叫到Object.prototype中定義的方法?
Object被定義為函式,下面會提到,只要是函式都會存在prototype屬性,它指向一個物件,被稱為原型物件,toString、hasOwnProperty等方法就定義在該原型物件上。var person = {name: 'a'};
執行時會建立一個新的物件,並且底層會先將person的[[Prototype]]屬性值設定為Object.prototype,相當於執行Object.setPrototypeOf({name: a}, Object.prototype)
。這樣在整個[[Prototype]]鏈上就可以找到這些方法了。
- 再想一下我們怎麼能使person物件可以呼叫fn中的getName方法呢?
- 使用Object.assign(person, fn)將fn的getName直接新增到person物件中。
- 使用Object.setPrototypeOf(person, fn),會將person中的[[Prototype]]屬性由預設的Object改為指向fn物件,而fn中的[[Prototype]]會指向Object.prototype。在執行person.getName()時會進行屬性查詢,根據上面提到的規則,在其[[Prototype]]鏈上可以找到getName方法,其中的this應用的
隱式繫結
。
除此之外,還有另外一種實現方式,其原理和第二條一樣:
var fn = {
getName: function() {
return this.name;
}
};
// 建立一個新物件person,使其[[Prototype]]指向fn
var person = Object.create(fn);
person.name = 'a';
person.getName(); // 'a'
複製程式碼
函式中的prototype
JavaScript中的函式有一種特殊特性:所有函式預設都會擁有一個名為prototype
的公有並且不可列舉的屬性,他會指向一個物件:這個物件通常被稱為該函式的原型。該函式同時存在內建屬性[[Prototype]],注意這兩者的區別!
同時該prototype物件存在一個叫constructor
的屬性,會持有該函式的引用。
這些特性與下面要說的“建構函式”沒有任何關係,也就是說只要是函式就有這些特性。
建構函式
JavaScript中把首字母大寫的方法稱為建構函式,這只是一種約定,同時這也意味著要使用new關鍵字來呼叫。
使用new呼叫函式會執行下面的步驟:
- 建立一個全新的物件。
- 這個新物件會被執行[[Prototype]]連線。
- 這個新物件會被繫結到函式呼叫的this。
- 如果函式沒有返回其它物件。那麼new表示式中的函式呼叫會自動返回這個物件。
下面是虛擬碼
function customNew(fn) {
var o = {};
var rs = fn.apply(o, [].slice.call(arguments, 1));
Object.setPrototypeOf(o, fn.prototype);
return typeof rs === 'undefined' ? o : rs;
}
function Person(name) {
this.name = name;
}
Person.prototype.getName = function() {
return this.name;
};
var p = customNew(Person, 'JS');
p.getName(); // 'JS'
console.log(p.constructor === Person); // true
複製程式碼
Person的例項可以訪問到getName
和constructor
都是基於“屬性訪問”的原理。
繼承
繼承方式有多種,“紅皮書”裡也有講到,最盛行的一種是“組合繼承”,即“借用建構函式”與“原型繼承”組合起的一種方式。
function Foo(name) {
this.name = name;
}
Foo.prototype.myName = function() {
return this.name;
};
function Bar(name, label) {
// 借用Foo的建構函式
Foo.call(this, name);
this.label = label;
}
// Bar的原型物件被賦值為一個全新的物件,
// 該物件的[[Prototype]]指向Foo.prototype物件
Bar.prototype = Object.create(Foo.prototype);
// 設定不可列舉
Object.defineProperty(Bar.prototype, 'constructor', {
enumerable: false,
writable: true,
configurable: true,
value: Bar
});
// 或者可以用ES6中的setPrototypeOf方法,它只單純修改Bar.prototype中的[[Prototype]],使之指向Foo.prototype,並不會重新賦值,所以constructor不會丟失
// Object.setPrototypeOf(Bar.prototype, Foo.prototype);
Bar.prototype.myLabel = function() {
return this.label;
};
var a = new Bar("name", "label");
console.log(a.myName()); // "name"
console.log(a.myLabel()); // "label"
console.log(Bar);
複製程式碼
如果理解了屬性查詢及prototype和[[Prototype]],再來理解Js中的繼承就容易多了。