O futuro do JavaScript: classes





Bom dia amigos!



Hoje eu quero falar com vocês sobre três propostas relacionadas a classes JavaScript que estão em 3 estágios de consideração:





Considerando que essas propostas obedecem integralmente à lógica de posterior desenvolvimento de classes e utilizam a sintaxe existente, pode-se ter certeza de que serão padronizadas sem grandes alterações. Isso também é evidenciado pela implementação dos "recursos" nomeados em navegadores modernos.



Vamos lembrar o que são classes em JavaScript.



Na maior parte, as classes são chamadas de "açúcar sintático" (abstração ou, mais simplesmente, um invólucro) para funções de construtor. Essas funções são usadas para implementar o padrão de design do Construtor. Esse padrão, por sua vez, é implementado (em JavaScript) usando o modelo de herança prototípico. O modelo de herança prototípico às vezes é definido como um padrão "Protótipo" autônomo. Você pode ler mais sobre padrões de projeto aqui .



O que é um protótipo? É um objeto que atua como um projeto ou projeto para outros objetos - instâncias. Um construtor é uma função que permite criar objetos de instância com base em um protótipo (classe, superclasse, classe abstrata, etc.). O processo de passar propriedades e funções do protótipo para a instância é chamado de herança. Propriedades e funções na terminologia de classe são geralmente chamadas de campos e métodos, mas, de fato, são a mesma coisa.



Qual é a aparência de uma função construtora?



//      
'use strict'
function Counter(initialValue = 0) {
  this.count = initialValue
  //   ,   this
  console.log(this)
}

      
      





Definimos uma função "Contador" que leva um parâmetro "initialValue" com um valor padrão de 0. Este parâmetro é atribuído à propriedade de instância "count" quando a instância é inicializada. O contexto "this" neste caso é o objeto criado (retornado) pela função. Para dizer ao JavaScript para chamar não apenas uma função, mas também uma função construtora, você deve usar a palavra-chave "nova":



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

      
      





Como podemos ver, a função construtora retorna um objeto com uma propriedade que definimos "contagem" e um protótipo (__proto__) como um objeto global "Objeto", para o qual as cadeias de protótipo de quase todos os tipos (dados) em JavaScript voltam (exceto para objetos sem um protótipo criado usando Object.create (null)). É por isso que dizem que em JavaScript "tudo é um objeto".



Chamar uma função construtora sem "novo" lançará um "TypeError" (erro de tipo) indicando que "a propriedade 'count' não pode ser atribuída indefinida":



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

//   
const counter = Counter() // Window

      
      





Isso ocorre porque o valor "this" dentro de uma função é "indefinido" no modo estrito e o objeto "Window" global no modo não estrito.



Vamos adicionar métodos distribuídos (compartilhados, comuns a todas as instâncias) à função do construtor para aumentar, diminuir, redefinir e obter o valor do contador:



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
}

      
      





Se você definir métodos na própria função do construtor, e não em seu protótipo, para cada instância seus próprios métodos serão criados, o que pode dificultar a alteração subsequente da funcionalidade das instâncias. Anteriormente, isso também poderia levar a problemas de desempenho.



Adicionar vários métodos ao protótipo de uma função de construtor pode ser otimizado da seguinte maneira:



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

      
      





Ou você pode tornar ainda mais fácil:



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

      
      





Vamos usar nossos métodos:



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

      
      





A sintaxe da classe é mais concisa:



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

      
      





Vejamos um exemplo mais complexo para demonstrar como funciona a herança de JavaScript. Vamos criar uma classe "Person" e sua subclasse "SubPerson".



A classe Person define as propriedades firstName, lastName e age, bem como getFullName (obtém o nome e o sobrenome), getAge (obtém a idade) e saySomething ”(dizendo uma frase).



A subclasse SubPerson herda todas as propriedades e métodos de Person e também define novos campos para estilo de vida, habilidade e interesse, bem como novos métodos getInfo para obter o nome completo chamando o método herdado pelos pais "getFullName" e estilo de vida), " getSkill "(obter uma habilidade)," getLike "(obter um hobby) e" setLike "(definir um hobby).



Função de construtor:



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

      
      





Classe:



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

      
      





Acho que tudo está claro aqui. Se movendo.



O principal problema de herança em JavaScript era e ainda é a falta de herança múltipla incorporada, ou seja, a capacidade de uma subclasse de herdar propriedades e métodos de várias classes ao mesmo tempo. Claro, como tudo é possível em JavaScript, podemos simular herança múltipla, por exemplo, usando este 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

      
      





No entanto, essa não é uma solução completa e é apenas um truque para inserir o JavaScript na estrutura da programação orientada a objetos.



Vamos direto às inovações oferecidas pelas propostas indicadas no início do artigo.



Hoje, dados os recursos padronizados, a sintaxe da classe se parece com isto:



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

      
      





Acontece que podemos definir campos públicos e privados e métodos públicos de instâncias, bem como métodos públicos de uma classe, mas não podemos definir métodos privados de instâncias, bem como campos públicos e privados de uma classe. Bem, na verdade, ainda é possível definir um campo público de uma classe:



C.publicClassField = '  '
console.log(C.publicClassField) //   

      
      





Mas, você deve admitir que não parece muito bom. Parece que voltamos a trabalhar com protótipos.



A primeira proposta permite que você defina campos de instância públicos e privados sem usar um construtor:



publicInstanceField = '  '
#privateInstanceField = '  '

      
      





A segunda proposta permite que você defina métodos de instância privada:



#privateInstanceMethod() {
  log('  ')
}

//    
getPrivateInstanceMethod() {
  this.#privateInstanceMethod()
}

      
      





E, finalmente, a terceira proposta permite definir campos públicos e privados (estáticos), bem como métodos privados (estáticos) de uma classe:



static publicClassField = '  '
static #privateClassField = '  '

static #privateClassMethod() {
  log('  ')
}

//     
static getPrivateClassField() {
  log(C.#privateClassField)
}

//    
static getPrivateClassMethod() {
  C.#privateClassMethod()
}

      
      





É assim que o conjunto completo ficará (na verdade, ele já se parece):



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

      
      





Tudo ficaria bem, apenas há uma nuance interessante: os campos privados não são herdados. No TypeScript e em outras linguagens de programação, há uma propriedade especial, geralmente chamada de "protegida", que não pode ser acessada diretamente, mas pode ser herdada junto com as propriedades públicas.



É importante notar que as palavras "privado", "público" e "protegido" são palavras reservadas em JavaScript. Se você tentar usá-los no modo estrito, uma exceção é lançada:



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

      
      





Portanto, a esperança para a implementação de campos de classe protegidos em um futuro distante permanece.



Chamo sua atenção para o fato de que a técnica de encapsular variáveis, ou seja, sua proteção contra acesso externo é tão antiga quanto o próprio JavaScript. Antes da padronização dos campos de classe privada, os fechamentos eram comumente usados ​​para ocultar variáveis, bem como os padrões de projeto de fábrica e módulo. Vejamos esses padrões usando o exemplo de um carrinho de compras.



Módulo:



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

      
      





Fábrica:



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

      
      





Classe:



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

      
      





Como podemos ver, os padrões "Módulo" e "Fábrica" ​​em nada são inferiores à classe, exceto que a sintaxe desta é um pouco mais concisa, mas permite que você abandone completamente o uso da palavra-chave "este" , cujo principal problema é a perda de contexto quando usado em funções de seta e manipuladores de eventos. Isso exige vinculá-los a uma instância no construtor.



Finalmente, vamos dar uma olhada em um exemplo de criação de um componente de botão da web usando a sintaxe de classe (do texto de uma das frases com uma pequena modificação).



Nosso componente estende o elemento HTML embutido do botão, adicionando o seguinte à sua funcionalidade: quando o botão é clicado com o botão esquerdo, o valor do contador é aumentado em 1, quando o botão é clicado com o botão direito, o valor do contador diminui 1. Ao mesmo tempo, podemos usar qualquer número de botões com contexto e estado próprios:



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

      
      





Resultado:







parece que, por um lado, as classes não vão ganhar ampla aceitação na comunidade de desenvolvedores até que resolvam, vamos chamar de “esse problema”. Não é por acaso que depois de muito tempo usando classes (componentes de classe), a equipe React trocou-as por funções (ganchos). Uma tendência semelhante é observada no Vue Composition API. Por outro lado, muitos dos desenvolvedores ECMAScript, engenheiros de componentes da web do Google e a equipe do TypeScript estão trabalhando ativamente no desenvolvimento do componente "orientado a objetos" do JavaScript, então você não deve descartar as classes nos próximos anos.



Todo o código do artigo está aqui .



Você pode ler mais sobre JavaScript orientado a objetos aqui .



O artigo acabou sendo um pouco mais longo do que eu planejava, mas espero que você esteja interessado. Obrigado pela atenção e tenha um bom dia.



All Articles