Otimizar tarefas longas

Você já deve ter ouvido "não bloqueie a linha de execução principal" e "divida suas tarefas longas", mas o que isso significa?

Publicado em 30 de setembro de 2022, última atualização em 19 de dezembro de 2024

As recomendações comuns para manter os apps JavaScript rápidos costumam se resumir ao seguinte:

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

Esse é um ótimo conselho, mas o que isso envolve? Enviar menos JavaScript é bom, mas isso significa automaticamente interfaces do usuário mais responsivas? Talvez sim, 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. Isso inclui renderização, análise de HTML e CSS, execução de JavaScript e outros tipos de trabalho que talvez você não controle diretamente. De tudo isso, o JavaScript que você escreve é talvez a maior fonte de tarefas.

Uma visualização de uma tarefa, conforme mostrado no criador de perfil de desempenho do DevTools do Chrome. A tarefa está no topo de uma pilha, com um manipulador de eventos de clique, uma chamada de função e mais itens abaixo dela. A tarefa também inclui um trabalho de renderização no lado direito.
Uma tarefa iniciada por um manipulador de eventos click, mostrada no criador de perfil de desempenho do Chrome DevTools.

As tarefas associadas ao JavaScript afetam o desempenho de duas maneiras:

  • Quando um navegador baixa um arquivo JavaScript durante a inicialização, ele coloca as tarefas em fila para analisar e compilar esse JavaScript para que ele possa ser executado mais tarde.
  • Em outros momentos durante a vida útil da página, as tarefas são colocadas em fila quando o JavaScript realiza ações como responder a interações usando manipuladores de eventos, animações orientadas por JavaScript e atividades em segundo plano, como a coleta de análises.

Tudo isso, com exceção de web workers e APIs semelhantes, acontece 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 o período de bloqueio da tarefa.

O navegador impede que as interações ocorram enquanto uma tarefa de qualquer duração está em execução, mas isso não é perceptível para o usuário, desde que as tarefas não demorem muito. No entanto, quando um usuário tenta interagir com uma página que tem muitas tarefas longas, a interface do usuário não responde e pode até parecer corrompida se a linha de execução principal estiver bloqueada por períodos muito longos.

Uma tarefa longa no criador de perfil de desempenho do DevTools do Chrome. A parte de bloqueio da tarefa (maior que 50 milissegundos) é representada por um padrão de listras diagonais vermelhas.
Uma tarefa longa, conforme mostrada no criador de perfil de desempenho do Chrome. As tarefas longas são indicadas por um triângulo vermelho no canto da tarefa, com a parte de bloqueio da tarefa 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 mais curtas. A tarefa longa é um retângulo grande, enquanto a tarefa dividida em partes é composta por cinco caixas menores que, juntas, têm a mesma largura da tarefa longa.
Uma 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 cedo, incluindo interações do usuário. Depois disso, as tarefas restantes são executadas até o fim, garantindo que o trabalho inicialmente colocado 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 ela seja concluída. Na parte de baixo, a tarefa dividida permite que o manipulador de eventos seja executado antes do que seria possível de outra forma.
Uma visualização do que acontece com as interações quando as tarefas são muito longas e o navegador não consegue responder 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 colocado em fila por uma interação do usuário teve que esperar uma única tarefa longa antes de começar, o que 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 parecer 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, poderá percorrer a árvore do projeto para descobrir o que cada função faz. Dividir o trabalho dessa forma facilita a navegação e a manutenção dos projetos.

No entanto, um possível problema é 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 as cinco funções serão executadas como uma única tarefa.

A função saveSettings, conforme mostrada no criador de perfil de desempenho do Chrome. Embora a função de nível superior chame cinco outras funções, todo o trabalho ocorre em uma tarefa longa, de modo que o resultado da execução da função não fica visível para o usuário até que todas sejam concluídas.
Uma única função saveSettings() que chama cinco funções. O trabalho é executado como parte de uma longa tarefa monolítica, bloqueando qualquer resposta visual até que as cinco funções sejam concluídas.

No melhor cenário, apenas uma dessas funções pode contribuir com 50 milissegundos ou mais para a duração total da tarefa. No pior dos casos, mais dessas tarefas podem levar muito mais tempo, especialmente em dispositivos com recursos limitados.

Nesse caso, saveSettings() é acionado por um clique do usuário e, como o navegador não consegue mostrar uma resposta até que toda a função seja executada, o resultado dessa tarefa longa é uma interface lenta e sem resposta, que será medida como uma Interaction to Next Paint (INP) ruim.

Adiar manualmente a execução de código

Para garantir que as tarefas importantes voltadas ao usuário e as respostas da interface aconteçam antes das tarefas de menor prioridade, é possível ceder à linha de execução principal interrompendo brevemente o trabalho para dar ao navegador oportunidades de executar tarefas mais importantes.

Um método que os desenvolvedores usam para dividir tarefas em partes menores envolve o setTimeout(). Com essa técnica, você transmite a função para setTimeout(). Isso adia a execução do callback para 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 yielding e funciona melhor para uma série de funções que precisam ser executadas sequencialmente.

No entanto, seu código nem sempre vai estar 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.
  }
}

Usar setTimeout() aqui é problemático devido à ergonomia do desenvolvedor e, após cinco rodadas de setTimeout()s aninhados, o navegador vai começar a impor um atraso mínimo de 5 milissegundos para cada setTimeout() adicional.

O setTimeout também tem outra desvantagem quando se trata de ceder: quando você cede à linha de execução principal adiando o código para ser executado em uma tarefa subsequente usando setTimeout, essa tarefa é adicionada ao final da fila. Se houver outras tarefas em espera, elas serão executadas antes do código adiado.

Uma API de rendimento dedicada: scheduler.yield()

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox: 142.
  • Safari: not supported.

Source

scheduler.yield() é uma API projetada especificamente para ceder à linha de execução principal no navegador.

Não é uma sintaxe de nível de linguagem ou uma construção especial. scheduler.yield() é apenas uma função que retorna um Promise que será resolvido em uma tarefa futura. Qualquer código encadeado para ser executado depois que o Promise for resolvido (em uma cadeia .then() explícita ou depois de usar await em uma função assíncrona) será executado nessa tarefa futura.

Na prática: insira um await scheduler.yield(), e a função vai pausar a execução nesse ponto e ceder à linha de execução principal. A execução do restante da função, chamada de continuação da função, será programada para ser executada em uma nova tarefa de loop de eventos. Quando essa tarefa for iniciada, a promessa aguardada será resolvida, e a função vai continuar a execução de onde parou.

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

  // Yield to the main thread:
  await scheduler.yield()

  // Work that isn't user-visible, continued in a separate task:
  saveToDatabase();
  sendAnalytics();
}
A função saveSettings, conforme mostrada no criador de perfil de desempenho do Chrome, agora dividida em duas tarefas. A primeira tarefa chama duas funções e, em seguida, gera um resultado, permitindo que o trabalho de layout e pintura aconteça e dê ao usuário uma resposta visível. Como resultado, o evento de clique é concluído em 64 milissegundos, o que é muito mais rápido. A segunda tarefa chama as últimas três funções.
A execução da função saveSettings() agora é dividida em duas tarefas. Como resultado, o layout e a pintura podem ser executados entre as tarefas, oferecendo ao usuário uma resposta visual mais rápida, conforme medido pela interação do ponteiro, que agora é muito mais curta.

No entanto, o verdadeiro benefício de scheduler.yield() em relação a outras abordagens de suspensão é que a continuação é priorizada. Isso significa que, se você suspender no meio de uma tarefa, a continuação da tarefa atual será executada antes do início de qualquer outra tarefa semelhante.

Isso evita que o código de outras fontes de tarefas interrompa a ordem de execução do seu código, como tarefas de scripts de terceiros.

Três diagramas que mostram tarefas sem suspensão, com suspensão e com suspensão e continuação. Sem a transferência, há tarefas longas. Com a suspensão, há mais tarefas que são mais curtas, mas que podem ser interrompidas por outras tarefas não relacionadas. Com a suspensão e a continuação, há mais tarefas que são mais curtas, mas a ordem de execução é preservada.
Quando você usa scheduler.yield(), a continuação é retomada de onde parou antes de passar para outras tarefas.

Suporte para vários navegadores

scheduler.yield() ainda não é compatível com todos os navegadores, então é necessário um substituto.

Uma solução é colocar o scheduler-polyfill no build para que o scheduler.yield() possa ser usado diretamente. O polyfill vai usar outras funções de programação de tarefas para que ele funcione de maneira semelhante em todos os navegadores.

Como alternativa, uma versão menos sofisticada pode ser escrita em algumas linhas, usando apenas setTimeout encapsulado em uma promessa como substituto se scheduler.yield() não estiver disponível.

function yieldToMain () {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }

  // Fall back to yielding with setTimeout.
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

Embora os navegadores sem suporte a scheduler.yield() não recebam a continuação priorizada, eles ainda vão gerar resultados para que o navegador permaneça responsivo.

Por fim, pode haver casos em que o código não pode ser transferido para a linha de execução principal se a continuação não for priorizada (por exemplo, uma página que se sabe que está ocupada, em que a transferência corre o risco de não concluir o trabalho por algum tempo). Nesse caso, scheduler.yield() pode ser tratado como um tipo de aprimoramento progressivo: gerar em navegadores em que scheduler.yield() está disponível, caso contrário, continuar.

Isso pode ser feito detectando o recurso e voltando a esperar uma única microtarefa em uma linha prática:

// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();

Dividir o trabalho de longa duração com scheduler.yield()

O benefício de usar qualquer um desses métodos de uso de scheduler.yield() é que você pode await em qualquer função async.

Por exemplo, se você tiver uma matriz de jobs para executar que geralmente resultam em uma tarefa longa, poderá inserir yields para dividir a tarefa.

async function runJobs(jobQueue) {
  for (const job of jobQueue) {
    // Run the job:
    job();

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

A continuação de runJobs() será priorizada, mas ainda permitirá que trabalhos de maior prioridade, como responder visualmente à entrada do usuário, sejam executados, sem precisar esperar que a lista de jobs, que pode ser longa, seja concluída.

No entanto, esse não é um uso eficiente da suspensão. O scheduler.yield() é rápido e eficiente, mas tem alguma sobrecarga. Se alguns dos jobs em jobQueue forem muito curtos, a sobrecarga poderá rapidamente resultar em mais tempo gasto na produção e na retomada do que na execução do trabalho real.

Uma abordagem é agrupar os jobs em lote, gerando apenas entre eles se tiver passado tempo suficiente desde a última geração. Um prazo comum é de 50 milissegundos para evitar que as tarefas se tornem longas, mas ele pode ser ajustado como um equilíbrio entre a responsividade e o tempo para concluir a fila de jobs.

async function runJobs(jobQueue, deadline=50) {
  let lastYield = performance.now();

  for (const job of jobQueue) {
    // Run the job:
    job();

    // If it's been longer than the deadline, yield to the main thread:
    if (performance.now() - lastYield > deadline) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

O resultado é que os jobs são divididos para nunca demorarem muito para serem executados, mas o executor só cede à linha de execução principal a cada 50 milissegundos.

Uma série de funções de job, mostradas no painel de desempenho do Chrome DevTools, com a execução dividida em várias tarefas
Jobs agrupados em várias tarefas.

Não use isInputPending().

Browser Support

  • Chrome: 87.
  • Edge: 87.
  • Firefox: not supported.
  • Safari: not supported.

Source

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

Isso permite que o JavaScript continue se nenhuma entrada estiver pendente, em vez de ceder e acabar no final da fila de tarefas. Isso pode resultar em melhorias de desempenho impressionantes, conforme detalhado em 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, nossa compreensão sobre a entrega aumentou, principalmente com a introdução da INP. Não recomendamos mais o uso dessa API. Em vez disso, recomendamos a entrega independentemente de haver ou não entrada pendente por vários motivos:

  • isInputPending() pode retornar incorretamente false, mesmo que um usuário tenha interagido em algumas circunstâncias.
  • A entrada não é o único caso em que as tarefas precisam ser suspensas. As 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, foram introduzidas APIs de rendimento mais abrangentes que abordam as preocupações 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 existe um único conselho para gerenciar e priorizar tarefas, mas sim várias técnicas diferentes. Para reiterar, estes são os principais pontos a serem considerados ao gerenciar tarefas:

  • Ceda à linha de execução principal para tarefas críticas voltadas ao usuário.
  • Use scheduler.yield() (com um substituto para vários navegadores) para gerar e receber continuações priorizadas de forma ergonômica.
  • Por fim, faça o mínimo de trabalho possível nas suas funções.

Para saber mais sobre scheduler.yield(), o scheduler.postTask() relativo à programação de tarefas explícita e a priorização de tarefas, consulte a documentação da API Prioritized Task Scheduling.

Com uma ou mais dessas ferramentas, você pode estruturar o trabalho no aplicativo para priorizar 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.

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