Pre-tocho:
Estoy intentando arrancar con un blog sobre desarrollo próximamente, aunque me llevará tiempo tener todos los estilos y todo listo, pero me gustaría compartir con vosotros lo que voy preparando y que me dierais vuestra opinión y crítica constructiva, hijos de puta. Espero que os resulte interesante y, de paso, me digáis qué os gustó menos o qué cambiaríais, ya sea en forma o contenido.
Si llevas un tiempo programando con JavaScript, seguramente te hayas encontrado situaciones en las que las interacciones de usuario en la web que estabas programando empezaban a ralentizarse, o quizá te hayas dado de bruces con un problema inesperado de recursividad, que deja tu web completamente congelada.
Para poder entender mejor estos problemas, y de cara a poder evitarlos en el futuro, conviene entender, aunque sea superficialmente, cómo funciona el motor de JS que utiliza nuestro navegador o aplicación de Node (v8 en Chrome/Chromium/NodeJS, SpiderMonkey en Firefox, JavaScriptCore en Safari).
¿Cómo funciona JavaScript?
En primer lugar, deberíamos cuestionarnos lo más básico. ¿Cómo funciona JS? De forma práctica podemos decir que, en esencia, cargamos código, ya sean scripts incrustados en HTML (inline) o desde un archivo externo, lo que causa que se definan funciones, variables y se ejecute aquel código que esté fuera de toda función.
¿Qué es una variable para el motor de JavaScript? ¿Cómo se procesa una asignación? ¿Qué es una función? ¿Cómo maneja la ejecución de una función? ¿Y si ésta llama a otra? ¿Cómo resuelve el motor una referencia a una variable de un bloque o una función externa?
Vamos a examinar el siguiente bloque de código:
const title = document.title;
function logTitle () {
const n = 10;
console.log(title);
return n;
}
logTitle();
¡En estas 10 líneas de código están pasando muchas cosas! En las líneas 1 y 4 estamos declarando variables, en la 3 declaramos una función; estamos invocando a dicha función, llamando a una API (console.log) y devolviendo un número desde ella.
¿Cómo funciona todo esto? Es decir, ¿qué es declarar una variable, o una función? ¿en qué consiste realmente asignarle un valor? Pero antes de llegar a eso, algo más básico si cabe, ¿cómo llegamos desde nuestro código escrito en nuestro IDE favorito y almacenado en un archivo hasta ver el resultado funcionando en nuestro navegador?
Del IDE al navegador
JavaScript es un lenguaje de programación creado para dotar de interactividad a páginas web HTML durante los primeros años de la web. Se trata de un lenguaje monohilo: una pestaña web de tu navegador tendrá un único hilo (proceso ejecutándose en tu Sistema Operativo) encargándose de ejecutar el código JavaScript de dicha pestaña.
En cualquier momento, ese hilo puede estar ocupado encargándose de ejecutar alguna función o bloque de código, y puede haber eventos pendientes de ser procesados (como una petición al servidor que ha terminado, un setTimeout que debe ejecutarse, etc.). Ésta es una característica muy importante del lenguaje, porque define cómo se estructura el código que escribimos, simplifica el desarrollo e introduce nuevos problemas también.
JavaScript carece de mecanismos de sincronización como semáforos o mutex’es.
Cada pestaña tiene un solo hilo, pero otras APIs como ServiceWorker o WebWorkers funcionarán con su/s propio/s hilo/s.
Como JavaScript es un lenguaje de programación, posee características que podemos encontrar en el idioma Castellano o Inglés:
- Léxico: definen el alfabeto del lenguaje, es decir, qué símbolos o caracteres son válidos en el lenguaje, así como las categorías léxicas que existen en él (identificadores, palabras clave, operadores…).
- Sintaxis o gramática: los usaremos indistintamente. Son las reglas que definen qué elementos del alfabeto del lenguaje pueden aparecer junto a qué otros, y en qué orden, para formar sentencias validas dentro del lenguaje.
- Semántica: da significado a las sentencias y a los distintos elementos del alfabeto que aparecen en el contexto de una sentencia.
Así, tomando como referencia la siguiente línea de código:
const a = 2;
Pongamos que contamos con una sintaxis muy simple, con una regla que dice «una sentencia debe estar formada por const/var/let, seguido de un identificador, el carácter = y un número entero». Para dicha sintaxis, nuestra línea de código sería válida. No lo sería si cambiáramos, por ejemplo, «=» por «+=». Las sintaxis/gramáticas, son, obviamente, complejas: en este caso no hemos definido lo que es un identificador, ni hemos tratado los espacios, ni signos de punto y coma…
En cuanto a la semántica, como sabemos un poco de JavaScript, sabemos que const declara una constante, a es el nombre/identificador de la constante, el símbolo igual realiza una asignación y 2 es el valor que tendrá la constante. Además, sabemos que al escribir punto y coma cualquier carácter que aparezca a continuación pertenecerá a la siguiente sentencia.
Teniendo claros estos conceptos, vamos a empezar a entender cómo nuestro bonito código cobra vida.
Del código a la ejecución: el tokenizador/tokenizer
El input esencial del navegador, a la hora de procesar nuestros scripts, se puede considerar esencialmente una secuencia enorme de texto: nuestro código fuente. Pero el motor de JavaScript no entiende este texto, nuestro procesador tampoco. Tal y como está, como texto, no podemos ejecutarlo.
La primera fase de procesado del código se conoce como tokenización. En esta fase, un programa (analizador léxico, tokenizador o tokenizer) va transformando el texto de entrada en tokens, lexemas o partículas válidas para el lenguaje.
El tokenizer va leyendo carácter a carácter del código de entrada hasta que se encuentra caracteres especiales (un espacio, una coma, un punto y coma, etc.), y va «cortando» en estos puntos para generar tokens. Cuidado, los puntos y coma o las comas, además de servir para delimitar, también generan tokens del lenguaje.
(Una asignación sencilla transformada a Tokens)
En la imagen anterior, se puede ver cómo se podría transformar en tokens una asignación de una variable en JS. Nótese cómo el «;» es considerado un token de tipo separador. También que se puede guardar información de la línea y carácter/columna en que aparece el código (y podríamos guardar el archivo en el que aparece). De esta forma podríamos saber, cuando ocurra un error, en qué línea de código ha ocurrido – cada token generado en esa línea lleva consigo dicha información.
Lexer o scanner son otras formas de referirse a tokenizer. En el motor v8, por ejemplo, se denomina scanner.
El proceso de tokenización no es imprescindible. Podría hacerse un parser que no necesite un lexer, pero suele ser mucho menos eficiente.
En esta fase del proceso, además, se realizan las siguientes tareas:
- Eliminar whitespaces o espacios en blanco (espacios, tabulaciones y saltos de línea – con atención a estos últimos, ya que pueden implicar el final de una instrucción y la inserción automática de un punto y coma).
- Eliminar comentarios. Ya que no forman parte del código, no se convierten en tokens.
- Lanzar un error (que se mostrará como SyntaxError) en el caso de que el input (nuestro código) contenga un carácter ilegal, no aceptado por el léxico del lenguaje (por ejemplo, const \t123 = 2 produciría tal error, ya que la secuencia \t123 no genera un lexema o token válido.
Del código a la ejecución: el analizador/parser
Llegados a este punto, hemos dividido el código fuente de entrada en trozos atómicos llamados tokens, deshaciéndonos de elementos superfluos para la ejecución (espacios, comentarios) y quedándonos con aquello que representa la lógica de nuestro código.
Sin embargo, esto sigue siendo insuficiente para ejecutar el programa. Por ejemplo, hemos analizado los lexemas para validar que formen parte del léxico del lenguaje, pero no hemos analizado la sintaxis (si analizamos const a b = 2 y lo convertimos en tokens, los tokens son válidos, pero sintácticamente la expresión no lo es).
Un nuevo programa o pieza de código se encargará de convertir secuencias de tokens en una estructura más útil para su procesamiento: el analizador o parser.
El parser se encargará de analizar los tokens resultantes de la etapa anterior, pero no individualmente sino en grupos, de acuerdo con la sintaxis del lenguaje. El parser sabrá, por ejemplo, para el caso de const a b = 2, que en una declaración, después de un identificador (a) no puede aparecer otro identificador de inmediato (b). Esto produciría un error de sintaxis (por ejemplo, Unexpected identifier).
Abstract Syntax Tree (AST)
El resultado del análisis del código es una estructura de datos conocida como árbol sintáctico abstracto o abstract syntax tree, en Inglés, generalmente abreviado con sus siglas AST.
Sin meternos en mucho detalle, este árbol ordenado contiene una representación de la estructura de nuestro código, generado a partir del listado de tokens anterior. Es decir, agrupa bloques de código (varios tokens) en ramas reflejando su funcionamiento y orden. Veamos esto con un ejemplo:
function greet () {
var a = 2;
return "Hello";
}
greet();
(AST simplificado generado a partir del código anterior)
Se ha simplificado el resultado para su representación. Si te interesa, en ASTExplorer puedes introducir código de ejemplo y ver el AST resultante sobre la marcha.
Como se puede observar, el parser ha interpretado los tokens que le ha proporcionado el tokenizer y ha aplicado la sintaxis de JS para construir un árbol sintáctico que representa lo que hace nuestro código.
Si volvemos a ver el árbol podemos darnos cuenta de un detalle: aquí ya no hay separadores, caracteres especiales u operadores. Nos quedamos sólo con la semántica del código, nos hemos deshecho de paréntesis, puntos y coma, etc. Por eso se llama árbol de sintaxis abstracto.
Si quisiéramos ejecutar el código mentalmente bastaría por empezar desde el nodo inicial (Program) y realizar una búsqueda en profundidad de izquierda a derecha (es decir, para cada nodo, cogemos los hijos del nodo, de izquierda a derecha, y los visitamos, realizando lo mismo recursivamente).
El orden de ejecución sería:
- Program: punto de inicio
- FunctionDeclaration: nodo que representa la declaración de la función greet.
- BlockStatement: nodo que representa el código en el cuerpo o bloque principal {} de la función (así como scope, pero esto se verá en una entrada - posterior).
- VariableDeclaration: nodo que declara una variable a, y su inicializador con valor 2.
- ReturnStatement: último nodo de la función, que retorna el literal «Hello».
- ExpressionStatement: invocación a la función greet vía greet().
Nuestros caracteres de código ya empiezan a formar algo más consistente que está mas cerca de poder ser ejecutado. Al menos, tenemos un árbol que define semánticamente y unívocamente el funcionamiento de nuestro código. Sin embargo, nuestra CPU no entiende ese código. En realidad, este código no irá directamente al procesador de nuestro ordenador. Aún nos faltan un par de piezas por explicar.
Del código a la ejecución: el intérprete y compilador
Si uno está atento a buscar las cosquillas a la entrada, podría preguntarse «¿y por qué hablamos del código que entiende la CPU, si interpretando ese árbol ya se podría ejecutar el código?» y sería una pregunta completamente válida. De hecho, así funcionaba JS en algunos navegadores en sus etapas iniciales.
Una de las maneras de agrupar lenguajes de programación es dependiendo de si son interpretados o compilados.
Los lenguajes compilados, como pueden ser C/C++, Rust o GO, transforman el código en uno o varios archivos, generalmente produciendo un ejecutable (un .exe en Windows, por ejemplo, o un archivo binario en Linux) que contiene instrucciones en código binario para la arquitectura de Sistema Operativo y procesador para los que se compiló (por ejemplo, Windows x64 o Linux x86). Esto permite optimizar mucho el código, conseguir un rendimiento excelente, pero requiere compilar el código de antemano para cada plataforma antes de ser distribuido, y es un proceso más lento y costoso.
Los lenguajes interpretados, como JavaScript, Java, Python o PHP, son lenguajes no pensados para ser compilados (transformados a código máquina), si no interpretados a partir del código fuente, por un intérprete (como el intérprete de JavaScript o de Python) que actúa a partir del código y controla la ejecución. Existen casos intermedios y/o excepciones como puede ser Java y su máquina virtual, o JS, como veremos a continuación.
Si bien hemos dicho que JS es interpretado y que con el AST podríamos ejecutar el código, y así se hacía, las cosas han cambiado mucho. JS ya no se usa solo para implementar algunos apaños sino de forma intensiva, ya sea como orquestador principal de toda la aplicación web o de cientos de formas distintas. Interpretar el mismo código una y otra vez, cuando una función puede ejecutarse decenas, cientos o miles de veces no es algo muy eficiente. Y a nadie le gusta acabar con el hilo de JS bloqueado haciendo cálculos.
Por esta razón, la mayoría de los navegadores actuales utilizan uno o varios compiladores de código para generar un código máquina intermedio llamado bytecode, que aunque no es código binario construido para tu procesador y SO, está en un nivel intermedio y es mucho más eficiente que interpretar JavaScript al vuelo.
Sin entrar en mucho detalle, el motivo por el que los navegadores pueden utilizar varios compiladores se debe a la optimización de código. Hoy en día el motor de JS es una parte crítica de la web, y por lo tanto su ejecución debe ser lo más rápida posible, pero también lo más temprana. Así, se suele utilizar un compilador base (baseline compiler), que realiza sólo las optimizaciones más sencillas y rápidas, para que la web empiece a ejecutarlo cuanto antes. Según las funciones son invocadas, y en función de múltiples parámetros como el número de veces que se invoca, con qué tipos, qué tipo de datos devuelve, etc., un segundo compilador (optimizing compiler) entra en acción generando bytecode aún más optimizado que el anterior. De igual manera, si alguna asunción hecha por el compilador no se ve satisfecha, es posible que el código optimizado se elimine y se vuelva a la versión base o interpretada (esto se conoce como bailout).
Resumen
Ahora sí. Nos ha costado, pero ya tenemos una visión general de cómo llega a ejecutarse el código a partir de los caracteres que escribimos, guardamos en archivos y cargamos en el navegador.
Todavía no sabemos cómo funciona eso de los eventos ni cómo se ejecuta realmente el código (¿dónde se guarda el valor de una variable?), pero ya tenemos una buena base sobre la cual ir asentando el resto de conceptos que nos faltan para entenderlo.
Si quieres leer más sobre estos temas, aquí van unos enlaces:
V8 Blog: Blazingly fast parsing, part 1: optimizing the scanner
V8 Blog: Blazingly fast parsing, part 2: lazy parsing
V8 Blog: https://v8.dev/blog/ignition-interpreter
A crash course in JIT compilers
Understanding JS's ByteCode
How JS works