constructor 被我搞丢了,原型链当场去世

82 阅读2分钟

深入理解JavaScript原型链:从基础到实践

在JavaScript这门动态、灵活且基于原型的编程语言中,原型链(Prototype Chain) 是其面向对象机制的核心基石。它不仅是实现继承的关键,也深刻影响着对象属性的查找、方法的共享以及整个语言的运行时行为。理解原型链,是掌握JavaScript高级特性和进行高效开发的必经之路。

一、 为什么需要原型链?—— 从对象创建说起

JavaScript中创建对象最常见的方式之一是使用构造函数:

function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.sayHello = function() {
    console.log(`Hello, I'm ${this.name}`);
};

const alice = new Person('Alice', 30);
const bob = new Person('Bob', 25);

在这个例子中,alicebobPerson 构造函数的实例。它们各自拥有独立的 nameage 属性(存储在实例自身)。但 sayHello 方法呢?如果每个实例都存储一份 sayHello 函数的副本,不仅浪费内存,而且难以维护。

JavaScript的解决方案就是原型(Prototype)。每个函数(包括构造函数)在创建时,都会自动拥有一个 prototype 属性,这个属性指向一个对象,即“原型对象”。这个原型对象包含了可以被所有实例共享的属性和方法。

这里容易产生误解:为什么说属性和方法都被共享,属性被共享了的话,那修改其中一个实例,其他实例不是也会被修改吗

其实原型上的属性确实是共享的,但访问和修改的方式决定了是否影响其他实例。

举个例子:

function Person(name) {
    this.name = name;
}

Person.prototype.species = 'Human';

const alice = new Person('Alice');
const bob = new Person('Bob');

console.log(alice.species); // "Human"
console.log(bob.species);   // "Human"

此时,两个实例都没有自己的 species 属性,所以都从原型中读取。

读取时共享,修改时则不同

情况一:只读不改

console.log(alice.species); // "Human"
console.log(bob.species);   // "Human"

都读取的是原型上的值,看起来是“共享”的。

情况二:其中一个实例修改原型属性

alice.species = 'Alien';

console.log(alice.species); // "Alien"(实例自己的属性)
console.log(bob.species);   // "Human"(原型上的属性)

这里发生了什么?

  • alice.species = 'Alien' 时,JavaScript 会alice 实例上创建一个自己的 species 属性屏蔽(shadowing)了原型上的同名属性
  • bob 还是访问原型上的 species,值没有变化。

所以,原型上的属性虽然共享,但一旦某个实例修改该属性,它就会在该实例上创建一个自有属性,不会影响其他实例或原型本身。

二、 原型(Prototype)与原型链(Prototype Chain)的核心概念

  1. prototype 属性(函数特有)

    • 只有函数拥有 prototype 属性(箭头函数除外)。
    • 它是一个指针,指向一个对象(我们称之为“原型对象”)。
    • 这个原型对象默认有一个 constructor 属性,指回函数本身。
    • 我们通常在这个原型对象上添加方法和共享属性。
  2. [[Prototype]] 内部槽(对象特有)

    • 每个对象(包括函数,因为函数也是对象)都有一个内部的、不可直接访问的属性 [[Prototype]]
    • 这个 [[Prototype]] 指向该对象的“原型”。
    • 在大多数现代JavaScript环境中,可以通过 Object.getPrototypeOf(obj) 来获取一个对象的 [[Prototype]],或者使用非标准的 __proto__ 属性(不推荐在生产环境中直接使用)。
  3. 原型链的形成

    • 当我们通过 new Person() 创建一个实例(如 alice)时,JavaScript引擎会:
      1. 创建一个新对象。
      2. 将这个新对象的 [[Prototype]] 内部槽设置为 Person.prototype
    • 这样,例子中alice[[Prototype]] 就指向了 Person.prototype 对象。
    • Person.prototype 本身也是一个对象,它也有自己的 [[Prototype]]。根据JavaScript的规则,Person.prototype[[Prototype]] 指向 Object.prototype(因为 Person.prototype 是一个普通对象)。
    • Object.prototype[[Prototype]]null,表示原型链的终点。
    • 这样,一条链条就形成了: alicePerson.prototypeObject.prototypenull

    这条由 [[Prototype]] 连接起来的链条,就是原型链

三、 原型链如何工作?—— 属性查找机制

原型链最核心的作用体现在属性查找上。当你尝试访问一个对象的某个属性或方法时,JavaScript引擎会按以下顺序进行搜索:

  1. 查找对象自身: 首先检查该对象自身是否直接拥有这个属性(即在对象的自有属性中查找)。
  2. 查找原型链: 如果自身没有,引擎会沿着 [[Prototype]] 指针,到该对象的原型对象中去查找。
  3. 递归查找: 如果原型对象中也没有,就继续沿着原型链向上查找,直到找到该属性,或者到达原型链的末端([[Prototype]]null)。
  4. 返回 undefined 如果在整条原型链上都找不到该属性,则返回 undefined

让我们用上面的例子验证:

console.log(alice.name); // "Alice" - 直接在实例自身找到
console.log(alice.age);  // 30 - 直接在实例自身找到
alice.sayHello(); // "Hello, I'm Alice" - 实例自身没有sayHello,查找其原型Person.prototype,找到并执行
console.log(alice.toString()); // "[object Object]" - 实例自身没有,Person.prototype上没有,继续查找Object.prototype,找到toString方法
console.log(alice.nonExistent); // undefined - 整条链都找不到

四、 原型链的实践与继承

原型链是JavaScript实现继承的主要方式(虽然ES6引入了 class 语法糖,但底层仍是基于原型链)。

  • 构造函数继承(经典继承): 通过改变原型链的连接来实现。

    function Student(name, age, grade) {
        Person.call(this, name, age); // 调用父构造函数
        this.grade = grade;
    }
    
    // 关键步骤:建立原型链连接
    Student.prototype = Object.create(Person.prototype);
    // 或者 ES6: Object.setPrototypeOf(Student.prototype, Person.prototype);
    // 注意:需要手动修复constructor
    Student.prototype.constructor = Student;
    
    Student.prototype.study = function() {
        console.log(`${this.name} is studying.`);
    };
    
    const charlie = new Student('Charlie', 16, '10th');
    charlie.sayHello(); // "Hello, I'm Charlie" - 继承自Person
    charlie.study();    // "Charlie is studying." - Student自己的方法
    

    此时,charlie 的原型链是:charlieStudent.prototypePerson.prototypeObject.prototypenull

  • class 语法糖: ES6的 classextends 关键字让基于原型链的继承看起来更像传统的类继承,但本质不变。

    class Animal {
        constructor(name) {
            this.name = name;
        }
        speak() {
            console.log(`${this.name} makes a noise.`);
        }
    }
    
    class Dog extends Animal {
        constructor(name, breed) {
            super(name); // 调用父类构造函数
            this.breed = breed;
        }
        speak() { // 方法重写
            console.log(`${this.name} barks.`);
        }
    }
    
    const dog = new Dog('Rex', 'German Shepherd');
    dog.speak(); // "Rex barks." - Dog.prototype.speak() 覆盖了 Animal.prototype.speak()
    

    dog 的原型链:dogDog.prototypeAnimal.prototypeObject.prototypenull

五、 关键点与注意事项

  1. prototype vs __proto__ vs Object.getPrototypeOf()

    • prototype: 函数的属性,指向其实例的原型对象。
    • __proto__: 对象的非标准属性(尽管广泛支持),用于访问其 [[Prototype]]。应优先使用 Object.getPrototypeOf()Object.setPrototypeOf()
    • Object.getPrototypeOf(obj): 标准方法,获取 obj[[Prototype]]
  2. constructor 属性: 原型对象上的 constructor 默认指向构造函数。当手动修改原型(如 Student.prototype = Object.create(Person.prototype))时,constructor 会被覆盖,需要手动修复。

  3. 性能: 原型链越长,属性查找所需的时间可能越长(尽管现代引擎对此有优化)。尽量将频繁访问的属性放在实例自身或靠近链顶的原型上。

  4. hasOwnProperty 用于判断属性是对象自身的(自有属性)还是从原型链继承来的。

    console.log(alice.hasOwnProperty('name')); // true
    console.log(alice.hasOwnProperty('sayHello')); // false
    console.log(alice.hasOwnProperty('toString')); // false
    
  5. in 操作符: 检查属性是否在对象自身或其原型链上存在。

    console.log('name' in alice); // true (自身)
    console.log('sayHello' in alice); // true (原型)
    console.log('toString' in alice); // true (原型链)
    

总结

原型链是JavaScript实现对象继承和属性共享的精巧机制。它通过 [[Prototype]] 内部槽将对象连接成一条链,使得对象可以访问其自身以及其原型链上所有祖先对象的属性和方法。理解 prototype[[Prototype]]、属性查找规则以及如何构建原型链,是深入掌握JavaScript面向对象编程、理解 class 语法底层原理以及进行高效、可维护代码开发的关键。虽然现代开发中 class 语法更为直观,但其背后的原型链原理始终是JavaScript这门语言强大灵活性的根基。