Inicio / Artículos

Los verdaderos planos de JavaScript: prototipos, delegación y funciones

Por qué la clase es un “azúcar sintáctico” frente a los prototipos, cómo difiere la delegación de la herencia clásica, dónde falla esto y qué estilo funcional se adopta en JavaScript.

Editor: REACTAPP.TOP11 min de lectura2593 palabras

Fuentes: The True Blueprints to JavaScript

Olvídense de los planos rígidos orientados a objetos importados de otros lenguajes. La verdadera arquitectura de JavaScript se ha basado durante mucho tiempo en la programación funcional y la delegación por prototipos. Luchar contra esa estructura es lo que hace que el lenguaje parezca defectuoso; aprovecharla es lo que lo hace versátil.

Durante aproximadamente una década, JavaScript parecía un lenguaje OOP ordinario. Escriben class, extends, constructor y super(), y si miran con atención se asemeja a Java o C#. Esa similitud es un disfraz intencionado. Debajo de él, el entorno de ejecución hace algo completamente distinto. En el momento en que alguien olvida ese disfraz, especialmente alguien que primero aprendió OOP clásico, es cuando aparecen los errores “imposibles”. JavaScript nunca fue clásico. Ver lo que hay debajo de la sintaxis es la única forma de confiar en lo que promete la superficie.

La palabra clave class es un disfraz

Antes de ECMAScript 2015 (ES6), JavaScript no contaba con la palabra clave class. Los desarrolladores seguían escribiendo código de apariencia orientada a objetos mediante funciones constructoras y asignación manual de prototipos. Cuando apareció class, se presentó como azúcar sintáctico: una forma más amigable de expresar el mismo mecanismo, y no un nuevo modelo de herencia añadido posteriormente.

TC39, el comité de estándares, fue claro al respecto. Una class en JavaScript no es un plano impreso en las instancias de la misma manera que lo es una clase en Java. Es simplemente una función que viste un “sombrero” diferente.

Unas pocas líneas lo ilustran claramente:

class Dog {
  constructor(name) {
    this.name = name;
  }
  bark() {
    return `${this.name} says woof`;
  }
};
// Then just ask JS what Dog actually is.
console.log(typeof Dog);    // "function"

El método bark() no se encuentra en una tabla de clases abstractas, sino en Dog.prototype, exactamente donde estaría un método anterior a 2015. Conceptualmente, cada clase sigue compilándose en una función más un objeto prototipo con métodos asociados. La palabra clave cambió; la máquina no se movió.

Eso es importante porque las expectativas clásicas introducen de forma encubierta garantías que JavaScript nunca ofreció: una jerarquía fija resuelta en tiempo de compilación, campos privados por defecto y un mecanismo de envío de métodos que funciona igual sin importar cómo se llame al método. Con la sintaxis class, JavaScript proporciona una cadena de búsqueda en tiempo de ejecución, viva y mutable, que se asemeja a esas garantías hasta que surge un caso en el que esa similitud termina.

Eso no es un defecto de diseño por el que disculparse. Es un disfraz que cumple su función: permitir que una criatura diferente se mueva por una habitación construida para otra cosa. Los problemas surgen cuando el disfraz se trata como anatomía. El verdadero modelo a aprender es la delegación, no la herencia clásica.

Qué significa realmente la delegación

Delegación prototípica e herencia clásica suenan similares pero se comportan de manera diferente. En un lenguaje clásico, la estructura de subclases está fijada en el momento de la definición o durante la compilación: una instancia de Dog es estructuralmente una copia exacta de lo que define Animal.

En JavaScript, un objeto no recibe una copia. Recibe una referencia en tiempo real a otro objeto (su prototipo). Cuando falta una propiedad, el motor no falla inmediatamente; sigue la referencia y pregunta allí en su lugar. Ese proceso se denomina delegación:

  • No es: “Contiene una fotocopia del comportamiento del padre”
  • Sino es: “Pregunta al objeto padre cuando la respuesta falta localmente”

El algoritmo de búsqueda es preciso: primero se verifican las propiedades del propio objeto; si no están presentes, se sigue el enlace interno [[Prototype]] (exposido históricamente como __proto__, y de forma correcta mediante Object.getPrototypeOf()); se repite el proceso hasta que se encuentra la propiedad o la cadena termina en Object.prototype, cuyo prototipo es null. Se trata de una cadena en tiempo de ejecución que indica “preguntar al siguiente objeto”, y no de una jerarquía estática fija para siempre.

Dado que el enlace de prototipo está activo y es mutable, no se trata de una copia incorporada, puedes agregar un método a un prototipo compartido incluso después de que ya existan mil objetos, y todos lo obtendrán de inmediato. Ellos almacenan un puntero, no una fotocopia. Incluso puedes reasignar el prototipo de un objeto en tiempo de ejecución con Object.setPrototypeOf(), y las búsquedas posteriores seguirán la nueva cadena.

Los lenguajes clásicos no pueden hacer eso sin recompilación. El modelo de JavaScript no es una herencia clásica más débil; se trata de un mecanismo diferente lo suficientemente flexible como para imitar la herencia clásica cuando se desea.

Object.create() muestra la delegación sin necesidad de trucos adicionales:

const animal = {
  speak() {
    return '...';
  },
};
const dog = Object.create(animal);
// creates dog w/animal set directly as its pro

Ningún constructor, ninguna instrucción new, ni palabras clave ocultas que actúen “en tu nombre”. dog.speak() funciona porque la búsqueda delega en animal cuando dog no cuenta con su propia función speak. Ese es el mecanismo que class y extends envuelven en una sintaxis más amigable.

Dónde se rompe la ilusión

Los equipos que vienen de C# o Java suelen descubrir el problema durante la conexión de eventos o las devoluciones de llamada de temporizadores, y no durante las llamadas a métodos en el flujo normal. La filosofía del lenguaje enseña que “los métodos pertenecen a las instancias”, por lo que perder this se percibe como un error del lenguaje en lugar de una regla relacionada con el lugar donde se realiza la llamada. Documentar esta regla desde temprano evita semanas de uso indiscriminado de .bind(this) sin comprender su propósito.

También aparece presión por mantener estado privado en bibliotecas que exportan objetos “seguros” pero que aún exponen estructuras mutables. Si un usuario puede modificar un campo, las invariantes que parecían internas en realidad eran solo convencionales. Prefiera cierres de fábrica o campos # cuando sea necesario mantener esas invariantes; prefiera objetos simples cuando los datos estén destinados a ser compartidos intencionalmente.

Los tutoriales sencillos mantienen el disfraz intacto. Las costuras aparecen cuando this se utiliza con algo más complejo que llamar directamente a un método en un objeto visible. En JavaScript, this nunca estuvo vinculado a “la instancia” de la manera tradicional. Son las mecánicas del lugar de llamada las que determinan el receptor, no el ámbito léxico de la función ni el bloque de clase donde se escribió el método.

El problema al que casi todos se enfrentan: pasar un método como callback:

setTimeout(instance.greet, 1000);
// or, wiring up an event listener directly
const el = document.getElementById('btn');
el.addEventListener('click', instance.handleClick);
// NOTE: The `this` inside that function is no longer the instance
// you extracted it from.

En un lenguaje clásico como C#, un método vinculado lleva consigo su instancia. En JavaScript, un método es una función ordinaria. Al separarse del lugar de llamada que proporciona this, pierde la instancia. setTimeout y los listeners del DOM son ejemplos comunes donde ocurre esta separación. Las soluciones incluyen .bind, envoltorios de flecha o rediseñar el código para que el lugar de llamada siga proporcionando el receptor adecuado; algo que los lenguajes clásicos no obligan a considerar para los métodos ordinarios.

Un tipo de encapsulamiento más discreto es el estado privado real. Los guiones bajos en las convenciones (this._internal) representan acuerdos sociales, no mecanismos de cumplimiento obligatorio. Los campos #private modernos cierran parte de esa brecha, pero llegaron tarde y siguen coexistiendo con una cultura basada en propiedades mutables públicas. Esperar un encapsulamiento al estilo de Java por defecto genera sorpresa cuando el código externo modifica lo que parecía “interno”.

Reconstruir clases a partir de prototipos, manualmente

La forma más rápida de internalizar el modelo es reconstruir el mismo comportamiento sin utilizar class, empleando funciones constructoras comunes junto con objetos prototipo: el estilo anterior a 2015 que el motor sigue utilizando bajo su capa de abstracción. La documentación de MDN sobre las funciones básicas de scripting y los prototipos avanzados de objetos sigue siendo una referencia útil al practicar esos patrones.

// function (using the `this` keyword)
function Dog(name) {
  this.name = name;
};

// prototype (doing the same thing)
Dog.prototype.bark = function() {
  return `${this.name} says woof`;
};

// assigning a variable to a new instance
const rex = new Dog('Rex');
console.log(rex.bark());
// Output: "Rex says woof"

Caminar a través de new Dog('Rex') desmitifica una palabra clave que muchas personas utilizan durante años sin conocer los pasos: crear un nuevo objeto vacío; establecer [[Prototype]] de ese objeto en Dog.prototype; llamar a Dog con this vinculado al nuevo objeto; devolver ese objeto a menos que el constructor devuelva explícitamente otro objeto. No existe un registro oculto de clases ni un plano compilado: solo la creación de objetos, un enlace de prototipo y una llamada a función con un this específico.

Herencia sin extends se ve así; esta es la forma exacta a la que se compila extends:

// an extension of the Dog
function Puppy(name) {
  Dog.call(this, name);
};

Puppy.prototype = Object.create(Dog.prototype);
Puppy.prototype.constructor = Puppy;

// This line here doesn't work, because arrow functions can't use `this`.
// Puppy.prototype.bark = () => { return `${this.name} whines instead of barks`; };

// Use a standard function like this instead:
Puppy.prototype.bark = function() {
  return `${this.name} whines instead of barks`;
};

const buddy = new Puppy('Buddy');
console.log(buddy.bark());
// Output: "Buddy whines instead of barks"

Object.create(Dog.prototype) hace lo que automatiza extends Dog: conecta [[Prototype]] de Puppy.prototype con Dog.prototype para que las propiedades faltantes deleguen su búsqueda hacia arriba. Dog.call(this, name) realiza la misma función que super(name): ejecuta el inicializador del padre en la misma instancia. Una vez que esta conexión se establece manualmente varias veces, class deja de ser algo mágico y se convierte simplemente en una forma abreviada de un mecanismo ya conocido.

Las funciones siempre fueron el lenguaje nativo

Pensar en términos de funciones también aclara el diseño de los módulos. Exportar un puñado de funciones bien definidas desde un archivo suele ser más claro que exportar una clase cuyos métodos ignoran en su mayoría los campos de instancia compartidos. Cuando una “clase” no tiene un estado significativo por instancia, en realidad es solo un espacio de nombres disfrazado de OOP. Un módulo de funciones expresa lo mismo con menos formalidades y sorpresas relacionadas con this.

Los cierres funcionales complementan este enfoque: una fábrica puede cerrarse sobre la configuración y devolver un pequeño objeto API cuyos métodos no necesitan una jerarquía clásica. El resultado sigue pareciendo orientado a objetos en el lugar de llamada (api.save()), mientras que la implementación permanece léxica en lugar de basada en prototipos cuando eso es más adecuado.

Una vez que el disfraz clásico parece opcional, surge una pregunta más contundente:

¿Cómo es en realidad la estructura nativa de JavaScript?

La respuesta es que funcionan como valores de primera clase: se pueden pasar, almacenar y devolver; no se trata de jerarquías de clases. En ese sentido, las funciones estuvieron presentes desde el primer lanzamiento de JavaScript en 1995. Las clases aparecieron unos veinte años después. La mayor parte de la historia del lenguaje tuvo poco que ver con la POO. Si se busca la característica en torno a la cual se construyó JavaScript, nunca fue la jerarquía de objetos, sino la función.

Las influencias históricas a menudo citadas para esto incluyen:

  • Scheme — un lenguaje de la familia Lisp que enfatiza las funciones de primera clase
  • Self — un lenguaje basado en prototipos que influyó en el modelo de delegación
  • Java — cuya sintaxis superficial posteriormente hizo que el concepto de class resultara atractivo comercialmente

Los cierres surgen de forma natural de las funciones de primera clase: una función mantiene acceso al entorno léxico en el que fue creada. Esa retención es la forma en que las fábricas ocultan el estado sin campos privados clásicos, y cómo los ganchos y el middleware conservan el contexto por solicitud sin subclases.

Qué realmente te aporta la programación funcional

En la práctica, los equipos adoptan estos hábitos de forma gradual. Mantén las entradas y salidas en los extremos —red, disco, DOM— y deja que la parte central del programa se encargue de transformar datos simples. Solo esa separación elimina muchas condiciones de carrera que aparecen cuando los eventos de la interfaz modifican el mismo grafo de objetos desde múltiples manejadores.

Los auxiliares de inmutabilidad (spreads, structuredClone o bibliotecas) son tácticas; la estrategia consiste en dificultar la expresión de estados intermedios ilegales. Las uniones discriminadas y las funciones de actualización puras combinan bien con las lecciones sobre prototipos mencionadas anteriormente: los objetos siguen siendo útiles, pero las rutas de actualización se convierten en valores explícitos en lugar de escrituras silenciosas de campos.

También cambia la forma de depurar. Con actualizaciones puras se pueden registrar las entradas y salidas de una transformación para reproducir los fallos sin conexión. Con mutaciones en instancias complejas, a menudo se necesita toda una sesión de la aplicación para recrear exactamente esa situación caótica de campos que provocó un error.

Al eliminar el enfoque académico y la programación funcional, en términos prácticos eso significa crear programas principalmente a partir de funciones puras: mismo entrada, mismo salida, sin efectos secundarios fuera del valor devuelto. Los programas componen esas funciones en cadenas de transformaciones, en lugar de modificar los campos de las instancias mediante métodos con el paso del tiempo.

La primera ventaja es la posibilidad de pruebas. Una función pura no necesita procedimientos de configuración o desconfiguración, ni this simulado, ni instancias preparadas con cuidado. Se introduce una entrada y se verifica la salida. Gran parte de lo que hace que las pruebas orientadas a objetos sean inestables es la ceremonia necesaria para llevar un objeto al estado interno adecuado antes de probar el comportamiento relevante. Las funciones puras evitan esa ceremonia porque no existe un estado de instancia oculto que deba prepararse primero.

La segunda ventaja responde directamente a la sección this: el estado mutable compartido es la causa principal de la mayoría de los problemas relacionados con los enlaces en el código clásico. Un método que lee y escribe campos de instancia compartidos puede verse afectado por cualquier otra operación que haya modificado dicha instancia, en un orden que el lugar donde se llama al método no hace evidente. Una función pura no tiene estado mutable compartido, por lo que reordenar las llamadas no modifica silenciosamente su significado.

La tercera ventaja es la composibilidad. Los pequeños auxiliares puros se combinan bien: cada valor devuelto alimenta el siguiente paso, formando cadenas que permiten pruebas específicas para cada etapa. Las jerarquías de clases complejas dificultan esta recombinación, ya que el comportamiento está disperso a lo largo de cadenas de herencia y campos mutables, en lugar de estar organizado en unidades independientes que puedan probarse por separado.

Nada de esto prohíbe objetos o métodos. En su lugar, redefine cuándo son útiles: como límites y espacios de nombres, y no como el lugar por defecto donde debe residir todo comportamiento.

Punto final

Enseñar este modelo a los principiantes acorta el tiempo que transcurre desde copiar un tutorial de clases hasta comprender por qué un manejador de clics perdió su receptor. También atenúa las “guerras religiosas”: los patrones clásicos siguen estando disponibles como una forma simplificada de sintaxis cuando ayudan a clarificar los límites del dominio, mientras que los flujos diarios con arrays, reducers y hooks mantienen visible la estructura nativa del lenguaje.

Al revisar código, pregúntese si una nueva clase está modelando una identidad real con vida útil e invariantes, o simplemente agrupando funciones. Si es lo segundo, un módulo de funciones —o una única fábrica— suele comunicarse mejor. Si es lo primero, mantenga la clase, pero recuerde que sigue basándose en prototipos y en el this del lugar de llamada, y escriba pruebas que ejerzan callbacks independientes cuando esos lugares de llamada existan en producción.

Nada de esto es teórico en el JavaScript cotidiano. Los métodos de array como map, filter y reduce son herramientas funcionales y se encuentran entre las APIs más utilizadas en este lenguaje. El paso de React de componentes basados en clases a componentes funcionales y Hooks, comenzando con React 16.8 y continuando en versiones posteriores, representó un cambio intencional para tratar a los componentes como funciones puras de props y estado, lo cual es más fácil de comprender que un componente basado en clases que maneja métodos de ciclo de vida y campos mutables.

Los reducers de Redux siguen un contrato de pureza establecido en el tutorial oficial sobre fundamentos relacionados con el estado, las acciones y los reducers: las entradas de estado y acciones idénticas deben generar el mismo estado siguiente sin modificar el anterior en su lugar, y por eso es posible depurar mediante viajes en el tiempo. Consulte la guía de fundamentos de Redux para encontrar la redacción canónica. Muchos equipos también prefieren almacenes más ligeros como Zustand para el estado de la aplicación; las mismas prácticas de pureza siguen siendo aplicables siempre que las actualizaciones deban mantenerse predecibles.

Los pipelines de procesamiento de flujos y eventos —bibliotecas que componen pequeñas transformaciones sobre secuencias continuas de eventos— son una forma de programación funcional aplicada a uno de los problemas más complejos del frontend: la entrada de datos asincrónica y continua por parte del usuario.

La conclusión: JavaScript nunca fue clásico. Los patrones de POO aún pueden ser útiles en ciertos casos, pero los fundamentos nativos siempre fueron funcionales. Durante dos décadas el lenguaje llevó un disfraz que lo hacía parecer distinto, lo suficientemente convincente como para que toda una generación aprendiera a través de ese disfraz, incluyendo sus errores. Debajo de él, siguió siendo un lenguaje basado en funciones y delegación: más parecido a Scheme con una “chaqueta” de Java que al propio Java, lo cual significa que el estilo funcional en JavaScript nunca fue una influencia externa. Siempre fue su acento nativo.

Dicho de otra manera: aprende el lenguaje de marcado para que la colaboración sea sencilla, y aprende la anatomía del código para que los errores en la producción tengan sentido. Los equipos que solo memorizan la sintaxis de class siguen descubriendo callbacks desconectados y mutaciones inesperadas en los prototipos. Los equipos que también practican Object.create, funciones constructoras y actualizaciones puros pasan menos tiempo discutiendo con el entorno de ejecución y más tiempo dando forma a los datos del dominio.

Con esa base, class se convierte en un dialecto opcional en lugar de la definición del lenguaje, y la depuración comienza desde los puntos de llamada y los enlaces de prototipo, en lugar de partir de suposiciones provenientes de entornos de ejecución tradicionales.

Los revisores que solicitan esa distinción desde el principio evitan la creación accidental de objetos especiales disfrazados de modelos de dominio.

Lecturas relacionadas