Доброго времени суток, друзья!
Сегодня я хочу поговорить с вами о трех предложениях, относящихся к JavaScript-классам, которые находятся на 3 стадии рассмотрения:
- определение полей классов
- приватные методы и геттеры/сеттеры классов
- статические возможности классов: статические публичные поля, статические приватные поля и статические приватные методы
Учитывая, что указанные предложения в полной мере соответствуют логике дальнейшего развития классов и используют существующий синтаксис, можно быть уверенным, что они будут стандартизированы без каких-либо серьезных изменений. Об этом также свидетельствует реализация названных «фич» в современных браузерах.
Вспомним, что такое классы в JavaScript.
По большей части, классы представляют собой так называемый «синтаксический сахар» (абстракцию или, проще говоря, обертку) для функций-конструкторов. Такие функции используется для реализации паттерна проектирования «Конструктор». Данный паттерн, в свою очередь, реализуется (в JavaScript) с помощью модели прототипного наследования (prototypal inheritance). Модель прототипного наследования иногда определяют в качестве самостоятельного паттерна «Прототип». Подробнее о паттернах проектирования можно почитать здесь.
Что такое прототип? Это объект, который выступает в роли проекта или схемы (blueprint) для других объектов — экземпляров (instances). Конструктор — это функция, позволяющая создавать объекты-экземпляры на основе прототипа (класса, суперкласса, абстрактного класса etc.). Процесс передачи свойств и функций от прототипа к экземпляру называется наследованием. Свойства и функции в терминологии классов, обычно, именуются полями и методами, но, де-факто, это одно и тоже.
Как выглядит функция-конструктор?
// обратите внимание на включение строгого режима
'use strict'
function Counter(initialValue = 0) {
this.count = initialValue
// смотрим на то, что такое this
console.log(this)
}
Мы определяем функцию «Counter», принимающую параметр «initialValue» со значением по умолчанию, равным 0. Этот параметр присваивается свойству экземпляра «count» при инициализации экземпляра. Контекстом «this» в данном случае является создаваемый (возвращаемый) функцией объект. Для того, чтобы указать JavaScript на вызов не просто функции, но функции-конструктора, необходимо использовать ключевое слово «new»:
const counter = new Counter() // { count: 0, __proto__: Object }
Как мы видим, функция-конструктор возвращает объект с определенным нами свойством «count» и прототипом (__proto__) в виде глобального объекта «Object», к которому восходят цепочки прототипов почти всех типов (данных) в JavaScript (за исключением объектов без прототипа, создаваемых с помощью Object.create(null)). Поэтому говорят, что в JavaScript «все является объектом».
Если вызвать функцию-конструктор без «new», то будет выброшено исключение «TypeError» (ошибка типа), говорящее о том, что «свойство 'count' не может быть присвоено undefined»:
const counter = Counter() // TypeError: Cannot set property 'count' of undefined
// в нестрогом режиме
const counter = Counter() // Window
Это объясняется тем, что значением «this» внутри функции в строгом режиме является «undefined», а в нестрогом — глобальный объект «Window».
Добавим в функцию-конструктор распределенные (совместно используемые, общие для всех экземпляров) методы по увеличению, уменьшению, сбросу и получению значения счетчика:
Counter.prototype.increment = function () {
this.count += 1
// возвращаем this, чтобы иметь возможность выстраивания цепочки из вызовов методов
return this
}
Counter.prototype.decrement = function () {
this.count -= 1
return this
}
Counter.prototype.reset = function () {
this.count = 0
return this
}
Counter.prototype.getInfo = function () {
console.log(this.count)
return this
}
Если определить методы в самой функции-конструкторе, а не в ее прототипе, то для каждого экземпляра будут создаваться собственные методы, что может затруднить последующее изменение функционала экземпляров. Раньше это также могло привести к проблемам с производительностью.
Добавление нескольких методов в прототип функции-конструктора можно оптимизировать следующим образом:
;(function () {
this.increment = function () {
this.count += 1
return this
}
this.decrement = function () {
this.count -= 1
return this
}
this.reset = function () {
this.count = 0
return this
}
this.getInfo = function () {
console.log(this.count)
return this
}
// привязываем методы к прототипу функции-конструктора
// https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Function/call
}.call(Counter.prototype))
Или можно сделать еще проще:
// это современный синтаксис, раньше такой возможности не было
Object.assign(Counter.prototype, {
increment() {
this.count += 1
return this
},
decrement() {
this.count -= 1
return this
},
reset() {
this.count = 0
return this
},
getInfo() {
console.log(this.count)
return this
}
})
Воспользуемся нашими методами:
counter
.increment()
.increment()
.getInfo() // 2
.decrement()
.getInfo() // 1
.reset()
.getInfo() // 0
Синтаксис класса является более лаконичным:
class _Counter {
constructor(initialValue = 0) {
this.count = initialValue
}
increment() {
this.count += 1
return this
}
decrement() {
this.count -= 1
return this
}
reset() {
this.count = 0
return this
}
getInfo() {
console.log(this.count)
return this
}
}
const _counter = new _Counter()
_counter
.increment()
.increment()
.getInfo() // 2
.decrement()
.getInfo() // 1
.reset()
.getInfo() // 0
Для демонстрации работы механизма наследования в JavaScript рассмотрим более сложный пример. Создадим класс «Person» и его подкласс «SubPerson».
В классе «Person» определяются свойства «firstName» (имя), «lastName» (фамилия) и «age» (возраст), а также методы «getFullName» (получение имени и фамилии), «getAge» (получение возраста) и «saySomething» (произнесение фразы).
Подкласс «SubPerson» наследует все свойства и методы Person, а также определяет новые поля «lifestyle» (образ жизни), «skill» (навык) и «interest» (интерес, хобби), а также новые методы «getInfo» (получение полного имени посредством вызова родительского-унаследованного метода «getFullName» и образа жизни), «getSkill» (получение навыка), «getLike» (получение хобби) и «setLike» (определение-установка хобби).
Функция-конструктор:
const log = console.log
function Person({ firstName, lastName, age }) {
this.firstName = firstName
this.lastName = lastName
this.age = age
}
;(function () {
this.getFullName = function () {
log(`Этого человека зовут ${this.firstName} ${this.lastName}`)
return this
}
this.getAge = function () {
log(`Этому человеку ${this.age} лет`)
return this
}
this.saySomething = function (phrase) {
log(`Этот человек говорит: "${phrase}"`)
return this
}
}.call(Person.prototype))
const person = new Person({
firstName: 'Иван',
lastName: 'Петров',
age: 30
})
person.getFullName().getAge().saySomething('Привет!')
/*
Этого человека зовут Иван Петров
Этому человеку 30 лет
Этот человек говорит: "Привет!"
*/
function SubPerson({ lifestyle, skill, ...rest }) {
// привязываем конструктор Person к экземпляру SubPerson применительно к наследуемым свойствам
Person.call(this, rest)
this.lifestyle = lifestyle
this.skill = skill
this.interest = null
}
// делаем прототип Person прототипом SubPerson
SubPerson.prototype = Object.create(Person.prototype)
// и добавляем в него новые функции
Object.assign(SubPerson.prototype, {
getInfo() {
this.getFullName()
log(`Он ${this.lifestyle}`)
return this
},
getSkill() {
log(`Этот ${this.lifestyle} умеет ${this.skill}`)
return this
},
getLike() {
log(
`Этот ${this.lifestyle} ${
this.interest ? `любит ${this.interest}` : 'ничего не любит'
}`
)
return this
},
setLike(value) {
this.interest = value
return this
}
})
const developer = new SubPerson({
firstName: 'Петр',
lastName: 'Иванов',
age: 25,
lifestyle: 'разработчик',
skill: 'писать код на JavaScript'
})
developer
.getInfo()
.getAge()
.saySomething('Программирование - это круто!')
.getSkill()
.getLike()
/*
Этого человека зовут Петр Иванов
Он разработчик
Этому человеку 25 лет
Этот человек говорит: "Программирование - это круто!"
Этот разработчик умеет писать код на JavaScript
Этот разработчик ничего не любит
*/
developer.setLike('делать оригами').getLike()
// Этот разработчик любит делать оригами
Класс:
const log = console.log
class _Person {
constructor({ firstName, lastName, age }) {
this.firstName = firstName
this.lastName = lastName
this.age = age
}
getFullName() {
log(`Этого человека зовут ${this.firstName} ${this.lastName}`)
return this
}
getAge() {
log(`Этому человеку ${this.age} лет`)
return this
}
saySomething(phrase) {
log(`Этот человек говорит: "${phrase}"`)
return this
}
}
const _person = new Person({
firstName: 'Иван',
lastName: 'Петров',
age: 30
})
_person.getFullName().getAge().saySomething('Привет!')
/*
Этого человека зовут Иван Петров
Этому человеку 30 лет
Этот человек говорит: "Привет!"
*/
class _SubPerson extends _Person {
constructor({ lifestyle, skill /*, ...rest*/ }) {
// вызов super() почти аналогичен вызову Person.call(this, rest)
// super(rest)
super()
this.lifestyle = lifestyle
this.skill = skill
this.interest = null
}
getInfo() {
// super.getFullName()
this.getFullName()
log(`Он ${this.lifestyle}`)
return this
}
getSkill() {
log(`Этот ${this.lifestyle} умеет ${this.skill}`)
return this
}
get like() {
log(
`Этот ${this.lifestyle} ${
this.interest ? `любит ${this.interest}` : 'ничего не любит'
}`
)
}
set like(value) {
this.interest = value
}
}
const _developer = new SubPerson({
firstName: 'Петр',
lastName: 'Иванов',
age: 25,
lifestyle: 'разработчик',
skill: 'писать код на JavaScript'
})
_developer
.getInfo()
.getAge()
.saySomething('Программирование - это круто!')
.getSkill().like
/*
Этого человека зовут Петр Иванов
Он разработчик
Этому человеку 25 лет
Этот человек говорит: "Программирование - это круто!"
Этот разработчик умеет писать код на JavaScript
Этот разработчик ничего не любит
*/
developer.like = 'делать оригами'
developer.like
// Этот разработчик любит делать оригами
Думаю, тут все понятно. Двигаемся дальше.
Основной проблемой наследования в JavaScript было и остается отсутствие встроенной возможности множественного наследования, т.е. возможности подкласса наследовать свойства и методы нескольких классов одновременно. Разумеется, поскольку в JavaScript возможно все, мы можем создать имитацию множественного наследования, например, с помощью такого миксина:
// https://www.typescriptlang.org/docs/handbook/mixins.html
function applyMixins(derivedCtor, constructors) {
constructors.forEach((baseCtor) => {
Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => {
Object.defineProperty(
derivedCtor.prototype,
name,
Object.getOwnPropertyDescriptor(baseCtor.prototype, name) ||
Object.create(null)
)
})
})
}
class A {
sayHi() {
console.log(`${this.name} говорит: "Привет!"`)
}
sameName() {
console.log('Метод класса А')
}
}
class B {
sayBye() {
console.log(`${this.name} говорит: "Пока!"`)
}
sameName() {
console.log('Метод класса B')
}
}
class C {
name = 'Иван'
}
applyMixins(C, [A, B])
const c = new C()
// вызываем метод, унаследованный от класса A
c.sayHi() // Иван говорит: "Привет!"
// вызываем метод, унаследованный от класса B
c.sayBye() // Иван говорит: "Пока!"
// одноименный последующий метод перезаписывает предыдущий
c.sameName() // Метод класса B
Однако, это не является полноценным решением и представляет собой всего лишь «хак» для того, чтобы втиснуть JavaScript в рамки объектно-ориентированного программирования.
Перейдем непосредственно к новшествам, предлагаемым обозначенными в начале статьи предложениями.
На сегодняшний день, учитывая стандартизированные возможности, синтаксис класса выглядит следующим образом:
const log = console.log
class C {
constructor() {
this.publicInstanceField = 'Публичное поле экземпляра'
this.#privateInstanceField = 'Приватное поле экземпляра'
}
publicInstanceMethod() {
log('Публичный метод экземпляра')
}
// получаем значение приватного поля экземпляра
getPrivateInstanceField() {
log(this.#privateInstanceField)
}
static publicClassMethod() {
log('Публичный метод класса')
}
}
const c = new C()
console.log(c.publicInstanceField) // Публичное поле экземпляра
// при попытке прямого доступа к приватной переменной выбрасывается исключение
// console.log(c.#privateInstanceField) // SyntaxError: Private field '#privateInstanceField' must be declared in an enclosing class
c.getPrivateInstanceField() // Приватное поле экземпляра
c.publicInstanceMethod() // Публичный метод экземляра
C.publicClassMethod() // Публичный метод класса
Получается, что мы имеем возможность определять публичные и приватные поля и публичные методы экземпляров, а также публичные методы класса, но не можем определять приватные методы экземпляров, а также публичные и приватные поля класса. Ну, на самом деле возможность определить публичное поле класса все же имеется:
C.publicClassField = 'Публичное поле класса'
console.log(C.publicClassField) // Публичное поле класса
Но, согласитесь, что выглядит это не очень хорошо. Складывается впечатление, что мы вернулись к работе с прототипами.
Первое предложение позволяет определять публичные и приватные поля экземпляра без использования конструктора:
publicInstanceField = 'Публичное поле экземпляра'
#privateInstanceField = 'Приватное поле экземпляра'
Второе предложение позволяет определять приватные методы экземпляра:
#privateInstanceMethod() {
log('Приватный метод экземпляра')
}
// вызываем приватный метод экземпляра
getPrivateInstanceMethod() {
this.#privateInstanceMethod()
}
И, наконец, третье предложение позволяет определять публичные и приватные (статические) поля, а также приватные (статические) методы класса:
static publicClassField = 'Публичное поле класса'
static #privateClassField = 'Приватное поле класса'
static #privateClassMethod() {
log('Приватный метод класса')
}
// получаем значение приватного поле класса
static getPrivateClassField() {
log(C.#privateClassField)
}
// вызываем приватный метод класса
static getPrivateClassMethod() {
C.#privateClassMethod()
}
Вот как будет выглядеть (в действительности, уже выглядит) полный комплект:
const log = console.log
class C {
// class field declarations
// https://github.com/tc39/proposal-class-fields
publicInstanceField = 'Публичное поле экземпляра'
#privateInstanceField = 'Приватное поле экземпляра'
publicInstanceMethod() {
log('Публичный метод экземляра')
}
// private methods and getter/setters
// https://github.com/tc39/proposal-private-methods
#privateInstanceMethod() {
log('Приватный метод экземпляра')
}
// получаем значение приватного поля экземпляра
getPrivateInstanceField() {
log(this.#privateInstanceField)
}
// вызываем приватный метод экземпляра
getPrivateInstanceMethod() {
this.#privateInstanceMethod()
}
// static class features
// https://github.com/tc39/proposal-static-class-features
static publicClassField = 'Публичное поле класса'
static #privateClassField = 'Приватное поле класса'
static publicClassMethod() {
log('Публичный метод класса')
}
static #privateClassMethod() {
log('Приватный метод класса')
}
// получаем значение приватного поля класса
static getPrivateClassField() {
log(C.#privateClassField)
}
// вызываем приватный метод класса
static getPrivateClassMethod() {
C.#privateClassMethod()
}
// пытаемся получить публичное и приватное поля класса из экземпляра
getPublicAndPrivateClassFieldsFromInstance() {
log(C.publicClassField)
log(C.#privateClassField)
}
// пытаемся получить публичное и приватное поля экземпляра из класса
static getPublicAndPrivateInstanceFieldsFromClass() {
log(this.publicInstanceField)
log(this.#privateInstanceField)
}
}
const c = new C()
console.log(c.publicInstanceField) // Публичное поле экземпляра
// при попытке прямого доступа к значению приватного поля экземпляра выбрасывается исключение
// console.log(c.#privateInstanceField) // SyntaxError: Private field '#privateInstanceField' must be declared in an enclosing class
c.getPrivateInstanceField() // Приватное поле экземпляра
c.publicInstanceMethod() // Публичный метод экземляра
// попытка прямого доступа к приватному методу экземпляра также заканчивается ошибкой
// c.#privateInstanceMethod() // Error
c.getPrivateInstanceMethod() // Приватный метод экземпляра
console.log(C.publicClassField) // Публичное поле класса
// console.log(C.#privateClassField) // Error
C.getPrivateClassField() // Приватное поле класса
C.publicClassMethod() // Публичный метод класса
// C.#privateClassMethod() // Error
C.getPrivateClassMethod() // Приватный метод класса
c.getPublicAndPrivateClassFieldsFromInstance()
// Публичное поле класса
// Приватное поле класса
// публичное и приватное поля экземпляра недоступны из класса,
// поскольку на момент доступа к ним экземпляра не существует
// C.getPublicAndPrivateInstanceFieldsFromClass()
// undefined
// TypeError: Cannot read private member #privateInstanceField from an object whose class did not declare it
Все бы ничего, вот только существует один интересный нюанс: приватные поля не наследуются. В TypeScript и других языках программирования существует особое свойство, обычно именуемое «protected» (защищенное), к которому нельзя получить доступ напрямую, но которое, вместе с тем, может наследоваться наряду с публичными свойствами.
Стоит отметить, что слова «private», «public» и «protected» в JavaScript являются зарезервированными. При попытке их использования в строгом режиме выбрасывается исключение:
const private = '' // SyntaxError: Unexpected strict mode reserved word
const public = '' // Error
const protected = '' // Error
Поэтому надежда на реализацию защищенных полей класса в отдаленной перспективе остается.
Обращаю ваше внимание, что техника инкапсуляции переменных, т.е. их защиты от доступа извне, стара, как сам JavaScript. До стандартизации приватных полей класса для скрытия переменных, обычно, использовались замыкания, а также паттерны проектирования «Фабрика» и «Модуль». Рассмотрим эти паттерны на примере корзины для товаров.
Модуль:
const products = [
{
id: '1',
title: 'Хлеб',
price: 50
},
{
id: '2',
title: 'Масло',
price: 150
},
{
id: '3',
title: 'Молоко',
price: 100
}
]
const cartModule = (() => {
let cart = []
function getProductCount() {
return cart.length
}
function getTotalPrice() {
return cart.reduce((total, { price }) => (total += price), 0)
}
return {
addProducts(products) {
products.forEach((product) => {
cart.push(product)
})
},
removeProduct(obj) {
for (const key in obj) {
cart = cart.filter((prod) => prod[key] !== obj[key])
}
},
getInfo() {
console.log(
`В корзине ${getProductCount()} товар(а) на ${
getProductCount() > 1 ? 'общую ' : ''
}сумму ${getTotalPrice()} рублей`
)
}
}
})()
// модуль представляет собой обычный объект с методами
console.log(cartModule) // { addProducts: ?, removeProduct: ?, getInfo: ? }
// добавляем товары в корзину
cartModule.addProducts(products)
cartModule.getInfo()
// В корзине 3 товар(а) на общую сумму 300 рублей
// удаляем товар с идентификатором 2
cartModule.removeProduct({ id: '2' })
cartModule.getInfo()
// В корзине 2 товар(а) на общую сумму 150 рублей
// пытаемся получить доступ к инкапсулированому полю и методу
console.log(cartModule.cart) // undefined
// cartModule.getProductCount() // TypeError: cartModule.getProductCount is not a function
Фабрика:
function cartFactory() {
let cart = []
function getProductCount() {
return cart.length
}
function getTotalPrice() {
return cart.reduce((total, { price }) => (total += price), 0)
}
return {
addProducts(products) {
products.forEach((product) => {
cart.push(product)
})
},
removeProduct(obj) {
for (const key in obj) {
cart = cart.filter((prod) => prod[key] !== obj[key])
}
},
getInfo() {
console.log(
`В корзине ${getProductCount()} товар(а) на ${
getProductCount() > 1 ? 'общую ' : ''
}сумму ${getTotalPrice()} рублей`
)
}
}
}
const cart = cartFactory()
cart.addProducts(products)
cart.getInfo()
// В корзине 3 товар(а) на общую сумму 300 рублей
cart.removeProduct({ title: 'Молоко' })
cart.getInfo()
// В корзине 2 товар(а) на сумму 200 рублей
console.log(cart.cart) // undefined
// cart.getProductCount() // TypeError: cart.getProductCount is not a function
Класс:
class Cart {
#cart = []
#getProductCount() {
return this.#cart.length
}
#getTotalPrice() {
return this.#cart.reduce((total, { price }) => (total += price), 0)
}
addProducts(products) {
this.#cart.push(...products)
}
removeProduct(obj) {
for (const key in obj) {
this.#cart = this.#cart.filter((prod) => prod[key] !== obj[key])
}
}
getInfo() {
console.log(
`В корзине ${this.#getProductCount()} товар(а) на ${
this.#getProductCount() > 1 ? 'общую ' : ''
}сумму ${this.#getTotalPrice()} рублей`
)
}
}
const _cart = new Cart()
_cart.addProducts(products)
_cart.getInfo()
// В корзине 3 товар(а) на общую сумму 300 рублей
_cart.removeProduct({ id: '1', price: 100 })
_cart.getInfo()
// В корзине 1 товар(а) на общую сумму 150 рублей
console.log(_cart.cart) // undefined
// console.log(_cart.#cart) // SyntaxError: Private field '#cart' must be declared in an enclosing class
// _cart.getTotalPrice() // TypeError: cart.getTotalPrice is not a function
// _cart.#getTotalPrice() // Error
Как мы видим, паттерны «Модуль» и «Фабрика» ничем не уступают классу, разве что, синтаксис последнего является немного более кратким, но позволяют полностью отказаться от использования ключевого слова «this», основная проблема которого заключается в потере контекста при использовании в стрелочных функциях и обработчиках событий. Это обуславливает необходимость их привязки к экземпляру в конструкторе.
Напоследок, рассмотрим пример создания веб-компонента кнопки с помощью синтаксиса класса (из текста одного из предложений с небольшой модификацией).
Наш компонент расширяет встроенный HTML-элемент кнопки, добавляя в его функционал следующее: при нажатии кнопки левой кнопкой мыши, значение счетчика увеличивается на 1, при нажатии кнопки правой кнопкой мыши, значение счетчика уменьшается на 1. При этом мы можем использовать любое количество кнопок с собственным контекстом и состоянием:
// https://developer.mozilla.org/ru/docs/Web/Web_Components
class Counter extends HTMLButtonElement {
#xValue = 0
get #x() {
return this.#xValue
}
set #x(value) {
this.#xValue = value
// привязываем к экземпляру метод рендеринга
// https://developer.mozilla.org/ru/docs/DOM/window.requestAnimationFrame
// https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Function/bind
requestAnimationFrame(this.#render.bind(this))
}
#increment() {
this.#x++
}
#decrement(e) {
// отменяем вызов контекстного меню
e.preventDefault()
this.#x--
}
constructor() {
super()
// привязываем к экземпляру обработчики событий
this.onclick = this.#increment.bind(this)
this.oncontextmenu = this.#decrement.bind(this)
}
// монтирование в терминологии React/Vue или, проще говоря, встраивание элемента в DOM
connectedCallback() {
this.#render()
}
#render() {
// для упрощения будем считать, что 0 - это положительное число
this.textContent = `${this.#x} - ${
this.#x < 0 ? 'отрицательное' : 'положительное'
} ${this.#x & 1 ? 'нечетное' : 'четное'} число`
}
}
// регистрация веб-компонента
customElements.define('btn-counter', Counter, { extends: 'button' })
Результат:
Представляется, что, с одной стороны, классы не получат повсеместного признания в сообществе разработчиков до решения, назовем ее так, «проблемы this». Не случайно после продолжительного использования классов (классовых компонентов), команда React отказалась от них в пользу функций (хуков). Похожая тенденция наблюдается в Vue Composition API. С другой стороны, многие причастные к разработке ECMAScript, инженеры из Google, занимающиеся веб-компонентами, а также команда TypeScript активно работают над развитием «объектно-ориентированной составляющей» JavaScript, поэтому сбрасывать классы со счетов в ближайшие несколько лет точно не стоит.
Весь код, приводимый в статье, находится здесь.
Дополнительно про объектно-ориентированный JavaScript можно почитать здесь.
Статья получилась несколько длиннее, чем я планировал, но, надеюсь, вам было интересно. Благодарю за внимание и хорошего дня.