Skip to content

原型和原型链

经典真题

说一说你对 JS 中原型与原型链的理解?(美团 2019 年)

对一个构造函数实例化后,它的原型链指向什么?

生产对象的方式

不同的语言,生产对象的方式其实并不相同,整体来讲,可以分为两大类:

  1. 基于 生产对象
  2. 基于 原型 生产对象

1. 基于类生产对象

这种生产对象的方式可能是最常见的方式,很多语言中要生产一个对象,都需要先书写一个类,然后通过类来实例化对象。

Java
public class Person {

  private String name;
  private int age;

  public Person(String name, int age) {
    this.name = name;
    this.age = age;
  }

  public void sayHello() {
    System.out.println("我的名字是" + name + ",我今年" + age + "");
  }

  public static void main(String[] args) {
    Person p = new Person("张三", 18);
    p.sayHello();
  }
}
Python
class Person:
  def __init__(self, name, age):
      self.name = name
      self.age = age

  def say_hello(self):
      print(f"我的名字是{self.name},我今年{self.age}岁")

p = Person("张三", 18)
p.say_hello()
PHP
class Person {
  private $name;
  private $age;

  public function __construct($name, $age) {
      $this->name = $name;
      $this->age = $age;
  }

  public function sayHello() {
      echo "我的名字是" . $this->name . ",我今年" . $this->age . "\n";
  }
}

$p = new Person("张三", 18);
$p->sayHello();

可以看到,很多主流的编程语言,都是通过实例化类的方式来产生对象。

但是,这并非唯一的方式。

2. 基于原型生产对象

还有一种方式,则是基于原型来生产对象。

这种方式的核心思想就是:先有一个对象 A,然后你要生产一个新的对象 B,就先克隆一份对象 A 从而得到新对象 B,新的对象 B 可以添加新的属性或者方法,对于对象 B 而言,对象 A 就是自己的原型对象。

采用这种生产对象方式的语言,虽然不像上面所罗列的那些语言那么主流,但是也确确实实存在:

  1. Self - Self 语言彻底采用原型模式,没有类的概念,对象直接从其他对象克隆并可能修改。
  2. Lua - 虽然 Lua 中有模块和包的概念,但其表(table)结构可以用来实现原型式继承。
  3. Io - Io 是一个纯原型语言,所有对象都来自于克隆其他对象。
  4. Proto - 一个较少知的语言,专门设计为原型继承语言。

3. JS 生产对象

布兰登・艾奇在设计这门语言时,选择了 原型 的方式来生产对象,他给出的理由有两个:

  • 他自己本身是一个 Lisp 程序员,主要方向和兴趣是函数式编程,因此在编程范式上更喜欢属于声明式编程的函数式编程。
  • JS 设计的初衷,定位是一门面向非专业的开发人员(例如网页设计者)的语言。由于大部分网页设计者都没有任何的编程背景,因此这门语言应该尽可能的简单。

在 JS 中,你可以很轻松的查看一个对象的原型。

JavaScript
const obj = {};
console.log(obj.__proto__);

通过上面的例子,我们可以得出一个结论:在 JS 中,无论你这个对象是如何书写的,该对象都有自己的原型对象

在 ES5 中提供了一个 Object.create 方法,该方法的第二个参数就可以指定对象的原型对象。

JavaScript
const person = {
  arm: 2,
  legs: 2,
  walk() {
    console.log('walking');
  }
};

const john = Object.create(person, {
  name: {
    value: 'John',
    enumerable: true
  },
  age: {
    value: 18,
    enumerable: true
  }
});
console.log(john.__proto__ === person); // true

布兰登・艾奇最初在设计这门语言时的构想是很美好的,但是现实是很残酷的。

当时的大环境下,流行的是“基于类生产对象”的方式,其中又以 Java、C++ 这样的语言最为代表。另外,当时网景公司的整个管理层,都是 Java 语言的信徒,因此在 1995 年 5 月,网景公司做出决策,未来的网页脚本语言必须“看上去与 Java 足够相似”,但是需要比 Java 简单。

没办法,受到了公司高层的命令,布兰登・艾奇游不得不对 JS 进行改造,添加了 this、new 这些关键字,使其看上去像是基于类生产的对象。不过早期没有 class 关键字,怎么办呢?没错,就是使用 function 来模拟类,为了区分普通函数,一个不成文的规定就是构造函数名称首字母大写。

JavaScript
function Computer(name, price) {
  this.name = name;
  this.price = price;
}

这里的构造函数本身是普通的函数,但如果你使用 new 的方式来调用,执行机制则和普通的函数调用不一样,会经历如下的步骤:

  1. 创建一个空的简单 JS 对象(即 { } )。
  2. 为步骤 1 新创建的对象添加属性 __proto__,将该属性链接至构造函数的原型对象。
  3. 将步骤 1 新创建的对象作为 this 的上下文。
  4. 如果该函数没有返回对象,则返回 this。
JavaScript
function Computer(name, price) {
  // 1. 创建一个普通的对象
  // const obj = {};

  // 2. 设置该对象的原型对象
  // obj.__proto__ = Computer.prototype;

  // 3. 设置 this 的指向,指向该 obj
  // this ---> obj
  this.name = name; // {name: "华为"}
  this.price = price; // {name: "华为", price: 5000}

  // 4. 如果代码里面没有返回对象,那么返回该 this
  // return this;
}
const huawei = new Computer('华为', 5000);
console.log(huawei);

这其实就是 JS 中函数二义性的由来

不过,不管 JS 如何模拟面向对象的特性,哪怕 ES6 甚至新增了 class 关键字:

JavaScript
class Computer {
  constructor(name, price) {
    this.name = name;
    this.price = price;
  }
}
const huawei = new Computer('华为', 5000);
console.log(huawei);

JS 底层仍然是一门基于原型的语言,这一点是不会改变的。现在不会变,未来,也不会变。

原型对象与原型链

1. 三角关系

假设对象是由构造函数生产的,前面我们说过,那只是表象,只是模拟,最终底层仍然采用的是原型的方式。并且构造函数、实例对象以及原型对象这三者之间,还存在一个著名的三角关系,如下图所示:

alt

这里的三角关系指的是:

  1. 构造函数
  2. 实例对象
  3. 原型对象

这三者之间的关系。在 JS 中,只要是由构造函数 new 出来的对象,都满足这样的关系,不管你是自定义构造函数还是内置的构造函数。

JavaScript
function Computer() {}
const c = new Computer();
console.log(c.__proto__ === Computer.prototype);
console.log(c.constructor === Computer);
console.log(c.constructor === Computer.prototype.constructor);
console.log([].__proto__ === Array.prototype);
console.log([].constructor === Array);
console.log((1).__proto__ === Number.prototype);
console.log((1).constructor === Number);
console.log(true.__proto__ === Boolean.prototype);
console.log(true.constructor === Boolean);

2. 原型链全貌图

整个原型链的全貌图如下:

alt

  • JS 中的对象大体上分为两大类:普通对象构造器对象

  • 无论是 普通对象 还是 构造器对象,都会有自己的原型对象,通过 __proto__ 这个隐式属性,就能找到自己的原型对象,并且 一直向上找,最终会到达 null

  • 普通对象 和 构造器对象 的区别在于是否能够实例化,构造器对象 可以通过 new 的形式创建新的实例对象,这些实例对象的原型对象一直往上找最终仍然是到达 null。

  • 只有构造器对象才有 prototype 属性,其 prototype 属性指向实例对象的原型对象。

  • 所有构造器对象的原型对象均为 Function.prototype

  • 无论是普通对象还是构造器对象,最终的 constructor 指向 Function,而 Function 的 constructor 指向自己本身。

  • Object 这个构造器对象比较特殊,实例化出来的对象的原型对象直接就是 Object.prototype,而其他的构造器对象,其实例对象的原型对象为对应的 xxx.prototype,再往一层才是 Object.prototype。

3. 原型链实际应用

学习原型相关的知识有什么用?

其实你只有了解了原型,你才能深刻的理解为什么方法要挂在原型对象上面。

例如:

JavaScript
function Computer(name, price) {
  this.name = name;
  this.price = price;
}
Computer.prototype.showPrice = function () {
  console.log(`${this.name}的电脑价格为${this.price}`);
};

const huawei = new Computer('华为', 5000);
const apple = new Computer('苹果', 8000);

之所以要挂在原型对象上面,是因为由构造函数实例化出来的每一个实例对象,属性值是不相同的,所以需要每个对象独立有一份。

但是对于方法而言,所有对象都是相同的,因此我们不需要每个对象拥有一份,直接挂在原型对象上面共用一份即可。

如下图所示:

alt

你现在也就能够理解,为什么所有的构造函数内置方法都是挂在原型对象上面的。

例如:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array

另外,虽然我们能够轻松的给内置的构造器函数添加属性和方法:

JavaScript
Number.prototype.isEven = function () {
  return this % 2 === 0;
};
Number.prototype.isOdd = function () {
  return this % 2 === 1;
};
const i = 42;
console.log(i.isEven()); // true
const j = 13;
console.log(j.isOdd()); // true

但是目前 JS 社区的大部分人都不推荐这么做,这样的做法往往被称之猴子补丁(monkey-patching)。

大部分人的观点是“别耍流氓,不是你的对象别动手动脚”。

一种更好的最佳实践是继承想要修改的构造函数,在子类上面添加新的方法:

JavaScript
class myNum extends Number {
  constructor(...args) {
    super(...args);
  }
  zhangsan() {}
}
const i = new myNum(1);
i.zhangsan();

原型链相关方法

  1. Object.getPrototypeOf( )

    该方法用于查找一个对象的原型对象。

    JavaScript
    function Computer() {}
    const c = new Computer();
    console.log(Object.getPrototypeOf(c) === c.__proto__);
  2. instanceof 操作符

    判断一个对象是否是一个构造函数的实例。如果是返回 true,否则就返回 false

    JavaScript
    function Computer() {}
    const c = new Computer();
    console.log(c instanceof Computer); // true
    console.log(c instanceof Array); // false
    console.log([] instanceof Array); // true
  3. isPrototypeOf( )

    主要用于检测一个对象是否是一个另一个对象的原型对象,如果是返回 true,否则就返回 false

    JavaScript
    function Computer() {}
    const c = new Computer();
    console.log(Computer.prototype.isPrototypeOf(c)); // true
    console.log(Computer.prototype.isPrototypeOf([])); // false
    console.log(Array.prototype.isPrototypeOf([])); // true
  4. hasOwnProperty( )

    判断一个属性是定义在对象本身上面还是从原型对象上面继承而来的。

    如果是本身的,则返回 true,如果是继承而来的,则返回 false

    JavaScript
    const person = {
      arm: 2,
      legs: 2,
      walk() {
        console.log('walking');
      }
    };
    
    const john = Object.create(person, {
      name: {
        value: 'John',
        enumerable: true
      },
      age: {
        value: 18,
        enumerable: true
      }
    });
    console.log(john.hasOwnProperty('name')); // true
    console.log(john.hasOwnProperty('arms')); // false