JavaScript系列 - 原型链

298 阅读14分钟

本文主要讲述了 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 名词

类、构造函数、原型对象和实例对象。

  1. 类:与构造函数同名,代码中类名即为 Person
  2. 构造函数:在代码中 Person 函数通常被称之为构造函数。函数本身不是构造函数,只有在一个普通函数调用前,加上 new 关键字后,就会把这个函数调用变成一个 “构造函数调用”
  3. 原型对象:每一个构造函数都拥有一个原型对象。 构造函数.prototype 指向了原型对象。在代码中,即为 Person.pototype
  4. 实例对象:通过调用 new + 构造函数 而创建的实例化对象,就是实例对象。代码中 instance1instance2 就是实例对象。

1.2 关系

介绍上述四个名词之间的关系,即它们是如何联系在一起的。

1.2.1 指向原型对象

在代码中,原型对象是匿名的,没有一个可以直观看到的称呼。所以通常来讲,原型对象的表述形式是通过构造函数名:Person.prototype 来表示。原型对象的这个表述方式也阐述了 构造函数原型对象 的关系:

  1. Person.prototype ,即构造函数的 .prototype 属性,指向了原型对象。
  2. instance1.__proto__ ,即实例对象的 .__proto__ 属性,指向了原型对象。

1.2.2 指向构造函数

被创建的原型对象,默认会拥有一个不可枚举的属性 .constructor 指向构造函数。

  1. Person.prototype.constructor,即原型对象的 .constructor 属性指向了构造函数。

  2. 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 类的原型链

通过四张图片,描述原型链的具体过程。

首先解释一下图片中涉及到的模型:

  1. 一共有 3 个类,JavaScript 内置 Object、父类 Father、子类 Son,三者之间存在继承关系。
  2. 类的原型对象没有用 Object.prototype 的表述形式,而是用了 "Object 的原型对象" 。
  3. 每个构造函数列举了 3 个实例对象,命名方式为 构造函数名 + 数字,如:object1

原型链1.png

上图解释了实例对象是如何通过 .__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]] 原型链一步步向上遍历的。

原型链2.png

下图解释了:

  1. 构造函数通过 new 操作符创建了实例对象。
  2. 构造函数的 .prototype 属性值指向了 Object.prototype 即构造函数的原型对象。

原型链3.png

3 new 运算符

new 一个新对象的过程,发生了什么?

let person1 = new Person("Moxy", 15);

要创建 Person 的新实例,必须使用 new 操作符。以这种方式调用构造函数实际上会经历以下 5 个步骤:

  1. 创建一个 新对象 {}
  2. 为新对象绑定 原型链{}.__proto__ = Person.prototype
  3. 将构造函数的作用域 this 赋给新对象 {}
  4. 执行构造函数中的代码,为 {} 添加属性:Person.call(this)
  5. 如果构造函数最终会返回一个对象,就返回 构造函数中的对象
  6. 如果构造函数没有返回其他对象,就会返回 新对象

最终,代码中左侧的 person1 变量接收到了新创建的那个对象。

4 属性的设置和屏蔽

给对象更改某个属性,JavaScript 需要判断该属性:是否是已经存在的自有属性?是否是原型链上存在的继承属性?

person.name = "Moxy" 会触发 [[Put]] ,操作的完整过程是:

首先会判断对象中是否已存在该属性值,如果存在,则会执行 1;不存在,则会执行 2。

  1. 判断自有属性。如果 person 对象中存在一个同名的 数据属性,则该语句发生赋值行为,修改已有的这个属性。

  2. 判断继承属性。遍历 person 对象的原型链。

    1. 找不到。如果在原型链上找不到同名的 name 属性,则该语句发生创建行为,在 person 上创建新属性 name。否则执行 2.2;

    2. 找得到。如果在原型链上找得到同名的 name 属性,则又分为 3 种情况:

      1. 可写。如果找到的同名属性是 数据属性,且可写 writable:false 。则发生创建行为,在 person创建新属性 name
      2. 不可写。如果找到的同名属性是 数据属性,但不可写 writable:true。则该语句会被 静默忽略,在严格模式下报错:TypeError
      3. setter。如果找到的同名属性是一个有 setter 函数 访问器属性。则该语句会发生 setter 函数的调用。

总结:

  1. 所谓属性的 设置,就是修改了一个已存在的属性值; 所谓属性的 屏蔽,就是已知目标对象的原型链上存在一个同名属性,依然在目标对象上新建一个同名属性,则原型链上的同名属性被屏蔽。
  2. 所有 “数据属性” 都适用与该规则中,包括 基本数据类型引用属性类型(方法、数组、等等各种对象)。如果父类存在一个同名的 name 数组,执行 person.name = "Moxy" ,在 person 对象中创建的同名属性 name ,此时不再是一个引用属性类型数组,而是一个基本数据类型字符串。
  3. 规则 2.2.2 “不可写”的情况可以考虑为:如果继承属性在父类不允许写,则其继承的子类也不允许写。
  4. 规则 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 原型对象。这会导致:

  1. 原型对象的 constructor 属性丢失。它原本指向了构造函数,在重定义原型对象后,需要手动再指定一下 constructor 属性。
  2. 重定义原型对象的前后,实例化对象会指向不同的原型对象,造成表现不一致。
// 不要下面这样重定义原型对象:
A.prototype = {
    n: 2,
    m: 3
}
// 要像这样给原型对象添加属性、方法
A.prototype.n = 2;
A.prototype.m = 3;

答案:

console.log(a1.n); // 1
console.log(a1.m); // undefinedconsole.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()

解答:

  1. F 是一个构造函数,属于函数类型,是一个对象;
  2. f 是一个实例对象,属于 F 类型,是一个对象。

所有对象,都是 Object 对象。所以所有对象都继承自 Object,包括了题中的 Ff。那么在 Object 原型链上的 a 方法,fF 都可以调用;

构造函数 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 __ 等于什么?

解答:

和第二题是一致的:

PersonFunction 类,所以其原型链指向了 Function 的原型对象 Function.prototype

pPerson 类,其原型链指向了 Person 的原型对象 Person.prototype

引申:

p.__proto__.__proto__ === Person.__proto__.__proto__ // true

  • Function.prototype,是 Object 类,所以其原型链指向了 Object.prototype
  • Person.prototype,是 Object 类,所以其原型链指向了 Object.prototype

也就是说,PersonFunction 的原型对象,指向了同一个对象,就是 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(); 

解答:

  1. fn1fn2 是一个对象,也是一个函数。 fn1 当成对象看、 fn1() 当成函数看。
  2. fn1.call(fn2), 是执行了 fn1()。同时, fn1 内部的 this 值,指向了 fn2 对象。
  3. 这就导致了 numsayHey 这两个属性都赋值给了 对象 fn2。所以 fn2 作为一个对象,此时拥有了 numsayHey 这两个属性。
  4. fn1 此时没有赋值其他属性,所以 fn1.xxx 都未定义;fn2.numfn2.sayHey 存在。

答案:

fn1.call(fn2);  // 1 ,这里执行了 fn1() 自然会输出数字1.fn1();      // 1
fn1.num;    // undefined
fn1.sayHey();   // fn1.sayHey is not a functionfn2();      // 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 看代码解答:

按照如下要求实现 PersonStudent 对象

  • Student 继承 Person
  • Person 包含一个实例变量 name, 包含一个方法 printName
  • Student 包含一个实例变量 score, 包含一个方法 printScore
  • 所有 PersonStudent 对象之间共享 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 = StudentStudent.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高级程序设计 第四版》

🍭 图解原型和原型链 (juejin.cn)