8. 原型链
1 类、构造函数、prototype 原型对象和实例对象
面向对象的三大特性:封装、继承、多态
- 封装:将结构相似的属性和方法都封装到一个类中,这个过程称之为封装;
- 继承:通过原型链实现子类继承父类的属性和方法,实现重复代码量的减少;
- 多态:不同的对象在执行时表现出不同的形态。
先看一段代码:
function Person(name) {
this.name = name;
}
Person.prototype.printName = function () {
console.log(this.name);
};
let instance1 = new Person("Moxy");
let instance1 = new Person("Ninjee");
1.1 名词
类、构造函数、原型对象和实例对象。
- 类:与构造函数同名,代码中类名即为 Person。
- 构造函数:在代码中 Person 函数通常被称之为构造函数。函数本身不是构造函数,只有在一个普通函数调用前,加上 new 关键字后,就会把这个函数调用变成一个 “构造函数调用”。
- 原型对象:每一个构造函数都拥有一个原型对象。
构造函数.prototype
指向了原型对象。在代码中,即为Person.pototype
。 - 实例对象:通过调用 new + 构造函数而创建的实例化对象,就是实例对象。代码中 instance1 和 instance2 就是实例对象。
1.2 关系
介绍上述四个名词之间的关系,即它们是如何联系在一起的。
指向原型对象
在代码中,原型对象是匿名的,没有一个可以直观看到的称呼。所以通常来讲,原型对象的表述形式是通过构造函数名:Person.prototype
。原型对象的这个表述方式也阐述了构造函数和原型对象的关系:
Person.prototype
,即构造函数的.prototype
属性,指向了原型对象。instance1.__proto__
,即实例对象的.__proto__
属性,指向了原型对象。
指向构造函数
被创建的原型对象,默认会拥有一个公有、且不可枚举的属性 .constructor
指向构造函数。
Person.prototype.constructor
,即原型对象的.constructor
属性指向了构造函数。instance1.constroctor
,即实例对象的.constroctor
属性指向了构造函数。- 注:事实上,实例对象是没有
.constroctor
属性。可以通过.constroctor
访问到构造函数,是因为通过原型链访问到了原型对象的.constroctor
属性,即真正的访问链是:instance1.__proto__.constroctor
。关于原型链后文会进一步讲述。
- 注:事实上,实例对象是没有
2 原型链
对象的原型: [[prototype]]
。
JavaScript 中的每个对象都有两个特殊的内置属性,指向了某个对象或函数。
[[prototype]]
属性,指向了它的原型对象。.constructor
属性,指向了它的构造函数(enumberable 默认为 false)。
几乎所有的对象 在创建时,其自身的 [[prototype]]
属性都会被赋予一个值,指向另一个对象。
- 由于
[[prototype]]
是内置属性,我们不能显式的感知到它,因此浏览器定义了一个非标准的.__proto__
属性,该属性就代表了[[prototype]]
。事实上,.__proto__
并不是一个属性,而是一次函数调用,可以理解为__proto__()
,这个过程更像是一次[[Get]]
,在后文 ”检查类的关系“中,会进一步解释。 - 上文说到的 “几乎所有的对象”,唯一例外的是
Object.create(null)
方法,下文会解释。 - ES5 推出了获取原型链的 API:
Object.getPrototypeOf(obj1)
。
A 对象的 [[prototype]]
内置属性指向了 B 对象,B 对象的 [[prototype]]
内置属性指向了 C 对象 ... ,最终会指向 Object.prototype
。这样一个指向另一个组成的常常链条,就是人们所说的 原型链。
从数据结构的角度分析,原型链其实就是一个单向链表。
2.1 类的原型链
通过四张图片,描述原型链的具体过程。
首先解释一下图片中涉及到的模型:
- 一共有 3 个类,JavaScript 内置
Object
、父类Father
、子类Son
,三者之间存在继承关系。 - 类的原型对象没有用
Object.prototype
的表述形式,而是用了 "Object 的原型对象" 。 - 每个构造函数列举了 3 个实例对象,命名方式为 构造函数名 + 数字,如:
object1
上图解释了实例对象是如何通过 .__proto__
一步步遍历自己的原型链的,图片的主角是 .__proto__
属性。
可以看到,所有的实例对象都通过 .__proto__
属性,指向了自己的原型对象。然后各个原型对象因为继承关系,通过 .__proto__
属性指向了另一个原型对象。这样一个个串联起来,形成了完整的原型链。最终,所有原型对象都会指向 Object 的原型对象,也就是Object.prototype
。而为了表达Object.prototype
是所有原型链的根,它的 .__proto__
属性指向了 null
。
-
并不是所有对象,最终都会指向
Object.prototype
。通过Object.create(null)
创建的对象,是不会继承 Object 的,而是其原型对象的.__proto__
会显示undefined
。function Father(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
Father.prototype.__proto__ === Object.prototype; // true,原型对象默认指向了 Object.prototype
// 通过Object.create(null),断开原型链指向
Father.prototype = Object.create(null);
Father.prototype.__proto__ === Object.prototype; // false,原型对象的原型链被改变了。
Father.prototype.__proto__; // undefined,事实上,原型对象的原型链 .__proto__ 是被删除了。
Object.create()
它会创建一个对象,并把这个对象的
[[prototype]]
原型链关联到指定的对象。
事实上,通过原型链来实现继承关系,是一个链表,它更像是一个 “电梯”:
原型链都通过 __proto__
属性来实现串联。也就是内置属性 [[prototype]]
。
继承关系的原型对象,就是每一层的电梯:Son 的原型对象在 1 层,Father 的原型对象在 2 层。而 Object 的原型对象总是在顶层,因为它代表的原型链的根节点。其 .__proto__
永远指向 null
。
类名,或者说构造函数,就是每层楼的名称;实例对象则是每层楼的不同房间。这样就形成了如下模型,一个渐变红色的箭头代表了最底层实例对象是如何通过 [[prototype]]
原型链一步步向上遍历的。
下图解释了:
- 构造函数通过 new 操作符创建了实例对象。
- 构造函数的
.prototype
属性值指向了Object.prototype
即构造函数的原型对象。
3 new 运算符
new 一个新对象的过程,发生了什么?
let person1 = new Person("Moxy", 15);
要创建 Person 的新实例,必须使用 new 操作符。以这种方式调用构造函数实际上会经历以下 5 个步骤:
- 创建一个 新对象
{}
; - 为新对象绑定 原型链:
{}.__proto__ = Person.prototype
; - 将构造函数的作用域
this
赋给新对象{}
; - 执行构造函数中的代码,为
{}
添加属性:Person.call(this)
; - 返回对象:
- 如果构造函数最终会返回一个对象,就返回 构造函数中的对象。
- 如果构造函数没有返回其他对象,就会返回 新对象。
最终,代码中左侧的 person1
变量接收到了新创建的那个对象。
4 属性的设置和屏蔽
为什么要给对象定义一个原型,原型链的目的是什么?
给对象更改某个属性,JavaScript 如何判断该属性是否是已经存在的自有属性?或者是原型链上存在的继承属性?
person.name = "Moxy"
会触发 [[Put]]
,操作的完整过程是:
首先会判断对象中是否已存在该属性值,如果存在,则会执行 1;不存在,则会执行 2。
- 判断自有属性。如果 person 对象中存在一个同名的 数据属性,则该语句发生赋值行为,修改已有的这个属性。
- 判断继承属性。遍历 person 对象的原型链。如果存在,则会执行 2.1;不存在,则会执行 2.2。
- 找得到。如果在原型链上找得到同名的 name 属性,分为下面 3 种情况:
- 可写。如果找到的同名属性是 数据属性,且可写
writable:true
。则发生创建行为,在 person 上 创建新属性 name; - 不可写。如果找到的同名属性是 数据属性,但不可写
writable:false
。则该语句会被 静默忽略,在严格模式下报错:TypeError
; - setter。如果找到的同名属性是一个有 setter 函数 访问器属性。则该语 句会发生 setter 函数的调用。
- 可写。如果找到的同名属性是 数据属性,且可写
- 找不到。如果在原型链上找不到同名的 name 属性,则该语句发生创建行为,在 person 上创建新自有属性 name。
- 找得到。如果在原型链上找得到同名的 name 属性,分为下面 3 种情况:
总结:
- 所谓属性的设置,就是修改了一个已存在的属性值; 所谓属性的屏蔽,就是已知目标对象的原型链上存在一个同名属性,依然在目标对象上新建一个同名属性,则原型链上的同名属性被屏蔽。
- 所有 “数据属性” 都适用与该规则中,包括 基本数据类型 和 引用属性类型(方法、数组、等等各种对象)。如果父类存在一个同名的 name 数组,执行
person.name = "Moxy"
,在 person 对象中创建的同名属性 name ,此时不再是一个引用属性类型数组,而是一个基本数据类型字符串。 - 属性在父类不允许写,则继承的子类也不允许写(规则 2.1.2 “不可写”的情况)。 属性在父类有 setter,则子类的赋值也要调用这个 setter(规则 2.1.3 “setter”的情况)。
5 原型式继承
更多相关内容见:继承 篇章的 原型式继承。
// 定义父类:实例属性 + 公有方法
function Father(name) {
this.name = name;
}
Father.prototype.printName = function () {
console.log(this.name);
};
// 定义子类:实例属性 + 公有方法。现在共有方法可以紧接着实例属性去定义了
function Son(name, age) {
Father.call(this, name);
this.age = age;
}
Son.prototype.printAge = function () {
console.log(this.age);
};
// 方法三:原型式继承。使用ES6方法,
Object.setPrototypeOf(Son.prototype, Father.prototype);
// 实例化测试:
let instance1 = new Son("Moxy", 99); // Son {name: "Moxy", age: 99}
let instance2 = new Son("Ninjee", 5); // Son {name: "Ninjee", age: 5}
instance1.printName === instance2.printName; // true
Son.prototype.__proto__ === Father.prototype; // true
6 类的关系判断
更多关于类型判断的知识:见 类型 篇章的