目录

⛰ JavaScript 原型继承

当已经存在一个 user 对象以及其属性与方法,想要将 adminguest 作为基于 user 稍加修改的变体,在 user 基础之上构建一个新的对象。

JavaScript 的 原型继承(Prototype inheritance) 特性帮助实现这个需求。

# [[Prototype]] 属性

在 JavaScript 中,对象有一个特殊的 隐藏属性 [[Prototype]]

  • 要么为 null

  • 要么就是对另一个对象的引用。该对象被称为「原型」。

当尝试从一个对象 object 中获取一个 缺失的属性 时, JavaScript 会自动从 原型 中获取该属性。这个过程为「原型继承」。

对象的 [[Prototype]] 属性是 在内部的 并且是 隐藏的

🌰 例子:

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

rabbit.__proto__ = animal; 
1
2
3
4
5
6
7
8

当从 rabbit 中读取一个它不存在的属性,JavaScript 会从 animal 中获取:

console.log(rabbit.eats) // true
1

这个例子中的 rabbit__proto__ = animalanimal 设置为 rabbit原型。原来的 rabbit 对象并不存在 eats 属性,JavaScript 会顺着 [[Prototype]] 引用animal 中查找(自下而上)。

这个例子可以称 「 animalrabbit 的原型」 或者 「 rabbit 的原型是从 animal 继承而来的 」。当 animal 中有的属性和方法,都会自动变为在 rabbit 中可以使用(继承属性和方法)。

animal 中有方法:

let animal = {
  eats: true,
  walk() {
    console.log('animal walk')
  }
}  
1
2
3
4
5
6
rabbit.walk() // animal walk
1

# 原型链

🌰 例子:

let animal = {
  eats: true,
  walk() {
    alert("Animal walk");
  }
};

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

let longEar = {
  earLength: 10,
  __proto__: rabbit
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

现在,如果从 longEar 中读取一些它不存在的内容,JavaScript 会先在 rabbit 中查找,然后在 animal 中查找。

但是要遵循以下规则:

  • 引用不能形成闭环。试图在闭环内分配 __prototype__ 会报错。
  • __proto__ 的值可以是 对象,也可以是 null 。而其他的类型都会被忽略。
  • 显然,一个对象只能有一个 [[prototype]]。一个对象不能从其他两个对象中即成。

__proto__[[prototype]] 的访问器( getter / setter )( __proto__[[prototype]] 要区别开):

  • __proto__ 属性有点过时了,它的存在是出于历史的原因。现代编程语言建议应该使用函数 Object.getPrototypeOf / Object.setPrototypeOf 来取代 __proto__ 去 get/set 原型。

# 原型仅用于读取属性

🌰 例子:

let animal = {
  eats: true,
  walk() {
		// ... 
  }
};

let rabbit = {
  __proto__: animal
};

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

rabbit.walk() // Rabbit! Bounce-bounce!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

这个例子中, rabbit 中分配了自己的 walk 方法。

尽管 rabbit 的原型是 animal 。但是因为 rabbit 本身就有 walk 方法,所以可以在对象中找到该方法无需使用原型。

尽管设置了原型在先,但是后来的 添加方法 操作,是在对象子身上进行的,不会再原型上进行(对于删除属性同理)。

可以看出,原型仅用于读取属性。

🌰 例子 / 访问器属性是 例外。因为分配操作是通过 setter 函数处理的,所以写入此属性的操作相当于 读取该属性

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.log(admin.fullName) // John Smith
admin.fullName = "Alice Cooper"

console.log(admin.fullName) // Alice Cooper
console.log(user.fullName) // John Smith
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

admin.fullName = "Alice Cooper" 可以正常运行,因为它的原型对象中有 fullNamesetter 函数。

并且修改的是 admin 原型对象的内容,而不是 原来的对象(被保护)。

# this 的值

this 不受原型的影响:无论在哪里找到该方法(在一个对象或者在原型中),在方法调用时, this 始终指向的是 . 前面的对象(调用的该方法的对象)。

从上面的例子中可以知道,在 setter 被调用时, admin.fullName = '...'admin 调用的方法,所以 this 指向的是 admin

提示

清楚 this 的值。有可能有一个带有很多方法的大对象,并且还有从其继承的对象。当继承的对象运行方法时,它们应该只修改自身的状态而不修改大对象的状态。

🌰 例子 / 有一个方法是存储属性:

let animal = {
  walk() {
    if (!this.isSleeping) {
      alert(`I walk`);
    }
  },
  sleep() {
    this.isSleeping = true;
  }
};

let rabbit = {
  name: "White Rabbit",
  __proto__: animal
};

rabbit.sleep();
console.log(rabbit.isSleeping)  // true
console.log(animal.isSleeping) // undefined
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

原型方法共享,但是对象状态不是。

# 循环

🌰 例子:

let animal = {
  eats: true
};

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

// 对于 Object.keys
console.log(Object.keys(rabbit)) // jumps

// 对于 for...in
for(let prop in rabbit) console.log(prop) // jumps, eats
1
2
3
4
5
6
7
8
9
10
11
12
13
14

可以看出 for ... in 循环会迭代 继承的属性

如果想 排除继承的属性 或者利用它们进行其他操作, 可以使用 obj.hasOwnProperty(key) 判断:如果 obj 具有自己的(非继承的) key 属性则返回 true

for(let prop in rabbit) {
  let isOwn = rabbit.hasOwnProperty(prop)
  
  if(isOwn) {
    console.log(`our: ${prop}`)
  } else {
    console.log(`inherited: ${prop}`)
  }
}
1
2
3
4
5
6
7
8
9

hasOwnProperty 方法是 rabbitObject.prototype 中继承的(该方法是 Object.prototype.hasOwnProperty 提供的)。并且该方法是不可枚举的,所以 for ... in 没有列出。

提示

几乎所有的 其他键 / 值获取方法 都忽略继承的属性(例如 Object.keysObject.values 等)。它们只会对对象 自身 进行操作。不考虑 继承自原型的属性。

# 总结

  • [[prototype]] 是 对象 的隐藏属性,要么是另一个对象,要么为 null 。可以通过 obj.__proto__ 访问。
  • 通过 [[Prototype]] 引用的对象被称为「原型」。
  • 当读取 obj 的一个属性或者调用一个方法,并且它不存在,那么 JavaScript 就会尝试在原型中查找它。
    • 写 / 删除操作直接在 对象自身 上进行,不使用原型。
    • this 的值是 谁调用该方法,就是哪个对象。
  • for ... in 循环时,除了自身的属性,继承的属性也会迭代列出。所有其他的键 / 值获取方法仅对对象本身起作用。

# 实例

# 搜索链

let head = {
  glasses: 1
};

let table = {
  pen: 3
};

let bed = {
  sheet: 1,
  pillow: 2
};

let pockets = {
  money: 2000
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  1. 使用 __proto__ 来分配原型,以使得任何属性的查找都遵循以下路径: pocketsbedtablehead 。例如, pockets.pen 应该是 3 (在 table 中找到), bed.glasses 应该是 1 (在 head 中找到)。
  2. 通过 pockets.glasseshead.glasses 获取 glasses ,哪个更快?必要时需要进行基准测试。
点击查看
  • 在现代引擎中,从性能的角度来看,是从对象还是从原型链获取属性都是没区别的。它们(引擎)会记住在哪里找到的该属性,并在下一次请求中重用它。
  • 对于 pockets.glasses 来说,它们(引擎)会记得在哪里找到的 glasses (在 head 中),这样下次就会 直接在这个位置进行搜索。并且引擎足够聪明,一旦有内容更改,它们就会自动更新内部缓存,因此,该优化是安全的。

# 仓鼠例子

有两只仓鼠: speedylazy 都继承自普通的 hamster 对象。

当我们喂其中一只的时候,另一只也吃饱了。为什么?如何修复它?

let hamster = {
  stomach: [],

  eat(food) {
    this.stomach.push(food);
  }
};

let speedy = {
  __proto__: hamster
};

let lazy = {
  __proto__: hamster
};

speedy.eat("apple");
console.log(speedy.stomach) // apple
console.log(lazy.stomach) // apple
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
点击查看

由于每只仓鼠没有自身的 stomach ,在 push 操作时,只能顺着原型链找到 hamsterstomach

push 操作的是在原型链找到的 stomach 。但是 简单赋值 this.stomach 时不会出现这个种情况:

// ...
	eat(food) {
    this.stomach = [food]
  }
// ...
1
2
3
4
5

因为 this.stomach =。 不会执行对 stomach 的查找。该值会被直接写入 this 对象。

可以通过确保每只仓鼠都有自己的 stomach 解决:

let hamster = {
  stomach: [],

  eat(food) {
    this.stomach.push(food);
  }
};

let speedy = {
  __proto__: hamster,
  stomach: []
};

let lazy = {
  __proto__: hamster,
  stomach: []
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

所有描述特定对象状态的属性,例如上面的 stomach都应该被写入该对象中。这样可以避免此类问题。

📢 上次更新: 2022/09/02, 10:18:16