欢迎来到今天的专题讲座。今天,我们将深入探讨 JavaScript 中一个既核心又容易引起误解的机制——原型链(Prototype Chain),以及在这一机制下衍生出的一个重要概念,我们称之为“影子属性”(Shadowing Property)。当一个实例属性“覆盖”了原型链上的同名属性时,其背后的查找与赋值逻辑,是理解 JavaScript 面向对象编程范式的关键。
JavaScript 是一种基于原型的语言,与传统的基于类的语言有着显著的不同。它没有像 Java 或 C++ 那样显式的类结构(ES6 引入的 class 关键字也只是语法糖,其底层依然是基于原型链)。理解原型链,就如同掌握了 JavaScript 对象继承的精髓。而“影子属性”现象,正是原型链在实际操作中最常见、也最需要我们细致分析的行为之一。
我们将以一个编程专家的视角,剥开层层表象,直达其底层机制。准备好了吗?让我们开始这段深入 JavaScript 核心的旅程。
1. JavaScript 对象与原型链的基础
在探讨影子属性之前,我们必须对 JavaScript 对象和原型链的基础有一个清晰的认识。
1.1 万物皆对象(Objects Everywhere)
在 JavaScript 中,除了基本类型(如字符串、数字、布尔值、null、undefined 和 Symbol、BigInt),几乎所有东西都是对象。函数是对象,数组是对象,甚至对象本身也是对象。每个 JavaScript 对象都有一个内部属性 [[Prototype]],它指向另一个对象,这个被指向的对象就是它的原型(Prototype)。
这个 [[Prototype]] 属性在 ES5 之前是不可直接访问的,但大多数浏览器提供了非标准的 __proto__ 属性作为其访问器。ES6 之后,我们可以使用 Object.getPrototypeOf() 和 Object.setPrototypeOf() 来标准地操作它。
1.2 原型链的构成
当一个对象被创建时,它的 [[Prototype]] 属性会被设置。例如:
- 通过对象字面量
let obj = {};创建的对象,其[[Prototype]]指向Object.prototype。 - 通过构造函数
function MyConstructor() {}和new MyConstructor()创建的对象,其[[Prototype]]指向MyConstructor.prototype。 - 通过
Object.create(proto)创建的对象,其[[Prototype]]指向proto。
这些链接形成了一个链条,我们称之为原型链。链的尽头通常是 null,这意味着 Object.prototype 的 [[Prototype]] 是 null。
// 示例1.1: 基本的原型链结构
let myObject = {};
console.log(Object.getPrototypeOf(myObject) === Object.prototype); // true
console.log(Object.getPrototypeOf(Object.prototype)); // null
function Person(name) {
this.name = name;
}
let alice = new Person("Alice");
console.log(Object.getPrototypeOf(alice) === Person.prototype); // true
console.log(Object.getPrototypeOf(Person.prototype) === Object.prototype); // true
console.log(Object.getPrototypeOf(Object.prototype)); // null
1.3 属性查找机制([[Get]] 操作)
当我们尝试访问一个对象的属性时,JavaScript 引擎会执行一个被称为 [[Get]] 的内部操作,其查找逻辑如下:
- 首先,检查对象自身:如果对象自身拥有该属性(即是“自有属性”),则直接返回该属性的值。
- 如果对象自身没有:则沿着
[[Prototype]]链向上查找。它会检查当前对象的原型对象是否拥有该属性。 - 重复步骤2:如果原型对象也没有,就继续检查原型对象的原型,直到找到该属性。
- 链的尽头:如果查找到原型链的末端(即
[[Prototype]]为null),仍然没有找到该属性,则返回undefined。
这个查找机制是理解影子属性的基础。它确保了属性的继承和共享。
// 示例1.2: 属性查找机制
function Animal(species) {
this.species = species;
}
Animal.prototype.sound = "Generic Sound"; // 原型属性
Animal.prototype.makeSound = function() {
console.log(this.sound);
};
let dog = new Animal("Dog");
dog.name = "Buddy"; // 实例属性
console.log(dog.species); // "Dog" (实例自有属性)
console.log(dog.name); // "Buddy" (实例自有属性)
console.log(dog.sound); // "Generic Sound" (通过原型链查找)
dog.makeSound(); // "Generic Sound" (通过原型链查找方法)
console.log(dog.nonExistent); // undefined (查找到原型链末端未找到)
// 验证属性是否是自有属性
console.log(dog.hasOwnProperty('species')); // true
console.log(dog.hasOwnProperty('name')); // true
console.log(dog.hasOwnProperty('sound')); // false (在原型上)
console.log(dog.hasOwnProperty('makeSound')); // false (在原型上)
通过 hasOwnProperty() 方法,我们可以明确地区分一个属性是对象自身的还是从原型链上继承的。这是处理影子属性时非常重要的一个工具。
1.4 总结原型链基础
| 概念 | 描述 | 示例 |
|---|---|---|
[[Prototype]] |
每个对象内部的链接,指向其原型对象。 | Object.getPrototypeOf(myObj) |
__proto__ |
大多数浏览器提供的非标准访问器,用于读写 [[Prototype]]。已废弃,但不影响理解。 |
myObj.__proto__ |
prototype |
函数特有的属性,当函数作为构造函数使用时,新创建的实例的 [[Prototype]] 会指向这个 prototype 对象。 |
MyConstructor.prototype |
| 原型链 | 由 [[Prototype]] 链接起来的对象序列,用于实现属性和方法的继承。 |
instance -> Constructor.prototype -> Object.prototype -> null |
[[Get]] |
属性读取时的内部操作,沿原型链向上查找直到找到属性或链末尾。 | myObj.property |
hasOwnProperty() |
判断对象自身是否拥有某个属性(而非从原型链继承)。 | myObj.hasOwnProperty('property') |
2. 影子属性:当实例属性覆盖原型属性
现在,我们进入今天讲座的核心——“影子属性”(Shadowing Property)。这个现象发生在当我们对一个对象的属性进行写入操作时,如果该对象自身没有这个属性,但其原型链上存在同名属性,那么 JavaScript 不会修改原型上的属性,而是在对象实例自身上创建一个新的同名属性。这个新的实例属性“遮蔽”了原型链上的同名属性,使得在访问该属性时,原型上的属性仿佛“消失”了,我们称之为“影子属性”。
核心思想:读时沿链向上找,写时通常在实例上创建。
让我们通过一个例子来直观感受:
// 示例2.1: 影子属性的产生
function Car(model) {
this.model = model;
}
Car.prototype.color = "red"; // 原型属性:所有 Car 实例默认颜色都是红色
Car.prototype.start = function() {
console.log(`${this.model} starts with ${this.color} color.`);
};
let myCar = new Car("Tesla Model 3");
console.log(`我的车模型:${myCar.model}`); // Tesla Model 3 (实例属性)
console.log(`我的车颜色:${myCar.color}`); // red (原型属性)
myCar.start(); // Tesla Model 3 starts with red color.
console.log("n--- 现在,我们给我的车指定一个新颜色 ---");
myCar.color = "blue"; // 在 myCar 实例上创建了一个新的 color 属性
console.log(`我的车新颜色:${myCar.color}`); // blue (实例属性,遮蔽了原型属性)
console.log(`原型的车颜色:${Car.prototype.color}`); // red (原型属性未受影响)
console.log(`我的车是否拥有自己的颜色属性?${myCar.hasOwnProperty('color')}`); // true
myCar.start(); // Tesla Model 3 starts with blue color. (this.color 现在指向实例属性)
在上面的例子中,最初 myCar.color 访问的是 Car.prototype.color。但是,当我们执行 myCar.color = "blue"; 时,JavaScript 并没有去修改 Car.prototype.color,而是在 myCar 实例自身上创建了一个新的 color 属性,并将其值设置为 "blue"。
此后,当我们再通过 myCar.color 访问颜色时,根据 [[Get]] 属性查找规则,它会首先在 myCar 实例自身找到 color 属性并返回其值 "blue",从而“遮蔽”了原型链上的 color 属性。这就是所谓的“影子属性”。
值得注意的是,myCar.start() 方法中的 this.color 也会根据上下文指向 myCar 实例的 color 属性,因此打印出的是 "blue"。这是 this 绑定机制与原型链结合的又一个体现。
3. 底层查找与赋值逻辑([[Put]] 操作)
现在,让我们更深入地探讨当尝试为一个对象的属性赋值时,JavaScript 引擎内部是如何处理的。这涉及到 [[Put]] 内部操作的复杂逻辑。
当我们执行 myObject.property = value; 这样的赋值语句时,JavaScript 引擎的 [[Put]] 操作会遵循以下规则:
-
检查
myObject自身是否拥有property属性:- 如果
myObject自身已经拥有property属性:直接修改该属性的值。 - 如果
myObject自身没有property属性:进行下一步。
- 如果
-
检查原型链(
[[Prototype]]链):- 情况A:原型链上没有同名属性:在
myObject自身上创建一个新的property属性,并赋值。这是最简单、最常见的影子属性的创建方式。 - 情况B:原型链上存在同名属性:这时情况变得复杂,需要进一步检查:
- B1:原型链上的同名属性是可写(
writable: true)的数据属性:在myObject自身上创建一个新的property属性,并赋值。这同样会产生影子属性。 - B2:原型链上的同名属性是只读(
writable: false)的数据属性:- 在非严格模式下:赋值操作会被静默忽略,
myObject自身不会创建新属性,原型上的属性也不会被修改。 - 在严格模式下:会抛出
TypeError错误。
- 在非严格模式下:赋值操作会被静默忽略,
- B3:原型链上的同名属性是一个
setter访问器属性:- 不会在
myObject自身创建新属性。 - 会调用原型链上的
setter方法。并且,setter方法中的this上下文将指向myObject实例本身。这意味着setter可以修改myObject自身上的其他属性,或者修改原型上的私有属性(如果设计允许)。
- 不会在
- B1:原型链上的同名属性是可写(
- 情况A:原型链上没有同名属性:在
这些规则确保了原型链的“只读”性质,即实例通常不会直接修改原型上的属性,而是通过创建影子属性来“覆盖”它们。
下面我们通过具体的代码示例来逐一验证这些情况。
3.1 场景一:原型链上没有同名属性
这是最简单的情况。赋值操作会直接在实例上创建新属性。
// 示例3.1: 原型链上没有同名属性
function Bike() {}
// Bike.prototype 上没有 engine 属性
let myBike = new Bike();
console.log(myBike.hasOwnProperty('engine')); // false
console.log(myBike.engine); // undefined
myBike.engine = "Electric"; // 在 myBike 实例上创建 engine 属性
console.log(myBike.hasOwnProperty('engine')); // true
console.log(myBike.engine); // "Electric"
console.log(Bike.prototype.hasOwnProperty('engine')); // false
在这个场景中,myBike 实例本身没有 engine 属性,原型链上也没有。因此,myBike.engine = "Electric"; 会在 myBike 实例上创建一个新的 engine 属性。这不会产生“影子”效果,因为没有被遮蔽的属性。
3.2 场景二:原型链上存在同名属性且是可写数据属性
这是典型的影子属性产生场景。实例会创建自己的属性来遮蔽原型属性。
// 示例3.2: 原型链上的可写数据属性被遮蔽
function Vehicle() {}
Vehicle.prototype.wheels = 4; // 原型属性,默认可写
let car = new Vehicle();
console.log(car.wheels); // 4
console.log(car.hasOwnProperty('wheels')); // false
car.wheels = 2; // 在 car 实例上创建 wheels 属性,遮蔽原型属性
console.log(car.wheels); // 2 (实例属性)
console.log(car.hasOwnProperty('wheels')); // true
console.log(Vehicle.prototype.wheels); // 4 (原型属性未受影响)
let truck = new Vehicle(); // 另一个实例
console.log(truck.wheels); // 4 (它仍然访问原型属性)
这里,Vehicle.prototype.wheels 是一个普通的数据属性,默认是可写的。当 car.wheels = 2; 执行时,JavaScript 引擎在 car 实例上创建了一个名为 wheels 的新属性,其值是 2。此后,对 car.wheels 的所有读取都将命中这个实例属性,从而“遮蔽”了 Vehicle.prototype.wheels。原型上的 wheels 属性仍然保持 4。
3.3 场景三:原型链上存在同名属性但它是只读数据属性(writable: false)
这种情况是赋值操作无法穿透原型链的典型案例。
// 示例3.3: 原型链上的只读数据属性
function Gadget() {}
Object.defineProperty(Gadget.prototype, 'version', {
value: "1.0",
writable: false, // 设置为只读
enumerable: true,
configurable: true
});
let myGadget = new Gadget();
console.log(myGadget.version); // "1.0"
console.log(myGadget.hasOwnProperty('version')); // false
// 尝试修改 version 属性
myGadget.version = "2.0"; // 在非严格模式下静默失败;在严格模式下会抛出 TypeError
console.log(myGadget.version); // "1.0" (仍然是原型属性)
console.log(myGadget.hasOwnProperty('version')); // false (实例上没有创建新属性)
console.log(Gadget.prototype.version); // "1.0" (原型属性未被修改)
// 在严格模式下尝试会抛错
(function() {
"use strict";
try {
myGadget.version = "3.0";
} catch (e) {
console.error("严格模式下赋值只读属性会报错:", e.message); // TypeError
}
})();
当 Gadget.prototype.version 被定义为 writable: false 时,任何尝试通过 myGadget.version = "..." 来修改它或在 myGadget 实例上创建同名影子属性的尝试都将失败。在非严格模式下,这个操作会静默失败,myGadget 实例上不会创建 version 属性,原型上的 version 也不会被修改。在严格模式下,则会直接抛出 TypeError。
3.4 场景四:原型链上存在同名属性但它是一个 setter 访问器属性
这是最特殊也是最容易混淆的场景。赋值操作会调用 setter,并且 setter 中的 this 指向实例。
// 示例3.4: 原型链上的 setter 访问器属性
function Light() {
this._brightness = 50; // 实例私有属性
}
Object.defineProperty(Light.prototype, 'brightness', {
enumerable: true,
configurable: true,
get: function() {
console.log("原型上的 getter 被调用");
return this._brightness; // 'this' 指向实例
},
set: function(value) {
console.log("原型上的 setter 被调用");
if (value >= 0 && value <= 100) {
this._brightness = value; // 'this' 指向实例,修改实例的 _brightness 属性
} else {
console.warn("亮度值超出范围 (0-100)");
}
}
});
let myLight = new Light();
console.log(myLight.brightness); // 50 (原型getter调用,返回实例_brightness)
console.log(myLight.hasOwnProperty('brightness')); // false
console.log(myLight.hasOwnProperty('_brightness')); // true
console.log("n--- 尝试修改亮度 ---");
myLight.brightness = 80; // 调用原型上的 setter
console.log(myLight.brightness); // 80 (原型getter调用,返回实例_brightness)
console.log(myLight.hasOwnProperty('brightness')); // false (实例上没有 brightness 属性)
console.log(myLight.hasOwnProperty('_brightness')); // true (_brightness 属性被 setter 修改了)
console.log("n--- 再次修改亮度,超出范围 ---");
myLight.brightness = 120; // 调用原型上的 setter,但值无效
console.log(myLight.brightness); // 80 (值未变,仍然是实例的 _brightness)
在这个例子中,Light.prototype 上定义了一个名为 brightness 的访问器属性(getter/setter)。当我们对 myLight.brightness 进行赋值操作时,JavaScript 引擎会沿原型链查找 brightness 属性。由于它找到了一个 setter,它会直接调用这个 setter 方法,而不是在 myLight 实例上创建新的 brightness 属性。
最关键的是,在 setter 方法内部,this 关键字会指向 myLight 实例本身。因此,this._brightness = value; 实际上修改的是 myLight 实例自身的 _brightness 属性。这是一种“伪影子”效果,因为表面上 myLight.brightness 的行为改变了,但 brightness 属性本身并未在 myLight 实例上创建。
3.5 场景五:实例自身已经拥有同名属性
如果实例自身已经拥有同名属性,那么赋值操作只会修改实例自身的属性,与原型链无关。这不会产生新的影子属性,只是修改了已有的实例属性。
// 示例3.5: 实例自身已有同名属性
function Computer(type) {
this.type = type;
this.power = "On"; // 实例自有属性
}
Computer.prototype.power = "Off"; // 原型属性
let myComputer = new Computer("Laptop");
console.log(myComputer.power); // "On" (实例自有属性)
console.log(myComputer.hasOwnProperty('power')); // true
console.log(Computer.prototype.power); // "Off"
myComputer.power = "Standby"; // 修改实例自身的 power 属性
console.log(myComputer.power); // "Standby" (实例自有属性被修改)
console.log(myComputer.hasOwnProperty('power')); // true
console.log(Computer.prototype.power); // "Off" (原型属性未受影响)
在这种情况下,myComputer 实例在创建时就有了自己的 power 属性。因此,myComputer.power = "Standby"; 直接修改的是 myComputer 实例自身的 power 属性,原型链在此次赋值操作中并未被触及。
3.6 总结赋值操作([[Put]])的逻辑
赋值目标 (myObj.prop = value;) |
myObj 自身拥有 prop? |
myObj 原型链上是否存在 prop? |
原型上的 prop 特性 |
结果 |
|---|---|---|---|---|
myObj.prop = value; |
是 | 不相关 | 不相关 | 直接修改 myObj 自身的 prop 属性。 |
myObj.prop = value; |
否 | 否 | 不相关 | 在 myObj 自身上创建新的 prop 属性。 |
myObj.prop = value; |
否 | 是 | 可写(writable: true)的数据属性 |
在 myObj 自身上创建新的 prop 属性,遮蔽原型上的同名属性。 |
myObj.prop = value; |
否 | 是 | 只读(writable: false)的数据属性 |
非严格模式下静默失败,不创建新属性,不修改原型属性。严格模式下抛出 TypeError。 |
myObj.prop = value; |
否 | 是 | setter 访问器属性 |
调用原型上的 setter 方法,this 指向 myObj 实例。通常会修改 myObj 实例上的其他(私有)属性,而不会在 myObj 自身创建同名 prop 属性。 |
4. delete 操作与影子属性
delete 操作符用于删除对象的自有属性。当一个影子属性被删除时,它会揭示(reveal)被遮蔽的原型属性。
// 示例4.1: delete 操作与影子属性
function CoffeeMachine() {}
CoffeeMachine.prototype.status = "Off"; // 原型属性
let myCoffeeMachine = new CoffeeMachine();
console.log(myCoffeeMachine.status); // "Off"
myCoffeeMachine.status = "On"; // 创建影子属性
console.log(myCoffeeMachine.status); // "On"
console.log(myCoffeeMachine.hasOwnProperty('status')); // true
console.log("n--- 删除实例属性 ---");
delete myCoffeeMachine.status; // 删除 myCoffeeMachine 实例上的 status 属性
console.log(myCoffeeMachine.status); // "Off" (原型属性再次可见)
console.log(myCoffeeMachine.hasOwnProperty('status')); // false
// 如果删除一个不存在的实例属性,会返回 true 且不影响原型
delete myCoffeeMachine.nonExistent; // true
delete myCoffeeMachine.status; 删除了 myCoffeeMachine 实例自身的 status 属性。由于实例上不再有 status 属性,当再次访问 myCoffeeMachine.status 时,JavaScript 引擎会沿原型链查找,并找到 CoffeeMachine.prototype.status,因此原型上的属性被“揭示”出来。
5. 实际应用与最佳实践
理解影子属性的底层逻辑,对于编写健壮、可维护的 JavaScript 代码至关重要。
5.1 避免意外修改原型
影子属性机制本身就很好地保护了原型,防止实例意外地修改共享的原型属性。如果你想修改一个“继承”来的属性,正确的做法通常是直接在实例上赋值,从而创建影子属性。
// 避免直接修改原型,影响所有实例
// Car.prototype.color = "blue"; // 这样做会改变所有 Car 实例的默认颜色,包括已经存在的
如果你确实需要修改原型上的属性,请确保这是你深思熟虑后的决定,并且清楚其影响范围将是所有当前及未来通过该原型链创建的实例。
5.2 何时使用 hasOwnProperty()
hasOwnProperty() 是区分自有属性和原型属性的关键。在以下场景中非常有用:
- 遍历对象属性:当你只想遍历对象自身的属性,而不包括继承属性时,可以使用
for...in循环结合hasOwnProperty()。for (let prop in myCar) { if (myCar.hasOwnProperty(prop)) { console.log(`自有属性: ${prop}: ${myCar[prop]}`); } } - 避免函数库或框架的潜在问题:某些库可能在
Object.prototype上添加了非标准的属性。不使用hasOwnProperty()可能会导致for...in循环意外地遍历到这些原型属性。
5.3 Object.create() 与原型继承
Object.create() 方法可以创建一个新对象,并使用现有对象作为新对象的原型。这提供了一种干净、直接的原型继承方式,避免了构造函数和 new 关键字的一些复杂性。
// 示例5.1: 使用 Object.create()
let animalPrototype = {
sleep: function() { console.log("Zzzzz..."); },
type: "unknown"
};
let cat = Object.create(animalPrototype);
cat.name = "Whiskers"; // 实例属性
cat.type = "feline"; // 创建影子属性,遮蔽 animalPrototype.type
console.log(cat.name); // "Whiskers"
console.log(cat.type); // "feline"
cat.sleep(); // "Zzzzz..."
console.log(animalPrototype.type); // "unknown" (原型未受影响)
使用 Object.create() 使得原型链的构建更加明确,也更容易理解影子属性的产生。
5.4 性能考量
虽然 JavaScript 引擎对原型链查找进行了高度优化,但过长的原型链在极端情况下可能会对属性查找性能产生轻微影响。在大多数日常应用中,这种影响可以忽略不计。更重要的是清晰地理解其行为,避免逻辑错误。
5.5 避免“意外”的 setter 行为
如果你继承了一个包含 setter 的原型,你需要清楚地知道对实例属性的赋值操作将不会创建影子属性,而是调用原型上的 setter。这可能导致意想不到的行为,特别是当 setter 内部修改了实例的其他状态时。
始终查阅你所使用的库或框架的文档,了解其原型上是否定义了访问器属性。
6. 深入理解:Object.getOwnPropertyDescriptor()
要真正理解属性的底层特性,Object.getOwnPropertyDescriptor() 是一个非常有力的工具。它允许我们查看一个对象自有属性的完整描述符,包括 value, writable, enumerable, configurable。
// 示例6.1: 查看属性描述符
function Gadget() {}
Object.defineProperty(Gadget.prototype, 'version', {
value: "1.0",
writable: false,
enumerable: true,
configurable: true
});
let myGadget = new Gadget();
myGadget.id = 123; // 实例属性
// 查看实例自有属性
console.log(Object.getOwnPropertyDescriptor(myGadget, 'id'));
// { value: 123, writable: true, enumerable: true, configurable: true }
// 尝试查看原型属性(注意:getOwnPropertyDescriptor 只查找自有属性)
console.log(Object.getOwnPropertyDescriptor(myGadget, 'version')); // undefined
// 查看原型自身的属性
console.log(Object.getOwnPropertyDescriptor(Gadget.prototype, 'version'));
/*
{
value: '1.0',
writable: false,
enumerable: true,
configurable: true
}
*/
// 制造一个影子属性,然后查看
myGadget.version = "2.0"; // 在非严格模式下静默失败,因为原型属性是只读的
console.log(Object.getOwnPropertyDescriptor(myGadget, 'version')); // 仍然是 undefined,因为没有创建影子属性
// 另一个例子,可写原型属性被遮蔽
function Item() {}
Item.prototype.count = 5;
let myItem = new Item();
myItem.count = 10; // 创建影子属性
console.log(Object.getOwnPropertyDescriptor(myItem, 'count'));
// { value: 10, writable: true, enumerable: true, configurable: true }
console.log(Object.getOwnPropertyDescriptor(Item.prototype, 'count'));
// { value: 5, writable: true, enumerable: true, configurable: true }
Object.getOwnPropertyDescriptor() 明确地告诉我们一个属性是自有属性(因为它只返回自有属性的描述符),以及它的 writable 状态,这对于理解赋值操作的行为至关重要。
7. 结语
JavaScript 的原型链机制及其影子属性现象,是这门语言独特魅力的体现。它提供了一种强大而灵活的继承模型。深入理解属性的查找 ([[Get]]) 和赋值 ([[Put]]) 逻辑,特别是当原型链上存在同名属性时的行为,是成为一名优秀 JavaScript 开发者的必经之路。
记住,在 JavaScript 中,读属性是沿着原型链向上查找,而写属性通常是在实例自身上创建或修改,从而形成“影子”。只有在少数特定情况下(如原型上的只读属性或 setter),赋值行为才会偏离这一基本模式。掌握这些细节,将使你在 JavaScript 的世界中如鱼得水,编写出更加健壮、高效和可预测的代码。