本文主要讲述了 JavaScript 原型链的相关知识。通过图文的形式,看懂类、构造函数和示例对象的关系。还介绍了原型链的查找、如何获取和设置一个属性等等细节。最后,展示了通过原型链实现的继承方式。
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 原型链
JavaScript 中的对象有一个特殊的 [[prototype]] 内置属性,其实就是对于其他对象的引用。几乎所有的对象* 在创建时,其自身的 [[prototype]] 属性都会被赋予一个值,指向另一个对象。
- 由于
[[prototype]]是内置属性,我们不能显式的感知到它,因此浏览器定义了一个非标准的.__proto__属性,该属性就代表了[[prototype]]。事实上,.__proto__并不是一个属性,而是相当于一次函数调用,可以理解为__proto__(),这个过程更像是一次[[Get]],在后文 “检查类的关系” 中,会进一步解释。 - 上文说到的 “几乎所有的对象”,唯一例外的是
Object.create(null)方法,下文会解释。
原型链:A 对象的 [[prototype]] 内置属性指向了 B 对象,B 对象的 [[prototype]] 内置属性指向了 C 对象 ... ,最终会指向 Object.prototype。这样一个对象指向另一个对象所组成的链条,就是人们所说的 原型链。
从数据结构的角度来看,原型链其实就是一个单向链表,每个节点就是一个对象。
2.2 类的原型链
通过四张图片,描述原型链的具体过程。
首先解释一下图片中涉及到的模型:
- 一共有 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对象的原型链。-
找不到。如果在原型链上找不到同名的
name属性,则该语句发生创建行为,在 person 上创建新属性name。否则执行 2.2; -
找得到。如果在原型链上找得到同名的
name属性,则又分为 3 种情况:- 可写。如果找到的同名属性是 数据属性,且可写
writable:false。则发生创建行为,在person上 创建新属性name; - 不可写。如果找到的同名属性是 数据属性,但不可写
writable:true。则该语句会被 静默忽略,在严格模式下报错:TypeError; - setter。如果找到的同名属性是一个有 setter 函数 访问器属性。则该语句会发生 setter 函数的调用。
- 可写。如果找到的同名属性是 数据属性,且可写
-
总结:
- 所谓属性的 设置,就是修改了一个已存在的属性值; 所谓属性的 屏蔽,就是已知目标对象的原型链上存在一个同名属性,依然在目标对象上新建一个同名属性,则原型链上的同名属性被屏蔽。
- 所有 “数据属性” 都适用与该规则中,包括 基本数据类型 和 引用属性类型(方法、数组、等等各种对象)。如果父类存在一个同名的
name数组,执行person.name = "Moxy",在person对象中创建的同名属性name,此时不再是一个引用属性类型数组,而是一个基本数据类型字符串。 - 规则 2.2.2 “不可写”的情况可以考虑为:如果继承属性在父类不允许写,则其继承的子类也不允许写。
- 规则 2.2.3 “setter”的情况可以考虑为:如果继承属性在父类是有setter,则子类的赋值也要调用这个 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操作符。
在传统的面向类环境中,检查一个实例 (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 题目:
题目均是从其他作者的文章中整理而来,对此表示感谢。
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
引用:
《你不知道的JavaScript 上》
《JavaScript高级程序设计 第四版》