理解 JavaScript 中的原型
原文链接 https://bubkoo.github.io/2014/03/12/prototypes-in-javascript/
注:以下为加速网络访问所做的原文缓存,经过重新格式化,可能存在格式方面的问题,或偶有遗漏信息,请以原文为准。
当你在JavaScript中定义一个函数,它有一些预定义的属性,其中之一就是令人迷惑的原型。本文将详细解释什么是原型,以及为什么要在项目中使用它。
什么是原型
对象初始化时原型是一个空对象,你可以将任何其他对象添加到原型上。
var myObject = function(name){
this.name = name;
return this;
};
console.log(typeof myObject.prototype); // object
console.log(myObject.prototype); // Object {}
myObject.prototype.getName = function(){
return this.name;
};
console.log(myObject.prototype); // Object {getName: function...}
上面的代码中,我们创建了一个函数,如果我们调用 myObject()
,它将简单的返回一个 window
对象,因为它还没有被实例化,而这个函数是在全局作用域中定义的,this
理所当然地指向了全局对象。
console.log(myObject() === window); // true
<!--more-->
一个“隐秘”属性
继续之前,我想讨论一下关于原型的一个的“隐秘”属性。
JavaScript 中的每一个对象在被定义或实例化之后,都有一个叫做 __proto__
的隐秘属性,这是原型链的核心。但是,我并不建议在代码中直接访问 __proto__
,因为并不是所有的浏览器都支持它。
不能将 __proto__
和对象的原型混为一谈,它们是两个不同的属性,但又紧密相关,可能初学者会感到非常困惑,很难将他们区分开来,下面我将详细道来。当我们创建 myObject
这个函数时,实际上是定义了一个 Function
类型的对象。
console.log('function' === typeof myObject); // true
Function
是 JavaScript 中的一个预定义对象,它有自己的一些属性(比如 length
和 arugments
) 和方法(比如 call
和 apply
),还有自己的原型对象,以及“隐秘”的 __proto__
属性。这意味着,在 JavaScript 引擎内的某个位置,可能有一些类似于下面的代码:
Function.prototype = {
arguments: null,
length: 0,
call: function(){
// secret code
},
apply: function(){
// secret code
}
...
}
事实上,Function
的定义并不是如此简单,这里只是为了说明原型链的原理。
目前为止,我们定义了 myObject
这个函数,并为其指定了名为 name
的形参,但我们并没有为其设定任何属性(如 length
)和方法(如 call
),那么下面的代码是怎么回事呢?
console.log(myObject.length); // 1 (形参的数量)
这是因为在定义 myObject
这个对象时,它内置了 __proto__
这个属性,并且其值是 Function.prototype
。所以,当我们使用 myObject.length
时,首先将在 myObject
对象中查找名为 length
的属性, 没有找到,然后将通过 __proto__
这个“隐秘”的属性查找其原型链,最后找到 length
这个属性并返回。
您可能想知道为什么 length
的值为什么是 1,而不是 0,或任何其他数字。这是因为 myObject
实际上是 Function
的一个实例。
console.log(myObject instanceof Function); // true
console.log(myObject === Function); // false
当一个对象的实例被创建时,__proto__
将指向构造函数的原型,在我们的例子中就是 Function
的原型。
console.log(myObject.__proto__ === Function.prototype) // true
当创建一个新的 Function
对象时,在 Function
的构造函数内将获取形参的数量,并更新 this.length
的值,在这里是 1。
如果我们使用 new
操作符创建一个 myObject
的实例,该实例的 __proto__
将指向 myObject.prototype
,因为 myObject
是该实例的构造函数。
var myInstance = new myObject(“foo”);
console.log(myInstance.__proto__ === myObject.prototype); // true
现在 myInstance
除了可以访问 Function.prototype
中的原生方法(比如 call
和 apply
)之外,还可以访问到 myObject.prototype
中的方法: getName
。
console.log(myInstance.getName()); // foo
var mySecondInstance = new myObject(“bar”);
console.log(mySecondInstance.getName()); // bar
console.log(myInstance.getName()); // foo
译者注:一个对象实际上包含 __proto__
和 prototype
两个属性,这两个属性代表着不一样的东西,__proto__
指向创建该对象的构造函数的原型,原型链查找就是借助于 __proto__
来实现;而 prototype
指向该对象自身的原型。
可以想象,这是非常方便的,我们可以用它来获取一个对象的结构,并根据需要创建实例,让我们开始讨论下一个话题。
为什么要使用原型
我们先来看一个实例,现在我们需要开发一个 canvas 上的游戏,需要在 canvas 一次性绘制一些(可能是数百个)对象,每个对象都包含一些自己的属性,比如 x
和 y
坐标、width
、height
等等。
我们可以这样做:
var GameObject1 = {
x: Math.floor((Math.random() * myCanvasWidth) + 1),
y: Math.floor((Math.random() * myCanvasHeight) + 1),
width: 10,
height: 10,
draw: function(){
myCanvasContext.fillRect(this.x, this.y, this.width, this.height);
}
...
};
var GameObject2 = {
x: Math.floor((Math.random() * myCanvasWidth) + 1),
y: Math.floor((Math.random() * myCanvasHeight) + 1),
width: 10,
height: 10,
draw: function(){
myCanvasContext.fillRect(this.x, this.y, this.width, this.height);
}
...
};
... 重复做 98 次 ...
这将在内存中创建所有这些对象,这些对象都有单独的方法,比如 draw
和其他一些所需要的方法。这当然会很糟糕,因为这个游戏很可能将占光浏览器内存,并运行的非常缓慢,甚至停止响应。
虽然只有 100 个对象的时候这还不可能发生,但会对性能造成很大的影响,因为它需要查找一百个不同的对象,而不是一个相同的原型对象。
如何使用原型
为了使我们的应用运行的更快,遵循最佳实践,我们来重新定义 GameObject
的原型,GameObject
对象的每一个实例将使用 GameObject.prototype
中的方法,就像它们自身的方法一样。
// 定义 GameObject 的构造函数
var GameObject = function(width, height) {
this.x = Math.floor((Math.random() * myCanvasWidth) + 1);
this.y = Math.floor((Math.random() * myCanvasHeight) + 1);
this.width = width;
this.height = height;
return this;
};
// 定义 GameObject 的原型
GameObject.prototype = {
x: 0,
y: 0,
width: 5,
width: 5,
draw: function() {
myCanvasContext.fillRect(this.x, this.y, this.width, this.height);
}
};
然后,我们来实例化 100 个 GameObject 对象:
var x = 100,
arrayOfGameObjects = [];
do {
arrayOfGameObjects.push(new GameObject(10, 10));
} while(x--);
现在,我们有了一个有 100 个 GameObjects 实例的数组,这些实例对象共享相同的原型,这大大节省了应用所占用的内存。
当我们调用 draw
方法时,实际上调用的都是原型上相同的方法。
var GameLoop = function() {
for(gameObject in arrayOfGameObjects) {
gameObject.draw();
}
};
原型是活动对象(Live Object)
对象的原型是一个活动对象,什么意思呢?根据上面示例来说就是,当我创建 GameObject
对象的实例之后,我们可以修改 GameObject.prototype.draw
方法,来画一个圆,而不是画一个矩形,这样调用所有已经实例化的对象或后面再实例化的对象中的 draw
方法就会画一个圆。
GameObject.prototype.draw = function() {
myCanvasContext.arc(this.x, this.y, this.width, 0, Math.PI*2, true);
}
修改内置对象的原型
你可能熟悉一些 JavaScript 库,比如 Prototype,他们都充分利用了这种方法。
来看一个简单的例子:
String.prototype.trim = function() {
return this.replace(/^\s+|\s+$/g, ‘’);
};
现在我们可以在任何字符串上使用该方法:
"foo bar".trim(); // "foo bar"
不过这样做也有一定的缺点。比如,你将这个方法应用到你的代码中,也许一两年之后,JavaScript 可能会在 String
的原型中实现了该方法,这意味着你的方法将覆盖 JavaScript 的原生方法。为了避免这种情况,我们需要在定义自身的方法前,做一个简单的判断:
if(!String.prototype.trim) {
String.prototype.trim = function() {
return this.replace(/^\s+|\s+$/g, ‘’);
};
}
如果存在原生的 trim
方法,我们就会使用原生的 trim
方法。
根据经验法则,通常也被认为是最佳实践,最好避免扩展内置对象的原型。但是,如果有必要,也可以不遵循这个规则。
总结
希望本文已经阐释清楚了 JavaScript 中的原型,现在你应该能够编写更加高效的代码了。
如果你有关于原型的任何问题,你可以写在评论中,我会尽力解答。
英文原文:Leigh Kaszick,翻译:布谷 bubkoo