Como o JavaScript funciona: Análise, resumo das árvores de sintaxe (ASTs) + 5 dicas sobre como minimizar o tempo de análise

Robisson Oliveira
React Brasil
15 min readMay 24, 2019

--

Este é o post #14 da série dedicada a explorar JavaScript e seus componentes de construção. No processo de identificação e descrição dos elementos centrais, também compartilhamos algumas regras práticas que usamos ao criar o SessionStack , um aplicativo JavaScript que precisa ser robusto e altamente eficiente para ajudar os usuários a ver e reproduzir seus defeitos do aplicativo da Web em tempo real.

Se você perdeu os capítulos anteriores, você pode encontrá-los aqui:

Visão geral

Nós todos sabemos como as coisas podem ficar confusas, terminando em uma grande bolha de JavaScript. Não apenas esse trecho de código precisa ser transferido pela rede, mas também deve ser analisado, compilado em bytecode e finalmente executado. Nas postagens anteriores, discutimos tópicos como a engine JS, o tempo de execução e a call stack, bem como o mecanismo V8 usado principalmente pelo Google Chrome e pelo NodeJS. Todos eles desempenham um papel vital em todo o processo de execução do JavaScript. O tópico que estamos planejando apresentar hoje não é menos importante: veremos como a maioria das engines JavaScript analisa o texto em algo significativo para a máquina, o que acontece depois e como nós, como desenvolvedores da Web, podemos transformar esse conhecimento em nossa vantagem.

Como funcionam as linguagens de programação

Então, vamos dar um passo para trás e ver como as linguagens de programação funcionam em primeiro lugar. Não importa qual linguagem de programação você esteja usando, você sempre precisará de algum software que possa pegar o código-fonte e fazer o computador realmente fazer alguma coisa. Este software pode ser um intérprete ou um compilador. Não importa se você está usando uma linguagem interpretada (JavaScript, Python, Ruby) ou compilada (C #, Java, Rust), sempre haverá uma parte comum: analisar o código-fonte como texto simples em uma estrutura de dados chamada árvore de sintaxe abstrata (AST). Não só ASTs apresentam o código-fonte de forma estruturada, mas também desempenham um papel crítico na análise semântica, onde o compilador valida a correção e o uso adequado do programa e dos elementos da linguagem. Mais tarde, os ASTs são usados ​​para gerar o bytecode ou o código da máquina.

Aplicações AST

As ASTs não são usadas apenas em intérpretes de linguagem e compiladores. Eles têm vários aplicativos no mundo da informática. Uma das maneiras mais comuns de usá-los é para análise de código estático. Os analisadores estáticos não executam o código dado às suas entradas. Ainda assim, eles precisam entender a estrutura do código. Por exemplo, você pode querer implementar uma ferramenta que encontre estruturas de código comuns, para que você possa refatorá-las para reduzir a duplicação. Você pode fazer isso usando a comparação de strings, mas a implementação será muito básica e limitada. Naturalmente, se você estiver interessado em implementar essa ferramenta, não precisará escrever seu próprio analisador. Existem muitas implementações de código aberto que são totalmente compatíveis com as especificações do Ecmascript. Esprima e Acorn, para citar um casal. Há também muitas ferramentas que podem ajudar com a saída produzida pelo analisador, ou seja, os ASTs. ASTs também são amplamente utilizados na implementação de transpiladores de código. Por exemplo, você pode querer implementar um transpilador que converta código Python em JavaScript. A idéia básica é que você usaria um transpilador Python para gerar um AST que você usaria para gerar de volta o código JavaScript. Você pode perguntar: como isso é possível? O problema é que as ASTs são apenas uma maneira diferente de representar alguma linguagem. Antes da análise é representado como texto que segue algumas regras que compõem uma linguagem. Após a análise, ela é representada como uma estrutura de árvore que contém exatamente as mesmas informações que o texto de entrada. Portanto, podemos sempre fazer o passo oposto e voltar a uma representação textual.

Análise de JavaScript

Então vamos ver como um AST é construído. Nós temos uma função JavaScript simples como um exemplo:

O analisador produzirá o seguinte AST.

Observe que, para fins de visualização, essa é uma versão simplificada do que o analisador produziria. A AST real é muito mais complexa. A ideia aqui, no entanto, é ter uma ideia do que seria a primeira coisa que aconteceria ao código-fonte antes de ser executado. Se você quiser ver como é a AST, pode verificar o AST Explorer . É uma ferramenta online na qual você passa algum JavaScript e gera o AST para esse código.

Por que preciso saber como o analisador JavaScript funciona, você pode perguntar. Afinal, deve ser responsabilidade do navegador fazê-lo funcionar. E você está certo, mais ou menos. O gráfico abaixo mostra a alocação total de tempo para as diferentes etapas do processo de execução do JavaScript. Observe atentamente e veja se acha algo interessante.

Você viu isso? Olhe mais de perto. Em média, o navegador leva cerca de 15% a 20% do tempo total de execução para analisar o JavaScript. Eu não inventei os números. Estas são estatísticas de aplicativos do mundo real e sites que utilizam JavaScript de uma forma ou de outra. Agora, 15% podem não parecer muito para você, mas acredite, é verdade. Um SPA típico carrega cerca de 0,4MB de JavaScript e leva o navegador a aproximadamente 370ms para analisá-lo. Mais uma vez, você pode dizer, bem, isso não é muito. Não é muito por si só. Tenha em mente que este é apenas o tempo necessário para analisar o código JavaScript em ASTs. Isso não inclui a execução em si ou qualquer um dos demais processos que ocorrem durante um carregamento de página, como renderização de CSS e HTML . E isso tudo se refere apenas ao desktop. Quando entramos no celular, as coisas ficam mais complicadas rapidamente. O tempo gasto na análise pode ser de duas a cinco vezes maior em telefones do que em computadores.

O gráfico acima mostra o tempo de análise do pacote de 1MB de JavaScript em dispositivos móveis e de área de trabalho de classe diferente.

Além disso, os aplicativos da Web estão ficando mais complexos a cada minuto, à medida que mais lógica de negócios vai para o lado do cliente, para introduzir uma experiência de usuário mais nativa. Você pode entender facilmente o quanto isso está afetando seu aplicativo/website. Tudo o que você precisa fazer é abrir as ferramentas de desenvolvimento do navegador e medir o tempo gasto em análise, compilação e tudo mais que está acontecendo no navegador até que a página esteja totalmente carregada.

Infelizmente, não há ferramentas de desenvolvimento em navegadores móveis. Não se preocupe, não significa que não há nada que você possa fazer sobre isso. É por isso que existem ferramentas como o DeviceTiming . Ele pode ajudá-lo a medir tempos de análise e execução de scripts em um ambiente controlado. Ele funciona agrupando scripts locais com código de instrumentação para que cada vez que suas páginas sejam atingidas a partir de dispositivos diferentes, você possa medir localmente os tempos de análise e execução.

O bom é que os mecanismos JavaScript fazem muito para evitar trabalho redundante e ficar mais otimizados. Aqui estão algumas coisas que os mecanismos fazem nos principais navegadores.

O V8, por exemplo, faz script de streaming e cache de código. O fluxo de script significa que scripts assíncronos e adiados são analisados ​​em um thread separado assim que o download é iniciado. Isso indica que a análise é feita quase imediatamente após o download do script. Isso resulta em páginas sendo carregadas cerca de 10% mais rápido.

O código JavaScript é geralmente compilado em bytecode em cada visita à página. Esse bytecode, no entanto, é descartado assim que o usuário navega para outra página. Isso acontece porque o código compilado depende muito do estado e contexto da máquina no momento da compilação. É aqui que o Chrome 42 introduz o armazenamento em cache de bytecode. É uma técnica que armazena o código compilado localmente, assim, quando o usuário retorna à mesma página, todas as etapas, como download, análise e compilação, podem ser ignoradas. Isso permite que o Chrome economize cerca de 40% no tempo de análise e compilação. Além disso, isso também resulta em economizar a vida útil da bateria dos dispositivos móveis.

No Opera, a engine Carakan pode reutilizar a saída do compilador de outro programa que foi compilado recentemente. Não há exigência de que o código seja da mesma página ou até do domínio. Essa técnica de armazenamento em cache é realmente muito eficaz e pode ignorar completamente a etapa de compilação. Ele se baseia no comportamento típico do usuário e nos cenários de navegação: sempre que o usuário segue uma determinada jornada do usuário no aplicativo/site, o mesmo código JavaScript é carregado. No entanto, a engine Carakan foi substituída há muito tempo pelo V8 do Google.

A engine SpiderMonkey usado pelo Firefox não armazena tudo em cache. Ele pode fazer a transição para um estágio de monitoramento em que conta quantas vezes um determinado script está sendo executado. Com base nessa contagem, determina quais partes do código estão quentes e precisam ser otimizadas.

Obviamente, alguns tomam a decisão de não fazer nada. Maciej Stachowiak , o desenvolvedor líder do Safari, afirma que o Safari não faz nenhum armazenamento em cache do código de bytes compilado. É algo que eles consideraram, mas não implementaram, já que a geração de código é inferior a 2% do tempo total de execução.

Essas otimizações não afetam diretamente a análise do código-fonte do JavaScript, mas fazem o possível para ignorá-lo completamente. O que pode ser uma otimização melhor do que não fazê-lo completamente?

Há muitas coisas que podemos fazer para melhorar o tempo de carregamento inicial de nossos aplicativos. Podemos minimizar a quantidade de JavaScript que estamos enviando: menos script, menos análise, menos execução. Para fazer isso, podemos entregar apenas o código necessário em uma rota específica, em vez de carregar um grande blob de tudo. Por exemplo, o padrão PRPL prega esse tipo de entrega de código. Alternativamente, podemos verificar nossas dependências e ver se há algo redundante que possa estar fazendo nada além de inchar nossa base de código. Essas coisas merecem um tópico próprio, no entanto.

O objetivo deste artigo é discutir o que nós, como desenvolvedores da Web, podemos fazer para ajudar o analisador JavaScript a fazer seu trabalho mais rapidamente. E aqui está. Analisadores modernos de JavaScript usam heurísticas para determinar se uma determinada parte do código será executada imediatamente ou se sua execução será adiada por algum tempo no futuro. Com base nessas heurísticas, o analisador fará uma análise ansiosa ou preguiçosa. Análises ágeis percorrem as funções que precisam ser compiladas imediatamente. Ele faz três coisas principais: constrói o AST, constrói a hierarquia do escopo e localiza todos os erros de sintaxe. A análise preguiçosa, por outro lado, é usada apenas em funções que não precisam ser compiladas ainda. Ele não cria um AST e não encontra todos os erros de sintaxe. Ele apenas constrói a hierarquia de escopo, o que economiza cerca de metade do tempo comparado à avaliação ansiosa.

Claramente, isso não é um conceito novo. Mesmo navegadores como o IE 9 suportam esse tipo de otimização, embora de uma maneira bastante rudimentar, em comparação com a maneira como os analisadores de hoje funcionam.

Então, vamos ver um exemplo de como isso funciona. Digamos que temos algum JavaScript que tenha o seguinte trecho de código:

Assim como no exemplo anterior, o código é alimentado no analisador que faz a análise sintática e gera uma AST. Então, temos algo ao longo das linhas de:

Função declaração de foo que aceita um argumento (x). Tem uma declaração de retorno. A função retorna o resultado da operação + sobre x e 10.

Função declaração de bar que aceita dois argumentos (x e y). Tem uma declaração de retorno. A função retorna o resultado da operação + sobre x e y.

Faça uma chamada de função para barrar com dois argumentos 40 e 2.

Faça uma chamada de função para console.log com um argumento o resultado da chamada de função anterior.

Então o que aconteceu? O analisador viu uma declaração da função foo, uma declaração da função bar, uma chamada da função bar e uma chamada da função console.log. Mas espere um minuto … há algum trabalho extra feito pelo analisador que é completamente irrelevante. Essa é a análise da função foo. Por que isso é irrelevante? Porque a função foo nunca é chamada (ou pelo menos não nesse momento). Este é um exemplo simples e pode parecer algo incomum, mas em muitos aplicativos do mundo real, muitas das funções declaradas nunca são chamadas.

Aqui, em vez de analisar a função foo, podemos notar que ela é declarada sem especificar o que ela faz. A análise real ocorre quando necessário, pouco antes de a função ser executada. E sim, a análise preguiçosa ainda precisa encontrar todo o corpo da função e fazer uma declaração para isso, mas é só isso. Não precisa da árvore de sintaxe porque ela ainda não será processada. Além disso, ele não aloca memória do heap que normalmente ocupa uma boa quantidade de recursos do sistema. Em resumo, pular essas etapas introduz uma grande melhoria no desempenho.

Portanto, no exemplo anterior, o analisador faria algo como o seguinte.

Note que a declaração da função foo é reconhecida, mas é isso. Nada mais foi feito para entrar no corpo da função em si. Neste caso, o corpo da função era apenas uma declaração de retorno. No entanto, como na maioria dos aplicativos do mundo real, ele pode ser muito maior, contendo várias instruções de retorno, condicionais, loops, declarações de variáveis ​​e até mesmo declarações de função aninhadas. E tudo isso seria um completo desperdício de tempo e recursos do sistema, já que a função nunca será chamada.

É um conceito bastante simples, mas, na realidade, sua implementação está longe de ser simples. Aqui mostramos um exemplo que definitivamente não é o único caso. Todo o método se aplica a funções, loops, condicionais, objetos, etc. Basicamente, tudo o que precisa ser analisado.

Por exemplo, aqui está um padrão bastante comum para implementar módulos em JavaScript.

Esse padrão é reconhecido pela maioria dos analisadores JavaScript modernos e é um sinal de que o código deve ser analisado ansiosamente.

Então, por que os analisadores nem sempre analisam preguiçosamente? Se algo é analisado preguiçosamente, tem que ser executado imediatamente, e isso na verdade irá torná-lo mais lento. Vai fazer uma única análise preguiçosa e outra análise ansiosa logo após a primeira. Isso resultará em uma desaceleração de 50% em comparação com apenas analisá-lo ansiosamente.

Agora que temos uma compreensão básica do que está acontecendo nos bastidores, é hora de pensar sobre o que podemos fazer para dar uma mão ao analisador. Podemos escrever nosso código de tal maneira que as funções sejam analisadas no momento certo. Existe um padrão que é reconhecido pela maioria dos analisadores: envolvendo uma função entre parênteses. Isso é quase sempre um sinal positivo para o analisador de que a função será executada imediatamente. Se o analisador vir um parêntese de abertura e imediatamente depois disso uma declaração de função, analisará ansiosamente a função. Podemos ajudar o analisador declarando explicitamente uma função como tal que será executada imediatamente.

Digamos que tenhamos uma função chamada foo.

Como não há sinais óbvios de que a função será executada imediatamente, o navegador fará uma análise preguiçosa. No entanto, temos certeza de que isso não está correto, para que possamos fazer duas coisas.

Primeiro, nós armazenamos a função em uma variável:

Note que deixamos o nome da função entre a palavra-chave da função e o parêntese de abertura antes dos argumentos da função. Isso não é necessário, mas é recomendado, pois, no caso de uma exceção lançada, o stacktrace conterá o nome real da função, em vez de apenas dizer <anônimo>.

O analisador ainda vai fazer uma análise preguiçosa. Isso pode ser evitado adicionando um pequeno detalhe: envolvendo a função entre parênteses.

Neste ponto, quando o analisador vê o parêntese de abertura antes da palavra-chave da função, ele fará imediatamente uma análise rápida.

Isso pode ser difícil de gerenciar manualmente, pois precisaremos saber em quais casos o analisador decidirá analisar o código preguiçosamente ou ansiosamente. Além disso, precisaríamos gastar tempo pensando se uma determinada função será invocada imediatamente ou não. Nós certamente não queremos fazer isso. Por último, mas não menos importante, isso tornará nosso código mais difícil de ler e entender. Para nos ajudar a fazer isso, ferramentas como Optimize.js vêm para o resgate. Seu único objetivo é otimizar o tempo de carregamento inicial do código-fonte do JavaScript. Eles fazem uma análise estática do seu código e o modificam de tal forma que as funções que precisam ser executadas primeiro sejam colocadas entre parênteses para que o navegador possa analisá-las ansiosamente e prepará-las para a execução.

Então, estamos codificando como de costume e há um código que se parece com isso:

Tudo parece bem, funcionando como esperado e é rápido porque há um parêntese de abertura antes da declaração da função. Ótimo. Claro que, antes de entrar em produção, precisamos minimizar nosso código para salvar bytes.O código a seguir é a saída do minificador:

Parece ok. O código funciona como antes. Há algo faltando embora. O minifier removeu o parêntese que envolve a função e, em vez disso, colocou um único ponto de exclamação antes da função. Isso significa que o analisador irá ignorar isso e fará uma análise preguiçosa. No topo, para poder executar a função, fará uma análise rápida logo após a preguiça. Isso tudo faz nosso código ficar mais lento. Felizmente, temos ferramentas como Optimize.js que fazem o trabalho duro para nós. Passar o código reduzido pelo Optimize.js produzirá a seguinte saída:

Isso é mais parecido. Agora, temos o melhor dos dois mundos: o código é reduzido e o analisador identifica corretamente quais funções precisam ser analisadas ansiosamente e com preguiça.

Pré-compilação

Mas por que não podemos fazer todo esse trabalho no lado do servidor?Afinal, é muito melhor fazer isso uma vez e entregar os resultados ao cliente, em vez de forçar cada cliente a fazer o trabalho toda vez. Bem, há uma discussão em andamento sobre se os mecanismos devem oferecer uma maneira de executar scripts pré-compilados para que esse tempo não seja desperdiçado no navegador. Em essência, a ideia é ter uma ferramenta do lado do servidor que possa gerar o bytecode, o qual precisaríamos apenas transferir através do fio e executá-lo no lado do cliente. Então, veríamos algumas diferenças importantes no tempo de inicialização. Pode parecer tentador, mas não é tão simples assim. Isso pode ter o efeito oposto, já que seria maior e, provavelmente, precisaria assinar o código e processá-lo por motivos de segurança. A equipe do V8, por exemplo, está trabalhando internamente para evitar reparos, de modo que a pré-compilação não seja realmente benéfica.

Algumas dicas que você pode seguir para entregar seu aplicativo para os usuários o mais rápido possível

  • Verifique suas dependências. Livre-se de tudo que não é necessário.
  • Divida seu código em partes menores em vez de carregar um grande blob.
  • Adie o carregamento de JavaScript quando possível. Você pode carregar apenas os pedaços de código necessários com base na rota atual.
  • Use as ferramentas dev e DeviceTiming para descobrir onde está o gargalo.
  • Use ferramentas como Optimize.js para ajudar o analisador a decidir quando analisar ansiosamente e quando preguiçosamente.

O SessionStack é uma ferramenta que recria visualmente tudo o que aconteceu com os usuários finais no momento em que eles tiveram um problema ao interagir com um aplicativo da web. A ferramenta não reproduz a sessão como um vídeo real, mas simula todos os eventos em um ambiente de área restrita no navegador. Isso traz algumas implicações, por exemplo, em cenários em que a base de código da página atualmente carregada se torna grande e complexa.

As técnicas acima são algo que recentemente começamos a incorporar no processo de desenvolvimento do SessionStack. Essas otimizações nos permitem carregar o SessionStack mais rapidamente. O SessionStack mais veloz pode liberar os recursos do navegador para uma experiência de usuário mais natural e natural que a ferramenta oferecerá ao carregar e assistir às sessões do usuário.

Existe um plano gratuito se você quiser experimentar o SessionStack .

Referências

Este é um artigo traduzido com a autorização de Alexander Zlatkov, CEO da SessionStack. O artigo original pode ser lido em https://blog.sessionstack.com/how-javascript-works-parsing-abstract-syntax-trees-asts-5-tips-on-how-to-minimize-parse-time-abfcf7e8a0c8

Autor do post original — Lachezar Nickolov — Co-founder & CTO @SessionStack

--

--

Robisson Oliveira
React Brasil

Senior Cloud Application at Amazon Web Services(AWS)