Como desarrollador de JS, esto es lo que me mantiene despierto por la noche
Publicado: 2022-03-11JavaScript es un bicho raro de un lenguaje. Aunque está inspirado en Smalltalk, utiliza una sintaxis similar a C. Combina aspectos de los paradigmas de programación orientada a objetos (POO), funcional y procedimental. Tiene numerosos enfoques, a menudo redundantes, para resolver casi cualquier problema de programación concebible y no tiene opiniones firmes sobre cuáles son los preferidos. Está tipeado débil y dinámicamente, con un enfoque laberíntico para escribir coerción que hace tropezar incluso a los desarrolladores experimentados.
JavaScript también tiene sus verrugas, trampas y características cuestionables. Los nuevos programadores luchan con algunos de sus conceptos más difíciles: piense en la asincronía, los cierres y el levantamiento. Los programadores con experiencia en otros lenguajes asumen razonablemente que las cosas con nombres y apariencias similares funcionarán de la misma manera en JavaScript y, a menudo, se equivocan. Los arreglos no son realmente arreglos; ¿Cuál es el problema con this
, qué es un prototipo y qué hace realmente lo new
?
El problema con las clases ES6
El peor infractor por mucho es nuevo en la última versión de lanzamiento de JavaScript, ECMAScript 6 (ES6): clases . Algunas de las charlas en las clases son francamente alarmantes y revelan un malentendido profundamente arraigado sobre cómo funciona realmente el idioma:
"¡JavaScript es finalmente un verdadero lenguaje orientado a objetos ahora que tiene clases!"
O:
"Las clases nos liberan de pensar en el modelo de herencia roto de JavaScript".
O incluso:
“Las clases son un enfoque más seguro y sencillo para crear tipos en JavaScript”.
Estas declaraciones no me molestan porque implican que hay algo mal con la herencia prototípica; dejemos de lado esos argumentos. Estas afirmaciones me molestan porque ninguna de ellas es cierta y demuestran las consecuencias del enfoque de "todo para todos" de JavaScript para el diseño del lenguaje: paraliza la comprensión del lenguaje por parte del programador con más frecuencia de lo que permite. Antes de ir más lejos, vamos a ilustrar.
Prueba sorpresa de JavaScript n.º 1: ¿Cuál es la diferencia esencial entre estos bloques de código?
function PrototypicalGreeting(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } PrototypicalGreeting.prototype.greet = function() { return `${this.greeting}, ${this.name}!` } const greetProto = new PrototypicalGreeting("Hey", "folks") console.log(greetProto.greet())
class ClassicalGreeting { constructor(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } greet() { return `${this.greeting}, ${this.name}!` } } const classyGreeting = new ClassicalGreeting("Hey", "folks") console.log(classyGreeting.greet())
La respuesta aquí es que no hay uno . Estos hacen efectivamente lo mismo, es solo una cuestión de si se usó la sintaxis de clase ES6.
Es cierto que el segundo ejemplo es más expresivo. Solo por esa razón, podría argumentar que la class
es una buena adición al lenguaje. Desafortunadamente, el problema es un poco más sutil.
Prueba sorpresa de JavaScript n.° 2: ¿Qué hace el siguiente código?
function Proto() { this.name = 'Proto' return this; } Proto.prototype.getName = function() { return this.name } class MyClass extends Proto { constructor() { super() this.name = 'MyClass' } } const instance = new MyClass() console.log(instance.getName()) Proto.prototype.getName = function() { return 'Overridden in Proto' } console.log(instance.getName()) MyClass.prototype.getName = function() { return 'Overridden in MyClass' } console.log(instance.getName()) instance.getName = function() { return 'Overridden in instance' } console.log(instance.getName())
La respuesta correcta es que se imprime en la consola:
> MyClass > Overridden in Proto > Overridden in MyClass > Overridden in instance
Si respondió incorrectamente, no entiende qué class
es realmente. Esto no es tu culpa. Al igual que Array
, class
no es una característica del lenguaje, es oscurantismo sintáctico . Intenta ocultar el modelo de herencia prototípico y las torpes expresiones idiomáticas que lo acompañan, e implica que JavaScript está haciendo algo que no está haciendo.
Es posible que le hayan dicho que la class
se introdujo en JavaScript para que los desarrolladores clásicos de programación orientada a objetos que provienen de lenguajes como Java se sientan más cómodos con el modelo de herencia de clase ES6. Si eres uno de esos desarrolladores, ese ejemplo probablemente te horrorizó. Debería. Muestra que la palabra clave de class
de JavaScript no viene con ninguna de las garantías que una clase debe proporcionar. También demuestra una de las diferencias clave en el modelo de herencia de prototipos: los prototipos son instancias de objetos , no tipos .
Prototipos vs Clases
La diferencia más importante entre la herencia basada en clases y prototipos es que una clase define un tipo que se puede instanciar en tiempo de ejecución, mientras que un prototipo es en sí mismo una instancia de objeto.
Un hijo de una clase ES6 es otra definición de tipo que amplía el padre con nuevas propiedades y métodos, que a su vez se pueden instanciar en tiempo de ejecución. Un hijo de un prototipo es otra instancia de objeto que delega al padre cualquier propiedad que no esté implementada en el hijo.
Nota al margen: es posible que se pregunte por qué mencioné métodos de clase, pero no métodos de prototipo. Eso es porque JavaScript no tiene un concepto de métodos. Las funciones son de primera clase en JavaScript y pueden tener propiedades o ser propiedades de otros objetos.
Un constructor de clase crea una instancia de la clase. Un constructor en JavaScript es simplemente una función antigua que devuelve un objeto. Lo único especial de un constructor de JavaScript es que, cuando se invoca con la new
palabra clave, asigna su prototipo como el prototipo del objeto devuelto. Si eso le suena un poco confuso, no está solo, lo está, y es una gran parte de por qué los prototipos no se entienden bien.
Para poner un punto realmente fino en eso, un hijo de un prototipo no es una copia de su prototipo, ni es un objeto con la misma forma que su prototipo. El niño tiene una referencia viva al prototipo, y cualquier propiedad del prototipo que no exista en el niño es una referencia unidireccional a una propiedad del mismo nombre en el prototipo.
Considera lo siguiente:
let parent = { foo: 'foo' } let child = { } Object.setPrototypeOf(child, parent) console.log(child.foo) // 'foo' child.foo = 'bar' console.log(child.foo) // 'bar' console.log(parent.foo) // 'foo' delete child.foo console.log(child.foo) // 'foo' parent.foo = 'baz' console.log(child.foo) // 'baz'
En el ejemplo anterior, aunque child.foo
no estaba undefined
, hacía referencia a parent.foo
. Tan pronto como definimos foo
en child
, child.foo
tenía el valor 'bar'
, pero parent.foo
retuvo su valor original. Una vez que delete child.foo
, nuevamente se refiere a parent.foo
, lo que significa que cuando cambiamos el valor del padre, child.foo
se refiere al nuevo valor.
Veamos lo que acaba de suceder (a los efectos de una ilustración más clara, vamos a fingir que se trata de Strings
y no de cadenas literales, la diferencia no importa aquí):
La forma en que esto funciona bajo el capó, y especialmente las peculiaridades de new
y this
, son un tema para otro día, pero Mozilla tiene un artículo completo sobre la cadena de herencia de prototipos de JavaScript si desea leer más.
La conclusión clave es que los prototipos no definen un type
; ellos mismos son instances
y son mutables en tiempo de ejecución, con todo lo que implica y conlleva.
¿Aún conmigo? Volvamos a analizar las clases de JavaScript.
Prueba sorpresa de JavaScript n.º 3: ¿Cómo se implementa la privacidad en las clases?
Nuestras propiedades de clase y prototipo anteriores no están tan "encapsuladas" como "colgando precariamente por la ventana". Deberíamos arreglar eso, pero ¿cómo?
No hay ejemplos de código aquí. La respuesta es que no puedes.
JavaScript no tiene ningún concepto de privacidad, pero tiene cierres:
function SecretiveProto() { const secret = "The Class is a lie!" this.spillTheBeans = function() { console.log(secret) } } const blabbermouth = new SecretiveProto() try { console.log(blabbermouth.secret) } catch(e) { // TypeError: SecretiveClass.secret is not defined } blabbermouth.spillTheBeans() // "The Class is a lie!"
¿Entiendes lo que acaba de pasar? Si no, no entiendes los cierres. Está bien, de verdad, no son tan intimidantes como parecen, son súper útiles y deberías tomarte un tiempo para aprender sobre ellos.
Prueba sorpresa de JavaScript n.º 4: ¿Cuál es el equivalente al anterior usando la palabra clave class
?
Lo siento, esta es otra pregunta capciosa. Puedes hacer básicamente lo mismo, pero se ve así:
class SecretiveClass { constructor() { const secret = "I am a lie!" this.spillTheBeans = function() { console.log(secret) } } looseLips() { console.log(secret) } } const liar = new SecretiveClass() try { console.log(liar.secret) } catch(e) { console.log(e) // TypeError: SecretiveClass.secret is not defined } liar.spillTheBeans() // "I am a lie!"
Déjame saber si parece más fácil o más claro que en SecretiveProto
. En mi opinión personal, es un poco peor: rompe el uso idiomático de las declaraciones de class
en JavaScript y no funciona como se esperaría, por ejemplo, de Java. Esto quedará claro con lo siguiente:

Prueba sorpresa de JavaScript n.º 5: ¿Qué hace SecretiveClass::looseLips()
?
Vamos a averiguar:
try { liar.looseLips() } catch(e) { // ReferenceError: secret is not defined }
Bueno... eso fue incómodo.
Prueba sorpresa de JavaScript n.º 6: ¿Qué prefieren los desarrolladores de JavaScript con experiencia: prototipos o clases?
Lo has adivinado, esa es otra pregunta capciosa: los desarrolladores de JavaScript experimentados tienden a evitar ambos cuando pueden. Aquí hay una buena manera de hacer lo anterior con JavaScript idiomático:
function secretFactory() { const secret = "Favor composition over inheritance, `new` is considered harmful, and the end is near!" const spillTheBeans = () => console.log(secret) return { spillTheBeans } } const leaker = secretFactory() leaker.spillTheBeans()
No se trata solo de evitar la fealdad inherente de la herencia o de hacer cumplir la encapsulación. Piense en qué más podría hacer con secretFactory
y leaker
que no podría hacer fácilmente con un prototipo o una clase.
Por un lado, puede desestructurarlo porque no tiene que preocuparse por el contexto de this
:
const { spillTheBeans } = secretFactory() spillTheBeans() // Favor composition over inheritance, (...)
Eso es muy bueno. Además de evitar new
y this
tonterías, nos permite usar nuestros objetos indistintamente con módulos CommonJS y ES6. También hace que la composición sea un poco más fácil:
function spyFactory(infiltrationTarget) { return { exfiltrate: infiltrationTarget.spillTheBeans } } const blackHat = spyFactory(leaker) blackHat.exfiltrate() // Favor composition over inheritance, (...) console.log(blackHat.infiltrationTarget) // undefined (looks like we got away with it)
Los clientes de blackHat
no tienen que preocuparse por el origen de los exfiltrate
, y spyFactory
no tiene que perder el tiempo con Function::bind
malabares de contexto o propiedades profundamente anidadas. Tenga en cuenta que no tenemos que preocuparnos mucho por this
en el código de procedimiento síncrono simple, pero causa todo tipo de problemas en el código asíncrono que es mejor evitar.
Con un poco de reflexión, spyFactory
podría convertirse en una herramienta de espionaje altamente sofisticada que podría manejar todo tipo de objetivos de infiltración, o en otras palabras, una fachada.
Por supuesto, también podría hacer eso con una clase, o mejor dicho, con una variedad de clases, todas las cuales heredan de una abstract class
o interface
abstracta... excepto que JavaScript no tiene ningún concepto de resúmenes o interfaces.
Volvamos al ejemplo de saludo para ver cómo lo implementaríamos con una fábrica:
function greeterFactory(greeting = "Hello", name = "World") { return { greet: () => `${greeting}, ${name}!` } } console.log(greeterFactory("Hey", "folks").greet()) // Hey, folks!
Es posible que haya notado que estas fábricas se vuelven más concisas a medida que avanzamos, pero no se preocupe, hacen lo mismo. ¡Las ruedas de entrenamiento se están saliendo, amigos!
Eso ya es menos repetitivo que el prototipo o la versión de clase del mismo código. En segundo lugar, logra la encapsulación de sus propiedades de manera más efectiva. Además, tiene una huella de memoria y rendimiento más baja en algunos casos (puede no parecerlo a primera vista, pero el compilador JIT está trabajando silenciosamente detrás de escena para reducir la duplicación e inferir tipos).
Por lo tanto, es más seguro, a menudo más rápido y más fácil escribir código como este. ¿Por qué necesitamos clases de nuevo? Oh, por supuesto, la reutilización. ¿Qué sucede si queremos variantes de saludo infeliz y entusiasta? Bueno, si estamos usando la clase ClassicalGreeting
, probablemente pasemos directamente a soñar con una jerarquía de clases. Sabemos que tendremos que parametrizar la puntuación, así que refactorizaremos un poco y agregaremos algunos elementos secundarios:
// Greeting class class ClassicalGreeting { constructor(greeting = "Hello", name = "World", punctuation = "!") { this.greeting = greeting this.name = name this.punctuation = punctuation } greet() { return `${this.greeting}, ${this.name}${this.punctuation}` } } // An unhappy greeting class UnhappyGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, " :(") } } const classyUnhappyGreeting = new UnhappyGreeting("Hello", "everyone") console.log(classyUnhappyGreeting.greet()) // Hello, everyone :( // An enthusiastic greeting class EnthusiasticGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, "!!") } greet() { return super.greet().toUpperCase() } } const greetingWithEnthusiasm = new EnthusiasticGreeting() console.log(greetingWithEnthusiasm.greet()) // HELLO, WORLD!!
Es un buen enfoque, hasta que llega alguien y pide una función que no encaja perfectamente en la jerarquía y todo deja de tener sentido. Ponga un alfiler en ese pensamiento mientras tratamos de escribir la misma funcionalidad con fábricas:
const greeterFactory = (greeting = "Hello", name = "World", punctuation = "!") => ({ greet: () => `${greeting}, ${name}${punctuation}` }) // Makes a greeter unhappy const unhappy = (greeter) => (greeting, name) => greeter(greeting, name, ":(") console.log(unhappy(greeterFactory)("Hello", "everyone").greet()) // Hello, everyone :( // Makes a greeter enthusiastic const enthusiastic = (greeter) => (greeting, name) => ({ greet: () => greeter(greeting, name, "!!").greet().toUpperCase() }) console.log(enthusiastic(greeterFactory)().greet()) // HELLO, WORLD!!
No es obvio que este código sea mejor, aunque es un poco más corto. De hecho, podría argumentar que es más difícil de leer, y tal vez este sea un enfoque obtuso. ¿No podríamos simplemente tener una unhappyGreeterFactory
y una GreeterFactory enthusiasticGreeterFactory
?
Entonces llega su cliente y dice: “¡Necesito un nuevo saludador que no esté contento y quiera que toda la sala lo sepa!”.
console.log(enthusiastic(unhappy(greeterFactory))().greet()) // HELLO, WORLD :(
Si necesitáramos usar este saludo entusiasta e infeliz más de una vez, podríamos hacerlo más fácil para nosotros:
const aggressiveGreeterFactory = enthusiastic(unhappy(greeterFactory)) console.log(aggressiveGreeterFactory("You're late", "Jim").greet())
Hay aproximaciones a este estilo de composición que funcionan con prototipos o clases. Por ejemplo, podría repensar UnhappyGreeting
y EnthusiasticGreeting
como decoradores. Todavía se necesitaría más repetitivo que el enfoque de estilo funcional utilizado anteriormente, pero ese es el precio que paga por la seguridad y la encapsulación de clases reales .
La cuestión es que, en JavaScript, no obtienes esa seguridad automática. Los marcos de JavaScript que enfatizan el uso de class
hacen mucha "magia" para disimular este tipo de problemas y obligan a las clases a comportarse. Echa un vistazo al código fuente de ElementMixin
de Polymer alguna vez, te reto. Son niveles arcana de arcanos de JavaScript, y lo digo sin ironía ni sarcasmo.
Por supuesto, podemos solucionar algunos de los problemas discutidos anteriormente con Object.freeze
u Object.defineProperties
con mayor o menor efecto. Pero, ¿por qué imitar la forma sin la función, ignorando las herramientas que JavaScript nos proporciona de forma nativa y que quizás no encontremos en lenguajes como Java? ¿Usaría un martillo con la etiqueta "destornillador" para apretar un tornillo, cuando su caja de herramientas tenía un destornillador real sentado justo al lado?
Encontrar las partes buenas
Los desarrolladores de JavaScript a menudo enfatizan las partes buenas del lenguaje, tanto coloquialmente como en referencia al libro del mismo nombre. Tratamos de evitar las trampas tendidas por sus opciones de diseño de lenguaje más cuestionables y nos ceñimos a las partes que nos permiten escribir código limpio, legible, que minimiza los errores y reutilizable.
Hay argumentos razonables sobre qué partes de JavaScript califican, pero espero haberlo convencido de que la class
no es una de ellas. De lo contrario, es de esperar que comprenda que la herencia en JavaScript puede ser un desastre confuso y que la class
no lo soluciona ni le ahorra tener que comprender los prototipos. Crédito adicional si captó las sugerencias de que los patrones de diseño orientados a objetos funcionan bien sin clases o herencia ES6.
No te estoy diciendo que evites class
por completo. A veces necesita herencia, y class
proporciona una sintaxis más limpia para hacerlo. En particular, class X extends Y
es mucho mejor que el antiguo enfoque de prototipo. Además de eso, muchos marcos front-end populares fomentan su uso y probablemente debería evitar escribir código extraño no estándar solo por principio. Simplemente no me gusta a dónde va esto.
En mis pesadillas, toda una generación de bibliotecas de JavaScript se escribe usando class
, con la expectativa de que se comporte de manera similar a otros lenguajes populares. Se descubren clases completamente nuevas de errores (juego de palabras). Se resucitan los antiguos que podrían haberse dejado fácilmente en el Cementerio de JavaScript malformado si no hubiéramos caído por descuido en la trampa de la class
. Los desarrolladores de JavaScript experimentados están plagados de estos monstruos, porque lo que es popular no siempre es bueno.
Eventualmente, todos nos damos por vencidos por la frustración y comenzamos a reinventar las ruedas en Rust, Go, Haskell, o quién sabe qué más, y luego compilamos en Wasm para la web, y los nuevos marcos y bibliotecas web proliferan en una infinidad multilingüe.
Realmente me mantiene despierto por la noche.