|

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 属性依然是存在。

类似文章

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注