Proceso de Ejecución de JavaScript

El Flujo de Ejecución de JavaScript

Comprender cómo JavaScript ejecuta el código es fundamental para cualquier desarrollador. Este artículo profundiza en los mecanismos internos del motor JavaScript, cubriendo el análisis, la fase de compilación (conocida como precompilación en ciertos contextos) y la ejecución del código, incluyendo la gestión de tareas síncronas y asíncronas, y cómo se determina el valor de this.

  1. Fases de Ejecución del Código JavaScript

Cuando un motor JavaScript carga un bloque de código (ya sea desde un archivo externo o incrustado en el HTML), sigue un proceso estructurado que se puede dividir en tres fases principales que se repiten para cada bloque de script en el mismo ámbito global.

1.1 Análisis Sintáctico

En esta primera fase, el motor JavaScript examina el código para asegurar que cumple con las reglas gramaticales del lenguaje. Si se detecta cualquier violación, como una sintaxis incorrecta, se lanzará un SyntaxError, impidiendo la ejecución del script. Si la sintaxis es válida, el proceso continúa a la siguiente fase.

1.2 Fase de Compilación (Precompilación)

Esta fase prepara el entorno para la ejecución del código. Cada vez que el motor JavaScript entra en un nuevo ámbito de ejecución (global o de función), se crea un "Contexto de Ejecución" (Execution Context) asociado. Estos contextos se gestionan en una estrcutura de datos tipo pila conocida como la "Pila de Llamadas" (Call Stack). El contexto de ejecución global siempre reside en la base de la pila, y el contexto actual (el que se está ejecutando) siempre está en la parte superior.

Ejemplo de Pila de Llamadas

function calcularSuma(num2) {
  let num1 = 10;
  return num1 + num2;
}

function procesarDatos(valorInicial) {
  let factor = 5;
  return calcularSuma(factor + valorInicial);
}

console.log(procesarDatos(8));

/*
1. El código entra en el entorno global. Se crea el Contexto de Ejecución Global y se añade a la pila.
2. Se invoca console.log(). Se crea su contexto y se añade a la pila.
3. Dentro de console.log(), se invoca procesarDatos(8). Se crea su contexto y se añade a la pila.
4. Dentro de procesarDatos(), se invoca calcularSuma(factor + valorInicial). Se crea su contexto y se añade a la pila.
5. La función calcularSuma() se ejecuta, no invoca a otras funciones. Su contexto sale de la pila.
6. La función procesarDatos() termina su ejecución. Su contexto sale de la pila.
7. La función console.log() termina su ejecución. Su contexto sale de la pila.
8. El Contexto de Ejecución Global permanece hasta que el navegador o la pestaña se cierran.
*/

Tipos de Entornos de Ejecución en JavaScript

  • Entorno Global: Es el contexto predeterminado una vez que el motor JavaScript ha cargado el script.
  • Entorno de Función: Se crea cada vez que se invoca una función. Cada función tiene su propio entorno.
  • Entorno eval: Se crea cuando se utiliza la función eval(). Su uso no es recomendado debido a riesgos de seguridad y rendimiento.

Creación del Contexto de Ejecución

Durante la creación de un contexto de ejecución, el motor JavaScript realiza varias tareas esenciales:

1. Inicialización del Objeto de Entorno (Environment Record)

Este paso incluye la creación del objeto arguments, la elevación de declaraciones de funciones (function hoisting) y la elevación de declaraciones de variables (variable hoisting).

Creación del Objeto arguments

En el ámbito de las funciones (excepto las funciones de flecha), se crea un objeto arguments que contiene todos los argumentos pasados a la función.

Elevación de Declaraciones de Funciones (Function Hoisting)

El motor escanea el código en el contexto actual en busca de declaraciones de funciones. Estas funciones se "elevan" o se hacen disponibles en la memoria antes de que se ejecute cualquier línea de código. Si ya existe una función con el mismo nombre, será sobrescrita por la nueva declaración. Las funciones declaradas de esta manera pueden ser invocadas antes de su aparición en el código.

function ejecutarContexto() {
    // mostrarMensaje() puede ser llamada aquí debido a la elevación de funciones.
    mostrarMensaje(); 
    function mostrarMensaje() {
        console.log("Mensaje interno de la función declarada.");
    }
    mostrarMensaje();
}
ejecutarContexto(); 
// mostrarMensaje(); // Esto causaría un ReferenceError aquí, ya que la función es local a ejecutarContexto.

Elevación de Declaraciones de Variables (Variable Hoisting)

Similar a las funciones, las declaraciones de variables hechas con var también se elevan al inicio de su contexto. Sin embargo, solo se eleva la declaración, no su asignación de valor, inicializándose a undefined. Si la variable ya existe, la declaración se ignora. Las variables declaradas con let y const también se elevan, pero no se inicializan, lo que las coloca en una "Zona Muerta Temporal" (Temporal Dead Zone) hasta su declaración real.

Es importante recordar que la elevación de funciones tiene prioridad sobre la elevación de variables.

console.log(demoHoisting); // Imprime la definición de la función "demoHoisting"
demoHoisting();            // Ejecuta la función declarada: "Esta es una declaración de función."

var demoHoisting = function() {
    console.log("Esta es una función de expresión.");
};

function demoHoisting() {
    console.log("Esta es una declaración de función.");
}

demoHoisting();            // Ahora ejecuta la función de expresión (var sobrescribe la declaración de función): "Esta es una función de expresión."

/*
Durante la fase de compilación:
1. `function demoHoisting()` se eleva y se convierte en la primera definición de `demoHoisting`.
2. `var demoHoisting` también se eleva, pero como `demoHoisting` ya existe (de la función),
   la declaración `var` se ignora por su baja prioridad frente a la función.
   Sin embargo, la asignación de la función de expresión a `demoHoisting` ocurre en tiempo de ejecución.

Durante la fase de ejecución:
1. `console.log(demoHoisting)`: `demoHoisting` ya está definida como la función declarada. Imprime la función.
2. `demoHoisting()`: Ejecuta la función declarada.
3. `var demoHoisting = function() { ... }`: Esta línea asigna la función de expresión a `demoHoisting`, sobrescribiendo la función declarada.
4. `demoHoisting()`: Ejecuta la función de expresión recién asignada.
*/

2. Creación de la Cadena de Alcance (Scope Chain)

La cadena de alcance es una lista de objetos que define el acceso de un contexto de ejecución a variables y funciones. Está compuesta por el Objeto de Entorno del contexto actual (que puede ser un Objeto de Variables o un Objeto de Activación, dependiendo si el contexto está en compilación o ejecución) y los Objetos de Activación de sus contextos padres. Esta cadena asegura que las variables y funciones sean accesibles en el orden correcto, buscando desde el ámbito actual hacia los ámbitos superiores hasta llegar al ámbito global.

function funcionPrincipal() {
    let valorX = 50;

    function subFuncion() {
        let valorY = 25;
        // subFuncion puede acceder a valorX debido a la cadena de alcance.
        return valorX + valorY; 
    }

    console.log(subFuncion()); // Imprime 75
}
funcionPrincipal();

Un ejemplo más complejo con elevación:

function demoAmbito() {
    var externo = 100;

    function calcularValores() {
        console.log(externo); // Debido a la elevación de 'var externo' local, imprime 'undefined'.
                              // La variable 'externo' de la función externa es inaccesible aquí
                              // hasta que la variable local 'externo' sea inicializada.
        var externo = 5;      // Declaración de variable local que se eleva.
        var interno = 20;
        return externo + interno;
    }
    console.log(calcularValores()); // Imprime 25 (5 + 20)
}
demoAmbito();

3. Determinación del Valor de this

El valor de this se establece durante la creación del contexto de ejecución y depende de cómo se invoca la función. Este tema se explorará en detalle en una sección posterior.

Consideremos un ejemplo más elaborado que ilustra la interacción de la elevación, el alcance y this:

function Administrador() {
    // Esta línea redefine la función 'obtenerNombre' en el ámbito global (window en navegadores)
    // porque no usa 'var', 'let' o 'const'.
    obtenerNombre = function() {
        console.log("Mensaje: 10");
    };
    return this; // Retorna 'this', que será 'window' en una llamada regular
}

// Método estático de Administrador
Administrador.obtenerNombre = function() {
    console.log("Mensaje: 20");
};

// Método de prototipo de Administrador
Administrador.prototype.obtenerNombre = function() {
    console.log("Mensaje: 30");
};

// Declaración de variable global con función de expresión
var obtenerNombre = function() {
    console.log("Mensaje: 40");
};

// Declaración de función global (se eleva antes que 'var')
function obtenerNombre() {
    console.log("Mensaje: 50");
}

console.log(Administrador);          // Imprime la definición de la función Administrador
Administrador.obtenerNombre();       // Llama al método estático: "Mensaje: 20"
obtenerNombre();                     // En este punto, 'obtenerNombre' es la función de expresión global (40)
                                     // (La función 50 se eleva, luego la var 40 se eleva y su asignación sobrescribe 50).
                                     // Así que imprime "Mensaje: 40".

Administrador().obtenerNombre();     // 1. Administrador() se ejecuta:
                                     //    - 'this' es 'window'.
                                     //    - Se asigna 'obtenerNombre = function(){ console.log("Mensaje: 10"); }' globalmente.
                                     // 2. El retorno ('window') llama a su método 'obtenerNombre'.
                                     //    - Imprime "Mensaje: 10".

obtenerNombre();                     // Ahora la función global 'obtenerNombre' fue redefinida a "Mensaje: 10".
                                     // Así que imprime "Mensaje: 10".

new Administrador.obtenerNombre();   // 'new (Administrador.obtenerNombre())'
                                     // Llama al método estático como constructor. Aún imprime "Mensaje: 20".

new Administrador().obtenerNombre(); // 1. 'new Administrador()': crea una instancia, pero también ejecuta la lógica interna de Administrador.
                                     //    - 'this' de Administrador() es la nueva instancia.
                                     //    - El asignamiento 'obtenerNombre = function(){ console.log("Mensaje: 10"); }' SUCEDE (globalmente).
                                     // 2. Sobre la instancia creada ('new Administrador()'), se llama 'obtenerNombre()'.
                                     //    - Esto busca en la cadena de prototipos y encuentra 'Administrador.prototype.obtenerNombre'.
                                     //    - Imprime "Mensaje: 30".

new new Administrador().obtenerNombre(); // 1. 'new Administrador()' crea una instancia (y redefine global 'obtenerNombre' a 10).
                                         // 2. '.obtenerNombre()' se resuelve al método de prototipo (30).
                                         // 3. 'new (...)' invoca ese método de prototipo como constructor.
                                         //    - Imprime "Mensaje: 30".

1.3 Fase de Ejecución

Después de las fases de análisis y compilación, el código finalmente se ejecuta línea por línea. Durante esta fase, las variables se asignan sus valores finales y las funciones se invocan. Esta etapa interactúa directamente con el Event Loop de JavaScript para gestionar tareas síncronas y asíncronas.

  1. Asincronía y Sincronía en JavaScript

JavaScript es un lenguaje de programación de un solo hilo, lo que significa que solo puede ejecutar una operación a la vez. Sin embargo, para evitar que operaciones largas bloqueen el hilo principal, JavaScript utiliza un modelo de concurrencia basado en el "Event Loop".

  • Tareas Síncronas: Se ejecutan una tras otra, de forma secuencial. Una tarea debe completarse antes de que la siguiente pueda comenzar.
  • Tareas Asíncronas: Son operaciones que pueden tomar un tiempo considerable (como solicitudes de red, temporizadores o I/O). En lugar de esperar a que se completen, el hilo principal puede continuar con otras tareas. Cuando la operación asíncrona finaliza, su resultado se coloca en una "Cola de Tareas" para ser procesado posteriormente por el Event Loop.

El Event Loop (Bucle de Eventos)

El Event Loop es el mecanismo que permite a JavaScript manejar la asincronía. Funciona monitoreando la pila de llamadas y las colas de tareas. Cuando la pila de llamadas está vacía (indicando que todas las tareas síncronas han terminado), el Event Loop mueve las tareas de las colas a la pila de llamadas para su ejecución.

Colas de Tareas: Microtareas y Macrotareas

Las tareas asíncronas se dividen en dos categorías principales:

  • Microtareas (Microtasks): Tienen una mayor prioridad. Incluyen: Promise.then(), Promise.catch(), Promise.finally(), process.nextTick (Node.js), y MutationObserver.
  • Macrotareas (Macrotasks): Tienen una prioridad menor. Incluyen: la ejecución de un bloque de script completo, setTimeout(), setInterval(), setImmediate() (Node.js), eventos I/O y renderizado de UI.

Secuencia del Event Loop

  1. El motor JavaScript ejecuta el código síncrono del Macrotarea actual (inicialmente, el script completo). Si encuentra una tarea asíncrona, la registra y, cuando esté lista, la coloca en la cola correspondiente (microtarea o macrotarea).
  2. Una vez que la pila de llamadas está vacía (todas las tareas síncronas del macrotarea actual han terminado), el Event Loop verifica la Cola de Microtareas.
  3. Ejecuta todas las microtareas en la cola, una tras otra, hasta que la cola esté vacía.
  4. Después de vaciar la cola de microtareas, el Event Loop pasa a la siguiente Macrotarea en su cola.
  5. Repite el proceso desde el paso 1 para la nueva macrotarea.

Ejemplo de Event Loop

console.log('Inicio del script');

setTimeout(function() {
  console.log('Callback de setTimeout');
}, 0); // Este setTimeout se registra como una macrotarea.

Promise.resolve().then(function() {
  console.log('Promise 1'); // Este .then() se registra como una microtarea.
}).then(function() {
  console.log('Promise 2'); // Este .then() también se registra como una microtarea, después de 'Promise 1'.
});

console.log('Fin del script');

Salida esperada:

Inicio del script
Fin del script
Promise 1
Promise 2
Callback de setTimeout

Explicación:

  1. Se ejecuta el script principal (macrotarea inicial). console.log('Inicio del script') se imprime.

  2. setTimeout se registra. Su callback se añade a la cola de macrotareas después de 0 ms.

  3. Promise.resolve().then() se ejecuta. Su callback se añade a la cola de microtareas.

  4. console.log('Fin del script') se imprime.

  5. La pila de llamadas está vacía. El Event Loop verifica la cola de microtareas.

  6. El callback de la primera promesa se ejecuta: console.log('Promise 1').

  7. Durante la ejecución de 'Promise 1', su .then() anidado crea otra microtarea, que se añade a la cola de microtareas.

  8. La cola de microtareas aún no está vacía, así que se ejecuta la siguiente: console.log('Promise 2').

  9. La cola de microtareas está vacía.

  10. El Event Loop verifica la cola de macrotareas.

  11. El callback de setTimeout se ejecuta: console.log('Callback de setTimeout').

  12. La Determinación de this


El valor de this en JavaScript no se define en el momento de la declaración de una función, sino en el momento de su invocación. Su valor depende del contexto en el que se llama la función. Una excepción a esto son las funciones de flecha, cuyo this se determina léxicamente (heredan el this de su contexto envolvente).

Patrones Comunes de Vinculación de this:

  • Llamada de Función Regular (Global/Directa): En el modo no estricto, this se refiere al objeto global (window en navegadores, global en Node.js). En el modo estricto, this es undefined.
  • Llamada como Método de un Objeto: Si una función se invoca como un método de un objeto (objeto.metodo()), this se vincula al objeto en sí.
  • Llamada como Constructor: Cuando una función se invoca con la palabra clave new (new MiClase()), this se vincula a la nueva instancia creada por el constructor.
  • Uso de call(), apply() o bind(): Estos métodos permiten vincular explícitamente this a un objeto específico.
  • Funciones de Flecha (Arrow Functions): Las funciones de flecha no tienen su propio this. Heredan el valor de this de su ámbito léxico circundante (el this del contexto donde fueron definidas).

Ejemplos de Vinculación de this

// 1. Llamada de Función Regular
function mostrarThisGlobal() {
    console.log("Contexto global:");
    console.log(this); // En navegadores, 'window'; en modo estricto, 'undefined'.
}
mostrarThisGlobal();

var nombreGlobal = "Valor Global";
function obtenerNombre() {
    console.log(this.nombreGlobal); // Accede a 'nombreGlobal' del objeto global.
}
obtenerNombre(); // Imprime "Valor Global"

let idGlobal = "ID Global"; // 'let' no añade la variable al objeto 'window'.
function obtenerId() {
    console.log(this.idGlobal); 
}
obtenerId(); // Imprime 'undefined' porque 'this' es 'window', y 'idGlobal' no es una propiedad de 'window'.


// 2. Llamada como Método de un Objeto
let miObjeto = {
    propiedad: "Soy una propiedad de miObjeto",
    verThis: function() {
        console.log("Contexto de método:");
        console.log(this); // 'this' se refiere a 'miObjeto'.
    }
};
miObjeto.verThis(); // Imprime el objeto 'miObjeto'.


// 3. Llamada como Constructor
function Usuario(nombre) {
    this.nombre = nombre;
    console.log("Contexto de constructor:");
    console.log(this); // 'this' se refiere a la nueva instancia de Usuario.
}
let usuario1 = new Usuario("Alice"); // Crea una nueva instancia de Usuario.
console.log(usuario1.nombre); // Imprime "Alice".


// 4. Uso de call(), apply(), bind()
let datosExternos = {
    valor: 99
};

function mostrarValor() {
    console.log("Valor vinculado explícitamente:");
    console.log(this.valor);
}

mostrarValor.call(datosExternos);   // 'this' se vincula a 'datosExternos'. Imprime 99.
mostrarValor.apply(datosExternos);  // Similar a 'call', Imprime 99.

let funcionVinculada = mostrarValor.bind(datosExternos);
funcionVinculada(); // 'this' siempre estará vinculado a 'datosExternos'. Imprime 99.


// 5. Funciones de Flecha
let miComponente = {
    titulo: "Mi Componente",
    metodoRegular: function() {
        setTimeout(function() {
            console.log("Desde método regular con setTimeout:");
            console.log(this.titulo); // 'this' es 'window' (o undefined en estricto) aquí, no 'miComponente'.
        }, 100);
    },
    metodoFlecha: function() {
        setTimeout(() => { // La función de flecha hereda 'this' de 'metodoFlecha'.
            console.log("Desde método flecha con setTimeout:");
            console.log(this.titulo); // 'this' es 'miComponente'.
        }, 100);
    }
};

miComponente.metodoRegular(); // Imprime 'undefined' (o nada si no hay 'titulo' en global)
miComponente.metodoFlecha();  // Imprime "Mi Componente"

Etiquetas: JavaScript execution-context event-loop Hoisting scope-chain

Publicado el 7-13 06:09