CODEGURU

Всё, что вы хотели знать о классах в Javascript

В javascript, в отличие от ООП-языков (как, например, PHP), нет реализации классов. Таким образом «классы» в javascript это лишь абстракция, надстройка над прототипным наследованием, которая лишь иммитирует классы. С приходом ES2015 делать эту иммитацию стало немного проще, так как теперь у нас есть синтаксический сахар в виде Class. Вот и поглядим, что можно с ним делать.

Создание класса

Итак, для инициализации класса достаточно написать:

class User {
  // Тело класса
};

Можно поместить его в переменную:

const User = class {
  // Тело класса
};

Можно написать и так:

export default class USer {
  // Тело класса
};

А можно использовать именованный экспорт:

export class User {
  // Тело класса
};

Тут никаких ограничений, делайте как вам удобно. Единственное, о чём нужно помнить — рекомендуется, как правило хорошего тона, имена классов писать с заглавной буквы.

Но объявить класс это только полдела. Его же нужно как-то использовать. Для создания экземпляра (объекта, содержащего в себе все данные и методы) класса используем оператор new:

const user = new User();

Инициализация экземпляра класса

При инициализации экземпляра класса срабатывает специальная функция — constructor, которую мы описываем в самом классе:

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

В данном примере функция-конструктор принимает аргумент name, который мы используем для определения свойства name класса. Аргумент мы передаем при создании экземпляра класса:

const user = new User("John");

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

Если мы не опишем конструктор в своём классе, то функция constructor всё равно будет создана, просто она будет пустой.

Свойства(поля) класса

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

Итак, в классе мы можем иметь два вида полей:

  • Поля экземпляра класса;
  • Поля самого класса (статичные).

Каждый из этих видов может, в свою очередь, принадлежать к одному из двух типов:

  • Публичный, т.е. доступный извне;
  • Приватный, т.е. доступный только внутри класса.

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

С ними мы уже знакомы. Мы выше уже писали:

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

const user = new User("John");

Здесь мы создали экземпляр класса User. У этого класса есть поле name. И мы легко можем получить к нему доступ:

console.log(user.name); // John

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

class User() {
  name = "John";
  surname;

  constructor(surname) {
    this.surname = surname
  }
}

Вот, например, класс, все экземпляры которого будут иметь имя John, а фамилию будут получать из аргументов. И нам достаточно одного взгляда, чтобы сразу увидеть, все публичные поля экземпляра.

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

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

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

class User {
  #name;

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

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

const user = new User("John");
user.getName(); // John
user.#name; // SyntaxError: Private field '#name' must be declared in an enclosing class

Вот в нашем классе мы объявили приватное поле name. У метода getName в теле класса есть доступ к этому полю. А вот получить его напрямую не получится. На то оно и приватное.

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

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

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("Adam Smith", User.TYPE_ADMIN);
admin.type === User.TYPE_ADMIN // true

Мы создали две публичные статичные переменные: TYPE_ADMIN и TYPE_REGULAR. Поскольку статичные переменные принадлежат самому классу, то для обращения к ним мы используем имя класса: User.TYPE_ADMIN. Попытка обратиться к ним через экземпляр класса: admin.TYPE_ADMIN – вернёт нам undefined.

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

Ну здесь всё так же, как и в полях экземпляра класса – перед именем добавляем #:

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

  constructor(name) {
    User.#instances += 1;
    if (User.#instances > User.#MAX_INSTANCES) {
      throw new Error("Too many users!")
    }
    this.name = name;
  }
}

new User("John");
new User ("Adam");
new User ("Robert") // Вернёт ошибку

Методы класса

Итак, с полями разобрались. Двигаемся дальше. В полях у нас хранится информация. Но сама по себе она мало интересна. Она нам нужна для, того, чтобы что-то с ней делать. И вот как раз для этого и предназначены методы. Т.е. это просто функции, которые выполняют какие-то действия с имеющейся информацией. Как и поля, методы делятся на два типа: методы экземпляра класса и статичные.

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

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

class User {
  name = 'Unknown';

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

  getName() {
    return this.name;
  }
}

const user = new User('John');
user.getName(); // выведет John

В примере выше у нас есть класс User, у которого есть метод getName. Как и в функции constructor this внутри метода ссылается на созданный экземпляр класса. Давайте добавим ещё один метод, который будет вызывать другой метод.

class User {
  name = 'Unknown';

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

  getName() {
    return this.name;
  }

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

const user = new User('John');
user.nameContains('John'); // выведет true
user.nameContains('Jane'); // выведет false

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

class User() {
  #name = 'Unknown';

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

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

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

const user = new User('John');
user.nameContains('John'); // выведет true
user.nameContains('Jane'); // выведет false

user.#getName(); // выведет ошибку

Геттеры и сеттеры

Геттеры и сеттеры это специальные методы, которые срабатывают автоматически, когда вы обращаетесь к какому-то полю, чтобы получить(геттеры) или изменить(сеттеры) его значение.

class User {
  #nameValue;

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

  get name() {
    return this.#nameValue
  }

  set name(name) {
    if (name === '') {
      throw new Error('Name field can not be empty.')
    }
    this.#nameValue = name;
  }
}

const user = new User('John');
user.name; // срабатывает геттер и выводит John
user.name = 'Jane'; // срабатывает сеттер и user.name теперь Jane

Статичные методы

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

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

  1. Статичные методы имеют доступ к статичным полям,
  2. Статичные методы не имеют доступа к полям экземпляра класса.

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

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

  constructor(name) {
    if (User.isNameTaken(name)) {
      throw new Error('This name is already taken');
    }
    this.name = name;
    User.#takenNames.push(name);
  }
}

const user = new User('John');

User.isNameTaken('John'); // выведет true
User.isNameTaken('Jane'); // выведет false

Статичные методы могут быть приватными. В этом случае на них распространяются те же, правила, что и на статичные приватные поля.

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

Классы в javascript поддерживают наследование с помощью ключевого слова extends. Запись class Child extend Parent {...} говорит о том, что класс Child наследует от класса Parent функцию-конструктор, поля и методы.

Рассмотрим пример, создав класс Admin, который будет наследником класса User.

class User {
  name;

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

  getName() {
    return this.name;
  }
}

class Admin extends User {
  status = 'admin';
}

const superUser = new Admin('John Doe');

console.log(superUser.name) // John Doe
console.log(superUser.getName()) // John Doe
console.log(superUser.status // admin

Как видите класс Admin унаследовал функцию-конструктор, метод getName() и поле name. Также у этого класса есть собственное поле status.

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

Родительский конструктор: super()

При создании класса-наследника есть одна особенность. Дело в том, что после создания у него не будет собственного this. И если вы попытаетесь в новом классе сделать что-то такое:

class Child extends Parent {
  constructor(name) {
    this.name = name;
  }
}

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

class User {
  name;

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

  getName() {
    return this.name;
  }
}

class Employee extends User {
  status;
  constructor(name, status) {
    super(name);
    this.status = status;
  }
}

Заодно, благодаря вызову super(), мы сразу видим какие поля принадлежат родителю, а какие ребёнку.

Родительский класс: super

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

class User {
  name;
  status;

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

  renderProperties(el) {
    el.innerHTML = JSON.stringify(this);
  }
}

Например, у нас есть класс User. у него есть метод renderProperties, который получает в качестве аргумента элемент Node-дерева и рендерит в него все поля класса. Теперь создадим дочерний класс.

class Worker extends User {
  constructor {
    super('John', 'Frontend developer');
  }

  renderWithSuper(el) {
    el.classList.add('green');
    super.renderProperties(el);
  }
}

Мы создаём дочерний класс Worker, которые наследуется от класса User. И нам нужно, чтобы, дочерний класс рендерил текст зелёного цвета. Мы можем переопределить метод renderProperties родительского класса. Но мы решили так не делать, а сделали новый метод renderWithSuper, в котором совершаем манипуляции с элементом и с помощью super вызываем родительский метод renderProperties. Собственно это всё, что нужно знать о super. Это ключевое слово позволяет вам вызвать метод родительского класса внутри дочернего класса.

Заключение

С появлением Class работа с классами в javascript стала намного проще и приятнее. Да, это всего лишь синтаксический сахар. Но он позволяет нам писать более читаемый и лаконичный код. Нам доступно понятное наследование с помощью extends, нам доступны приватные и статичные поля и методы. А это всё не может не радовать 😎.