JavaScript 中的原型与原型链
本篇主要整理 JavaScript 中 原型 与 原型链 的概念,由于 JavaScript 中没有类的概念,所以对象的继承和实例化全部通过原型和原型链来实现,深入理解原型与原型链的概念将有助于理解 JavaScript 这门语言。
原型与原型链
假如有如下构造函数:
function Student () {}
let std = new Student();
std.name = "Alice";
std.age = 18;
console.log(std.name, std.age);
// Alice 18
上述代码非常好理解,Stdudent
是一个函数,这里可以当构造函数用,使用 new
关键词创建了一个新的 Student 对象 std
,并给 std 对象加入两个属性 name 和 age 并赋值。
下面我们将基于这个构造函数,开始慢慢梳理原型与原型链,并讨论其本质。
prototype
每个函数都有一个 prototype 属性,每一个 JavaScript 对象(null 除外)在创建的时候就会与之关联一个对象,这个对象就是我们所说的原型,每一个对象都会从原型 “继承” 属性。
对上述代码进行修改:
function Student () {}
Student.prototype.name = "Alice";
Student.prototype.age = 18;
let std = new Student();
console.log(std.name, std.age);
// Alice 18
此时使用 prototype 来定义 name 和 age 两个属性,也可以看到输出,但此时的输出实际含义已经与前文有了很大不同,这里的 name 和 age 是定义在 Student 函数上的,而不是 std 实例上的。
如果使用 Student 继续构造新的实例,这些实例将共享 Student 的 prototype 属性:
function Student () {}
Student.prototype.name = "Alice";
Student.prototype.age = 18;
let std1 = new Student();
let std2 = new Student();
console.log(std1.name, std1.age); // Alice 18
console.log(std2.name, std2.age); // Alice 18
console.log(std1.name === std2.name); // true
修改上面的示例,我们再 Student 函数内创建自己的属性,然后同时定于原型属性:
function Student (name, age) {
this.name = name;
this.age = age;
}
Student.prototype.name = "Alice";
Student.prototype.age = 18;
let std1 = new Student("Tom", 20);
let std2 = new Student("Sean", 22);
console.log(std1.name, std1.age); // Tom 20
console.log(std2.name, std2.age); // Sean 22
console.log(std1.name === std2.name); // false
按照原型查找的规则,会首先在当前实例中查找属性,如果没有找到,则到当前实例的原型中查找属性,如果依然没有找到,再到当前实例的上一层原型对象中查找,不管查找到哪里,只要找到即可返回。
上面的 prototype 就是原型实现,定义在该属性上的属性和方法就是原型属性和原型方法;
需要注意,原型本身也是一个对象,一般我们叫原型对象。
__proto__
这是一个内部属性,该属性指向了该对象的原型。
上文的代码不变,增加一句输出,看下面的例子:
function Student () {}
Student.prototype.name = "Alice";
Student.prototype.age = 18;
let std = new Student();
console.log(std.name, std.age); // Alice 18
// 增加下面的一句输出
console.log(std.__proto__); // Student { name: 'Alice', age: 18 }
可以看到,__proto__
是每一个对象都具备的一个属性,这个属性指向了该对象的原型,所以输出 __proto__ 的时候可以看到 std 的原型,即 Student.prototype 的定义。
所以,下面的代码应该是好理解的:
console.log(std.__proto__ === Student.prototype); // true
之所以输出为 true ,是由于 __proto__ 指向了 prototype ,所以这两个属性中的指针值相同,即所以的对象地址相同。
constructor
同时,每一个原型都有一个 constructor
属性指向了关联的构造函数,在这个示例中,关联的构造函数为 function Student () {}
,所以:
function Student () {}
console.log(Student === Student.prototype.constructor); // true
即 constructor 属性指向了其对应的构造函数,所以 constructor 与 函数 Student 所指向的函数对象也是相同的。
所以,Student
是构造函数,Student.prototype
是原型,Student.prototype.constructor
是原型的构造函数,等于 Student
,而实例 std 具有一个 __proto__
属性,这个属性指向了其原型对象,即 std.__proto__ === Student.prototype
为真。
实例与原型
在上文说过使用 prototype 定义的属性与在实例中定义的属性是不一样的,假设有如下代码:
function Student () {}
Student.prototype.name = "Alice";
Student.prototype.age = 18;
let std = new Student();
std.name = "Sean";
std.age = 20;
console.log(std.name, std.age);
// Sean 20
delete std.name;
delete std.age
console.log(std.name, std.age);
// Alice 18
上述代码两次都输出了 name 和 age 的值,但这两个值不同。原因在于在 Student.prototype
添加的属性是添加在原型中,在实例 std
上添加的属性是添加在 std 实例上,这两个属性虽然可以同名,但是代表的含义不同,所以当删除了 std.name 以后,根据访问规则,这是删除了 std 实例上的 name 属性,其原型对象的 name 属性依然存在,所以输出 std.name 依然有值,但这个值已经变成了 std 原型 Student.prototype 上的属性。
这里可以看到属性查找的过程:
- 如果实例对象上存在 name 属性,则返回该属性;
- 如果实例对象上没有 name 属性,则在还实例的原型上继续查找 name 属性;
原型链
基于以上过程,如果该对象实例的原型上依然没有 name 属性,则会继续向上查找,即通过查找该对象原型实例的 __proto__ 属性所指向的原型中的 name 属性,直到查找到 Object 为止,因为 Object 没有对应的原型了,此时 Object.prototype.__proto__ 为 null。
写一个属性测试一下:
// 给 Object 原型增加 name 属性
Object.prototype.name = "Root";
function Student () {}
// 不再给 Student 原型设置 name 属性
// Student.prototype.name = "Alice";
let std = new Student();
// std 实例也不再设置 name 属性
// std.name = "Sean";
console.log(std.name);
// Root
可以看到此时输出 std.name 得到了 Root ,说明 name 属性被层层向上查找,最后在 Object 原型中找到并输出。
下面有一张网络上的图片,绘制了 JavaScript 中各个原生对象之间的集成关系。
JavaScript 并不会复制原型对象的属性,相反,JavaScript 只是在两个对象之间创建一个关联,这样,一个对象就可以通过委托访问另一个对象的属性和函数,所以将其叫继承。
测试一下原型链:
function Student () {}
Student.prototype.name = "Alice";
Student.prototype.age = 18;
let std = new Student();
Object.prototype.name = "Root"
console.log(std.__proto__, std.__proto__.__proto__, std.__proto__.__proto__.__proto__)
// Student { name: 'Alice', age: 18 } { name: 'Root' } null
所以,__proto__ 的作用就是让 JavaScript 的原型链有效,以上就是原型与原型链的关系。
引申:原型与原型链的本质
搞清楚上述原型与原型链的关系后,应该基本能看清原型与原型链的本质了。
简单说,原型的存在,是为了让对象共享属性或者方法,所以定义在属性 prototype 上的原型对象不会在实例之间进行深拷贝。
而原型链,就是通过 __proto__ 属性逐层指向构成的一个引用链,相当于一个链表,在这个链条上的属性和方法,都可以在实例中访问到。
引申:属性访问流程
我们在访问一个对象的属性或者方法时,一般流程是这样的:
- 首先,在当前实例中查找属性或者方法;
- 如果当前对象中没有找到,则在当前对象的原型对象上查找;
- 重复上面的过程,直到找到了目标属性或方法,或者完全没有找到。
属性访问的原则与逻辑判断有相似的地方,即找到后马上停止,这跟逻辑判断中的逻辑短路类似。
引申:属性覆盖或函数覆盖的本质
如果对属性的访问流程理解透彻的话,就会知道,属性覆盖和函数覆盖的本质就是有访问短路造成的,由于属性访问在找到属性后就会立刻停止,所以距离当前对象越近的属性会越早本访问到,而后面的属性就可能会忽略,这就造成了感觉上的属性覆盖,而实际上,被覆盖的属性和方法依然在其原型链的对象上。
上面例子中就出现的属性覆盖:
function Student (name, age) {
this.name = name;
this.age = age;
}
Student.prototype.name = "Alice";
Student.prototype.age = 18;
let std = new Student("Tom", 20);
console.log(std.name, std.prototype.name); // Tom Alice
上面的例子中,std 实例中的 name 属性距离该对象最近,所以访问到以后即停止,那么就不会访问到其原型对象上的 name 属性,这就产生了感觉上的覆盖,而实际上原型对象的 name 属性依然是存在。