[Leitura recomendada] As outras 19 partes do ciclo Todos sabemos que o código JavaScript para projetos da Web pode crescer em um tamanho enorme. E quanto maior o código, mais o navegador o carregará. Mas o problema aqui não é apenas no momento da transmissão de dados pela rede. Após o carregamento do programa, ele ainda precisa ser analisado, compilado no bytecode e finalmente executado. Hoje, chamamos a atenção para a tradução da parte 14 da série de ecossistemas JavaScript. Nomeadamente, falaremos sobre a análise do código JS, como as árvores de sintaxe abstrata são construídas e como um programador pode influenciar esses processos, obtendo um aumento na velocidade de seus aplicativos.

Como estão as linguagens de programação
Antes de falar sobre árvores de sintaxe abstrata, vamos nos concentrar em como as linguagens de programação funcionam. Independentemente de qual idioma você usa, você sempre precisa usar certos programas que pegam o código-fonte e o convertem em algo que contém comandos específicos para as máquinas. Intérpretes ou compiladores agem como tais programas. Não importa se você escreve em uma linguagem interpretada (JavaScript, Python, Ruby) ou compilada (C #, Java, Rust), seu código, que é texto sem formatação, sempre passará pelo estágio de análise, ou seja, transformar texto sem formatação em uma estrutura de dados chamado de árvore de sintaxe abstrata (AST).
As árvores de sintaxe abstrata não apenas fornecem uma representação estruturada do código-fonte, mas também desempenham um papel crucial na análise semântica, durante a qual o compilador verifica a correção das construções de software e o uso correto de seus elementos. Após formar o AST e executar verificações, essa estrutura é usada para gerar código de código ou máquina.
Usando árvores de sintaxe abstrata
Árvores de sintaxe abstratas são usadas não apenas em intérpretes e compiladores. Eles, no mundo dos computadores, são úteis em muitas outras áreas. Uma das aplicações mais comuns é a análise de código estático. Os analisadores estáticos não executam o código passado para eles. No entanto, apesar disso, eles precisam entender a estrutura dos programas.
Suponha que você deseje desenvolver uma ferramenta que encontre estruturas que ocorrem com frequência no seu código. Os relatórios dessa ferramenta ajudarão na refatoração e reduzirão a duplicação de código. Isso pode ser feito usando a comparação usual de cadeias, mas essa abordagem será muito primitiva, seus recursos serão limitados. De fato, se você deseja criar uma ferramenta semelhante, não precisa escrever seu próprio analisador para JavaScript. Existem muitas implementações de código aberto desses programas que são totalmente compatíveis com a especificação ECMAScript. Por exemplo - Esprima e Acorn. Também existem ferramentas que podem ajudar no trabalho com o que os analisadores geram, ou seja, no trabalho com árvores de sintaxe abstratas.
Além disso, as árvores de sintaxe abstrata são amplamente utilizadas no desenvolvimento de transpilers. Suponha que você decida desenvolver um transpiler que converta o código Python em código JavaScript. Um projeto semelhante pode ser baseado na ideia de que um transpiler é usado para criar uma árvore de sintaxe abstrata baseada no código Python, que, por sua vez, é convertido em código JavaScript. Provavelmente aqui você se perguntará como isso é possível. O problema é que as árvores de sintaxe abstrata são apenas uma maneira alternativa de representar código em alguma linguagem de programação. Antes de o código ser convertido para AST, ele se parece com texto comum, quando escrito, que segue certas regras que formam o idioma. Após a análise, esse código se transforma em uma estrutura em árvore que contém as mesmas informações que o código-fonte do programa. Como resultado, é possível realizar não apenas a transição do código fonte para o AST, mas também a transformação inversa, transformando a árvore da sintaxe abstrata em uma representação de texto do código do programa.
Analisando JavaScript
Vamos falar sobre como as árvores de sintaxe abstrata são construídas. Como exemplo, considere uma função JavaScript simples:
function foo(x) { if (x > 10) { var a = 2; return a * x; } return x + 10; }
O analisador criará uma árvore de sintaxe abstrata, representada esquematicamente na figura a seguir.
Árvore de sintaxe abstrataObserve que esta é uma representação simplificada dos resultados do analisador. Uma verdadeira árvore de sintaxe abstrata parece muito mais complicada. Nesse caso, nosso principal objetivo é ter uma idéia do que, em primeiro lugar, o código fonte se transforma antes de ser executado. Se você estiver interessado em ver como é uma árvore de sintaxe abstrata real, use o site do
AST Explorer . Para gerar um AST para um determinado fragmento de código JS, basta colocá-lo no campo correspondente da página.
Talvez aqui você tenha uma pergunta sobre por que o programador precisa saber como o analisador JS funciona. No final, analisar e executar o código é uma tarefa do navegador. De certa forma, você está certo. A figura abaixo mostra o tempo necessário para que alguns projetos da web conhecidos executem várias etapas no processo de execução do código JS.
Dê uma olhada neste desenho, talvez você veja algo interessante lá.
Tempo gasto na execução do código JSEstá vendo? Caso contrário, olhe novamente. Na verdade, estamos falando do fato de que, em média, os navegadores passam de 15 a 20% do tempo analisando o código JS. E isso não é alguns dados condicionais. Aqui estão informações estatísticas sobre o trabalho de projetos reais da Web que usam JavaScript de uma maneira ou de outra. Talvez o número de 15% possa não parecer tão grande para você, mas acredite, isso é muito. Um aplicativo típico de uma página carrega aproximadamente 0,4 MB de código JavaScript e o navegador precisa de aproximadamente 370 ms para analisar esse código. Mais uma vez, você pode dizer que não há com o que se preocupar. E sim, isso por si só não é muito. No entanto, não esqueça que este é apenas o tempo necessário para analisar o código e transformá-lo em um AST. Isso não inclui o tempo necessário para executar o código ou o tempo necessário para resolver outras tarefas que acompanham o carregamento da página, por exemplo, as tarefas de processamento de HTML e CSS e
renderização da página . Além disso, estamos falando apenas de navegadores de desktop. No caso de sistemas móveis ainda é pior. Em particular, o tempo de análise para o mesmo código em dispositivos móveis pode ser 2-5 vezes maior que no computador. Veja a figura a seguir.
Tempo de análise de 1 MB de código JS em vários dispositivosAqui está o tempo necessário para analisar 1 MB de código JS em vários dispositivos móveis e de desktop.
Além disso, os aplicativos da Web estão constantemente se tornando mais complexos e cada vez mais tarefas estão sendo transferidas para o lado do cliente. Tudo isso visa melhorar a experiência do usuário de trabalhar com sites, a fim de aproximar esses sentimentos daqueles que os usuários experimentam ao interagir com aplicativos tradicionais. É fácil descobrir o quanto isso afeta os projetos da web. Para fazer isso, basta abrir as ferramentas do desenvolvedor no navegador, acessar um site moderno e ver quanto tempo é gasto na análise do código, compilação e tudo o mais que acontece no navegador ao preparar a página para o trabalho.
Análise de site usando ferramentas de desenvolvedor em um navegadorInfelizmente, os navegadores móveis não possuem essas ferramentas. No entanto, isso não significa que as versões móveis dos sites não possam ser analisadas. Aqui ferramentas como o
DeviceTiming virão em nosso auxílio. Com o DeviceTiming, você pode medir o tempo necessário para analisar e executar scripts em ambientes gerenciados. Isso funciona graças à colocação de scripts locais no ambiente formado pelo código auxiliar, o que leva ao fato de que toda vez que a página é carregada de vários dispositivos, temos a oportunidade de medir localmente o tempo de análise e execução do código.
Analisando otimização e mecanismos JS
Os mecanismos JS fazem muitas coisas úteis para evitar trabalho desnecessário e otimizar os processos de processamento de código. Aqui estão alguns exemplos.
O mecanismo V8 suporta scripts de streaming e cache de código. Nesse caso, streaming significa que o sistema analisa scripts carregados de forma assíncrona e scripts atrasados em um encadeamento separado, começando a fazer isso a partir do momento em que o código começa a ser carregado. Isso leva ao fato de que a análise termina quase simultaneamente com a conclusão do carregamento do script, o que reduz em cerca de 10% o tempo necessário para preparar as páginas para o trabalho.
O código JavaScript geralmente é compilado no código de bytes toda vez que uma página é visitada. Esse bytecode, no entanto, é perdido depois que o usuário navega para outra página. Isso se deve ao fato de o código compilado ser altamente dependente do estado e do contexto do sistema no momento da compilação. Para melhorar a situação, o Chrome 42 introduziu o suporte ao cache de bytecode. Graças a essa inovação, o código compilado é armazenado localmente; como resultado, quando o usuário retorna à página que já foi visitada, não há necessidade de baixar, analisar e compilar scripts para prepará-lo para o trabalho. Isso economiza o Chrome cerca de 40% do tempo analisando e compilando. Além disso, no caso de dispositivos móveis, isso economiza energia da bateria.
O mecanismo
Carakan , que foi usado no navegador Opera e foi substituído pelo V8 por um longo tempo, poderia reutilizar os resultados da compilação de scripts já processados. Não era necessário que esses scripts fossem conectados à mesma página ou mesmo carregados do mesmo domínio. Essa técnica de armazenamento em cache, de fato, é muito eficaz e permite que você abandone completamente a etapa de compilação. Ela se baseia em cenários típicos de comportamento do usuário, em como as pessoas trabalham com recursos da Web. Ou seja, quando o usuário segue uma determinada sequência de ações, enquanto trabalha com um aplicativo Web, o mesmo código é carregado.
O intérprete
SpiderMonkey usado pelo FireFox não armazena em cache tudo em uma linha. Ele suporta um sistema de monitoramento que conta o número de chamadas para um script específico. Com base nesses indicadores, são determinadas seções do código que precisam de otimização, ou seja, aquelas com carga máxima.
Obviamente, alguns desenvolvedores de navegadores podem decidir que seus produtos não precisam ser armazenados em cache. Portanto,
Masei Stachovyak , desenvolvedor líder do navegador Safari, diz que o Safari não está envolvido no cache do
código de código compilado. A possibilidade de armazenamento em cache foi considerada, mas ainda não foi implementada, pois a geração de código leva menos de 2% do tempo total de execução do programa.
Essas otimizações não afetam diretamente a análise do código-fonte em JS. No curso de sua aplicação, tudo é feito para, em certos casos, pular completamente esta etapa. Não importa a rapidez da análise, ainda leva algum tempo, e a completa ausência de análise talvez seja o exemplo de otimização perfeita.
Reduza o tempo de preparação de aplicativos da web
Como descobrimos acima, seria bom minimizar a necessidade de scripts de análise, mas você não pode se livrar completamente dele, então vamos falar sobre como reduzir o tempo necessário para preparar aplicativos da Web para o trabalho. De fato, muito pode ser feito para isso. Por exemplo, você pode minimizar a quantidade de código JS incluído no aplicativo. Um pequeno código que prepara uma página para o trabalho pode ser analisado mais rapidamente e provavelmente levará menos tempo para ser executado do que um código mais volumoso.
Para reduzir a quantidade de código, você pode organizar o carregamento na página apenas do que ele realmente precisa, e não um pedaço enorme de código, que inclui absolutamente tudo o que é necessário para o projeto da web como um todo. Assim, por exemplo, o padrão
PRPL promove exatamente essa abordagem para carregar código. Como alternativa, você pode verificar as dependências e verificar se há algo redundante nelas, de modo que isso leve apenas a um crescimento injustificado da base de código. De fato, aqui abordamos um grande tópico digno de um material separado. Voltar para a análise.
Portanto, o objetivo deste material é discutir técnicas que permitem que um desenvolvedor da Web ajude um analisador a realizar seu trabalho mais rapidamente. Tais técnicas existem. Os analisadores JS modernos usam algoritmos heurísticos para determinar se será necessário executar um determinado pedaço de código o mais rápido possível ou se será necessário executá-lo posteriormente. Com base nessas previsões, o analisador analisa completamente o fragmento de código usando o algoritmo de análise ansioso ou usa o algoritmo de análise lenta. Com uma análise completa, você entende as funções que precisa compilar o mais rápido possível. Durante esse processo, três tarefas principais são resolvidas: criar um AST, criar uma hierarquia de áreas de visibilidade e localizar erros de sintaxe. A análise preguiçosa, por outro lado, é usada apenas para funções que ainda não precisam ser compiladas. Isso não cria um AST e não procura erros. Com essa abordagem, apenas uma hierarquia de áreas de visibilidade é criada, o que economiza cerca de metade do tempo em comparação com as funções de processamento que precisam ser executadas o mais rápido possível.
De fato, o conceito não é novo. Até navegadores desatualizados como o IE9 suportam essas abordagens de otimização, embora, é claro, os sistemas modernos tenham ido muito à frente.
Vamos examinar um exemplo que ilustra a operação desses mecanismos. Suponha que tenhamos o seguinte código JS:
function foo() { function bar(x) { return x + 10; } function baz(x, y) { return x + y; } console.log(baz(100, 200)); }
Como no exemplo anterior, o código cai no analisador, que executa sua análise e forma o AST. Como resultado, o analisador representa um código que consiste nas seguintes partes principais (não prestaremos atenção à função
foo
):
- Declarando uma função de
bar
que recebe um argumento ( x
). Esta função possui um comando de retorno, retorna o resultado da adição de x
e 10. - Declarando uma função
baz
que recebe dois argumentos ( x
e y
). Ela também tem um comando de retorno, ela retorna o resultado da adição de y
. - Fazer uma chamada para a função
baz
com dois argumentos - 100 e 200. - Fazer uma chamada para a função
console.log
com um argumento, que é o valor retornado pela função chamada anteriormente.
Aqui está como fica.
O resultado da análise do código de amostra sem aplicar a otimizaçãoVamos falar sobre o que está acontecendo aqui. O analisador vê a declaração da função
bar
, a declaração da função
baz
, a chamada para a função
baz
e a chamada para a função
console.log
. Obviamente, analisando esse trecho de código, o analisador encontrará uma tarefa cuja execução não afetará os resultados deste programa. É sobre analisar a
bar
funções. Por que a análise dessa função não é prática? O fato é que a função
bar
, pelo menos no fragmento de código apresentado, nunca é chamada. Este exemplo simples pode parecer absurdo, mas muitos aplicativos reais têm um grande número de funções que nunca são chamadas.
Em tal situação, em vez de analisar a função da
bar
, podemos simplesmente registrar que ela é declarada, mas não é usada em nenhum lugar. Ao mesmo tempo, a análise real dessa função é feita quando se torna necessária, imediatamente antes de sua execução. Naturalmente, ao executar uma análise lenta, você precisa detectar o corpo da função e fazer um registro de sua declaração, mas é aí que o trabalho termina. Para essa função, não é necessário formar uma árvore de sintaxe abstrata, pois o sistema não possui informações de que essa função está planejada para ser executada. Além disso, a memória heap não é alocada, o que geralmente requer recursos consideráveis do sistema. Em poucas palavras, a recusa em analisar funções desnecessárias leva a um aumento significativo no desempenho do código.
Como resultado, no exemplo anterior, o analisador real formará uma estrutura semelhante ao esquema a seguir.
Resultado da análise de código de exemplo com otimizaçãoObserve que o analisador fez uma anotação sobre a declaração da
bar
funções, mas não lidou com sua análise adicional. O sistema não fez nenhum esforço para analisar o código da função. Nesse caso, o corpo da função era um comando para retornar o resultado de cálculos simples. No entanto, na maioria dos aplicativos do mundo real, o código de função pode ser muito mais longo e mais complexo, contendo muitos comandos de retorno, condições, loops, comandos de declaração variável e funções aninhadas. Analisar tudo isso, desde que essas funções nunca sejam chamadas, é uma perda de tempo.
Não há nada complicado no conceito descrito acima, mas sua implementação prática não é uma tarefa fácil. Aqui examinamos um exemplo muito simples e, de fato, ao decidir se um determinado pedaço de código será procurado em um programa, é necessário analisar funções, loops, operadores condicionais e objetos. Em geral, podemos dizer que o analisador precisa processar e analisar absolutamente tudo o que está no programa.
Aqui, por exemplo, é um padrão muito comum para implementar módulos em JavaScript:
var myModule = (function() {
A maioria dos analisadores JS modernos reconhece esse padrão; para eles, é um sinal de que o código localizado dentro do módulo precisa ser totalmente analisado.
Mas e se os analisadores sempre usassem a análise lenta? Infelizmente, isso não é uma boa ideia. O fato é que, com essa abordagem, se algum código precisar ser executado o mais rápido possível, encontraremos uma desaceleração no sistema. O analisador executará um passo de análise lenta, após o qual começará imediatamente a analisar completamente o que precisa ser feito o mais rápido possível. Isso levará a uma desaceleração de cerca de 50% em comparação com a abordagem quando o analisador começar imediatamente a analisar completamente o código mais importante.
Otimização de código, levando em consideração os recursos de sua análise
Agora que descobrimos um pouco sobre o que está acontecendo dentro dos analisadores, é hora de pensar no que pode ser feito para ajudá-los. Podemos escrever código para que a análise das funções seja realizada no momento em que precisamos. Há um padrão que a maioria dos analisadores entende. É expresso no fato de que as funções estão entre colchetes. Esse design quase sempre informa ao analisador que a função precisa ser desmontada imediatamente. Se o analisador detectar um colchete de abertura, imediatamente após o qual a declaração da função segue, começará imediatamente a analisar a função. Podemos ajudar o analisador aplicando esta técnica ao descrever funções que precisam ser executadas o mais rápido possível.
Suponha que tenhamos uma função
foo
:
function foo(x) { return x * 10; }
Como não há indicação explícita nesse fragmento de código de que esta função está programada para ser executada imediatamente, o navegador executará apenas sua análise lenta. No entanto, estamos confiantes de que precisaremos dessa função muito em breve, para que possamos recorrer ao próximo truque.
Primeiro, salve a função em uma variável:
var foo = function foo(x) { return x * 10; };
Observe que deixamos o nome da função inicial entre a palavra-chave da
function
e o colchete de abertura. Não se pode dizer que isso é absolutamente necessário, mas é recomendável fazer exatamente isso, porque se uma exceção for lançada quando a função estiver em execução, você poderá ver o nome da função nos dados de rastreamento da pilha, e não
<anonymous>
.
Após a alteração acima, o analisador continuará usando a análise lenta. Para mudar isso, basta um pequeno detalhe. A função deve estar entre colchetes:
var foo = (function foo(x) { return x * 10; });
Agora, quando o analisador encontrar um colchete de abertura na frente da palavra-chave
function
, ele começará imediatamente a analisar essa função.
Pode não ser fácil executar essas otimizações manualmente, pois para isso é necessário saber em quais casos o analisador executará a análise lenta e em qual a completa. Além disso, para fazer isso, você precisa dedicar um tempo para decidir se uma função específica precisa estar pronta para o trabalho o mais rápido possível ou não.
Os programadores, com certeza, não vão querer arcar com todo esse trabalho adicional. Além disso, o que não é menos importante do que tudo o que já foi dito, o código processado dessa maneira será mais difícil de ler e entender. Nessa situação, pacotes de software especiais como o Optimize.js estão prontos para nos ajudar. Seu principal objetivo é otimizar o tempo de inicialização inicial do código-fonte JS. Eles executam a análise estática do código e a modificam para que as funções que precisam ser executadas o mais rápido possível sejam colocadas entre colchetes, o que leva ao fato de que o navegador as analisa imediatamente e as prepara para execução.
Portanto, suponha que programamos, sem realmente pensar em nada, e temos o seguinte fragmento de código:
(function() { console.log('Hello, World!'); })();
Parece normal, funciona como esperado, é executado rapidamente, pois o analisador encontra o colchete de abertura na frente da palavra-chave
function
. Até agora tudo bem. , , , :
!function(){console.log('Hello, World!')}();
, , . , - .
, , . , , , . , , , . , , . Optimize.js. Optimize.js, :
!(function(){console.log('Hello, World!')})();
, . , . , , , — .
, JS- — , . ? , , , , . , , , , JS- , . , , , -, . - . , , . , , , , . , JS- , , V8 , , . .
, -:
- . .
- , .
- , , , JS-. , , .
- DeviceTiming , .
- Optimize.js , , .
Sumário
, ,
SessionStack , , -, . , . — . , — , -, , , .
Caros leitores! - JavaScript-?