JS 原型链中的‘影子属性’:当实例属性覆盖原型属性时的底层查找逻辑

欢迎来到今天的专题讲座。今天,我们将深入探讨 JavaScript 中一个既核心又容易引起误解的机制——原型链(Prototype Chain),以及在这一机制下衍生出的一个重要概念,我们称之为“影子属性”(Shadowing Property)。当一个实例属性“覆盖”了原型链上的同名属性时,其背后的查找与赋值逻辑,是理解 JavaScript 面向对象编程范式的关键。

JavaScript 是一种基于原型的语言,与传统的基于类的语言有着显著的不同。它没有像 Java 或 C++ 那样显式的类结构(ES6 引入的 class 关键字也只是语法糖,其底层依然是基于原型链)。理解原型链,就如同掌握了 JavaScript 对象继承的精髓。而“影子属性”现象,正是原型链在实际操作中最常见、也最需要我们细致分析的行为之一。

我们将以一个编程专家的视角,剥开层层表象,直达其底层机制。准备好了吗?让我们开始这段深入 JavaScript 核心的旅程。

1. JavaScript 对象与原型链的基础

在探讨影子属性之前,我们必须对 JavaScript 对象和原型链的基础有一个清晰的认识。

1.1 万物皆对象(Objects Everywhere)

在 JavaScript 中,除了基本类型(如字符串、数字、布尔值、nullundefinedSymbolBigInt),几乎所有东西都是对象。函数是对象,数组是对象,甚至对象本身也是对象。每个 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]] 的内部操作,其查找逻辑如下:

  1. 首先,检查对象自身:如果对象自身拥有该属性(即是“自有属性”),则直接返回该属性的值。
  2. 如果对象自身没有:则沿着 [[Prototype]] 链向上查找。它会检查当前对象的原型对象是否拥有该属性。
  3. 重复步骤2:如果原型对象也没有,就继续检查原型对象的原型,直到找到该属性。
  4. 链的尽头:如果查找到原型链的末端(即 [[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]] 操作会遵循以下规则:

  1. 检查 myObject 自身是否拥有 property 属性

    • 如果 myObject 自身已经拥有 property 属性:直接修改该属性的值。
    • 如果 myObject 自身没有 property 属性:进行下一步。
  2. 检查原型链([[Prototype]] 链)

    • 情况A:原型链上没有同名属性:在 myObject 自身上创建一个新的 property 属性,并赋值。这是最简单、最常见的影子属性的创建方式。
    • 情况B:原型链上存在同名属性:这时情况变得复杂,需要进一步检查:
      • B1:原型链上的同名属性是可写(writable: true)的数据属性:在 myObject 自身上创建一个新的 property 属性,并赋值。这同样会产生影子属性。
      • B2:原型链上的同名属性是只读(writable: false)的数据属性
        • 在非严格模式下:赋值操作会被静默忽略,myObject 自身不会创建新属性,原型上的属性也不会被修改。
        • 在严格模式下:会抛出 TypeError 错误。
      • B3:原型链上的同名属性是一个 setter 访问器属性
        • 不会myObject 自身创建新属性。
        • 会调用原型链上的 setter 方法。并且,setter 方法中的 this 上下文将指向 myObject 实例本身。这意味着 setter 可以修改 myObject 自身上的其他属性,或者修改原型上的私有属性(如果设计允许)。

这些规则确保了原型链的“只读”性质,即实例通常不会直接修改原型上的属性,而是通过创建影子属性来“覆盖”它们。

下面我们通过具体的代码示例来逐一验证这些情况。

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 的世界中如鱼得水,编写出更加健壮、高效和可预测的代码。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注