¡Buen dia amigos!
Hoy quiero hablaros de tres propuestas relacionadas con las clases de JavaScript que se encuentran en 3 etapas de consideración:
- definición de campos de clase
- métodos privados y captadores / definidores de clases
- capacidades de clase estática: campos públicos estáticos, campos privados estáticos y métodos privados estáticos
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.