Наследование классов и прототипов JavaScript ES2015 часть 1

Что такое JavaScript ES2015?

После выхода окончательной версии спецификации ECMA Script 2015 (ES2015) сообщество получило возможность двигаться в направлении ее реализации в движках JavaScript.

ES2015 предлагает для существующих функций множество новых полезных возможностей и более чистый синтаксис. Например, ключевое слово class, а также улучшенный синтаксис для JavaScript prototype.

До ES2015 реализация наследования прототипов с помощью JavaScript была запутанной. В традиционной модели классы наследуются от классов. Классы являются не более чем спецификацией или шаблоном, используемым для создания объектов.

Спецификации могут наследовать свойства других спецификаций. Используя унаследованные и собственные свойства, можно создавать новые объекты. В отличие от традиционного наследования, JavaScript не включает в себя поддержку этих спецификаций.

Что такое наследование прототипов JavaScript?

Наследование прототипов в JavaScript предполагает, что один объект наследуется от другого объекта, вместо того, чтобы одна спецификация наследовалась от другой. Даже ключевое слово нового класса является некорректным, потому что подразумевает спецификацию. Но на самом деле один объект наследуется от другого. Синтаксис в более ранних версиях JavaScript был слишком сложным, и ему трудно было следовать. Поэтому, как только разработчики принимают наследование от объекта к объекту, возникает вторая задача. Она состоит в том, чтобы улучшить синтаксис JavaScript prototype наследования — ввести классы ES2015.

ES2015 классы в JavaScript

Данная спецификация обеспечивает более четкий синтаксис для определения структур классов, создания функций конструктора, расширения классов, вызова конструктора и функций в супер классе, а также предоставляет статические функции. Также ES2015 улучшает синтаксис для создания стиля ES5 получателя / установщика дескриптора свойств, что позволяет разработчикам использовать эти малоизвестные возможности спецификации.

Определения классов

JavaScript не содержит классов. Даже классы ES2015 это не совсем классы в традиционном смысле этого слова. А всего лишь «вычищенный» синтаксис для создания наследования прототипов между объектами. Но поскольку ES2015 использует термин «класс» для объектов, созданных с помощью функции конструктора (функция-конструктор является конечным результатом ключевого слова class), в этой статье мы будем использовать термин «класс«, чтобы описать не только классы ES2015, но и ES5.

В версии ES5 и более ранних функции конструктора определяли «классы» следующим образом:

function MyClass() { }

var myClass = new MyClass();

В ES2015 был введен новый синтаксис, с использованием ключевого слова class:

class MyClass {
  constructor() {  }
}
var myClass = new MyClass();

Нажмите здесь, чтобы загрузить код [typeof.js]

Функция конструктора осталась той же, что определена в ES5. В обернутом блоке ключевого слова class определяются свойства для JavaScript function prototype. Синтаксис ключевого слова new для установки нового экземпляра класса остался неизменным.

С введением ключевого слова class появляется объект функции, который используется ES5. Рассмотрим следующий выходной результат среды Node.js REPL. Во-первых, мы определяем новый класс, а затем оператор TypeOf перечисляет типы объекта класса:

> class MyClass { constructor() {} }
class MyClass { constructor() {} }
[Function: MyClass]
> typeof MyClass
'function'
>

В ES2015 роль и назначение функции конструктора не пересматривались, для нее просто был «вычищен» синтаксис.

Что такое конструкторы в JavaScript?

Конструктор — это функция, которая выполняется, когда используется оператор new для создания нового экземпляра класса. В функцию конструктора могут быть переданы аргументы для инициализации свойств объекта и выполнения других задач.

В ES5 функция конструктора выглядит следующим образом:

function Person(firstName, lastName) {
  this.firstName = firstName;
  this.lastName = lastName;
}

Аналог функции конструктора с синтаксисом ES2015 выглядит следующим образом:

// имя функции конструктора ES5 -
// это имя класса ES2015
class Person {

  // обратите внимание, что здесь нет ключевого слова "function"
  // также используется слово "constructor", а не "Person"
  constructor(firstName, lastName) {

    // этот код представляет новый созданный и
    // инициализированный объект
    this.firstName = firstName;
    this.lastName = lastName;

  }
}

Хотя новый синтаксис стал более объемным, но он является более четким и позволяет проще добавлять наследуемые свойства.

Чтобы установить объект с тем же синтаксисом, код должен быть тот же:

var person = new Person("Bob", "Smith");

// выводит "Bob"
console.log(person.firstName);

// выводит "Smith"
console.log(person.lastName);

Нажмите здесь, чтобы загрузить код [constructors.js]

Расширение классов

До ES2015 большинство разработчиков не понимали, как реализовать наследования между объектами и использовать JavaScript prototype. Пообщавшись с разработчиками на C ++, Java или C #, вы поймете, с какой легкостью они настраивают наследование одного класса от другого, а затем создают экземпляр объекта из подкласса. Попросите JavaScript разработчика продемонстрировать, как происходит наследование между двумя объектами, и в ответ вы увидите пустой взгляд.

Настройка наследования прототипов является непростым делом, а понятие наследования прототипа является неизвестным для большинства JavaScript разработчиков. Вот несколько примеров кода с комментариями, которые поясняют процесс настройки наследования:

// вызывается с оператором "new",
// создается новый объект Person

function Person(firstName, lastName) {
  // оператор "new" устанавливает связь
  // от "this" к новому объекту
  this.firstName = firstName;
  this.lastName = lastName;
}

// это свойство, связывающее функцию,
// конфигурируется для объекта прототипа Person,
// и наследуется Student
Person.prototype.getFullName = function() {
  return this.firstName + " " + this.lastName;
};

// Когда функция конструктора Student
// вызывается с оператором "new",
// создается новый объект Student

function Student(studentId, firstName, lastName) {
  // оператор "new" устанавливает связь от "this" к
  // новому объекту, новый объект затем передается в
  // функцию конструктора Person через использование вызова,
  // таким образом могут быть установлены свойства имени и фамилии
  this._super.call(this, firstName, lastName);
  this.studentId = studentId;
}

// Student наследуются от нового объекта,
// который наследуется от родительского
Student.prototype = Object.create(Person.prototype);

// устанавливаем свойства конструктора обратно для 
// функции конструктора Student
Student.prototype.constructor = Student;

// "_super" НЕ является частью ES5, его конвенция, определенная
// разработчиком, устанавливает
// "_super" для функции конструктора Person
Student.prototype._super = Person;

// это будет существовать в прототипе объекта студента
Student.prototype.getStudentInfo = function() {
  return this.studentId + " " + this.lastName + ", " + this.firstName;
};

// устанавливаем новый объект Student
var student = new Student(1, "Bob", "Smith");

// вызываем функцию в выводе родительского
// прототипа "Bob Smith"
console.log(student.getFullName());

// вызываем функцию в выводе родительского
// прототипа "1 Smith, Bob"
console.log(student.getStudentInfo());

Нажмите здесь, чтобы загрузить код [es5_inheritance.js]

Приведенный выше код трудно разобрать, и на то, чтобы установить наследование одного объекта от другого, поддерживая при этом функции конструктора, уходит много времени. Большинство JavaScript разработчиков не могут создать этот код по памяти, а многие вообще никогда не видели ничего подобного при работе с JavaScript.

Чтобы решить эту проблему, в новом синтаксисе структуры классов в ES2015 было введено ключевое слово extends. В следующем коде продемонстрировано то же наследование, что и в первом примере кода, но с использованием синтаксиса ES2015 для JavaScript object prototype:

"use strict";

class Person {

  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  getFullName() {
    return this.firstName + " " + this.lastName;
  }

}

class Student extends Person {

  constructor(studentId, firstName, lastName) {
    super(firstName, lastName);
    this.studentId = studentId;
  }

  getStudentInfo() {
    return this.studentId + " " + this.lastName + ", " + this.firstName;
  }

}

var student = new Student(1, "Bob", "Smith");
console.log(student.getFullName());
console.log(student.getStudentInfo());

Нажмите здесь, чтобы загрузить код [es6_inheritance.js]

Очевидно, что второй подход более прост для понимания. Оба кода воспроизводят одинаковую структуру объекта. Таким образом, новый синтаксис улучшает код наследования, но не меняет результат.

Другой способ изучить, как это работает — рассмотреть код наследования ES5, сгенерированный TypeScript. TypeScript – это препроцессорный язык, который оптимизирует JavaScript через строгую типизацию и транспиллинг кода ES2015 в код ES5. Транспилинг — это процесс компиляции исходного кода одного языка программирования в исходный код другого языка.

Функция _extends в JavaScript

Для поддержки наследования классов ES2015 TypeScript транспилирует функционал ключевого слова extends в функцию с именем __extends, которая запускает код, необходимый для настройки наследования. Вот код функции __extends:

var __extends = (this && this.__extends) || function (d, b) {
    for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
    function __() { this.constructor = d; }
    d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};

Приведенный выше код немного труден, поэтому ниже приводится его расширенная, задокументированная версия. Чтобы понять назначение каждой строки кода, прочтите комментарии, добавленные в исходный код JavaScript prototype. Функция __extends работает с любой парой родительских и дочерних объектов:

// объявляем переменную, чтобы связать функцию extends
var __extends;

if (this && this.__extends) {

	// функция extends уже определена в контексте
	// этого кода, поэтому используйте существующую функцию __extends
	__extends = this.__extends;

} else {

Остальное содержимое блока — это реализация функции __extends. Она использует как шаблон примеси и JavaScript prototype наследование, чтобы построить взаимосвязь наследования между родительским и дочерним объектами. Шаблон примеси копирует свойства из одного объекта в другой. Приведенный ниже код обрабатывается через функцию __extends:

// функция extends еще не определена в текущем контексте;
// поэтому определяем ее
__extends = function (child, parent) {

  // шаблон примеси для копирования свойств функции родительского конструктора
  // в качестве статических свойств для свойств функции дочернего конструктора
  // в функции конструктора часто называют статическим свойством
  for (var parentPropertyName in parent) {

    // только скопированные свойства отдельно определяются для родителя
    if (parent.hasOwnProperty(parentPropertyName)) {
      // для простейших типов этот код копирует значения,
      // для типов объектов этот код копирует только связи
      child[parentPropertyName] = parent[parentPropertyName];
    }

  }

  // функция конструктора для объекта, который установил дочерний объект,
  // наследуемый из этой функции,
  // является уникальной внутри контекста каждого вызова extend

  function __() {
    this.constructor = child;
  }

  if (parent === null) {

    // объект, установленный с помощью дочерней функции конструктора,
    // наследуется от объекта, который в свою очередь не наследуется ни от чего,
    // даже не от встроенного JavaScript Object
    child.prototype = Object.create(parent);

  } else {

    // назначаем свойства прототипа родительской функции конструктора
    // свойствам прототипа функции конструктора, определенной выше
    __.prototype = parent.prototype;

    // создаем объект, от которого наследуются все дочерние экземпляры, 
    // и назначаем его свойству прототипа дочерней функции
    // конструктора
    child.prototype = new __();

  }

};

Следующие две строки кода сбивают с толку многих разработчиков:

// назначаем свойство прототипа родительской функции конструктора
// свойству прототипа функции конструктора, определенной выше
__.prototype = parent.prototype;

// создаем объект, от которого наследуются все дочерние экземпляры
// и назначаем его свойству прототипа дочерней
// функции конструктора
child.prototype = new __();

Можно подумать, что вместо этого код должен быть составлен следующим образом:

// Этот код не даст нужного результата
child.prototype = parent.prototype;

Разработчики ошибочно полагают, что дочерний объект теперь будет наследоваться от объекта прототипа родительской функции конструктора. Но на самом деле объекты, созданные с помощью родительской функции конструктора, а также объекты, созданные с помощью дочерней функции конструктора, наследуются от точно такого же JavaScript object prototype. Это нежелательно, так как свойство прототипа дочерней функции конструктора не может быть изменено без одновременного изменения свойства прототипа родительской функции конструктора. Поэтому все изменения, внесенные в дочерний объект, будут также применены к родителю. Это некорректное наследование:

inheritance-structure

При создании нового экземпляра объекта с помощью оператора new и родительской или дочерней функции конструктора, полученные объекты будут наследоваться от того же объекта-прототипа (РРО). Установленные родительские и дочерние объекты являются объектами одного уровня с РРО в качестве родителя. Дочерний объект не наследуется от родителя.

Таким образом, целью данного кода является установить следующую структуру наследования:

__.prototype = parent.prototype;
child.prototype = new __();
correct-inheritance-structure

С помощью этой новой структуры новые дочерние объекты наследуются от CPO (объект прототипа потомков), который наследуется от РРО. Новые свойства могут быть добавлены в CPO, который не влияет на РРО. Новые родительские объекты наследуются от РРО, и не зависят от изменений в СРО. Изменения в РРО будут унаследованы объектом, созданным как с помощью родительской, так и с помощью дочерней функций конструктора. С помощью этой новой структуры дочерние объекты наследуются от родителя.

И в конце закрывающая фигурная скобка относится к изначальному блоку if:

}

Нажмите здесь, чтобы загрузить код [ts_extends.js]

Синтаксис ES2015 для расширения классов гораздо более прост для понимания JavaScript prototype. Он содержит два новых ключевых слов: extends и super. Ключевое слово extends устанавливает отношения наследования прототипа между родительскими и дочерними классами. Ключевое слово super вызывает конструктор для класса родителя (он же суперкласс). Вызов функции super требуется, даже если родительский объект не содержит конфигурацию.

Вызов супер конструктора это то, что создало этот новый объект, который будет использоваться в дочернем классе (он же подкласс):

class Person {

  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  getFullName() {
    return this.firstName + " " + this.lastName;
  }

}

// ключевое слово 'extends' устанавливает отношения наследования прототипа
// с супер объектом
// ключевое слово 'extends' выполняет те же базовые операции, как ранее
// функция '__extends'

class Student extends Person {

  constructor(studentId, firstName, lastName) {
    // 'super' должна быть первой функцией, вызываемой в конструкторе
    // 'super' вызывает конструктор супер класса
    // значение 'this' не определяется до тех пор, пока не вызвана 'super'
    super(firstName, lastName);

    // если эта строка выполняется до вызова описанной выше 'super'
    // выдается сообщение об ошибке, говорящее, что 'this' не определено
    this.studentId = studentId;
  }

  getStudentInfo() {
    return this.studentId + " " + this.lastName + ", " + this.firstName;
  }

}

var student = new Student(1, "Bob", "Smith");

Нажмите здесь, чтобы загрузить код [extends.js]

Объекты JavaScript позволяют только одиночное наследование, и не имеют встроенной поддержки интерфейсов (TypeScript предоставляет интерфейсы).

Классы ES2015 значительно улучшают синтаксис для определения свойств наследования объектов, свойств получателя / установщика. Хотя классы ES2015 не меняют характер наследования прототипа, они делает JavaScript prototype более доступным для JavaScript разработчиков.

Перевод статьи «JavaScript ES2015 Classes and Prototype Inheritance (Part 1 of 2)» был подготовлен дружной командой проекта Сайтостроение от А до Я.