JavaScript原型链与继承

2,185 阅读10分钟

一、ES5中的继承

1. 原型链

  • 原型链

    • 基本思想:利用原型让一个引用类型继承另一个引用类型的属性和方法

    • 原型对象:每个构造函数在创建时都会有一个prototype属性指向这个函数的原型对象,而原型对象会获得一个constructor属性指向构造函数。当调用构造函数创建实例后,实例都包含一个指向构造函数的原型对象(不是指向构造函数)的内部指针,Firefox、Safiri、Chrome用__proto__表示这个指针。

    • 原型链:如果一个实例的原型对象等于另一个类型的实例,而那个实例的原型对象又等于其他类型的实例,如此层层递进,就构成了实例与原型的链条,这就是所谓的原型链。

      function A() {}
      function B() {}
      B.prototype = new A();
      var c = new B();
      
    • 原型搜索机制:读取一个实例属性时,首先会在事例中搜索该属性。如果没有找到,则会继续搜索实例的原型。在通过原型链实现继承的情况下,搜索过程得以沿着原型链继续向上。在找不到属性或方法的情况下,搜索过程总是要一环一环地前行到原型链末端(Object.prototype)才会停下来。

  • 确定原型与实例之间的关系

    • instanceof:测试实例与构造函数

      c instanceof B     // true
      c instanceof A     // true
      c instanceof Object  // true
      
    • isPrototypeOf():测试实例与原型

      B.prototype.isPrototypeOf(c)    // true
      A.prototype.isPrototypeOf(c)    // true
      Object.prototype.isPrototypeOf(c)   // true
      
  • 谨慎定义方法

    • 子类型给原型添加方法的代码一定要放在替换原型的语句之后。如:
      • 重写超类型中的某个方法;
      • 添加超类型中不存在的某个方法。
    • 在通过原型链实现继承时,不能使用对象字面量创建原型方法。因为这样做会重写原型链。
  • 原型链的问题:

    • 包含引用类型值的原型,所有实例属性会共享该引用类型的属性。
    • 在创建子类型的实例时,没有办法在不影响所有对象实例的情况下 ,向超类型的构造函数中传递参数 。

2. 借用构造函数(伪造对象或经典继承)

function A() {}
function B() {
    A.call(this);
}
var c = new B();
  • 基本思想:
    • 在子类型构造函数的内部调用超类型构造函数
  • 优势:
    • 可以在子类型构造函数中向超类型构造函数传递参数。
      • A.call(this, name, sex, ...)
  • 问题:
    • 方法在构造函数中定义,无法实现函数复用。
    • 在超类型的原型中定义的方法,对子类型而言是不可见的,结果所有类型都只能使用构造函数模式。

3. 组合继承(伪经典继承)

function A() {}
function B() {
    // 继承属性
    A.call(this, name, ...);
}
// 继承方法
B.prototype = new A();
B.prototype.constructor = B;
  • 基本思想:
    • 使用原型链实现对原型属性和方法的继承,通过借用构造函数实现对实例属性的继承
  • 优点:
    • 既实现了函数复用,又保证了每个实例都有自己的属性。
    • 可以使用instanceofisPrototypeOf()识别基于组合继承创建的对象。
    • JavaScript最常用的继承模式。

4. 原型式继承

function object(o) {
    function F() {}
    F.prototype = o;
    return new F();
}
var a = {};
var b = object(a);
  • 基本思想:
    • 借助原型基于已有的对象创建新对象
  • 本质:
    • object()对传入其中的对象执行了一次浅复制。
  • ES5通过新增Object.create()方法规范了原型式继承:
    • 这个方法接收两个参数:一个用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象。
    • 第二个参数定义的属性会覆盖原型对象上的同名属性。
  • 缺点:
    • 和使用原型模式一样,包含引用类型值的属性始终会共享相应的值。

5. 寄生式继承

function A(original) {
    var clone = object(original);
    clone.sayHi = function() {
        alert('Hi');
    };
    return clone;
}

var a = {};
var b = A(a);
  • 基本思想:创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后返回对象
  • 适用场景:主要考虑对象而不是自定义类型和构造函数的情况。

6. 寄生组合式继承

  • 组合继承的不足:无论什么情况下,都会调用两次超类型构造函数,一次是创建子类型原型的时候,另一次是子类型构造函数内部。

  • 寄生组合式继承的基本模式:

    function inherite(subType, superType) {
        var f = object(superType.prototype);  // 创建superType的实例f
        f.constructor = subType;
        subType.prototype = f;
    }
    
    function SuperType() {}
    
    function SubType() {
        SuperType.call(this);
    }
    
    inherite(SubType, SuperType);
    
  • 基本思想:通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。实际上是在组合继承的基础上,用超类型原型的副本代替调用超类型的构造函数给子类型指定原型

  • 本质上,是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。

  • 优点:只调用了一次超类型的构造函数,并且因此避免了在子类型的原型上创建不必要的、多余的属性。与此同时,原型链还能保持不变。因此,还能够正常使用instanceofisPrototypeOf()。普遍认为寄生组合式继承是引用类型最理想的继承方式。

二、new操作符

1. 使用new操作符调用构造函数

  • 创建一个新对象;
  • 将新对象链接到构造函数的原型上;
  • 将构造函数的作用域赋给新对象(绑定this);
  • 执行构造函数中的代码(为这个新对象添加属性);
  • 返回新对象。

2. new操作符的实现

function create() {
    let obj = {};                         // 创建一个新对象
    let Con = [].shift.call(arguments);   // 获取构造函数
    obj.__proto__ = Con.prototype;        // 链接到原型
    let result = Con.apply(obj, arguments);     // 绑定this,执行构造函数
    return typeof result === 'object' ? result : obj;  // 确保new出来的是个对象
}

// 调用
function F() {}
var f = create(F);

3. new操作符的优先级问题

function Foo() {
    return this;
}

Foo.getName = function() {
    console.log(1);
}

Foo.prototype.getName = function() {
    console.log(2);
}
代码 执行结果 执行过程
new Foo
new Foo()
Foo {}
new Foo.getName
new (Foo.getName)
new Foo.getName()
new (Foo.getName)()
1
Foo.getName {}
1. 执行var f = Foo.getName
2. 执行new fnew f()
(new Foo).getName
(new Foo()).getName
new Foo().getName
f() {
console.log(2);
}
1. 执行var f = new Foo()
2. 访问f.getName
(new Foo).getName()
(new Foo()).getName()
new Foo().getName()
2 1. 执行var f = new Foo()
2. 调用f.getName()

优先级:.()相等,大于new操作符的优先级

三、ES6继承

1. Class

  • Class可看作构造函数的另一种写法

    • 类的数据类型是函数,类本身指向构造函数
    • 类的所有方法都定义在类的prototype属性上面
      • 在类的实例上调用方法,其实就是调用原型上的方法
      • 可以通过实例的__proto__属性为类添加方法,但是不推荐使用,会影响到所有实例
    • 类的内部定义的所有方法,都是不可枚举的(与 ES5 行为不一致)
    class Point {
        constructor() {}
        toString() {}
    }
    
    typeof Point    // "function"
    Point === Point.prototype.constructor   // true
    var b = new Point();   // 使用new操作符创建实例
    
    b.constructor === Point.prototype.constructor  // true
    b.constructor === b.__proto__.constructor   // true
    
    Object.keys(Point.prototype)    // []
    Object.getOwnPropertyNames(Point.prototype)   // ["constructor", "toString"]
    
    var Spot = function() {}  // 使用ES5改写Class
    Spot.prototype.toString = function() {}
    
    Object.keys(Spot.prototype)    // ["toString"]
    Object.getOwnPropertyNames(Spot.prototype)    // ["constructor", "toString"]
    
  • 注意点

    • 类和模块的内部,默认是严格模式
    • 类不存在变量提升,声明前使用会报错
    • 类的方法内部如果含有this,它默认指向类的实例
      • 将类的方法提取出来单独使用时,方法内的this会指向该方法运行时所在的环境 => 可以在构造函数中使用bind或箭头函数让this指向实例对象
  • 其他特性

    • 静态方法:使用static修饰,表示该方法不会被实例继承,而是直接通过类来调用。
      • 静态方法中的this指的是类,不是实例。
      • 父类的静态方法,可以被子类继承。
    • 实例属性:可以定义在constructor()方法里的this上面,也可以定义在类的最顶层。
    • new.target:用在构造函数或Class中,返回new命令作用于的那个构造函数或Class
      • 如果构造函数不是通过new命令或Reflect.construct()调用的,new.target会返回undefined
      • 在函数外部使用new.target会报错。

2. Class的继承

  • 使用extends实现继承,子类继承了父类的所有属性和方法。

    • 子类必须在constructor方法中调用super方法,否则新建实例时会报错。
    • 在子类的构造函数中,只有调用super之后,才能使用this关键字。
  • 与ES5继承机制的区别:

    • ES5的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面。

      Parent.apply(this)
      
    • ES6的继承,实质是先通过super()方法将父类实例对象的属性和方法,加到this上面,然后再用子类的构造函数修改this

  • super关键字:既可以当做函数使用,也可以当作对象使用

    • super作为函数调用时,代表父类的构造函数,返回子类的实例。只能用在子类的构造函数中,用在其他地方会报错。

      class A {}
      
      class B extends A {
          constructor() {
              super();   // 相当于 A.prototype.constructor.call(this)
          }
      }
      
    • super作为对象使用时:

      (1) 在子类的普通方法中通过super调用父类的方法时,super指向父类的原型对象

      • 定义在父类实例上的方法或属性,无法通过super调用
      • 父类方法内部的this指向当前的子类实例
      • 通过super对某个属性赋值时,super就是this,赋值的属性会变成子类实例的属性
      • 通过super访问某个属性时,super依旧指向父类的原型对象

      (2) 在子类的静态方法中通过super调用父类的方法时,super指向父类

      • 父类方法内部的this指向当前的子类
    • 使用super的时候,必须显示指定是作为函数还是作为对象使用,否则会报错,如:

      console.log(super);    // 报错
      
    • 由于对象总是继承自其他对象,所以可以在任何一个对象中,使用super关键字

  • Class的prototype属性和__proto__属性

    • 大多数浏览器的ES5实现中,每一个对象都有__proto__属性,指向对应的构造函数的prototype属性。

    • ES6的Class中,同时有prototype属性和__proto__属性,因此同时存在两条继承链:

      • 子类的__proto__属性指向父类
      • 子类prototype属性的__proto__属性指向父类的prototype属性
      class A {}
      
      class B extends A {}
      
      B.__proto__ = A   // true
      B.prototype.__proto__ = A.prototype   // true
      
    • 原因,类的继承是按照下面的模式实现的:

      class A {}
      
      class B {}
      
      // B的实例继承A的实例
      Object.setPrototypeOf(B.prototype, A.prototype);
      // let a = new A();
      // let b = new B();
      // B.prototype === b.__proto__
      // A.prototype === a.__proto__
      
      // B继承A的静态属性
      Object.setPrototypeOf(B, A);
      
      // Object.setPrototypeOf的实现
      Object.setPrototypeOf = function(obj, proto) {
          obj.__proto__ = proto;
          return obj;
      }
      
  • 原生构造函数的继承

    • ES5中,无法继承原生构造函数。原因是ES5先构建子类的实例对象this,再将父类的属性添加到子类上,由于父类的内部属性无法获取,导致无法继承原生的构造函数。
    • ES6允许继承原生构造函数定义子类,因为ES6先构建父类的实例对象this,然后再用子类的构造函数修饰this,使得父类的所有行为都可以继承。

参考:

  1. JavaScript高级程序设计(第三版),第6章。
  2. 前端进阶之道:yuchengkai.cn/docs/fronte…
  3. 【阮一峰】ES6入门:es6.ruanyifeng.com/#docs/class