原型

原型与类 属于 JavaScript

32 个练习

关于 原型与类

JavaScript 具备面向对象编程(OOP)的能力。 在 OOP 中,你会想从“模板”(类)创建对象(实例),让它们包含特定的数据和功能。 在 OOP 语境中,数据属性称为_字段_,函数属性称为_方法_。

在 2015 年类被加入语言规范之前,JavaScript 根本没有类,但它允许使用基于原型的继承来进行面向对象编程。 尽管如今已经有了class关键字,JavaScript 仍然是一门_基于原型_的语言。

为了理解基于原型意味着什么,以及 JavaScript 实际是如何工作的,我们要回到还没有类的那个年代。

原型语法

构造函数

在 JavaScript 中,模板(类)是由一个普通函数实现的。 当一个函数被用作这样的模板时,它被称为_构造函数_,按照约定,函数名应以大写字母开头。 调用构造函数时,使用new关键字从模板派生出实例(对象)。

function Car() {
  // ...
}

const myCar = new Car();
const yourCar = new Car();

需要注意的是,在 JavaScript 中,即使实例创建之后,实例和构造函数之间仍然保持着关系。 每个实例对象都包含一个隐藏的内部属性,在语言规范中称为[[prototype]]。 它保存着对构造函数prototype键的值的引用。 没错,你没看错,JavaScript 函数可以有键值对,因为它在背后也是一个对象。

从 2015 年起,可以通过Object.getPrototypeOf()访问[[prototype]]。 在此之前,在许多环境中可以通过__proto__键访问它。

不要把对象的原型([[prototype]])和构造函数的prototype属性混淆。

Note

总结一下:

  • JavaScript 中的构造函数是普通函数。
  • 构造一个新实例会创建一个对象,它与构造函数之间有一种称为_原型_的关系。
  • 函数是对象(可调用对象),因此它们可以有属性。
  • 构造函数的prototype属性会成为实例的_原型_。

实例字段

通常,你会希望所有派生出的对象(实例)都包含一些字段,并在构造对象时为这些字段传入初始值。 这可以通过this关键字来实现。 在构造函数内部,this代表将通过new创建的新对象。 当用new调用构造函数时,this会自动从构造函数返回。

这意味着我们可以通过在构造函数中把它们添加到this上来给新实例添加字段。

function Car(color, weight) {
  this.color = color;
  this.weight = weight;
  this.engineRunning = false;
}

const myCar = new Car('red', '2mt');
myCar.color;
// => 'red'
myCar.engineRunning;
// => false

实例方法

方法通过构造函数的prototype属性添加。 在方法内部,你可以通过this访问实例的字段。 这之所以可行,是因为下面这条通用规则。

当一个函数作为对象的方法被调用时,它的this会被设置为调用该方法的对象。1

function Car() {
  this.engineRunning = false;
  // ...
}

Car.prototype.startEngine = function () {
  this.engineRunning = true;
};

Car.prototype.addGas = function (litre) {
  // ...
};

const myCar = new Car();
myCar.startEngine();
myCar.engineRunning;
// => true

原型链

上面例子中的myCar是一个普通的 JavaScript 对象,如果我们检查它(比如在浏览器控制台里),我们不会在myCar对象里直接找到值为函数的startEngine属性。 那么上面的代码到底是怎么工作的呢?

这里的秘密叫作_原型链_。 当你试图访问对象的任何属性(字段或方法)时,JavaScript 首先检查该键能否直接在对象本身中找到。 如果找不到,它会继续在原始对象的[[prototype]]属性所引用的对象中查找该键。 如前所述,在我们的例子中,[[prototype]]指向构造函数的prototype属性。 JavaScript 就是在这里找到startEngine函数的,因为我们把它加在了那里。

function Car() {
  // ...
}

Car.prototype.startEngine = function () {
  // ...
};

这条链并不止于此。 Car.prototype的[[prototype]]属性(myCar.[[prototype]].[[prototype]])引用Object.prototype(Object构造函数的prototype属性)。 它包含所有 JavaScript 对象都可用的通用方法,例如toString()。 Object的[[prototype]]通常是null,所以原型链到此结束。 总之,你可以调用myCar.toString(),该方法会存在,因为 JavaScript 会沿着整条原型链搜索该方法。 你可以在 MDN 文章“Inheritance and the prototype chain”中找到详细的示例。

Caution

注意,只有在读取值时才会沿着原型链查找。 直接设置属性或删除实例对象的属性只作用于那个特定的实例。 如果你习惯了基于类的继承语言,这可能与你的预期不符。

动态方法(为所有现有实例添加方法)

JavaScript 允许你在所有现有实例创建之后仍为它们添加方法。

我们了解到,每个实例都保存着对构造函数prototype属性的引用。 这意味着,如果你向那个prototype对象添加一个条目,这个新条目(例如一个新方法)会立即对从该构造函数创建的所有实例可用。

function Car() {
  this.engineRunning = false;
}

const myCar = new Car();
// Calling myCar.startEngine() here would result in "TypeError:
// myCar.startEngine is not a function".

Car.prototype.startEngine = function () {
  this.engineRunning = true;
};

myCar.startEngine();
// This works, even though myCar was created before the method
// was added.

理论上,动态方法甚至可以用来扩展内置对象(如Object或Array)的功能,方法是修改它们的原型。 这被称为_猴子补丁_。 由于这种更改会影响整个应用,应避免这样做,以防出现意外的副作用。 唯一合理的用例是为旧环境中缺失的方法提供 polyfill。

类语法

如今,JavaScript 支持用class关键字定义类。 这是 2015 年加入语言规范的。 一方面,它提供了语法糖,使类更易于阅读和编写。 新语法更接近 C++ 或 Java 等语言中类的写法。 从这些语言转过来的开发者更容易适应。 另一方面,类语法为原型语法中无法实现的新语言特性铺平了道路。

类声明

使用新语法时,类用class关键字定义,后面跟类名和花括号中的类体。 类体包含构造函数的定义,即一个名为constructor的特殊方法。 这个函数的工作方式与原型语法中的构造函数完全一样。 类体还包含所有的方法定义。 方法的语法类似于我们见过的、在对象内部把函数作为值添加的简写形式,参见对象概念。

class Car {
  constructor(color, weight) {
    this.color = color;
    this.weight = weight;
    this.engineRunning = false;
  }

  startEngine() {
    this.engineRunning = true;
  }

  addGas(litre) {
    // ...
  }
}

const myCar = new Car();
myCar.startEngine();
myCar.engineRunning;
// => true

与函数声明和函数表达式类似,除了上面展示的_类声明_之外,JavaScript 还支持类表达式。

请记住,在背后,JavaScript 仍然是一门基于原型的语言。 我们在上面“原型语法”一节中学到的所有机制仍然适用。

私有字段、Getter 和 Setter

默认情况下,JavaScript 中所有实例字段都是公开的。 它们可以被直接访问和赋值。

向语言规范中添加真正的私有字段仍在进行中,详情请见提案文档。

在此期间,你可以使用一个约定俗成的惯例:以下划线开头的字段和方法应被视为私有。 不应从类外部直接访问它们。

私有字段有时会搭配 getter 和 setter。 使用get和set关键字,你可以定义一些函数,当访问或赋值与函数同名的属性时,这些函数会被执行。

class Car {
  constructor() {
    this._mileage = 0;
  }

  get mileage() {
    return this._mileage;
  }

  set mileage(value) {
    throw new Error(`Mileage cannot be manipulated, ${value} is ignored.`);
    // Just an example, usually you would not provide a setter in this case.
  }
}

const myCar = new Car();
myCar.mileage;
// => 0
myCar.mileage = 100;
// => Error: Mileage cannot be manipulated, 100 is ignored.

类字段和类方法

在 OOP 中,你有时想提供一些不依赖于特定实例的工具字段或方法。 它们是为类本身定义的。 这可以通过static关键字实现。

class Car {
  static type = 'vehicle';

  static isType(targetType) {
    return targetType === 'vehicle';
  }
}

Car.type;
// => 'vehicle'

Car.isType('road sign');
// => false

基于类的继承

除了我们之前看到的沿原型链进行的继承之外,在 JavaScript 中你还可以表示类之间的继承。 这部分内容在继承概念中介绍。


  1. this示例 - 作为对象方法,MDN。https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this#as_an_object_method(参考日期:2021 年 12 月 3 日) ↩

通过 GitHub 编辑 该链接会在新窗口或标签页中打开

学习 原型与类