Полное руководство по классам JavaScript

Spread the love

Оригинальная статья: Dmitri PavlutinThe Complete Guide to JavaScript Classes

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

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

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

Содержание

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

Специальное ключевое слово class определяет класс в JavaScript:

class User {
  // The body of class
}

Приведенный выше код определяет класс User. Фигурные скобки {} определяют тело класса. Обратите внимание, что такой синтаксис называется объявлением класса.

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

const UserClass = class {
  // The body of class
};

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

Вот синтаксис для экспорта по умолчанию:

export default class User {
 // The body of class
}

И именной экспорт:

export class User {
  // The body of class
}

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

Оператор new создает экземпляр класса в JavaScript таким образом: instance = new Class().

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

const myUser = new User();

new User() создает экземпляр класса User.

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

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

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

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

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

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

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

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

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

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

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

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

3. Поля

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

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

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

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

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

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

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

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

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

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

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

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

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

Предложение (proposal) TC39 о полях класса позволяет определять поля внутри тела класса. Кроме того, вы можете сразу указать начальное значение:

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 внутри тела класса объявляется как публичное поле.

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

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

class User {
  name = 'Unknown';
  constructor() {
    // No initialization
  }
}

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

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

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

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

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

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

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

Для того что бы сделать поле приватным нужно использовать префикс # перед именем поля, например, #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 is thrown

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

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

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

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

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

Давайте добавим новый тип поля, который указывает тип пользователя: admin или regular. Статические поля TYPE_ADMIN и TYPE_REGULAR являются удобными константами для различения пользовательских типов:

class User {
  static TYPE_ADMIN = 'admin';
  static 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

static TYPE_ADMIN и static TYPE_REGULAR определяют статические переменные внутри класса User. Чтобы получить доступ к статическим полям, вы должны использовать класс, за которым следует имя поля: 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'); // throws Error

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

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

4. Методы

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

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

4.1 Методы экземпляра

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

Например, давайте определим метод getName(), который возвращает имя в классе 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().

Давайте добавим новый метод 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(), чтобы получить имя пользователя.

Метод также может быть закрытым. Чтобы сделать метод приватным, добавьте к его имени префикс #.

Давайте сделаем метод 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 is thrown

#getName() является приватным методом. Внутри метода nameContains(str) вызывается приватный метод this.#getName().

Будучи приватным, #getName() не может быть вызван вне тела класса User.

4.2 Getters и setters

getter и setter имитируют обычное поле, но с большим контролем над тем, как поле доступно.

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

Чтобы убедиться, что свойство name пользователя не может быть пустым, давайте обернем приватное поле #nameValue в методы getter и setter:

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; // The getter is invoked, => 'Jon Snow'
user.name = 'Jon White'; // The setter is invoked

user.name = ''; // The setter throws an Error

get name () {…} getter выполняется при доступе к значению поля user.name.

В то время как set name(name) {…}  выполняется при обновлении поля user.name = ‘Jon White’. setter выдаст ошибку, если новое значение будет пустой строкой.

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

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

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

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

  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 для проверки принятого значения name.

Статические методы могут быть приватными: 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 наследует от пользователя конструктор, метод 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) {
    // Does not work!
    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 'Unknown';
    }
    return name;
  }
}

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

getName() дочернего класса ContentWriter обращается к методу super.getName() напрямую из родительского класса User.

Эта функция называется переопределением (overriding) метода.

Обратите внимание, что вы также можете использовать 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 я даже не использовал термин prototype.

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

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

Версия с классом:

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, я рекомендую вам хорошо разбираться с прототипным наследованием (prototypal inheritance).

8. Наличие возможностей класса

Свойство классов, рассмотрены в этом посте, были внедрены в ES2015 а так же новыми предложениям (proposals) находящимися на этапе 3.

В конце 2019 года функции класса разделяются на:

  • Публичные и приватные поля экземпляра являются частью предложения TC39 о полях класса Class fields proposal
  • Приватные методы экземпляра и инструменты доступа являются частью предложения TC39 о приватных методов класса Class private methods proposal
  • Публичные и приватные статические поля и приватные статические методы являются частью предложения TC39 о статических функций класса Class static features proposal
  • Остальное является частью стандарта ES2015.

9. Заключение

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

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

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

Классы в JavaScript становятся все более удобными в использовании.

Была ли вам полезна эта статья?
[39 / 4.8]

Spread the love
Подписаться
Уведомление о
guest
6 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Эльдар
4 лет назад

очень чётко и внятно расписано, спасибо

LazySquirrel
LazySquirrel
4 лет назад

Огромное спасибо! Все разложено по полочкам. Много информации в компактной подаче.

Анонимно
Анонимно
4 лет назад

Судя по тексту — перевод статьи…

Анонимно
Анонимно
4 лет назад

Это указано в статье, первое предложение…

Анонимно
Анонимно
4 лет назад

Отличная статья. Большое спасибо

Митя
Митя
2 лет назад

Полезная статья, жаль что я не нашел её раньше