Avaliação de script e tarefas longas

Ao carregar scripts, o navegador leva tempo para avaliá-los antes da execução, o que pode causar tarefas longas. Saiba como a avaliação de script funciona e o que fazer para evitar que ela cause tarefas longas durante o carregamento da página.

Quando se trata de otimizar a Interaction to Next Paint (INP), a maioria do conselho que você vai encontrar é otimizar as interações por conta própria. Por exemplo, no guia de otimização de tarefas longas, você verá técnicas como a geração de resultados com setTimeout, entre outras. Essas técnicas são benéficas, porque permitem que a linha de execução principal tenha um pouco de espaço, evitando tarefas longas, o que pode permitir mais oportunidades de interações e outras atividades mais rápidas, em vez de esperar por uma única tarefa longa.

No entanto, e as tarefas longas que vêm do carregamento de scripts? Essas tarefas podem interferir nas interações do usuário e afetar o INP de uma página durante o carregamento. Este guia vai mostrar como os navegadores lidam com tarefas iniciadas pela avaliação de script e o que você pode fazer para dividir o trabalho de avaliação de script, para que a linha de execução principal possa ser mais responsiva à entrada do usuário enquanto a página está sendo carregada.

O que é a avaliação de script?

Se você criou o perfil de um aplicativo que envia muito JavaScript, talvez tenha notado tarefas longas em que o culpado é identificado como Evaluate Script.

A avaliação do script funciona como mostrado no perfil de desempenho do Chrome DevTools. O trabalho causa uma tarefa longa durante a inicialização, o que bloqueia a capacidade da linha de execução principal de responder às interações do usuário.
A avaliação de script funciona conforme mostrado no criador de perfil de desempenho no Chrome DevTools. Nesse caso, o trabalho é suficiente para causar uma tarefa longa que bloqueia a linha de execução principal de realizar outros trabalhos, incluindo tarefas que geram interações do usuário.

A avaliação de script é uma parte necessária da execução de JavaScript no navegador, já que o JavaScript é compilado no momento certo antes da execução. Quando um script é avaliado, primeiro ele é analisado em busca de erros. Se o analisador não encontrar erros, o script será compilado em código de bytes e poderá continuar a execução.

Embora necessária, a avaliação do script pode ser problemática, já que os usuários podem tentar interagir com uma página logo após a renderização inicial. No entanto, o fato de uma página ter sido renderizada não significa que ela terminou de carregar. As interações que ocorrem durante o carregamento podem sofrer atrasos porque a página está ocupada avaliando scripts. Embora não haja garantia de que uma interação possa ocorrer neste momento, já que um script responsável por ela pode não ter sido carregado ainda, pode haver interações dependentes do JavaScript que estão prontas ou a interatividade não depende do JavaScript.

A relação entre scripts e as tarefas que os avaliam

A forma como as tarefas responsáveis pela avaliação do script são iniciadas depende se o script que você está carregando é carregado com um elemento <script> típico ou se o script é um módulo carregado com o type=module. Como os navegadores tendem a processar as coisas de maneira diferente, vamos abordar como os principais mecanismos de navegador lidam com a avaliação de script, em que os comportamentos de avaliação de script variam.

Scripts carregados com o elemento <script>

O número de tarefas enviadas para avaliar scripts geralmente tem uma relação direta com o número de elementos <script> em uma página. Cada elemento <script> inicia uma tarefa para avaliar o script solicitado, de modo que ele possa ser analisado, compilado e executado. Esse é o caso dos navegadores baseados em Chromium, Safari e Firefox.

Por que isso é importante? Digamos que você esteja usando um agrupador para gerenciar seus scripts de produção e o tenha configurado para agrupar tudo o que sua página precisa para ser executada em um único script. Se esse for o caso do seu site, uma única tarefa será enviada para avaliar esse script. Isso é ruim? Não necessariamente, a menos que o script seja enorme.

É possível interromper o trabalho de avaliação do script evitando o carregamento de grandes blocos de JavaScript e carregar scripts mais individuais e menores usando outros elementos <script>.

Embora seja importante carregar o mínimo de JavaScript possível durante o carregamento da página, a divisão dos scripts garante que, em vez de uma tarefa grande que pode bloquear a linha de execução principal, você tenha um número maior de tarefas menores que não bloqueiam a linha de execução principal ou, pelo menos, menor do que o inicial.

Várias tarefas que envolvem a avaliação do script, como mostrado no perfil de desempenho do Chrome DevTools. Como vários scripts menores são carregados em vez de scripts maiores, as tarefas têm menos probabilidade de se tornar longas, permitindo que a linha de execução principal responda à entrada do usuário mais rapidamente.
Diversas tarefas geradas para avaliar scripts como resultado de vários elementos <script> presentes no HTML da página. Isso é preferível ao envio de um pacote de script grande para os usuários, que tem maior probabilidade de bloquear a linha de execução principal.

Pense em dividir as tarefas para avaliação do script como algo semelhante ao retorno durante callbacks de eventos executados durante uma interação. No entanto, com a avaliação de script, o mecanismo de produção divide o JavaScript carregado em vários scripts menores, em vez de um número menor de scripts maiores do que os que têm mais chances de bloquear a linha de execução principal.

Scripts carregados com o elemento <script> e o atributo type=module

Agora é possível carregar módulos ES de forma nativa no navegador com o atributo type=module no elemento <script>. Essa abordagem para o carregamento de scripts traz alguns benefícios para a experiência do desenvolvedor, como não ter que transformar o código para uso na produção, especialmente quando usada junto com mapas de importação. No entanto, carregar scripts dessa forma agenda tarefas que diferem de navegador para navegador.

Navegadores baseados no Chromium

Em navegadores como o Chrome ou derivados dele, o carregamento de módulos ES usando o atributo type=module produz diferentes tipos de tarefas do que você normalmente encontraria ao não usar type=module. Por exemplo, uma tarefa para cada script de módulo que envolve uma atividade rotulada como Compile module é executada.

A compilação de módulos funciona em várias tarefas, conforme mostrado no Chrome DevTools.
Comportamento de carregamento de módulos em navegadores baseados no Chromium. Cada script de módulo gera uma chamada Compile module para compilar o conteúdo antes da avaliação.

Depois que os módulos forem compilados, qualquer código executado neles vai iniciar uma atividade com a etiqueta Módulo de avaliação.

Avaliação em tempo real de um módulo conforme visualizado no painel de desempenho do Chrome DevTools.
Quando o código em um módulo é executado, esse módulo é avaliado no momento certo.

O efeito aqui, pelo menos no Chrome e em navegadores relacionados, é que as etapas de compilação são divididas ao usar módulos ES. Essa é uma vitória clara em termos de gerenciamento de tarefas longas. No entanto, o trabalho de avaliação do módulo resultante ainda significa que você está gerando custos inevitáveis. Embora você deva se esforçar para enviar o mínimo de JavaScript possível, o uso de módulos ES, independentemente do navegador, oferece os seguintes benefícios:

  • Todo o código do módulo é executado automaticamente no modo estrito, o que permite possíveis otimizações por mecanismos JavaScript que não poderiam ser feitas em um contexto não restrito.
  • Os scripts carregados usando type=module são tratados como se fossem adiados por padrão. É possível usar o atributo async em scripts carregados com type=module para mudar esse comportamento.

Safari e Firefox

Quando os módulos são carregados no Safari e no Firefox, cada um deles é avaliado em uma tarefa separada. Isso significa que, teoricamente, é possível carregar um único módulo de nível superior que consiste apenas em instruções import estáticas para outros módulos. Cada módulo carregado vai gerar uma solicitação de rede e uma tarefa separadas para avaliação.

Scripts carregados com import() dinâmico

import() dinâmico é outro método de carregamento de scripts. Ao contrário das instruções import estáticas que precisam estar no topo de um módulo ES, uma chamada import() dinâmica pode aparecer em qualquer lugar em um script para carregar um trecho de JavaScript sob demanda. Essa técnica é chamada de divisão de código.

O import() dinâmico tem duas vantagens para melhorar a INP:

  1. Os módulos que são adiados para carregar posteriormente reduzem a contenção da linha de execução principal durante a inicialização, reduzindo a quantidade de JavaScript carregado naquele momento. Isso libera a linha de execução principal para que ela possa ser mais responsiva às interações do usuário.
  2. Quando chamadas import() dinâmicas são feitas, cada chamada separa efetivamente a compilação e a avaliação de cada módulo para uma tarefa própria. É claro que uma import() dinâmica que carrega um módulo muito grande inicia uma tarefa de avaliação de script bastante grande, o que pode interferir na capacidade da linha de execução principal de responder à entrada do usuário se a interação ocorrer ao mesmo tempo que a chamada import() dinâmica. Por isso, ainda é muito importante carregar a menor quantidade possível de JavaScript.

As chamadas dinâmicas de import() se comportam de maneira semelhante em todos os principais mecanismos de navegador: as tarefas de avaliação de script que resultam serão iguais à quantidade de módulos importados dinamicamente.

Scripts carregados em um worker da Web

Os web workers são um caso de uso especial de JavaScript. Os Web Workers são registrados na linha de execução principal, e o código dentro do worker é executado na própria linha de execução. Isso é muito benéfico porque, embora o código que registra o web worker seja executado na linha de execução principal, o código no web worker não. Isso reduz o congestionamento da linha de execução principal e pode ajudar a manter a linha de execução principal mais responsiva às interações do usuário.

Além de reduzir o trabalho da linha de execução principal, os próprios Web workers podem carregar scripts externos para serem usados no contexto do worker, seja por meio de instruções importScripts ou de instruções import estáticas em navegadores que oferecem suporte a workers do módulo. O resultado é que qualquer script solicitado por um web worker é avaliado fora da linha de execução principal.

Vantagens e desvantagens e considerações

Dividir seus scripts em arquivos menores ajuda a limitar tarefas longas em vez de carregar arquivos muito maiores. No entanto, é importante considerar alguns fatores ao decidir como dividir os scripts.

Eficiência da compressão

A compactação é um fator importante para dividir scripts. Quando os scripts são menores, a compactação se torna um pouco menos eficiente. Os scripts maiores vão se beneficiar muito mais da compactação. Embora o aumento da eficiência da compactação ajude a manter os tempos de carregamento dos scripts o mais baixos possível, é preciso equilibrar para garantir que os scripts sejam divididos em partes menores para facilitar a interatividade durante a inicialização.

Os bundlers são ferramentas ideais para gerenciar o tamanho da saída dos scripts de que seu site depende:

  • No que diz respeito ao webpack, o plug-in SplitChunksPlugin dele pode ajudar. Consulte a documentação do SplitChunksPlugin para ver as opções que podem ser definidas para ajudar a gerenciar os tamanhos dos recursos.
  • Para outros agrupadores, como Rollup e esbuild, é possível gerenciar os tamanhos de arquivos de script usando chamadas import() dinâmicas no código. Esses agrupadores, assim como o Webpack, vão dividir automaticamente o recurso importado dinamicamente no próprio arquivo, evitando tamanhos iniciais maiores.

Invalidação de cache

A invalidação do cache tem um papel importante na velocidade de carregamento de uma página em visitas repetidas. Quando você envia pacotes de script grandes e monolíticos, você tem uma desvantagem em relação ao armazenamento em cache do navegador. Isso acontece porque, quando você atualiza o código próprio, seja atualizando pacotes ou enviando correções de bugs, o pacote inteiro é invalidado e precisa ser baixado novamente.

Ao dividir seus scripts, você não apenas divide o trabalho de avaliação em tarefas menores, como também aumenta a probabilidade de que os visitantes recorrentes extraiam mais scripts do cache do navegador em vez de da rede. Isso se traduz em um carregamento de página geral mais rápido.

Módulos aninhados e desempenho de carregamento

Se você estiver enviando módulos ES em produção e os carregando com o atributo type=module, é preciso saber como o aninhamento de módulos pode afetar o tempo de inicialização. O aninhamento de módulo se refere a quando um módulo ES importa estaticamente outro módulo ES que importa estaticamente outro módulo ES:

// a.js
import {b} from './b.js';

// b.js
import {c} from './c.js';

Se os módulos de ES não estiverem agrupados, o código anterior vai resultar em uma cadeia de solicitações de rede: quando a.js for solicitado de um elemento <script>, outra solicitação de rede será enviada para b.js, o que envolve outra solicitação para c.js. Uma maneira de evitar isso é usar um bundler, mas configure-o para dividir os scripts e distribuir o trabalho de avaliação.

Se você não quiser usar um bundler, outra maneira de contornar chamadas de módulos aninhados é usar a dica de recurso modulepreload, que vai carregar os módulos ES com antecedência para evitar cadeias de solicitações de rede.

Conclusão

Otimizar a avaliação de scripts no navegador é sem dúvida um feito complicado. A abordagem depende dos requisitos e das restrições do seu site. No entanto, ao dividir os scripts, você distribui o trabalho de avaliação de scripts em várias tarefas menores e, portanto, permite que a linha de execução principal processe as interações do usuário de maneira mais eficiente, em vez de bloquear a linha de execução principal.

Para recapitular, aqui estão algumas coisas que você pode fazer para dividir grandes tarefas de avaliação de script:

  • Ao carregar scripts usando o elemento <script> sem o atributo type=module, evite carregar scripts muito grandes, porque eles iniciam tarefas de avaliação de script que consomem muitos recursos e bloqueiam a linha de execução principal. Distribua seus scripts em mais elementos <script> para dividir o trabalho.
  • O uso do atributo type=module para carregar módulos ES de forma nativa no navegador inicia tarefas individuais para avaliação de cada script de módulo.
  • Reduza o tamanho dos seus pacotes iniciais usando chamadas import() dinâmicas. Isso também funciona em agrupadores, já que eles vão tratar cada módulo importado dinamicamente como um "ponto de divisão", resultando na geração de um script separado para cada módulo importado dinamicamente.
  • Considere os trade-offs, como eficiência de compactação e invalidação de cache. Scripts maiores são comprimidos melhor, mas têm maior probabilidade de envolver um trabalho de avaliação de script mais caro em menos tarefas e resultar na invalidação do cache do navegador, levando a uma eficiência de cache mais baixa.
  • Se você usar módulos ES de forma nativa sem agrupamento, use a sugestão de recurso modulepreload para otimizar o carregamento deles durante a inicialização.
  • Como sempre, envie o mínimo de JavaScript possível.

É um equilíbrio, mas, ao dividir os scripts e reduzir os payloads iniciais com import() dinâmico, você pode melhorar o desempenho de inicialização e acomodar melhor as interações do usuário durante esse período crucial. Isso vai ajudar você a ter uma pontuação melhor na métrica INP, proporcionando uma experiência melhor ao usuário.