Для будущих студентов курса "JavaScript Developer. Basic" и всех желающих подготовили перевод полезной статьи.

Приглашаем также посмотреть открытый урок на тему
"Прототипное наследование в JavaScript".


В своем исследовании я обнаружил, что существует четыре подхода к объектно-ориентированному программированию на JavaScript:

  1. Использование конструктора

  2. Использование классов

  3. Использование Объектов, связывающих с другими объектами (OLOO)

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

Какие методы мне следует использовать? Какой из них "лучший"? Здесь я представлю свои выводы вместе с информацией, которая может помочь вам решить, какой из них подходит именно вам.

Чтобы принять такое решение, мы не просто посмотрим на различные вкусы, а сравним концептуальные аспекты между ними:

  1. Классы против фабричных функций - Наследование

  2. Классы против фабричных функций - Инкапсулирование

  3. Классы против фабричных функций - this 

  4. Занятия против фабричных функций - Слушатели событий

Начнем с основы 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 () { /* ... */ }
}

В объектно-ориентированном программировании существует два способа декларирования свойств и методов:

  1. Прямо на экземпляре

  2. В прототипе

Давайте научимся делать и то, и другое.

Декларирование свойств и методов с помощью конструкторов

Если вы хотите декларировать свойство непосредственно на экземпляре, то вы можете записать его внутри функции конструктора. Обязательно установите его в качестве свойства для 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 Прототипов, если вам интересно узнать больше.

Предварительный вердикт

Мы можем сделать несколько заметок из кода, который мы написали выше. Это мое собственное мнение!

  1. Классы лучше конструкторов, потому что на них легче писать несколько методов.

  2. OLOO странный из-за Object.create part. Я запускаю на время OLOO, но постоянно забываю Object.create. Для меня странно это не использовать.

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

У нас осталось два варианта. Стоит ли нам выбрать классы или фабричные функции? Давайте сравним их!


Классы против фабричных функций — Наследование

Чтобы продолжить обсуждение Классов и фабричных функций — нам необходимо понять еще три понятия, которые тесно связаны с Объектно-ориентированным программированием.

  1. Наследование

  2. Инкапсуляция

  3. this

Давайте начнем с Наследования.

Что такое наследование?

Наследование — это громкое слово. Многие люди в отрасли, на мой взгляд, неправильно используют слово "наследование". Слово "наследование" используется, когда вы получаете вещи откуда-то. Например:

  • Если вы получаете наследство от родителей, это означает, что вы получаете от них деньги и имущество.

  • Если вы наследуете гены от своих родителей, это означает, что вы получаете от них свои гены.

  • Если вы наследуете информацию от своего учителя, это означает, что вы получаете ее от него.

Довольно прямолинейно.

В JavaScript наследование может означать то же самое: где вы получаете свойства и методы от родительского blueprint.

Это означает, что все экземпляры на самом деле наследуют от своих blueprints. Они наследуют свойства и методы двумя способами:

  1. создавая свойство или метод непосредственно после создания экземпляра.

  2. через цепочку Прототипов

Для наследования в 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)

Подклассы с фабричными функциями

Создание подклассов с фабричными функциями состоит из четырех этапов:

  1. Создать новую фабричную функцию

  2. Создать экземпляр родительского blueprint 

  3. Создать новую копию этого экземпляра

  4. Добавить свойства и методы в эту новую копию

Процесс выглядит так:

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. Я объясняю это в этой статье.

Переопределение родительского метода

Иногда нужно переписать родительский метод внутри подкласса. Вы можешь сделать это:

  1. Создать метод с тем же именем

  2. Вызов родительского метода (необязательно).

  3. Изменение всего, что вам нужно в методе Подкласса.

Процесс выглядит так с Классами:

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".

Рекомендуем обратить внимание на смежные курсы:


ЗАБРАТЬ СКИДКУ