本文详解 JavaScript 中 prototype 的正确使用方式。通过对比方法定义在实例与原型上的差异,阐述如何利用 prototype 节省内存。介绍使用闭包实现私有数据的方法及其局限,强调实例状态不应共享至原型。最后指出继承 Array 等标准类型的风险,推荐使用组合模式替代,以避免 [[class]] 属性导致的长度同步失效问题。
在prototype上保存方法
一、在prototype上保存方法
完全可以在不使用 prototype 的情况下进行 JavaScript 编码,例如:
function User(name, passwordHash) {
this.name = name;
this.passwordHash = passwordHash;
this.toString = function() {
return "[User " + this.name + "]";
};
this.checkPassword = function(password) {
return hash(password) === this.passwordHash;
};
}
var u1 = new User(/* ... */);
var u2 = new User(/* ... */);
var u3 = new User(/* ... */);
然而,当创建多个 User 类型的实例时,这种写法会引发问题:不仅 name 和 passwordHash 属性存在于每个实例中,toString 和 checkPassword 方法也会在每一个实例上生成一份独立的拷贝。其内存分布情况如下图所示:

相比之下,如果将 toString 和 checkPassword 方法定义在 prototype 上,内存结构则变为如下所示:

此时,toString 和 checkPassword 方法被定义在 User.prototype 对象上,这意味着这两个方法仅存在一份拷贝,并被所有 User 实例所共享。
有人可能会认为,将方法作为拷贝放置在每个实例上,可以节省方法查询的时间(当方法定义在 prototype 上时,JavaScript 引擎首先会在实例本身查找方法,若未找到,才会去 prototype 上继续查找)。
但在现代 JavaScript 执行引擎中,对方法查询机制进行了大量优化,因此这种查询时间的差异几乎可以忽略不计。相比之下,将方法放置在 prototype 对象上能够显著节省内存空间。
使用闭包来保存私有数据
二、使用闭包来保存私有数据
从语法层面来看,JavaScript 的对象系统并不鼓励使用信息隐藏(Information Hiding)。因为当使用诸如 this.name、this.passwordHash 时,这些属性的默认访问级别均为 public,在任何位置都可以通过 obj.name、obj.passwordHash 直接访问这些属性。
在 ES5 环境中,虽然提供了 Object.keys()、Object.getOwnPropertyNames() 等更方便访问对象所有属性的方法,但一些开发人员仍通过约定来定义私有属性,例如最典型的做法是使用下划线作为属性前缀,以此告知其他开发人员和用户该属性不应被直接访问。
然而,这种做法并不能从根本上解决问题,其他开发人员或用户依然可以直接访问带有下划线的属性。对于确实需要私有属性的场景,可以使用闭包来实现。
从某种意义而言,在 JavaScript 中,闭包对变量的访问策略与对象的访问策略代表了两个极端。闭包中的任何变量默认都是私有的,仅在函数内部才能访问这些变量。例如,可以将 User 类型实现如下:
function User(name, passwordHash) {
this.toString = function() {
return "[User " + name + "]";
};
this.checkPassword = function(password) {
return hash(password) === passwordHash;
};
}
此时,name 和 passwordHash 并未被保存为实例的属性,而是通过局部变量进行保存。根据闭包的访问规则,实例上的方法可以访问它们,而在外部则无法访问。
使用这种模式的一个缺点是,利用局部变量的方法都需要被定义在实例本身上,不能将这些方法定义在 prototype 对象上。正如在 Item34 中讨论的那样,这样做的问题是会增加内存消耗。但在某些特定场合下,即使将方法定义在实例上也是可行的。
实例状态只保存在实例对象上
三、实例状态只保存在实例对象上
一个类型的 prototype 与该类型的实例之间是“一对多”的关系。因此,需要确保实例相关的数据不会被错误地保存在 prototype 之上。例如,对于一个实现了树结构的类型而言,将它的子节点保存在该类型的 prototype 上是不正确的:
function Tree(x) {
this.value = x;
}
Tree.prototype = {
children: [], // should be instance state!
addChild: function(x) {
this.children.push(x);
}
};
var left = new Tree(2);
left.addChild(1);
left.addChild(3);
var right = new Tree(6);
right.addChild(5);
right.addChild(7);
var top = new Tree(4);
top.addChild(left);
top.addChild(right);
top.children; // [1, 3, 5, 7, left, right]
当状态被保存到 prototype 上时,所有实例的状态都会被集中保存。在上述场景中,这显然是不正确的:本属于每个实例的状态被错误地共享了。如下图所示:

正确的实现方式应当如下:
function Tree(x) {
this.value = x;
this.children = []; // instance state
}
Tree.prototype = {
addChild: function(x) {
this.children.push(x);
}
};
此时,实例状态的存储结构如下图所示:

由此可见,当本属于实例的状态被共享到 prototype 上时,可能会产生问题。在需要在 prototype 上保存状态属性前,务必确保该属性是可以被共享的。
总体而言,当一个属性是不可变(无状态)的属性时,就可以将其保存在 prototype 对象上(例如方法能够被保存在 prototype 上正是因为这一点)。当然,有状态的属性也能够放在 prototype 上,这取决于具体的应用场景,典型的例子是用来记录一个类型实例数量的变量。使用 Java 语言作为类比,这类能够存储在 prototype 对象上的变量相当于 Java 中的类变量(使用 static 关键字修饰)。
避免继承标准类型
四、避免继承标准类型
ECMAScript 标准库虽然不大,但提供了一些重要的类型,如 Array、Function 和 Date。在某些场合下,你可能会考虑继承其中的某个类型来实现特定功能,但这种做法并不被鼓励。
例如,为了操作一个目录,可以让目录类型继承 Array 类型,如下所示:
function Dir(path, entries) {
this.path = path;
for (var i = 0, n = entries.length; i < n; i++) {
this[i] = entries[i];
}
}
Dir.prototype = Object.create(Array.prototype);
// extends Array
var dir = new Dir("/tmp/mysite", ["index.html", "script.js", "style.css"]);
dir.length; // 0
但是可以发现,dir.length 的值为 0,而不是期待中的 3。
发生这种现象的原因在于:只有当对象是真正的 Array 类型时,length 属性才会起作用。
在 ECMAScript 标准中,定义了一个不可见的内部属性,被称为 [[class]]。该属性的值只是一个字符串,因此不要被误导认为 JavaScript 实现了自己的类型系统。对于 Array 类型,这个属性的值就是“Array”;对于 Function 类型,这个属性的值就是“Function”。下表列出了 ECMAScript 定义的所有 [[class]] 值:
那么,当对象的类型确实是 Array 时,length 属性的特别之处在于:length 的值会和该对象中被索引的属性个数保持一致。例如,对于一个数组对象 arr,arr[0] 和 arr[1] 表示该对象有两个被索引的属性,那么 length 的值就是 2。当添加了 arr[2] 时,length 的值会被自动同步为 3。同样地,当设置 length 值为 2 时,arr[2] 会被自动设置为 undefined。
但是,当继承 Array 类型并创建实例时,该实例的 [[class]] 属性并不是 Array,而是 Object。因此,length 属性不能正确工作。
在 JavaScript 中,也提供了用于查询 [[class]] 属性的方法,即使用 Object.prototype.toString 方法:
var dir = new Dir("/", []);
Object.prototype.toString.call(dir); // "[object Object]"
Object.prototype.toString.call([]); // "[object Array]"
因此,更好的实现方法是使用组合而不是继承:
function Dir(path, entries) {
this.path = path;
this.entries = entries; // array property
}
Dir.prototype.forEach = function(f, thisArg) {
if (typeof thisArg === "undefined") {
thisArg = this;
}
this.entries.forEach(f, thisArg);
};
以上代码不再使用继承,而是将一部分功能代理给内部的 entries 属性来实现,该属性的值是一个 Array 类型对象。
ECMAScript 标准库中,大部分的构造函数都会依赖内部属性值(如 [[class]])来实现正确的行为。对于继承这些标准类型的子类型,无法保证它们的行为是正确的。因此,不要继承 ECMAScript 标准库中的类型,包括: Array,Boolean,Date,Function,Number,RegExp,String
以上就是对使用 prototype 的几点注意事项进行的总结,希望可以帮助大家正确地使用 prototype。