- Операторы
- Управляющие инструкции
- JS Объекты
- браузер BOM
- HTML DOM
- События
- HTML Объекты
- Промисы, async/await
- Сетевые запросы
- Бинарные данные и файлы
- Модули
- Классы
- Разное
Классы
- Класс: базовый синтаксис
- Наследование классов
- Статические свойства и методы
- Приватные и защищённые методы и свойства
- Расширение встроенных классов
- Проверка класса: "instanceof"
- Примеси
Класс: базовый синтаксис
В объектно-ориентированном программировании класс – это расширяемый шаблон кода для создания объектов, который устанавливает в них начальные значения (свойства) и реализацию поведения (методы).
На практике нам часто надо создавать много объектов одного вида, например пользователей, товары или что-то ещё.
Для этого можно использовать new function
.
Но в современном JavaScript есть и более продвинутая конструкция «class», которая предоставляет новые возможности, полезные для объектно-ориентированного программирования.
Синтаксис «class»
Базовый синтаксис выглядит так:
class MyClass { // методы класса constructor() { ... } method1() { ... } method2() { ... } method3() { ... } ... }
Затем используйте вызов new MyClass()
для создания нового объекта со всеми перечисленными методами.
При этом автоматически вызывается метод constructor()
, в нём мы можем инициализировать объект.
Например:
class User { constructor(name) { this.name = name; } sayHi() { alert(this.name); } } // Использование: let user = new User("Иван"); user.sayHi();
Когда вызывается new User("Иван")
:
- Создаётся новый объект.
constructor
запускается с заданным аргументом и сохраняет его вthis.name
.
…Затем можно вызывать на объекте методы, такие как user.sayHi()
.
Методы в классе не разделяются запятой
Частая ошибка начинающих разработчиков – ставить запятую между методами класса, что приводит к синтаксической ошибке.
Синтаксис классов отличается от литералов объектов, не путайте их. Внутри классов запятые не требуются.
Что такое класс?
Итак, что же такое class
? Это не полностью новая языковая сущность, как может показаться на первый взгляд.
Давайте развеем всю магию и посмотрим, что такое класс на самом деле. Это поможет в понимании многих сложных аспектов.
В JavaScript класс – это разновидность функции.
Взгляните:
class User { constructor(name) { this.name = name; } sayHi() { Alert(this.name); } } // доказательство: User - это функция Alert(typeof User); // function
Вот что на самом деле делает конструкция class User {...}
:
- Создаёт функцию с именем
User
, которая становится результатом объявления класса. Код функции берётся из методаconstructor
(она будет пустой, если такого метода нет). - Сохраняет все методы, такие как
sayHi
, вUser.prototype
.
При вызове метода объекта new User
он будет взят из прототипа. Таким образом, объекты new User
имеют доступ к методам класса.
На картинке показан результат объявления class User
:
Можно проверить вышесказанное и при помощи кода:
class User { constructor(name) { this.name = name; } sayHi() { Alert(this.name); } } // класс - это функция Alert(typeof User); // function // ...или, если точнее, это метод constructor Alert(User === User.prototype.constructor); // true // Методы находятся в User.prototype, например: Alert(User.prototype.sayHi); // sayHi() { alert(this.name); } // в прототипе ровно 2 метода Alert(Object.getOwnPropertyNames(User.prototype)); // constructor, sayHi
Не просто синтаксический сахар
Иногда говорят, что class
– это просто «синтаксический сахар» в JavaScript (синтаксис для
улучшения читаемости кода, но не делающий ничего принципиально нового),
потому что мы можем сделать всё то же самое без конструкции class
:
// перепишем класс User на чистых функциях // 1. Создаём функцию constructor function User(name) { this.name = name; } // каждый прототип функции имеет свойство constructor по умолчанию, // поэтому нам нет необходимости его создавать // 2. Добавляем метод в прототип User.prototype.sayHi = function() { alert(this.name); }; // Использование: let user = new User("Иван"); user.sayHi();
Результат этого кода очень похож. Поэтому, действительно, есть причины, по которым class
можно считать синтаксическим сахаром для определения конструктора вместе с методами прототипа.
Однако есть важные отличия:
-
Во-первых, функция, созданная с помощью
class
, помечена специальным внутренним свойством[[IsClassConstructor]]: true
. Поэтому это не совсем то же самое, что создавать её вручную.В отличие от обычных функций, конструктор класса не может быть вызван без
new
:class User { constructor() {} } alert(typeof User); // function try { User(); } catch(err) {alert(Error: Class constructor User cannot be invoked without 'new' );}
Кроме того, строковое представление конструктора класса в большинстве движков JavaScript начинается с «class …»
class User { constructor() {} } alert(User); // class User { ... }
-
Методы класса являются неперечислимыми. Определение класса устанавливает флаг
enumerable
вfalse
для всех методов в"prototype"
.И это хорошо, так как если мы проходимся циклом
for..in
по объекту, то обычно мы не хотим при этом получать методы класса. -
Классы всегда используют
use strict
. Весь код внутри класса автоматически находится в строгом режиме.
Также в дополнение к основной, описанной выше, функциональности, синтаксис class
даёт ряд других интересных возможностей, с которыми мы познакомимся чуть позже.
Class Expression
Как и функции, классы можно определять внутри другого выражения, передавать, возвращать, присваивать и т.д.
Пример Class Expression (по аналогии с Function Expression):
let User = class { sayHi() { alert("Привет"); } };
Аналогично Named Function Expression, Class Expression может иметь имя.
Если у Class Expression есть имя, то оно видно только внутри класса:
// "Named Class Expression" // (в спецификации нет такого термина, но происходящее похоже на Named Function Expression) let User = class MyClass { sayHi() { Alert(MyClass); // имя MyClass видно только внутри класса } }; new User().sayHi(); // работает, выводит определение MyClass alert(MyClass); // ошибка, имя MyClass не видно за пределами класса
Мы даже можем динамически создавать классы «по запросу»:
function makeClass(phrase) { // объявляем класс и возвращаем его return class { sayHi() { alert(phrase); }; }; } // Создаём новый класс let User = makeClass("Привет"); new User().sayHi(); // Привет
Геттеры/сеттеры, другие сокращения
Как и в литеральных объектах, в классах можно объявлять вычисляемые свойства, геттеры/сеттеры и т.д.
Вот пример user.name
, реализованного с использованием get/set
:
class User { constructor(name) { // вызывает сеттер this.name = name; } get name() { return this._name; } set name(value) { if (value.length < 4) { alert("Имя слишком короткое."); return; } this._name = value; } } let user = new User("Иван"); alert(user.name); // Иван user = new User(""); // Имя слишком короткое.
При объявлении класса геттеры/сеттеры создаются на User.prototype
, вот так:
Object.defineProperties(User.prototype, { name: { get() { return this._name }, set(name) { // ... } } });
Пример с вычисляемым свойством в скобках [...]
:
class User { ['say' + 'Hi']() { alert("Привет"); } } new User().sayHi();
Свойства классов
Старым браузерам может понадобиться полифил
Свойства классов добавлены в язык недавно.
В приведённом выше примере у класса User
были только методы. Давайте добавим свойство:
class User { name = "Аноним"; sayHi() { alert(`Привет, ${this.name}!`); } } new User().sayHi();
Свойство name
не устанавливается в User.prototype
. Вместо этого оно создаётся оператором new
перед запуском конструктора, это именно свойство объекта.
Итого
Базовый синтаксис для классов выглядит так:
class MyClass { prop = value; // свойство constructor(...) { // конструктор // ... } method(...) {} // метод get something(...) {} // геттер set something(...) {} // сеттер [Symbol.iterator]() {} // метод с вычисляемым именем (здесь - символом) // ... }
MyClass
технически является функцией (той, которую мы определяем как constructor
), в то время как методы, геттеры и сеттеры записываются в MyClass.prototype
.
Наследование классов
Наследование классов – это способ расширения одного класса другим классом.
Таким образом, мы можем добавить новый функционал к уже существующему.
Ключевое слово «extends»
Допустим, у нас есть класс Animal
:
class Animal { constructor(name) { this.speed = 0; this.name = name; } run(speed) { this.speed = speed; ale${this.speed}rt(`${this.name} бежит со скоростью ${this.speed}.`); } stop() { this.speed = 0; alert(`${this.name} стоит неподвижно.`); } } let animal = new Animal("Мой питомец");
Вот как мы можем представить объект animal
и класс Animal
графически:
…И мы хотели бы создать ещё один class Rabbit
.
Поскольку кролики – это животные, класс Rabbit
должен быть основан на Animal
, и иметь доступ к методам животных, так чтобы кролики могли делать то, что могут делать «общие» животные.
Синтаксис для расширения другого класса следующий: class Child extends Parent
.
Давайте создадим class Rabbit
, который наследуется от Animal
:
class Rabbit extends Animal { hide() { alert(`${this.name} прячется!`); } } let rabbit = new Rabbit("Белый кролик"); rabbit.run(5); // Белый кролик бежит со скоростью 5. rabbit.hide(); // Белый кролик прячется!
Объект класса Rabbit
имеет доступ как к методам Rabbit
, таким как rabbit.hide()
, так и к методам Animal
, таким как rabbit.run()
.
Внутри ключевое слово extends
работает по старой доброй механике прототипов. Оно устанавливает Rabbit.prototype.[[Prototype]]
в Animal.prototype
. Таким образом, если метода не оказалось в Rabbit.prototype
, JavaScript берет его из Animal.prototype
.
Например, чтобы найти метод rabbit.run
, движок проверяет (снизу вверх на картинке):
- Объект
rabbit
(не имеетrun
). - Его прототип, то есть
Rabbit.prototype
(имеетhide
, но не имеетrun
). - Его прототип, то есть (вследствие
extends
)Animal.prototype
, в котором, наконец, есть методrun
.
JavaScript использует наследование на прототипах для встроенных объектов. Например, Date.prototype.[[Prototype]]
является Object.prototype
, поэтому у дат есть универсальные методы объекта.
После extends
разрешены любые выражения
Синтаксис создания класса допускает указывать после extends
не только класс, но и любое выражение.
Пример вызова функции, которая генерирует родительский класс:
function f(phrase) { return class { sayHi() { alert(phrase); } }; } class User extends f("Привет") {} new User().sayHi(); // Привет
Здесь class User
наследует от результата вызова f("Привет")
.
Это может быть полезно для продвинутых приёмов проектирования, где мы можем использовать функции для генерации классов в зависимости от многих условий и затем наследовать их.
Переопределение методов
Теперь давайте продвинемся дальше и переопределим метод. По умолчанию все методы, не указанные в классе Rabbit
, берутся непосредственно «как есть» из класса Animal
.
Но если мы укажем в Rabbit
собственный метод, например stop()
, то он будет использован вместо него:
class Rabbit extends Animal { stop() { // ...теперь это будет использоваться для rabbit.stop() // вместо stop() из класса Animal } }
Впрочем, обычно мы не хотим полностью заменить родительский метод, а скорее хотим сделать новый на его основе, изменяя или расширяя его функциональность. Мы делаем что-то в нашем методе и вызываем родительский метод до/после или в процессе.
У классов есть ключевое слово "super"
для таких случаев.
super.method(...)
вызывает родительский метод.super(...)
для вызова родительского конструктора (работает только внутри нашего конструктора).
Пусть наш кролик автоматически прячется при остановке:
class Animal { constructor(name) { this.speed = 0; this.name = name; } run(speed) { this.speed = speed; Alert(`${this.name} бежит со скоростью ${this.speed}.`); } stop() { this.speed = 0; Alert(`${this.name} стоит.`); } } class Rabbit extends Animal { hide() { Alert(`${this.name} прячется!`); } stop() { super.stop(); // вызываем родительский метод stop this.hide(); // и затем hide } } let rabbit = new Rabbit("Белый кролик"); rabbit.run(5); // Белый кролик бежит со скоростью 5. rabbit.stop(); // Белый кролик стоит. Белый кролик прячется!
Теперь у класса Rabbit
есть метод stop
, который вызывает родительский super.stop()
в процессе выполнения.
У стрелочных функций нет super
.
При обращении к super
стрелочной функции он берётся из внешней функции:
class Rabbit extends Animal { stop() { setTimeout(() => super.stop(), 1000); // вызывает родительский stop после 1 секунды } }
В примере super
в стрелочной функции тот же самый, что и в stop()
, поэтому метод отрабатывает как и ожидается. Если бы мы указали здесь «обычную» функцию, была бы ошибка:
// Unexpected super setTimeout(function() { super.stop() }, 1000);
Переопределение конструктора
С конструкторами немного сложнее.
До сих пор у Rabbit
не было своего конструктора.
Согласно спецификации, если класс расширяет другой класс и не имеет конструктора, то автоматически создаётся такой «пустой» конструктор:
class Rabbit extends Animal { // генерируется для классов-потомков, у которых нет своего конструктора constructor(...args) { super(...args); } }
Как мы видим, он просто вызывает конструктор родительского класса. Так будет происходить, пока мы не создадим собственный конструктор.
Давайте добавим конструктор для Rabbit
. Он будет устанавливать earLength
в дополнение к name
:
class Animal { constructor(name) { this.speed = 0; this.name = name; } // ... } class Rabbit extends Animal { constructor(name, earLength) { this.speed = 0; this.name = name; this.earLength = earLength; } // ... } // Не работает! let rabbit = new Rabbit("Белый кролик", 10); // Error: this is not defined.
Упс! При создании кролика – ошибка! Что не так?
Если коротко, то:
Конструкторы в наследуемых классах должны обязательно вызывать super(...)
, и (!) делать это перед использованием this
.
…Но почему? Что происходит? Это требование кажется довольно странным.
Конечно, всему есть своё объяснение. Давайте углубимся в детали, чтобы вы действительно поняли, что происходит.
В JavaScript существует различие между «функцией-конструктором
наследующего класса» и всеми остальными. В наследующем классе
соответствующая функция-конструктор помечена специальным внутренним
свойством [[ConstructorKind]]:"derived"
.
Разница в следующем:
- Когда выполняется обычный конструктор, он создаёт пустой объект и присваивает его
this
. - Когда запускается конструктор унаследованного класса, он этого не делает. Вместо этого он ждёт, что это сделает конструктор родительского класса.
Поэтому, если мы создаём собственный конструктор, мы должны вызвать super
, в противном случае объект для this
не будет создан, и мы получим ошибку.
Чтобы конструктор Rabbit
работал, он должен вызвать super()
до того, как использовать this
, чтобы не было ошибки:
class Animal { constructor(name) { this.speed = 0; this.name = name; } // ... } class Rabbit extends Animal { constructor(name, earLength) { super(name); this.earLength = earLength; } // ... } // теперь работает let rabbit = new Rabbit("Белый кролик", 10); alert(rabbit.name); // Белый кролик alert(rabbit.earLength); // 10
Переопределение полей класса: тонкое замечание
Продвинутое замечание
В этом подразделе предполагается, что у вас уже есть определённый опыт работы с классами, возможно, в других языках программирования.
Это даёт лучшее представление о языке, а также объясняет поведение, которое может быть источником ошибок (но не очень часто).
Если вы считаете этот материал слишком трудным для понимания, просто продолжайте читать дальше, а затем вернитесь к нему через некоторое время.
Мы можем переопределять не только методы, но и поля класса.
Однако, когда мы получаем доступ к переопределенному полю в родительском конструкторе, это поведение отличается от большинства других языков программирования.
Рассмотрим этот пример:
class Animal { name = 'animal'; constructor() { alert(this.name); // (*) } } class Rabbit extends Animal { name = 'rabbit'; } new Animal(); // animal new Rabbit(); // animal
Здесь, класс Rabbit
расширяет Animal
и переопределяет поле name
своим собственным значением.
В Rabbit
нет собственного конструктора, поэтому вызывается конструктор Animal
.
Что интересно, в обоих случаях: new Animal()
и new Rabbit()
, alert
в строке (*)
показывает animal
.
Другими словами, родительский конструктор всегда использует своё собственное значение поля, а не переопределённое.
Что же в этом странного?
Если это ещё не ясно, сравните с методами.
Вот тот же код, но вместо поля this.name
, мы вызываем метод this.showName()
:
class Animal { showName() { // вместо this.name = 'animal' alert('animal'); } constructor() { this.showName(); // вместо alert(this.name); } } class Rabbit extends Animal { showName() { alert('rabbit'); } } new Animal(); // animal new Rabbit(); // rabbit
Обратите внимание: теперь результат другой.
И это то, чего мы, естественно, ожидаем. Когда родительский конструктор вызывается в производном классе, он использует переопределённый метод.
…Но для полей класса это не так. Как уже было сказано, родительский конструктор всегда использует родительское поле.
Почему же наблюдается разница?
Что ж, причина заключается в порядке инициализации полей. Поле класса инициализируется:
- Перед конструктором для базового класса (который ничего не расширяет),
- Сразу после
super()
для производного класса.
В нашем случае Rabbit
– это производный класс. В нем нет конструктора constructor()
. Как было сказано ранее, это то же самое, как если бы был пустой конструктор, содержащий только super(...args)
.
Итак, new Rabbit()
вызывает super()
, таким
образом, выполняя родительский конструктор, и (согласно правилу для
производных классов) только после этого инициализируются поля его
класса. На момент выполнения родительского конструктора ещё нет полей
класса Rabbit
, поэтому используются поля Animal
.
Это тонкое различие между полями и методами характерно для JavaScript.
К счастью, такое поведение проявляется только в том случае, когда переопределенное поле используется в родительском конструкторе. Тогда может быть трудно понять, что происходит, поэтому мы объясняем это здесь.
Если это становится проблемой, её можно решить, используя методы или геттеры/сеттеры вместо полей.
Устройство super, [[HomeObject]]
Продвинутая информация
Если вы читаете учебник первый раз – эту секцию можно пропустить.
Она рассказывает о внутреннем устройстве наследования и вызовe super
.
Давайте заглянем «под капот» super
. Здесь есть некоторые интересные моменты.
Вообще, исходя из наших знаний до этого момента, super
вообще не может работать!
Ну правда, давайте спросим себя – как он должен работать, чисто
технически? Когда метод объекта выполняется, он получает текущий объект
как this
. Если мы вызываем super.method()
, то движку необходимо получить method
из прототипа текущего объекта. И как ему это сделать?
Задача может показаться простой, но это не так. Движок знает текущий this
и мог бы попытаться получить родительский метод как this.__proto__.method
. Однако, увы, такой «наивный» путь не работает.
Продемонстрируем проблему. Без классов, используя простые объекты для наглядности.
Вы можете пропустить эту часть и перейти ниже к подсекции [[HomeObject]]
, если не хотите знать детали. Вреда не будет. Или читайте далее, если хотите разобраться.
В примере ниже rabbit.__proto__ = animal
. Попробуем в rabbit.eat()
вызвать animal.eat()
, используя this.__proto__
:
let animal = { name: "Animal", eat() { alert(`${this.name} ест.`); } }; let rabbit = { __proto__: animal, name: "Кролик", eat() { // вот как предположительно может работать super.eat() this.__proto__.eat.call(this); // (*) } }; rabbit.eat(); // Кролик ест.
В строке (*)
мы берём eat
из прототипа (animal
) и вызываем его в контексте текущего объекта. Обратите внимание, что .call(this)
здесь неспроста: простой вызов this.__proto__.eat()
будет выполнять родительский eat
в контексте прототипа, а не текущего объекта.
Приведённый выше код работает так, как задумано: выполняется нужный alert
.
Теперь давайте добавим ещё один объект в цепочку наследования и увидим, как все сломается:
let animal = { name: "Животное", eat() { alert(`${this.name} ест.`); } }; let rabbit = { __proto__: animal, eat() { // ...делаем что-то специфичное для кролика и вызываем родительский (animal) метод this.__proto__.eat.call(this); // (*) } }; let longEar = { __proto__: rabbit, eat() { // ...делаем что-то, связанное с длинными ушами, и вызываем родительский (rabbit) метод this.__proto__.eat.call(this); // (**) } }; longEar.eat(); // Error: Maximum call stack size exceeded
Теперь код не работает! Ошибка возникает при попытке вызова longEar.eat()
.
На первый взгляд все не так очевидно, но если мы проследим вызов longEar.eat()
, то сможем понять причину ошибки. В обеих строках (*)
и (**)
значение this
– это текущий объект (longEar
). Это важно: для всех методов объекта this
указывает на текущий объект, а не на прототип или что-то ещё.
Итак, в обеих линиях (*)
и (**)
значение this.__proto__
одно и то же: rabbit
. В обоих случаях метод rabbit.eat
вызывается в бесконечном цикле не поднимаясь по цепочке вызовов.
Картина того, что происходит:
-
Внутри
longEar.eat()
строка(**)
вызываетrabbit.eat
со значениемthis=longEar
.// внутри longEar.eat() у нас this = longEar this.__proto__.eat.call(this) // (**) // становится longEar.__proto__.eat.call(this) // то же что и rabbit.eat.call(this);
-
В строке
(*)
вrabbit.eat
мы хотим передать вызов выше по цепочке, ноthis=longEar
, поэтомуthis.__proto__.eat
снова равенrabbit.eat
!// внутри rabbit.eat() у нас также this = longEar this.__proto__.eat.call(this) // (*) // становится longEar.__proto__.eat.call(this) // или (снова) rabbit.eat.call(this);
-
…
rabbit.eat
вызывает себя в бесконечном цикле, потому что не может подняться дальше по цепочке.
Проблема не может быть решена с помощью одного только this
.
[[HomeObject]]
Для решения этой проблемы в JavaScript было добавлено специальное внутреннее свойство для функций: [[HomeObject]]
.
Когда функция объявлена как метод внутри класса или объекта, её свойство [[HomeObject]]
становится равно этому объекту.
Затем super
использует его, чтобы получить прототип родителя и его методы.
Давайте посмотрим, как это работает – опять же, используя простые объекты:
let animal = { name: "Животное", eat() { // animal.eat.[[HomeObject]] == animal alert(`${this.name} ест.`); } }; let rabbit = { __proto__: animal, name: "Кролик", eat() { // rabbit.eat.[[HomeObject]] == rabbit super.eat(); } }; let longEar = { __proto__: rabbit, name: "Длинноух", eat() { // longEar.eat.[[HomeObject]] == longEar super.eat(); } }; // работает верно longEar.eat(); // Длинноух ест.
Это работает как задумано благодаря [[HomeObject]]
. Метод, такой как longEar.eat
, знает свой [[HomeObject]]
и получает метод родителя из его прототипа. Вообще без использования this
.
Методы не «свободны»
До
этого мы неоднократно видели, что функции в JavaScript «свободны», не
привязаны к объектам. Их можно копировать между объектами и вызывать с
любым this
.
Но само существование [[HomeObject]]
нарушает этот принцип, так как методы запоминают свои объекты. [[HomeObject]]
нельзя изменить, эта связь – навсегда.
Единственное место в языке, где используется [[HomeObject]]
– это super
. Поэтому если метод не использует super
, то мы все ещё можем считать его свободным и копировать между объектами. А вот если super
в коде есть, то возможны побочные эффекты.
Вот пример неверного результата super
после копирования:
let animal = { sayHi() { alert("Я животное"); } }; // rabbit наследует от animal let rabbit = { __proto__: animal, sayHi() { super.sayHi(); } }; let plant = { sayHi() { alert("Я растение"); } }; // tree наследует от plant let tree = { __proto__: plant, sayHi: rabbit.sayHi // (*) }; tree.sayHi(); // Я животное (?!?)
Вызов tree.sayHi()
показывает «Я животное». Определённо неверно.
Причина проста:
- В строке
(*)
, методtree.sayHi
скопирован изrabbit
. Возможно, мы хотели избежать дублирования кода? - Его
[[HomeObject]]
– этоrabbit
, ведь он был создан вrabbit
. Свойство[[HomeObject]]
никогда не меняется. - В коде
tree.sayHi()
есть вызовsuper.sayHi()
. Он идёт вверх отrabbit
и берёт метод изanimal
.
Вот диаграмма происходящего:
Методы, а не свойства-функции
Свойство [[HomeObject]]
определено для методов как классов, так и обычных объектов. Но для объектов методы должны быть объявлены именно как method()
, а не "method: function()"
.
Для нас различий нет, но они есть для JavaScript.
В приведённом ниже примере используется синтаксис не метода, свойства-функции. Поэтому у него нет [[HomeObject]]
, и наследование не работает:
let animal = { eat: function() { // намеренно пишем так, а не eat() { ... // ... } }; let rabbit = { __proto__: animal, eat: function() { super.eat(); } }; rabbit.eat(); // Ошибка вызова super (потому что нет [[HomeObject]])
Итого
- Чтобы унаследовать от класса:
class Child extends Parent
:- При этом
Child.prototype.__proto__
будет равенParent.prototype
, так что методы будут унаследованы.
- При этом
- При переопределении конструктора:
- Обязателен вызов конструктора родителя
super()
в конструктореChild
до обращения кthis
.
- Обязателен вызов конструктора родителя
- При переопределении другого метода:
- Мы можем вызвать
super.method()
в методеChild
для обращения к методу родителяParent
.
- Мы можем вызвать
- Внутренние детали:
- Методы запоминают свой объект во внутреннем свойстве
[[HomeObject]]
. Благодаря этому работаетsuper
, он в его прототипе ищет родительские методы. - Поэтому копировать метод, использующий
super
, между разными объектами небезопасно.
- Методы запоминают свой объект во внутреннем свойстве
Также:
- У стрелочных функций нет своего
this
иsuper
, поэтому они «прозрачно» встраиваются во внешний контекст.
Статические свойства и методы
Мы также можем присвоить метод самому классу. Такие методы называются статическими.
В объявление класса они добавляются с помощью ключевого слова static
, например:
class User { static staticMethod() { alert(this === User); } } User.staticMethod(); // true
Это фактически то же самое, что присвоить метод напрямую как свойство функции:
class User { } User.staticMethod = function() { alert(this === User); };
Значением this
при вызове User.staticMethod()
является сам конструктор класса User
(правило «объект до точки»).
Обычно статические методы используются для реализации функций, которые будут принадлежать классу в целом, но не какому-либо его конкретному объекту.
Звучит не очень понятно? Сейчас все встанет на свои места.
Например, есть объекты статей Article
, и нужна функция для их сравнения.
Естественное решение – сделать для этого статический метод Article.compare
:
class Article { constructor(title, date) { this.title = title; this.date = date; } static compare(articleA, articleB) { return articleA.date - articleB.date; } } // использование let articles = [ new Article("HTML", new Date(2019, 1, 1)), new Article("CSS", new Date(2019, 0, 1)), new Article("JavaScript", new Date(2019, 11, 1)) ]; articles.sort(Article.compare); alert( articles[0].title ); // CSS
Здесь метод Article.compare
стоит «над» статьями, как средство для их сравнения. Это метод не отдельной статьи, а всего класса.
Другим примером может быть так называемый «фабричный» метод.
Скажем, нам нужно несколько способов создания статьи:
- Создание через заданные параметры (
title
,date
и т. д.). - Создание пустой статьи с сегодняшней датой.
- …или как-то ещё.
Первый способ может быть реализован через конструктор. А для второго можно использовать статический метод класса.
Такой как Article.createTodays()
в следующем примере:
class Article { constructor(title, date) { this.title = title; this.date = date; } static createTodays() { // помним, что this = Article return new this("Сегодняшний дайджест", new Date()); } } let article = Article.createTodays(); alert( article.title ); // Сегодняшний дайджест
Теперь каждый раз, когда нам нужно создать сегодняшний дайджест, нужно вызывать Article.createTodays()
. Ещё раз, это не метод одной статьи, а метод всего класса.
Статические методы также используются в классах, относящихся к базам данных, для поиска/сохранения/удаления вхождений в базу данных, например:
// предположим, что Article - это специальный класс для управления статьями // статический метод для удаления статьи по id: Article.remove({id: 12345});
Статические методы недоступны для отдельных объектов
Статические методы могут вызываться для классов, но не для отдельных объектов.
Например. такой код не будет работать:
// ... article.createTodays(); /// Error: article.createTodays is not a function
Статические свойства
Новая возможность
Эта возможность была добавлена в язык недавно. Примеры работают в последнем Chrome.
Статические свойства также возможны, они выглядят как свойства класса, но с static
в начале:
class Article { static publisher = "Иван Иванов"; } alert( Article.publisher ); // Иван Иванов
Это то же самое, что и прямое присваивание Article
:
Article.publisher = "Иван Иванов";
Наследование статических свойств и методов
Статические свойства и методы наследуются.
Например, метод Animal.compare
в коде ниже наследуется и доступен как Rabbit.compare
:
class Animal { constructor(name, speed) { this.speed = speed; this.name = name; } run(speed = 0) { this.speed += speed; alert(`${this.name} бежит со скоростью ${this.speed}.`); } static compare(animalA, animalB) { return animalA.speed - animalB.speed; } } // Наследует от Animal class Rabbit extends Animal { hide() { alert(`${this.name} прячется!`); } } let rabbits = [ new Rabbit("Белый кролик", 10), new Rabbit("Чёрный кролик", 5) ]; rabbits.sort(Rabbit.compare); rabbits[0].run(); // Чёрный кролик бежит со скоростью 5.
Мы можем вызвать Rabbit.compare
, при этом будет вызван унаследованный Animal.compare
.
Как это работает? Снова с использованием прототипов. Как вы уже могли предположить, extends
даёт Rabbit
ссылку [[Prototype]]
на Animal
.
Так что Rabbit extends Animal
создаёт две ссылки на прототип:
- Функция
Rabbit
прототипно наследует от функцииAnimal
. Rabbit.prototype
прототипно наследует отAnimal.prototype
.
В результате наследование работает как для обычных, так и для статических методов.
Давайте это проверим кодом:
class Animal {} class Rabbit extends Animal {} // для статики alert(Rabbit.__proto__ === Animal); // true // для обычных методов alert(Rabbit.prototype.__proto__ === Animal.prototype); // true
Итого
Статические методы используются для функциональности, принадлежат классу «в целом», а не относятся к конкретному объекту класса.
Например, метод для сравнения двух статей Article.compare(article1, article2)
или фабричный метод Article.createTodays()
.
В объявлении класса они помечаются ключевым словом static
.
Статические свойства используются в тех случаях, когда мы хотели бы сохранить данные на уровне класса, а не какого-то одного объекта.
Синтаксис:
class MyClass { static property = ...; static method() { ... } }
Технически, статическое объявление – это то же самое, что и присвоение классу:
MyClass.property = ... MyClass.method = ...
Статические свойства и методы наследуются.
Для class B extends A
прототип класса B
указывает на A
: B.[[Prototype]] = A
. Таким образом, если поле не найдено в B
, поиск продолжается в A
.
Приватные и защищённые методы и свойства
Один из важнейших принципов объектно-ориентированного программирования – разделение внутреннего и внешнего интерфейсов.
Это обязательная практика в разработке чего-либо сложнее, чем «hello world».
Чтобы понять этот принцип, давайте на секунду забудем о программировании и обратим взгляд на реальный мир.
Устройства, которыми мы пользуемся, обычно довольно сложно устроены. Но разделение внутреннего и внешнего интерфейсов позволяет нам пользоваться ими без каких-либо проблем.
Пример из реальной жизни
Например, кофеварка.
Простая снаружи: кнопка, экран, несколько отверстий… И, конечно, результат – прекрасный кофе! :), Но внутри… (картинка из инструкции по ремонту)
Множество деталей. Но мы можем пользоваться ею, ничего об этом не зная.
Кофеварки довольно надёжны, не так ли? Мы можем пользоваться ими годами, и если что-то пойдёт не так – отнесём в ремонт.
Секрет надёжности и простоты кофеварки – все детали хорошо отлажены и спрятаны внутри.
Если мы снимем защитный кожух с кофеварки, то пользоваться ею будет гораздо сложнее (куда нажимать?) и опаснее (может привести к поражению электрическим током).
Как мы увидим, в программировании объекты похожи на кофеварки.
Но, чтобы скрыть внутренние детали, мы будем использовать не защитный кожух, а специальный синтаксис языка и соглашения.
Внутренний и внешний интерфейсы
В объектно-ориентированном программировании свойства и методы разделены на 2 группы:
- Внутренний интерфейс – методы и свойства, доступные из других методов класса, но не снаружи класса.
- Внешний интерфейс – методы и свойства, доступные снаружи класса.
Если мы продолжаем аналогию с кофеваркой – то, что скрыто внутри: трубка кипятильника, нагревательный элемент и т.д. – это внутренний интерфейс.
Внутренний интерфейс используется для работы объекта, его детали используют друг друга. Например, трубка кипятильника прикреплена к нагревательному элементу.
Но снаружи кофеварка закрыта защитным кожухом, так что никто не может добраться до сложных частей. Детали скрыты и недоступны. Мы можем использовать их функции через внешний интерфейс.
Итак, всё, что нам нужно для использования объекта, это знать его внешний интерфейс. Мы можем совершенно не знать, как это работает внутри, и это здорово.
Это было общее введение.
В JavaScript есть два типа полей (свойств и методов) объекта:
- Публичные: доступны отовсюду. Они составляют внешний интерфейс. До этого момента мы использовали только публичные свойства и методы.
- Приватные: доступны только внутри класса. Они для внутреннего интерфейса.
Во многих других языках также существуют «защищённые» поля, доступные только внутри класса или для дочерних классов (то есть, как приватные, но разрешён доступ для наследующих классов) и также полезны для внутреннего интерфейса. В некотором смысле они более распространены, чем приватные, потому что мы обычно хотим, чтобы наследующие классы получали доступ к внутренним полям.
Защищённые поля не реализованы в JavaScript на уровне языка, но на практике они очень удобны, поэтому их эмулируют.
А теперь давайте сделаем кофеварку на JavaScript со всеми этими типами свойств. Кофеварка имеет множество деталей, мы не будем их моделировать для простоты примера (хотя могли бы).
Защищённое свойство «waterAmount»
Давайте для начала создадим простой класс для описания кофеварки:
class CoffeeMachine { waterAmount = 0; // количество воды внутри constructor(power) { this.power = power; alert( `Создана кофеварка, мощность: ${power}` ); } } // создаём кофеварку let coffeeMachine = new CoffeeMachine(100); // добавляем воды coffeeMachine.waterAmount = 200;
Прямо сейчас свойства waterAmount
и power
публичные. Мы можем легко получать и устанавливать им любое значение извне.
Давайте изменим свойство waterAmount
на защищённое, чтобы иметь больше контроля над ним. Например, мы не хотим, чтобы кто-либо устанавливал его ниже нуля.
Защищённые свойства обычно начинаются с префикса _
.
Это не синтаксис языка: есть хорошо известное соглашение между программистами, что такие свойства и методы не должны быть доступны извне. Большинство программистов следуют этому соглашению.
Так что наше свойство будет называться _waterAmount
:
class CoffeeMachine { _waterAmount = 0; set waterAmount(value) { if (value < 0) throw new Error("Отрицательное количество воды"); this._waterAmount = value; } get waterAmount() { return this._waterAmount; } constructor(power) { this._power = power; } } // создаём новую кофеварку let coffeeMachine = new CoffeeMachine(100); // устанавливаем количество воды coffeeMachine.waterAmount = -10; // Error: Отрицательное количество воды
Теперь доступ под контролем, поэтому указать воду ниже нуля не удалось.
Свойство только для чтения «power»
Давайте сделаем свойство power
доступным только для чтения. Иногда нужно, чтобы свойство
устанавливалось только при создании объекта и после этого никогда не
изменялось.
Это как раз требуется для кофеварки: мощность никогда не меняется.
Для этого нам нужно создать только геттер, но не сеттер:
class CoffeeMachine { // ... constructor(power) { this._power = power; } get power() { return this._power; } } // создаём кофеварку let coffeeMachine = new CoffeeMachine(100); alert(`Мощность: ${coffeeMachine.power}W`); // Мощность: 100W coffeeMachine.power = 25; // Error (no setter)
Геттеры/сеттеры
Здесь мы использовали синтаксис геттеров/сеттеров.
Но в большинстве случаев использование функций get.../set...
предпочтительнее:
class CoffeeMachine { _waterAmount = 0; setWaterAmount(value) { if (value < 0) throw new Error("Отрицательное количество воды"); this._waterAmount = value; } getWaterAmount() { return this._waterAmount; } } new CoffeeMachine().setWaterAmount(100);
Это выглядит немного длиннее, но функции более гибкие. Они могут принимать несколько аргументов (даже если они нам сейчас не нужны). Итак, на будущее, если нам надо что-то отрефакторить, функции – более безопасный выбор.
С другой стороны, синтаксис get/set короче, решать вам.
Защищённые поля наследуются
Если мы унаследуем class MegaMachine extends CoffeeMachine
, ничто не помешает нам обращаться к this._waterAmount
или this._power
из методов нового класса.
Таким образом, защищённые поля, конечно же, наследуются. В отличие от приватных полей, в чём мы убедимся ниже.
Приватное свойство «#waterLimit»
Новая возможность
Эта возможность была добавлена в язык недавно. В движках JavaScript пока не поддерживается или поддерживается частично, нужен полифил.Есть новшество в языке JavaScript, которое почти добавлено в стандарт: оно добавляет поддержку приватных свойств и методов.
Приватные свойства и методы должны начинаться с #
. Они доступны только внутри класса.
Например, в классе ниже есть приватное свойство #waterLimit
и приватный метод #checkWater
для проверки количества воды:
class CoffeeMachine { #waterLimit = 200; #checkWater(value) { if (value < 0) throw new Error("Отрицательный уровень воды"); if (value > this.#waterLimit) throw new Error("Слишком много воды"); } } let coffeeMachine = new CoffeeMachine(); // снаружи нет доступа к приватным методам класса coffeeMachine.#checkWater(); // Error coffeeMachine.#waterLimit = 1000; // Error
На уровне языка #
является специальным
символом, который означает, что поле приватное. Мы не можем получить к
нему доступ извне или из наследуемых классов.
Приватные поля не конфликтуют с публичными. У нас может быть два поля одновременно – приватное #waterAmount
и публичное waterAmount
.
Например, давайте сделаем аксессор waterAmount
для #waterAmount
:
class CoffeeMachine { #waterAmount = 0; get waterAmount() { return this.#waterAmount; } set waterAmount(value) { if (value < 0) throw new Error("Отрицательный уровень воды"); this.#waterAmount = value; } } let machine = new CoffeeMachine(); machine.waterAmount = 100; alert(machine.#waterAmount); // Error
В отличие от защищённых, функциональность приватных полей обеспечивается самим языком. Это хорошо.
Но если мы унаследуем от CoffeeMachine
, то мы не получим прямого доступа к #waterAmount
. Мы будем вынуждены полагаться на геттер/сеттер waterAmount
:
class MegaCoffeeMachine extends CoffeeMachine { method() { alert( this.#waterAmount ); // Error: can only access from CoffeeMachine } }
Во многих случаях такое ограничение слишком жёсткое. Раз уж мы расширяем CoffeeMachine
,
у нас может быть вполне законная причина для доступа к внутренним
методам и свойствам. Поэтому защищённые свойства используются чаще, хоть
они и не поддерживаются синтаксисом языка.
Важно:
Приватные поля особенные.
Как мы помним, обычно мы можем получить доступ к полям объекта с помощью this[name]
:
class User {
...
sayHi() {
let fieldName = "name";
alert(`Hello, ${this[fieldName]}
`
);
}
}
С приватными свойствами такое невозможно: this['#name']
не работает. Это ограничение синтаксиса сделано для обеспечения приватности.
Итого
В терминах ООП отделение внутреннего интерфейса от внешнего называется инкапсуляция.
Это даёт следующие выгоды:
- Защита для пользователей, чтобы они не выстрелили себе в ногу
-
Представьте себе, что есть команда разработчиков, использующая кофеварку. Она была изготовлена компанией «Лучшие Кофеварки» и работает нормально, но защитный кожух был снят. Внутренний интерфейс стал доступен извне.
Все разработчики культурны – они используют кофеварку по назначению. Но один из них, Джон, решил, что он самый умный, и сделал некоторые изменения во внутренностях кофеварки. После чего кофеварка вышла из строя через два дня.
Это, конечно, не вина Джона, а скорее человека, который снял защитный кожух и позволил Джону делать свои манипуляции.
То же самое в программировании. Если пользователь класса изменит вещи, не предназначенные для изменения извне – последствия непредсказуемы.
- Поддерживаемость
-
Ситуация в программировании сложнее, чем с реальной кофеваркой, потому что мы не просто покупаем её один раз. Код постоянно подвергается разработке и улучшению.
Если мы чётко отделим внутренний интерфейс, то разработчик класса сможет свободно менять его внутренние свойства и методы, даже не информируя пользователей…
Если вы разработчик такого класса, то приятно знать, что приватные методы можно безопасно переименовывать, их параметры можно изменять и даже удалять, потому что от них не зависит никакой внешний код.
В новой версии вы можете полностью всё переписать, но пользователю будет легко обновиться, если внешний интерфейс остался такой же.
- Сокрытие сложности
-
Люди обожают использовать простые вещи. По крайней мере, снаружи. Что внутри – это другое дело.
Программисты не являются исключением.
Всегда удобно, когда детали реализации скрыты, и доступен простой, хорошо документированный внешний интерфейс.
Для сокрытия внутреннего интерфейса мы используем защищённые или приватные свойства:
- Защищённые поля имеют префикс
_
. Это хорошо известное соглашение, не поддерживаемое на уровне языка. Программисты должны обращаться к полю, начинающемуся с_
, только из его класса и классов, унаследованных от него. - Приватные поля имеют префикс
#
. JavaScript гарантирует, что мы можем получить доступ к таким полям только внутри класса.
В настоящее время приватные поля не очень хорошо поддерживаются в браузерах, но можно использовать полифил.
Расширение встроенных классов
От встроенных классов, таких как Array
, Map
и других, тоже можно наследовать.
Например, в этом примере PowerArray
наследуется от встроенного Array
:
// добавим один метод (можно более одного) class PowerArray extends Array { isEmpty() { return this.length === 0; } } let arr = new PowerArray(1, 2, 5, 10, 50); alert(arr.isEmpty()); // false let filteredArr = arr.filter(item => item >= 10); alert(filteredArr); // 10, 50 alert(filteredArr.isEmpty()); // false
Обратите внимание на интересный момент: встроенные методы, такие как filter
, map
и другие возвращают новые объекты унаследованного класса PowerArray
. Их внутренняя реализация такова, что для этого они используют свойство объекта constructor
.
В примере выше,
arr.constructor === PowerArray
Поэтому при вызове метода arr.filter()
он внутри создаёт массив результатов, именно используя arr.constructor
, а не обычный массив. Это замечательно, поскольку можно продолжать использовать методы PowerArray
далее на результатах.
Более того, мы можем настроить это поведение.
При помощи специального статического геттера Symbol.species
можно вернуть конструктор, который JavaScript будет использовать в filter
, map
и других методах для создания новых объектов.
Если бы мы хотели, чтобы методы map
, filter
и т. д. возвращали обычные массивы, мы могли бы вернуть Array
в Symbol.species
, вот так:
class PowerArray extends Array { isEmpty() { return this.length === 0; } // встроенные методы массива будут использовать этот метод как конструктор static get [Symbol.species]() { return Array; } } let arr = new PowerArray(1, 2, 5, 10, 50); alert(arr.isEmpty()); // false // filter создаст новый массив, используя arr.constructor[Symbol.species] как конструктор let filteredArr = arr.filter(item => item >= 10); // filteredArr не является PowerArray, это Array alert(filteredArr.isEmpty()); // Error: filteredArr.isEmpty is not a function
Как вы видите, теперь .filter
возвращает Array
. Расширенная функциональность не будет передаваться далее.
Аналогично работают другие коллекции
Другие коллекции, такие как Map
, Set
, работают аналогично. Они также используют Symbol.species
.
Отсутствие статического наследования встроенных классов
У встроенных объектов есть собственные статические методы, например Object.keys
, Array.isArray
и т. д.
Как мы уже знаем, встроенные классы расширяют друг друга.
Обычно, когда один класс наследует другой, то наследуются и статические методы.
Но встроенные классы – исключение. Они не наследуют статические методы друг друга.
Например, и Array
, и Date
наследуют от Object
, так что в их экземплярах доступны методы из Object.prototype
. Но Array.[[Prototype]]
не ссылается на Object
, поэтому нет методов Array.keys()
или Date.keys()
.
Ниже вы видите структуру Date
и Object
:
Как видите, нет связи между Date
и Object
. Они независимы, только Date.prototype
наследует от Object.prototype
.
В этом важное отличие наследования встроенных объектов от того, что мы получаем с использованием extends
.
Проверка класса: "instanceof"
Оператор instanceof
позволяет проверить, принадлежит ли объект указанному классу, с учётом наследования.
Такая проверка может потребоваться во многих случаях. Здесь мы используем её для создания полиморфной функции, которая интерпретирует аргументы по-разному в зависимости от их типа.
Оператор instanceof
Синтаксис:
obj instanceof Class
Оператор вернёт true
, если obj
принадлежит классу Class
или наследующему от него.
Например:
class Rabbit {} let rabbit = new Rabbit(); // это объект класса Rabbit? alert( rabbit instanceof Rabbit ); // true
Также это работает с функциями-конструкторами:
// вместо класса function Rabbit() {} alert( new Rabbit() instanceof Rabbit ); // true
…И для встроенных классов, таких как Array
:
let arr = [1, 2, 3]; alert( arr instanceof Array ); // true alert( arr instanceof Object ); // true
Пожалуйста, обратите внимание, что arr
также принадлежит классу Object
, потому что Array
наследует от Object
.
Обычно оператор instanceof
просматривает для проверки цепочку прототипов. Но это поведение может быть изменено при помощи статического метода Symbol.hasInstance
.
Алгоритм работы obj instanceof Class
работает примерно так:
-
Если имеется статический метод
Symbol.hasInstance
, тогда вызвать его:Class[Symbol.hasInstance](obj)
. Он должен вернуть либоtrue
, либоfalse
, и это конец. Это как раз и есть возможность ручной настройкиinstanceof
.Пример:
// проверка instanceof будет полагать, // что всё со свойством canEat - животное Animal class Animal { static [Symbol.hasInstance](obj) { if (obj.canEat) return true; } } let obj = { canEat: true }; alert(obj instanceof Animal); // true: вызван Animal[Symbol.hasInstance](obj)
-
Большая часть классов не имеет метода
Symbol.hasInstance
. В этом случае используется стандартная логика: проверяется, равен лиClass.prototype
одному из прототипов в прототипной цепочкеobj
.Другими словами, сравнивается:
obj.__proto__ === Class.prototype? obj.__proto__.__proto__ === Class.prototype? obj.__proto__.__proto__.__proto__ === Class.prototype? ... // если какой-то из ответов true - возвратить true // если дошли до конца цепочки - false
В примере выше
rabbit.__proto__ === Rabbit.prototype
, так что результат будет получен немедленно.В случае с наследованием, совпадение будет на втором шаге:
class Animal {} class Rabbit extends Animal {} let rabbit = new Rabbit(); alert(rabbit instanceof Animal); // true // rabbit.__proto__ === Animal.prototype (нет совпадения) // rabbit.__proto__.__proto__ === Animal.prototype (совпадение!)
Вот иллюстрация того как rabbit instanceof Animal
сравнивается с Animal.prototype
:
Кстати, есть метод objA.isPrototypeOf(objB), который возвращает true
, если объект objA
есть где-то в прототипной цепочке объекта objB
. Так что obj instanceof Class
можно перефразировать как Class.prototype.isPrototypeOf(obj)
.
Забавно, но сам конструктор Class
не участвует в процессе проверки! Важна только цепочка прототипов Class.prototype
.
Это может приводить к интересным последствиям при изменении свойства prototype
после создания объекта.
Как, например, тут:
function Rabbit() {} let rabbit = new Rabbit(); // заменяем прототип Rabbit.prototype = {}; // ...больше не rabbit! alert( rabbit instanceof Rabbit ); // false
Бонус: Object.prototype.toString возвращает тип
Мы уже знаем, что обычные объекты преобразуются к строке как [object Object]
:
let obj = {}; alert(obj); // [object Object] alert(obj.toString()); // то же самое
Так работает реализация метода toString
. Но у toString
имеются скрытые возможности, которые делают метод гораздо более мощным. Мы можем использовать его как расширенную версию typeof
и как альтернативу instanceof
.
Звучит странно? Так и есть. Давайте развеем мистику.
Согласно спецификации встроенный метод toString
может быть позаимствован у объекта и вызван в контексте любого другого значения. И результат зависит от типа этого значения.
- Для числа это будет
[object Number]
- Для булева типа это будет
[object Boolean]
- Для
null
:[object Null]
- Для
undefined
:[object Undefined]
- Для массивов:
[object Array]
- …и т.д. (поведение настраивается).
Давайте продемонстрируем:
// скопируем метод toString в переменную для удобства let objectToString = Object.prototype.toString; // какой это тип? let arr = []; alert( objectToString.call(arr) ); // [object Array]
В примере мы использовали call, чтобы выполнить функцию objectToString
в контексте this=arr
.
Внутри, алгоритм метода toString
анализирует контекст вызова this
и возвращает соответствующий результат. Больше примеров:
let s = Object.prototype.toString; alert( s.call(123) ); // [object Number] alert( s.call(null) ); // [object Null] alert( s.call(alert) ); // [object Function]
Symbol.toStringTag
Поведение метода объектов toString
можно настраивать, используя специальное свойство объекта Symbol.toStringTag
.
Например:
let user = { [Symbol.toStringTag]: "User" }; alert( {}.toString.call(user) ); // [object User]
Такое свойство есть у большей части объектов, специфичных для определённых окружений. Вот несколько примеров для браузера:
// toStringTag для браузерного объекта и класса alert( window[Symbol.toStringTag]); // window alert( XMLHttpRequest.prototype[Symbol.toStringTag] ); // XMLHttpRequest alert( {}.toString.call(window) ); // [object Window] alert( {}.toString.call(new XMLHttpRequest()) ); // [object XMLHttpRequest]
Как вы можете видеть, результат – это значение Symbol.toStringTag
(если он имеется) обёрнутое в [object ...]
.
В итоге мы получили «typeof на стероидах», который не только работает с примитивными типами данных, но также и со встроенными объектами, и даже может быть настроен.
Можно использовать {}.toString.call
вместо instanceof
для встроенных объектов, когда мы хотим получить тип в виде строки, а не просто сделать проверку.
Итого
Давайте обобщим, какие методы для проверки типа мы знаем:
работает для | возвращает | |
---|---|---|
typeof |
примитивов | строка |
{}.toString |
примитивов, встроенных объектов, объектов с Symbol.toStringTag |
строка |
instanceof |
объектов | true/false |
Как мы можем видеть, технически {}.toString
«более продвинут», чем typeof
.
А оператор instanceof
– отличный выбор, когда мы работаем с иерархией классов и хотим делать проверки с учётом наследования.
Примеси
В JavaScript можно наследовать только от одного объекта. Объект имеет единственный [[Prototype]]
. И класс может расширить только один другой класс.
Иногда это может ограничивать нас. Например, у нас есть класс StreetSweeper
и класс Bicycle
, а мы хотим создать их смесь: StreetSweepingBicycle
.
Или у нас есть класс User
, который реализует пользователей, и класс EventEmitter
, реализующий события. Мы хотели бы добавить функциональность класса EventEmitter
к User
, чтобы пользователи могли легко генерировать события.
Для таких случаев существуют «примеси».
По определению из Википедии, примесь – это класс, методы которого предназначены для использования в других классах, причём без наследования от примеси.
Другими словами, примесь определяет методы, которые реализуют определённое поведение. Мы не используем примесь саму по себе, а используем её, чтобы добавить функциональность другим классам.
Пример примеси
Простейший способ реализовать примесь в JavaScript – это создать объект с полезными методами, которые затем могут быть легко добавлены в прототип любого класса.
В примере ниже примесь sayHiMixin
имеет методы, которые придают объектам класса User
возможность вести разговор:
// примесь let sayHiMixin = { sayHi() { alert(`Привет, ${this.name}`); }, sayBye() { alert(`Пока, ${this.name}`); } }; // использование: class User { constructor(name) { this.name = name; } } // копируем методы Object.assign(User.prototype, sayHiMixin); // теперь User может сказать Привет new User("Вася").sayHi(); // Привет, Вася!
Это не наследование, а просто копирование методов. Таким образом, класс User
может наследовать от другого класса, но при этом также включать в себя примеси, «подмешивающие» другие методы, например:
class User extends Person { // ... } Object.assign(User.prototype, sayHiMixin);
Примеси могут наследовать друг друга.
В примере ниже sayHiMixin
наследует от sayMixin
:
let sayMixin = { say(phrase) { alert(phrase); } }; let sayHiMixin = { __proto__: sayMixin, // (или мы можем использовать Object.setPrototypeOf для задания прототипа) sayHi() { // вызываем метод родителя super.say(`Привет, ${this.name}`); // (*) }, sayBye() { super.say(`Пока, ${this.name}`); // (*) } }; class User { constructor(name) { this.name = name; } } // копируем методы Object.assign(User.prototype, sayHiMixin); // теперь User может сказать Привет new User("Вася").sayHi(); // Привет, Вася!
Обратим внимание, что при вызове родительского метода super.say()
из sayHiMixin
(строки, помеченные (*)
) этот метод ищется в прототипе самой примеси, а не класса.
Вот диаграмма (см. правую часть):
Это связано с тем, что методы sayHi
и sayBye
были изначально созданы в объекте sayHiMixin
. Несмотря на то, что они скопированы, их внутреннее свойство [[HomeObject]]
ссылается на sayHiMixin
, как показано на картинке выше.
Так как super
ищет родительские методы в [[HomeObject]].[[Prototype]]
, это означает, что он ищет sayHiMixin.[[Prototype]]
.
EventMixin
Многие объекты в браузерной разработке (и не только) обладают важной способностью – они могут генерировать события. События – отличный способ передачи информации всем, кто в ней заинтересован. Давайте создадим примесь, которая позволит легко добавлять функциональность по работе с событиями любым классам/объектам.
- Примесь добавит метод
.trigger(name, [...data])
для генерации события. Аргументname
– это имя события, за которым могут следовать дополнительные аргументы с данными для события. - Также будет добавлен метод
.on(name, handler)
, который назначает обработчик для события с заданным именем. Обработчик будет вызван, когда произойдёт событие с указанным именемname
, и получит данные из.trigger
. - …и метод
.off(name, handler)
, который удаляет обработчик указанного события.
После того, как все методы примеси будут добавлены, объект user
сможет сгенерировать событие "login"
после входа пользователя в личный кабинет. А другой объект, к примеру, calendar
сможет использовать это событие, чтобы показывать зашедшему пользователю актуальный для него календарь.
Или menu
может генерировать событие "select"
, когда элемент меню выбран, а другие объекты могут назначать обработчики, чтобы реагировать на это событие, и т.п.
Вот код примеси:
let eventMixin = { /** * Подписаться на событие, использование: * menu.on('select', function(item) { ... } */ on(eventName, handler) { if (!this._eventHandlers) this._eventHandlers = {}; if (!this._eventHandlers[eventName]) { this._eventHandlers[eventName] = []; } this._eventHandlers[eventName].push(handler); }, /** * Отменить подписку, использование: * menu.off('select', handler) */ off(eventName, handler) { let handlers = this._eventHandlers?.[eventName]; if (!handlers) return; for (let i = 0; i < handlers.length; i++) { if (handlers[i] === handler) { handlers.splice(i--, 1); } } }, /** * Сгенерировать событие с указанным именем и данными * this.trigger('select', data1, data2); */ trigger(eventName, ...args) { if (!this._eventHandlers?.[eventName]) { return; // обработчиков для этого события нет } // вызовем обработчики this._eventHandlers[eventName].forEach(handler => handler.apply(this, args)); } };
Итак, у нас есть 3 метода:
-
.on(eventName, handler)
– назначает функциюhandler
, чтобы обработать событие с заданным именем.Технически существует свойство
_eventHandlers
, в котором хранится массив обработчиков для каждого имени события, и оно просто добавляет это событие в список. -
.off(eventName, handler)
– убирает функцию из списка обработчиков. -
.trigger(eventName, ...args)
– генерирует событие: все назначенные обработчики из_eventHandlers[eventName]
вызываются, и...args
передаются им в качестве аргументов.
Использование:
let eventMixin = { /** * Подписаться на событие, использование: * menu.on('select', function(item) { ... } */ on(eventName, handler) { if (!this._eventHandlers) this._eventHandlers = {}; if (!this._eventHandlers[eventName]) { this._eventHandlers[eventName] = []; } this._eventHandlers[eventName].push(handler); }, /** * Отменить подписку, использование: * menu.off('select', handler) */ off(eventName, handler) { let handlers = this._eventHandlers?.[eventName]; if (!handlers) return; for (let i = 0; i < handlers.length; i++) { if (handlers[i] === handler) { handlers.splice(i--, 1); } } }, /** * Сгенерировать событие с указанным именем и данными * this.trigger('select', data1, data2); */ trigger(eventName, ...args) { if (!this._eventHandlers?.[eventName]) { return; // обработчиков для этого события нет } // вызовем обработчики this._eventHandlers[eventName].forEach(handler => handler.apply(this, args)); } }; // Создадим класс class Menu { choose(value) { this.trigger("select", value); } } // Добавим примесь с методами для событий Object.assign(Menu.prototype, eventMixin); let menu = new Menu(); // Добавим обработчик, который будет вызван при событии "select": menu.on("select", value => alert(`Выбранное значение: ${value}`)); // Генерирует событие => обработчик выше запускается и выводит: menu.choose("123"); // Выбранное значение: 123
Теперь если у нас есть код, заинтересованный в событии "select"
, то он может слушать его с помощью menu.on(...)
.
А eventMixin
позволяет легко добавить такое поведение в любой класс без вмешательства в цепочку наследования.
Итого
Примесь – общий термин в объектно-ориентированном программировании: класс, который содержит в себе методы для других классов.
Некоторые другие языки допускают множественное наследование. JavaScript не поддерживает множественное наследование, но с помощью примесей мы можем реализовать нечто похожее, скопировав методы в прототип.
Мы можем использовать примеси для расширения функциональности классов, например, для обработки событий, как мы сделали это выше.
С примесями могут возникнуть конфликты, если они перезаписывают существующие методы класса. Стоит помнить об этом и быть внимательнее при выборе имён для методов примеси, чтобы их избежать.