前言
原文來自MDN JavaScript主題的高階教程部分,一共5篇。分別涉及繼承與原型、嚴格模式、類型數組、內存管理、併發模型和事件循環。本篇是第一部分,關於繼承和原型。
原文鏈接請點我
下面是正文部分:
對於熟悉基於類的編程語言(例如 Java 和 C++)的開發者來説,JavaScript 會讓他們感到困惑,因為 JS 的動態性以及其本身並不提供class的實現(ES2015 中提出的class關鍵字僅僅是語法糖,JS 仍然是基於原型的)
提到繼承,JavaScript 只有一個結構:對象(objects)。每個對象都有一個私有屬性,該屬性鏈接到另一個對象(稱為該對象的原型(prototype))。這個原型對象自身也有一個原型,直到一個對象的原型為null。根據定義,null不存在原型,它代表這條原型鏈的終點。
在 JavaScript 中,幾乎所有對象都是Object的實例,Object在原型鏈頂端。
儘管這種困惑經常被認為是 JavaScript 的缺點,但是這種原型式的繼承模型實際上比一些經典的模型更為強大。例如,在一個原型式模型的基礎上再構造一個經典模型是非常簡單的。
通過原型鏈繼承
繼承屬性
JavaScript 對象就像一堆屬性的動態“包裹”(這堆屬性稱為對象自身屬性)(譯者注:原文為 JavaScript objects are dynamic "bags" of properties (referred to as own properties).)。
JavaScript 對象有一個指向原型對象的鏈接。當訪問一個對象的屬性時,不僅會在該對象上查找,還會在該對象的原型,以及這個原型的原型上查找,直到匹配上這個屬性名或者遍歷完該原型鏈。
根據 ECMAScript 標準,someObject.[[Prototype]]用於指定someObject的原型。從 ECMAScript 2015 開始,[[Prototype]]可以通過Object.getPrototypeOf()和Object.setPrototypeOf()訪問。這和通過 JavaScript 中的__proto__訪問是一樣的,儘管這不標準,但是已經被很多瀏覽器所實現。
最好不要和函數的_func_.prototype屬性混淆。當一個函數被當做構造器(constructor)調用時,會生成一個對象,而函數上的_func_.prototype屬性引用的對象會作為生成對象的[[Prototype]]存在。Object.prototype就表示了Object這一函數的 prototype。
下面例子展示了訪問對象屬性的過程:
// 讓我們使用構造函數f創建一個對象o,o上面有屬性a和b:
let f = function () {
this.a = 1;
this.b = 2;
};
let o = new f(); // {a: 1, b: 2}
// 在f的prototype對象上添加一些屬性
f.prototype.b = 3;
f.prototype.c = 4;
// 不要對prototype重新賦值比如: f.prototype = {b:3,c:4}; 這會打斷原型鏈
// o.[[Prototype]] 上有屬性b和c
// o.[[Prototype]].[[Prototype]] 就是 Object.prototype
// 最終, o.[[Prototype]].[[Prototype]].[[Prototype]] 為 null
// 這就是原型鏈的終端, 等於 null,
// 根據定義, null不再有 [[Prototype]]
// 因此, 整條原型鏈看起來類似:
// {a: 1, b: 2} ---> {b: 3, c: 4} ---> Object.prototype ---> null
console.log(o.a); // 1
// o上存在自身屬性'a'嗎?當然,該屬性值為1
console.log(o.b); // 2
// o上存在自身屬性'b'嗎?當然,該屬性值為2
// prototype 上也有屬性'b', 但是並不會被訪問到
// 這叫做“屬性覆蓋”
console.log(o.c); // 4
// o上存在自身屬性'c'嗎?不存在, 繼續查找它的原型
// o.[[Prototype]]上存在自身屬性'c'嗎?當然,該屬性值為4
console.log(o.d); // undefined
// o上存在自身屬性'd'嗎?不存在, 繼續查找它的原型
// o.[[Prototype]]上存在自身屬性'd'嗎?不存在, 繼續查找o.[[Prototype]]的原型
// o.[[Prototype]].[[Prototype]] 為 Object.prototype, 上面不存在屬性'd', 繼續查找o.[[Prototype]].[[Prototype]]的原型
// o.[[Prototype]].[[Prototype]].[[Prototype]] 為 null, 停止查找
// 沒找到屬性'd',返回undefined
在線代碼鏈接
在一個對象上設置屬性稱為創建了一個”自身屬性“(譯者注:原文為Setting a property to an object creates an own property.)。唯一會影響屬性 set 和 get 行為的是當該屬性使用getter 或者 setter定義。
繼承“方法”
JavaScript 中並沒有像在基於類語言中定義的”方法“。在 JavaScript 中,任何函數也是以屬性的形式被添加到對象中,繼承的函數和其他繼承的屬性一樣,也存在上面提到的”屬性覆蓋”(這裏叫做方法覆蓋(_method overriding_))。
當一個繼承的函數被執行時,函數內的this指向當前繼承的對象,而不一定是將該函數作為“自身屬性“的對象本身。
var o = {
a: 2,
m: function () {
return this.a + 1;
},
};
console.log(o.m()); // 3
// 當調用 o.m 時, 'this' 指向 o
var p = Object.create(o);
// p 是一個繼承o的對象
p.a = 4; // 在p上創建一個'a'屬性
console.log(p.m()); // 5
// 當調用 p.m 時, 'this' 指向 p.
// 所以當 p 從 o 上繼承了方法 m時,
// 'this.a' 等於 p.a
在 JavaScript 中使用原型
讓我們更詳細地來看看背後的原理。
在 JavaScript 中,正如上面提到,函數也可以擁有屬性。所有函數都有一個特殊的屬性prototype。請注意下面的代碼是獨立的(可以安全地假設網頁中除了下面的代碼就沒有其他代碼了)。為了更好的學習體驗,非常推薦你打開瀏覽器的控制枱,點擊'console'標籤,複製粘貼以下代碼,點擊 Enter/Return 鍵來執行它。(大多數瀏覽器的開發者工具(Developer Tools)中都包含控制枱。詳情請查看Firefox 開發者工具、Chrome 開發者工具,以及Edge 開發者工具)
function doSomething() {}
console.log(doSomething.prototype);
// 不管你如何聲明函數,
// JavaScript中的函數都有一個默認的
// prototype 屬性
// (Ps: 這裏有一個意外,箭頭函數上沒有默認的 prototype 屬性)
var doSomething = function () {};
console.log(doSomething.prototype);
可以在 console 中看到,doSomething()有一個默認的prototype屬性,打印的內容和下面類似:
{
constructor: ƒ doSomething(),
__proto__: {
constructor: ƒ Object(),
hasOwnProperty: ƒ hasOwnProperty(),
isPrototypeOf: ƒ isPrototypeOf(),
propertyIsEnumerable: ƒ propertyIsEnumerable(),
toLocaleString: ƒ toLocaleString(),
toString: ƒ toString(),
valueOf: ƒ valueOf()
}
}
如果我們在doSomething()的prototype上添加屬性,如下:
function doSomething() {}
doSomething.prototype.foo = "bar";
console.log(doSomething.prototype);
結果為:
{
foo: "bar",
constructor: ƒ doSomething(),
__proto__: {
constructor: ƒ Object(),
hasOwnProperty: ƒ hasOwnProperty(),
isPrototypeOf: ƒ isPrototypeOf(),
propertyIsEnumerable: ƒ propertyIsEnumerable(),
toLocaleString: ƒ toLocaleString(),
toString: ƒ toString(),
valueOf: ƒ valueOf()
}
}
現在我們可以通過new操作符來基於這個 prototype 對象創建doSomething()的實例。使用new操作符調用函數只需要在調用前加上new前綴。這樣該函數會返回其自身的一個實例對象。接着我們便可以往該實例對象上添加屬性:
function doSomething() {}
doSomething.prototype.foo = "bar"; // 往prototype上添加屬性'foo'
var doSomeInstancing = new doSomething();
doSomeInstancing.prop = "some value"; // 往實例對象上添加屬性'prop'
console.log(doSomeInstancing);
打印結果和如下類似:
{
prop: "some value",
__proto__: {
foo: "bar",
constructor: ƒ doSomething(),
__proto__: {
constructor: ƒ Object(),
hasOwnProperty: ƒ hasOwnProperty(),
isPrototypeOf: ƒ isPrototypeOf(),
propertyIsEnumerable: ƒ propertyIsEnumerable(),
toLocaleString: ƒ toLocaleString(),
toString: ƒ toString(),
valueOf: ƒ valueOf()
}
}
}
可以得知,doSomeInstancing的__proto__就是doSomething.prototype。但是,這代表什麼呢?放你訪問doSomeInstancing的一個屬性時,瀏覽器會首先查看doSomeInstancing自身是否存在該屬性。
如果不存在,瀏覽器會繼續查找doSomeInstancing的__proto__(或者説是 doSomething.prototype)。如果存在,則doSomeInstancing的__proto__的這個屬性會被使用。
否則,會繼續查找doSomeInstancing的__proto__的__proto__。默認情況下,任何函數 prototype 屬性的__proto__屬性就是window.Object.prototype。因此,會在doSomeInstancing的__proto__的__proto__(或者説是doSomething.prototype.__proto__,或者説是Object.prototype)繼續查找對應屬性。
最終,直到所有的__proto__被查找完畢,瀏覽器會斷言該屬性不存在,因此得出結論:該屬性的值為 undefined。
然我們在 console 上再添加一些代碼:
function doSomething() {}
doSomething.prototype.foo = "bar";
var doSomeInstancing = new doSomething();
doSomeInstancing.prop = "some value";
console.log("doSomeInstancing.prop: " + doSomeInstancing.prop);
console.log("doSomeInstancing.foo: " + doSomeInstancing.foo);
console.log("doSomething.prop: " + doSomething.prop);
console.log("doSomething.foo: " + doSomething.foo);
console.log("doSomething.prototype.prop: " + doSomething.prototype.prop);
console.log("doSomething.prototype.foo: " + doSomething.prototype.foo);
結果如下:
doSomeInstancing.prop: some value
doSomeInstancing.foo: bar
doSomething.prop: undefined
doSomething.foo: undefined
doSomething.prototype.prop: undefined
doSomething.prototype.foo: bar
使用不同的方法創建對象和原型鏈
使用語法結構(字面量)創建對象
var o = { a: 1 };
// 新創建的對象以 Object.prototype 作為它的 [[Prototype]]
// o 沒有叫做'hasOwnProperty'的自身屬性
// hasOwnProperty 是 Object.prototype 的自身屬性
// 也就是説 o 從Object.prototype 上繼承了 hasOwnProperty
// Object.prototype 的原型為 null
// o ---> Object.prototype ---> null
var b = ["yo", "whadup", "?"];
// 數組繼承自 Array.prototype
// (Array.prototype 上擁有方法例如 indexOf, forEach 等等)
// 原型鏈如下:
// b ---> Array.prototype ---> Object.prototype ---> null
function f() {
return 2;
}
// 函數繼承自 Function.prototype
// (Function.prototype 上擁有方法例如 call, bind, 等等)
// f ---> Function.prototype ---> Object.prototype ---> null
使用構造器函數
構造器函數和普通函數的差別就在於其恰好使用new操作符調用
function Graph() {
this.vertices = [];
this.edges = [];
}
Graph.prototype = {
addVertex: function (v) {
this.vertices.push(v);
},
};
var g = new Graph();
// g 是一個有 'vertices' 和 'edges' 作為屬性的對象
// 當執行 new Graph() 時,g.[[Prototype]] 的值就是 Graph.prototype
使用 Object.create
ECMAScript 提出了一個新方法:Object.create()。調用該方法時會創建一個新對象。這個對象的原型為傳入該函數的第一個參數:
var a = { a: 1 };
// a ---> Object.prototype ---> null
var b = Object.create(a);
// b ---> a ---> Object.prototype ---> null
console.log(b.a); // 1 (繼承自 a )
var c = Object.create(b);
// c ---> b ---> a ---> Object.prototype ---> null
var d = Object.create(null);
// d ---> null
console.log(d.hasOwnProperty);
// undefined, 因為 d 並沒有繼承自 Object.prototype
與Object.create和new操作符一起,使用delete操作符
下面的示例使用Object.create創建一個對象,並使用delete操作符來展示原型鏈的變化
var a = { a: 1 };
var b = Object.create(a);
console.log(a.a); // 1
console.log(b.a); // 1
b.a = 5;
console.log(a.a); // 1
console.log(b.a); // 5
delete b.a;
console.log(a.a); // 1
console.log(b.a); // 1(b.a 的值 5 已經被刪除,因此展示其原型鏈上的值)
delete a.a; // 也可以使用 'delete b.__proto__.a'
console.log(a.a); // undefined
console.log(b.a); // undefined
如果換成new操作符創建對象,原型鏈更短:
function Graph() {
this.vertices = [4, 4];
}
var g = new Graph();
console.log(g.vertices); // print [4,4]
g.vertices = 25;
console.log(g.vertices); // print 25
delete g.vertices;
console.log(g.vertices); // print undefined
使用 class 關鍵字
ECMAScript 2015 提出了一系列新的關鍵字用於實現類。包括class、constructor、static、extends以及super。
"use strict";
class Polygon {
constructor(height, width) {
this.height = height;
this.width = width;
}
}
class Square extends Polygon {
constructor(sideLength) {
super(sideLength, sideLength);
}
get area() {
return this.height * this.width;
}
set sideLength(newLength) {
this.height = newLength;
this.width = newLength;
}
}
var square = new Square(2);
關於性能
如果需要查找的對象屬性位於原型鏈的頂端,查找時間會對性能有影響,尤其對於對性能要求很高的應用來説,影響會進一步放大。另外,如果是訪問一個不存在的屬性,總是會遍歷整條原型鏈。
此外,當對對象的屬性進行迭代查找時,原型鏈上所有可枚舉的屬性都會被遍歷。為了檢查哪些屬性是對象的自身屬性而不是來自其原型鏈,很有必要使用繼承自Object.prototype的hasOwnProperty方法。下面來看一個具體的例子,該例子繼續使用上一個圖形的例子:
console.log(g.hasOwnProperty("vertices"));
// true
console.log(g.hasOwnProperty("nope"));
// false
console.log(g.hasOwnProperty("addVertex"));
// false
console.log(g.__proto__.hasOwnProperty("addVertex"));
// true
hasOwnProperty是 JavaScript 中查找對象屬性時唯一不遍歷原型鏈的方法。
注意:僅僅檢查屬性是undefined並不能代表該屬性不存在,也許是因為它的值恰好被設置為了undefined。
不好的實踐:對原生的 prototypes 進行擴展
經常容易犯的一個錯誤是擴展Object.prototype或者是一些其他內置的 prototype。
這被稱為是”猴子補丁“,會打破程序的封裝性。儘管在一些出名的框架中也這樣做,例如 Prototype.js,但是仍然沒有理由在內置類型上添加非標準的功能。
擴展內置類型的唯一理由是保證一些早期 JavaScript 引擎的兼容性,例如Array.forEach(譯者注:Array.forEach是在 ECMA-262-5 中提出,部分早期瀏覽器引擎沒有實現該標準,因此需要 polyfill)
繼承原型鏈的方法總結
下面表格展示了四種方法以及它們各自的優缺點。以下例子創建的inst對象完全一致(因此控制枱打印的結果也一樣),除了它們之間有不同的優缺點。
| 名稱 | 舉例 | 優點 | 缺點 |
|---|---|---|---|
使用new初始化 |
<pre lang="javascript"> function foo(){} foo.prototype = { foo_prop: "foo val" }; function bar(){} var proto = new foo; proto.bar_prop = "bar val"; bar.prototype = proto; var inst = new bar; console.log(inst.foo_prop); console.log(inst.bar_prop); </pre> | 支持所有瀏覽器(甚至到IE 5.5),同時,運行速度、標準化以及JIT優化性都非常好 | 問題是,為了使用該方法函數必須被初始化。在初始化過程中,構造函數可能會為每個創建對象創建一些特有屬性,然而例子中只會構造一次,因此這些特有信息只會生成一次,可能存導致潛在問題。 之外,構造函數初始化時可能會添加冗餘的方法到實例對象上。不過,只要這是你自己的代碼且你明確這是幹什麼的,這些通常來説也不是問題(實際上是利大於弊)。 |
使用Object.create |
<pre lang="javascript"> function foo(){} foo.prototype = { foo_prop: "foo val" }; function bar(){} var proto = Object.create( foo.prototype ); proto.bar_prop = "bar val"; bar.prototype = proto; var inst = new bar; console.log(inst.foo_prop); console.log(inst.bar_prop); </pre> <pre lang="javascript"> function foo(){} foo.prototype = { foo_prop: "foo val" }; function bar(){} var proto = Object.create( foo.prototype, { bar_prop: { value: "bar val" } } ); bar.prototype = proto; var inst = new bar; console.log(inst.foo_prop); console.log(inst.bar_prop) </pre> | 支持目前所有的現代瀏覽器,包括非IE瀏覽器以及IE9及以上版本瀏覽器。相當於允許一次性設置proto,這樣有利於瀏覽器優化該對象。同時也允許創建沒有原型的對象例如:Object.create(null) |
不支持IE8以及以下版本瀏覽器,不過,微軟目前已不再支持運行這些瀏覽器的操作系統,對大多數應用來説這也不是一個問題。 之外,如果使用第二個參數,則對象的初始化會變慢,這也許會成為性能瓶頸,因為第二個參數作為對象描述符屬性,每個對象的描述符屬性是另一個對象。當以對象形式處理成千上萬的對象描述符時,可能會嚴重影響運行速度。 |
使用Object.setPrototypeOf |
<pre lang="javascript"> function foo(){} foo.prototype = { foo_prop: "foo val" }; function bar(){} var proto = { bar_prop: "bar val" }; Object.setPrototypeOf( proto, foo.prototype ); bar.prototype = proto; var inst = new bar; console.log(inst.foo_prop); console.log(inst.bar_prop); </pre> <pre lang="javascript"> function foo(){} foo.prototype = { foo_prop: "foo val" }; function bar(){} var proto; proto = Object.setPrototypeOf( { bar_prop: "bar val" }, foo.prototype ); bar.prototype = proto; var inst = new bar; console.log(inst.foo_prop); console.log(inst.bar_prop) </pre> | 支持目前所有的現代瀏覽器,包括非IE瀏覽器以及IE9及以上版本瀏覽器。支持動態的操作對象的原型,甚至可以為Object.create(null)創建的對象強制添加一個原型 |
由於性能不佳,應該會被棄用。如果你敢在生產環境中使用這樣的語法,JavaScript代碼快速運行幾乎不可能。因為許多瀏覽器優化了原型,舉個例子,在訪問一個對象上的屬性之前,編譯器會提前確定原型上的屬性在內存中的位置,但是如果使用了Object.setPrototypeOf對原型進行動態更改,這相當於擾亂了優化,甚至會讓編譯器重新編譯並放棄對這部分的優化,僅僅是為了能讓你這段代碼跑起來。 同時,不支持IE8以及以下版本瀏覽器 |
使用proto |
<pre lang="javascript"> function foo(){} foo.prototype = { foo_prop: "foo val" }; function bar(){} var proto = { bar_prop: "bar val", __proto__: foo.prototype }; bar.prototype = proto; var inst = new bar; console.log(inst.foo_prop); console.log(inst.bar_prop); </pre> <pre lang="javascript"> var inst = { __proto__: { bar_prop: "bar val", __proto__: { foo_prop: "foo val", __proto__: Object.prototype } } }; console.log(inst.foo_prop); console.log(inst.bar_prop) </pre> | 支持目前幾乎所有的現代瀏覽器,包括非IE瀏覽器以及IE11及以上版本瀏覽器。將proto設置為非對象的類型不會拋出異常,但是會導致程序運行失敗 |
嚴重過時而且性能不佳。如果你敢在生產環境中使用這樣的語法,JavaScript代碼快速運行幾乎不可能。因為許多瀏覽器優化了原型,舉個例子,在訪問一個對象上的屬性之前,編譯器會提前確定原型上的屬性在內存中的位置,但是如果使用了proto對原型進行動態更改,這相當於擾亂了優化,甚至會讓編譯器重新編譯並放棄對這部分的優化,僅僅是為了能讓你這段代碼跑起來。 同時,不支持IE10及以下版本瀏覽器。 |
prototype和Object.getPrototypeOf
對於從 Java 和 C++過來的開發者來説,JavaScript 會讓他們感到有些困惑,因為 JavaScript 是動態類型、代碼無需編譯可以在 JS Engine 直接運行(譯者注:Java 代碼需要編譯成機器碼後在 JVM 執行),同時它還沒有類。所有的幾乎都是實例(objects)。儘管模擬了class,但其本質還是函數對象。
你也許注意到了function A上有一個特殊的屬性prototype。這個特殊屬性與 JavaScriptnew操作符一起使用。當使用new操作符創建出來一個實例對象,這個特殊屬性prototype會被複制給該對象的內部[[Prototype]]屬性。舉個例子,當運行var a1 = new A()代碼時,JavaScript(在內存中創建完新實例對象之後且準備運行函數A()之前,運行函數時函數內部的this會指向該對象)會設置:a1.[[Prototype]] = A.prototype。
當你之後訪問創建的對象屬性時,JavaScript 首先會檢查屬性是否存在於對象本身,如果不存在,則繼續查找其[[Prototype]]。這意味着你在prototype上定義的屬性實際上被所有實例對象共享,如果你願意,甚至可以修改prototype,這些改動會同步到所有存在的實例對象中。
如果在上面的例子中,你執行:var a1 = new A(); var a2 = new A();,那麼a1.doSomething就是Object.getPrototypeOf(a1).doSomething,這和你定義的A.prototype.doSomething是同一個對象,所以:Object.getPrototypeOf(a1).doSomething === Object.getPrototypeOf(a2).doSomething === A.prototype.doSomething。
簡而言之,prototype是針對類型的,而Object.getPrototypeOf()對於實例對象是一致的。(譯者注:原文為In short, prototype is for types, while Object.getPrototypeOf() is the same for instances.)。
[[Prototype]]會被遞歸地查找,例如:a1.doSomething, Object.getPrototypeOf(a1).doSomething, Object.getPrototypeOf(Object.getPrototypeOf(a1)).doSomething等等,直到Object.getPrototypeOf返回null。
因此,當你執行:
var o = new Foo();
實際上是執行:
var o = new Object();
o[[Prototype]] = Foo.prototype;
Foo.call(o);
接着如果你訪問:
o.someProp;
JavaScript 會檢查是否 o 上存在自身屬性someProp。如果不存在,繼續檢查Object.getPrototypeOf(o).someProp是否存在,如果還不存在繼續檢查Object.getPrototypeOf(Object.getPrototypeOf(o)).someProp,依次類推。
總結
在編寫基於原型的複雜代碼之前,很有必要先理解原型式的繼承模型。同時,請注意代碼中原型鏈的長度,並且在必要時將其分解以避免可能存在的性能問題。此外,應該杜絕在原生的原型對象上進行擴展,除非是為了考慮兼容性,例如在老的 JavaScript 引擎上適配新的語言特性。
Tags: Advanced Guide Inheritance JavaScript OOP
本篇文章由一文多發平台ArtiPub自動發佈