Классы

ℹ️Важно

Синтаксический сахар на поверхности прототипной модели наследования.

Инкапсуляция 🕵️‍♀️

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

1// Функция конструктор
2var Person = function(name) {
3 // Приватная функция
4 var log = function(message) {
5 console.log(message);
6 };
7
8 // Публичное свойство
9 this.name = name;
10
11 // Публичный метод
12 this.logger = function(message) {
13 log('Public ' + message);
14 };
15};

В JavaScript отсутствует возможность явно обозначить приватность, как в других языках программирования с помощью ключевых слов private, protected и public. Подобная проблема достаточно просто решается с помощью тех же самых функций — создаётся ещё одна обёртка над кодом в виде само вызывающейся анонимной функции, например, при использовании паттерна “модуль”:

Реализация паттерна модуль для калькулятора

1var Peron = (function() {
2 // Приватная функция
3 var log = function(message) {
4 console.log(message);
5 };
6
7 var Person = function(name) {
8 // Публичное свойство
9 this.name = name;
10 };
11
12 // Публичный метод
13 Person.prototype.logger = function(message) {
14 log('Public ' + message);
15 };
16
17 // Экспорт публичной функции
18 return Person;
19})();

Наследование 👨‍👩‍👧‍👦

С помощью наследования вы, буквально, говорите: “У меня есть один конструктор/класс и другой конструктор/класс, который точно такой же, как и первый, кроме вот этого и вот этого”. Чаще всего наследование в JavaScript реализуется с помощью функции Object.create(), позволяющий создать новый объект с заданным прототипом.

1// функция конструктор
2const Person = function(name) {
3 this.name = name + ' Doe';
4};
5
6// запись метода в прототип
7Person.prototype.sayName = function() {
8 console.log(this.name);
9};
10
11// Вызов конструктора родителя внутри дочернего
12// конструктора для записи всех свойств
13const GreatPerson = function(name, phrase) {
14 Person.apply(this, arguments);
15 this.phrase = phrase;
16};
17
18// Перезапись прототипа дочернего конструктора
19GreatPerson.prototype = Object.create(Person.prototype);
20
21GreatPerson.prototype.sayPhrase = function() {
22 console.log(this.name + ' says: "' + this.phrase + '"');
23};
24
25// создание нового объекта
26const john = new Person('John');
27const jane = new GreatPerson('Jane', 'Hello, World!');
28
29john.sayName(); // John Doe
30jane.sayName(); // Jane Doe
31jane.sayPhrase(); // Jane Doe says: "Hello, World!"
❗️Важно

в JavaScript прототипное наследование “динамическое”, можно изменять всё налету, классическое же наследование подобным похвастаться не может: всё, что вы объявили в одном классе останется там навсегда.

Полиморфизм 🤯

Полиморфизм проще всего постичь на примере встроенных конструкторов (String, Array, Object).

Например число 42 и массив [4,2] являются разными типами, но разделяют определённую часть методов, например, метод toString, унаследованный от Object. Метод toString можно весьма успешно переназначить, во-первых, в прототипе функции конструктора, и, во-вторых, сразу же для данного конкретного объекта.

1// Наш собственный конструктор
2const Person = function(name) {
3 this.name = name;
4};
5
6// Переназначение метода toString для всех объектов,
7// созданных с помощью данного конструктора
8Person.prototype.toString = function() {
9 return 'Person ' + this.name;
10};
11
12const john = new Person('John');
13
14// Два массива, второй абсолютно обычный,
15// для первого переназначен метод toString
16const arr1 = [4, 2];
17const arr2 = [5, 3];
18arr1.toString = function() {
19 return 'Array ' + this.reduce(function(result, item) {
20 return result + '' + item;
21 });
22};
23
24// В итоге
25console.log(john.toString()); // Person John
26console.log(arr1.toString()); // Array 42
27console.log(arr2.toString()); // 5,3

При использовании метода toString на каждом из объектов происходит проверка того, какой метод нужно выбрать. Проверка ведётся следующим образом: изначально мы проверяем существует ли нужное свойство на самом объекте, если существует, то используется именно оно, если же нет, то проверка продолжается — на наличие свойства проверяется прототип, потом прототип прототипа, потом прототип прототипа прототипа... и так, пока не дойдём до самого конца — null.

❗️Важно

Полиморфизм отвечает за то, чей метод вызвать.

Классы

❗️important

Код внутри тела класса всегда выполняется в strict mode

1class Person {
2 constructor(name) {
3 this.name = name;
4 }
5
6 sayName() {
7 console.log(`Person ${this.name} said his name`);
8 }
9}
10
11const john = new Person('John');
12john.sayName(); // Person John said his name

Пример выше можно записать в стиле функции конструктора:

1var Person = function(name) {
2 this.name = name;
3};
4
5Person.prototype.sayName = function() {
6 console.log('Person ' + this.name + ' said his name');
7};
8
9var john = new Person('John');
10john.sayName(); // Person John said his name

Что нужно знать про классы:

  • Создавая класс, вы пользуетесь блоком кода (всё, что находится между { и }), внутри которого объявляете, всё, что хотите видеть в прототипе.
  • Запись class Person означает, что будет создана функция конструктор Person (всё точно так же, как и в ES5)
  • Свойство constructor используется для обозначение того, что будет происходить непосредственно в самом конструкторе.
  • Все методы для класса используют краткую запись.
  • При перечислении методов не надо использовать запятые (на самом деле, они запрещены)
❗️Важно

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

1typeof Person === 'function'; // true

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

1const Person = class P { /* делаем свои дела */ }
2const obj = {
3 Person
4};
  • Конструктор Person объявленная через ключевое слово class обязана быть вызвана с оператором new.

  • Конструктор, созданный с помощью class не испытывает на себе поднятия (hoisting).

extends

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

1class GreatPerson extends Person {
2 constructor(name, phrase) {
3 super(name);
4 this.phrase = phrase;
5 }
6 sayPhrase() {
7 console.log(`${this.name} says: "${this.phrase}"`)
8 }
9}
10
11const jane = new Person('Jane', 'Hello, World!');
12jane.sayName(); // Person Jane said his name
13jane.sayPhrase(); // Jane says: "Hello, World!"

super

В примере выше мы использовали super для вызова конструктора-родителя. С помощью подобного вызова мы записали свойство name для текущего объекта.

Другими словами super при вызове внутри конструктора (свойства constructor) — вызывает конструктор родителя и записывает в текущий объект (то есть в this) всё, что от него требуется. В ES5 для подобных действий приходилось напрямую обращаться к конструктору:

1var GreatPerson = function(name, phrase) {
2 // Передача всех аргументов в конструктор родителя
3 Person.apply(this, arguments);
4
5 // или только одного
6 Person.call(this, name);
7
8 // запись новых свойств
9 this.phrase = phrase;
10};

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

1class Person {
2 constructor(name) {
3 this.name = name;
4 }
5 speak(phrase) {
6 return `${this.name} says ${phrase}`;
7 }
8}
9
10class Speaker extends Person {
11 speak(phrase) {
12 console.log(`${super.speak(phrase)} very confidently`);
13 }
14}
15
16const bob = new Speaker('Bob');
17const john = new Person('John');
18console.log(john.speak('I don\'t have a lot of money'));
19// John says "I don't have a lot of money"
20bob.speak('I have a lot of money');
21// Bob says "I have a lot of money" very confidently

Без super нам бы пришлось напрямую обращаться к прототипу конструктора родителя, чтобы получить перезаписанный нами метод:

1var Person = function(name) {
2 this.name = name;
3};
4
5Person.prototype.speak = function(phrase) {
6 return `${this.name} says "${phrase}"`;
7};
8var Speaker = function(name) {
9 Person.call(this, name);
10};
11Speaker.prototype = Object.create(Person.prototype);
12Speaker.prototype.speak = function(phrase) {
13 // обращаемся к методу speak из функции родителя
14 var originalSpeak = Person.prototype.speak;
15 // в добавок ко всему нужно использовать и call
16 console.log(originalSpeak.call(this, phrase) + ' very confidently');
17};

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

🔥Подводный камень

super: при реализации наследования с помощью extends и работе с дочерним конструктором необходимо вызвать super() перед добавлением любого нового свойства.

1class Pesron {
2 constructor(name) {
3 this.name = name;
4 }
5}
6
7// Всё работает хорошо
8class GreatPerson extends Person {
9 constructor(name, phrase) {
10 // Необходимо вызвать super
11 super(name);
12 this.phrase = phrase;
13 }
14}
15
16// А тут ошибка
17class GreatPerson extends Person {
18 constructor(name, phrase) {
19 // Необходимо вызвать super до записи собственных свойств
20 this.phrase = phrase;
21 super(name);
22 }
23}

Классы без конструкторов

1class Speaker extends Person {
2 speak(phrase) {
3 console.log(`${super.speak(phrase)} very confidently`);
4 }
5}

В примере выше не было использовано свойство constructor для класса Speaker, поскольку в нём просто нет необходимости в данном случае. Но свойство name всё равно было записано. Когда вы не указываете явно, что нужно сделать в конструкторе, то всё решается без вашего участия. Можно представить себе данный процесс следующим образом:

1// Эквивалентно созданию класса без конструктора
2class GreatPerson extends Person {
3 constructor() {
4 super(...arguments);
5 }
6}

static

При работе с конструкторами в ES5 многие привыкли использовать функции, как объекты (они же и есть объекты) и вешать на них служебные функции:

1// Конструктор
2var Person = function() {};
3
4// Служебное значение
5Person.hello = 'World';
6// Служебная функция
7Person.speak = function() {
8 console.log('I am alive!');
9};

Подобные значения не имеют ничего общего с прототипами и доступны только при запросе непосредственно с функции (объекта):

1Person.speak(); // I am alive!
2var john = new Person();
3john.speak(); // Ошибка: нет такого метода!

При работе с классами запись подобных свойств доступна сразу внутри класса с помощью оператора static. Причем все записанные подобным образом свойства при наследовании благополучно переносятся на конструктор потомка:

1class Person {
2 static sos() {
3 console.log('I really need help!');
4 }
5}
6
7class Artist extends Person {
8 draw(art) {
9 console.log(`Artist has just drawn ${art}`);
10 }
11}
12
13const artist = new Artist();
14Person.sos(); // I really need help!
15Artist.sos(); // I really need help!
16artist.sos(); // artist.sos is not a function

Практика 👩‍💻👨‍💻

Создать Список для чтения

Создать класс BookList Создать еще один класс Book

BookList: должен иметь следующие свойства:

  • Количество книг, отмеченных как прочитанные
  • Количество книг, отмеченных как непрочитанные
  • Ссылка на следующую (next) книгу для чтения (book instance)
  • Ссылка на текущую(current) читаемую книгу (book instance)
  • Ссылка на последнюю(last) прочитанную книгу (book instance)
  • Массив всех Книг

Каждая книга должна иметь несколько свойств:

  • заглавие
  • Жанр
  • Автор
  • прочитана (true/false)
  • Дата чтения может быть пустым, иначе должен быть объектDate

В каждом списке книг должно быть несколько методов:

  • .add(book)
  • следует добавить книгу в список книг.
  • finishCurrentBook()
  • должен отметить книгу, которая сейчас читается, как "прочитанную"
  • изменить дату чтения на текущую new Date(Date.now())
  • изменить последнюю прочитанную книгу на книгу, которую только что закончили
  • изменить текущую книгу, чтобы она была следующей книгой для чтения
  • изменить свойство следующей книги для чтения, чтобы она была первой непрочитанной книгой, которую вы найдете в списке книг

Booklist и Book может потребоваться больше методов, чем описано.

1class Book {
2 constructor(title, genre, author, read = false, date = new Date()){
3 this.title = title || "No Title";
4 this.genre = genre || "Fiction";
5 this.author = author || "No Author";
6 this.read = read;
7 this.readDate = date
8 }
9}
10
11class BookList{
12
13 constructor(books =[]){
14 this.list = books;
15 }
16}
17
18const books = [
19 new Book("A Smarter Way to Learn JavaScript", "programming", "Mark Myers"),
20 new Book(
21 "Eloquent JavaScript: A Modern Introduction to Programming",
22 "programming",
23 "Marjin Haverbeke"
24 ),
25 new Book("JavaScript: The Good Parts", "programming", "Douglas Crockford"),
26 new Book("JavaScript: The Definitive Guide", "Programming", "David Flanagan")
27];
28
29const bookList = new BookList(books);
30
31console.log("current", bookList.currentBook);
32console.log("next", bookList.nextBook);
33console.log("last", bookList.lastBook);
34
35bookList.finishCurrentBook();
36console.log("==2==");
37
38console.log("current", bookList.currentBook);
39console.log("next", bookList.nextBook);
40console.log("last", bookList.lastBook);
41
42bookList.finishCurrentBook();
43console.log("==3==");
44
45console.log("current", bookList.currentBook);
46console.log("next", bookList.nextBook);
47console.log("last", bookList.lastBook);
Hello