Статьи

Объектно-ориентированный JavaScript: глубокое погружение в классы ES6

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

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

Рассмотрим этот неклассный код. Сколько ошибок вы можете найти? Как бы вы их исправить?

// set today to December 24 const today = { month: 24, day: 12, }; const tomorrow = { year: today.year, month: today.month, day: today.day + 1, }; const dayAfterTomorrow = { year: tomorrow.year, month: tomorrow.month, day: tomorrow.day + 1 <= 31 ? tomorrow.day + 1 : 1, }; 

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

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

 class SimpleDate { constructor(year, month, day) { // Check that (year, month, day) is a valid date // ... // If it is, use it to initialize "this" date this._year = year; this._month = month; this._day = day; } addDays(nDays) { // Increase "this" date by n days // ... } getDay() { return this._day; } } // "today" is guaranteed to be valid and fully initialized const today = new SimpleDate(2000, 2, 28); // Manipulating data only through a fixed set of functions ensures we maintain valid state today.addDays(1); 
ЖАРГОН СОВЕТ:

  • Когда функция связана с классом или объектом, мы называем это методом .
  • Когда объект создается из класса, этот объект называется экземпляром класса.

Конструкторы

Метод constructor — особенный, и он решает первую проблему. Его задача — инициализировать экземпляр в допустимое состояние, и он будет вызываться автоматически, поэтому мы не можем забыть инициализировать наши объекты.

Хранить данные в секрете

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

JARGON TIP: Хранение данных в секрете называется инкапсуляция .

Конфиденциальность с конвенциями

К сожалению, свойства закрытого объекта не существуют в JavaScript. Мы должны подделать их. Наиболее распространенный способ сделать это — придерживаться простого соглашения: если имя свойства имеет префикс подчеркивания (или, реже, суффикс подчеркивания), то оно должно рассматриваться как закрытое. Мы использовали этот подход в предыдущем примере кода. Обычно это простое соглашение работает, но технически данные все еще доступны каждому, поэтому мы должны полагаться на собственную дисциплину, чтобы поступать правильно.

Конфиденциальность с использованием привилегированных методов

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

 class SimpleDate { constructor(year, month, day) { // Check that (year, month, day) is a valid date // ... // If it is, use it to initialize "this" date's ordinary variables let _year = year; let _month = month; let _day = day; // Methods defined in the constructor capture variables in a closure this.addDays = function(nDays) { // Increase "this" date by n days // ... } this.getDay = function() { return _day; } } } 

Конфиденциальность с символами

Символы являются новой функцией JavaScript с ES6, и они дают нам еще один способ подделать свойства частного объекта. Вместо подчеркивания имен свойств мы могли бы использовать уникальные ключи объектов символов, и наш класс может захватывать эти ключи в замыкании. Но есть утечка. Еще одна новая функция JavaScript — Object.getOwnPropertySymbols , и она позволяет извне получать доступ к символьным клавишам, которые мы пытались сохранить в Object.getOwnPropertySymbols :

 const SimpleDate = (function() { const _yearKey = Symbol(); const _monthKey = Symbol(); const _dayKey = Symbol(); class SimpleDate { constructor(year, month, day) { // Check that (year, month, day) is a valid date // ... // If it is, use it to initialize "this" date this[_yearKey] = year; this[_monthKey] = month; this[_dayKey] = day; } addDays(nDays) { // Increase "this" date by n days // ... } getDay() { return this[_dayKey]; } } return SimpleDate; }()); 

Конфиденциальность с помощью слабых карт

Слабые карты также являются новой функцией JavaScript. Мы можем хранить свойства частного объекта в парах ключ / значение, используя наш экземпляр в качестве ключа, и наш класс может захватывать эти карты ключ / значение в замыкании:

 const SimpleDate = (function() { const _years = new WeakMap(); const _months = new WeakMap(); const _days = new WeakMap(); class SimpleDate { constructor(year, month, day) { // Check that (year, month, day) is a valid date // ... // If it is, use it to initialize "this" date _years.set(this, year); _months.set(this, month); _days.set(this, day); } addDays(nDays) { // Increase "this" date by n days // ... } getDay() { return _days.get(this); } } return SimpleDate; }()); 

Другие модификаторы доступа

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

Ссылаясь на текущий объект

Посмотрите еще раз на getDay() . Он не указывает никаких параметров, так как он узнает объект, для которого он был вызван? Когда функция вызывается как метод с использованием нотации object.function , существует неявный аргумент, который используется для идентификации объекта, и этот неявный аргумент присваивается неявному параметру с именем this . Чтобы проиллюстрировать это, вот как мы должны передавать объектный аргумент явно, а не неявно:

 // Get a reference to the "getDay" function const getDay = SimpleDate.prototype.getDay; getDay.call(today); // "this" will be "today" getDay.call(tomorrow); // "this" will be "tomorrow" tomorrow.getDay(); // same as last line, but "tomorrow" is passed implicitly 

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

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

 class SimpleDate { static setDefaultDate(year, month, day) { // A static property can be referred to without mentioning an instance // Instead, it's defined on the class SimpleDate._defaultDate = new SimpleDate(year, month, day); } constructor(year, month, day) { // If constructing without arguments, // then initialize "this" date by copying the static default date if (arguments.length === 0) { this._year = SimpleDate._defaultDate._year; this._month = SimpleDate._defaultDate._month; this._day = SimpleDate._defaultDate._day; return; } // Check that (year, month, day) is a valid date // ... // If it is, use it to initialize "this" date this._year = year; this._month = month; this._day = day; } addDays(nDays) { // Increase "this" date by n days // ... } getDay() { return this._day; } } SimpleDate.setDefaultDate(1970, 1, 1); const defaultDate = new SimpleDate(); 

Подклассы

Часто мы находим сходство между нашими классами — повторяющийся код, который мы хотели бы объединить. Подклассы позволяют нам включать состояние и поведение другого класса в наши собственные. Этот процесс часто называют наследованием , и говорят, что наш подкласс «наследует» от родительского класса, также называемого суперклассом . Наследование может избежать дублирования и упростить реализацию класса, который нуждается в тех же данных и функциях, что и другой класс. Наследование также позволяет нам заменять подклассы, полагаясь только на интерфейс, предоставленный общим суперклассом.

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

Рассмотрим этот код без наследования:

 class Employee { constructor(firstName, familyName) { this._firstName = firstName; this._familyName = familyName; } getFullName() { return `${this._firstName} ${this._familyName}`; } } class Manager { constructor(firstName, familyName) { this._firstName = firstName; this._familyName = familyName; this._managedEmployees = []; } getFullName() { return `${this._firstName} ${this._familyName}`; } addEmployee(employee) { this._managedEmployees.push(employee); } } 

Свойства данных _firstName и _familyName , а также метод getFullName повторяются между нашими классами. Мы могли бы устранить это повторение, если бы наш класс Manager наследовал от класса Employee . Когда мы это сделаем, состояние и поведение класса Employee — его данных и функций — будут включены в наш класс Manager .

Вот версия, которая использует наследование. Обратите внимание на использование супер :

 // Manager still works same as before but without repeated code class Manager extends Employee { constructor(firstName, familyName) { super(firstName, familyName); this._managedEmployees = []; } addEmployee(employee) { this._managedEmployees.push(employee); } } 

IS-A и WORKS-LIKE-A

Существуют принципы проектирования, которые помогут вам решить, когда уместно наследование. Наследование всегда должно моделировать отношения IS-A и WORKS-LIKE-A. То есть менеджер «является» и «работает как» конкретный тип сотрудника, так что где бы мы ни работали с экземпляром суперкласса, мы могли бы иметь возможность заменить его в экземпляре подкласса, и все по-прежнему должно работать. Разница между нарушением и соблюдением этого принципа иногда может быть тонкой. Классическим примером тонкого нарушения является суперкласс Rectangle и подкласс Square :

 class Rectangle { set width(w) { this._width = w; } get width() { return this._width; } set height(h) { this._height = h; } get height() { return this._height; } } // A function that operates on an instance of Rectangle function f(rectangle) { rectangle.width = 5; rectangle.height = 4; // Verify expected result if (rectangle.width * rectangle.height !== 20) { throw new Error("Expected the rectangle's area (width * height) to be 20"); } } // A square IS-A rectangle... right? class Square extends Rectangle { set width(w) { super.width = w; // Maintain square-ness super.height = w; } set height(h) { super.height = h; // Maintain square-ness super.width = h; } } // But can a rectangle be substituted by a square? f(new Square()); // error 

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

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

Остерегайтесь чрезмерного использования

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

Давайте вернемся к проблеме дублирования кода. Можем ли мы решить это без наследства? Альтернативный подход состоит в том, чтобы связать объекты через ссылки, чтобы представить отношения часть-целое. Мы называем эту композицию .

Вот версия отношений менеджер-работник, использующая композицию, а не наследование:

 class Employee { constructor(firstName, familyName) { this._firstName = firstName; this._familyName = familyName; } getFullName() { return `${this._firstName} ${this._familyName}`; } } class Group { constructor(manager /* : Employee */ ) { this._manager = manager; this._managedEmployees = []; } addEmployee(employee) { this._managedEmployees.push(employee); } } 

Здесь менеджер не отдельный класс. Вместо этого менеджер — это обычный экземпляр Employee который ссылается экземпляр Group . Если наследование моделирует отношение IS-A, то композиция моделирует отношение HAS-A. То есть в группе «есть» менеджер.

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

Наследовать для замены подклассов

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

 // This will be our common superclass class Cache { get(key, defaultValue) { const value = this._doGet(key); if (value === undefined || value === null) { return defaultValue; } return value; } set(key, value) { if (key === undefined || key === null) { throw new Error('Invalid argument'); } this._doSet(key, value); } // Must be overridden // _doGet() // _doSet() } // Subclasses define no new public methods // The public interface is defined entirely in the superclass class ArrayCache extends Cache { _doGet() { // ... } _doSet() { // ... } } class LocalStorageCache extends Cache { _doGet() { // ... } _doSet() { // ... } } // Functions can polymorphically operate on any cache by interacting through the superclass interface function compute(cache) { const cached = cache.get('result'); if (!cached) { const result = // ... cache.set('result', result); } // ... } compute(new ArrayCache()); // use array cache through superclass interface compute(new LocalStorageCache()); // use local storage cache through superclass interface 

Больше, чем сахар

Синтаксис классов JavaScript часто называют синтаксическим сахаром, и во многих отношениях это так, но есть и реальные отличия — вещи, которые мы можем сделать с классами ES6, которые мы не могли сделать в ES5.

Статические свойства наследуются

ES5 не позволил нам создать истинное наследование между функциями конструктора. Object.create может создать обычный объект, но не функциональный объект. Мы подделали наследование статических свойств, скопировав их вручную. Теперь с классами ES6 мы получаем реальную ссылку на прототип между функцией конструктора подкласса и конструктором суперкласса:

 // ES5 function B() {} Bf = function () {}; function D() {} D.prototype = Object.create(B.prototype); Df(); // error 
 // ES6 class B { static f() {} } class D extends B {} Df(); // ok 

Встроенные конструкторы могут быть разделены на подклассы

Некоторые объекты являются «экзотическими» и не ведут себя как обычные объекты. Например, массивы корректируют свое свойство length чтобы оно превышало наибольший целочисленный индекс. В ES5, когда мы пытались создать подкласс Array , new оператор выделял для нашего подкласса обычный объект, а не экзотический объект нашего суперкласса:

 // ES5 function D() { Array.apply(this, arguments); } D.prototype = Object.create(Array.prototype); var d = new D(); d[0] = 42; d.length; // 0 - bad, no array exotic behavior 

Классы ES6 исправили это, изменив, когда и кем распределяются объекты. В ES5 объекты размещались до вызова конструктора подкласса, и подкласс передавал этот объект конструктору суперкласса. Теперь с классами ES6 объекты распределяются до вызова конструктора суперкласса , и суперкласс делает этот объект доступным для конструктора подкласса. Это позволяет Array выделять экзотический объект, даже когда мы вызываем new в нашем подклассе.

 // ES6 class D extends Array {} let d = new D(); d[0] = 42; d.length; // 1 - good, array exotic behavior 

Разнообразный

Есть небольшой ассортимент других, возможно, менее значительных отличий. Конструкторы классов не могут быть вызваны функциями. Это защищает от забвения вызывать конструкторы с new . Кроме того, свойство prototype конструктора класса не может быть переназначено. Это может помочь движкам JavaScript оптимизировать объекты классов. И, наконец, методы класса не имеют свойства prototype . Это может сэкономить память за счет удаления ненужных объектов.

Использование новых функций в творческих способах

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

Множественное наследование с прокси

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

 const transmitter = { transmit() {} }; const receiver = { receive() {} }; // Create a proxy object that intercepts property accesses and forwards to each parent, // returning the first defined value it finds const inheritsFromMultiple = new Proxy([transmitter, receiver], { get: function(proxyTarget, propertyKey) { const foundParent = proxyTarget.find(parent => parent[propertyKey] !== undefined); return foundParent && foundParent[propertyKey]; } }); inheritsFromMultiple.transmit(); // works inheritsFromMultiple.receive(); // works 

Можем ли мы расширить это для работы с классами ES6? prototype класса может быть прокси, который перенаправляет доступ к свойствам нескольким другим прототипам. Сообщество JavaScript работает над этим прямо сейчас. Вы можете понять это? Присоединяйтесь к обсуждению и делитесь своими идеями.

Множественное наследование с фабриками классов

Другой подход, над которым экспериментирует сообщество JavaScript, — это создание классов по требованию, которые расширяют переменный суперкласс. В каждом классе все еще есть только один родитель, но мы можем связать этих родителей интересными способами:

 function makeTransmitterClass(Superclass = Object) { return class Transmitter extends Superclass { transmit() {} }; } function makeReceiverClass(Superclass = Object) { return class Receiver extends Superclass receive() {} }; } class InheritsFromMultiple extends makeTransmitterClass(makeReceiverClass()) {} const inheritsFromMultiple = new InheritsFromMultiple(); inheritsFromMultiple.transmit(); // works inheritsFromMultiple.receive(); // works 

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

Вывод

Как показано на рисунке ниже, поддержка классов довольно хорошая .

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

Эта статья была рецензирована Нильсоном Жаком и Тимом Севериеном . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!