El panel de scope: leer los closures que no sabías que tenías
Los siete tipos de ámbito que puede mostrar el depurador, qué significa cada uno, y cómo el panel revela exactamente qué está capturando cada función.
El panel de scope es la ventana más directa a un mecanismo del lenguaje que casi todo el mundo usa a diario y muy poca gente ha visto: la cadena de ámbitos. Cuando estás detenido en una función, el panel enseña exactamente qué variables tiene accesibles, de dónde viene cada una, y qué está reteniendo cada closure. Leerlo con atención resuelve una familia entera de bugs —los del valor capturado que no era el que esperabas— y explica de paso por qué ciertas fugas de memoria son inevitables.
- Identificar los tipos de ámbito que muestra el panel y qué contiene cada uno.
- Leer el contenido de un closure y determinar qué variable está capturando.
- Modificar el valor de una variable en vivo para probar una hipótesis sin recargar.
- Diagnosticar el caso del valor capturado obsoleto.
Los tipos de ámbito
El panel lista los ámbitos de más cercano a más lejano, y el orden es exactamente el que el motor recorre al resolver un identificador.
Local. Los parámetros y las variables declaradas en la función actual, más this y arguments si aplica. Es el que se mira siempre.
Block. Las variables declaradas con let y const dentro de un bloque —un if, un for, un bloque suelto— cuando estás dentro de ese bloque. Aparece como ámbito separado del local porque lo es, y esa separación es la diferencia visible entre var y las declaraciones de bloque.
Closure. Uno por cada función envolvente que aporte variables capturadas. El panel los nombra con el nombre de esa función, lo que permite saber de qué nivel viene cada valor. Es el más informativo de todos.
Script. Las variables del ámbito de módulo o de script en el que vive la función. En un módulo ES, aquí están las importaciones y las declaraciones de nivel superior.
Module. En algunos casos aparece separado, mostrando explícitamente el espacio de nombres del módulo.
Catch. Cuando estás dentro de un bloque catch, el parámetro de la excepción vive en su propio ámbito.
Global. El objeto global con todo lo que cuelga de él. Es enorme y rara vez interesa.
El motor solo conserva en un closure las variables que la función usa de verdad. Si una función anidada solo referencia una de las quince variables de su envolvente, el panel mostrará solo esa. Esa optimización explica por qué a veces buscas una variable en el closure y no está: no es que el depurador la esconda, es que la función no la captura.
Leer un closure
El caso donde el panel resuelve un bug entero es el del valor capturado obsoleto, y merece verse con un ejemplo que se puede reproducir.
// Este codigo funciona pegado en la consola y produce el bug clasico
function crearContadores() {
const salida = [];
for (var i = 0; i < 3; i++) {
salida.push(function () { return i; });
}
return salida;
}
const fns = crearContadores();
console.log(fns.map(f => f())); // [3, 3, 3], no [0, 1, 2]
Poniendo un breakpoint dentro de la función devuelta y ejecutando fns[0](), el panel de scope muestra un closure de crearContadores con i valiendo 3. Ahí está la explicación completa y visible: las tres funciones comparten el mismo ámbito, porque var declara una variable por función y no por iteración.
Cambiando var por let, el panel muestra tres closures distintos con valores distintos, porque let crea una ligadura nueva en cada vuelta. La diferencia entre las dos versiones del lenguaje deja de ser una regla que memorizar y pasa a ser algo que se ve.
El mismo mecanismo explica la clase de bug más frecuente con efectos en frameworks modernos: una función registrada en un efecto captura el valor de un estado en el momento del registro, y si el efecto no se vuelve a registrar cuando ese estado cambia, la función sigue viendo el valor antiguo para siempre. El panel de scope lo enseña en un vistazo: el closure contiene el valor viejo mientras la interfaz muestra el nuevo.
Editar valores en vivo
Los valores del ámbito local se pueden modificar: doble clic sobre el valor, escribir otro, confirmar. La ejecución continúa con el valor nuevo.
Es una herramienta de contraste de hipótesis con ciclo de un segundo, exactamente igual que editar CSS en el panel de estilos, y con las mismas reglas: una variable por experimento, y recargar antes de creerse el resultado.
Los usos que más rinden.
Forzar una rama. Estás detenido antes de un if y quieres ver qué pasa por el otro camino sin reproducir las condiciones que lo provocan. Cambias el valor y continúas.
Sanear un dato malo. Has descubierto que una respuesta llega con un campo null. Lo cambias por un valor válido y continúas para comprobar si el resto del flujo funciona. Si funciona, el bug está solo en el origen del dato; si sigue fallando, tienes dos problemas.
Provocar un caso límite. Cambiar un array a vacío, un número a cero, una cadena a muy larga, para ver si el código de abajo lo aguanta.
Los límites: no se pueden modificar las variables de un closure ni las de ámbito de script, solo las locales del marco actual. Y una constante declarada con const tampoco.
Reconstruir el flujo con el scope y la pila
Combinar este panel con el de pila de llamadas es lo que convierte una parada en un diagnóstico. El procedimiento, sobre cualquier bug de valores.
Detente donde el valor ya es incorrecto. Anótalo.
Sube un marco en la pila. Mira el mismo valor —o el argumento que lo produjo— en ese contexto. ¿Ya estaba mal?
Repite subiendo hasta encontrar el marco donde el valor todavía era correcto. La función que hay entre ese marco y el siguiente es la que lo estropeó.
Con la bisección sobre la pila, esto son tres o cuatro comprobaciones para una pila de dieciséis marcos, y todas se hacen sin recargar y sin poner ni un breakpoint más.
Hay una lectura de este panel que va más allá de la depuración de valores y que explica una de las causas de fuga más difíciles de encontrar. Un closure mantiene vivo todo lo que captura, y lo mantiene vivo tanto tiempo como viva la función. Si registras un manejador de eventos que captura una variable, y esa variable referencia un objeto grande —un componente entero, un array de diez mil elementos, un nodo del DOM con todo su subárbol— ese objeto no se puede recoger mientras el manejador siga registrado, aunque el resto de la aplicación se haya olvidado de él por completo. El panel de scope hace esto visible de una forma que ninguna otra herramienta consigue: al detenerte dentro de un callback y expandir sus closures, estás viendo literalmente la lista de todo lo que ese callback está impidiendo que se libere. Y hay un matiz que sorprende a mucha gente y que conviene conocer porque es contraintuitivo: el motor conserva las variables que la función usa, pero la granularidad no siempre es la que esperarías. Dos funciones definidas en el mismo ámbito comparten el registro de ese ámbito, así que si una de las dos captura un objeto enorme y la otra no captura nada, la segunda puede acabar manteniendo vivo el ámbito compartido de todas formas, y con él el objeto enorme. Ese es un patrón de fuga real y muy difícil de encontrar sin entender el mecanismo, porque la función que aparece reteniendo el objeto en el análisis de memoria no menciona ese objeto por ninguna parte. Las dos consecuencias prácticas: cuando persigas una fuga y el retenedor sea un contexto de función, el sospechoso no es solo la función que ves sino todas las que se declararon junto a ella; y cuando escribas código de larga vida, extrae a una variable local lo que necesites y suelta la referencia grande antes de crear el callback, que es el patrón que corta la captura en origen.