JavaScript继承机制学习笔记

48 阅读10分钟

JavaScript继承机制学习笔记

一、继承的基本概念与原理

在面向对象编程中,继承是类之间共享属性和方法的核心机制,它允许我们基于已有对象创建新对象,从而提高代码复用性 。JavaScript作为基于原型的语言,其继承机制与传统类继承有所不同,但通过巧妙的设计,同样实现了面向对象的继承特性。

JavaScript的继承本质是原型链继承,即通过将子类的原型对象指向父类的实例,从而形成一条链式结构。当访问对象的属性时,JavaScript引擎会先在对象自身查找,若未找到则沿着原型链向上查找,直到找到匹配属性或到达链的末端(即原型为null的对象) 。这种机制使得子类实例可以共享父类原型上的方法,同时拥有自己的实例属性。

理解原型链继承的关键在于掌握两个核心概念:[[Prototype]]prototype。[[Prototype]]是对象的内部属性,指向其原型对象;而只有函数才有prototype属性,当函数作为构造函数使用时,新创建的对象会将该函数的prototype作为自己的[[Prototype]] 。通过Object.getPrototypeOf()和Object.setPrototypeOf()可以安全地操作原型链,避免使用非标准的__proto__属性。

二、原型链继承的实现方式

原型链继承是最基本的JavaScript继承方式,实现起来相对简单。其核心思想是将子类的原型对象设置为父类的实例,从而形成原型链。

1. 基础实现

// 父类
function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype物种 = '动物';
Animal.prototype进食 = function() {
    console.log('正在进食');
};

// 子类
function Cat(name, age, color) {
    this.name = name;
    this.age = age;
    this.color = color;
}
Cat.prototype = new Animal(); // 原型链继承

// 修正构造函数指向
Cat.prototype.constructor = Cat;

const cat1 = new Cat('加菲猫', 2, '黄色');
console.log(cat1.物种); // 输出:动物
cat1.进食(); // 输出:正在进食

这种方式实现了子类Cat对父类Animal的继承,Cat的实例可以访问Animal原型上的物种属性和进食方法。但原型链继承存在两个主要问题

  • 无法向父类构造函数传参:父类Animal的构造函数在创建Cat的原型时被调用,但无法传递特定参数,所有Cat实例共享同一条原型链。
  • 引用类型属性共享问题:如果父类原型上有引用类型属性(如数组),所有子类实例会共享该属性,修改其中一个实例会影响其他实例。

2. 原型链继承的优化

为解决引用类型属性共享的问题,可以使用**Object.create()**方法创建子类的原型对象:

function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype物种 = '动物';

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

// 使用Object.create()优化原型链
Cat.prototype = Object.create(Animal.prototype);

// 修正构造函数指向
Cat.prototype.constructor = Cat;

const cat1 = new Cat('加菲猫', 2, '黄色');
console.log(cat1.物种); // 输出:动物

**Object.create()**的第二个参数可用于定义新对象的属性,实现更灵活的继承控制。例如:

// 创建一个继承自Animal.prototype的新对象,并添加额外属性
Cat.prototype = Object.create(Animal.prototype, {
    constructor: {
        value: Cat,
        enumeration: false,
        writable: true,
        configurable: true
    }
});

这种方式避免了父类构造函数的重复调用,同时保留了原型链继承的优点。

三、构造函数继承的实现

构造函数继承通过**call()apply()**方法在子类构造函数中调用父类构造函数,从而复制父类的实例属性 。这种方式解决了原型链继承无法向父类传参的问题。

1. 基础实现

function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype物种 = '动物';

function Cat(name, age, color) {
    // 在子类构造函数中调用父类构造函数
    Animal.call(this, name, age);
    this.color = color;
}

Cat.prototype = new Animal(); // 原型链继承
Cat.prototype constructor = Cat; // 修正构造函数指向

const cat1 = new Cat('加菲猫', 2, '黄色');
console.log(cat1.物种); // 输出:动物

2. 使用apply()传递参数

function Cat(name, age, color) {
    // 使用apply()传递参数数组
    Animal.apply(this, [name, age]);
    this.color = color;
}

构造函数继承的优势在于可以向父类构造函数传递参数,使父类属性在子类实例中独立存在,避免了引用类型属性共享的问题。但其主要缺点是:

  • 无法共享父类方法:所有方法都需要在子类构造函数中重新定义,导致代码重复 。
  • 构造函数模式问题:必须在构造函数中定义方法,无法实现方法的重用。

四、组合继承:构造函数继承与原型链继承的结合

组合继承结合了构造函数继承和原型链继承的优点,是JavaScript中最常用的继承方式 。它通过在子类构造函数中调用父类构造函数(复制实例属性),并通过原型链继承父类方法(实现方法共享)。

1. 基础实现

function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype物种 = '动物';

function Cat(name, age, color) {
    // 复制实例属性
    Animal.call(this, name, age);
    this.color = color;
}

// 继承原型方法
Cat.prototype = new Animal();

// 修正构造函数指向
Cat.prototype constructor = Cat;

const cat1 = new Cat('加菲猫', 2, '黄色');
console.log(cat1.物种); // 输出:动物

2. 组合继承的缺点

组合继承虽然解决了参数传递和方法共享的问题,但仍存在两个主要缺点:

  • 父类构造函数被调用两次:一次在子类构造函数中(通过call/apply),一次在设置子类原型时(通过new Animal()) 。
  • 属性遮蔽问题:父类原型上的属性和子类实例上的同名属性可能产生冲突,删除实例属性后会暴露原型属性 。

例如:

function Father(name) {
    this.name = name;
    this sayName = function() {
        alert(this.name);
    };
}

function Son(name, age) {
    Father.call(this, name);
    this.age = age;
}
Son.prototype = new Father();

var son1 = new Son("Patrick", 20);
son1 sayName(); // 输出Patrick
delete son1.name;
son1 sayName(); // 输出Adam(原型上的name属性)

五、寄生组合继承:优化的组合继承方式

为解决组合继承的父类构造函数被调用两次的问题,可以使用寄生组合继承,它通过Object.create()直接继承父类的原型对象,避免了父类构造函数的重复调用。

1. 基础实现

function inheritPrototype(Child, Parent) {
    // 创建父类原型的副本
    var F = function() {};
    F.prototype = Parent.prototype;
    // 继承父类原型
    Child.prototype = new F();
    // 修正构造函数指向
    Child.prototype constructor = Child;
}

// 使用
inheritPrototype(Cat, Animal);

const cat1 = new Cat('加菲猫', 2, '黄色');
console.log(cat1.物种); // 输出:动物

2. 优化实现

function inheritPrototype(Child, Parent) {
    // 直接继承父类原型
    Child.prototype = Object.create(Parent.prototype);
    // 修正构造函数指向
    Child.prototype constructor = Child;
}

// 使用
inheritPrototype(Cat, Animal);

const cat1 = new Cat('加菲猫', 2, '黄色');
console.log(cat1.物种); // 输出:动物

寄生组合继承的优势在于:

  • 避免父类构造函数的重复调用:仅在子类实例化时调用一次父类构造函数。
  • 保持原型链结构:子类原型正确指向父类原型,确保方法共享。
  • 属性隔离:父类实例属性在子类中独立存在,不会共享引用值。

六、ES6 class语法糖与super关键字

ES6引入了class语法糖super关键字,使得JavaScript的面向对象编程更加直观和易于理解 。这些特性本质上是基于JavaScript原型链的语法封装。

1. class语法基础

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

    greet() {
        console.log(`你好,我叫${this.name},今年${this.age}岁。`);
    }
}

// 实例化
const person1 = new Person('张三', 30);
person1.greet(); // 输出:你好,我叫张三,今年30岁。

2. 继承实现

class Student extends Person {
    constructor(name, age, major) {
        // 必须先调用super()才能使用this
        super(name, age);
        this.major = major;
    }

    study() {
        console.log(`正在学习${this.major}。`);
    }

    // 重写父类方法
    greet() {
        console.log(`你好,我是学生${this.name},今年${this.age}岁,主修${this重大}。`);
        // 调用父类方法
        super.greet();
    }
}

// 实例化
const student1 = new Student('李四', 20, '计算机');
student1.greet(); // 输出:自定义的问候
student1 study(); // 输出:正在学习计算机

3. super关键字详解

**super()**用于在子类构造函数中调用父类构造函数,必须作为构造函数的第一条语句执行,否则会抛出错误 :

class Child extends Parent {
    constructor() {
        // 必须首先调用super()
        super();
        // 然后才能使用this
        this property = value;
    }
}

**super.method()**用于在子类方法中调用父类原型上的方法:

class Child extends Parent {
    method() {
        // 调用父类方法
        super.method();
        // 添加子类自己的逻辑
    }
}

4. Babel编译后的底层实现

Babel将ES6的class语法编译为基于原型的ES5代码:

// Babel编译后的代码
var Parent = function Parent() {
    _CallCheck(this, Parent);
};

var Child = function() {
    _CallCheck(this, Child);

    // 调用父类构造函数
    _CallCheck(Parent, this);

    // 继承父类实例属性
    Parent.apply(this, arguments);

    // 子类自己的初始化
    this property = value;
};

// 设置原型链
 Inherit(Child, Parent);

function _CallCheck(target, thisArg, argumentsList) {
    if (thisArg === void 0) {
        thisArg = this;
    }
    if (target === null) {
        throw new TypeError("调用目标不能为null");
    }
    if (typeof target !== "function") {
        throw new TypeError("预期为函数");
    }
    return target.apply(thisArg, argumentsList);
}

function _Inherit(subClass, superClass) {
    if (typeof superClass !== "function" &&中学 !== null) {
        throw new TypeError("Super expression must either be null or a function");
    }

    subClass.prototype = Object.create超级Class &&中学.prototype, {
        constructor: {
            value: subClass,
            enumeration: false,
            writable: true,
            configurable: true
        }
    });

    // 设置子类构造函数的原型指向父类
    if (superClass) {
        Object.setPrototypeOf ? Object.setPrototypeOf(subClass,中学) : subClass.__proto__ =中学;
    }
}

ES6类继承的底层原理

  • 子类的[[Prototype]]指向父类:通过Object.setPrototypeOf(Child, Parent)实现,确保子类可以访问父类的静态方法和属性 。
  • 子类原型的[[Prototype]]指向父类原型:通过Object.create(Parent.prototype)实现,确保子类实例可以访问父类原型的方法 。

七、继承方式的对比与选择

以下是JavaScript中几种主要继承方式的对比:

继承方式参数传递方法共享引用属性共享代码复杂度适用场景
原型链继承不支持支持存在简单继承,无需参数传递
构造函数继承支持不支持不存在需要参数传递,不共享方法
组合继承支持支持不存在最常用,平衡参数传递和方法共享
寄生组合继承支持支持不存在中高避免父构造函数重复调用
ES6类继承支持支持不存在现代JavaScript开发,推荐使用

在实际开发中,建议优先使用ES6的class语法糖,因为它提供了更直观的继承语法,同时避免了组合继承的父构造函数重复调用问题。在需要兼容旧环境的情况下,可以使用寄生组合继承作为替代方案。

八、继承的常见问题与解决方案

1. 构造函数指向问题

当修改子类原型时,子类的prototype constructor属性会指向父类,导致实例的constructor属性不正确。解决方案是手动修正:

Cat.prototype = new Animal();
Cat.prototype constructor = Cat; // 修正构造函数指向

2. 引用类型属性共享问题

如果父类原型上有引用类型属性(如数组),所有子类实例会共享该属性。解决方案是将引用类型属性放在构造函数中:

function Animal(name, age) {
    this.name = name;
    this.age = age;
    // 将引用类型属性放在构造函数中
    this arr = [];
}
Animal.prototype物种 = '动物';

3. 静态方法继承

ES6类的静态方法不会自动继承,需要手动复制:

class Parent {
    static staticMethod() {
        console.log('静态方法');
    }
}

class Child extends Parent {
    // 手动继承静态方法
    static staticMethod = Parent staticMethod;
}

4. 构造函数继承的参数传递

使用call/apply方法时,需要确保参数正确传递:

function Child(...args) {
    // 使用剩余参数语法传递所有参数
    Parent.apply(this, args);
    // 子类自己的初始化
    this property = value;
}

九、总结与实践建议

JavaScript的继承机制虽然复杂,但通过理解原型链、构造函数继承和组合继承等概念,我们可以灵活运用各种继承方式。ES6的class语法糖提供了最简洁和推荐的继承方式 ,但在需要兼容旧环境的情况下,寄生组合继承是一个很好的替代方案。

在实际开发中,建议遵循以下实践:

  1. 优先使用ES6的class语法:它提供了更直观的继承语法,同时避免了组合继承的父构造函数重复调用问题。
  2. 理解super关键字的作用:它不仅用于调用父类构造函数,还可以用于访问父类原型上的方法。
  3. 避免修改内置原型:直接修改Object.prototype会影响所有对象,破坏封装性。
  4. 合理使用继承:继承应该用于"is-a"关系,而不是简单的代码复用。

通过深入理解JavaScript的继承机制,我们可以更好地利用面向对象编程的特性,构建更模块化、可维护的JavaScript应用。