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 dos conselhos que você vai encontrar é para otimizar as interações. Por exemplo, no guia de otimização de tarefas longas, são discutidas técnicas como o rendimento com setTimeout e 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, de modo 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 demorada 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 do script funciona como mostrado no perfilador 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 do JavaScript no navegador, porque o JavaScript é compilado justo antes da execução. Quando um script é avaliado, ele é analisado primeiro para detectar erros. Se o analisador não encontrar erros, o script será compilado em código de bytes e poderá continuar a execução.

Embora seja 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 ser atrasadas porque a página está ocupada avaliando scripts. Embora não haja garantia de que uma interação possa ocorrer neste momento, já que o 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 no 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 dividir o trabalho de avaliação de script evitando o carregamento de grandes partes do JavaScript e carregando scripts menores e mais individuais usando outros elementos <script>.

Embora você sempre deva tentar 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, menos do que começou.

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 rendimento divide o JavaScript carregado em vários scripts menores, em vez de um número menor de scripts maiores, que têm maior probabilidade 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 de carregamento de script traz alguns benefícios para a experiência do desenvolvedor, como não precisar transformar o código para uso de produção, principalmente quando usado em combinação com mapas de importação. No entanto, o carregamento de scripts dessa forma programa tarefas que variam de acordo com o 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 encontra ao não usar type=module. Por exemplo, uma tarefa para cada script de módulo será executada e envolverá uma atividade rotulada como Compile module.

A compilação de módulos funciona em várias tarefas, como 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 just-in-time 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. Isso é uma vantagem clara em termos de gerenciamento de tarefas longas. No entanto, o trabalho de avaliação do módulo resultante ainda significa que você está incorrendo em algum custo inevitável. 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 estrito.
  • 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 avaliá-lo.

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 carregamento posterior reduzem a contenção da linha de execução principal durante a inicialização, reduzindo a quantidade de JavaScript carregada nesse momento. Isso libera a linha de execução principal para que ela possa responder melhor às interações do usuário.
  2. Quando as chamadas import() dinâmicas são feitas, cada chamada separa efetivamente a compilação e a avaliação de cada módulo para a própria tarefa. É claro que um import() dinâmico 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. Portanto, ainda é muito importante carregar o mínimo de JavaScript possível.

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

Scripts carregados em um worker da Web

Os workers da Web são um caso de uso especial do 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 worker da Web seja executado na linha de execução principal, o código dentro do 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 workers da Web podem carregar scripts externos para serem usados no contexto do worker, seja por importScripts ou por instruções estáticas import em navegadores compatíveis com workers de 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

Embora dividir os scripts em arquivos menores e separados ajude a limitar tarefas longas, em vez de carregar arquivos menores e muito maiores, é importante considerar alguns fatores ao decidir como dividir os scripts.

Eficiência de compactaçã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 de saída dos scripts de que seu site depende:

  • No caso do webpack, o plug-in SplitChunksPlugin 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 de scripts 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 significa um carregamento de página 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, é necessário saber como o aninhamento de módulos pode afetar o tempo de inicialização. O aninhamento de módulos ocorre 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 aninhadas é 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, uma tarefa difícil. A abordagem depende dos requisitos e 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 a eficiência da compactação e a invalidação do 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.