Otimizar tarefas longas

Você recebeu a instrução "não bloquear a linha de execução principal" e "dividir as tarefas longas", mas o que isso significa?

O conselho comum para manter os apps JavaScript rápidos tende a se resumir a este:

  • "Não bloquear a linha de execução principal".
  • "Divida as tarefas longas."

Esse é um ótimo conselho, mas qual é o trabalho envolvido? Enviar menos JavaScript é bom, mas isso equivale automaticamente a interfaces de usuário mais responsivas? Talvez, mas talvez não.

Para entender como otimizar tarefas em JavaScript, primeiro você precisa saber o que são tarefas e como o navegador as processa.

O que é uma tarefa?

Uma tarefa é qualquer trabalho discreto que o navegador realiza. Esse trabalho inclui renderização, análise de HTML e CSS, execução de JavaScript e outros tipos de trabalho que você talvez não tenha controle direto. De tudo isso, o JavaScript que você escreve é talvez a maior fonte de tarefas.

Uma visualização de uma tarefa, conforme exibida no perfilador de desempenho do Chrome DevTools. A tarefa está no topo de uma pilha, com um gerenciador de eventos de clique, uma chamada de função e mais itens abaixo dela. A tarefa também inclui alguns trabalhos de renderização no lado direito.
A task started by a click event handler in, shown in Chrome DevTools' performance profiler.

As tarefas associadas ao JavaScript afetam a performance de algumas maneiras:

  • Quando um navegador faz o download de um arquivo JavaScript durante a inicialização, ele enfileira tarefas para analisar e compilar esse JavaScript para que ele possa ser executado mais tarde.
  • Em outros momentos durante a vida da página, as tarefas são enfileiradas quando o JavaScript funciona, como gerar interações por meio de manipuladores de eventos, animações acionadas por JavaScript e atividades em segundo plano, como a coleta de dados de análise.

Todas essas coisas, com exceção de Web Workers e APIs semelhantes, acontecem na linha de execução principal.

O que é a linha de execução principal?

A linha de execução principal é onde a maioria das tarefas é executada no navegador e onde quase todo o JavaScript que você escreve é executado.

A linha de execução principal só pode processar uma tarefa por vez. Qualquer tarefa que leve mais de 50 milissegundos é uma tarefa longa. Para tarefas que excedem 50 milissegundos, o tempo total da tarefa menos 50 milissegundos é conhecido como período de bloqueio da tarefa.

O navegador bloqueia as interações enquanto uma tarefa de qualquer tamanho está em execução, mas isso não é perceptível para o usuário, desde que as tarefas não sejam executadas por muito tempo. No entanto, quando um usuário tenta interagir com uma página com muitas tarefas longas, a interface do usuário não responde e pode até mesmo ficar corrompida se a linha de execução principal for bloqueada por muito tempo.

Uma tarefa demorada no perfilador de desempenho das DevTools do Chrome. A parte de bloqueio da tarefa (maior que 50 milissegundos) é representada por um padrão de faixas diagonais vermelhas.
Uma tarefa longa, conforme mostrado no perfilador de desempenho do Chrome. As tarefas longas são indicadas por um triângulo vermelho no canto da tarefa, com a parte de bloqueio preenchida com um padrão de listras vermelhas diagonais.

Para evitar que a linha de execução principal seja bloqueada por muito tempo, divida uma tarefa longa em várias menores.

Uma única tarefa longa em comparação com a mesma tarefa dividida em tarefas menores. A tarefa longa é um retângulo grande, enquanto a tarefa dividida é composta por cinco caixas menores que, juntas, têm a mesma largura que a tarefa longa.
A visualização de uma única tarefa longa em comparação com a mesma tarefa dividida em cinco tarefas mais curtas.

Isso é importante porque, quando as tarefas são divididas, o navegador pode responder a trabalhos de maior prioridade muito mais rápido, incluindo interações do usuário. Depois, as tarefas restantes são concluídas, garantindo que o trabalho que você inicialmente colocou na fila seja concluído.

Uma representação de como dividir uma tarefa pode facilitar a interação do usuário. Na parte de cima, uma tarefa longa impede que um manipulador de eventos seja executado até que a tarefa seja concluída. Na parte de baixo, a tarefa dividida permite que o gerenciador de eventos seja executado mais cedo do que seria possível.
Uma visualização do que acontece com as interações quando as tarefas são muito longas e o navegador não consegue responder às interações com rapidez suficiente, em comparação com quando as tarefas mais longas são divididas em tarefas menores.

Na parte de cima da figura anterior, um manipulador de eventos enfileirado por uma interação do usuário teve que esperar uma única tarefa longa antes de começar. Isso atrasa a interação. Nesse cenário, o usuário pode ter notado um atraso. Na parte de baixo, o manipulador de eventos pode começar a ser executado mais cedo, e a interação pode ter sido instantânea.

Agora que você sabe por que é importante dividir as tarefas, aprenda a fazer isso em JavaScript.

Estratégias de gerenciamento de tarefas

Um conselho comum na arquitetura de software é dividir o trabalho em funções menores:

function saveSettings () {
  validateForm
();
  showSpinner
();
  saveToDatabase
();
  updateUI
();
  sendAnalytics
();
}

Neste exemplo, há uma função chamada saveSettings() que chama cinco funções para validar um formulário, mostrar um ícone de carregamento, enviar dados para o back-end do aplicativo, atualizar a interface do usuário e enviar análises.

Conceitualmente, o saveSettings() tem uma boa arquitetura. Se você precisar depurar uma dessas funções, navegue pela árvore do projeto para descobrir o que cada uma delas faz. Essa divisão facilita a navegação e a manutenção dos projetos.

Um possível problema aqui é que o JavaScript não executa cada uma dessas funções como tarefas separadas, porque elas são executadas na função saveSettings(). Isso significa que todas as cinco funções serão executadas como uma tarefa.

A função "saveSettings", conforme exibida no perfil de desempenho do Chrome. Enquanto a função de nível superior chama cinco outras funções, todo o trabalho ocorre em uma tarefa longa que bloqueia a linha de execução principal.
Uma única função saveSettings() que chama cinco funções. O trabalho é executado como parte de uma tarefa monolítica longa.

Na melhor das hipóteses, até mesmo uma única função pode contribuir com 50 milissegundos ou mais para a duração total da tarefa. Na pior das hipóteses, mais dessas tarefas podem ser executadas por muito mais tempo, especialmente em dispositivos com recursos limitados.

Adiar manualmente a execução do código

Um método que os desenvolvedores usam para dividir tarefas em tarefas menores envolve setTimeout(). Com essa técnica, você transmite a função para setTimeout(). Isso adia a execução do callback em uma tarefa separada, mesmo que você especifique um tempo limite de 0.

function saveSettings () {
 
// Do critical work that is user-visible:
  validateForm
();
  showSpinner
();
  updateUI
();

 
// Defer work that isn't user-visible to a separate task:
  setTimeout
(() => {
    saveToDatabase
();
    sendAnalytics
();
 
}, 0);
}

Isso é conhecido como rendimento e funciona melhor para uma série de funções que precisam ser executadas sequencialmente.

No entanto, seu código nem sempre é organizado dessa forma. Por exemplo, você pode ter uma grande quantidade de dados que precisam ser processados em um loop, e essa tarefa pode levar muito tempo se houver muitas iterações.

function processData () {
 
for (const item of largeDataArray) {
   
// Process the individual item here.
 
}
}

O uso de setTimeout() aqui é problemático devido à ergonomia do desenvolvedor, e toda a matriz de dados pode levar muito tempo para ser processada, mesmo que cada iteração individual seja executada rapidamente. Tudo isso se soma, e setTimeout() não é a ferramenta certa para o trabalho, pelo menos não quando usada dessa forma.

Use async/await para criar pontos de rendimento

Para garantir que tarefas importantes do usuário sejam executadas antes de tarefas de prioridade mais baixa, é possível passar para a linha de execução principal interrompendo brevemente a fila de tarefas para dar ao navegador oportunidades de executar tarefas mais importantes.

Como explicado anteriormente, setTimeout pode ser usado para renderizar a linha de execução principal. Para maior conveniência e melhor legibilidade, chame setTimeout em um Promise e transmita o método resolve como o callback.

function yieldToMain () {
 
return new Promise(resolve => {
    setTimeout
(resolve, 0);
 
});
}

A vantagem da função yieldToMain() é que ela pode ser await em qualquer função async. Com base no exemplo anterior, você pode criar uma matriz de funções para executar e retornar à linha de execução principal após cada uma delas:

async function saveSettings () {
 
// Create an array of functions to run:
 
const tasks = [
    validateForm
,
    showSpinner
,
    saveToDatabase
,
    updateUI
,
    sendAnalytics
 
]

 
// Loop over the tasks:
 
while (tasks.length > 0) {
   
// Shift the first task off the tasks array:
   
const task = tasks.shift();

   
// Run the task:
    task
();

   
// Yield to the main thread:
    await yieldToMain
();
 
}
}

O resultado é que a tarefa monolítica agora é dividida em tarefas separadas.

A mesma função "saveSettings" mostrada no perfilador de desempenho do Chrome, só que com rendimento. O resultado é que a tarefa monolítica agora é dividida em cinco tarefas separadas, uma para cada função.
Agora, a função saveSettings() executa as funções filhas como tarefas separadas.

Uma API de agendamento dedicada

setTimeout é uma maneira eficaz de dividir tarefas, mas pode ter uma desvantagem: quando você cede à linha de execução principal adiando a execução do código em uma tarefa subsequente, essa tarefa é adicionada ao final da fila.

Se você controla todo o código da página, é possível criar seu próprio agendador com a capacidade de priorizar tarefas, mas os scripts de terceiros não vão usar seu agendador. Na prática, não é possível priorizar o trabalho nesses ambientes. Você só pode dividir ou ceder explicitamente às interações do usuário.

Compatibilidade com navegadores

  • Chrome: 94.
  • Edge: 94.
  • Firefox: atrás de uma flag.
  • Safari: não é compatível.

Origem

A API de agendamento oferece a função postTask(), que permite uma programação mais detalhada das tarefas e é uma maneira de ajudar o navegador a priorizar o trabalho para que as tarefas de baixa prioridade sejam cedidas à linha de execução principal. O postTask() usa promessas e aceita uma das três configurações de priority:

  • 'background' para as tarefas de menor prioridade.
  • 'user-visible' para tarefas de prioridade média. Esse é o padrão se nenhum priority for definido.
  • 'user-blocking' para tarefas críticas que precisam ser executadas com alta prioridade.

Confira o exemplo de código abaixo, em que a API postTask() é usada para executar três tarefas com a maior prioridade possível e as duas restantes com a menor prioridade possível.

function saveSettings () {
 
// Validate the form at high priority
  scheduler
.postTask(validateForm, {priority: 'user-blocking'});

 
// Show the spinner at high priority:
  scheduler
.postTask(showSpinner, {priority: 'user-blocking'});

 
// Update the database in the background:
  scheduler
.postTask(saveToDatabase, {priority: 'background'});

 
// Update the user interface at high priority:
  scheduler
.postTask(updateUI, {priority: 'user-blocking'});

 
// Send analytics data in the background:
  scheduler
.postTask(sendAnalytics, {priority: 'background'});
};

Aqui, a prioridade das tarefas é programada de modo que as tarefas priorizadas pelo navegador, como interações do usuário, possam funcionar conforme necessário.

A função "saveSettings", conforme exibida no perfil de performance do Chrome, mas usando postTask. O postTask divide cada execução da função "saveSettings" e a prioriza para que uma interação do usuário tenha a chance de ser executada sem ser bloqueada.
Quando saveSettings() é executado, a função programa as funções individuais usando postTask(). O trabalho crítico voltado ao usuário é programado com alta prioridade, enquanto o trabalho que o usuário não conhece é programado para ser executado em segundo plano. Isso permite que as interações do usuário sejam executadas mais rapidamente, já que o trabalho é dividido e priorizado de maneira adequada.

Este é um exemplo simplificado de como postTask() pode ser usado. É possível instanciar diferentes objetos TaskController que podem compartilhar prioridades entre tarefas, incluindo a capacidade de mudar prioridades para diferentes instâncias de TaskController conforme necessário.

Rendimento integrado com continuação usando a API scheduler.yield()

Compatibilidade com navegadores

  • Chrome: 129.
  • Edge: 129.
  • Firefox: não é compatível.
  • Safari: não é compatível.

Origem

scheduler.yield() é uma API projetada especificamente para ceder à linha de execução principal no navegador. O uso dela se assemelha à função yieldToMain() demonstrada anteriormente neste guia:

async function saveSettings () {
 
// Create an array of functions to run:
 
const tasks = [
    validateForm
,
    showSpinner
,
    saveToDatabase
,
    updateUI
,
    sendAnalytics
 
]

 
// Loop over the tasks:
 
while (tasks.length > 0) {
   
// Shift the first task off the tasks array:
   
const task = tasks.shift();

   
// Run the task:
    task
();

   
// Yield to the main thread with the scheduler
   
// API's own yielding mechanism:
    await scheduler
.yield();
 
}
}

Esse código é bastante conhecido, mas, em vez de usar yieldToMain(), ele usa await scheduler.yield().

Três diagramas que mostram tarefas sem rendimento, com rendimento e com rendimento e continuação. Sem rendimento, há tarefas longas. Com o rendimento, há mais tarefas mais curtas, mas que podem ser interrompidas por outras tarefas não relacionadas. Com a cessão e a continuação, há mais tarefas mais curtas, mas a ordem de execução delas é preservada.
Quando você usa scheduler.yield(), a execução da tarefa retoma de onde parou, mesmo após o ponto de rendimento.

O benefício de scheduler.yield() é a continuação, o que significa que, se você sair no meio de um conjunto de tarefas, as outras tarefas programadas vão continuar na mesma ordem após o ponto de saída. Isso evita que scripts de terceiros interrompam a ordem de execução do código.

Não use isInputPending()

Compatibilidade com navegadores

  • Chrome: 87.
  • Edge: 87.
  • Firefox: não é compatível.
  • Safari: não é compatível.

Origem

A API isInputPending() oferece uma maneira de verificar se um usuário tentou interagir com uma página e só retorna se uma entrada estiver pendente.

Isso permite que o JavaScript continue se nenhuma entrada estiver pendente, em vez de renderizar e terminar na parte de trás da fila de tarefas. Isso pode resultar em melhorias impressionantes no desempenho, conforme detalhado na Intent to Ship, para sites que, de outra forma, não retornariam à linha de execução principal.

No entanto, desde o lançamento dessa API, nosso entendimento sobre rendimento aumentou, principalmente com a introdução do INP. Não recomendamos mais o uso dessa API. Em vez disso, recomendamos gerar independentemente de a entrada estar pendente ou não por vários motivos:

  • isInputPending() pode retornar false incorretamente, mesmo que um usuário tenha interagido em algumas circunstâncias.
  • A entrada não é o único caso em que as tarefas precisam ser geradas. Animações e outras atualizações regulares da interface do usuário podem ser igualmente importantes para oferecer uma página da Web responsiva.
  • Desde então, APIs de rendimento mais abrangentes foram introduzidas para resolver problemas de rendimento, como scheduler.postTask() e scheduler.yield().

Conclusão

Gerenciar tarefas é um desafio, mas isso garante que sua página responda mais rapidamente às interações do usuário. Não há um único conselho para gerenciar e priorizar tarefas, mas sim várias técnicas diferentes. Para reiterar, estas são as principais coisas que você deve considerar ao gerenciar tarefas:

  • Transmita para a linha de execução principal tarefas críticas voltadas ao usuário.
  • Priorize tarefas com postTask().
  • Experimente usar scheduler.yield().
  • Por fim, faça o mínimo de trabalho possível nas suas funções.

Com uma ou mais dessas ferramentas, você pode estruturar o trabalho no seu aplicativo para que ele priorize as necessidades do usuário, garantindo que o trabalho menos crítico ainda seja feito. Isso vai criar uma experiência do usuário melhor, mais responsiva e mais agradável de usar.

Agradecemos especialmente a Philip Walton pela avaliação técnica deste guia.

Imagem em miniatura extraída do Unsplash, cortesia de Amirali Mirhashemian.