Варианты объектно-ориентированного программирования (на JavaScript)
Перевод: Zell Liew — The Flavors of Object-Oriented Programming (in JavaScript)
В своем исследовании я рассмотрел четыре подхода к объектно-ориентированному программированию в JavaScript:
- Использование функций конструктора
- Использование классов
- Объекты, связанные с другими объектами (OLOO)
- Использование фабричных функций
Какой способ лучше других? Какой нужно использовать, а какой нет? Здесь я представлю свои выводы вместе с информацией, которая поможет вам решить, что подходит именно вам.
Чтобы принять это решение, мы не просто рассмотрим разные подходы, но и сравним их концептуальные аспекты:
- Классы против Фабричных функций — Наследование
- Классы против Фабричных функций — Инкапсуляция
- Классы против Фабричных функций — this
- Классы против Фабричных функций — Event listeners
Начнем с основ ООП в JavaScript.
Что такое объектно-ориентированное программирование?
Объектно-ориентированное программирование — это способ написания кода, который позволяет создавать разные объекты из объекта. Общий объект обычно называется blueprint (базовая схема), а созданные объекты — экземплярами.
У каждого экземпляра есть свойства, которые не используются другими экземплярами. Например, если у вас есть blueprint человека, вы можете создавать экземпляры людей с разными именами.
Второй аспект объектно-ориентированного программирования касается структурирования кода, когда у вас есть несколько уровней blueprint элементов. Обычно это называется наследованием.
Третий аспект объектно-ориентированного программирования — это инкапсуляция, при которой вы скрываете определенные фрагменты информации внутри объекта, чтобы они были недоступны.
Если вам нужно нечто большее, чем это краткое введение, вот статья, которая знакомит с аспектами объектно-ориентированного программирования подробнее.
Начнем с основ — введение в четыре разновидности объектно-ориентированного программирования.
Четыре разновидности объектно-ориентированного программирования
Есть четыре способа использовать объектно-ориентированное программирование на JavaScript:
- Использование функции конструктора
- Использование классов
- Использование объектов, связанные с другими объектами
- Использование фабричных функций
Использование функций конструктора
Конструкторы — это функции, содержащие ключевое слово this.
function Human (firstName, lastName) { this.firstName = firstName this.lastName = lastName }
this
позволяет хранить уникальные значения, созданные для каждого экземпляра. Вы можете создать экземпляр с ключевым словом new.
const chris = new Human('Chris', 'Coyier') console.log(chris.firstName) // Chris console.log(chris.lastName) // Coyier const zell = new Human('Zell', 'Liew') console.log(zell.firstName) // Zell console.log(zell.lastName) // Liew
Синтаксис Class
Ключевое слово class считаются «синтаксическим сахаром» функций-конструкторов.
Существуют серьезные разногласия по поводу того, являются ли использование классов плохой практикой (например, это и это). Мы не собираемся здесь углубляться в эти аргументы. Вместо этого мы просто посмотрим, как писать код с помощью классов, и решим, лучше ли классы, чем конструкторы, на основе кода, который мы пишем.
Классы могут быть написаны со следующим синтаксисом:
class Human { constructor(firstName, lastName) { this.firstName = firstName this.lastName = lastName } }
Обратите внимание, что функция constructor
содержит тот же код, что и синтаксис конструктора выше? Нам используем это, поскольку мы хотим инициализировать значения для this. (Мы можем пропустить constructor
, если нам не нужно инициализировать значения. Подробнее об этом позже в разделе «Наследование»).
На первый взгляд кажется, что классы уступают конструкторам — потому что нужно писать больше кода! Но придержите лошадей и не делайте выводов на этом этапе. Нам есть еще много чего рассказать. Классы рассмотрим позже.
Как и раньше, вы можете создать экземпляр с ключевым словом new.
const chris = new Human('Chris', 'Coyier') console.log(chris.firstName) // Chris console.log(chris.lastName) // Coyier
Объекты, связанные с другими объектами (OLOO — Objects Linking to Other Objects)
OLOO был придуман и популяризирован Kyle Simpson. В OLOO вы определяете blueprint как обычный объект. Затем вы используете метод (часто называемый init, но он не требуется, как конструктор для класса) для инициализации экземпляра.
const Human = { init (firstName, lastName ) { this.firstName = firstName this.lastName = lastName } }
Далее используете Object.create для создания экземпляра. После создания экземпляра необходимо запустить функцию инициализации.
const chris = Object.create(Human) chris.init('Chris', 'Coyier') console.log(chris.firstName) // Chris console.log(chris.lastName) // Coyier
Вы можете связать init после Object.create, если вы вернули его внутри init.
const Human = { init () { // ... return this } } const chris = Object.create(Human).init('Chris', 'Coyier') console.log(chris.firstName) // Chris console.log(chris.lastName) // Coyier
Фабричные функции
Фабричные функции — это функции, возвращающие объект. Вы можете вернуть любой объект. Вы даже можете вернуть экземпляр класса или экземпляр OLOO — и он по-прежнему будет действующей фабричной функцией.
Вот самый простой способ создания фабричной функций:
function Human (firstName, lastName) { return { firstName, lastName } }
Для создания экземпляров с фабричной функцией не требуется new. Вы просто вызываете функцию.
const chris = Human('Chris', 'Coyier') console.log(chris.firstName) // Chris console.log(chris.lastName) // Coyier
Теперь, когда мы рассмотрели эти четыре возможности ООП, давайте посмотрим, как объявлять свойства и методы для каждого из них, чтобы мы могли лучше понять работу с ними, прежде чем перейти к более подробному сравнению.
Объявление свойств и методов
Методы — это функции, объявленные как свойство объекта.
const someObject = { someMethod () { /* ... */ } }
В объектно-ориентированном программировании есть два способа объявления свойств и методов:
- Непосредственно на экземпляре
- В прототипе
Давай научимся делать и то, и другое.
Объявление свойств и методов с помощью конструкторов
Если вы хотите объявить свойство непосредственно в экземпляре, вы можете записать свойство внутри функции-конструктора. Обязательно установите его как свойство для this.
function Human (firstName, lastName) { // Declares properties this.firstName = firstName this.lastname = lastName // Declares methods this.sayHello = function () { console.log(`Hello, I'm ${firstName}`) } } const chris = new Human('Chris', 'Coyier') console.log(chris)
Методы обычно объявляются в Prototype, потому что Prototype позволяет экземплярам использовать один и тот же метод. Это типа как «отпечаток кода».
Чтобы объявить свойства в прототипе, вам необходимо использовать свойство prototype.
function Human (firstName, lastName) { this.firstName = firstName this.lastname = lastName } // Declaring method on a prototype Human.prototype.sayHello = function () { console.log(`Hello, I'm ${this.firstName}`) }
Это может быть неудобно, если вы хотите объявить несколько методов в прототипе.
// Declaring methods on a prototype Human.prototype.method1 = function () { /*...*/ } Human.prototype.method2 = function () { /*...*/ } Human.prototype.method3 = function () { /*...*/ }
Вы можете упростить задачу, используя функции слияния, например с помощью Object.assign.
Object.assign(Human.prototype, { method1 () { /*...*/ }, method2 () { /*...*/ }, method3 () { /*...*/ } })
Object.assign не поддерживает объединение функций Getter и Setter. Вам нужен другой инструмент. Вот почему. А вот инструмент, который я создал для объединения объектов с Getter и Setter.
Объявление свойств и методов с помощью классов
Вы можете объявить свойства для каждого экземпляра внутри функции constructor.
class Human { constructor (firstName, lastName) { this.firstName = firstName this.lastname = lastName this.sayHello = function () { console.log(`Hello, I'm ${firstName}`) } } }
Используя классы проще объявить методы. Вы можете описать метод после constructor
, как обычную функцию.
class Human (firstName, lastName) { constructor (firstName, lastName) { /* ... */ } sayHello () { console.log(`Hello, I'm ${this.firstName}`) } }
Объявлять несколько методов в классах так же проще, чем в конструкторах. Вам не нужен синтаксис Object.assign. Вы просто пишете больше функций.
class Human (firstName, lastName) { constructor (firstName, lastName) { /* ... */ } method1 () { /*...*/ } method2 () { /*...*/ } method3 () { /*...*/ } }
Объявление свойств и методов с помощью OLOO
Вы можете использовать тот же процесс для объявления свойств и методов в экземпляре. Для этого назначьте их как свойство this.
const Human = { init (firstName, lastName) { this.firstName = firstName this.lastName = lastName this.sayHello = function () { console.log(`Hello, I'm ${firstName}`) } return this } } const chris = Object.create(Human).init('Chris', 'Coyier') console.log(chris)
Чтобы объявить методы, просто опишите метод как обычный объект.
const Human = { init () { /*...*/ }, sayHello () { console.log(`Hello, I'm ${this.firstName}`) } }
Объявление свойств и методов с помощью фабричных функций
Вы можете объявлять свойства и методы напрямую, включая их в возвращаемый объект.
function Human (firstName, lastName) { return { firstName, lastName, sayHello () { console.log(`Hello, I'm ${firstName}`) } } }
Нельзя объявлять методы в прототипе при использовании фабричной функций. Если вам действительно нужны методы в прототипе, вам нужно вернуть экземпляр Constructor, Class или OLOO. (Но не делайте этого, потому что в этом нет никакого смысла.)
// Do not do this function createHuman (...args) { return new Human(...args) }
Где объявлять свойства и методы
Следует ли объявлять свойства и методы непосредственно в экземпляре? Или вам стоит использовать прототип как можно чаще?
Многие люди гордятся тем, что JavaScript — это «язык прототипов» (что означает, что он использует прототипы). Из этого утверждения вы можете сделать предположение, что использование «Прототипов» лучше.
Настоящий ответ таков: это не имеет значения.
Если вы объявляете свойства и методы для экземпляров, каждый экземпляр будет занимать немного больше памяти. Если вы объявляете методы в прототипах, объем памяти, используемый каждым экземпляром, уменьшится, но ненамного. Эта разница несущественна с вычислительной мощностью компьютера, какой она есть сегодня. Вместо этого лучше понять, насколько легко писать код — и можно ли вообще использовать прототипы.
Например, если вы используете классы или OLOO, вам лучше использовать прототипы, поскольку так легче писать код. Если вы используете фабричные функции, вы не можете использовать прототипы. Вы можете создавать свойства и методы только непосредственно в экземпляре.
Я написал отдельную статью о понимании прототипов JavaScript, если вам интересно узнать больше.
Предварительный вердикт
Резюмируем, то что я написал выше. Но учтите это только мое собственное мнение!
- Классы лучше, чем конструкторы, потому что в классах проще написать несколько методов.
- Использование OLOO довольно неудобно из-за Object.create. Некоторое время я пробовал использовать OLOO, но я всегда забываю написать Object.create.
- Проще всего использовать классы и фабричные функции. Проблема в том, что фабричные функции не поддерживают прототипы. Но, как я уже сказал, это не всегда имеет значение.
Остался один вопрос. Что нам выбрать классы или фабричные функции? Давай сравним их!
Классы против фабричных функций — Наследование
Чтобы продолжить обсуждение классов и фабричных функций, нам нужно понять еще три концепции, которые тесно связаны с объектно-ориентированным программированием.
- Наследование
- Инкапсуляция
this
Начнем с наследования.
Что такое наследование?
Наследование — это многозначное слово. На мой взгляд, многие в индустрии неправильно используют наследование. Слово «наследование» используется, когда вы получаете что-то откуда-то. Например:
- Если вы получаете наследство от родителей, это означает, что вы получаете от них деньги и имущество.
- Если вы наследуете гены от своих родителей, это означает, что вы получаете свои гены от них.
- Если вы унаследовали процесс от своего учителя, это означает, что вы получаете этот процесс от него.
Довольно просто.
В JavaScript наследование может означать то же самое: это то что вы получаете свойства и методы из родительского проекта.
Это означает, что все экземпляры фактически наследуют свои blueprint. Они наследуют свойства и методы двумя способами:
- путем создания свойства или метода непосредственно при создании экземпляра
- через цепочку прототипов
У наследования в JavaScript есть второе значение: вы создаете текущую структуру на основе родительской структуры. Этот процесс более точно называется Subclassing, но люди иногда также называют его наследованием.
Понимание Subclassing
Создание Subclassing — это создание производной структуры из общей начально структуры. Вы можете использовать любой вариант объектно-ориентированного программирования для создания Subclass.
Поговорим об этом с синтаксисом класса, потому что его легче понять.
Создание Subclassing с помощью класса
Для создания Subclass, нужно использовать ключевое слово extends.
class Child extends Parent { // ... Stuff goes here }
Например, предположим, что мы хотим создать класс Developer из класса Human.
// Human Class class Human { constructor (firstName, lastName) { this.firstName = firstName this.lastName = lastName } sayHello () { console.log(`Hello, I'm ${this.firstName}`) } }
Класс Developer унаследуется от Human следующим образом:
class Developer extends Human { constructor(firstName, lastName) { super(firstName, lastName) } // Add other methods }
Примечание: super вызывает класс Human (также называемый «родительским») классом. Точнее он запускает constructor
из Human. Если вам не нужен дополнительный код запуска, вы можете полностью пропустить constructor
.
class Developer extends Human { // Add other methods }
Допустим, Developer может писать код. Тогда мы можем добавить метод code прямо в Developer.
class Developer extends Human { code (thing) { console.log(`${this.firstName} coded ${thing}`) } }
Вот пример экземпляра Developer:
const chris = new Developer('Chris', 'Coyier') console.log(chris)
Subclassing с помощью фабричных функций
Есть четыре шага для создания Subclass с фабричными функциями:
- Создайте новую фабричную функцию
- Создайте экземпляр родительского класса
- Создать новую копию этого экземпляра
- Добавьте свойства и методы к этой новой копии
Процесс выглядит так:
function Subclass (...args) { const instance = ParentClass(...args) return Object.assign({}, instance, { // Properties and methods go here }) }
Мы можем использовать тот же пример — создание подкласса Developer — чтобы проиллюстрировать этот процесс. Вот фабричная функция Human:
function Human (firstName, lastName) { return { firstName, lastName, sayHello () { console.log(`Hello, I'm ${firstName}`) } } }
Далее создаем Developer следующим образом:
function Developer (firstName, lastName) { const human = Human(firstName, lastName) return Object.assign({}, human, { // Properties and methods go here }) }
Затем добавляем метод code:
function Developer (firstName, lastName) { const human = Human(firstName, lastName) return Object.assign({}, human, { code (thing) { console.log(`${this.firstName} coded ${thing}`) } }) }
Вот пример создания экземпляра Developer:
const chris = Developer('Chris', 'Coyier') console.log(chris)
Примечание. Вы не можете использовать Object.assign, если используете геттеры и сеттеры. Вам понадобится другой инструмент, например микс. Я объясняю это в этой статье.
Переопределение родительского метода
Иногда вам нужно переопределить родительский метод внутри подкласса. Это можно сделать так:
- Создать одноименный метод
- Вызвать родительский метод (необязательно)
- Изменить все, что нужно, в методе подкласса
С классами процесс выглядит так:
class Developer extends Human { sayHello () { // Calls the parent method super.sayHello() // Additional stuff to run console.log(`I'm a developer.`) } } const chris = new Developer('Chris', 'Coyier') chris.sayHello()
С фабричными функциями это выглядит так:
function Developer (firstName, lastName) { const human = Human(firstName, lastName) return Object.assign({}, human, { sayHello () { // Calls the parent method human.sayHello() // Additional stuff to run console.log(`I'm a developer.`) } }) } const chris = new Developer('Chris', 'Coyier') chris.sayHello()
Наследование против Композиции
Ни один разговор о наследовании никогда не заканчивается без упоминания композиции. Такие эксперты, как Эрик Эллиот, часто предлагают отдавать предпочтение композиции, а не наследованию.
«Предпочитайте композицию объектов наследованию классов», «Design Patterns: Elements of Reusable Object Oriented Software»
«В информатике составной тип данных — это любой тип данных, который может быть сконструирован в программе с использованием примитивных типов данных языка программирования и других составных типов. […] Построение составного типа известно как композиция ». ~ Википедия
Итак, давайте посмотрим на композицию глубже и разберемся, что это такое.
Понимание композиции
Композиция — это соединение двух вещей вместе. Речь идет о слиянии. Самый распространенный (и самый простой) способ объединения объектов — Object.assign.
const one = { one: 'one' } const two = { two: 'two' } const combined = Object.assign({}, one, two)
Использование композиции можно лучше объяснить на примере. Допустим, у нас уже есть два подкласса: Designer
и Developer. Designers могут проектировать, а Developer могут писать код. И Designers, и Developer наследуют от класса Human.
Пример:
class Human { constructor(firstName, lastName) { this.firstName = firstName this.lastName = lastName } sayHello () { console.log(`Hello, I'm ${this.firstName}`) } } class Designer extends Human { design (thing) { console.log(`${this.firstName} designed ${thing}`) } } class Developer extends Designer { code (thing) { console.log(`${this.firstName} coded ${thing}`) } }
Теперь предположим, что вы хотите создать третий подкласс. Этот подкласс представляет собой смесь Designer и Developer — они могут проектировать и кодировать. Назовем его DesignerDeveloper (или DeveloperDesigner, как вам нравится).
Как бы вы создали третий подкласс?
Мы не можем расширять классы Designer и Developer одновременно. Это невозможно, потому что мы не можем решить, какие свойства будут первыми. Это часто называют проблемой алмаза (The Diamond Problem).
Проблема с алмазом может быть легко решена, если мы сделаем что-то вроде Object.assign — где мы будем отдавать приоритет одному объекту над другим. Если мы воспользуемся подходом Object.assign, мы сможем расширить такие классы. Но это не поддерживается в JavaScript.
// Doesn't work class DesignerDeveloper extends Developer, Designer { // ... }
Поэтому нам нужно полагаться на композицию.
Композиция гласит: вместо того, чтобы пытаться создать DesignerDeveloper через подклассы, давайте создадим новый объект, который хранит общие функции. Затем мы можем использовать эти функции при необходимости.
На практике это может выглядеть так:
const skills = { code (thing) { /* ... */ }, design (thing) { /* ... */ }, sayHello () { /* ... */ } }
В этом случае мы можем полностью пропустить Human и создать три разных класса в зависимости от их навыков.
Вот код для DesignerDeveloper:
class DesignerDeveloper { constructor (firstName, lastName) { this.firstName = firstName this.lastName = lastName Object.assign(this, { code: skills.code, design: skills.design, sayHello: skills.sayHello }) } } const chris = new DesignerDeveloper('Chris', 'Coyier') console.log(chris)
Вы можете сделать то же самое с Developer и Designer.
class Designer { constructor (firstName, lastName) { this.firstName = firstName this.lastName = lastName Object.assign(this, { design: skills.design, sayHello: skills.sayHello }) } } class Developer { constructor (firstName, lastName) { this.firstName = firstName this.lastName = lastName Object.assign(this, { code: skills.code, sayHello: skills.sayHello }) } }
Вы заметили, что мы создаем методы прямо в экземпляре? Это всего лишь один из вариантов. Мы все еще можем добавлять методы в прототип, но я думаю, что код будет выглядит неуклюже. (Это как если бы мы заново писали функции-конструкторы.)
class DesignerDeveloper { constructor (firstName, lastName) { this.firstName = firstName this.lastName = lastName } } Object.assign(DesignerDeveloper.prototype, { code: skills.code, design: skills.design, sayHello: skills.sayHello })
Не стесняйтесь использовать любую структуру кода, которая вам нравится. В любом случае результаты примерно такие же.
Композиция с фабричными функциями
Композиция с фабричными функциями по сути добавляет общие методы в возвращаемый объект.
function DesignerDeveloper (firstName, lastName) { return { firstName, lastName, code: skills.code, design: skills.design, sayHello: skills.sayHello } }
Наследование и Композиция одновременно
Никто не говорит, что нельзя использовать наследование и композицию одновременно. Мы можем!
Если взять пример, который мы до сих пор разобрали, Designer, Developer и DesignerDeveloper Human по-прежнему останется Human. Но подклассы будут наследоваться от Human.
Пример, в котором мы используем и наследование, и композицию с синтаксисом класса.
class Human { constructor (firstName, lastName) { this.firstName = firstName this.lastName = lastName } sayHello () { console.log(`Hello, I'm ${this.firstName}`) } } class DesignerDeveloper extends Human {} Object.assign(DesignerDeveloper.prototype, { code: skills.code, design: skills.design })
То же самое и с фабричными функциями:
function Human (firstName, lastName) { return { firstName, lastName, sayHello () { console.log(`Hello, I'm ${this.firstName}`) } } } function DesignerDeveloper (firstName, lastName) { const human = Human(firstName, lastName) return Object.assign({}, human, { code: skills.code, design: skills.design } }
Subclassing в реальном мире
И последнее, о подклассах и композиции. Несмотря на то, что эксперты отметили, что композиция более гибкая (и, следовательно, более полезная), Subclassing все же имеет свои достоинства. Многие вещи, которые мы используем сегодня, построены на стратегии Subclassing.
Например: событие click
, которое мы знаем и любим, — это MouseEvent. MouseEvent — это подкласс UIEvent, который, в свою очередь, является подклассом Event.
Другой пример: элементы HTML — это подклассы Nodes. Вот почему они могут использовать все свойства и методы Nodes.
Предварительный вердикт
И классы, и фабричные функции могут использовать наследование и композицию. Хотя композиция кажется более чистой в фабричных функциях, это не большая победа над классами.
Далее мы рассмотрим классы и фабричные функции более подробно.
Классы и фабричные функции — инкапсуляция
Мы рассмотрели четыре различных варианта объектно-ориентированного программирования. Две из них — классы и фабричные функции — проще в использовании по сравнению с остальными.
Но остаются вопросы: что использовать? И почему?
Чтобы продолжить обсуждение классов и фабричных функций, нам нужно понять три концепции, которые тесно связаны с объектно-ориентированным программированием:
- Наследование
- Инкапсуляция
this
Мы только что говорили о наследовании. Теперь поговорим об инкапсуляции.
Инкапсуляция
Инкапсуляция — громкое слово, но имеет простое значение. Инкапсуляция — это процесс помещения одной вещи внутрь другой, чтобы то, что внутри, не просочилось наружу. Подумайте о хранении воды в бутылке. Бутылка предотвращает вытекание воды.
В JavaScript мы заинтересованы во включении переменных (которые могут включать функции), чтобы эти переменные не попадали во внешнюю область видимости. Это означает, что вам нужно понимать область действия scope, чтобы понять инкапсуляцию.
Простая инкапсуляция
Самая простая форма инкапсуляции — это область видимости блока.
{ // Variables declared here won't leak out }
Когда вы находитесь в блоке, вы можете получить доступ к переменным, объявленным вне блока.
const food = 'Hamburger' { console.log(food) }
Но когда вы находитесь вне блока, вы не можете получить доступ к переменным, объявленным внутри блока.
{ const food = 'Hamburger' } console.log(food)
Примечание. Переменные, объявленные с помощью var, не учитывают область действия блока. Вот почему я рекомендую вам использовать let или const для объявления переменных.
Инкапсуляция с функциями
Функции ведут себя как области видимости блока. Когда вы объявляете переменную внутри функции, они не могут быть доступны вне этой функции. Это работает для всех переменных, даже объявленных с помощью var.
function sayFood () { const food = 'Hamburger' } sayFood() console.log(food)
Точно так же, когда вы находитесь внутри функции, вы можете получить доступ к переменным, которые объявлены вне этой функции.
const food = 'Hamburger' function sayFood () { console.log(food) } sayFood()
Функции могут возвращать значение. Это возвращаемое значение можно использовать позже, вне функции.
function sayFood () { return 'Hamburger' } console.log(sayFood())
Замыкание
Замыкания — это продвинутая форма инкапсуляции. Это просто функции, завернутые в функции.
// Here's a closure function outsideFunction () { function insideFunction () { /* ...*/ } }
Переменные, объявленные в outsideFunction
, могут использоваться в insideFunction.
function outsideFunction () { const food = 'Hamburger' console.log('Called outside') return function insideFunction () { console.log('Called inside') console.log(food) } } // Calls `outsideFunction`, which returns `insideFunction` // Stores `insideFunction` as variable `fn` const fn = outsideFunction() // Calls `insideFunction` fn()
Инкапсуляция и объектно-ориентированное программирование
Когда вы создаете объекты, вы хотите сделать некоторые свойства общедоступными (чтобы люди могли их использовать). Но вы также хотите сохранить некоторые свойства закрытыми (чтобы другие не могли нарушить вашу реализацию).
Давайте рассмотрим это на примере, чтобы прояснить ситуацию. Допустим, у нас есть blueprint автомобиля. Когда мы производим новые автомобили, мы заправляем каждую машину по 50 литров топлива.
class Car { constructor () { this.fuel = 50 } }
Здесь мы создали свойство fuel. Пользователи могут использовать fuel, чтобы получить количество топлива, оставшееся в их автомобилях.
const car = new Car() console.log(car.fuel) // 50
Пользователи также могут использовать свойство fuel для установки любого количества топлива.
const car = new Car() car.fuel = 3000 console.log(car.fuel) // 3000
Добавим условие и скажем, что каждая машина имеет максимальную вместимость 100 литров. С этим условием мы не хотим, чтобы пользователи могли свободно устанавливать свойство fuel, потому что они могут сломать машину.
Есть два способа запретить пользователям устанавливать топливо:
- По соглашению
- Использовать настоящие приватные переменные
По соглашению
В JavaScript существует практика добавления символов подчеркивания к имени переменной. Это означает, что переменная является приватной и не должна использоваться.
class Car { constructor () { // Denotes that `_fuel` is private. Don't use it! this._fuel = 50 } getFuel () { return this._fuel } setFuel (value) { this._fuel = value // Caps fuel at 100 liters if (value > 100) this._fuel = 100 } }
Пользователи должны использовать методы getFuel и setFuel для получения и установки топлива.
const car = new Car() console.log(car.getFuel()) // 50 car.setFuel(3000) console.log(car.getFuel()) // 100
Но _fuel на самом деле не является приватной. Это по-прежнему общедоступная переменная. Вы все еще можете получить к ней доступ, и использовать ее, и вы все еще можете злоупотребить этим (даже если злоупотребление является случайным).
const car = new Car() console.log(car.getFuel()) // 50 car._fuel = 3000 console.log(car.getFuel()) // 3000
Нам нужно использовать настоящие приватные переменные, если мы хотим полностью запретить пользователям доступ к ним.
Настоящие приватные члены
Члены здесь относятся к переменным, функциям и методам. Это собирательный термин.
Приватные классы
Классы позволяют создавать закрытые члены, добавляя символ # к имени переменной.
class Car { constructor () { this.#fuel = 50 } }
К сожалению, вы не можете использовать # непосредственно внутри функции-конструктора.
Сначала вам нужно объявить частную переменную вне конструктора.
class Car { // Declares private variable #fuel constructor () { // Use private variable this.#fuel = 50 } }
В этом случае мы можем использовать сокращение и заранее объявить #fuel, поскольку мы устанавливаем для топлива значение 50.
class Car { #fuel = 50 }
Вы не можете получить доступ к #fuel за пределами автомобиля. Вы получите сообщение об ошибке.
const car = new Car() console.log(car.#fuel)
Вам нужны методы (например, getFuel или setFuel) для использования переменной #fuel.
class Car { #fuel = 50 getFuel () { return this.#fuel } setFuel (value) { this.#fuel = value if (value > 100) this.#fuel = 100 } } const car = new Car() console.log(car.getFuel()) // 50 car.setFuel(3000) console.log(car.getFuel()) // 100
Примечание: лучше использовать геттеры и сеттеры вместо getFuel и setFuel. Синтаксис легче читать.
class Car { #fuel = 50 get fuel () { return this.#fuel } set fuel (value) { this.#fuel = value if (value > 100) this.#fuel = 100 } } const car = new Car() console.log(car.fuel) // 50 car.fuel = 3000 console.log(car.fuel) // 100
Частные члены с фабричными функциями
Фабричные функции автоматически создают приватные члены. Вам просто нужно объявить переменную как обычно. Пользователи не смогут получить эту переменную где-либо еще. Это связано с тем, что переменные имеют функциональную область видимости и, следовательно, инкапсулируются по умолчанию.
function Car () { const fuel = 50 } const car = new Car() console.log(car.fuel) // undefined console.log(fuel) // Error: `fuel` is not defined
Мы можем создать функции получения и установки для использования этой частной переменной fuel
.
function Car () { const fuel = 50 return { get fuel () { return fuel }, set fuel (value) { fuel = value if (value > 100) fuel = 100 } } } const car = new Car() console.log(car.fuel) // 50 car.fuel = 3000 console.log(car.fuel) // 100
Просто и легко!
Вердикт для инкапсуляции
Инкапсуляция с фабричными функциями проще и понятнее. Они полагаются на области видимости, которые составляют большую часть языка JavaScript.
С другой стороны, инкапсуляция с классами требует добавления # к имени приватной переменной. Но это может сделать вещи неуклюжими.
Далее рассмотрим окончательную концепцию, для завершения сравнения классов и фабричных функций.
Классы и фабричные функции — переменная this
this
(ха!) — один из главных аргументов против использования классов для объектно-ориентированного программирования. Почему? Потому что значение this
меняется в зависимости от того, как оно используется. Это может сбивать с толку многих разработчиков (как новичков, так и опытных).
Но на самом деле концепция this
относительно проста. Есть только шесть контекстов, в которых вы можете использовать this
. Если вы освоите эти шесть контекстов, у вас не будет проблем с использованием this
.
Шесть контекстов:
- В глобальном контексте
- В конструкторе объекта
- В свойствах объекта
- В простых функциях
- В стрелочных функциях
- В event listener
Я подробно рассмотрел эти шесть контекстов тут. Прочтите эту статью, если вам нужна помощь в понимании this.
Примечание. Не бойтесь научиться пользоваться this. Это важная концепция, которую вам необходимо понять, если вы собираетесь освоить JavaScript.
Вернитесь к этой статье после того, как закрепите свои знания в этой области. У нас будет более глубокое обсуждение использования этого в классах и фабричных функциях.
Еще не вернулся? Хороший. Пошли!
Использование this
в классах
this
относится к экземпляру при использовании в классе. (Он использует контекст «В свойстве объекта».) Вот почему вы можете установить свойства и методы для экземпляра внутри функции constructor
.
class Human { constructor (firstName, lastName) { this.firstName = firstName this.lastName = lastName console.log(this) } } const chris = new Human('Chris', 'Coyier')
Используя this в функциях конструктора
Если вы используете this внутри функции и new для создания экземпляра, this будет относиться к экземпляру. Так создается функция-конструктор.
function Human (firstName, lastName) { this.firstName = firstName this.lastName = lastName console.log(this) } const chris = new Human('Chris', 'Coyier')
Я упомянул функции конструктора, потому что вы можете использовать их внутри фабричной функции. Но this
указывает на Window (или undefined, если вы используете модули ES6, или сборщик, такой как webpack).
// NOT a Constructor function because we did not create instances with the `new` keyword function Human (firstName, lastName) { this.firstName = firstName this.lastName = lastName console.log(this) } const chris = Human('Chris', 'Coyier')
По сути, когда вы создаете фабричную функцию, вы не должны использовать this, как если бы это была бы функция-конструктор. Это одна небольшая проблема, с this которой сталкиваются люди. Я хотел выделить проблему и прояснить ее.
Использование this в фабричной функции
Правильный способ использовать this
в фабричной функции — использовать его «в контексте свойства объекта».
function Human (firstName, lastName) { return { firstName, lastName, sayThis () { console.log(this) } } } const chris = Human('Chris', 'Coyier') chris.sayThis()
Несмотря на то, что вы можете использовать this
в фабричной функции, вам не нужно этого делать. Вы можете создать переменную, указывающую на экземпляр. Как только вы это сделаете, вы можете использовать эту переменную вместо this. Вот пример.
function Human (firstName, lastName) { const human = { firstName, lastName, sayHello() { console.log(`Hi, I'm ${human.firstName}`) } } return human } const chris = Human('Chris', 'Coyier') chris.sayHello()
human.firstName более понятнее, чем this.firstName, потому что human определенно указывает на экземпляр.
Если вы привыкли к JavaScript, вы также можете заметить, что вообще нет необходимости писать human.firstName! Просто firstName достаточно, потому что firstName находится в лексической области видимости. (Прочтите эту статью, если вам нужна помощь с лексическими областями видимости.)
function Human (firstName, lastName) { const human = { firstName, lastName, sayHello() { console.log(`Hi, I'm ${firstName}`) } } return human } const chris = Human('Chris', 'Coyier') chris.sayHello()
То, что мы рассмотрели до сих пор, это был простой пример. Непросто решить, действительно ли это необходимо, пока мы не создадим достаточно сложный пример. Так что давай сделаем это.
Подробный пример
Допустим, у нас есть blueprint Human
. Этот Human имеет свойства firstName и lastName и метод sayHello.
У нас есть blueprint Developer
, созданный на основе Human. Developer могут кодировать, поэтому у них будет метод code
. Developer также хотят объявить себя разработчиками, поэтому нам нужно перезаписать sayHello и добавить в консоль «Я разработчик».
Мы создадим этот пример с помощью классов и фабричных функций. (Мы сделаем пример с this
и пример без this
для фабричной функций).
Пример с классами
Во-первых, у нас есть blueprint Human
. This Human
имеет свойства firstName и lastName, а также метод sayHello.
class Human { constructor (firstName, lastName) { this.firstName = firstName this.lastname = lastName } sayHello () { console.log(`Hello, I'm ${this.firstName}`) } }
У нас есть blueprint Developer
, созданный на основе Human. Разработчики могут кодировать, поэтому у них будет метод code
.
class Developer extends Human { code (thing) { console.log(`${this.firstName} coded ${thing}`) } }
Разработчики также хотят объявить себя разработчиками. Нам нужно перезаписать sayHello и добавить в консоль «Я разработчик». Мы делаем это, вызывая метод SayHello для Human. Сделаем это с помощью super.
class Developer extends Human { code (thing) { console.log(`${this.firstName} coded ${thing}`) } sayHello () { super.sayHello() console.log(`I'm a developer`) } }
Пример с фабричными функциями (с this
)
Опять же, во-первых, у нас есть blueprint Human
. Этот Human
имеет свойства firstName и lastName, а также метод sayHello.
function Human () { return { firstName, lastName, sayHello () { console.log(`Hello, I'm ${this.firstName}`) } } }
Затем у нас есть Developer
, созданный на основе Human. Разработчики могут кодировать, поэтому у них будет метод code
.
function Developer (firstName, lastName) { const human = Human(firstName, lastName) return Object.assign({}, human, { code (thing) { console.log(`${this.firstName} coded ${thing}`) } }) }
Разработчики также хотят объявлять себя разработчиками. Нам нужно перезаписать sayHello и добавить в консоль «Я разработчик».
Мы делаем это, вызывая метод SayHello для Human. Мы можем сделать это на примере human.
function Developer (firstName, lastName) { const human = Human(firstName, lastName) return Object.assign({}, human, { code (thing) { console.log(`${this.firstName} coded ${thing}`) }, sayHello () { human.sayHello() console.log('I\'m a developer') } }) }
Пример с фабричными функциями (без this
)
Вот полный код с использованием функций Factory (с this):
function Human (firstName, lastName) { return { firstName, lastName, sayHello () { console.log(`Hello, I'm ${this.firstName}`) } } } function Developer (firstName, lastName) { const human = Human(firstName, lastName) return Object.assign({}, human, { code (thing) { console.log(`${this.firstName} coded ${thing}`) }, sayHello () { human.sayHello() console.log('I\'m a developer') } }) }
Вы заметили, что firstName доступно в лексической области как в Human, так и в Developer? Это означает, что мы можем опустить this и использовать firstName непосредственно в обоих схемах.
function Human (firstName, lastName) { return { // ... sayHello () { console.log(`Hello, I'm ${firstName}`) } } } function Developer (firstName, lastName) { // ... return Object.assign({}, human, { code (thing) { console.log(`${firstName} coded ${thing}`) }, sayHello () { /* ... */ } }) }
Это означает, что вы можете спокойно опустить this из своего кода при использовании фабричной функции.
Вердикт для this
Проще говоря, классы требуют this, а фабричные функции — нет. Я предпочитаю использовать фабричные функции, потому что:
- Контекст this может измениться (что может сбивать с толку)
- Код, написанный с использованием фабричных функций, короче и чище (поскольку мы можем использовать инкапсулированные переменные, не используя this.#Variable).
Далее идет последний раздел, в котором мы создаем простой компонент вместе с классами и фабричными функциями. Вы увидите, чем они отличаются и как использовать Event listeners.
Классы и фабричные функции — Event listeners
В большинстве статей по объектно-ориентированному программированию приведены примеры без Event listeners. Эти примеры проще для понимания, но они не отражают работу, которую мы выполняем как разработчики внешнего интерфейса. Для работы, которую мы выполняем, требуются Event listeners — по простой причине — потому что нам нужно создавать вещи, которые полагаются на ввод данных пользователем.
Поскольку listeners изменяют контекст this, они могут затруднить работу с классами. В то же время они делают фабричные функции более привлекательными.
Но на самом деле это не так.
Это изменение this
не имеет значения, если вы знаете, как использовать this
как в классах, так и в фабричных функциях. Существует множество статей посвящены этой теме, поэтому я подумал, что было бы неплохо завершить эту статью простым компонентом, использующим разновидности объектно-ориентированного программирования.
Создание счетчика
Далее мы построим простой счетчик. Мы используем все, что вы узнали из этой статьи, включая приватные переменные.
Скажем, счетчик содержит две вещи:
- Сам счетчик
- Кнопку для увеличения значения счетчика
Вот простейший HTML-код счетчика:
<div class="counter"> <p>Count: <span>0</span> <button>Increase Count</button> </div>
Создание счетчика с использованием классов
class Counter () { constructor (counter) { // Do stuff } } // Usage const counter = new Counter(document.querySelector('.counter'))
Нам нужно получить два элемента в классе Counter:
- <span> содержащий счетчик — нам нужно обновить этот элемент, когда счетчик увеличивается
- <button> — нам нужно добавить прослушиватель событий к этому классу элемента
Counter () { constructor (counter) { this.countElement = counter.querySelector('span') this.buttonElement = counter.querySelector('button') } }
Мы инициализируем переменную count и установим для нее то, что показывает countElement. Мы будем использовать частную переменную #count, так как счетчик не должен отображаться где-либо еще.
class Counter () { #count constructor (counter) { // ... this.#count = parseInt(countElement.textContent) } }
Когда пользователь нажимает <button>, мы увеличим #count. Назовем этот метод increaseCount.
class Counter () { #count constructor (counter) { /* ... */ } increaseCount () { this.#count = this.#count + 1 } }
Затем нам нужно обновить DOM с новым значением #count. Для этого создадим метод updateCount. Мы будем вызывать updateCount из increaseCount:
class Counter () { #count constructor (counter) { /* ... */ } increaseCount () { this.#count = this.#count + 1 this.updateCount() } updateCount () { this.countElement.textContent = this.#count } }
Теперь мы готовы добавить прослушиватель событий.
Добавление прослушивателя событий
Мы добавим прослушиватель событий в this.buttonElement. К сожалению, мы не можем сразу использовать extensionCount в качестве обратного вызова. Если попробуем, то получим сообщение об ошибке.
class Counter () { // ... constructor (counter) { // ... this.buttonElement.addEventListener('click', this.increaseCount) } // Methods }
Вы получаете сообщение об ошибке, потому что this
указывает на buttonElement. (Это контекст прослушивателя событий.) Вы увидите buttonElement, если выведите this
в консоль.
Нам нужно изменить значение this обратно на экземпляр для increaseCount
, чтобы все работало. Сделать это можно двумя способами:
- Используя
bind
- Используя стрелочную функцию
Большинство людей используют первый метод (но второй проще).
Добавление прослушивателя событий с bind
bind возвращает новую функцию. Это позволяет вам изменить this
на первый переданный аргумент. Обычно слушатели событий создаются с помощью вызова bind (this).
class Counter () { // ... constructor (counter) { // ... this.buttonElement.addEventListener('click', this.increaseCount.bind(this)) } // ... }
Это работает, но читать не очень приятно. Это также неудобно для новичков, потому что bind рассматривается как расширенная функция JavaScript.
Стрелочные функции
Второй способ — использовать стрелочные функции. Стрелочные функции работают, потому что они сохраняют значение this в лексическом контексте.
Большинство людей пишут методы внутри обратного вызова стрелочной функции, например:
class Counter () { // ... constructor (counter) { // ... this.buttonElement.addEventListener('click', _ => { this.increaseCount() }) } // Methods }
Это работает, но это долгий путь. На самом деле есть ярлык.
Вы можете создать increaseCount
с помощью стрелочных функций. Если вы сделаете это, значение this для increaseCount
будет сразу же привязано к значению экземпляра.
Итак, вот код, который вам нужен:
class Counter () { // ... constructor (counter) { // ... this.buttonElement.addEventListener('click', this.increaseCount) } increaseCount = () => { this.#count = this.#count + 1 this.updateCounter() } // ... }
Полная версия кода
Вот полная версия кода на основе классов (с использованием стрелочных функций).
https://codepen.io/anon/embed/VwabbEE?height=450&theme-id=1&slug-hash=VwabbEE&default-tab=result
Создание счетчика с фабричными функциями
Здесь мы сделаем то же самое.
function Counter (counter) { // ... } const counter = Counter(document.querySelector('.counter'))
Нам нужно получить два элемента из счетчика — <span> и <button>. Мы можем использовать здесь обычные переменные (без this), потому что они уже являются частными переменными.
function Counter (counter) { const countElement = counter.querySelector('span') const buttonElement = counter.querySelector('button') }
Мы инициализируем переменную счетчика значением, которое присутствует в HTML.
function Counter (counter) { const countElement = counter.querySelector('span') const buttonElement = counter.querySelector('button') let count = parseInt(countElement.textContext) }
Мы увеличим переменную count
с помощью метода increaseCount
. Вы можете выбрать здесь обычную функцию, но мне нравится создать метод, позволяющий поддерживать порядок и порядок.
function Counter (counter) { // ... const counter = { increaseCount () { count = count + 1 } } }
Наконец, мы обновим счетчик с помощью метода updateCount. Мы также вызовем updateCount из increaseCount.
function Counter (counter) { // ... const counter = { increaseCount () { count = count + 1 counter.updateCount() } updateCount () { increaseCount() } } }
Обратите внимание, что я использовал counter.updateCount вместо this.updateCount? Мне это больше нравится, потому что counter
более понятен по сравнению с this. Я также делаю это, потому что новички могут ошибиться с this
внутри фабричных функций (о которых я расскажу позже).
Добавление слушателей событий
Мы можем добавить слушателей событий в buttonElement. Когда мы это сделаем, мы можем сразу использовать counter.increaseCount в качестве обратного вызова.
Мы можем это сделать, потому что мы не использовали this, поэтому это не имеет значения, даже если слушатели событий изменят значение this.
function Counter (counterElement) { // Variables // Methods const counter = { /* ... */ } // Event Listeners buttonElement.addEventListener('click', counter.increaseCount) }
Уловка с this
Вы можете использовать this
в фабричных функциях. Но вам нужно использовать this
в контексте метода.
В следующем примере, если вы вызываете counter.increaseCount, JavaScript также вызовет counter.updateCount. Это работает, потому что this
указывает на переменную counter
.
function Counter (counterElement) { // Variables // Methods const counter = { increaseCount() { count = count + 1 this.updateCount() } } // Event Listeners buttonElement.addEventListener('click', counter.increaseCount) }
К сожалению, прослушиватель событий не будет работать, потому что значение this было изменено. Вам потребуется то же самое, что и с классами — с функциями привязки или стрелочными функциями, чтобы снова заставить обработчик событий работать.
И это подводит меня ко второй проблеме.
Вторая уловка с this
Если вы используете синтаксис фабричных функции, вы не можете создавать методы со стрелочными функциями. Это связано с тем, что методы создаются в контексте простой функции.
function Counter (counterElement) { // ... const counter = { // Do not do this. // Doesn't work because `this` is `Window` increaseCount: () => { count = count + 1 this.updateCount() } } // ... }
Поэтому я настоятельно рекомендую не использовать this, если вы используете фабричные функции. Так намного проще.
Код
https://codepen.io/anon/embed/WNwjjaQ?height=450&theme-id=1&slug-hash=WNwjjaQ&default-tab=result
Вердикт для слушателей событий
Слушатели событий изменяют значение this, поэтому мы должны быть очень осторожны при использовании this
. Если вы используете классы, я рекомендую создавать обратные вызовы прослушивателей событий со стрелочными функциями, чтобы вам не приходилось использовать bind.
Если вы используете фабричные функции, я рекомендую не использовать this, потому что это может вас запутать. Это все!
Заключение
Мы говорили о четырех разновидностях объектно-ориентированного программирования:
- Использование функций конструктора
- Использование классов
- Объекты, связанные с другими объектами (OLOO)
- Использование фабричных функций
Во-первых, мы пришли к выводу, что классы и фабричные функции проще использовать с точки зрения кода.
Во-вторых, мы сравнили, как использовать подклассы с классами и фабричными функциями. Здесь мы видим, что создание подклассов проще с классами, но композиция проще с фабричными функциями.
В-третьих, мы сравнили инкапсуляцию с классами и фабричными функциями. Здесь мы видим, что инкапсуляция с помощью фабричных функций естественна, как и JavaScript, в то время как инкапсуляция с классами требует, чтобы вы добавляли # перед переменными.
В-четвертых, мы сравнили использование this
в классах и фабричных функциях. Я считаю, что здесь выигрывают фабричные функции, потому что это может быть неоднозначным. Написание this
.#PrivateVariable также создает более длинный код по сравнению с использованием самой privateVariable.
Наконец, в этой статье мы построили простой счетчик с использованием классов и фабричных функций. Вы узнали, как добавлять прослушиватели событий в оба типа объектно-ориентированного программирования.
Я надеюсь, что это статья прольет свет на объектно-ориентированное программирование в JavaScript для вас. Если вам понравилась эта статья, возможно, вам понравится мой курс JavaScript, Learn JavaScript, где я объясню (почти) все, что вам нужно знать о JavaScript.
Если у вас есть какие-либо вопросы по JavaScript или фронтенд-разработке в целом, не стесняйтесь обращаться ко мне. Я посмотрю, чем могу помочь!