JavaScript中单继承

64 阅读9分钟

ES5 继承

ES5中没有类的概念,通常通过声明一个构造函数来模拟类

function Person(name) {
  this.name = name;
  this.sayName1 = function () {
    console.log(this.name + '在工作');
  }
}

// 原型链上的方法和属性会被多个实例共享,构造函数中的则不会
Person.prototype.sayName2 = function () {
  console.log(this.name + '在学习');
}
// 静态方法
Person.sayName3 = () => {
    console.log(this.name + '在运动')
}

var person = new Person('Tom');

person.sayName1(); // Tom在工作
person.sayName2(); // Tom在学习

原型方法和实例方法的区别:

  • 写在原型中的方法可以被所有的实例共享, 实例化的时候不会在实例内存中再复制一份,占有的内存消耗少。
  • js中每个函数都有一个prototype属性,这个属性指向一个对象(所有属性的集合:默认constructor属性,值指向这个函数本身。)
  • 每个原型对象都属于对象,所以它也有自己的原型,而它自己的原型对象又有自己的原型,所以就形成了原型链。
  • 一个对象的隐式原型指向构造这个对象的构造函数的显式原型,所以这个对象可以访问构造函数的属性和方法。(new一个实例)
  • js的继承也就是通过原型链来实现的,当访问一个对象的属性,如果这个对象本身不存在,则沿着proto依次往上查找,如果有则返回值,没有则一直到查到Object.prototype的proto的值为null.

继承

ES5实现继承的方式有原型链继承,构造继承,实例继承,拷贝继承,组合继承,寄生组合继承这六种

原型链继承

  • 优点
    • 实例是子类的实例,也是父类的实例
    • 可以调用父类的实例属性和方法,也可以调用父类原型链上的属性和方法
  • 缺点
    • 子类无法在构造器中新增属性或者方法,必须要在new Person()之后
    • 在子类实例化时,无法向父类传参
    • 父类原型对象的所有属性被所有实例共享
// 原型链继承
function Cat() { }
Cat.prototype = new Person('Pt'); // 只能在这里向父类传参,或者下面代码那样

var cat = new Cat();

cat.sayName1();

构造继承

  • 优点
    • 解决了原型链继承中,子类实例共享父类引用属性的问题
    • 在创建子类实例时,可以向父类传参
  • 缺点
    • 实例并不是父类的实例,只是子类的实例
    • 只能继承父类构造函数中的属性和方法,不能继承父类原型链中的属性和方法
    • 无法实现函数的复用,每个子类都有父类实例函数的副本,影响性能
function Cat(name) {
  Person.call(this, name);
}

var cat = new Cat('Tom');

cat.sayName1();

实例继承

  • 缺点
    • 无法实现多继承
    • 子类实例化出来的对象是父类类型,不是子类类型
// 实例继承
function Cat(name) {
  var instance = new Person(name);
  return instance;
}

var cat = new Cat('Tom');

cat.sayName1()

组合继承

  • 优点
    • 弥补了构造继承的缺陷,可以继承实例的属性/方法,也可以继承原型上的属性/方法
    • 既是子类的实例,也是父类的实例
    • 可以向父类传参
    • 函数可以复用
  • 缺点
    • 调用了两次父类的构造函数,生成了两份实例
function Cat(name, age) {
  Person.call(this, name);
  this.age = age;
}

Cat.prototype = new Person();



var cat = new Cat('Tom', 18);

console.log(cat.name);

cat.sayName1();
cat.sayName2();

console.log(cat.age);

寄生组合继承

function Cat(name) {
  Person.call(this, name);
}

var Temp = Object.create(Person.prototype); // 创建对象,创建父类原型的一个副本
Temp.constructor = Cat;  // 增强对象,弥补因重写原型而失去的默认的constructor 属性
Cat.prototype = Temp; // 指定对象,将新创建的对象赋值给子类的原型

var cat = new Cat('Tom');

cat.sayName1()

new的时候都做了什么?

  • 创建一个新对象
  • 把这个新对象的__proto__属性指向你要new 的那个对象的prototype
  • 让构造函数里面的this指向新的对象,然后执行构造函数
  • 返回这个新对象
function _new(constructor, ...args) {
    const obj = Object.create(constructor.prototype);
    const result = constructor.call(obj, ...args);
    
    return result instanceof Object ? result : obj;
}

ES6 Class

ES6提供了更接近传统的写法,基本上可以看作只是一个语法糖,它的绝大部分功能,ES5都可以做到

class Person {
    constructor(name) {
        this.name = name;
    }
    sayName() {
        console.log(this.name + '在学习')
    }
}

const p = new Person('jiacheng')

构造函数上的prototype属性,在ES6类上面继续存在,事实上,类的所有方法都是定义在类的prototype属性上面的

class Point {
  constructor() {
    // ...
  }

  toString() {
    // ...
  }

  toValue() {
    // ...
  }
}

// 等同于
Point.prototype = {
  constructor() {},
  toString() {},
  toValue() {},
};

因此,在类的实例上面调用方法,其实就是调用原型上的方法

class B{}

const b = new B();

b.constructor === B.prototype.constructor // true

所以,使用Object.assin()方法可以很方便地一次向类添加多个方法

class Point {
  constructor(){
    // ...
  }
}

Object.assign(Point.prototype, {
  toString(){},
  toValue(){}
});

prototype对象的constructor属性,直接指向类本身,这与ES5的行为是一致的

Point.prototype.constructor === Point // true

另外,类内部所有定义的方法,都是不可枚举的

class Point {
  constructor(x, y) {
    // ...
  }

  toString() {
    // ...
  }
}

Object.keys(Point.prototype)
// []
Object.getOwnPropertyNames(Point.prototype)
// ["constructor","toString"]

constructor

constructor方法是类的默认方法,通过new命令生成对象实例时,自动调用该方法,一个类即使没有显示定义constructor,也会被默认添加一个空的constructor

class Person{}

// 相当于
class Person {
    constructor(){}
}

constructor默认返回实例对象,完全可以指定返回另一个对象

class Person {
    constructor() {
        return Object.create(null)
    }
}

new Person instanceof Person // false

类必须使用new调用,否则会直接报错

class Person {
}

Person()
// TypeError: Class constructor Foo cannot be invoked without 'new'

类的实例

直接通过new命令生成一个类的实例,与ES5不同的是,直接调用类会报错

class Point {
  // ...
}

// 报错
var point = Point(2, 3);

// 正确
var point = new Point(2, 3);

与 ES5 一样,实例的属性除非显式定义在其本身(即定义在this对象上),否则都是定义在原型上(即定义在class上)

//定义类
class Point {

  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return '(' + this.x + ', ' + this.y + ')';
  }

}

var point = new Point(2, 3);

point.toString() // (2, 3)

point.hasOwnProperty('x') // true
point.hasOwnProperty('y') // true
point.hasOwnProperty('toString') // false
point.__proto__.hasOwnProperty('toString') // true

类的所有实例共享一个原型对象

var p1 = new Point(2,3);
var p2 = new Point(3,2);

//true
p1.__proto__ === p2.__proto__

这也意味着,可以通过实例的__proto__属性为“类”添加方法。

__proto__ 并不是语言本身的特性,这是各大厂商具体实现时添加的私有属性,虽然目前很多现代浏览器的 JS 引擎中都提供了这个私有属性,但依旧不建议在生产中使用该属性,避免对环境产生依赖。生产环境中,我们可以使用 Object.getPrototypeOf 方法来获取实例对象的原型,然后再来为原型添加方法/属性。

var p1 = new Point(2,3);
var p2 = new Point(3,2);

p1.__proto__.printName = function () { return 'Oops' };

p1.printName() // "Oops"
p2.printName() // "Oops"

var p3 = new Point(4,2);
p3.printName() // "Oops"

上面代码在p1的原型上添加了一个printName()方法,由于p1的原型就是p2的原型,因此p2也可以调用这个方法。而且,此后新建的实例p3也可以调用这个方法。这意味着,使用实例的__proto__属性改写原型,必须相当谨慎,不推荐使用,因为这会改变“类”的原始定义,影响到所有实例。

getter和setter

与 ES5 一样,在“类”的内部可以使用getset关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为。

class MyClass {
  constructor() {
    // ...
  }
  get prop() {
    return 'getter';
  }
  set prop(value) {
    console.log('setter: '+value);
  }
}

let inst = new MyClass();

inst.prop = 123;
// setter: 123

inst.prop
// 'getter'

存值函数和取值函数是设置在属性的 Descriptor 对象上的。

class CustomHTMLElement {
  constructor(element) {
    this.element = element;
  }

  get html() {
    return this.element.innerHTML;
  }

  set html(value) {
    this.element.innerHTML = value;
  }
}

var descriptor = Object.getOwnPropertyDescriptor(
  CustomHTMLElement.prototype, "html"
);

"get" in descriptor  // true
"set" in descriptor  // true

注意点

  • 严格模式
    类和模块的内部,默认就是严格模式,不需要使用'use strict'指定运行模式
  • 不存在提升
    类不存在变量提升
  • Generator方法
    如果某个方法前加上*,就表示该方法是一个Generator函数
class Foo {
  constructor(...args) {
    this.args = args;
  }
  * [Symbol.iterator]() {
    for (let arg of this.args) {
      yield arg;
    }
  }
}

for (let x of new Foo('hello', 'world')) {
  console.log(x);
}
// hello
// world

上面代码中,Foo类的Symbol.iterator方法前有一个星号,表示该方法是一个 Generator 函数。Symbol.iterator方法返回一个Foo类的默认遍历器,for...of循环会自动调用这个遍历器。

  • this的指向
    类方法内部如果含有this,它默认指向类的实例,但该方法无法单独使用
class Logger {
  printName(name = 'there') {
    this.print(`Hello ${name}`);
  }

  print(text) {
    console.log(text);
  }
}

const logger = new Logger();
const { printName } = logger;
printName(); // TypeError: Cannot read property 'print' of undefined

上面代码中,因为printName方法中的this,默认指向Logger类的实例,但如果将这个方法单独提取出来使用,this会指向该方法运行时所在的环境,而由于class内部是严格模式,所以这时候this直接指向undefined,导致报错
解决方法:

    • 在构造方法中绑定this,这样就不会找不到print方法了。
class Logger {
  constructor() {
    this.printName = this.printName.bind(this);
  }

  printName(name = 'there') {
    this.print(`Hello ${name}`);
  }

  print(text) {
    console.log(text);
  }
}
    • 使用箭头函数
      箭头函数内部的this总是指向定义时所在的对象。上面代码中,箭头函数位于构造函数内部,它的定义生效的时候,是在构造函数执行的时候。这时,箭头函数所在的运行环境,肯定是实例对象,所以this会总是指向实例对象
class Person {
  printName = () => {
    this.print()
  }

  print() {
    console.log('111');
  }
}

var p = new Person();

var { printName } = p;

printName()

静态方法

类相当于实例的原型,所有在类中定义的方法,都会被实例继承,如果在一个方法前面加上static关键字,就表示该方法不会被实例继承,而是通过类来调用,这被称为静态方法

class Foo {
  static classMethod() {
    return 'hello';
  }
}

Foo.classMethod() // 'hello'

var foo = new Foo();
foo.classMethod()
// TypeError: foo.classMethod is not a function
  • 如果静态方法中包含this关键字,这个this指向的是类,而不是实例
class Foo {
  static bar() {
    this.baz();
  }
  static baz() {
    console.log('hello');
  }
  baz() {
    console.log('world');
  }
}

Foo.bar() // hello
  • 父类的静态方法,可以被子类继承
  • 静态方法也可以从super对象上调用
class Foo {
  static classMethod() {
    return 'hello';
  }
}

class Bar extends Foo {
  static classMethod() {
    return super.classMethod() + ', too';
  }
}

Bar.classMethod() // "hello, too"

静态属性

静态属性是指class本身的属性,而不是定义在实例对象上的属性

class Foo {
    
}

Foo.prop = 1;

// 或者
class Foo{
    static name = 'jiacheng'
}

Foo.name; // jiacheng

继承

class通过extends关键字来实现继承

class Person{}

class ColorPoint extends Person {}
  • super
    super关键字表示父类的构造函数,用来新建父类的this对象
    子类必须在constructor中调用super方法,否则会报错
class ColorPoint extends Point {
  constructor(x, y, color) {
    super(x, y); // 调用父类的constructor(x, y)
    this.color = color;
  }

  toString() {
    return this.color + ' ' + super.toString(); // 调用父类的toString()
  }
}

ES6继承和ES5继承的区别

  • ES5里的构造函数是一个普通函数,可以使用new调用,也可以直接调用,且存在变量提升。ES6的class必须使用new操作符调用,且不存在变量提升
  • ES5子类的原型是指向Function.prototype,而ES6子类的原型是指向父类的
  • ES5的原型方法和静态方法是可枚举的,而class的默认不可枚举,但可以使用Object.getOwnPropertyNames方法获取
  • ES5的继承,实质是先创造一个子类实例对象的this,然后再执行父类构造函数给它添加实例方法和属性(不执行也无所谓);ES6的继承机制则相反,先将父类的属性和方法,加到一个空对象上,然后再将该对象作为子类的实例,即"继承在前,实例在后"