Inicio / Artículos

Explicando JavaScript del navegador en entrevistas: colas, montón, fugas de memoria, cierres

Un recorrido estructurado por el entorno de ejecución del navegador para la preparación de entrevistas: pila de llamadas, colas de tareas y microtareas, heap y recolección de basura, fugas de memoria, delegación, prototipos y cierres.

Editor: REACTAPP.TOP10 min de lectura2346 palabras

Fuentes: #1 FrontEnd Concepts For Interviews — Javascript Fundament

Las entrevistas de front-end rara vez valoran las frases memorizadas. Los entrevistadores buscan saber si puedes explicar qué hace el motor con tu código: dónde espera una función de callback, por qué un objeto sobrevive a su función, por qué un único listener en un contenedor puede reemplazar a cientos. Esta guía aborda esos conceptos fundamentales en el orden en que se construyen unos sobre otros, para que puedas razonar ante una pregunta desconocida en lugar de recitar una respuesta. Una buena autoevaluación: después de cada sección, explica la idea en voz alta como si lo hicieras a un colega que nunca haya oído hablar de ella.

La pila de llamadas: dónde se ejecuta tu código

Cada motor de JavaScript ejecuta código en una pila de llamadas, una estructura de tipo último en entrar, primero en salir que se puede imaginar como un array donde solo se realizan operaciones de inserción y extracción en los extremos. Al llamar a una función se inserta un marco para su contexto de ejecución; al devolverse, este se elimina. Solo existe una pila de este tipo en el hilo principal, por lo que mientras un marco está en ejecución, nada más puede hacerse. Un bucle síncrono largo congela la renderización y las entradas hasta que finaliza.

Por qué una solicitud de red no congela la página

Si solo se ejecuta una cosa a la vez, ¿por qué la página no se detiene mientras espera al servidor? Porque no es JavaScript el que espera. Imagine una pantalla que procesa un gran conjunto de datos y luego llama a varias APIs en secuencia. Al llamar a fetch() se transfiere el trabajo a una API web proporcionada por el navegador, la cual maneja las operaciones de red fuera de la pila. La función devuelve inmediatamente una promesa pendiente, y el usuario sigue interactuando.

Cuando llega la respuesta, la promesa se resuelve y sus callbacks .then() se programan como microtareas, las cuales se ejecutan una vez que la pila está vacía. Estrictamente hablando, la finalización de la red se transmite a través del mecanismo de tareas del navegador, y lo que entra en la cola de microtareas son las reacciones de la promesa. El punto importante a nivel de entrevista es que tu continuación se coloca en cola, no se ejecuta directamente.

Cola de tareas versus cola de microtareas

Los temporizadores siguen un camino diferente: el callback de setTimeout() va a la cola de tareas, también conocida como cola de macrotareas. La regla del bucle de eventos es que, después de que la tarea actual finalice y la pila esté vacía, se procesan todas las microtareas antes de seleccionar la siguiente tarea. Esta función programa una de cada tipo; los números en los mensajes indican el orden.

function triggerBothQueues() {
  console.log("1. Synchronous: Code starts on Call Stack");

  // Enqueue a Macrotask (Task Queue)
  setTimeout(() => {
    console.log("4. Task Queue (Macrotask): setTimeout callback executed");
  }, 0);

  // Enqueue a Microtask (Microtask Queue)
  Promise.resolve().then(() => {
    console.log("3. Microtask Queue: Promise.then callback executed");
  });

  console.log("2. Synchronous: Code ends on Call Stack");
}

triggerBothQueues();

Las dos líneas síncronas se imprimen primero, luego la función de callback de la promesa y finalmente el temporizador. Un retraso de 0 significa “colocar en la cola lo antes posible”, no “ejecutar ahora”, y el microtask sigue teniendo prioridad. Una consecuencia común: un microtask que programa otro microtask se une a la misma cola y se ejecuta antes que cualquier temporizador, por lo que una cadena interminable de microtasks puede obstaculizar el renderizado.

El heap: donde viven los objetos

La pila almacena los contextos de ejecución; el montón contiene los datos a los que apuntan. Los objetos, arrays, funciones y cualquier elemento manejado por referencia se encuentran en el montón, mientras que las primitivas locales como los números y los booleanos, conceptualmente, se mantienen junto con el contexto de ejecución de la función. Diga en una entrevista que esto es una simplificación: los motores suelen asignar las cadenas en el montón y pueden compartirlas, y una primitiva almacenada como propiedad de un objeto se encuentra en el montón junto con ese objeto. Este modelo es una ayuda mental, no una especificación.

Recolección de basura y alcanzabilidad

El montón no es infinito. Un recolector de basura en segundo plano recupera la memoria utilizando un principio llamado alcanzabilidad:

  • Raíces son alcanzables por definición: variables globales, además de los parámetros y variables locales de las funciones que se encuentran actualmente en la pila.
  • Valores accesibles son todo aquello a lo que se puede llegar siguiendo referencias desde una raíz.
  • Cualquier cosa a la que no se pueda acceder desde ninguna raíz es basura. Al recolector no le importa si aún tiene la intención de usar un objeto; si existe una referencia accesible, este permanece. Así es como ocurren las fugas de memoria. Para un análisis más profundo centrado en herramientas, consulte cazando fugas de memoria en JavaScript con accesibilidad y retentores.

    Dos formas clásicas de causar fugas de memoria

    Un temporizador que nunca se detiene

    En este rastreador de panel de control, se crea un array grande dentro de una función y un callback de intervalo lee su longitud cada segundo.

    function startDashboardTracker() {
      const hugeDataPayload = new Array(1000000).fill("data");
    
      // Timer is launched
      setInterval(() => {
        // Because hugeDataPayload is referenced here,
        // it stays trapped in memory forever!
        console.log("Tracker pinging... Payload length:", hugeDataPayload.length);
      }, 1000);
    }
    
    startDashboardTracker();
    // Even after startDashboardTracker finishes executing and leaves the Call Stack,
    // hugeDataPayload CANNOT be garbage collected because the timer's closure holds a reference to it.
    

    Cuando startDashboardTracker devuelve un valor, podrías pensar que el array se vuelve inútil. Pero no es así: el navegador mantiene el intervalo activo, dicho intervalo conserva su función de callback, y la cierre de esa función contiene hugeDataPayload. Guarda el ID devuelto por setInterval() y llama a clearInterval() cuando el trabajo esté terminado o cuando un componente de interfaz se desmonte; al romper ese primer vínculo se libera toda la cadena.

    Un listener en un objeto global

    Este caso es más sutil porque no parece que esté ejecutándose nada. Un widget de búsqueda crea un índice grande y registra un manejador de cambio de tamaño en window.

    function setupSearchWidget() {
      // Heavy data tied to the widget scope
      const heavySearchIndex = new Array(1000000).fill("search_index_item");
    
      function handleResize() {
        console.log("Window resized. Index size:", heavySearchIndex.length);
      }
    
      // Listener attached to the global 'window' object (a Root)
      window.addEventListener("resize", handleResize);
    }
    
    setupSearchWidget();
    
    // Later, user navigates away and the Search Widget DOM element is deleted.
    // BUT 'window' still holds a reference to 'handleResize',
    // which holds a reference to 'heavySearchIndex'.
    

    Cuando el usuario se aleja y el DOM del widget es eliminado, window, que es la raíz, sigue teniendo registrado handleResize, el cual hace referencia a heavySearchIndex. Llame a removeEventListener cuando el widget sea desmontado, pasando la misma referencia de función que registró; una función flecha nueva es un objeto distinto y no eliminará nada.

    Burbujeo de eventos y delegación

    React y Angular ocultan gran parte de esta gestión. Una razón es la delegación de eventos: en lugar de tener un listener en cada nodo, React escucha en el contenedor raíz y envía los eventos a sus manejadores a medida que estos burbujean hacia arriba, y su sistema de eventos sintéticos conecta y desconecta los manejadores según los componentes se monten o desmonten.

    La delegación se basa en el mecanismo de propagación: un clic se envía al elemento objetivo y luego viaja hacia arriba a través de cada ancestro. Esta página contiene tres cajas de colores, cada una con un receptor de clics.

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Event Bubbling Example</title>
        <style>
            #grandparent {
                background-color: lightcoral;
                padding: 20px;
            }
            #parent {
                background-color: lightblue;
                padding: 20px;
            }
            #child {
                background-color: lightgreen;
                padding: 20px;
            }
        </style>
    </head>
    <body>
    
    <div id="grandparent">
        <h1>Grandparent - only one event listener</h1>
        <div id="parent">
            Parent
            <div id="child">
                Child
            </div>
        </div>
    </div>
    
    <script>
        document.getElementById('grandparent').addEventListener('click', function(event) {
            console.log('Grandparent clicked!');
            console.log('target: ', event.target.getAttribute("id"));
            // if (event.target.getAttribute("id") === 'child') {
            //     //
            // }
        });
    
        document.getElementById('parent').addEventListener('click', function(event) {
            console.log('Parent clicked!');
            // event.stopPropagation();
        });
    
        document.getElementById('child').addEventListener('click', function(event) {
            console.log('Child clicked!');
            // event.stopPropagation();
        });
    </script>
    
    </body>
    </html>
    

    Al hacer clic en la caja más interna, se registra el elemento hijo, luego el padre y finalmente el abuelo. El abuelo lee event.target para determinar qué elemento se hizo clic realmente, y el if comentado indica cómo ese único receptor de eventos podría diferenciarse según el ID, de modo que los elementos hijos no necesiten uno propio. Eso es delegación en miniatura: un único receptor de eventos en la parte superior del árbol cubre a todos los descendientes, incluidos aquellos que se añadan posteriormente, ocupando solo una fracción de la memoria.

    Las llamadas comentadas a event.stopPropagation() muestran un control opuesto: al estar habilitadas en el elemento hijo, detienen el evento antes de que llegue a los ancestros (véanse las documentaciones de stopPropagation). Úselas con moderación, ya que también impiden el funcionamiento de los manejadores delegados en niveles superiores, incluido el oyente raíz del framework. Vale la pena experimentar directamente con este archivo; el uso sencillo de addEventListener debería resultar familiar antes que las abstracciones propias de los frameworks.

    Programación funcional en resumen

    La programación funcional (PF) a menudo se representa como una línea de montaje formada por máquinas aisladas: cada una recibe una entrada, genera una salida y no afecta a nada más. Mientras que el código orientado a objetos tiende a mutar el estado de los objetos, la PF evita dichas mutaciones y se basa en cinco principios:

    1. Funciones puras: la salida depende únicamente de la entrada, sin efectos secundarios externos.
  • Inmutabilidad: las transformaciones devuelven nuevos valores en lugar de modificar los antiguos.
  • Funciones de primera clase: las funciones son valores que se pueden almacenar y pasar de un lugar a otro.
  • Funciones de orden superior: funciones que aceptan o devuelven otras funciones, lo que permite su composición (vea esta visión general de las funciones de orden superior).
  • Estilo declarativo: se describe el resultado deseado, por ejemplo con map y filter, en lugar de gestionar contadores y estado.
  • Clases y prototipos

    Desde ES2022, las clases admiten campos privados reales con el prefijo #. Aquí #balance solo es accesible dentro del cuerpo de la clase.

    // 1. PURE JAVASCRIPT (Native ES2022+)
    class BankAccount {
      #balance = 0; // Native JS private field (using #)
    
      constructor(owner, initialBalance) {
        this.owner = owner;
        this.#balance = initialBalance;
      }
    
      deposit(amount) {
        this.#balance += amount;
      }
    }
    
    const account = new BankAccount("Alex", 1000);
    // account.#balance; ❌ Uncaught SyntaxError (Hard runtime privacy)
    

    Leer account.#balance desde el exterior genera un SyntaxError; por lo tanto, la privacidad está garantizada por el lenguaje y no por convención.

    Aún así, JavaScript utiliza la herencia por prototipos: los objetos delegan las búsquedas de propiedades a un prototipo. Antes de ES6, las estructuras similares a clases se creaban manualmente con funciones constructoras, como en esta versión de la cuenta con una subclase de ahorros.

    // 1. BASE CONSTRUCTOR FUNCTION (Parent Class)
    function BankAccount(owner, initialBalance) {
      this.owner = owner; // Public property
    
      // PRIVATE VARIABLE (Data Privacy via Closure)
      // Lives in function scope, completely hidden from the outside world!
      var balance = initialBalance;
    
      // Public method privileged to access the private 'balance' variable
      this.deposit = function (amount) {
        balance += amount;
        this._logTransaction("Deposited quot; + amount + ". New Balance: quot; + balance);
      };
    }
    
    // PROTECTED / SHARED METHODS (Prototypal Inheritance)
    // Attached to the prototype so methods aren't re-created for every single instance
    BankAccount.prototype._logTransaction = function (action) {
      console.log("[" + this.owner + "]: " + action);
    };
    
    // 2. DERIVED CONSTRUCTOR FUNCTION (Child Class)
    function SavingsAccount(owner, initialBalance, interestRate) {
      // Call Parent Constructor with 'this' context (Super Call)
      BankAccount.call(this, owner, initialBalance);
    
      this.interestRate = interestRate;
    }
    
    // Inherit parent prototype methods on the child
    SavingsAccount.prototype = Object.create(BankAccount.prototype);
    SavingsAccount.prototype.constructor = SavingsAccount; // Reset constructor reference
    
    // Child Public Method
    SavingsAccount.prototype.addInterest = function () {
      // Calls the inherited prototype method
      this._logTransaction("Applied " + this.interestRate + "% interest");
    };
    
    // 3. USAGE
    var mySavings = new SavingsAccount("Alex", 1000, 5);
    mySavings.deposit(200);   // -> [Alex]: Deposited $200. New Balance: $1200
    mySavings.addInterest();  // -> [Alex]: Applied 5% interest
    

    Obsérvese que balance es privado gracias a un cierre: se encuentra en el ámbito del constructor y solo deposit puede acceder a él. _logTransaction está en el prototipo, por lo que todas las instancias comparten la misma función; su guion bajo indica “protegido” únicamente por convención. BankAccount.call(this, ...) es el equivalente manual a super(), y Object.create vincula los prototipos antes de que se restaure la propiedad constructor sobrescrita.

    El código moderno de front-end prefiere la composición sobre la herencia en cualquier caso. Si deseas controles más estrictos de POO como modificadores de acceso e interfaces, TypeScript es la opción más adecuada; esta explicación más extensa profundiza aún más en el tema.

    Cierres

    Hay mucho más por explorar, desde Canvas y otras API del navegador hasta módulos, iteradores y generadores, pero los cierres aparecen en casi todas las entrevistas y ya los has visto en los ejemplos de fugas de memoria. Un cierre es una función asociada al entorno léxico en el que fue creada. Dado que la función interna mantiene una referencia a ese entorno, puede seguir leyendo y actualizando las variables de la función externa mucho tiempo después de que esta haya finalizado su ejecución.

    function createCounter() {
      let count = 0; // Outer variable
    
      return function() {
        count++; // Inner function accesses 'count'
        return count;
      };
    }
    
    const counter = createCounter();
    
    console.log(counter()); // 1
    console.log(counter()); // 2
    console.log(counter()); // 3
    

    count persiste entre llamadas porque la función devuelta mantiene su entorno accesible, lo cual está directamente relacionado con las reglas de recolección de basura. Cada llamada a createCounter genera un contador independiente.

    Un cierre práctico: debounce

    Un uso realista es el debounce: ejecutar una función solo una vez que las llamadas hayan cesado durante un determinado retraso, como en la función de búsqueda al escribir.

    function debounce(func, delay = 300) {
      let timer; // 1. The closure variable that holds the timeout reference
    
      return function (...args) {
        // 2. If the function is called again before the delay finishes,
        // cancel the previous scheduled run
        clearTimeout(timer);
    
        // 3. Schedule a new run after the delay
        timer = setTimeout(() => {
          func.apply(this, args);
        }, delay);
      };
    }
    
    // --- Usage Example ---
    
    function handleSearch(query) {
      console.log(`Searching API for: ${query}`);
    }
    
    // Wrap our search function in the debounce function
    const debouncedSearch = debounce(handleSearch, 500);
    
    // Simulating fast typing into an input field:
    debouncedSearch("a");    // Timer starts...
    debouncedSearch("ap");   // Cancels "a", resets timer...
    debouncedSearch("app");  // Cancels "ap", resets timer...
    debouncedSearch("apple");// Cancels "app", resets timer...
    
    // Only "Searching API for: apple" will log to the console
    // 500ms after the last keypress!
    

    El timer cerrado representa el estado. Cada llamada borra el tiempo de espera pendiente y programa uno nuevo, por lo que solo se ejecuta la última llamada en una secuencia rápida: al escribir “a”, “ap”, “app” o “apple” seguidamente, se realiza una búsqueda de “apple” 500 ms después del último tecleo. func.apply(this, args) transmite tanto los argumentos como el contexto de llamada. Para casos de cancelación y situaciones especiales, consulte cómo crear una utilidad de debounce desde cero.

    Puntos clave

    • Una pila de llamadas ejecuta su código; las API del navegador se encargan de la espera y devuelven los resultados a través de colas.
    • Todas las microtareas pendientes se ejecutan antes de la siguiente tarea, como una devolución de llamada de un temporizador.
    • El recolector conserva todo lo que es accesible desde la raíz, ya sea necesario o no.
  • Los intervalos y oyentes no eliminados en variables globales son fugas clásicas; límpielos con las referencias originales.
  • Bubbling permite la delegación, que es la forma en que los frameworks manejan los eventos de manera eficiente.
  • Las clases son una sintaxis sobre prototipos, y los cierres explican el estado privado, la función debounce y muchas fugas.
  • Lecturas relacionadas