JavaScript对象的原型与原型链

一. 认识原型

1.1. 认识对象的原型

JavaScript 当中每个对象都有一个特殊的内置属性 [[prototype]],这个特殊的对象可以指向另外一个对象。

那么这个对象有什么用呢?

  • 当我们通过引用对象的属性 key 来获取一个 value 时,它会触发 [[Get]]的操作;

  • 这个操作会首先检查该属性是否有对应的属性,如果有的话就使用它;

  • 如果对象中没有改属性,那么会访问对象[[prototype]]内置属性指向的对象上的属性;

  • 这个 [[prototype]] 我们通常会将其称之为隐式原型;

那么如果通过字面量直接创建一个对象,这个对象也会有这样的属性吗?如果有,应该如何获取这个属性呢?

  • 答案是有的,只要是对象都会有这样的一个内置属性;

  • 获取的方式有两种:

  • 方式一:通过对象的 __proto__ 属性可以获取到(但是这个是早期浏览器自己添加的,存在一定的兼容性问题);

  • 方式二:通过 Object.getPrototypeOf 方法可以获取到;

1
2
3
4
5
6
const obj = {};
// 方式一: __proto__(有浏览器兼容问题)
console.log(obj.__proto__);

// 方式二: Object.getPrototypeOf
console.log(Object.getPrototypeOf(obj));

那么我们就可以进行如下的测试了:

1
2
3
4
5
6
7
8
9
10
11
12
// 定义一个obj对象
const obj = {};
// 直接给对象添加address属性
// obj.address = "北京市"

// 直接给隐式原型上添加address属性
// 给__proto__上添加address属性
obj.__proto__.address = "广州市";

// 通过Object.setPrototypeOf来设置隐式原型
Object.setPrototypeOf(obj, { address: "上海市", name: "setPrototypeOf" });
console.log(obj.address);

1.2. 认识函数的原型

1.2.1. 函数的 prototype

那么我们知道上面的东西对于我们的构造函数创建对象来说有什么用呢?

  • 它的意义是非常重大的,接下来我们继续来探讨;

这里我们又要引入一个新的概念:所有的函数都有一个 prototype 的属性:

1
2
3
function foo() {}
// 所有的函数都有一个属性, 名字是 prototype
console.log(foo.prototype);

你可能会问题,老师是不是因为函数是一个对象,所以它有 prototype 的属性呢?

  • 不是的,因为它是一个函数,才有了这个特殊的属性;

  • 而不是它是一个对象,所以有这个特殊的属性;

1
2
const obj = {};
console.log(obj.prototype); // obj就没有这个属性

我们前面讲过 new 关键字的步骤如下:

  • 1.在内存中创建一个新的对象(空对象);

  • 2.这个对象内部的[[prototype]]属性会被赋值为该构造函数的 prototype 属性;(后面详细讲);

  • 3.构造函数内部的 this,会指向创建出来的新对象;

  • 4.执行函数的内部代码(函数体代码);

  • 5.如果构造函数没有返回非空对象,则返回创建出来的新对象;

我们将重心放到步骤一和二中:

  • 在内存中创建一个对象;

  • 将对象的[[prototype]]属性赋值为该构造函数的 prototype 属性;

那么也就意味着:

1
2
3
4
5
6
function Person() {}
const p1 = new Person();

// 上面的操作相当于会进行如下的操作:
p = {};
p.__proto__ = Person.prototype;

那么也就意味着我们通过 Person 构造函数创建出来的所有对象的[[prototype]]属性都指向 Person.prototype:

1
2
3
4
5
6
function Person() {}
const p1 = new Person();
const p2 = new Person();
const p3 = new Person();
console.log(p1.__proto__ === p2.__proto__);
console.log(p1.__proto__ === Person.prototype);

1.2.2. 创建对象的内存

对象对象的内存表现

1.2.3. prototype 属性

如果我们在函数的 prototype 中添加属性,那么创建的对象是否可以访问到呢?

1
2
3
4
5
6
7
8
9
10
function Person() {}

Person.prototype.name = "why";
Person.prototype.age = 18;

const p1 = new Person();
const p2 = new Person();

console.log(p1.name, p1.age);
console.log(p2.name, p2.age);

代码的内存表现

1.2.4. constructor 属性

事实上原型对象上面是有一个属性的:constructor

  • 默认情况下原型上都会添加一个属性叫做 constructor,这个 constructor 指向当前的函数对象;
1
2
3
console.log(Person.prototype.constructor); // [Function: Person]
console.log(p1.__proto__.constructor); // [Function: Person]
console.log(p1.__proto__.constructor.name); // Person

1.2.5. 重写原型对象

如果我们需要在原型上添加过多的属性,通常我们会重新整个原型对象:

1
2
3
4
5
6
7
8
9
function Person() {}

Person.prototype = {
name: "why",
age: 18,
eating: function () {
console.log(this.name + "在吃东西~");
}
};

前面我们说过, 每创建一个函数, 就会同时创建它的 prototype 对象, 这个对象也会自动获取 constructor 属性;

  • 而我们这里相当于给 prototype 重新赋值了一个对象, 那么这个新对象的 constructor 属性, 会指向 Object 构造函数, 而不是 Person 构造函数了
1
2
3
4
console.log(Person.prototype.constructor); // [Function: Object]
// 为什么是Object呢? 因为对象的字面量是由Object函数产生的
const obj = {};
console.log(obj.constructor); // [Function: Object]

如果希望 constructor 指向 Person,那么可以手动添加:

1
2
3
4
5
6
7
8
Person.prototype = {
constructor: Person,
name: "why",
age: 18,
eating: function () {
console.log(this.name + "在吃东西~");
}
};

上面的方式虽然可以, 但是也会造成 constructor 的[[Enumerable]]特性被设置了 true.

  • 默认情况下, 原生的 constructor 属性是不可枚举的.

  • 如果希望解决这个问题, 就可以使用我们前面介绍的 Object.defineProperty()函数了.

1
2
3
4
5
6
7
8
9
10
11
12
Person.prototype = {
name: "why",
age: 18,
eating: function () {
console.log(this.name + "在吃东西~");
}
};

Object.defineProperty(Person.prototype, "constructor", {
enumerable: false,
value: Person
});

1.3. 组合构造函数和原型

我们在上一个构造函数的方式创建对象时,有一个弊端:会创建出重复的函数,比如 running、eating 这些函数

那么有没有办法让所有的对象去共享这些函数呢?

  • 可以,将这些函数放到 Person.prototype 的对象上即可;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Person(name, age, height, address) {
this.name = name;
this.age = age;
this.height = height;
this.address = address;
}

Person.prototype.eating = function () {
console.log(this.name + "在吃东西~");
};

Person.prototype.running = function () {
console.log(this.name + "在跑步~");
};

const p1 = new Person("why", 18, 1.88, "广州市");
const p2 = new Person("kobe", 30, 1.98, "北京市");

p1.eating();
p2.running();

二. 类、原型链

2.1. JS 中的类和对象

当我们编写如下代码的时候,我们会如何来称呼这个 Person 呢?

  • 在 JS 中 Person 应该被称之为是一个构造函数;

  • 从很多面向对象语言过来的开发者,也习惯称之为类,因为类可以帮助我们创建出来对象 p1、p2;

  • 如果从面向对象的编程范式角度来看,Person 确实是可以称之为类的;

1
2
3
function Person() {}
const p1 = new Person();
const p2 = new Person();

2.2. 为什么需要继承

面向对象有三大特性:封装、继承、多态

  • 封装:我们前面将属性和方法封装到一个类中,可以称之为封装的过程;

  • 继承:继承是面向对象中非常重要的,不仅仅可以减少重复代码的数量,也是多态前提(纯面向对象中);

  • 多态:不同的对象在执行时表现出不同的形态;

那么这里我们核心讲继承。

比如下面的这段代码,如果我们不使用继承,那么会存在大量的重复代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
function Student(name, age, sno) {
this.name = name;
this.age = age;
this.sno = sno;
}

Student.prototype.eating = function () {
console.log(this.name + "在吃饭~");
};

Student.prototype.running = function () {
console.log(this.name + "在跑步~");
};

Student.prototype.studying = function () {
console.log(this.name + "在学习~");
};

function Teacher(name, age, title) {
this.name = name;
this.age = age;
this.title = title;
}

Teacher.prototype.eating = function () {
console.log(this.name + "在吃饭~");
};

Teacher.prototype.running = function () {
console.log(this.name + "在跑步~");
};

Teacher.prototype.teaching = function () {
console.log(this.name + "上课~");
};

那么继承是做什么呢?继承可以帮助我们将重复的代码和逻辑抽取到父类中,子类只需要直接继承过来使用即可。

那么 JavaScript 当中如何实现继承呢?

  • 不着急,我们先来看一下 JavaScript 原型链的机制;

  • 再利用原型链的机制实现一下继承;

2.3. JavaScript 原型链

在真正实现继承之前,我们先来理解一个非常重要的概念:原型链。

我们知道,从一个对象上获取属性,如果在当前对象中没有获取到就会去它的原型上面获取:

1
2
3
4
5
6
7
8
9
10
const obj = {
name: "why",
age: 18
};

obj.__proto__ = {
address: "广州市"
};

console.log(obj.address);

但是如果 obj 的原型上也没有对应的 address 属性呢?必然还是获取不到的。

那么如果我们配置的原型对象上,继续配置原型呢?

1
2
3
4
5
6
7
8
9
10
11
12
const obj = {
name: "why",
age: 18
};

obj.__proto__ = {};
obj.__proto__.__proto__ = {};
obj.__proto__.__proto__.__proto__ = {
address: "北京市"
};

console.log(obj.address);

原型链

2.4. Object 的原型

那么什么地方是原型链的尽头呢?比如第三个对象是否也是有原型__proto__属性呢?

1
console.log(obj.__proto__.__proto__.__proto__.__proto__); // [Object: null prototype] {}

我们会发现它打印的是 [Object: null prototype] {}

  • 事实上这个原型就是我们最顶层的原型了

我们来研究一下默认字面量的原型是什么:

1
2
3
4
5
6
7
8
9
const obj = { name: "why" };
console.log(obj.__proto__); // [Object: null prototype] {}

const obj1 = new Object();
console.log(obj1.__proto__); // [Object: null prototype] {}
console.log(Object.prototype); // [Object: null prototype] {}

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

我们可以知道,从 Object 直接创建出来的对象的原型都是 [Object: null prototype] {}

那么我们可能会问题: [Object: null prototype] {} 原型有什么特殊吗?

  • 特殊一:该对象不再继续有原型属性了,也就是已经是顶层原型了;

  • 特殊二:该对象上有很多默认的属性和方法;

Object 原型

那么我们回到刚才创建的原型链中,它们最终也会找到 Object 的 prototype 的:

原型链的内存图


文章转载于coderwhy | JavaScript 高级系列(十) - 对象原型、原型链