Для будущих студентов курса "JavaScript Developer. Basic" и всех желающих подготовили перевод полезной статьи.
Приглашаем также посмотреть открытый урок на тему "Прототипное наследование в JavaScript".
В своем исследовании я обнаружил, что существует четыре подхода к объектно-ориентированному программированию на JavaScript:
Какие методы мне следует использовать? Какой из них "лучший"? Здесь я представлю свои выводы вместе с информацией, которая может помочь вам решить, какой из них подходит именно вам.
Чтобы принять такое решение, мы не просто посмотрим на различные вкусы, а сравним концептуальные аспекты между ними:
Начнем с основы OOП в JavaScript.
Что такое объектно-ориентированного программирования (ОПП)?
Объектно-ориентированное программирование - это способ написания кода, позволяющий создавать различные объекты из общего объекта. Общий объект обычно называется blueprint, в то время как создаваемые объекты называются экземплярами.
Каждый экземпляр имеет свойства, которые не разделяются с другими экземплярами. Например, если у вас есть blueprint Human, вы можете создавать экземпляры Human с разными именами.
Второй аспект Объектно-ориентированного программирования - это структурирование кода, когда у Вас есть несколько уровней blueprints. Это обычно называется наследованием или подклассом.
Третий аспект объектно-ориентированного программирования связан с инкапсуляцией, когда вы скрываете определенные кусочки информации внутри объекта, чтобы они были недоступны.
Начнем с основ - введения в четырех разновидностей объектно-ориентированного программирования.
Четыре разновидности объектно-ориентированного программирования
Существует четыре способа написания объектно-ориентированного программирования на JavaScript. Ими являются:
Часть 1 : Использование конструктора
Часть 2 : Использование классов
Часть 3: Использование Объектов, связывающих с другими объектами (OLOO)
Часть 4: Использование фабричных функций
Использование конструктора
Конструкторы — это функции, которые содержат ключевое слово 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 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)
OLOO был придуман и популяризирован Кайлом Симпсоном. В OLOO вы определяете blueprint
как обычный объект. Затем вы используете метод (часто называемый init
, но это не является таким же обязательным как в случае с использованием конструктора для класса) для инициации экземпляра.
const Human = {
init (firstName, lastName ) {
this.firstName = firstName
this.lastName = lastName
}
}
Вы используете Object.create
для создания экземпляра. После создания экземпляра необходимо запустить функцию init
.
const chris = Object.create(Human)
chris.init('Chris', 'Coyier')
console.log(chris.firstName) // Chris
console.log(chris.lastName) // Coyier
Вы можете ставить один за другим init
за Object.create
если вы возвращаете this
в init
.
const Human = {
init () {
// ...
return this
}
}
const chris = Object.create(Human).init('Chris', 'Coyier')
console.log(chris.firstName) // Chris
console.log(chris.lastName) // Coyier
Фабричные функции
Фабричные функции — это функции, которые возвращают объект. Вы можете вернуть любой объект. Вы даже можете вернуть экземпляр Class или 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
.
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. Вам нужен другой инструмент. Вот почему. Здесь инструмент, что я создал для объединения объектов с Getters и Setters.
Декларирование свойств и методов с помощью классов
Свойства можно декларировать для каждого экземпляра внутри функции 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}`)
}
}
}
Нельзя декларировать методы на Прототипе при использовании фабричных функций. Если вам действительно нужны методы на прототипе, вам нужно вернуть экземпляр Конструктор, Класс или OLOO. (Не делайте этого, так как это не имеет смысла.)
// Do not do this
function createHuman (...args) {
return new Human(...args)
}
Где декларировать свойства и методы
Следует ли декларировать свойства и методы непосредственно на экземпляре? Или вы должны использовать prototype
как можно чаще?
Многие люди гордятся тем, что JavaScript является "языком прототипов" (что означает, что он использует прототипы). Из этого утверждения можно сделать предположение, что использование "прототипов" лучше.
Реальный ответ: это не имеет значения.
Если вы декларируете свойства и методы на экземплярах, то каждый экземпляр будет занимать чуть больше памяти. При декларировании методов на прототипах память, используемая каждым экземпляром, будет уменьшаться, но не сильно. Эта разница несущественна с вычислительной мощностью компьютера, какой он есть сегодня. Вместо этого вы хотите посмотреть, насколько легко писать код — и возможно ли использовать прототипы в первую очередь.
Например, если вы используете Классы или OLOO, то лучше использовать Прототипы, так как код легче писать. Если вы используете фабричные функции, то вы не можете использовать Прототипы. Вы можете создавать свойства и методы только непосредственно на экземпляре.
Я написал отдельную статью о понимании JavaScript Прототипов, если вам интересно узнать больше.
Предварительный вердикт
Мы можем сделать несколько заметок из кода, который мы написали выше. Это мое собственное мнение!
Классы лучше конструкторов, потому что на них легче писать несколько методов.
OLOO странный из-за
Object.create part
. Я запускаю на время OLOO, но постоянно забываюObject.create
. Для меня странно это не использовать.Классы и фабричные функции проще всего использовать. Проблема в том, что фабричные функции не поддерживают Прототипы. Но, как я уже сказал, в производстве это не имеет значения.
У нас осталось два варианта. Стоит ли нам выбрать классы или фабричные функции? Давайте сравним их!
Классы против фабричных функций — Наследование
Чтобы продолжить обсуждение Классов и фабричных функций — нам необходимо понять еще три понятия, которые тесно связаны с Объектно-ориентированным программированием.
Наследование
Инкапсуляция
this
Давайте начнем с Наследования.
Что такое наследование?
Наследование — это громкое слово. Многие люди в отрасли, на мой взгляд, неправильно используют слово "наследование". Слово "наследование" используется, когда вы получаете вещи откуда-то. Например:
Если вы получаете наследство от родителей, это означает, что вы получаете от них деньги и имущество.
Если вы наследуете гены от своих родителей, это означает, что вы получаете от них свои гены.
Если вы наследуете информацию от своего учителя, это означает, что вы получаете ее от него.
Довольно прямолинейно.
В JavaScript наследование может означать то же самое: где вы получаете свойства и методы от родительского blueprint
.
Это означает, что все экземпляры на самом деле наследуют от своих blueprints
. Они наследуют свойства и методы двумя способами:
создавая свойство или метод непосредственно после создания экземпляра.
через цепочку Прототипов
Для наследования в JavaScript есть второе значение — где вы создаете производный blueprint
из родительского blueprint
. Этот процесс более точно называется Подклассификация, но иногда люди называют это Наследование (Inheritance).
Понимание подклассов
Подкласс - это создание производного blueprint из общего blueprint. Вы можете использовать любой объектно-ориентированное программирование для создания подкласса.
Сначала мы поговорим об этом с синтаксисом Класса, потому что это легче понять.
Подклассификация с классами
Когда вы создаете Подкласс, вы используете ключевое слово 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)
Подклассы с фабричными функциями
Создание подклассов с фабричными функциями состоит из четырех этапов:
Создать новую фабричную функцию
Создать экземпляр родительского
blueprint
Создать новую копию этого экземпляра
Добавить свойства и методы в эту новую копию
Процесс выглядит так:
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
, если вы используете Getters
и Setters
. Вам нужен будет другой инструмент, такой как mix. Я объясняю это в этой статье.
Переопределение родительского метода
Иногда нужно переписать родительский метод внутри подкласса. Вы можешь сделать это:
Создать метод с тем же именем
Вызов родительского метода (необязательно).
Изменение всего, что вам нужно в методе Подкласса.
Процесс выглядит так с Классами:
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()
Наследование против Композиции
Никакие разговоры о наследовании никогда не заканчиваются без упоминания Композиции. Такие эксперты, как Эрик Эллиот, часто советуют отдавать предпочтение Композиции, а не Наследованию.
"Отдать предпочтение объектной композиции перед наследованием классов" Банда четырех, "Шаблоны дизайна": Элементы многоразового объектно-ориентированного программного обеспечения".
"В информатике композитным типом данных или составным типом данных является любой тип данных, который может быть построен в программе с использованием примитивных типов данных языка программирования и других составных типов. […] Акт построения составного типа известен как составной". ~ Википедия
Так что давайте посмотрим на Композицию глубже и поймем, что это такое.
Понимание Композиции
Композиция — это акт объединения двух вещей в одну. Речь идет об объединении вещей. Самый распространенный (и самый простой) способ объединения объектов — это Object.assign
.
const one = { one: 'one' }
const two = { two: 'two' }
const combined = Object.assign({}, one, two)
Использование Композиции можно лучше объяснить на примере. Допустим, у нас уже есть два подкласса — Designer
и 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
одновременно. Это невозможно, потому что мы не можем решить, какие свойства стоят на первом месте. Это часто называется "Проблема ромба".
Проблема ромба может быть быстро решена, если вы сделаете что-то типа 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)
Вы можете сделать тоже самое с Designer
и Developer
.
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
Humans
все еще являются Humans
. Они могут расширять объект 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
}
}
Подклассификация в реальном мире
Последний пункт о Подклассификации против Композиции. Несмотря на то, что эксперты указывали на то, что Композиция является более гибкой (и, следовательно, более полезной), подклассификация все же имеет свои преимущества. Многие вещи, которые мы используем сегодня, построены с помощью стратегии Подклассов.
Например: событие click
, что мы знаем и любим — это MouseEvent
. MouseEvent
— это подкласс UIEvent
, который превращается в подкласс Event
.
Еще один пример: HTML элементы — это подклассы Nodes. Поэтому они могут использовать все свойства и методы Nodes.
Предварительный вердикт
Классы и фабричные функции могут использовать как наследование, так и композицию. Состав кажется более чистым в фабричных функциях, но это не большой выигрыш по сравнению с классами.
Более подробно мы рассмотрим Классы и фабричные функции далее. Читать продолжение.
Узнать подробнее о курсе "JavaScript Developer. Basic".
Посмотреть открытый урок на тему "Прототипное наследование в JavaScript".
bromzh
Да уж, главное выпустить хоть что-то для рекламы своих курсов, а на качество перевода можно положить болт.
И так сойдёт.jpg