Перевод статьи The Complete Guide to JavaScript Classes.

Язык JavaScript использует прототипное наследование, то есть любой объект наследует свойства и методы своего объекта-прототипа. Традиционных классов, как шаблонов для создания объектов, которые используются в Java или Swift, в JavaScript не существует.

Напомним, что прототипный тип наследования имеет дело только с объектами. И прототипное наследование может лишь эмулировать классическое наследование классов. Для того, чтобы наконец реализовать традиционные классы в JavaScript, стандарт ES2015 ввёл синтаксис класса, однако он по сути является своеобразным синтаксическим сахаром над прототипным наследованием.

Этот пост познакомит вас с классами JavaScript: как определить класс, инициализировать новый экземпляр класса, определить его поля и методы, дадим понятие о реализации приватных и публичных полей, а также статических полей и методов.

1. Определение: ключевое слово класса

В примере ниже определяется класс User. Фигурные скобки { } отделяют код с телом класса. Обратите внимание, что этот синтаксис называется объявлением класса (class declaration).

class User {

}

При этом вам не обязательно указывать имя класса. Используя выражение класса (class expression), вы можете присвоить имя класса любой переменной:

const UserClass = class {

};

Так же вы можете легко экспортировать класс как часть модуля ES2015.

Синтаксис экспорта по умолчанию default export:

export default class User {
 
}

А вот именованная форма экспорта класса named export:

export class User {
  
}

Использование класса становится действительно полезным, если вы можете создавать экземпляры класса. Экземпляр — это объект, содержащий данные и поведение, описанные классом.

В языке JavaScript оператор new создает экземпляр класса с использованием синтаксиса вида:

let instance = new Class()

Например, вы можете создать экземпляр класса User с помощью оператора new следующим образом:

const myUser = new User();

2. Инициализация: constructor()

Конструктор constructor(param1, param2, ...) — это специальный метод определяемый классом, который инициализирует его экземпляр. И это то, самое место в вашем коде, где вы можете установить любые начальные значения для полей экземпляра класса или можете выполнить любую настройку его свойств.

В следующем примере конструктор устанавливает начальное значение для поля name:

class User{
  constructor(name){    
      this.name = name;  
  }
}

Конструктор класса User принимает единственный параметр name, который используется для инициализации начального значения поля this.name.

Внутри конструктора значение ключевого слова this эквивалентно вновь созданному экземпляру (а точнее представляет собой ссылку на него).

Аргументы, используемые для создания экземпляра класса, являются параметрами его конструктора:

class User {
  constructor(name) {
    name;     
    this.name = name; // => 'Jon Snow'
  }
}

const user = new User('Jon Snow');

Параметр name внутри конструктора получает значение Jon Snow.

Если вы не определяете конструктор для класса, то создается конструктор по умолчанию. Конструктор по умолчанию является пустой функцией, которая при создании экземпляра никак его не изменяет.

В то же время класс JavaScript может иметь только один конструктор.

3. Поля

Поля класса являются его внутренними переменными, которые содержат его внутреннюю информацию. Поля могут быть привязаны к сущностям двух видов:

  1. Поля собственно экземпляра класса.
  2. Поля в объявлении класса (то есть статические поля).

Также поля имеют два уровня доступности:

  1. Публичное поле: поле доступное везде.
  2. Приватное поле: поле доступное только внутри тела класса.

3.1 Публичные поля экземпляра класса

Давайте снова взглянем на предыдущий фрагмент кода:

class User {
  constructor(name) {
    this.name = name;  }
}

Выражение this.name = name создает поле в экземпляре класса и присваивает ему начальное значение.

Позже вы можете получить доступ к полю name с помощью метода доступа к свойству:

const user = new User('Jon Snow');
user.name; // => 'Jon Snow'

Поле name является публичным public field и поэтому вы можете получить к нему доступ вне тела класса User.

Когда поля создаются неявно внутри конструктора, как в предыдущем случае, то может быть трудно определить весь список полей, содержащихся в классе. Вы можете сделать только исходя из содержимого кода конструктора.


Наилучшим подходом является явное объявление полей класса. Независимо от того, что делает конструктор, экземпляр всегда имеет одинаковый набор полей.

Метод предложения полей класса class fields proposal позволяет очень наглядно определять поля внутри тела класса. Кроме того, вы можете сразу указать начальное значение:

class SomeClass {
  field1;
  field2 = 'Initial value';

  // ...
}

Давайте изменим наш класс User и объявим публичное поле name:

class User {
  name;
  
  constructor(name) {
    this.name = name;
  }
}

const user = new User('Jon Snow');
user.name; // => 'Jon Snow'

Инструкция name; внутри тела класса объявляет name публичное поле.

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

Более того, поле класса может быть инициализировано сразу при его объявлении.

class User {
  name = 'Unknown';
  constructor() { // инициализации данных нет
  }
}

const user = new User();
user.name; // => 'Unknown'

Инструкция name = 'Unknown' помещенная внутри тела класса, объявляет поле name и инициализирует его значением 'Unknown'.

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

3.2 Приватные поля экземпляра класса

Инкапсуляция — это важная концепция, которая позволяет скрыть внутренние детали реализации класса. Тот, кто в последствии будет использовать инкапсулированный класс при разработке своего приложения, может взаимодействовать только с открытым интерфейсом, который предоставляет ваш класс, и никак не связан с его деталями реализации.

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

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

Приватные поля private fields доступны только внутри тела класса.

Внимание! Эта новая возможность была добавлена в язык недавно. В движках Javascript пока поддерживается частично и поэтому для её использования нужен соответствующий полифил.

Добавьте префикс, представляющий собой специальный символ #, к имени поля для того, чтобы сделать его приватным, например #myField. При этом префикс # должен использоваться каждый раз, когда вы обращаетесь к приватному полю: объявляете его, читаете или изменяете его значение.

Теперь давайте убедимся в том, что нашему полю #name мы можем присвоить значение лишь один раз: при инициализации экземпляра класса.

class User {
  #name;
  constructor(name) {
    this.#name = name;
  }

  getName() {
    return this.#name;
  }
}

const user = new User('Jon Snow');
user.getName(); // => 'Jon Snow'

user.#name; // получаем исключение SyntaxError

Таким образом, видим, что действительно наше поле #name является приватным (в различной литературе могут так же использоваться другие термины, по сути являющиеся одним и тем же: скрытые или закрытые поля). Вы можете получить доступ или изменить #name только внутри класса User. При этом метод getName() (подробнее о методах поговорим в следующем разделе), реализованный в нашем классе, имеет полный доступ к полю #name.

Однако, если вы попытаетесь получить доступ к закрытому поле #name вне нашего класса User, то генерируется исключение с типом ошибка синтаксиса: SyntaxError: Private field '#name' must be declared in an enclosing class.

3.3 Публичные статические поля

Внутри класса вы можете определять статические поля static fields. Этот тип полей полезен для определения констант класса или хранения информации, специфичной для него.

Для создания статических полей в классе JavaScript, используется специальное ключевое слово , static за которой следует имя поля: static myStaticField.

Давайте добавим новое поле, type которое будет указывать на тип пользователя сайта: администратор или обычный (гость). Статические поля TYPE_ADMIN и TYPE_REGULAR удобно использовать в качестве констант для обозначения типа пользователя следующим образом:

class User {
  static TYPE_ADMIN = 'admin';  
  statiс TYPE_REGULAR = 'regular';
  name;
  type;

  constructor(name, type) {
    this.name = name;
    this.type = type;
  }
}

const admin = new User('Site Admin', User.TYPE_ADMIN);
admin.type === User.TYPE_ADMIN; // => true

В примере кода выше мы внутри класса User определяем статические поля static TYPE_ADMIN и static TYPE_REGULAR. Теперь для получения доступа к их значениям, мы можем использовать имя класса, за которым после точки следует имя поля: User.TYPE_ADMIN и User.TYPE_REGULAR.

Использование значений статических полей класса возможно без его предварительной инициализации.

3.4 Приватные статические поля

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

Чтобы сделать статическое поле класса приватным, добавьте к его имени префикс состоящий из специального символа #, так же как мы поступали ранее с обычными приватными полями: static #myPrivateStaticField.

Допустим, что вы хотите ограничить количество создаваемых экземпляров вашего класса User. Чтобы скрыть информацию об ограничении их числа, вы можете использовать приватные статические поля:

class User {
  static #MAX_INSTANCES = 2;  
  static #instances = 0;  
  name;

  constructor(name) {
    User.#instances++;
    if (User.#instances > User.#MAX_INSTANCES) {
      throw new Error('Unable to create User instance');
    }
    this.name = name;
  }
}

new User('Jon Snow');
new User('Arya Stark');
new User('Sansa Stark'); // возникает исключение Error

Содержимое статического поля User.#MAX_INSTANCES определяет максимальное количество разрешенных экземпляров, в то же время как статическое поле User.#Instances сохраняет фактическое количество уже инициализированных экземпляров класса.

Приватные статические поля доступны только внутри класса User. И ничто из внешнего мира не может помешать, только что рассмотренному нами, механизму ограничений: это еще одно преимущество использования концепции инкапсуляции в вашем коде.

4. Методы

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

Классы JavaScript поддерживают как создание и инициализацию начальными значениями их экземпляров, так и методы, которые обрабатывают значения их полей.

Например, давайте определим метод getName(), который возвращает значение поля name, уже знакомого нам по прошлым экспериментам, класса User:

class User {
  name = 'Unknown';

  constructor(name) {
    this.name = name;
  }

  getName() {    
      return this.name;  
  }
}

const user = new User('Jon Snow');
user.getName(); // => 'Jon Snow'

getName () {...} — это метод класса User.

user.getName () — инструкция вызова метода. Она выполняет код метода и может возвращать некоторое вычисленное значение.

В методе класса, как и в конструкторе используется информация, которая хранится в текущем экземпляре для того, чтобы выполнить с ней какие-либо действия. Для этого используется ключевое слово this, которое эквивалентно ссылке на текущий экземпляр класса. Используйте this для доступа к данным полей внутри экземпляра класса: this.field, а также для вызова других его методов внутри кода класса: this.method().

Давайте добавим к нашему классу User новый метод nameContains(str), который будет принимать один параметр и вызывает другой его метод:

class User {
  name;
  constructor(name) {
    this.name = name;
  }

  getName() {
    return this.name;
  }

  nameContains(str) {    
      return this.getName().includes(str);  
  }
}

const user = new User('Jon Snow');
user.nameContains('Jon');   // => true
user.nameContains('Stark'); // => false

nameContains(str) {...} — это метод класса User, который принимает параметр str. Затем он выполняет другой метод текущего экземпляра this.getName(), чтобы получить имя пользователя.

Как вы знаете методы также могут быть приватными и язык Javascript не исключение. Cделать метод приватным можно просто добавив к его имени префикс #.

Давайте сделаем метод getName() приватным:

class User {
  #name;

  constructor(name) {
    this.#name = name;
  }

  #getName() {    
   return this.#name;  
  }
  
  nameContains(str) {
    return this.#getName().includes(str);  
  }
}

const user = new User('Jon Snow');
user.nameContains('Jon'); // => true
user.nameContains('Stark'); // => false

user.#getName(); // возбуждается исключение SyntaxError

Метод #getName() является приватным методом. Внутри метода nameContains(str) вы вызываете приватный метод следующим образом: this.#GetName(). Будучи закрытым, метод #getName() не может быть вызван вне тела класса User, поэтому выполнение кода в последней строке нашего примера вызовет возбуждение исключения SyntaxError.

4.2 Геттеры (getters) и сеттеры (setters)

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

Соответственно, геттер выполняется при попытке получить значение поля, а сеттер при попытке установить его новое значение.

Рассмотрим следующий пример. Для того, что бы быть уверенным, что свойство name класса User никогда не будет пустым, давайте подключим к приватному полю #nameValue нашего класса специальные методы для получения и установки его значения (то есть определим для него геттер и сеттер):

class User {
  #nameValue;

  constructor(name) {
    this.name = name;
  }

  get name() {    
      return this.#nameValue;
  }

  set name(name) {    
      if (name === '') {
      throw new Error(`name field of User cannot be empty`);
    }
    this.#nameValue = name;
  }
}

const user = new User('Jon Snow');
user.name; // Вызывается геттер, => 'Jon Snow'
user.name = 'Jon White'; // Вызывается сеттер

user.name = ''; // Вызов сеттера с пустой строкой приводит к возбуждению исключения Error

Геттер get name() {...} выполняется, когда вы хотите получить доступ к содержимому поля: user.name. Когда выполняется сеттер set name(name) {...} значение поля обновляется user.name = 'Jon White'. Если новое значение для поля задается пустой строкой, то сеттер возвращает ошибку (возбуждается исключение).

4.3 Статические методы

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

Для объявления статического метода используйте специальное ключевое слово static, за которым следует обычный синтаксис метода: static myStaticMethod () {...}.

При работе со статическими методами нужно помнить два простых правила:

  1. Статический метод может получить доступ к статическим полям.
  2. Статический метод не может получить доступ к полям экземпляра класса.

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

class User {
  static #takenNames = [];

  static isNameTaken(name) {    
      return User.#takenNames.includes(name);  
  }
  name = 'Unknown';

  constructor(name) {
    this.name = name;
    User.#takenNames.push(name);
  }
}

const user = new User('Jon Snow');

User.isNameTaken('Jon Snow'); // => true
User.isNameTaken('Arya Stark'); // => false

isNameTaken() — это статический метод, который использует статическое приватное поле User.#takeNames для проверки уже принятых ранее имен.

Статические методы могут быть приватными: static #staticFunction(){...}. Опять же следуя концепции инкапсуляции, вы можете вызывать приватный статический метод только внутри тела класса.

5. Наследование: extends

Классы в JavaScript поддерживают наследование одним только способом: с использованием ключевого слова extends.

В выражении class Child extends Parent {...} , класс Child автоматически наследует от родительского класса Parent его конструктор, поля и методы.

Например, давайте создадим новый дочерний класс ContentWriter, который расширяет функционал нашего родительского класса User.

class User {
  name;

  constructor(name) {
    this.name = name;
  }

  getName() {
    return this.name;
  }
}

class ContentWriter extends User {  
    posts = [];
}

const writer = new ContentWriter('John Smith');

writer.name; // => 'John Smith'
writer.getName(); // => 'John Smith'
writer.posts; // => []

ContentWriter наследует от класса User конструктор, метод getName(), также его поле name. В классе ContentWriter мы объявляем новое поле, в котором будет находится массив сообщений posts.

Обратите внимание, что приватные члены родительского класса не наследуются дочерним классом.

5.1 Конструктор родителя: super() в constructor()

Если вы захотите вызвать родительский конструктор в дочернем классе, то вам нужно использовать специальный метод super(), доступный в конструкторе дочернего класса.

Например, давайте сделаем так, чтобы конструктор класса ContentWriter вызывал родительский конструктор нашего класса User, а затем инициализировал новое поле posts:

class User {
  name;

  constructor(name) {
    this.name = name;
  }

  getName() {
    return this.name;
  }
}

class ContentWriter extends User {
  posts = [];

  constructor(name, posts) {
    super(name);    
      this.posts = posts;
  }
}

const writer = new ContentWriter('John Smith', ['Why I like JS']);
writer.name; // => 'John Smith'
writer.posts // => ['Why I like JS']

При вызове метода super(name) внутри дочернего класса ContentWriter выполняется конструктор родительского класса User. А за ним вы помещаете код вашего конструктора дочернего класса.

Обратите внимание на следующий ниже пример кода. Внутри конструктора дочернего класса мы сначала должны выполнить метод super() перед использованием ключевого слова this. Вызов метода super() гарантирует то, что конструктор родительского класса заблаговременно инициализирует в экземпляре дочернего, поля и методы класса-родителя.

class Child extends Parent {
  constructor(value1, value2) {
  // Не работает!
    this.prop2 = value2;    
    super(value1);  
  }
}

5.2 Родительские методы в экземпляре дочернего класса: super в методах

Для того, чтобы получить доступ к методу родительского класса внутри метода дочернего, вы должны использовать специальную ссылку на экземпляр родительского класса: super.

class User {
  name;

  constructor(name) {
    this.name = name;
  }

  getName() {
    return this.name;
  }
}

class ContentWriter extends User {
  posts = [];

  constructor(name, posts) {
    super(name);
    this.posts = posts;
  }

  getName() {
    const name = super.getName();    
      if (name === '') {
      return 'Unknwon';
    }
    return name;
  }
}

const writer = new ContentWriter('', ['Why I like JS']);
writer.getName(); // => 'Unknwon'

Как видно из примера выше, метод getName() дочернего класса ContentWriter обращается к методу super.getName(), который был реализован в родительского классе User. То есть в дочернем классе мы заменяем родительский метод getName() на свой с тем же именем. Этот механизм объектно-ориентированного программирования называется переопределением метода .

Обратите внимание, вы можете использовать ссылку super со статическими методами, то есть получаете доступ к статическим методам родителя.

6. Проверка типа объекта: instanceof

Инструкция вида object instanceof Class определяет, является ли object экземпляром Class.

Давайте посмотрим на оператор instanceof в действии:

class User {
  name;

  constructor(name) {
    this.name = name;
  }

  getName() {
    return this.name;
  }
}

const user = new User('Jon Snow');
const obj = {};

user instanceof User; // => true
obj instanceof User; // => false

Как видим, объект user является экземпляром класса User, поэтому результат выполнения инструкции user instanceof User определяется как true.

Пустой объект {} не является экземпляром User, соответственно , obj instanceof User возвращает false.

Оператор instanceof является полиморфным: он определяет экземпляр дочернего класса как экземпляр родительского.

class User {
  name;

  constructor(name) {
    this.name = name;
  }

  getName() {
    return this.name;
  }
}

class ContentWriter extends User {
  posts = [];

  constructor(name, posts) {
    super(name);
    this.posts = posts;
  }
}

const writer = new ContentWriter('John Smith', ['Why I like JS']);

writer instanceof ContentWriter; // => true
writer instanceof User; // => true

writer является экземпляром дочернего класса ContentWriter. Поэтому инструкция writer instanceof ContentWriter возвращает true.

В то же время ContentWriter это дочерний класс User. Поэтому writer instanceof User также возвращает true.

Как же быть если вы хотите определить точно экземпляром какого класса является ваш объект? Для этого вы можете использовать свойство constructor вашего экземпляра класса, значение которого вы сравнивается с именем класса:

writer.constructor === ContentWriter; // => true
writer.constructor === User; // => false

7. Классы и прототипы

Как мы могли уже убедиться, синтаксис класса в JavaScript отлично справляется с абстрагированием от прототипного наследования. Для описания синтаксиса определения классов с использованием ключевого слова class я даже не использовал термин прототип.

Но тем не менее, как нам известно, классы в Javascript все таки построены на основе прототипного наследования. То есть каждый класс по сути является функцией и создает его экземпляр при вызове в качестве конструктора.

Следующие два фрагмента кода эквивалентны.

Версия класса с использованием ключевого слова class:

class User {
  constructor(name) {
    this.name = name;
  }

  getName() {
    return this.name;
  }
}

const user = new User('John');

user.getName(); // => 'John Snow'
user instanceof User; // => true

Версия кода выше, переписанная с использованием прототипного наследования:

function User(name) {
  this.name = name;
}

User.prototype.getName = function() {
  return this.name;
}

const user = new User('John');

user.getName(); // => 'John Snow'
user instanceof User; // => true

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

8. Доступность использования возможностей классов

Новые возможности использования классов, представленные в этом посте, отражены в стандарте ES2015 и предложениям, закладываемым в него на 3 этапе.

В конце 2019 года все, рассмотренные нами в посте, функциональные возможности классов представлены:

  • Публичные и приватные поля экземпляра класса являются частью Class fields proposal;
  • Приватные методы экземпляра, а также средства доступа (геттеры и сеттеры) являются частью Class private methods proposal;
  • Публичные и приватные статические поля, а также приватные статические методы являются частью Class static features proposal;
  • Все остальное является частью стандарта ES2015.

9. Выводы

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

Механизм наследования реализуется с использованием ключевого слова extends, с помощью которого вы можете легко создать дочерний класс от родительского. Ключевое слово super используется для доступа к полям и методам родительского класса из дочернего.

Для того, чтобы воспользоваться преимуществами концепции инкапсуляции, сделайте поля и методы приватными, чтобы скрыть внутренние детали реализации ваших классов. Имена приватных полей и методов должны начинаться с префикса #.

Тем не менее классы в JavaScript становятся все более удобными в использовании. И в первую очередь это достигается повышением наглядности вашего кода для понимания его работы в случае командной разработки больших приложений.

А что вы думаете об использовании префикса # при определении приватных свойств классов?

Оставить комментарий