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 关系
介绍上述四个名词之间的关系,即它们是如何联系在一起的。
1.2.1 指向原型对象
在代码中,原型对象是匿名的,没有一个可以直观看到的称呼。所以通常来讲,原型对象的表述形式是通过构造函数名:Person.prototype
。原型对象的这个表述方式也阐述了构造函数和原型对象的关系:
Person.prototype
,即构造函数的.prototype
属性,指向了原型对象。instance1.__proto__
,即实例对象的.__proto__
属性,指向了原型对象。
1.2.2 指向构造函数
被创建的原型对象,默认会拥有一个公有、且不可枚举的属性 .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 类的关系判断
更多关于类型判断的知识:见 类型 篇章的 类型判断 章节。
更多关于对象属性的判断:见 原型链 篇章的 判断对象的属性 章节。
关联:类型判断、继承判断、继承关系判断、原型链判断
类型判断的方法:
typeof
操作符。可以判断基本数据类型值。instenceof
操作符。可以判断引用类型值,但不好用。Object.prototype.toString()
函数。可以判断引用类型值,替代instanceof
操作符。in
/for in
操作符 / 遍历。
在传统的面向类环境中,检查一个实例 (JavaScript 中的对象)的继承祖先(JavaScript中的委托关联)通常被称为内省(或者反射)
注:委托关联,就是我们所说的继承关系。
方法一:instanceof
操作符
instance1 instanceof Father
,这段代码回答的问题是:
- 在实例对象 instance1 的整条原型链中,是否有
Father.prototype
原型对象呢?
注意:代码中 "Father" 是构造函数 Father
,而代码真正去寻找的是构造函数的原型对象 Father.prototype
,容易搞混注意区分。
方法二:isPrototypeOf()
Father.protoype.isProtoypeOf( instance1 )
,这段代码回答的问题是:
- 在实例对象 instance1 的整条原型链中,是否有
Father.prototype
原型对象呢?
可以看到该方法的作用和instanceof
操作符是一摸一样的。其好处就是排除了构造函数 Father
的干扰。直接去判断原型对象 Father.prototype
,表意更明确了。
方法三:Object.getPrototypeOf()
Object.getPrototypeOf( instance1 )
,这段代码解决的是:
- 获取实例对象
instance1
的原型对象。
注意:该方法返回的是其原型对象。不能返回完整的原型链。如果想获得一个完整的原型链,可以反复的调用该方法,遍历整条原型链直到 null
截止。
function getProto(obj) {
if (obj === null) return;
console.log(obj)
return Object.getPrototypeOf(obj)
}
方法四:.__proto__
instance1.__proto__ === Son.prototype
,这段代码是判断:
- 实例对象
instance1
的原型对象是Son.prototype
吗?
该属性是浏览器公认属性,而不是 JS 的官方标准。事实上,这个属性名更像是一个类似 getter 的方法。其内部的是依赖 Object.getPrototypeOf()
方法实现的,不推荐使用。
// 通过这种方法可以遍历原型链。
instance1.__proto__.__proto__ ....
7 题目:
题目均是从其他作者的文章中摘来,对此表示感谢。
0 关系:Object 和 Function 的亲密无间
// Object原型对象是顶层对象,它的.constroctur指向Object构造函数,.__proto__指向 null
Object.prototype.constructor === Object // true
Object.prototype.__proto__ === null // true
// Function原型对象是Object类型,所以其原型链.__proto__指向Object原型对象
Function.prototype.__proto__ === Object.prototype // true
// Object构造函数是函数,是Function类型,所以其原型链.__proto__指向Function原型对象
Object.__proto__ === Function.prototype // true
让我们看看顶层对象 Object.prototype 有什么内置属性和方法:
Object.getOwnPropertyDescriptors(Object.prototype); // 通过API可以打印出所有属性,不论是否可枚举
// chrome中,用console.log(Object.prototype)也可打印。其属性名颜色变淡,标明Object.prototype所有属性都不可枚举
console.log(Object.prototype);
1. `实例对象.xxx`来调用:
// 判断属性
// hasOwnProperty: boolean,判断实例对象上是否存在‘入参’属性
// propertyIsEnumerable: boolean,判断‘入参’属性是否可枚举,不可枚举或属性不存在返回false
// 判断原型链
// isPrototypeOf: boolean,判断‘入参’对象是否在实例对象的原型链上
// 显示输出/类型判断
// toString: 返回该对象的字符串表示。
// valueOf: 指定该对象的原始值。
// toLocaleString: 返回实例对象(数字的包装对象)在特定语言环境下的表示字符串:比如二进制、克/磅
2. 普通对象属性:
// constructor: 该对象的构造函数
// __defineGetter__: get 的非标准用法,为属性(入参1)绑定一个getter(入参2)
// __defineSetter__: set 的非标准用法,为属性(入参1)绑定一个setter(入参2)
// __lookupGetter__: 返回实例对象上某属性(入参)的 getter
// __lookupSetter__: 返回实例对象上某属性(入参)的 setter
1 看代码识结果:
var A = function () {};
A.prototype.n = 1;
var a1 = new A();
A.prototype = {
n: 2,
m: 3
}
var a2 = new A();
console.log(a1.n);
console.log(a1.m);
console.log(a2.n);
console.log(a2.m);
解答:
在创建实例对象 a1
后,执行了如下代码:A.prototype = { n:2. m:3 }
。
这导致了构造函数 A 的原型对象发生了改变:不再是以前的 { n:1 }
了,而是变成一个新的对象—— { n:2. m:3 }
。也就是说,地址值不再是以前的那个对象,而是指向了新对象。
我们知道,在 new 运算符实例化对象的时候,同时会把该实例对象的 __proto__
属性指向 此时 构造函数的原型对象。所以,
a1.__proto__
指向的是旧对象{n:2}
;a2.__proto__
指向的是新对象{n:2, m:3}
。
这两个实例对象分别指向了不同的对象,依照原型链查找的结果自然也不同。
结论:
不要轻易的重定义 prototype
原型对象。这会导致:
- 原型对象的
constructor
属性丢失。它原本指向了构造函数,在重定义原型对象后,需要手动再指定一下constructor
属性。 - 重定义原型对象的前后,实例化对象会指向不同的原型对象,造成表现不一致。
// 不要下面这样重定义原型对象:
A.prototype = {
n: 2,
m: 3
}
// 要像这样给原型对象添加属性、方法
A.prototype.n = 2;
A.prototype.m = 3;
答案:
console.log(a1.n); // 1
console.log(a1.m); // undefined
console.log(a2.n); // 2
console.log(a2.m); // 3
2 看代码识结果:
var F = function () {};
Object.prototype.a = function () {
console.log('a');
};
Function.prototype.b = function () {
console.log('b');
}
var f = new F();
f.a()
f.b()
F.a()
F.b()
解答:
F
是一个构造函数,属于函数类型,是一个对象;f
是一个实例对象,属于F
类型,是一个对象。
所有对象,都是 Object
对象。所以所有对象都继承自 Object
,包括了题中的 F
和 f
。那么在 Object
原型链上的 a 方法,f 和 F 都可以调用;
构造函数 F,是一个 Function
函数类型。而实例对象 f 不是 Function
。所以在 Function
原型链上的 b 方法,只有 F 可以调用, f 无法调用。
答案:
f.a() // a
f.b() // f.b is not a function
F.a() // a
F.b() // b
3 看代码解答:
function Person(name) {
this.name = name
}
let p = new Person('Tom');
问题1: p.__proto__
等于什么?
问题2: Person.__proto __
等于什么?
解答:
和第二题是一致的:
Person
是 Function
类,所以其原型链指向了 Function
的原型对象 Function.prototype
;
p
是 Person
类,其原型链指向了 Person
的原型对象 Person.prototype
;
引申:
p.__proto__.__proto__ === Person.__proto__.__proto__ // true
Function.prototype
,是Object
类,所以其原型链指向了Object.prototype
;Person.prototype
,是Object
类,所以其原型链指向了Object.prototype
;
也就是说,Person 和 Function 的原型对象,指向了同一个对象,就是 Object 的原型对象。
答案:
p.__proto__
指向:Person 原型,也就是Person.prototype
。
Person.__proto__
指向:Function 原型,也就是 Function.prototype
。
// 控制台的输出结果:
p.__proto__ // {constructor: ƒ}
Person.__proto__ // ƒ () { [native code] }
4 看代码识结果:
function fn1() {
console.log(1);
this.num = 111;
this.sayHey = function () {
console.log("say hey.");
}
}
function fn2() {
console.log(2);
this.num = 222;
this.sayHello = function () {
console.log("say hello.");
}
}
fn1.call(fn2); // 这里有输出吗?
fn1();
fn1.num;
fn1.sayHey();
fn2();
fn2.num;
fn2.sayHello();
fn2.sayHey();
解答:
- fn1 和 fn2 是一个对象,也是一个函数。
fn1
当成对象看、fn1()
当成函数看。 fn1.call(fn2)
, 是执行了fn1()
。同时,fn1
内部的 this 值,指向了fn2
对象。- 这就导致了 num、sayHey 这两个属性都赋值给了 对象 fn2。所以 fn2 作为一个对象,此时拥有了 num 和 sayHey 这两个属性。
- fn1 此时没有赋值其他属性。所以
fn1.xxx
都未定义;fn2.num
和fn2.sayHey
存在。
答案:
fn1.call(fn2); // 1 ,这里执行了 fn1() 自然会输出数字1.
fn1(); // 1
fn1.num; // undefined
fn1.sayHey(); // fn1.sayHey is not a function
fn2(); // 2
fn2.num; // 111
fn2.sayHello(); // fn2.sayHello is not a function
fn2.sayHey(); //say hey.
引申:
apply()
和 call()
都是为了改变某个函数 运行时 的上下文而存在的。
- 换句话说,就是为了改变函数内部的
this
指向。
let a1 = add.call(son, 4, 2) // 参数依次是:add的this,add的第一个参数,add的第二个参数
let a1 = add.apply(son, [4,2]) // 参数依次是:add的this,add的参数数组集合
因为这两个方法会立即调用,所以为了弥补它们的缺失,还有个方法 bind()
,它不会立即调用:
bind 不会执行方法,而是会返回一个新方法。这个新方法的 this 指向被固定为 bind 的参数。
原方法不受影响。
let newAdd = add.bind(son) // 返回一个新方法 newAdd,该方法的this被固定指向son
5 看代码解答:
Object.prototype.__proto__ // null
Function.prototype.__proto__ // Object.prototype
Object.__proto__ // Function.prototype
Object.prototype
原型链的根节点。Object.prototype
的原型对象为null
。Function.prototype
是原型对象。所以是Object
类的实例化对象。原型链指向Object.prototype
。Object
是构造函数。所以是Function
函数类的实例化对象。原型链自然指向Function.prototype
。
6 看代码解答:
按照如下要求实现 Person 和 Student 对象
- Student 继承 Person;
- Person 包含一个实例变量 name, 包含一个方法 printName;
- Student 包含一个实例变量 score, 包含一个方法 printScore;
- 所有 Person 和 Student 对象之间共享 printName 方法;
// 用构造函数实现:
function Person(name) {
this.name = name
}
Person.prototype.printName = function () {
console.log(this.name)
}
function Student(name, score) {
Person.call(this, name)
this.score = score
};
// 采用原型式继承
Student.prototype = Object.create(Person.prototype)
Student.prototype.constructor = Student
Student.prototype.printScore = function () {
console.log(this.score)
}
// 用类实现:
class Person {
constructor(name) {
this.name = name
}
printName() {
console.log(this.name)
}
}
class Student extends Person {
constructor(name, score) {
super(name);
this.score = score;
}
printScore() {
console.log(this.score)
}
}
// 实例化测试:
let instance1 = new Student("Moxy", 99); // Student {name: "Moxy", score: 99}
let instance2 = new Student("Ninjee", 5); // Student {name: "Ninjee", score: 5}
instance1.printName === instance2.printName // true
附:原型链经典图解
Foo 构造函数的特性:
- Foo 是一个函数,它有一个显式原型对象:
Foo.prototype
; - Foo 是一个对象,它有一个隐式原型对象(原型链):
Foo.__proto__
;
引用:
《你不知道的JavaScript 上》
《JavaScript高级程序设计 第四版》