你不知道的JavaScript-原型

vuestar發表於2018-02-13

[[Prototype]]

JavaScript中的物件(函式也是物件)有一個特殊的[[Prototype]]內建屬性,所謂的原型鏈就是由它“鏈”起來的。

屬性查詢

當引用物件的屬性時會觸發[[Get]]操作,可以理解為會執行[[Get]](),其邏輯是先查詢當前物件是否存在該屬性,如果存在就使用它。否則就去遞迴遍歷,查詢[[Prototype]]屬性所引用的物件中是否存在要查詢的屬性,如果找到則返回,否則直到[[Prototype]]=null時查詢結束,此時返回undefined。

在使用for in遍歷物件時原理和查詢[[Prototype]]鏈類似,任何可以通過原型鏈訪問到並且是enumerable的屬性都會被列舉。使用in操作符來檢查屬性在物件中是否存在時,會查詢物件的整條原型鏈。

屬性設定和遮蔽

下面以myObject.foo = 'bar'為例來說明所有可能出現的情況:

  1. 如果myObject自身存在foo屬性,不管其[[Prototype]]鏈上層是否存在,都會發生遮蔽現象,會重新賦值。
  2. 如果myObject自身不存在,[[Prototype]]鏈上也不存在,則foo屬性會被新增到myObject上。
  3. 如果myObject自身不存在,[[Prototype]]鏈上存在,會出現如下三種情況:
    1. 如果[[Prototype]]鏈上的foo為普通資料訪問屬性,並且沒有被標記為只讀(writable: false),那就會在myObject上新增foo屬性,它是遮蔽屬性。
    2. 如果[[Prototype]]鏈上的foo被標記為只讀(writable: true),如果執行在嚴格模式下,會丟擲一個錯誤。否則,這條賦值語句被忽略,並不會發生屬性遮蔽。
    3. 如果[[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方法呢?
  1. 使用Object.assign(person, fn)將fn的getName直接新增到person物件中。
  2. 使用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呼叫函式會執行下面的步驟:

  1. 建立一個全新的物件。
  2. 這個新物件會被執行[[Prototype]]連線。
  3. 這個新物件會被繫結到函式呼叫的this。
  4. 如果函式沒有返回其它物件。那麼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的例項可以訪問到getNameconstructor都是基於“屬性訪問”的原理。

繼承

繼承方式有多種,“紅皮書”裡也有講到,最盛行的一種是“組合繼承”,即“借用建構函式”與“原型繼承”組合起的一種方式。

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中的繼承就容易多了。

相關文章