wython / wython.github.io

个人博客记录, 订阅点击watch
10 stars 0 forks source link

谈js继承 #5

Open wython opened 5 years ago

wython commented 5 years ago

谈js继承

js没有提供面向对象编程的语法特性,也没有类的概念。在es6中可以使用class去定义类和继承,但是实际上却没有面向对象多态的这些特性。其底层依然是基于js的原型链去实现的。

js可以基于Object创建对象(如下),但是Object本质更像是键值对的哈希对象,所以基于Object字面量和简单的方式创建对象往往无法满足我们要求。

let o = new Object();
let p = { name: 'person' };

通过函数方式创建对象

通过函数方式也可以很好的创建对象,而且从代码复用性和封装性都比以上方式好。

function Animal(type, name) {
  this.type = type || 'animal';
  this.name = name || 'anonymity'
  this.sayName = function (){
    console.log(`${this.type}: ${this.name}`);
  }
}

// Animal('person'); 与普通函数无异
// new Animal('person'); 返回对象

通过new操作符,经历里一下步骤

  1. 函数创建一个新对象
  2. 将函数内部上下文赋予新对象
  3. 执行函数代码
  4. 返回新建对象

所以说,通过new方式,我们获得的是这个新创建的对象。因此,可以很方便的创造各种实例,如下:

const person = new Animal('person');
const dog = new Animal('dog');

person.sayName(); // 输出 person: anonymity
dog.sayName(); // 输出 dog: anonymity

但是其实很快就有新的问题,

person.sayName === dog.sayName
// 输出false

我们发现对于某些可以复用的代码,实际上重复执行了,说白了就是,sayName函数实际上只需要存在一份即可,但是实际运行过程中,不同实例都声明了不同的函数。这实际不是一个优雅的方式。所以引出下一个概念,原型链。

原型对象

希望读者先区分构造函数和实例两个不同的名词,首先,如果细心就会发现,对于元生对象如Object, Array之类的(本质依然是function)构造函数,都具有prototype属性。像我们自己定义的Animal函数,也具有一个prototype属性。 这个prototype属性指向的是原型对象的引用。每一个函数在创建过程中,会生成一个原型对象,同时,构造函数本身的prototype属性指向这个原型对象。细心点也会发现,通过new创建的实例person,dog具有proto属性。这是一个已废弃的属性,但是它的存在说明了实例上面也有指向原型对象的引用。不推荐用proto访问原型对象,那有什么办法可以访问实例的原型对象。可以通过Object.getPrototypeOf(person)方式获得实例的原型对象,实例的原型对象和构造函数的原型对象是不是一致的,答案是肯定的。

Object.getPrototypeOf(person) === Animal.prototype
// true

同时,原型对象上有constructor属性是指向构造函数本身的。

Animal.prototype.constructor === Animal
// true

所以可以总结为,当函数被创建时,会有一个prototype属性指向一个生成的原型对象, 并且原型对象具有指回构造函数的引用contructor属性。当通过构造函数创建实例时,实例也拥有指向原型对象的属性(即实例可以追溯原型对象)。

原型对象作用之共用公共属性

在别的语言里可以通过static声明公共的属性,js的原型对象有这样用处。因为实例访问属性的过程中,会遍历自身属性,如果没有会追溯原型链的属性。也就是说,实例可以访问到原型对象上的属性。同时所有实例共享同一个原型对象。

Object.getPrototypeOf(person) === Object.getPrototypeOf(dog);

// true

这也就达到一份代码可以多实例复用的要求。所以构造函数写成:

function Animal(type, name) {
  this.type = type || 'animal';
  this.name = name || 'anonymity'
}
Animal.prototype.sayName = function (){
  console.log(`${this.type}: ${this.name}`);
}
Animal.prototype.publicProper = 'p';

如果希望构造函数看上去封装性更好,可以优化

function Animal(type, name) {
  this.type = type || 'animal';
  this.name = name || 'anonymity'

  if (typeof this.sayName !== 'function') {
    Animal.prototype.sayName = function (){
      console.log(`${this.type}: ${this.name}`);
    }
  }
}

原型对象作用二之继承

讲了这么久,才到真正要讨论的内容,因为js真正属于面向对象特性的还是继承。有以上基础其实可以很简单的实现继承。最简单,最直接一般都是这样做

function Animal(type, name) {
  this.type = type || 'animal';
  this.name = name || 'anonymity'

  if (typeof this.sayName !== 'function') {
    Animal.prototype.sayName = function () {
      console.log(`${this.type}: ${this.name}`);
    }
  }
}

function Person(type, name) {
  this.name = name | 'anonymity';
}

Person.prototype = new Animal('person');

这样最简单的继承就实现了,但是这种继承方式存在缺点。

  1. 因为子类Person的原型是Animal的实例,所Person创建的实例也包含了Animal实例的属性,Person不同实例共用Animal的实例属性。是不是有办法,当Animal的实例属性继承下来是Person的实例属性。

  2. Animal构造函数是有参数的,这种方式因为是公用的,所以我们继承new Animal时候没有传参数name,因为我们默认Animal的name属性是需要覆盖的。明显这样写法灵活性不够。或者说,如果希望type, name属性依然是实例属性,实际上继承Animal构造函数的代码没有复用到。

  3. 子类新原型对象并没有constructor属性指回构造函数,与原生方式不同。

借用父类构造函数

可以通过在子类中调用父类构造函数,并且改变作用域,将实例属性赋值到子类的实例属性上

function Animal(type, name) {
  this.type = type || 'animal';
  this.name = name || 'anonymity'

  if (typeof this.sayName !== 'function') {
    Animal.prototype.sayName = function () {
      console.log(`${this.type}: ${this.name}`);
    }
  }
}

function Person(name) {
  this.job = null;
  Animal.call(this, 'person', name);
}

Person.prototype = new Animal();
Person.prototype.constructor = Person;

const p = new Person('peter');

p.sayName();

// Person peter

这样实现,大体上是已经很完美了, 但是依然有不足,一个是构造函数执行两次,所以原型属性上和实例属性上重复了。为了解决这个问题,我们最直观的想法时就是创建一个空对象并且,然后将原型上的内容复制到空对象上。实际上,我们一个可以创建一个空白构造函数,并且使其原型为父类原型,再将空白对象实例作为之类原型即可。用代码直观点:

function objectCreate(proto) {
  function F(){};
  F.prototype = proto;
  return new F();
}
// 继承代码改写为
function Animal(type, name) {
  this.type = type || 'animal';
  this.name = name || 'anonymity'

  if (typeof this.sayName !== 'function') {
    Animal.prototype.sayName = function () {
      console.log(`${this.type}: ${this.name}`);
    }
  }
}

function Person(name) {
  this.job = null;
  Animal.call(this, 'person', name);
}

Person.prototype = objectCreate(Animal.prototype);
Person.prototype.constructor = Person;

const p = new Person('peter');
p.sayName();
// Person peter

当然es5中已经提供了object.create()方法。

到此,继承的所有讨论就结束了,在es6,和ts已经这么普及的今天,讨论这一个老生常谈的问题,有必要吗?我觉得还是有必要的,第一,我觉得对于这样问题,并不是所有人都真正的理解其中的含义。第二,我觉得只要浏览器没有完全支持新语法,那么意味着我们最终跑得前端代码依然是很原始的代码,在这个角度去理解这些代码是有益的。最后,我个人也觉得,对于继承这个话题,在红皮书上有各种各样的定义,很多人太拘泥于它的命名,反而忽视了一些内容背后解决的问题,这些命名其实是从英文在通过翻译者翻译,并不是真正需要去理解的东西。