终于懂了原型链!

3,044 阅读13分钟

本文涉及关键词: 构造函数实例对象__proto__ 属性prototype 原型对象constructor 属性原型链原型链继承

本着包教包会,不会再看几遍就会,忘了再再看几遍也能想来的原则,将 JS 原型部分涉及概念一次说清楚。后续关于 ES6 Class 的语法糖也将基于本篇文章继续更新!

下面这段话来自 MDN,感觉描述的很全面,希望看完本文能够彻底理解下面这段话的含义!

JavaScript 常被描述为一种基于原型的语言。每个对象拥有一个原型对象,对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链 (prototype chain),它解释了为何一个对象会拥有定义在其他对象中的属性和方法。

通过原型这种机制,JavaScript 中的对象从其他对象继承功能特性;这种继承机制与经典的面向对象编程语言的继承机制不同。

准确地说,这些属性和方法定义在 Object 的构造器函数(constructor functions)之上的 prototype 属性上,而非对象实例本身。

在传统的 OOP 中,首先定义“类”,此后创建对象实例时,类中定义的所有属性和方法都被复制到实例中。在 JavaScript 中并不如此复制——而是在对象实例和它的构造器之间建立一个链接(它是 __proto__ 属性,是从构造函数的 prototype 属性派生的),之后通过上溯原型链,在构造器中找到这些属性和方法。

本文大纲共分为三部分:

  • 第一部分 理解 JS 原型链
  • 第二部分 new 操作符到底做了什么?实现一个 new !
  • 第三部分 JS 怎么实现继承的?原型链继承!

下面进入主题~

第一部分 理解 JS 原型链

1. 构造函数、实例对象

  • person 是 Person 函数的实例对象
  • Person 函数 是 person 的构造函数
function Person() {}
let person = new Person();
person; // Person {__proto__} 是一个对象,有一个__proto__属性
person.constructor == Person; // true 指向Person构造函数本身

constructor 属性其实就是一个拿来保存自己构造函数引用的属性。实例对象的 constructor 属性指向构造函数本身

(其实这么说不准确。后面会说明其实是 person 实例对象通过 __proto__ 属性指向了构造函数的原型对象 prototype,prototype 的 constructor 属性才指向了构造函数 Person 本身。所以打印结果上来看 person 上本身是没有 constructor 的,但是却能打印 person.constructor)

2. JS 内置对象 Object、Function

  1. Function 函数和 Object 函数是 JS 内置对象,也叫内部类。这些内置函数可以当作构造函数来使用。

  2. 为什么 “函数即对象”?关于 Function 对象的特殊性。

function Person() {}
Person.constructor == Function; // true 构造函数的 constructor 属性指向内置函数 Function
  • 所有函数都是 Function 函数的实例对象,所以常说函数即对象,JS 中一切皆对象。
// 也都指向内置 Function
Function.constructor == Function; // true
Object.constructor == Function; // true
  • 说明 Function 函数同时是自己的构造函数
  • 说明 Function 函数同样也是 Object 这类内置对象的构造函数。
let obj = {};
obj.constructor == Object; // true 普通对象 constructor 属性都指向 Object 构造函数
  • 说明普通对象也都是内置 Object 函数的实例对象。

整理一下 constructor 指向链:

  • person.constructor ——> Person 构造函数Person.constructor ——> Function 函数Function.constructor ——> Function 自己

  • obj.constructor ——> ObjectObject.constructor ——> Function 函数Function.constructor ——> Function 自己

3. 原型对象 prototype

  1. 为了让实例对象能够共享一些属性和方法,将他们全部放在了 构造函数的原型对象 prototype 上。
function Person() {}
Person.prototype.sayHello = function () {
  console.log("Hello!");
};
let person1 = new Person();
let person2 = new Person();

person1.sayHello === person2.sayHello; // true

关于 Function 的特殊性:所有函数本身是 Function 函数的实例对象,所以 Function 函数中同样会有一个 prototype 对象放它自己实例对象的共享属性和方法。

  1. constructor 也是一个共享属性,存放在原型对象 prototype 中,作用依然是指向自己的构造函数。
function Person() {...}
Person.prototype.constructor == Person // true

从示意图中看出实例对象构造函数原型对象 prototypeconstructor 的存在关系。

实例对象的 constructor 属性指向该实例对象的构造函数,constructor 属性作为共享属性存在于构造函数的 prototype 上。

疑问 🤔 :前文所说的 person.constructor 指向 Person 构造函数,那么 person 和 constructor 又是如何联系起来的呢?也就是如何让实例对象能找到自己的原型对象呢?↓↓↓

4. __proto__ 属性

  1. 先说结论,这也是理解原型链的核心公式: person.__proto__ == Person.prototype。即实例对象的 __proto__ 属性指向构造函数的原型对象 prototype

通过在 person 对象内部创建了一个属性 __proto__ 直接指向自己的原型对象 prototype,通过原型对象就可以找到共享属性 constructor ,此时 constructor 指向该实例对象的构造函数 Person() 了。

重新理解一些开篇的第一个例子,其实应该是这样的~~

function Person() {}
let person = new Person();
// 请背下来!!
person.__proto__ == Person.prototype;
// constructor存在于prototype上,指向构造函数
Person.prototype.constructor == Person;
// 所以person.__proto__可以读取原型prototype上的constructor,指向构造函数
person.__proto__.constructor == Person;

// 那为什么开篇的等式也成立呢?
person.constructor == Person; // true 也成立?
// 这就是因为原型链查找了,后面会详细说。因为person通过原型链查找到了constructor,并且指向了Person构造函数。
// but 注意
Person.constructor == Function; // 因为Person的构造函数是Function

关于 Function 的特殊性:

  1. 因为 JS 内所有函数都是 Function 函数的实例对象,所以 Person 函数也有个__proto__ 属性指向自己的原型对象,即 Function 函数的 prototype。
  2. Function 函数也有个 __proto__ 属性指向自己,因为它拿自身作为自己的构造函数
  1. 关于 __proto__ 的第二个重要概念是,由于原型对象 prototype 也是个对象,那它也有个__proto__属性,指向自己的原型对象。那它的构造函数是谁呢?

Person.prototype.__proto__ == Object.prototype , 找到了!

所以函数内的原型对象 prototype 跟所有普通对象一样,也都是内置 Object 函数的实例对象。

总结:实例对象的 __proto__ 属性指向构造函数的原型对象 prototype; 而所有原型对象 prototype__proto__ 属性,都指向了 Object 的原型对象!

有点绕了,但这正是我们理解原型链的核心原理!!

下面这张图描述了很好地描述了 __proto__ 与 prototype 原型对象的关系:

5. 原型链来了

有了上面的基础,接下来理解下原型链到底是什么?

  • 每个 实例对象 都有一个 私有属性 __proto__ 指向它的 构造函数原型对象 prototype。该原型对象也有自己的私有属性 __proto__指向原型对象...层层向上直到一个对象(内置Object对象)的原型对象为 null,并作为这个原型链中的最后一个环节。

  • 将一个个 实例对象原型对象 关联在一起,关联的原型对象也是别人的实例对象,所以就形成了串连的形式,也就形成了我们所说的原型链

  • Object 函数是所有对象通过原型链追溯到最根的构造函数。即 “最后一个 prototype 对象”便是 Object 函数内的 prototype 对象了。

  • Object 函数内的 prototype 对象的 __proto__ 指向 null。 为什么 Object 函数不能像 Function 函数一样让 __proto__ 属性指向自己的 prototype?答案就是如果指向自己的 prototype,那当找不到某一属性时沿着原型链寻找的时候就会进入死循环,所以必须指向 null,这个 null 其实就是个跳出条件。

还是上面的那张图,通过 Person 构造函数可以找到四条原型链:!!

第 1 条:person.__proto__ --> Person.prototype --> Person.prototype.__proto__ --> Object.prototype --> Object.prototype.__proto__ --> null

第 2 条:Person.__proto__ --> Function.prototype --> Function.prototype.__proto__ --> Object.prototype --> Object.prototype.__proto__ --> null

第 3 条:Function.__proto__ --> Function.prototype --> Function.prototype.__proto__ --> Object.prototype --> Object.prototype.__proto__ --> null

第 4 条:Object.__proto__ --> Function.prototype --> Function.prototype.__proto__ --> Object.prototype --> Object.prototype.__proto__ --> null (“我中有你,你中有我”。。。)

下面我们简单看一下实例对象 p 上都有什么

function P(){};
let p = new P(); p;
// ↓↓↓
> P {}
  > __proto__: Object // 实例对象p上有 __proto__ 属性,指向构造函数的原型对象prototype
    > constructor: ƒ P() // prototype 上有公共方法 constructor
    > __proto__: // prototype 上还有 __proto__ 属性,指向内置Object的原型对象
      > constructor: ƒ Object() // 内置Object的原型对象上的constructor 以及hasOwnProperty等公共方法
      > hasOwnProperty: ƒ hasOwnProperty()
      ...

那么构造函数的属性和方法放在 this 上和放在 prototype 上有什么区别?

其实是都可以的,区别在于 this 定义的属性和方法是生成的实例自己独有的属性和方法;而定义在 prototype 上的属性和方法,是每个实例所共有的。那么定义在 prototype 上的属性和方法发生改变则每个实例对象都会拿到。

举个例子:

function Person() {
  this.age = 0;
  this.list = [];
}
let p1 = new Person();
p1.list.push(1);
p1.list; // [1]

let p2 = new Person();
p2.list.push(2);
p2.list; // [2]

定义在 prototype 上的属性和方法,是每个实例所共有的:

function Person() {
  this.age = 0;
}
Person.prototype.list = [];

let p1 = new Person();
p1.list.push(1);
p1.list; // [1]

let p2 = new Person();
p2.list.push(2);
p2.list; // [1,2]

前面 1~5 节总结一下!!划重点!!!

  1. 函数/对象上都有什么呢?
  • 对象都有 __proto__ 属性
  • 函数也有 __proto__ 属性,还有原型对象 prototype
  • 原型对象 prototype上也有 __proto__ 属性、还有 constructor 属性、以及一些共享属性和方法(挂在 prototype 上)
  1. __proto__ 是浏览器实现的查看原型方案,也写成 [[prototype]]__proto__ 属性的作用就是,当访问一个对象的属性时,如果该对象内部不存在这个属性,那么就会去它的__proto__属性所指向的那个对象(父对象)里找,一直找,直到__proto__属性的终点 null,再往上找就相当于在 null 上取值,会报错。通过__proto__属性将对象连接起来的这条链路即我们所谓的原型链。

  2. constructor 属性其实就是一个拿来保存自己构造函数引用的属性。实例对象的 constructor 属性指向构造函数本身。

  3. 原型对象 prototype 的作用就是,让该函数所实例化的对象们都可以找到公用的属性和方法

第二部分 new 操作符到底做了什么?

new 操作符原理解析

结合前面原理的分析,我们来看下 new 关键字都实现了哪些功能呢?

function Person(age) {
  this.age = age;
}
let p = new Person(20);
p; // Person {age:20}
  1. 首先创建了一个空对象 p = {}
  2. 然后将 p 的 __proto__ 属性指向其构造函数 Person 的原型对象 prototype
  3. 将构造函数内部的 this 绑定到新对象 p 上面,执行构造函数 Person()(其实和调动普通函数一样,并传值 this.age = 20
  4. 若构造函数返回的是非引用类型,则返回该新建的对象 p;否则返回引用类型的值。

手写一个 new 的实现

不会手写的原理,不是真的理解了的原理,下面我们开始手动实现~

function Person(name, age) {
  this.name = name;
  this.age = age;
  // 构造函数本身也可能有返回结果
  // return {
  //   name,
  //   age,
  //   test:123
  // }
}

function _new(Func, ...rest) {
  // 1. 定义一个实例对象
  let obj = {};
  // 2. 手动将实例对象中的__proto__属性指向相应原型对象
  // 此时 obj.constructor 就指向了 Person 函数,即 obj 已经承认 Person 函数是它自己的构造函数
  obj.__proto__ = Person.prototype;
  // 3. obj 需要能够调用构造函数私有属性/方法
  // 也就是需要在实例对象的执行环境内调用构造函数,添加构造函数设置的私有属性/方法
  let res = Person.apply(obj, rest);
  // 4. 如果构造函数内返回的是对象,则直接返回原返回结果(和直接调用函数一样);否则返回新对象。
  return res instanceof Object ? res : obj;
}

let p = _new(Person, "张三", "20");
p; // Person{age: "20", name: "张三"}
p.constructor; // ƒ Person() {}

上述第 3 步,其实构造函数是一种特殊的方法,主要用来在创建对象时初始化对象,即为对象成员变量赋初始值。 函数声明后函数体内的语句并不会立即执行,而是在真正调用时才执行。如果不调用的话,此时 this 没有指向,age 也是没有值的。通过 apply 方法完成了函数调用,并为自己的对象成员变量赋初始值,同时将 this 的指向绑定到实例对象 p 上面了。

第三部分 原型链继承

ES6 的 Class 可以通过 extends 关键字实现继承,这比ES5 的通过修改原型链实现继承,实际上要清晰和方便很多。 但文本主要分析在 Class 出现之前,JS 是如何实现继承的。在后续系列文章中会针对 ES6 中的 Class 继承再进一步扩展。

ES5 的继承,实质是先创造子类的实例对象 this,然后再将父类的方法添加到 this 上面(Parent.apply(this))。

1. 使用 call 方法实现继承

function Father() {
  this.id = "1999";
}
function Son(age) {
  this.age = age;
  // 通过调用父函数的 call 方法来实现继承
  Father.call(this);
}
let p = new Son(20);
p; // Son {age: 20, id: "1999"} 拥有了父级的私有属性
p.id; // 1999

说明: 实例化一个对象 p,实现了两步:Son 内 this 指向 p,Father this 指向 Son。所以 p 有了 age、id 属性。

2. 原型链继承

function Father(id) {
  this.id = id;
}
function Son(age) {
  this.age = age;
}
// Son函数改变自己的prototype指向
// 实际上是Son.prototype.__proto__ 指向了 Father的实例,而 Father的实例指向Father的原型对象。
// 即 Son.prototype.__proto__  == Father.prototype(改变了原型链流向), 看图。。。
Son.prototype = new Father("1999");
let p = new Son(20);

p; // Son {age: 20} 和第一种方式不同,p上面虽没有父级的私有属性id,但是却能访问到
p.id; // 1999
p.__proto__; // Father {id: "1999"}

p.hasOwnProperty("age"); // true
p.hasOwnProperty("id"); // false

不知道你是否也有这样的疑惑,为什么不是写成 p.__proto__.id 而是 p.id 就能获取到呢? 这就是原型链的查找过程了:

  1. 实例对象直接查找 p.id--> 发现没有该属性 --> 通过 __proto__ 属性去创建它的构造函数的 Son.prototype 对象上查找 --> Son.prototype 指向了 Father 的实例对象,有前文 new 方法原理可知,Father 实例上有私有属性 id, 所以 p.id 通过原型链查找到了 id 属性,而不用写成 p.__proto__.id 的形式。

  2. 同理,实例对象直接查找 p.age--> 当实例对象没有某一属性 --> 通过 __proto__ 属性去创建它的构造函数的 prototype 对象上查找 --> prototype 对象上没有的话, prototype对象本身也有一个 __proto__ 属性指向它自己的原型对象(目前是内置 Object 对象),Object 对象的prototype上面有着构造函数留下的共享属性和方法。比如 hasOwnProperty()、valueof()等

  3. __proto__属性的作用就是当访问一个对象的属性时,如果该对象内部不存在这个属性,那么就会去它的 __proto__属性所指向的那个对象(父对象)里找,一直找,直到__proto__属性的终点 null,再往上找就相当于在 null 上取值,会报错。通过__proto__属性将对象连接起来的这条链路即我们所谓的原型链。

知识点基本就到这里了!!

总结

本文从三部分分析了原型链的基本原理、new 关键字是如何实现的、以及 ES5 中的继承实现方式。

那问题来了,ES6 做了哪些改变? Class 原理、extends 继承原理、语法糖怎么实现的呢,有了本文的基础,再理解后面的这些概念希望能够得心应手,真正理解 JS 这些核心的机制。

参考

继承与原型链

对象原型

用自己的方式(图)理解 constructor、prototype、proto和原型链 (本文图片均参考自这篇文章~)

帮你彻底搞懂 JS 中的 prototype、proto与 constructor(图解)