Otimizar tarefas longas

Ouvimos dizer "não bloqueie a linha de execução principal" e "divida suas tarefas longas". Mas o que significa fazer isso?

Jeremy wagner
Jeremy Wagner

Se você lê muito sobre o desempenho na web, o conselho para manter seus aplicativos JavaScript rápidos geralmente envolve algumas destas dicas:

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

O que isso significa? Enviar menos JavaScript é bom, mas isso equivale automaticamente a interfaces do usuário mais rápidas em todo o ciclo de vida da página? Talvez, mas talvez não.

Para entender por que é importante otimizar tarefas em JavaScript, é preciso entender a função das tarefas e como elas são processadas pelo navegador. Isso começa com o entendimento do que é uma tarefa.

O que é uma tarefa?

Uma tarefa é qualquer trabalho separado que o navegador realiza. As tarefas envolvem trabalhos como renderização, análise de HTML e CSS, execução do código JavaScript que você escreve e outras coisas sobre as quais você pode não ter controle direto. De tudo isso, o JavaScript que você escreve e implanta na Web é uma fonte importante de tarefas.

Uma captura de tela de uma tarefa, conforme retratada no 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 alguns trabalhos de renderização no lado direito.
Representa uma tarefa iniciada por um manipulador de eventos click no Performance Profiler no Chrome DevTools.

As tarefas afetam o desempenho de algumas maneiras. Por exemplo, 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 no ciclo de vida da página, as tarefas são iniciadas quando o JavaScript funciona, como gerar interações por meio de manipuladores de eventos, animações orientadas por JavaScript e atividades em segundo plano, como a coleta de análises. Tudo isso, exceto os web workers e APIs semelhantes, ocorre na linha de execução principal.

Qual é a linha de execução principal?

A linha de execução principal é onde a maioria das tarefas é executada no navegador. Ele é chamado de linha de execução principal por algum motivo: é aquele em que quase todo o JavaScript que você escreve funciona.

A linha de execução principal só pode processar uma tarefa por vez. Quando as tarefas ultrapassam um determinado ponto (exatamente 50 milissegundos), elas são classificadas como tarefas longas. Se o usuário estiver tentando interagir com a página durante a execução de uma tarefa longa ou se for necessária uma atualização importante de renderização, o navegador trará atrasos no processamento desse trabalho. Isso resulta em latência de interação ou renderização.

Uma tarefa longa no criador de perfil de desempenho do DevTools do Chrome. A parte de bloqueio da tarefa (mais de 50 milissegundos) é representada com um padrão de faixas diagonais vermelhas.
Uma tarefa longa, como mostrada no criador de perfil de desempenho do Chrome. Tarefas longas são indicadas por um triângulo vermelho no canto, com a parte que bloqueia a tarefa preenchida com um padrão de listras vermelhas diagonais.

É necessário dividir as tarefas. Isso significa dividir uma única tarefa longa em tarefas menores, que levam menos tempo para serem executadas individualmente.

Uma única tarefa longa versus a mesma tarefa dividida em tarefas mais curtas. A tarefa longa é um retângulo grande, enquanto a tarefa fragmentada é composta por cinco caixas menores que coletivamente têm a mesma largura da tarefa longa.
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 tem mais oportunidades de responder a trabalhos de maior prioridade, e isso inclui as interações do usuário.

Uma representação de como dividir uma tarefa pode facilitar a interação do usuário. Na parte superior, 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 manipulador de eventos seja executado antes da execução.
Uma visualização do que acontece com as interações quando as tarefas são muito longas e o navegador não consegue responder rápido o suficiente a interações, em comparação com quando tarefas mais longas são divididas em tarefas menores.

Na parte superior da figura anterior, um manipulador de eventos enfileirado por uma interação do usuário precisava esperar uma única tarefa longa antes que pudesse ser executado. Isso atrasa a ocorrência da interação. Na parte inferior, o manipulador de eventos pode ser executado antes. Como o manipulador de eventos teve a oportunidade de ser executado entre tarefas menores, ele é executado antes do que se precisasse esperar a conclusão de uma tarefa longa. No exemplo superior, o usuário pode ter notado um atraso. Na parte inferior, a interação pode ter sido instantânea.

No entanto, o problema é que o conselho de "dividir suas tarefas longas" e "não bloquear a linha de execução principal" não é específico o suficiente, a menos que você já saiba como fazer isso. Isso é o que este guia explicará.

Estratégias de gerenciamento de tarefas

Um conselho comum na arquitetura de software é dividir seu trabalho em funções menores. Isso oferece os benefícios de melhor legibilidade do código e manutenção do projeto. Isso também facilita a programação de testes.

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

Neste exemplo, há uma função chamada saveSettings() que chama cinco funções dentro dela para fazer o trabalho, como validar um formulário, mostrar um ícone de carregamento, enviar dados e assim por diante. Conceitualmente, a arquitetura está bem estruturada. Se precisar depurar uma dessas funções, você pode percorrer a árvore do projeto para descobrir o que cada função faz.

No entanto, o problema é que o JavaScript não executa cada uma dessas funções como tarefas separadas porque elas estão sendo executadas na função saveSettings(). Isso significa que todas as cinco funções são executadas como uma única tarefa.

A função saveSettings, conforme mostrado no criador de perfil 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 longa tarefa monolítica.

Na melhor das hipóteses, mesmo apenas uma dessas funções 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 um pouco mais de tempo, especialmente em dispositivos com recursos limitados. Veja a seguir um conjunto de estratégias que você pode usar para dividir e priorizar tarefas.

Adiar manualmente a execução do código

Um método que os desenvolvedores usaram para dividir as tarefas em tarefas menores envolve o 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 funciona bem se você tem uma série de funções que precisam ser executadas em sequência, mas o código nem sempre está organizado dessa maneira. Por exemplo, você pode ter uma grande quantidade de dados que precisa ser processada em loop, e essa tarefa pode demorar muito se você tem milhões de itens.

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

O uso de setTimeout() aqui é problemático porque a ergonomia dele dificulta a implementação, e o processamento de toda a matriz de dados pode levar muito tempo, mesmo que cada item possa ser processado muito rapidamente. Tudo faz sentido, e setTimeout() não é a ferramenta certa para o trabalho, pelo menos não quando usada dessa forma.

Além de setTimeout(), existem algumas outras APIs que permitem adiar a execução do código para uma tarefa subsequente. Um envolve o uso de postMessage() para tempos limite mais rápidos. Também é possível dividir o trabalho usando requestIdleCallback(), mas cuidado: o requestIdleCallback() programa tarefas com a prioridade mais baixa possível e apenas durante o tempo de inatividade do navegador. Quando a linha de execução principal está congestionada, as tarefas programadas com requestIdleCallback() podem nunca ser executadas.

Usar async/await para criar pontos de rendimento

Uma frase que você verá no restante deste guia é "resultar na conversa principal". Mas o que isso significa? Por que fazer isso? Quando é recomendado fazer isso?

Quando as tarefas são divididas, outras podem ser melhor priorizadas pelo esquema de priorização interna do navegador. Uma maneira de ceder à linha de execução principal envolve o uso de uma combinação de um Promise que é resolvido com uma chamada para setTimeout():

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

Na função saveSettings(), você pode ceder à linha de execução principal após cada bit de trabalho se await a função yieldToMain() após cada chamada de função:

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 antes monolítica agora é dividida em tarefas separadas.

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

A vantagem de usar uma abordagem baseada em promessas de rendimento em vez de manual de setTimeout() é a melhor ergonomia. Os pontos de rendimento se tornam declarativos e, portanto, mais fáceis de escrever, ler e entender.

Lucre somente quando necessário

E se você tiver várias tarefas, mas só quiser produzir se o usuário tentar interagir com a página? É para isso que o isInputPending() foi criado.

isInputPending() é uma função que pode ser executada a qualquer momento para determinar se o usuário está tentando interagir com um elemento da página: uma chamada para isInputPending() retornará true. Caso contrário, ela retornará false.

Digamos que você tenha uma fila de tarefas que precisa executar, mas não quer atrapalhar nenhuma entrada. Esse código, que usa isInputPending() e nossa função yieldToMain() personalizada, garante que uma entrada não seja atrasada enquanto o usuário tenta interagir com a página:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

Enquanto saveSettings() é executado, ele repetirá as tarefas na fila. Se isInputPending() retornar true durante a repetição, saveSettings() chamará yieldToMain() para que a entrada do usuário possa ser processada. Caso contrário, ele deslocará a próxima tarefa para fora da frente da fila e a executará continuamente. Ele fará isso até que não haja mais tarefas.

Representação da função saveSettings em execução no criador de perfil de desempenho do Chrome. A tarefa resultante bloqueia a linha de execução principal até que isInputPending retorne "true". Nesse momento, a tarefa retorna à linha de execução principal.
saveSettings() executa uma fila de cinco tarefas, mas o usuário clicou para abrir um menu enquanto o segundo item de trabalho estava em execução. O isInputPending() cessa à linha de execução principal para processar a interação e retoma a execução do restante das tarefas.

Usar o isInputPending() em combinação com um mecanismo de rendimento é uma ótima maneira de fazer o navegador interromper qualquer tarefa que esteja processando para responder a interações críticas voltadas ao usuário. Isso pode ajudar a melhorar a capacidade da página de responder ao usuário em muitas situações quando muitas tarefas estão em andamento.

Outra maneira de usar o isInputPending(), especialmente se você estiver preocupado em fornecer um substituto para navegadores incompatíveis, é usar uma abordagem baseada em tempo com o operador de encadeamento opcional:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline = performance.now() + 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

Com essa abordagem, você consegue um substituto para navegadores que não são compatíveis com isInputPending() usando uma abordagem baseada em tempo que usa (e ajusta) um prazo para que o trabalho seja dividido quando necessário, seja cedendo à entrada do usuário ou até um certo ponto no tempo.

Lacunas nas APIs atuais

As APIs mencionadas até agora podem ajudar a dividir tarefas, mas elas têm uma desvantagem significativa: quando você cede à linha de execução principal adiando a execução do código em uma tarefa subsequente, esse código é adicionado ao final da fila de tarefas.

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

Felizmente, existe uma API de programador dedicada que está em desenvolvimento e resolve esses problemas.

Uma API de programador dedicada

No momento, a API do programador oferece a função postTask() que, no momento, está disponível nos navegadores Chromium e no Firefox com uma sinalização. O postTask() permite uma programação mais refinada de tarefas e é uma maneira de ajudar o navegador a priorizar o trabalho para que as tarefas de baixa prioridade sejam usadas na linha de execução principal. postTask() usa promessas e aceita uma configuração priority.

A API postTask() tem três prioridades que podem ser usadas:

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

Veja o código abaixo como exemplo, em que a API postTask() é usada para executar três tarefas com a prioridade mais alta possível e as duas tarefas restantes com a prioridade mais baixa 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 é agendada de modo que tarefas priorizadas pelo navegador, como interações do usuário, possam ser acessadas.

A função saveSettings conforme representado no criador de perfil do Chrome, mas usando postTask. postTask divide cada função saveSettings executada e prioriza-as 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 para o usuário é programado com alta prioridade, enquanto o trabalho que o usuário não conhece tem a execução em segundo plano. Isso permite que as interações do usuário sejam executadas mais rapidamente, já que o trabalho é dividido e priorizado adequadamente.

Esse é um exemplo simplista de como postTask() pode ser usado. É possível instanciar diferentes objetos TaskController que compartilham prioridades entre tarefas, incluindo a capacidade de alterar as prioridades de diferentes instâncias de TaskController conforme necessário.

Rendimento integrado com continuação via scheduler.yield

Uma parte proposta da API do programador é a scheduler.yield, uma API projetada especificamente para funcionar com a linha de execução principal no navegador, que está atualmente disponível para teste como um teste de origem. O uso dela é parecido com a função yieldToMain() demonstrada anteriormente neste artigo:

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();
  }
}

O código acima é bastante conhecido, mas, em vez de usar yieldToMain(), você chama e await scheduler.yield().

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

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

Conclusão

Gerenciar tarefas é desafiador, mas fazer isso ajuda sua página a responder mais rapidamente às interações do usuário. Não há um único conselho para gerenciar e priorizar tarefas. São várias técnicas diferentes. Para reiterar, estes são os principais fatores que você precisa considerar ao gerenciar tarefas:

  • Lide com a linha de execução principal para tarefas essenciais voltadas ao usuário.
  • Use isInputPending() para ceder à linha de execução principal quando o usuário estiver tentando interagir com a página.
  • Priorize tarefas com o postTask().
  • Por fim, faça o mínimo de trabalho possível nas suas funções.

Com uma ou mais dessas ferramentas, você deve ser capaz de estruturar o trabalho em seu aplicativo para que ele priorize as necessidades do usuário, garantindo que o trabalho menos importante ainda seja feito. Isso vai criar uma melhor experiência do usuário, que se torna mais responsiva e agradável de usar.

Um agradecimento especial a Philip Walton pela verificação técnica deste artigo.

Imagem principal do Unsplash, cortesia de Amirali Mirhashemian.