JavaScript海底两万里:原型(链)

争取一文帮自己搞清楚JavaScript中的原型(链)。


Java选手们很容易看出来,如果存在三个类UserAdminGuest,这三个类之间可以存在继承关系,像这样:

flowchart BT
Admin -->|"[[Prototype]]"| User
Guest -->|"[[Prototype]]"| User

在JavaScript中,原型继承可以用来实现对象继承的能力。

[[Prototype]]

在JavaScript中,对象有一个隐藏属性[[Prototype]],称为“原型”,它要么是null,要么是另一个对象的引用

当我们从对象中读取一个缺失的属性,JavaScript会从原型中获取该属性,这称为“原型继承”,我们后面细看。

所谓隐藏属性,指[[Prototype]]是对象内部的,而且是隐藏的,可以说相当于Java中的private字段。

但就如同Java中可以设置getter/setter方法一样,JavaScript中也存在设置[[Prototype]]的方式,不过不同于Java中通过extend关键字来实现继承,一种方法是使用__proto__

1
2
3
4
5
6
7
8
9
let animal = {
eats: true
};

let rabbit = {
jump: true
};

rabbit.__proto__ = animal; // 此时rabbit的原型被设置为animal

现在,如果执行console.log(rabbit.eats),JavaScript引擎会顺着[[Prototype]]引用,从原型链中寻找eats属性。

不止访问属性,当访问对象中不存在的函数,JavaScript引擎也会从原型中寻找这个函数。

如果存在这样一条原型链:

flowchart BT
ShortHair -->|"[[Prototype]]"| Cat -->|"[[Prototype]]"| Animal

当我们从ShortHair中读取一些它不存在的内容,那么JavaScript会先从cat中查找,然后在animal中查找。

原型链中存在两个限制:

  1. 引用不能形成闭环,如果在闭环中分配__proto__,JavaScript会报错。
  2. __proto__的**值只能是对象或null**,其他的类型都会被忽略。

与Java相同,JavaScript也是单继承的。

但是事实上__proto__有点过时了,它是[[Prototype]]getter/setter,现代JavaScript建议使用Object.getPrototypeOf/Object.setPrototypeOf来取代__proto__,这两个函数后面再说。

在这里需要了解一下**getter/setter,它们也属于对象的属性,称为访问器属性,本质上是一类用于获取和设置值的函数**,但看起来就像常规属性。

1
2
3
4
5
6
7
8
let obj = {
get propName() {
// ...
},
set propName() {
// ...
}
};

于是,在读取obj.propName时,getter起作用;在设置obj.propName时,setter起作用。

比如这样一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
let user = {
name: '',
surname: '',
get fullName() {
return `${this.name} ${this.surname}`;
},
set fullName(name) {
[this.name, this.surname] = name.split(' ');
}
};

user.fullName = 'John Smith';
console.log(user.fullName); // John Smith

这样我们就设置了一个“虚拟”属性fullName,可以对其进行读写操作。


属性具有三个属性标志

  • writable:如果为true,则值可以被修改,否则为只读。
  • enumerable:如果为true,会被在循环中列出。
  • configurable:如果为true,则此属性可以被删除。

而相较于普通的属性标志,访问器属性没有valuewritable标志,这很好理解,getset的存在就是读取与设置value,同时set是否存在也就决定了一个属性是否writable

F.prototype

上一篇中我们可以知道,通过new F()的语法可以用构造函数创建一个新对象,而对于构造函数F,如果为它设置属性F.prototype的值是一个对象,那么当执行let obj = new F();时,new操作符会使用F.prototype为新对象obj设置[[Prototype]]

1
2
3
4
5
6
7
8
9
10
11
12
13
let animal = {
eats: true;
};

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

Cat.prototype = aniamal;

let cat = new Cat('Tom');

console.log(cat.eats); // true

上面的代码中,Cat.prototype = aniamal;的意思是当new Cat(...)时,把新对象的[[Prototype]]赋值为animal

注意F.prototypeF的一个名为"prototype"的属性,这是每个函数都有的属性,而不是代表F的原型,并且F.prototype的变化对已经创建出来的对象时没有作用的。

默认情况下,F.prototype是一个只有属性构造器的对象,构造器指向函数自身。

原生的原型

"prototype"属性在JavaScript的底层有广泛的应用,所有的内建函数都用到了它。

不愧叫JavaScript,在原型这方面,它的思想跟JavaObject类也有异曲同工之妙:

1
2
let obj = {};
alert(obj); // [object Object]

我们创建了一个空对象obj,但还是可以看到成功输出了obj的信息,那么很明显是内建的toString函数运行的结果,但是它在哪里呢?

注意,如果用console.log()输出obj,那么在命令行只会看到{}表示空对象,只有通过alert()才能在浏览器中看到[object Object]弹窗。

默认情况下,toString()方法被每个Object对象继承。如果此方法在自定义对象中未被覆盖,toString()返回 "[object type]“,其中type是对象的类型。

所以,[object Object]就表示Object对象。

我们知道let obj = {};let obj = new Object();是一样的,而new Object()是一个构造函数的调用,Object的prototype指向一个包含一堆方法的对象,当然也包括了toString(),这与Java中Object作为所有类的父类的理念有所雷同,所以,当new Object()被调用或{}被创建,这个对象的[[Prototype]]就会被设置为Object.prototype,而toString()方法正是从中获取的。其他的内建对象像Array、Date等等也都在prototype上挂载了方法。

按照规范,所有的内建原型顶端都是Object.prototype,所以才会有“一切都是从对象继承而来”的说法。

对于基本类型,同样与Java很像,JavaScript提供了临时包装器在试图访问它们的属性时通过内建构造器String()Number()Boolean()创建,使开发者能够操作字符串、数字、布尔值,“临时”意味着这些包装器在操作结束后就会被垃圾回收器回收。

原生的原型可以被修改,但是非常不建议这么做,因为会导致严重的冲突问题,除非需要在久JavaScript环境中实现新标准的方法,这被称为polyfilling。

原型方法

之前说过,通过obj.__proto__设置/读取原型已经是过时的方法了,现代JavaScript中推荐通过以下方式来实现:

  • Object.getPrototypeOf(obj) —— 返回对象obj的原型
  • Object.setPrototypeOf(obj, proto) —— 将对象obj的原型设置为proto

此外,通过Object.create(proto, [descriptors])可以利用规定的proto作为原型以及可选的描述属性来创建一个新对象。这个方法非常强大,因为可以为对象添加额外的属性:

1
2
3
4
5
6
7
8
9
10
11
let animal = {
eats: true
};

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

alert(rabbit.jumps); // true

还可以实现一种浅拷贝:

1
2
3
4
let clone = Object.create(
Object.getPrototypeOf(obj),
Object.getOwnPropertyDescriptors(obj)
);

使用 Object.create(null){__proto__: null} 还可以创建无原型的对象,这些对象被用作字典,以存储任意(可能是用户生成的)键。

通常,对象会从 Object.prototype 继承内建的方法和 __proto__ getter/setter,会占用相应的键,且可能会导致副作用。原型为 null 时,对象才真正是空的。


JavaScript海底两万里:原型(链)
https://skycurtain.github.io/2022/09/03/javascript-drowning-in-prototype/
作者
Skycurtain
发布于
2022年9月3日
许可协议