The future of JavaScript: classes – InformTFB

The future of JavaScript: classes

The future of JavaScript: classes

Good day, friends!

Today I want to talk to you about three proposals related to JavaScript classes that are currently under 3 stages of consideration:

Given that these suggestions fully correspond to the logic of further class development and use the existing syntax, you can be sure that they will be standardized without any major changes. This is also evidenced by the implementation of these “features” in modern browsers.

Let’s remember what classes are in JavaScript.

For the most part, classes are so-called “syntactic sugar” (an abstraction or, more simply, a wrapper) for constructor functions. These functions are used to implement the “Constructor” design pattern. This pattern, in turn, is implemented (in JavaScript) using the prototypal inheritance model. The prototype inheritance model is sometimes defined as an independent “Prototype” pattern. You can read more about design patterns here.

What is a prototype? This is an object that acts as a blueprint for other instance objects. A constructor is a function that allows you to create instantiated objects based on a prototype (class, superclass, abstract class, etc.). the Process of passing properties and functions from prototype to instance is called inheritance. Properties and functions in the terminology of classes are usually referred to as fields and methods, but, de facto, they are the same thing.

What does the constructor function look like?

// обратите внимание на включение строгого режима
'use strict'
function Counter(initialValue = 0) {
  this.count = initialValue
  // смотрим на то, что такое this
  console.log(this)
}

We define a “Counter” function that accepts the “initialValue” parameter with a default value of 0. This parameter is assigned to the instance property “count” when the instance is initialized. In this case, the “this” context is an object that is being created (returned) by the function. To tell JavaScript to call not just a function, but a constructor function, use the “new” keyword.»:

const counter = new Counter() // { count: 0, __proto__: Object }

As we can see, the constructor function returns an object with the “count” property defined by us and a prototype (__proto__) in the form of a global object “Object”, which is accessed by prototype chains of almost all types (data) in JavaScript (with the exception of objects without a prototype, created using Object.create(null)). That’s why they say that in JavaScript “everything is an object”.

If you call the constructor function without “new”, a “TypeError” exception will be thrown, indicating that ” the ‘count’ property cannot be assigned to undefined»:

const counter = Counter() // TypeError: Cannot set property 'count' of undefined

// в нестрогом режиме
const counter = Counter() // Window

This is because the value of “this” inside the function in strict mode is “undefined”, and in non — strict mode-the global object”Window”.

Adding distributed (shared, shared for all instances) methods for incrementing, decrementing, resetting, and retrieving the counter value to the constructor function:

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
}

If you define methods in the constructor function itself, rather than in its prototype, then each instance will create its own methods, which may make it difficult to change the functionality of the instances later. Previously, this could also lead to performance issues.

Adding multiple methods to a constructor function prototype can be optimized as follows::

;(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))

Or you can make it even easier:

// это современный синтаксис, раньше такой возможности не было
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
  }
})

Let’s use our methods:

counter
  .increment()
  .increment()
  .getInfo() // 2
  .decrement()
  .getInfo() // 1
  .reset()
  .getInfo() // 0

The class syntax is more concise:

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

To demonstrate how the inheritance mechanism works in JavaScript, let’s look at a more complex example. Let’s create the “Person” class and its “SubPerson” subclass.

The “Person” class defines the “firstName”, “lastName”, and “age” properties, as well as the “getFullName”, “getAge”, and “saySomething” methods.

The “SubPerson” subclass inherits all the properties and methods of Person, and also defines new fields “lifestyle”, ” skill “and” interest”, as well as new methods ” getInfo “(getting the full name by calling the parent-inherited method” getFullName “and lifestyle),” getSkill “(getting a skill),” getLike “(getting a hobby) and” setLike ” (definition-setting a hobby).

The constructor function:

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()
// Этот разработчик любит делать оригами

Class:

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
// Этот разработчик любит делать оригами

I think everything is clear here. Moving on.

The main problem with inheritance in JavaScript was and still is the lack of built-in multiple inheritance, i.e. the ability of a subclass to inherit properties and methods of several classes simultaneously. Of course, since everything is possible in JavaScript, we can create an imitation of multiple inheritance, for example, using such a mixin:

// 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

However, this is not a complete solution and is just a “hack” to squeeze JavaScript into the framework of object-oriented programming.

Let’s go directly to the innovations offered by the proposals indicated at the beginning of the article.

Today, taking into account the standardized features, the class syntax looks like this::

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() // Публичный метод класса

It turns out that we can define public and private fields and public methods of instances, as well as public methods of the class, but we cannot define private methods of instances, as well as public and private fields of the class. Well, in fact, it is still possible to define a public field of the class:

C.publicClassField = 'Публичное поле класса'
console.log(C.publicClassField) // Публичное поле класса

But, you must agree that it doesn’t look very good. It seems that we have returned to working with prototypes.

The first sentence allows you to define public and private fields of an instance without using the constructor:

publicInstanceField = 'Публичное поле экземпляра'
#privateInstanceField = 'Приватное поле экземпляра'

The second sentence allows you to define private instance methods:

#privateInstanceMethod() {
  log('Приватный метод экземпляра')
}

// вызываем приватный метод экземпляра
getPrivateInstanceMethod() {
  this.#privateInstanceMethod()
}

Finally, the third sentence allows you to define public and private (static) fields, as well as private (static) class methods:

static publicClassField = 'Публичное поле класса'
static #privateClassField = 'Приватное поле класса'

static #privateClassMethod() {
  log('Приватный метод класса')
}

// получаем значение приватного поле класса
static getPrivateClassField() {
  log(C.#privateClassField)
}

// вызываем приватный метод класса
static getPrivateClassMethod() {
  C.#privateClassMethod()
}

Here’s what the complete set will look like (in fact, it already looks like)::

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

Everything would be fine, but there is only one interesting nuance: private fields are not inherited. In TypeScript and other programming languages, there is a special property, usually called “protected”, which cannot be accessed directly, but which, however, can be inherited along with public properties.

It’s worth noting that the words “private”, “public”, and “protected” are reserved words in JavaScript. When you try to use them in strict mode, an exception is thrown:

const private = '' // SyntaxError: Unexpected strict mode reserved word
const public = '' // Error
const protected = '' // Error

Therefore, there is still hope for the implementation of protected class fields in the long term.

Please note that the technique of encapsulating variables, i.e. protecting them from external access, is as old as JavaScript itself. Prior to the standardization of private class fields, closures were usually used to hide variables, as well as the “Factory” and “Module” design patterns. Let’s look at these patterns using the example of a shopping cart.

Module:

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

Factory:

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:

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

As we can see, the “Module” and “Factory” patterns are not inferior to a class, except that the syntax of the latter is a little more concise, but they allow you to completely abandon the use of the “this” keyword, the main problem of which is the loss of context when used in arrow functions and event handlers. This makes it necessary to bind them to the instance in the constructor.

Finally, let’s look at an example of creating a web component of a button using the class syntax (from the text of one of the sentences with a slight modification).

Our component extends the built-in HTML button element, adding to its functionality is the following: when you click with the left mouse button, the counter is incremented by 1, when you click the right mouse button, the counter value decreases by 1. In this case, we can use any number of buttons with its own context and state:

// 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' })

The result:

It seems that, on the one hand, classes will not gain widespread recognition in the developer community until the solution, let’s call it that, “this problem”. It is no coincidence that after prolonged use of classes (class components), the React team abandoned them in favor of functions (hooks). A similar trend is observed in the Vue Composition API. On the other hand, many of those involved in ECMAScript development, Google engineers working on web components, and the TypeScript team are actively working on the development of the “object-oriented component” of JavaScript, so you definitely shouldn’t discount classes in the next few years.

All the code provided in this article can be found here.

You can read more about object-oriented JavaScript here.

The article turned out to be a little longer than I planned, but I hope you were interested. Thank you for your attention and have a nice day.

Valery Radokhleb
Valery Radokhleb
Web developer, designer

Leave a Reply

Your email address will not be published. Required fields are marked *