Inicio / Artículos / Cómo resuelve realmente la cadena de ámbitos de JavaScript las variables

Cómo resuelve realmente la cadena de ámbitos de JavaScript las variables

Explica el mecanismo de cadena de ámbito léxico detrás de la búsqueda de variables, por qué var causa errores en bucles y cómo las cierres surgen naturalmente de ello.

1323 palabras

La búsqueda de variables no tiene que ver con los paréntesis curvos

La forma típica en que la gente aprende por primera vez sobre el ámbito es diciendo “el ámbito es todo lo que está dentro de los paréntesis”. Esa descripción sirve para pasar un cuestionario, pero no te ayudará a predecir qué hace realmente un fragmento de código. El mecanismo real es más mecánico y predecible de lo que sugiere esa abreviatura, y una vez que lo entiendas, los cierres dejarán de parecer magia.

El ámbito está determinado por dónde escribes el código, no por cómo se ejecuta

JavaScript resuelve las variables de forma léxica. Eso significa que el ámbito de una variable está fijado según su ubicación física en el archivo fuente en el momento en que se escribe, y no según qué función llama a otra función mientras el programa está en ejecución.

const value = "outer";

function readValue() {
  console.log(value);
}

function runWithDifferentValue() {
  const value = "inner";
  readValue(); // still logs "outer", not "inner"
}

runWithDifferentValue();

readValue no tiene en cuenta quién lo invoca ni qué variables existen en el entorno del llamante. Lo único que le importa es dónde fue definido físicamente, justo al lado de const value = „outer“. Ese es el único value que podrá ver, sin importar desde dónde se le invoque en el programa. Por lo general, aquí es donde los desarrolladores provenientes de lenguajes con ámbito dinámico (o, en realidad, aquellos que nunca han tenido que pensar en esto, ya que el ámbito léxico es la norma casi en todas partes) se confunden. El ámbito está integrado en la estructura del código desde el momento en que se escribe; no cambia según la pila de llamadas en tiempo de ejecución.

El verdadero mecanismo de búsqueda: recorriendo la cadena de ámbitos

Cuando el motor necesita resolver una referencia a una variable, no busca en toda la base de código. Comienza exactamente en el punto donde se utiliza la variable y avanza hacia afuera, uno por uno los ámbitos anidados, deteniéndose en cuanto encuentra una coincidencia:

const a = "global";

function outer() {
  const b = "outer";

  function inner() {
    const c = "inner";
    console.log(a, b, c); // "global outer inner"
  }

  inner();
}

outer();

inner primero busca c y lo encuentra allí mismo, sin necesidad de buscar más. Luego busca b; como no está definido localmente, la búsqueda se dirige al ámbito de outer, donde sí se encuentra. A continuación busca a, que tampoco está ni en inner ni en outer, por lo que la búsqueda continúa avanzando hasta llegar al ámbito global, donde finalmente se localiza. Esa secuencia: el ámbito interno, luego el ámbito que lo contiene y así sucesivamente hasta el global, constituye todo el algoritmo de búsqueda. No hay nada más complejo que “mirar aquí, luego buscar un nivel más arriba y repetir hasta encontrarlo”.

Este mismo mecanismo explica el sombreado de variables sin necesidad de una regla separada. Si inner declarara su propio const b, la búsqueda se detendría en el momento en que encontrara ese b local y nunca llegaría siquiera al b definido en outer. En este escenario nada se sobrescribe; simplemente se encuentra primero la coincidencia más cercana, por lo que no hay razón para continuar con la búsqueda.

Por qué var se comporta de manera diferente y por qué eso causa un error bien conocido

let y const se vinculan al bloque contenedor más cercano, es decir, a cualquier par de llaves, ya sea en una instrucción if o en el cuerpo de un bucle. var, por su parte, no sigue esa regla en absoluto. En cambio, var se vincula a la función contenedor más cercana, ignorando por completo los límites de los bloques.

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// logs: 3, 3, 3

En este fragmento hay exactamente una variable i, limitada al ámbito de la función, y cada iteración del bucle comparte esa misma variable. Para cuando se ejecutan los callbacks de setTimeout, el bucle ya ha finalizado y i ya ha alcanzado el valor 3. Los tres callbacks hacen referencia a la misma variable en lugar de tener cada uno su propia copia, por lo que todos muestran el valor final al que llegó.

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// logs: 0, 1, 2

Dado que let tiene ámbito de bloque, y porque el lenguaje crea específicamente un nuevo enlace para i en cada iteración del bucle, cada función de callback termina teniendo acceso a su propio i distinto, congelado en el valor que tenía durante esa iteración en particular. El código parece casi idéntico al ejemplo anterior, pero la regla de ámbito subyacente es diferente, lo que produce un resultado mucho más predecible.

Las cierres no son una característica separada, sino solo una consecuencia de la cadena de ámbitos

Una vez que comprendas cómo funciona la cadena de ámbitos, las clausuras prácticamente no necesitan explicación adicional. Una clausura no es algún mecanismo extra añadido sobre el ámbito; simplemente es lo que ocurre de forma natural cada vez que defines una función dentro de otra, y esa función interna luego se utiliza en algún lugar después de que el ámbito externo hubiera sido normalmente descartado.

function debounce(fn, delayMs) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn(...args), delayMs);
  };
}

const debouncedSearch = debounce((query) => runSearch(query), 300);

debounce se ejecuta exactamente una vez. Si estás acostumbrado a lenguajes sin cierres, podrías pensar que timeoutId debería desaparecer en cuanto debounce termine de ejecutarse. Sin embargo, no desaparece, porque la función debounce que se devuelve fue definida dentro de ella, lo que significa que la cadena de ámbitos de esa función devuelta incluye permanentemente el propio ámbito de debounce, con timeoutId incluido. Cada llamada posterior a debouncedSearch, sin importar cuán tarde ocurra, recorre esa misma cadena de ámbitos hasta llegar al mismo timeoutId. Esa es precisamente la razón por la que funciona la lógica de debounce: se necesita una variable única y persistente para rastrear el tiempo de espera pendiente en cada llamada, y el cierre es lo que garantiza eso.

La misma característica que permite los cierres también puede causar fugas de memoria

Lo que hace útiles las clausuras es exactamente la misma propiedad que genera un problema específico y recurrente: una clausura conserva todo su ámbito circundante, no solo las variables a las que hace referencia en realidad, y mantiene una referencia activa a la variable en sí, en lugar de una copia congelada de su valor en el momento en que se creó la clausura.

function createHandlers() {
  let clickCount = 0;
  const massiveDataset = loadHugeArray(); // large, no longer needed after setup

  return {
    onClick: () => {
      clickCount++; // only this variable is actually used
      console.log(clickCount);
    },
  };
}

onClick captura todo el ámbito al que pertenece createHandlers, incluido massiveDataset, aunque onClick en realidad nunca lo lee. Mientras onClick siga activo, todo lo demás que haya capturado también permanecerá activo, lo cual representa un problema real de memoria, aunque generalmente menor, cada vez que una cierre de largo plazo acaba albergando referencias a datos grandes o no necesarios. Y como la cierre contiene la variable real en lugar de un valor copiado, siempre muestra el estado actual y actualizado. Esa es la misma regla subyacente que hizo que el ejemplo de debounce funcionara correctamente y que hiciera que el bucle var imprimiera 3, 3, 3; se trata de un mecanismo único que en un contexto aparece como una característica útil y en otro como un error.

Comprender el mecanismo es mejor que memorizar la definición

La definición del libro de texto “un cierre es una función emparejada con su entorno léxico” es técnicamente correcta, pero rara vez se comprende hasta que no se ha trazado manualmente la cadena de ámbito varias veces y se ha observado con exactitud dónde cesa la búsqueda. Una vez que trazar esa cadena se vuelve algo natural, los cierres dejan de requerir una explicación especial. Son simplemente una consecuencia habitual de cómo siempre funcionó el ámbito, aplicada a una función que, por casualidad, sobrevive al entorno en el que fue creada.

Lecturas relacionadas

  • React 19.2 explicado: Activity, useEffectEvent y renderizado estático — Aprenda cómo el nuevo componente Activity, el hook useEffectEvent y el renderizado estático parcial de React 19.2 corrigen los costos ocultos de rendimiento en las interfaces de usuario modernas.