原型链与类的继承

原型链与类的继承

Prototype Chaining | 原型链

原型 与继承是 ECMAScript 5.1 中明晰的,彼时 JavaScript 不包含传统的类继承模型,而是使用 prototypal 原型模型。继承方面,JavaScript 中的每个对象都有一个内部私有的链接指向另一个对象,这个对象就是原对象的原型。这个原型对象也有自己的原型,直到对象的原型为 null 为止(也就是没有原型)。这种一级一级的链结构就称为原型链。通俗来理解,一个对象 A 的__proto__属性所指向的那个对象 B 就是它的原型对象(或者叫上级对象、父对象),对象 A 可以使用对象 B 中定义的属性和方法,同时也可以使用对象 B 的原型对象 C 的属性与方法,以此递归,这也就是所谓的原型链。JavaScript 中任何对象在原型领域有两个属性,__proto__prototype,需要明晰的是,js 中的继承是指对__proto__的继承,而不是 prototype。它们二者的区分如下:

const b = new Foo(20);
const c = new Foo(30);

prototype 是一个 Function 的属性,在使用 new 运算符从构造函数中生成新的对象时,会自动将新对象的__proto__指向 Function.prototype。

proto is the actual object that is used in the lookup chain to resolve methods, etc. prototypeis the object that is used to build proto when you create an object with new:

(new Foo().__proto__ === Foo.prototype(new Foo()).prototype) === undefined;

更直观的表述方式是,在 JavaScript 中,仅有 Function 有内置的 prototype 属性,而 Object 没有。而 JavaScript 的引用类型大部分是 Object。

继承属性

JavaScript 对象有两种不同的属性,一种是对象自身的属性,另外一种是继承于原型链上的属性。下面的代码则演示了当访问一个对象的属性时发生的行为:

// 假定我们有个对象o,并且o所在的原型链如下:
// {a:1, b:2} ---> {b:3, c:4} ---> null
// 'a'和'b'是o自身的属性.

// 该例中,用"对象.[[Prototype]]"来表示这个对象的原型.
// 这只是一个纯粹的符号表示(ECMAScript标准中也这样使用),不能在实际代码中使用.

console.log(o.a); // 1
// a是o的自身属性吗?是的,该属性的值为1

console.log(o.b); // 2
// b是o的自身属性吗?是的,该属性的值为2
// o.[[Prototype]]上还有一个'b'属性,但是它不会被访问到.这种情况称为"属性遮蔽".

console.log(o.c); // 4
// c是o的自身属性吗?不是,那看看o.[[Prototype]]上有没有.
// c是o.[[Prototype]]的自身属性吗?是的,该属性的值为4
console.log(o.d); // undefined
// d是o的自身属性吗?不是,那看看o.[[Prototype]]上有没有.
// d是o.[[Prototype]]的自身属性吗?不是,那看看o.[[Prototype]].[[Prototype]]上有没有.
// o.[[Prototype]].[[Prototype]]为null,原型链已到顶端,没有d属性,返回undefined

继承方法

JavaScript 并没有真正的方法,JavaScript 只有函数,而且任何函数都可以添加到对象上作为对象的属性。继承的函数与其他的属性是基本没有差别的,包括 “ 属性遮蔽 ”(这种情况相当于其他语言的方法重写)。当继承的函数被调用时,this 指向的是当前继承原型的对象,而不是继承的函数所在的原型对象。

const o = {
  a: 2,
  m: function () {
    return this.a + 1;
  },
};

console.log(o.m()); // 3
// 当调用 o.m 时,'this'指向了o.

const p = Object.create(o);
// p是一个对象, p.[[Prototype]]是o.

p.a = 12; // 创建p的自身属性a.
console.log(p.m()); // 13
// 调用p.m时, 'this'指向 p. 'this.a'则是12.

在原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要。另外,试图访问不存在的属性时会遍历整个原型链。遍历对象的属性时,原型链上的每个属性都是可枚举的。检测对象的属性是定义在自身上还是在原型链上,有必要使用 hasOwnProperty 方法,该方法由所有对象继承自 Object.proptotypehasOwnProperty 是 JavaScript 中唯一一个只涉及对象自身属性而不会遍历原型链的方法。

Prototype Operation(原型操作)

class A {
  say() {
    console.log("It's A#say.");
  }
}
class B extends A {
  say() {
    console.log("It's B#say.");
  }
  run() {
    this.say();
    super.say();
  }
}

const b = new B();
b.run.call({
  say() {
    console.log("call");
  },
});

console.log("===");

const obj = {
  say() {
    console.log("It's obj.say.");
  },
  run() {
    this.say();
    super.say();
  },
};

Object.setPrototypeOf(obj, {
  say() {
    console.log("It's proto.say.");
  },
});

obj.run();
class A {}
class B extends A {
  constructor() {
    return {};
  }
}

console.log(new B());
// 删去 return {},则会报异常 ReferenceError: this is not defined

super(…); is basically sugar for this = new ParentConstructor(…);. Where ParentConstructor is the extended class, and this = is the initialisation of the this keyword (well, given that that’s forbidden syntax, there’s a bit more than sugar to it). And actually it will inherit from the proper new.target.prototype instead of ParentConstructor.prototype like it would from new. So no, how it works under the hood does not compare to ES5 at all, this is really a new feature in ES6 classes (and finally enables us to properly subclass builtins).

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

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

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

const getName = function () {
  console.log(4);
};

function getName() {
  console.log(5);
}

// 2
Foo.getName();

// 4
getName();

// 1
Foo().getName();

// 1
getName();

// 2
new Foo.getName();

// 3
new Foo().getName();

// 3
new new Foo().getName();
class A {
  say() {
    console.log("It's A#say.");
  }
}
class B extends A {
  say() {
    console.log("It's B#say.");
  }
  run() {
    this.say();
    super.say();
  }
}

const b = new B();
b.run.call({
  say() {
    console.log("call");
  },
});

console.log("===");

const obj = {
  say() {
    console.log("It's obj.say.");
  },
  run() {
    this.say();
    super.say();
  },
};

Object.setPrototypeOf(obj, {
  say() {
    console.log("It's proto.say.");
  },
});

obj.run();
class A {}
class B extends A {
  constructor() {
    return {};
  }
}

console.log(new B());

// 删去 return {},则会报异常 ReferenceError: this is not defined

super(…); is basically sugar for this = new ParentConstructor(…);. Where ParentConstructor is the extended class, and this = is the initialisation of the this keyword (well, given that that’s forbidden syntax, there’s a bit more than sugar to it). And actually it will inherit from the proper new.target.prototype instead of ParentConstructor.prototype like it would from new. So no, how it works under the hood does not compare to ES5 at all, this is really a new feature in ES6 classes (and finally enables us to properly subclass builtins).

继承方式

原型链继承

function Person(name, age) {
  this.name = name;
  this.age = age;
}
Person.prototype.say = function () {
  console.log("hello, my name is " + this.name);
};
function Man() {}
Man.prototype = new Person("pursue");
const man1 = new Man();
man1.say(); //hello, my name is pursue
const man2 = new Man();
console.log(man1.say === man2.say); //true
console.log(man1.name === man2.name); //true

这种继承方式很直接,为了获取 Person 的所有属性方法 ( 实例上的和原型上的 ),直接将父类的实例 new Person(‘pursue’) 赋给了子类的原型,其实子类的实例 man1,man2 本身是一个完全空的对象,所有的属性和方法都得去原型链上去找,因而找到的属性方法都是同一个。所以直接利用原型链继承是不现实的。

利用构造函数继承

function Person(name, age) {
  this.name = name;
  this.age = age;
}
Person.prototype.say = function () {
  console.log("hello, my name is " + this.name);
};
function Man(name, age) {
  Person.apply(this, arguments);
}
//Man.prototype = new Person('pursue');
const man1 = new Man("joe");
const man2 = new Man("david");
console.log(man1.name === man2.name); //false
man1.say(); //say is not a function

这里子类的在构造函数里利用了 apply 去调用父类的构造函数,从而达到继承父类属性的效果,比直接利用原型链要好的多,至少每个实例都有自己那一份资源,但是这种办法只能继承父类的实例属性,因而找不到 say 方法,为了继承父类所有的属性和方法,则就要修改原型链,从而引入了组合继承方式。

组合继承

function Person(name, age) {
  this.name = name;
  this.age = age;
}
Person.prototype.say = function () {
  console.log("hello, my name is " + this.name);
};
function Man(name, age) {
  Person.apply(this, arguments);
}
Man.prototype = new Person();
const man1 = new Man("joe");
const man2 = new Man("david");
console.log(man1.name === man2.name); //false
console.log(man1.say === man2.say); //true
man1.say(); //hello, my name is joe

需要注意的是 man1 和 man2 的实例属性其实是覆盖了原型属性,但是并没要覆盖掉原型上的 say 方法(因为它们没有),所以这里 man1.say === man2.say 依然返回 true,因而需要十分小心没有覆盖掉的原型属性,因为它是所有实例共有的。

寄生组合继承

function Person(name, age) {
  this.name = name;
  this.age = age;
}
Person.prototype.say = function () {
  console.log("hello, my name is " + this.name);
};
function Man(name, age) {
  Person.apply(this, arguments);
}
Man.prototype = Object.create(Person.prototype); //a.
Man.prototype.constructor = Man; //b.
const man1 = new Man("pursue");
const man2 = new Man("joe");
console.log(man1.say == man2.say); //true
console.log(man1.name == man2.name); //false

其实寄生组合继承和上面的组合继承区别仅在于构造子类原型对象的方式上(a. 和 b.),这里用到了 Object.creat(obj) 方法,该方法会对传入的 obj 对象进行浅拷贝,类似于:

function create(obj) {
  function T() {}
  T.prototype = obj;
  return new T();
}

因此,a. 会将子类的原型对象与父类的原型对象进行很好的连接,而并不像一般的组合继承那样直接对子类的原型进行复制(如 Man.prototype = new Person();), 这样只是很暴力的在对属性进行覆盖。而寄生组合继承方式则对实例属性和原型属性分别进行了继承,在实现上更加合理。注意 : 代码 b. 并不会改变 instanceof 的结果,但是对于需要用到 construcor 的场景,这么做更加严谨。

function A(a) {
  this.varA = a;
}

// 以上函数 A 的定义中,既然 A.prototype.varA 总是会被 this.varA 遮蔽,
// 那么将 varA 加入到原型(prototype)中的目的是什么?
A.prototype = {
  varA: null, // 既然它没有任何作用,干嘛不将 varA 从原型(prototype)去掉?
  // 也许作为一种在隐藏类中优化分配空间的考虑?
  // https://developers.google.com/speed/articles/optimizing-javascript#Initializing instance variables
  // 将会验证如果 varA 在每个实例不被特别初始化会是什么情况。
  doSomething: function () {
    // ...
  },
};

function B(a, b) {
  A.call(this, a);
  this.varB = b;
}
B.prototype = Object.create(A.prototype, {
  varB: {
    value: null,
    enumerable: true,
    configurable: true,
    writable: true,
  },
  doSomething: {
    value: function () {
      // override
      A.prototype.doSomething.apply(this, arguments); // call super
      // ...
    },
    enumerable: true,
    configurable: true,
    writable: true,
  },
});
B.prototype.constructor = B;

const b = new B();
b.doSomething();

类继承技巧

属性丢失现象

在 ES6 中,我们常常采用实体类方式来表示数据对象:

class Base {
  constructor(data = {}) {
    Object.assign(this, data);
  }
}

class A extends Base {
  a = null;

  constructor(data = {}) {
    super(data);
  }
}

new A({ a: 1 }); // { a: null }

但是这种方式会发现,传入的参数值是在父类中被赋值到当前实例,而又被子类的默认值给覆盖了。在 TypeScript 中,我们可以通过自定义 props 的方式来解决:

class Base {
  constructor(data = {}) {
    this._defineProps(data);
  }

  _defineProps(props) {
    Object.entries(props).forEach(([prop, value]) =>
      Object.defineProperty(this, prop, {
        get() {
          return value;
        },
        set(newValue) {
          if (newValue !== undefined) {
            value = newValue;
          }
        },
      })
    );
  }
}

class A extends Base {
  a = 2;

  constructor(data = {}) {
    super(data);
  }
}

console.log(new A({ a: 1 }));
上一页