[Recomendar lectura] Las otras 19 partes del ciclo Todos sabemos que el código JavaScript para proyectos web puede crecer a un tamaño enorme. Y cuanto más grande sea el código, más tiempo lo cargará el navegador. Pero el problema aquí no es solo en el momento de la transmisión de datos a través de la red. Después de que se carga el programa, aún debe analizarse, compilarse en bytecode y finalmente ejecutarse. Hoy traemos a su atención una traducción de la parte 14 de la serie de ecosistemas de JavaScript. Es decir, hablaremos sobre el análisis del código JS, cómo se construyen los árboles de sintaxis abstracta y cómo un programador puede influir en estos procesos, logrando un aumento en la velocidad de sus aplicaciones.

¿Cómo son los lenguajes de programación?
Antes de hablar sobre árboles de sintaxis abstracta, analicemos cómo funcionan los lenguajes de programación. Independientemente del idioma que use, siempre debe usar ciertos programas que toman el código fuente y lo convierten en algo que contiene comandos específicos para las máquinas. Tanto los intérpretes como los compiladores actúan como tales programas. No importa si escribe en un lenguaje interpretado (JavaScript, Python, Ruby) o compilado (C #, Java, Rust), su código, que es texto sin formato, siempre pasará por la etapa de análisis, es decir, convertir texto sin formato en una estructura de datos llamado un árbol de sintaxis abstracta (AST).
Los árboles de sintaxis abstracta no solo proporcionan una representación estructurada del código fuente, sino que también juegan un papel crucial en el análisis semántico, durante el cual el compilador verifica la corrección de las construcciones de software y el uso correcto de sus elementos. Después de formar el AST y realizar comprobaciones, esta estructura se utiliza para generar bytecode o código de máquina.
Usar árboles de sintaxis abstracta
Los árboles de sintaxis abstracta se utilizan no solo en intérpretes y compiladores. Ellos, en el mundo de las computadoras, son útiles en muchas otras áreas. Una de las aplicaciones más comunes es el análisis de código estático. Los analizadores estáticos no ejecutan el código que se les pasa. Sin embargo, a pesar de esto, necesitan comprender la estructura de los programas.
Suponga que desea desarrollar una herramienta que encuentre estructuras frecuentes en su código. Los informes de dicha herramienta ayudarán a refactorizar y reducirán la duplicación de código. Esto se puede hacer utilizando la comparación de cadenas habitual, pero este enfoque será muy primitivo, sus capacidades serán limitadas. De hecho, si desea crear una herramienta similar, no necesita escribir su propio analizador para JavaScript. Hay muchas implementaciones de código abierto de dichos programas que son totalmente compatibles con la especificación ECMAScript. Por ejemplo, Esprima y Bellota. También hay herramientas que pueden ayudar a trabajar con lo que generan los analizadores, a saber, trabajar con árboles de sintaxis abstracta.
Los árboles de sintaxis abstracta, además, son ampliamente utilizados en el desarrollo de transpiladores. Supongamos que decide desarrollar un transpiler que convierta el código Python en código JavaScript. Un proyecto similar puede basarse en la idea de que se utiliza un transpilador para crear un árbol de sintaxis abstracta basado en el código Python, que, a su vez, se convierte en código JavaScript. Probablemente aquí te preguntarás cómo es esto posible. La cuestión es que los árboles de sintaxis abstracta son solo una forma alternativa de representar código en algún lenguaje de programación. Antes de que el código se convierta a AST, parece un texto normal, cuando se escribe, que sigue ciertas reglas que forman el lenguaje. Después de analizar, este código se convierte en una estructura de árbol que contiene la misma información que el código fuente del programa. Como resultado, es posible llevar a cabo no solo la transición del código fuente a AST, sino también la transformación inversa, convirtiendo el árbol de sintaxis abstracta en una representación de texto del código del programa.
Analizando JavaScript
Hablemos de cómo se construyen los árboles de sintaxis abstracta. Como ejemplo, considere una función simple de JavaScript:
function foo(x) { if (x > 10) { var a = 2; return a * x; } return x + 10; }
El analizador creará un árbol de sintaxis abstracta, que se representa esquemáticamente en la siguiente figura.
Árbol de sintaxis abstractaTenga en cuenta que esta es una representación simplificada de los resultados del analizador. Un verdadero árbol de sintaxis abstracta parece mucho más complicado. En este caso, nuestro objetivo principal es tener una idea de en qué se convierte, en primer lugar, el código fuente antes de que se ejecute. Si está interesado en ver cómo se ve un árbol de sintaxis abstracta real, use el sitio web
AST Explorer . Para generar un AST para un cierto fragmento de código JS, es suficiente colocarlo en el campo correspondiente en la página.
Quizás aquí tenga una pregunta sobre por qué el programador necesita saber cómo funciona el analizador JS. Al final, analizar y ejecutar código es una tarea del navegador. En cierto modo, tienes razón. La siguiente figura muestra el tiempo requerido para que algunos proyectos web conocidos realicen varios pasos en el proceso de ejecución del código JS.
Eche un vistazo más de cerca a este dibujo, tal vez verá algo interesante allí.
Tiempo dedicado a ejecutar código JS¿Ves? Si no, mira de nuevo. En realidad, estamos hablando del hecho de que, en promedio, los navegadores pasan del 15 al 20% del tiempo analizando el código JS. Y estos no son algunos datos condicionales. Aquí hay información estadística sobre el trabajo de proyectos web reales que usan JavaScript de una forma u otra. Quizás la cifra del 15% no te parezca tan grande, pero créeme, esto es mucho. Una aplicación típica de una página carga aproximadamente 0.4 MB de código JavaScript, y el navegador necesita aproximadamente 370 ms para analizar este código. De nuevo, puedes decir que no hay nada de qué preocuparse. Y sí, eso solo no es mucho. Sin embargo, no olvide que este es solo el tiempo que lleva analizar el código y convertirlo en un AST. Esto no incluye el tiempo que lleva ejecutar el código o el tiempo que lleva resolver otras tareas que acompañan a la carga de la página, por ejemplo, las tareas de procesamiento de HTML y CSS y el
procesamiento de la página . Además, solo estamos hablando de navegadores de escritorio. En el caso de los sistemas móviles es aún peor. En particular, el tiempo de análisis para el mismo código en dispositivos móviles puede ser de 2 a 5 veces más largo que en el escritorio. Echa un vistazo a la siguiente figura.
Tiempo de análisis de 1 MB de código JS en varios dispositivosEste es el tiempo requerido para analizar 1 MB de código JS en varios dispositivos móviles y de escritorio.
Además, las aplicaciones web se vuelven cada vez más complejas y cada vez se transfieren más tareas al lado del cliente. Todo esto tiene como objetivo mejorar la experiencia del usuario al trabajar con sitios web, con el fin de acercar estos sentimientos a los que los usuarios experimentan al interactuar con las aplicaciones tradicionales. Es fácil determinar cuánto afecta esto a los proyectos web. Para hacer esto, solo abra las herramientas de desarrollador en el navegador, vaya a un sitio moderno y vea cuánto tiempo se dedica a analizar el código, compilar y todo lo demás que sucede en el navegador al preparar la página para el trabajo.
Análisis de sitios web utilizando herramientas de desarrollador en un navegadorDesafortunadamente, los navegadores móviles no tienen tales herramientas. Sin embargo, esto no significa que no se puedan analizar las versiones móviles de los sitios. Aquí herramientas como
DeviceTiming vendrán en nuestra ayuda. Con DeviceTiming, puede medir el tiempo que lleva analizar y ejecutar scripts en entornos administrados. Esto funciona debido a la colocación de scripts locales en el entorno formado por el código auxiliar, lo que lleva al hecho de que cada vez que la página se carga desde varios dispositivos, tenemos la oportunidad de medir localmente el tiempo de análisis y ejecución del código.
Optimización de análisis y motores JS
Los motores JS hacen muchas cosas útiles para evitar trabajos innecesarios y optimizar los procesos de procesamiento de código. Aquí hay algunos ejemplos.
El motor V8 admite secuencias de comandos de transmisión y almacenamiento en caché de código. En este caso, la transmisión se entiende como el hecho de que el sistema se dedica a analizar scripts cargados de forma asíncrona y scripts, cuya ejecución se retrasa, en un hilo separado, comenzando a hacerlo desde el momento en que el código comienza a cargarse. Esto lleva al hecho de que el análisis termina casi simultáneamente con la finalización de la carga de la secuencia de comandos, lo que da una reducción del 10% en el tiempo requerido para preparar las páginas para el trabajo.
El código JavaScript generalmente se compila en bytecode cada vez que se visita una página. Sin embargo, este código de bytes se pierde después de que el usuario navega a otra página. Esto se debe al hecho de que el código compilado depende en gran medida del estado y el contexto del sistema en el momento de la compilación. Para mejorar la situación, Chrome 42 introdujo soporte para el almacenamiento en caché de bytecode. Gracias a esta innovación, el código compilado se almacena localmente, como resultado, cuando el usuario regresa a la página que ya ha sido visitada, no hay necesidad de descargar, analizar y compilar scripts para prepararlo para el trabajo. Esto ahorra a Chrome aproximadamente el 40% del tiempo de análisis y compilación. Además, en el caso de los dispositivos móviles, esto lleva a ahorrar batería.
El motor
Carakan , que se utilizó en el navegador Opera y ha sido reemplazado por V8 durante mucho tiempo, podría reutilizar los resultados de la compilación de scripts ya procesados. No era necesario que estas secuencias de comandos se conectaran a la misma página o incluso se cargaran desde el mismo dominio. Esta técnica de almacenamiento en caché, de hecho, es muy efectiva y le permite abandonar por completo el paso de compilación. Ella se basa en escenarios de comportamiento de usuario típicos, en cómo las personas trabajan con recursos web. Es decir, cuando el usuario sigue una determinada secuencia de acciones, mientras trabaja con una aplicación web, se carga el mismo código.
El intérprete
SpiderMonkey utilizado por FireFox no almacena todo en una fila. Es compatible con un sistema de monitoreo que cuenta el número de llamadas a un script en particular. En función de estos indicadores, se determinan las secciones del código que necesitan optimización, es decir, aquellas que tienen la carga máxima.
Por supuesto, algunos desarrolladores de navegadores pueden decidir que sus productos no necesitan almacenamiento en caché. Entonces,
Masei Stachovyak , un desarrollador líder del navegador Safari, dice que Safari no está involucrado en el almacenamiento en caché del
código de bytes compilado. Se consideró la posibilidad de almacenamiento en caché, pero aún no se ha implementado, ya que la generación de código toma menos del 2% del tiempo total de ejecución del programa.
Estas optimizaciones no afectan directamente el análisis del código fuente en JS. En el curso de su aplicación, se hace todo lo posible para, en ciertos casos, omitir completamente este paso. No importa cuán rápido sea el análisis, todavía lleva algún tiempo, y la ausencia total de análisis es quizás el ejemplo de optimización perfecta.
Reduce el tiempo de preparación de aplicaciones web
Como descubrimos anteriormente, sería bueno minimizar la necesidad de analizar scripts, pero no puede deshacerse de él por completo, así que hablemos sobre cómo reducir el tiempo que lleva preparar las aplicaciones web para el trabajo. De hecho, se puede hacer mucho por esto. Por ejemplo, puede minimizar la cantidad de código JS incluido en la aplicación. Un código pequeño que prepara una página para el trabajo puede analizarse más rápido, y lo más probable es que tarde menos tiempo en ejecutarse que un código que sea más voluminoso.
Para reducir la cantidad de código, puede organizar la carga en la página solo lo que realmente necesita, y no una gran pieza de código, que incluye absolutamente todo lo que se necesita para el proyecto web en su conjunto. Entonces, por ejemplo, el patrón
PRPL promueve tal enfoque para cargar código. Como alternativa, puede verificar las dependencias y ver si hay algo redundante en ellas, de modo que solo conduzca a un crecimiento injustificado de la base de código. De hecho, aquí tocamos un gran tema digno de un material separado. De vuelta al análisis.
Por lo tanto, el propósito de este material es discutir técnicas que permitan a un desarrollador web ayudar a un analizador a hacer su trabajo más rápido. Tales técnicas existen. Los analizadores JS modernos utilizan algoritmos heurísticos para determinar si será necesario ejecutar un determinado fragmento de código lo antes posible o si será necesario ejecutarlo más tarde. En base a estas predicciones, el analizador analiza completamente el fragmento de código utilizando el algoritmo de análisis ansioso o utiliza el algoritmo de análisis diferido. Con un análisis completo, comprende las funciones que necesita compilar lo antes posible. Durante este proceso, se resuelven tres tareas principales: crear un AST, crear una jerarquía de áreas de visibilidad y encontrar errores de sintaxis. El análisis diferido, por otro lado, se usa solo para funciones que aún no necesitan compilarse. Esto no crea un AST y no busca errores. Con este enfoque, solo se crea una jerarquía de áreas de visibilidad, lo que ahorra aproximadamente la mitad del tiempo en comparación con las funciones de procesamiento que deben ejecutarse lo antes posible.
De hecho, el concepto no es nuevo. Incluso los navegadores obsoletos como IE9 son compatibles con estos enfoques de optimización, aunque, por supuesto, los sistemas modernos han avanzado mucho.
Examinemos un ejemplo que ilustra el funcionamiento de estos mecanismos. Supongamos que tenemos el siguiente código JS:
function foo() { function bar(x) { return x + 10; } function baz(x, y) { return x + y; } console.log(baz(100, 200)); }
Como en el ejemplo anterior, el código cae en el analizador, que realiza su análisis y forma el AST. Como resultado, el analizador representa un código que consta de las siguientes partes principales (no prestaremos atención a la función
foo
):
- Declarar una función de
bar
que toma un argumento ( x
). Esta función tiene un comando de retorno, devuelve el resultado de agregar x
y 10. - Declarando una función
baz
que toma dos argumentos ( x
e y
). Ella también tiene un comando de retorno, devuelve el resultado de sumar x
e y
. - Hacer una llamada a la función
baz
con dos argumentos: 100 y 200. - Realizar una llamada a la función
console.log
con un argumento, que es el valor devuelto por la función llamada anteriormente.
Así es como se ve.
El resultado de analizar el código de muestra sin aplicar la optimizaciónHablemos de lo que está pasando aquí. El analizador ve la declaración de la función de
bar
, la declaración de la función
baz
, la llamada a la función
baz
y la llamada a la función
console.log
. Obviamente, al analizar este fragmento de código, el analizador encontrará una tarea cuya ejecución no afectará los resultados de este programa. Se trata de analizar la
bar
funciones. ¿Por qué el análisis de esta función no es práctico? La cuestión es que la función de
bar
, al menos en el fragmento de código presentado, nunca se llama. Este ejemplo simple puede parecer exagerado, pero muchas aplicaciones reales tienen una gran cantidad de funciones que nunca se llaman.
En tal situación, en lugar de analizar la función de
bar
, simplemente podemos registrar que se declara, pero que no se usa en ninguna parte. Al mismo tiempo, el análisis real de esta función se realiza cuando es necesario, justo antes de su ejecución. Naturalmente, cuando se realiza un análisis lento, debe detectar el cuerpo de la función y hacer un registro de su declaración, pero aquí es donde termina el trabajo. Para tal función, no es necesario formar un árbol de sintaxis abstracta, ya que el sistema no tiene información de que esta función esté planeada para realizarse. Además, la memoria de almacenamiento dinámico no está asignada, lo que generalmente requiere considerables recursos del sistema. En pocas palabras, la negativa a analizar funciones innecesarias conduce a un aumento significativo en el rendimiento del código.
Como resultado, en el ejemplo anterior, el analizador real formará una estructura similar al siguiente esquema.
Resultado del análisis del código de ejemplo con optimizaciónTenga en cuenta que el analizador hizo una nota sobre la declaración de la
bar
funciones, pero no se ocupó de su análisis posterior. El sistema no hizo ningún esfuerzo por analizar el código de función. En este caso, el cuerpo de la función era un comando para devolver el resultado de cálculos simples. Sin embargo, en la mayoría de las aplicaciones del mundo real, el código de función puede ser mucho más largo y complejo, y contiene muchos comandos de retorno, condiciones, bucles, comandos de declaración de variables y funciones anidadas. Analizar todo esto, siempre que tales funciones nunca se llamen, es una pérdida de tiempo.
No hay nada complicado en el concepto descrito anteriormente, pero su implementación práctica no es una tarea fácil. Aquí examinamos un ejemplo muy simple y, de hecho, al decidir si un determinado fragmento de código tendrá demanda en un programa, es necesario analizar funciones, bucles, operadores condicionales y objetos. En general, podemos decir que el analizador necesita procesar y analizar absolutamente todo lo que está en el programa.
Aquí, por ejemplo, hay un patrón muy común para implementar módulos en JavaScript:
var myModule = (function() {
La mayoría de los analizadores JS modernos reconocen este patrón; para ellos es una señal de que el código ubicado dentro del módulo debe analizarse completamente.
Pero, ¿qué pasaría si los analizadores sintácticos siempre usaran el análisis lento? Esto, desafortunadamente, no es una buena idea. El hecho es que con este enfoque, si es necesario ejecutar algún código lo antes posible, encontraremos una desaceleración en el sistema. El analizador realizará una pasada de análisis diferido, después de lo cual comenzará a analizar inmediatamente lo que debe hacerse lo antes posible. Esto conducirá a una desaceleración de aproximadamente el 50% en comparación con el enfoque cuando el analizador comienza inmediatamente a analizar completamente el código más importante.
Optimización de código, teniendo en cuenta las características de su análisis.
Ahora que hemos descubierto un poco sobre lo que está sucediendo dentro de los analizadores, es hora de pensar qué se puede hacer para ayudarlos. Podemos escribir código para que el análisis de funciones se realice en el momento que lo necesitemos. Hay un patrón que la mayoría de los analizadores entienden. Se expresa en el hecho de que las funciones están entre corchetes. Tal diseño casi siempre le dice al analizador que la función necesita ser desmontada inmediatamente. Si el analizador detecta un paréntesis de apertura, inmediatamente después de lo cual sigue la declaración de la función, comenzará inmediatamente a analizar la función. Podemos ayudar al analizador aplicando esta técnica al describir las funciones que deben realizarse lo antes posible.
Supongamos que tenemos una función
foo
:
function foo(x) { return x * 10; }
Dado que no hay una indicación explícita en este fragmento de código de que esta función está programada para ejecutarse de inmediato, el navegador solo realizará su análisis diferido. Sin embargo, estamos seguros de que necesitaremos esta función muy pronto, por lo que podemos recurrir al próximo truco.
Primero, guarde la función en una variable:
var foo = function foo(x) { return x * 10; };
Tenga en cuenta que dejamos el nombre de la función inicial entre la palabra clave de la
function
y el paréntesis de apertura. No se puede decir que esto sea absolutamente necesario, pero se recomienda hacer exactamente eso, porque si se lanza una excepción cuando la función se está ejecutando, puede ver el nombre de la función en los datos de seguimiento de la pila, no
<anonymous>
.
Después del cambio anterior, el analizador continuará utilizando el análisis lento. Para cambiar esto, un pequeño detalle es suficiente. La función debe estar entre corchetes:
var foo = (function foo(x) { return x * 10; });
Ahora, cuando el analizador encuentre un paréntesis de apertura frente a la palabra clave de la
function
, comenzará a analizar esta función de inmediato.
Puede que no sea fácil realizar tales optimizaciones manualmente, porque para esto necesita saber en qué casos el analizador realizará un análisis lento y en qué casos el completo. Además, para hacer esto, debe dedicar tiempo a decidir si una función en particular debe estar lista para trabajar lo más rápido posible o no.
Los programadores, por supuesto, no querrán asumir todo este trabajo adicional. Además, lo cual no es menos importante que todo lo que ya se ha dicho, el código procesado de esta manera será más difícil de leer y comprender. En esta situación, los paquetes de software especiales como Optimize.js están listos para ayudarnos. Su objetivo principal es optimizar el tiempo de arranque inicial para el código fuente JS. Realizan análisis de código estático y lo modifican para que las funciones que deban ejecutarse lo antes posible estén entre corchetes, lo que lleva al hecho de que el navegador las analiza de inmediato y las prepara para su ejecución.
Entonces, supongamos que programamos, sin pensar realmente en nada, y tenemos el siguiente fragmento de código:
(function() { console.log('Hello, World!'); })();
Parece bastante normal, funciona como se esperaba, se ejecuta rápidamente, ya que el analizador encuentra el paréntesis de apertura frente a la palabra clave de
function
. Hasta ahora todo bien. , , , :
!function(){console.log('Hello, World!')}();
, , . , - .
, , . , , , . , , , . , , . Optimize.js. Optimize.js, :
!(function(){console.log('Hello, World!')})();
, . , . , , , — .
, JS- — , . ? , , , , . , , , , JS- , . , , , -, . - . , , . , , , , . , JS- , , V8 , , . .
, -:
- . .
- , .
- , , , JS-. , , .
- DeviceTiming , .
- Optimize.js , , .
Resumen
, ,
SessionStack , , -, . , . — . , — , -, , , .
Estimados lectores! - JavaScript-?