請在此暫時忘記之前學到的物件導向的一切知識。這裡只需要考慮賽車的情況。是的,就是賽車。
最近我正在觀看 24 Hours of Le Mans ,這是法國流行的一項賽事。最快的車被稱為 Le Mans 原型車。這些車雖然是由「奧迪」或「標緻」這些廠商製造的,可它們並不是你在街上或速公路上所見到的那類汽車。它們是專為參加高速耐力賽事而製造出來的。
廠家投入巨額資金,用於研發、設計、製造這些原型車,而工程師們總是努力嘗試將這項工程做到極致。他們在合金、生物燃料、煞車技術、輪胎的化合物成分和安全特性上進行了各種實驗。隨著時間的推移,這些實驗中的某些技術經過反覆改進,隨之進入車輛的主流產品線。你所駕駛車輛的某些技術,有可能是在賽車原型上第一次亮相的。
你也可以說,這些主流車輛繼承了來自賽車的技術原型。
到現在,我們就有討論 JavaScript 中的原型和繼承問題的基礎了。它雖然不像你在 C++、Java 或 C# 中了解的經典繼承模式一樣,但這種方式同樣強大,而且有可能會更加靈活。
有關對象和類別
JavaScript 中全是對象,這指的是傳統意義上的對象,也就是「一個包含了狀態和行為的單一實體」。例如,JavaScript 中的陣列是含有數個值,並且包含 push、reverse 和 pop 方法的物件。
var myArray = [1, 2]; myArray.push(3); myArray.reverse(); myArray.pop(); var length = myArray.length;
現在問題是,push 這樣的方法是從何而來的呢?我們前面提到的那些靜態語言使用「類別語法」來定義物件的結構,但是 JavaScript 是一個沒有「類別語法」的語言,無法用 Array「類別」的語法來定義每個陣列物件。而因為 JavaScript 是動態語言,我們可以在實際需要的情況下,將方法任意放置到物件上。例如下面的程式碼,就在二維空間中,定義了用來表示一個點的點對象,同時也定義了一個 add 方法。
var point = { x : 10, y : 5, add: function(otherPoint) { this.x += otherPoint.x; this.y += otherPoint.y; } };
但是上面的做法可擴展性並不好。我們需要確保每個點物件都含有一個 add 方法,同時也希望所有點物件都共享同一個 add 方法的實現,而不是這個方法手動添加每一個點物件上。這就是原型發揮它作用的地方。
有關原型
在 JavaScript 中,每個物件都保持著一塊隱藏的狀態 —— 一個對另一個物件的引用,也被稱為原型。我們之前創建的數組引用了一個原型對象,我們自行創建的點對像也是如此。上面說原型引用是隱藏的,但也有 ECMAScript(JavaScript 的正式名稱)的實作可以透過一個物件的__proto__屬性(例如Google瀏覽器)來存取這個原型引用。從概念上講,我們可以將物件當作類似 圖1 所表示的物件 —— 原型的關係。
展望未來,開發者將能夠使用 Object.getPrototypeOf 函數,取代__proto__屬性,取得物件原型的參考。在本文寫出的時候,已經可以在 Google Chrome,FIrefox 和 IE9 瀏覽器中使用 Object.getPrototypeOf 函數。更多瀏覽器在未來會實現此功能,因為它已經是 ECMAScript 標準的一部分了。我們可以使用下面的程式碼,來證明我們建立的 myArray 和點物件引用的是兩個不同的原型物件。
Object.getPrototypeOf(point) != Object.getPrototypeOf(myArray);
對於本文的其餘部分,我將交叉使用__proto__和Object.getPrototypeOf 函數,主要是因為__proto__ 在圖和句子中更容易識別。要記住的是它(__proto__)不是標準,而 Object.getPrototypeOf 函數才是檢視物件原型的建議方法。
是什麼讓原型如此特別?
我们还没有回答这个问题:数组中 push 这样的方法是从何而来的呢?答案是:它来源于 myArray 原型对象。图 2 是 Chrome 浏览器中脚本调试器的屏幕截图。我们已经调用 Object.getPrototypeOf 方法查看 myArray 的原型对象。
注意 myArray 的原型对象中有许多方法,包括那些在代码示例中调用的 push、pop 和 reverse 方法。因此,原型对象中的确包括 push 方法,但是 myArray 方法如何引用到呢?
myArray.push(3);
了解其工作原理的第一步,是要认识到原型并不是特别的。原型只是普通的对象。可以给原型添加方法,属性,并把他们当作其他 JavaScript 对象一样看待。然而,套用乔治·奥威尔的小说《动物农场》中“猪”的说法 —— 所有的对象应当是平等的,但有些对象(遵守规则的)比其他人更加平等。
JavaScript 中的原型对象的确是特殊的,因为他们遵从以下规则。当我们告诉 JavaScript 我们要调用一个对象的 push 方法,或读取对象的 x 属性时,运行时会首先查找对象本身。如果运行时找不到想要的东西,它就会循着 __proto__ 引用和对象原型寻找该成员。当我们 调用 myArray 的 push 方法时,JavaScript 并没有在 myArray 对象上发现 push 方法,而是在 myArray 的原型对象上找到了,于是 JavaScript 调用此方法(见图 3)。
上面所描述的行为是指一个对象本身继承了原型上的任何方法或属性。JavaScript 中其实不需要使用类语法也能实现继承。就像从赛车原型上继承了相应的技术的车,一个 JavaScript 对象也可以从原型对象上继承功能特性。
图 3 还展示了每个数组对象同时也可以维护自身的状态和成员。在请求得到 myArray 的 length 属性的情况下,JavaScript 会取得 myArray 中 length 属性的值,而不会去读取原型中的对应值。我们可以通过向对象上添加 push 这样的方法来“重写”push 方法。这样就会有效地隐藏原型中的 push 方法实现。
共享原型
JavaScript 中原型的真正神奇之处是多个对象如何维持对同一个原型对象的引用。例如,如果我们创建了这样的两个数组:
var myArray = [1, 2]; var yourArray = [4, 5, 6];
那么这两个数组将共享同一个原型对象,而下面的代码计算结果为 true:
<br/>
Object.getPrototypeOf(myArray) === Object.getPrototypeOf(yourArray);
如果我们引用两个数组对象上的 push 方法,JavaScript 会去寻找原型上共享的 push 方法。
JavaScript 中的原型对象提供继承功能,同时也就实现了该方法实现的共享。原型也是链式的。换句话说,因为原型对象只是一个对象,所以一个原型对象可以维持到另一个原型对象的引用。如果你重新审视图 2 便可以看到,原型的 __proto__ 属性是一个指向另一个原型的非空值。当 JavaScript 查找像 push 方法这样的成员时,它会循着原型引用链检查每一个对象,直到找到该成员,或者抵达原型链的末端。原型链为继承和共享开辟了一条灵活的途径。
你可能会问的下一个问题是:我该如何设置那些自定义对象的原型引用呢?例如前面所使用的点对象,如何才能将 add 方法添加到原型对象中,并从多个点对象中继承方法呢?在回答这个问题之前,我们需要看看函数。
有关函数
JavaScript 中的函数也是对象。这样的表述带来了几个重要的结果,而我们并不会在本文中涉及所有的事项。这其中,能将一个函数赋值给一个变量,并且将一个函数作为参数传递给另一个函数的能力构成了现代 JavaScript 编程表达的基本范式。
我们需要关注的是,函数本身就是对象,因此函数可以有自身的方法,属性,并且引用一个原型对象。让我们来讨论下面的代码的含义。
// 这将返回 true: typeof (Array) === "function" // 这样的表达式也是: Object.getPrototypeOf(Array) === Object.getPrototypeOf(function () { }) // 这样的表达式同样: Array.prototype != null
代码中的第一行证明, JavaScript 中的数组是函数。稍后我们将看到如何调用 Array 函数创建一个新的数组对象。下一行代码,证明了 Array 对象使用与任何其他函数对象相同的原型,就像我们看到数组对象间共享相同的原型一样。最后一行代码证明了 Array 函数都有一个 prototype 属性,而这个 prototype 属性指向一个有效的对象。这个 prototype 属性十分重要。
JavaScript 中的每一个函数对象都有 prototype 属性。千万不要混淆这个 prototype 属性的 __proto__ 属性。他们用途并不相同,也不是指向同一个对象。
// 返回 true Object.getPrototypeOf(Array) != Array.prototype
Array.__proto__ 提供的是 数组原型 – 请把它当作 Array 函数所继承的对象。
而 Array.protoype,提供的的是 所有数组的原型对象。也就是说,它提供的是像 myArray 这样数组对象的原型对象,也包含了所有数组将会继承的方法。我们可以写一些代码来证明这个事实。
// true Array.prototype == Object.getPrototypeOf(myArray) // 也是 true Array.prototype == Object.getPrototypeOf(yourArray);
我们也可以使用这项新知识重绘之前的示意图。
基于所知道的知识,请想象创建一个新的对象,并让新对象表现地像数组的过程。一种方法是使用下面的代码。
// 创建一个新的空对象 var o = {}; // 继承自同一个原型,一个数组对象 o.__proto__ = Array.prototype; // 现在我们可以调用数组的任何方法... o.push(3);
虽然这段代码很有趣,也能工作,可问题在于,并不是每一个 JavaScript 环境都支持可写的 __proto__ 对象属性。幸运的是,JavaScript 确实有一个创建对象内建的标准机制,只需要一个操作符,就可以创建新对象,并且设置新对象的 __proto__ 引用 – 那就是“new”操作符。
var o = new Array(); o.push(3);
JavaScript 中的 new 操作符有三个基本任务。首先,它创建新的空对象。接下来,它将设置新对象的 __proto__ 属性,以匹配所调用函数的原型属性。最后,操作符调用函数,将新对象作为“this”引用传递。如果要扩展最后两行代码,就会变成如下情况:
var o = {}; o.__proto__ = Array.prototype; Array.call(o); o.push(3);
函数的 call 方法允许你在调用函数的情况下在函数内部指定“this”所引用的对象。当然,函数的作者在这种情况下需要实现这样的函数。一旦作者创建了这样的函数,就可以将其称之为构造函数。
1.构造函数
2.构造函数和普通的函数一样,但是具有以下两个特殊性质。
通常构造函数的首字母是大写的(让识别构造函数变得更容易)。
构造函数通常要和 new 操作符结合,用来构造新对象。
Array 就是一个构造函数的例子。Array 函数需要和 new 操作符一起使用,而且 Array 的首字母是大写的。JavaScript 将 Array 作为内置函数包括在内,而任何人都可以写出自己的构造函数。事实上,我们最后可以为先前创建的点对象编写出构造函数。
var Point = function (x, y) { this.x = x; this.y = y; this.add = function (otherPoint) { this.x += otherPoint.x; this.y += otherPoint.y; } } var p1 = new Point(3, 4); var p2 = new Point(8, 6); p1.add(p2);
在上面的代码中,我们使用了 new 操作符和 Point 函数来构造点对象,这个对象带有 x 属性和 y 属性和一个 add 方法。你可以将最后的结果想象成图 6 的样子。
现在的问题是我们的每个点对象中仍然有单独的 add 方法。使用我们学到的原型和继承的知识,我们更希望将点对象的 add 方法从每个点实例中转移到 Point.prototype 中。要达到继承 add 方法的效果,我们所需要做的,就是修改 Point.prototype 对象。
var Point = function (x, y) { this.x = x; this.y = y; } Point.prototype.add = function (otherPoint) { this.x += otherPoint.x; this.y += otherPoint.y; } var p1 = new Point(3, 4); var p2 = new Point(8, 6); p1.add(p2);
大功告成!我们刚刚在 JavaScript 中完成原型式的继承模式!
总结
我希望這篇文章能幫助你揭開 JavaScript 原型概念的神秘面紗。開始看到的是原型如何讓一個物件從其他物件繼承功能,然後看到如何結合 new 操作符和建構函式來建構物件。這裡所提到的,只是開啟物件原型力量和彈性的第一步。本文鼓勵你自己發現學習有關原型和 JavaScript 語言的新資訊。
同時,請小心駕駛。你永遠不會知道這些行駛在路上的車輛會從他們的原型繼承到什麼(有缺陷)的技術。