JavaScript Prototype 使用指南:内存优化、私有数据与继承陷阱

作者:木棉

时间:2026-10-29

浏览:360

本文详解 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 方法也会在每一个实例上生成一份独立的拷贝。其内存分布情况如下图所示:

在prototype上保存方法

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

在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 上时,所有实例的状态都会被集中保存。在上述场景中,这显然是不正确的:本属于每个实例的状态被错误地共享了。如下图所示:

在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 上正是因为这一点)。当然,有状态的属性也能够放在 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。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章