原型继承

原型继承

特殊属性 [[Prototype]]

此属性只能有两个值:

  • null
  • 另一个对象引用,称为原型

当使用被调用对象中不存在的属性或者方法时,会自动从原型上进行查找,此外,原型链支持长链,对象A可以是对象B的原型,B对象可以是对象C的原型,以此类推。

但是,原型对象只能有一个,一个对象不允许存在多个原型对象。(理解为单继承)

[[Prototype]]是内部隐藏的属性,但可以通过__proto__进行显式设置。

外部进行调用设置:

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal; // 设置 rabbit.[[Prototype]] = animal

或者,创建对象时进行显式指定:

let animal = {
  eats: true,
  walk() {
    console.info("Animal walk");
  }
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

let longEar = {
  earLength: 10,
  __proto__: rabbit
};

// walk 是通过原型链获得的
longEar.walk(); // Animal walk
console.info(longEar.jumps); // true(从 rabbit)

原型链设置的限制:

  1. 禁止形成闭环引用
  2. __proto__只能是对象或者null,其余类型都会忽略

__proto__[[Prototype]]

__proto__[[Prototype]]getter/setter

但是通过__proto__来设置原型对象属于过时的,但为保持兼容性依旧保留,

现代编程推荐使用:

// 获取原型
Object.getPrototypeOf
// 设置原型
Object.setPrototypeOf

示例

let obj = { name: 'John' };

// 使用 __proto__ 获取原型
console.log(obj.__proto__); // [Object: null prototype] {}

// 使用 __proto__ 设置原型
let prototype = { age: 30 };
obj.__proto__ = prototype;

console.log(obj.age); // 30

// 现代方法获取原型
console.log(Object.getPrototypeOf(obj)); // { age: 30 }

// 现代方法设置原型
Object.setPrototypeOf(obj, { city: 'New York' });
console.log(obj.city); // New York

在规范要求中,__proto__仅受浏览器环境支持,但实际所有JavaScript环境都支持。

原型仅用于读取

对于所有的写入/删除操作是直接在对象上进行,

即使是原型已有的属性,也只会在当前对象进行操作。

let animal = {
  eats: true,
  walk() {
    /* rabbit 不会使用此方法 */
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.walk = function() {
  console.info("Rabbit! Bounce-bounce!");
};

rabbit.walk(); // Rabbit! Bounce-bounce!

此外,使用for in也能够迭代出继承的属性。

let animal = {
  eats: true
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

// Object.keys 只返回自己的 key
console.info(Object.keys(rabbit)); // jumps

// for..in 会遍历自己以及继承的键
for(let prop in rabbit) console.info(prop); // jumps,然后是 eats

但依旧可以通过obj.hasOwnProperty(key)判断出哪些是继承属性,哪些是自有属性:

let animal = {
  eats: true
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

for(let prop in rabbit) {
  let isOwn = rabbit.hasOwnProperty(prop);

  if (isOwn) {
    console.info(`Our: ${prop}`); // Our: jumps
  } else {
    console.info(`Inherited: ${prop}`); // Inherited: eats
  }
}

rabbit.hasOwnProperty(prop)从何而来?

继承自Object,所有对象都是继承自Object,但属于不可迭代属性,不会进行展示。

访问器属性 this

访问器属性的赋值是由setter函数处理的,本质与调用函数一致。

因此this可以动态的进行计算,能够准确的获取当前被读写的对象。

无论在哪里找到方法:在一个对象还是在原型中。在一个方法调用中,this 始终是点符号 . 前面的对象。

let user = {
  name: "John",
  surname: "Smith",

  set fullName(value) {
    [this.name, this.surname] = value.split(" ");
  },

  get fullName() {
    return `${this.name} ${this.surname}`;
  }
};

let admin = {
  __proto__: user,
  isAdmin: true
};

console.info(admin.fullName); // John Smith (*)

// setter triggers!
admin.fullName = "Alice Cooper"; // (**)

console.info(admin.fullName); // Alice Cooper,admin 的内容被修改了
console.info(user.fullName);  // John Smith,user 的内容被保护了

F.prototype

prototype

这里的prototype并非内建属性,在以前尚不存在对原型进行直接访问的方式,

因此手动添加一个名为prototype的属性。

如果F.prototype是一个对象,那么new操作符会使用它为新对象设置[[Prototype]]

let animal = {
  eats: true
};

function Rabbit(name) {
  this.name = name;
}

// “当创建了一个 new Rabbit 时,
// 把它的 [[Prototype]] 赋值为 animal”。
Rabbit.prototype = animal;

let rabbit = new Rabbit("White Rabbit"); //  rabbit.__proto__ == animal

console.info( rabbit.eats ); // true

要注意:

此时rabbit实例是继承自animal,但Rabbit对象并不是。
如果将prototype属性更改为其他对象,那么后续创建的新实例将以新对象为原型。
已有的实例原型不会改变。

默认的prototype与构造器属性

每个函数都有"prototype"属性,即使我们没有提供它

function Rabbit() {}

/* 默认的 prototype
Rabbit.prototype = { constructor: Rabbit };
*/

console.info( Rabbit.prototype.constructor == Rabbit ); // true

同样,对于生成的对象实例:

function Rabbit() {}

let rabbit = new Rabbit(); // 继承自 {constructor: Rabbit}

console.info(rabbit.constructor == Rabbit); // true (from prototype)

因此可以通过已创建的实例来创建一个新实例

这种方法非常适合创建一个来自第三方库,但不清楚其具体的构造器时,采取此方法很方便。

function Rabbit(name) {
  this.name = name;
  console.info(name);
}

let rabbit = new Rabbit("White Rabbit");

let rabbit2 = new rabbit.constructor("Black Rabbit");

构造器是可修改的,甚至覆盖

function Rabbit() {}
Rabbit.prototype = {
  jumps: true
};

let rabbit = new Rabbit();
console.info(rabbit.constructor === Rabbit); // false

因此建议选择添加/删除属性到默认"prototype",而不是将其整个覆盖:

function Rabbit() {}

Rabbit.prototype.jumps = true

或者类似于__proto__的显式指定

Rabbit.prototype = {
  jumps: true,
  constructor: Rabbit
};

原生的原型

Object.prototype

let obj = {};

// 等同于
let obj = new Object();

Object是一个内建的构造函数,其自身的prototype指向一个包含其他方法的巨大对象。
因此普通的obj自创建就已有很多内建方法。

一切的对象都是继承自ObjectObjectprototype上方无原型,指向null

let obj = {};

console.info(obj.__proto__ === Object.prototype); // true

console.info(obj.toString === obj.__proto__.toString); //true
console.info(obj.toString === Object.prototype.toString); //true

其余内建原型

除了Object,对于ArrayDateFunction等都在prototype上挂载了属于自己的方法。
但是所有内建对象的原型顶端都是Object.prototype。因此一切都是继承自对象。

let arr = [1, 2, 3];

// 它继承自 Array.prototype?
console.info( arr.__proto__ === Array.prototype ); // true

// 接下来继承自 Object.prototype?
console.info( arr.__proto__.__proto__ === Object.prototype ); // true

// 原型链的顶端为 null。
console.info( arr.__proto__.__proto__.__proto__ ); // null

对于一些重叠的方法,依据最近原则,调用在原型链上距离最近的方法进行运行。

基本数据类型

对于数字、字符串以及布尔值情况下,由于并非是对象,当访问他们的属性时,其实调用的是相应的构造器。
这些构造器提供了相应的方法进行调用,使用完后消失。
可以通过String.prototypeNumber.prototypeBoolean.prototype进行获取。

nullundefined无对象包装器,因此不存在方法和属性,也不存在相应的原型。

原型可修改

原型并非只是只读属性,允许进行手动添加

String.prototype.show = function() {
  console.info(this);
};

"BOOM!".show(); // BOOM!

但通常不建议如此使用,有可能会造成命名冲突,导致功能函数被覆盖。
不过在需要的情况下可以通过polyfill进行填充处理。

if (!String.prototype.repeat) { // 如果这儿没有这个方法
  // 那就在 prototype 中添加它

  String.prototype.repeat = function(n) {
    // 重复传入的字符串 n 次

    // 实际上,实现代码比这个要复杂一些(完整的方法可以在规范中找到)
    // 但即使是不够完美的 polyfill 也常常被认为是足够好的
    return new Array(n + 1).join(this);
  };
}

console.info( "La".repeat(3) ); // LaLaLa

方法借用

前文提过,可以借助其他对象的内建方法在另一个类型对象上使用,只要满足其所需要的规则。

let obj = {
  0: "Hello",
  1: "world!",
  length: 2,
};

obj.join = Array.prototype.join;

console.info( obj.join(',') ); // Hello,world!

因为内建的方法join的内部算法只关心正确的索引和length属性。它不会检查这个对象是否是真正的数组。

原型方法,无原型对象

指定原型创建对象

方法签名:

// 利用给定的 proto 作为 [[Prototype]] 和可选的属性描述来创建一个空对象。
Object.create(proto, [descriptors])

示例:

let animal = {
  eats: true
};

// 创建一个以 animal 为原型的新对象
let rabbit = Object.create(animal); // 与 {__proto__: animal} 相同

console.info(rabbit.eats); // true

console.info(Object.getPrototypeOf(rabbit) === animal); // true

Object.setPrototypeOf(rabbit, {}); // 将 rabbit 的原型修改为 {}

也可以在创建对象时提供额外的属性:

let rabbit = Object.create(animal, {
  jumps: {
    value: true
  }
});

全新的对象克隆方式:

let clone = Object.create(
  Object.getPrototypeOf(obj),
  Object.getOwnPropertyDescriptors(obj)
);

真正准确地拷贝,包括所有的属性:可枚举和不可枚举的,数据属性和 setters/getters,

包括所有内容,并带有正确的 [[Prototype]]

原型简史

为什么 __proto__ 被部分认可并允许在 {...} 中使用,但仍不能用作 getter/setter?

__proto__ 是一个历史遗留的属性,用于访问和修改对象的 [[Prototype]]。尽管它在实际开发中被广泛支持,但它并不是标准的一部分。2022年,官方允许在对象字面量 {...} 中使用 __proto__,但不允许用作 getter/setter(即 obj.__proto__)。这是因为:

  1. 历史兼容性__proto__ 已经在几乎所有环境中实现,完全移除它会导致大量现有代码无法运行。
  2. 规范限制:标准方法 Object.getPrototypeOfObject.setPrototypeOf 更为规范和安全,因此推荐使用这些方法来操作原型。
  3. 性能问题:直接修改 __proto__ 会导致性能下降,因为它会破坏对象属性访问的内部优化。

纯空对象

在 JavaScript 中,对象可以作为关联数组使用,但存在一个潜在问题:__proto__ 是一个特殊的属性,可能会导致意外行为。为了避免这些问题,可以使用 Object.create(null) 创建一个没有原型的“纯空对象”:

let obj = Object.create(null);

let key = "__proto__";
obj[key] = "some value";

console.info(obj[key]); // "some value"

如果不是纯空对象,则会显示为 [object Object]

这种对象被称为“very plain”或“pure dictionary”对象,因为它们比普通对象更简单,没有继承任何方法。缺点是它们没有内建的 toString 等方法,但可以通过 Object.keys 等标准方法操作。

修改已存在的对象的 [[Prototype]] 存在性能问题

从技术上讲,我们可以在任何时候修改对象的 [[Prototype]],但通常只在创建对象时设置一次。JavaScript 引擎对原型链的修改进行了高度优化,因此“即时”更改原型(如使用 Object.setPrototypeOfobj.__proto__=)是一个非常缓慢的操作。它会破坏对象属性访问的内部优化,导致性能下降。因此,除非必要,否则应避免修改已存在的对象的 [[Prototype]]