JS原理 - 构造函数、原型与继承详解

构造函数、原型的基础知识和继承的实现方式

原型

每一个对象(null 除外)都有其原型对象,对象以原型对象为模板,从原型继承方法和属性。

原型链

原型对象也可能拥有原型,并从中继承方法和属性,一层一层以此类推,这种关系就被称为原型链(下图蓝色线条)。

当查找某个属性或者方法时,会通过__proto__属性,一级一级向上查找,直到原型链上的所有__proto__都被查找完了,才返回undefined

小贴士:因为__proto__不是规范中规定的,是浏览器实现的,所以尽可能不通过__proto__去获取原型,可以通过Object.getPrototypeOf()去获取,

属性

__proto__constructor属性是对象所独有的,而prototype属性是函数独有的,函数作为一种特殊的对象,所以也会拥有__proto__constructor属性。

这里需要注意一点的是,修改原型属性并不会影响构造函数的属性,实例会通过__proto__逐级向上查找原型,而构造函数的__proto__依次指向Function.prototypeObject.prototypenull,没有经过构造函数的原型。举个例子:

function foo(){};
foo.prototype.friend = 'Nick';
console.log(foo); // ƒ foo(){}
console.log(foo.friend); // undefined
console.log(foo.prototype.friend); // Nick

原型属性

每个函数都有原型属性(prototype),下面以函数 foo 来举例:

function foo(){};
console.log(foo.prototype);

// output
{
    constructor: ƒ foo(), // 指向构造函数
    __proto__: { // Function的原型对象
        constructor: ƒ Object(),
        hasOwnProperty: ƒ hasOwnProperty(), // A.hasOwnProperty(B) 判断A是否含有自有属性B
        isPrototypeOf: ƒ isPrototypeOf(), // A.isPrototypeOf(B) 判断A是否存在于B的原型链上
        propertyIsEnumerable: ƒ propertyIsEnumerable(),
        toLocaleString: ƒ toLocaleString(),
        toString: ƒ toString(),
        valueOf: ƒ valueOf()
    }
}

当添加属性到函数的原型上时:

function foo(){};
foo.prototype.friend = 'Nike';
console.log(foo.prototype);

// output
{
    friend: 'Nike',
    constructor: ƒ foo(),
    __proto__: {
        constructor: ƒ Object(),
        hasOwnProperty: ƒ hasOwnProperty(),
        isPrototypeOf: ƒ isPrototypeOf(),
        propertyIsEnumerable: ƒ propertyIsEnumerable(),
        toLocaleString: ƒ toLocaleString(),
        toString: ƒ toString(),
        valueOf: ƒ valueOf()
    }
}

自有属性

在函数内部定义的属性叫做自有属性:

function f() {
  this.a = 1;
  this.b = 2; // 这里的 a 和 b 就是自有属性
}

自有属性和原型属性有什么区别?

  1. 在构建函数中,自有属性优先级会高于原型的属性,即存在相同名称时,自有属性会覆盖原型属性;

  2. 在实例中修改引用类型的自有属性 A 时不会导致其他实例的属性 A 发生变动,因为这些属性 this 指向了 new 运算符创建出的不同的对象;

    但是引用类型的原型属性 B 在修改时,继承于同一父类的实例内的属性 B 都会发生更改;

    所以一般都在构造函数内定义属性,在 prototype 内定义方法。

构造函数

构造函数就是 new 关键字创建实例时调用的函数,是生成实例的模板,下述例子中 foo 就是构造函数:

function foo(friendName){
    // 自有属性
    this.friend = friendName;
}
// 原型属性
foo.prototype.color = "red"; 
foo.prototype.eat = function(){
    console.log('eatting')
}

new 运算符做了什么?

构造函数会经历以下 4 个步骤:

  1. 创建一个对象:let newObj = {};
  2. 将构造函数的原型赋值给新对象:Object.setPrototypeOf( newObject , foo.prototype);
  3. 更改构造函数 this 指向新对象,然后执行构造函数的代码:foo.call( newObj );
  4. 返回新对象;

注意点

new 运算符会继承构造函数的自有属性和原型属性,但是不会继承静态属性(通过原型链查找属性值),举个例子:

function foo(friendName){
    // 自有属性
    this.friend = friendName;
}

foo.extra = 'hello';
foo.prototype.sex = 'female';

const Foo = new foo("Nick");

console.log(Foo.friend); // output: Nick
console.log(Foo.extra); // output: undefined
console.log(Foo.sex); // output: female

继承

原型链继承

原型链继承的方法就是重写原型,将子构造函数的 prototype 指向父构造函数的实例:

function Parent () {
    this.friend = 'Nick';
    this.getFriend = function () {
        console.log(this.friend);
    }
}

function Child () {}

Child.prototype = new Parent(); // 重写原型,会影响后续创建的实例

const child1 = new Child();

console.log(child1.friend); // 'Nick'

这里将 prototype 指向new Parent()创建出的实例,而非 Parent.prototype ,是为了继承 Parent 的自有属性 friend 和 getFriend。

缺点:

  • 父类的引用类型的自有属性会被所有实例共享

    这是因为在修改 child1 的 friend 属性时,因为其构造函数 Child 内没有 friend 属性,所以 child1 也没有 friend 属性,于是沿着原型链(__proto__)往上寻找 friend 属性,__proto__ 指向的是 Parent 创造出的实例对象,并在这里找到了自有属性 friend,由于 friend 属于引用类型,所以一旦修改,指向该内存地址的所有涉及到的 friend 的值都会被修改。

    function Parent () {
        this.friend = ['kevin'];
        this.getFriend = function () {
            console.log(this.friend);
        }
    }
    
    function Child () {}
    
    Child.prototype = new Parent(); 
    
    const child1 = new Child();
    
    child1.friend.push('mike'); 
    
    console.log(child1.friend); // ['kevin','mike']
    
    const child2 = new Child();
    
    console.log(child2.friend); // ['kevin','mike']
    

    这里还有一个问题,引用类型会被所有实例共享,那么基础类型是否会被共享?

    答案是不能被共享,因为访问原型中的基本类型时,访问到的其实是他的映射副本,对于基本类型的值的修改只有在当前实例中生效,举个例子:

    function Parent () {
        this.friend = 'kevin';
        this.getFriend = function () {
            console.log(this.friend);
        }
    }
    
    function Child () {}
    
    Child.prototype = new Parent(); 
    
    const child1 = new Child();
    
    child1.friend = 'molly'; 
    
    console.log(child1.friend); // molly
    
    const child2 = new Child();
    
    console.log(child2.friend); // kevin
    
  • 创建实例的时候无法向父类型(Parent)传参

  • 无法实现多继承

借用构造函数继承

借用构造函数继承就是在子类的构造函数内部调用父类的构造函数,这样一来父类型构造函数的内容就赋值给了子类型的构造函数。

这种继承方式,避免了引用类型的自有属性被所有实例共享,且可以通过 Child 向 Parent 传参。

function Parent () {
    this.friend = ['kevin', 'mike']; // 要实现非全局共享,这里的引用类型对象需要是新创建的
}

function Child () {
    Parent.call(this);
}

const child1 = new Child();

child1.friend.push('yayu');

console.log(child1.friend); // ["kevin", "mike", "yayu"]

const child2 = new Child();

console.log(child2.friend); // ["kevin", "mike"]

缺点:

  • 部分继承,只能继承父类的自有属性,不能继承父类原型的属性和方法

组合式继承

将原型链继承和借用构造函数继承相结合,在子类里面调用父类的构造函数,继承其自有属性,避免自有属性的全局共享;然后通过改写原型对象,让子类和父类的原型对象保持一致,这样子类就能获取到原型链上的属性和方法。

function Parent (number) {
    this.friend = ['kevin', 'mike'];
    this.sayNumber = function(){
        console.log(number)
    }
}

function Child (number) {
    Parent.call(this,number); // 先借用构造函数继承,复制父类型构造函数的内容
}

Child.prototype = Parent.prototype; // 后重写原型
Child.prototype.constructor = Child; // 修复构造函数指向

const child1 = new Child(2);

child1.friend.push('yayu');

console.log(child1.friend); // ["kevin", "mike", "yayu"]

child1.sayNumber(); // 2

const child2 = new Child(3);

console.log(child2.friend); // ["kevin", "mike"]

寄生式组合继承

组合式继承直接修改了原型的指向Child.prototype = Parent.prototype;,此时如果在子类的原型上追加方法,会影响到父类的原型。而寄生式组合继承需要用到Object.create(),作用是:创建一个新对象,使用现有的对象来提供新创建的对象的 __proto__,也就是原型对象,需要注意的是,这是浅拷贝。

function Parent (number) {
    this.friend = ['kevin', 'mike'];
    this.sayNumber = function(){
        console.log(number)
    }
}

function Child (number) {
    Parent.call(this,number);
}

Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

Child.prototype.test = '123';
console.log( Parent.prototype );  // 找不到test

Class 继承

ES6 加入的 class 属性提供了简单的继承方式:extends

class Animal {
  constructor(name) {
    super();
    this.name = name;
  }

  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name); // 调用父类的构造函数
  }

  speak() {
    console.log(`${this.name} barks.`);
  }

  oldSpeak() {
    super.speak(); // 调用父类的方法
  }
}

let d = new Dog('Mitzie')
d.speak() // Mitzie barks.
d.oldSpeak() // Mitzie makes a noise.

ES5 和 ES6 继承方式的区别在于:ES5 是先创造子类的实例对象,然后再将父类的方法和属性添加到这个实例对象上;ES6 则是先创建父类的实例对象(在 constructor 内需要先调用 super()),然后再使用子类的构造函数修改实例对象。

JS原理 - 构造函数、原型与继承详解

https://hashencode.github.io/post/de0024ee/

作者

BiteByte

发布于

2020-08-08

更新于

2024-02-24

许可协议