El futuro de JavaScript: clases





¡Buen dia amigos!



Hoy quiero hablaros de tres propuestas relacionadas con las clases de JavaScript que se encuentran en 3 etapas de consideración:





Teniendo en cuenta que estas propuestas cumplen plenamente con la lógica de un mayor desarrollo de clases y utilizan la sintaxis existente, puede estar seguro de que se estandarizarán sin cambios importantes. Esto también se evidencia por la implementación de las "características" nombradas en los navegadores modernos.



Recordemos qué clases hay en JavaScript.



En su mayor parte, las clases se denominan "azúcar sintáctico" (abstracción o, más simplemente, un contenedor) para funciones constructoras. Estas funciones se utilizan para implementar el patrón de diseño Constructor. Este patrón, a su vez, se implementa (en JavaScript) utilizando el modelo de herencia prototípico. El modelo de herencia prototípico a veces se define como un patrón de "prototipo" independiente. Puede leer más sobre patrones de diseño aquí .



¿Qué es un prototipo? Es un objeto que actúa como plano o plano para otros objetos: instancias. Un constructor es una función que le permite crear objetos de instancia basados ​​en un prototipo (clase, superclase, clase abstracta, etc.). El proceso de pasar propiedades y funciones de un prototipo a una instancia se llama herencia. Las propiedades y funciones en la terminología de clases se suelen llamar campos y métodos, pero de facto son lo mismo.



¿Qué aspecto tiene una función constructora?



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

      
      





Definimos una función "Contador" que toma un parámetro "initialValue" con un valor predeterminado de 0. Este parámetro se asigna a la propiedad de instancia "count" cuando se inicializa la instancia. El contexto "esto" en este caso es el objeto creado (devuelto) por la función. Para decirle a JavaScript que llame no solo a una función, sino a una función de constructor, debe usar la palabra clave "nueva":



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

      
      





Como podemos ver, la función constructora devuelve un objeto con una propiedad que definimos "cuenta" y un prototipo (__proto__) como un objeto global "Objeto", al que se remontan las cadenas de prototipos de casi todos los tipos (datos) en JavaScript (excepto para objetos sin un prototipo creado mediante Object.create (null)). Por eso dicen que en JavaScript "todo es un objeto".



Llamar a una función de constructor sin "nuevo" arrojará un "TypeError" (error de tipo) que indica que "la propiedad 'count' no se puede asignar sin definir":



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

//   
const counter = Counter() // Window

      
      





Esto se debe a que el valor "this" dentro de una función es "indefinido" en modo estricto y el objeto "Ventana" global en modo no estricto.



Agreguemos métodos distribuidos (compartidos, comunes a todas las instancias) a la función constructora para aumentar, disminuir, restablecer y obtener el valor del 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
}

      
      





Si define métodos en la función constructora en sí, y no en su prototipo, entonces para cada instancia se crearán sus propios métodos, lo que puede dificultar el cambio posterior de la funcionalidad de las instancias. Anteriormente, esto también podía provocar problemas de rendimiento.



La adición de varios métodos al prototipo de una función de constructor se puede optimizar de la siguiente manera:



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

      
      





O puede hacerlo aún más 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
  }
})

      
      





Usemos nuestros métodos:



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

      
      





La sintaxis de la clase es más 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

      
      





Veamos un ejemplo más complejo para demostrar cómo funciona la herencia de JavaScript. Creemos una clase "Persona" y su subclase "SubPersona".



La clase Person define las propiedades firstName, lastName y age, así como getFullName (obtener el nombre y apellido), getAge (obtener la edad) y saySomething ”(decir una frase).



La subclase SubPerson hereda todas las propiedades y métodos de Person, y también define nuevos campos para estilo de vida, habilidad e interés, así como nuevos métodos getInfo para obtener el nombre completo llamando al método heredado por los padres "getFullName" y lifestyle), " getSkill "(obtener una habilidad)," getLike "(obtener un pasatiempo) y" setLike "(definir un pasatiempo).



Función constructora:



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

      
      





Clase:



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

      
      





Creo que aquí todo está claro. Hacia adelante.



El principal problema de la herencia en JavaScript era y sigue siendo la falta de herencia múltiple incorporada, es decir, la capacidad de una subclase de heredar propiedades y métodos de varias clases al mismo tiempo. Por supuesto, dado que todo es posible en JavaScript, podemos simular herencia múltiple, por ejemplo, usando esta combinación:



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

      
      





Sin embargo, esta no es una solución completa y es solo un truco para introducir JavaScript en el marco de la programación orientada a objetos.



Vayamos directamente a las novedades que ofrecen las propuestas indicadas al inicio del artículo.



Hoy, dadas las características estandarizadas, la sintaxis de la clase se ve así:



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

      
      





Resulta que podemos definir campos públicos y privados y métodos públicos de instancias, así como métodos públicos de una clase, pero no podemos definir métodos privados de instancias, así como campos públicos y privados de una clase. Bueno, de hecho, todavía es posible definir un campo público de una clase:



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

      
      





Pero debes admitir que no se ve muy bien. Parece que volvemos a trabajar con prototipos.



La primera propuesta le permite definir campos de instancia públicos y privados sin usar un constructor:



publicInstanceField = '  '
#privateInstanceField = '  '

      
      





La segunda propuesta le permite definir métodos de instancia privados:



#privateInstanceMethod() {
  log('  ')
}

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

      
      





Y finalmente, la tercera propuesta permite definir campos públicos y privados (estáticos), así como métodos privados (estáticos) de una clase:



static publicClassField = '  '
static #privateClassField = '  '

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

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

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

      
      





Así es como se verá el conjunto completo (de hecho, ya se ve):



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

      
      





Todo estaría bien, solo hay un matiz interesante: los campos privados no se heredan. En TypeScript y otros lenguajes de programación, existe una propiedad especial, generalmente denominada "protegida", a la que no se puede acceder directamente, pero que se puede heredar junto con las propiedades públicas.



Vale la pena señalar que las palabras "privado", "público" y "protegido" son palabras reservadas en JavaScript. Si intenta usarlos en modo estricto, se lanza una excepción:



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

      
      





Por lo tanto, permanece la esperanza de la implementación de campos de clases protegidos en un futuro lejano.



Llamo su atención sobre el hecho de que la técnica de encapsular variables, es decir, su protección contra el acceso externo es tan antigua como el propio JavaScript. Antes de la estandarización de los campos de clases privadas, los cierres se usaban comúnmente para ocultar variables, así como los patrones de diseño de Fábrica y Módulo. Veamos estos patrones usando el ejemplo de un carrito 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

      
      





Clase:



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, los patrones "Module" y "Factory" no son de ninguna manera inferiores a la clase, salvo que la sintaxis de esta última es un poco más concisa, pero permite abandonar por completo el uso de la palabra clave "this" , cuyo principal problema es la pérdida de contexto cuando se utiliza en funciones de flecha y controladores de eventos. Esto requiere vincularlos a una instancia en el constructor.



Finalmente, veamos un ejemplo de creación de un componente web de botón usando la sintaxis de la clase (del texto de una de las oraciones con una ligera modificación).



Nuestro componente amplía el elemento HTML incorporado del botón, agregando lo siguiente a su funcionalidad: cuando se hace clic con el botón izquierdo, el valor del contador aumenta en 1, cuando se hace clic con el botón derecho, el valor del contador se reduce en 1. Al mismo tiempo, podemos utilizar cualquier número de botones con contexto y estado propios:



// 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 un lado, las clases no obtendrán una aceptación generalizada en la comunidad de desarrolladores hasta que no resuelvan, llamémoslo “este problema”. No es casualidad que después de mucho tiempo usando clases (componentes de clase), el equipo de React las abandonó a favor de funciones (ganchos). Se observa una tendencia similar en la API de composición de Vue. Por otro lado, muchos de los desarrolladores de ECMAScript, los ingenieros de componentes web de Google y el equipo de TypeScript están trabajando activamente en el desarrollo del componente "orientado a objetos" de JavaScript, por lo que no debería descartar clases en los próximos años.



Todo el código del artículo está aquí .



Puede leer más sobre JavaScript orientado a objetos aquí .



El artículo resultó ser un poco más largo de lo planeado, pero espero que les haya interesado. Gracias por su atención y que tenga un buen día.



All Articles