在JavaScript这门语言里,对象和函数并不是孤立存在的。每一个由构造函数创建出来的实例,在底层都通过一个隐式引用和该构造函数的原型对象相连。这种连接关系构成了所谓的原型链,也是实现属性复用和继承的根本方式。如果不搞清楚实例、构造函数以及原型对象三者之间如何互相指向,写出来的代码很容易出现共享状态被意外篡改或者属性查找结果不符合预期的问题。

原型对象与隐式引用的底层结构
当我们使用function Person(){}这样的语法声明一个函数时,JavaScript引擎会自动为这个函数附加一个prototype属性,它指向一个普通对象,这就是原型对象。与此同时,原型对象上有一个constructor属性指回原函数,形成双向关联。由new Person()生成的实例本身并没有prototype属性,但它内部有一个隐式引用,在多数浏览器中表现为__proto__,指向Person.prototype。
这种结构意味着,当我们读取instance.name而实例自身没有name属性时,引擎会去instance.__proto__也就是原型对象上查找。如果原型上也没有,就继续沿着原型对象的__proto__向上,直到Object.prototype,再往上是null,查找停止。下面的代码展示了这种关系:
function Animal(type) {
this.type = type;
}
Animal.prototype.speak = function() {
return '我是' + this.type;
};
var dog = new Animal('狗');
// 实例自身有type
console.log(dog.type); // 狗
// 自身没有speak,去原型上找
console.log(dog.speak()); // 我是狗
// 验证隐式引用
console.log(dog.__proto__ === Animal.prototype); // true
console.log(Animal.prototype.constructor === Animal); // true
从内存角度看,所有实例共享同一块原型对象的内存,方法定义在原型上就能被成百上千个实例复用,而不必每个实例都拷贝一份函数。这也是早期JavaScript实现面向对象时节省内存的关键手段。不过要注意,如果原型上挂的是引用类型比如数组,多个实例修改它会互相影响,这一点在后续小节会详细讨论。
原型链查找机制与继承实现对比
原型链的核心价值在于它提供了一种天然的委托机制。不同于传统类语言中复制类结构到实例,JavaScript的继承是通过把子构造函数的原型指向父构造函数的实例来实现的。当访问子实例的属性时,若自身和子原型都没有,就会沿着链走到父原型。这种基于委托的继承也被称为差异继承,因为实例只需保存和原型不同的部分。
我们可以用最基础的写法手动构建继承,也可以借助Object.create来简化。下面演示了两种方式的差异,前者容易遗漏父类构造逻辑,后者语义更清晰:
// 方式一:子类原型直接等于父类实例
function Cat(name) {
Animal.call(this, '猫');
this.name = name;
}
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;
var c = new Cat('咪咪');
console.log(c.speak()); // 我是猫
// 方式二:使用Object.create
function Bird(name) {
Animal.call(this, '鸟');
this.name = name;
}
Bird.prototype = Object.create(Animal.prototype);
Bird.prototype.constructor = Bird;
var b = new Bird('小黄');
console.log(b.speak()); // 我是鸟
方式一中new Animal()会执行父类构造函数,若父类依赖参数可能产生副作用;方式二只复制原型而不调用构造函数,更安全。ES6的class语法糖底层仍是这套原型链,只是写法更接近其他语言。理解原型链能帮我们在调试时快速定位属性到底定义在哪一层,而不是被class的表象迷惑。
修改原型的风险与工程实践
由于原型是共享的,在运行时往原型上添加属性或方法会影响所有实例。这有时很方便,比如给内置数组补一个方法,但更常见的是引发难以追踪的bug。例如在团队项目中,某处代码给Object.prototype加了可枚举属性,会导致所有for...in循环多出意料之外的键。因此扩展内置原型通常被视作反模式,除非做兼容垫片且用Object.defineProperty设为不可枚举。
另一个陷阱是引用类型共享。如果原型上有一个数组,实例用push修改它,其他实例读到的就是被改过的值。正确做法是在构造函数里给每个实例创建自己的引用,或者把共享逻辑写成返回新数据的方法。下面代码对比了错误与正确写法:
// 错误:共享引用
function Wrong() {}
Wrong.prototype.list = [];
var w1 = new Wrong();
var w2 = new Wrong();
w1.list.push(1);
console.log(w2.list); // [1] 被影响
// 正确:实例自有
function Right() {
this.list = [];
}
var r1 = new Right();
var r2 = new Right();
r1.list.push(1);
console.log(r2.list); // [] 不受影响
在工程上,建议把方法挂在原型、把状态放在实例。使用class时,写在类体内的方法自动落到原型,而构造函数里的赋值则是实例属性,这一规则正好契合上述原则。当确实需要修改原型,务必确认影响范围,并优先通过子类或组合而非直接动全局原型,这样才能把原型链这把双刃剑用稳。