Otimizar tarefas longas

Pediram para você não bloquear a linha de execução principal e dividir as tarefas longas. Mas o que significa fazer essas coisas?

Uma recomendação comum para manter aplicativos JavaScript rápidos se resume aos seguintes:

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

Esse é um ótimo conselho, mas isso envolve que trabalho? Enviar JavaScript menos é bom, mas isso equivale automaticamente a interfaces do 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 trata.

O que é uma tarefa?

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

Visualização de uma tarefa conforme representado 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. Ela também inclui alguns trabalhos 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.

Tarefas associadas ao JavaScript afetam o desempenho de duas maneiras:

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

Tudo isso acontece na linha de execução principal, exceto os web workers e APIs semelhantes.

Qual é 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 interações enquanto uma tarefa de qualquer duração está em execução, mas isso não é perceptível para o usuário, contanto que as tarefas não sejam executadas por muito tempo. No entanto, quando o usuário tenta interagir com uma página com muitas tarefas longas, a interface não responde e pode até ser interrompida se a linha de execução principal fica bloqueada por longos períodos.

Uma longa tarefa no criador de perfil de desempenho do DevTools do Chrome. A parte de bloqueio da tarefa (maior que 50 milissegundos) é representada com um padrão de listras diagonais vermelhas.
Uma tarefa longa, conforme mostrado no criador de perfil de desempenho do Chrome. 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, é possível dividir uma tarefa longa em várias menores.

Uma única tarefa longa versus a mesma tarefa dividida em tarefas mais curtas. A tarefa longa é um retângulo grande, enquanto a tarefa dividida consiste em cinco caixas menores que, coletivamente, têm a mesma largura que a 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 pode responder a trabalhos de maior prioridade com muito mais rapidez, incluindo as interações do usuário. Em seguida, as tarefas restantes são executadas até a conclusão, garantindo que o trabalho inicialmente colocado na fila seja feito.

Representação de como dividir uma tarefa pode facilitar a interação do usuário. No topo, uma tarefa longa impede que um manipulador de eventos seja executado até que a tarefa seja concluída. Na parte inferior, a tarefa dividida permite que o manipulador de eventos seja executado antes do que teria.
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 elas, em comparação com quando tarefas mais longas são divididas em tarefas menores.

No topo da figura anterior, um manipulador de eventos na fila por uma interação do usuário tinha que aguardar uma única tarefa longa antes que ela pudesse começar. Isso atrasa a ocorrência da interação. Nesse cenário, o usuário pode ter notado atraso. Na parte inferior, 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 seu 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, saveSettings() é bem arquitetado. Se você precisar depurar uma dessas funções, poderá atravessar 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 todas as cinco funções serão executadas como uma tarefa.

A função saveSettings é mostrado no criador de 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, 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 muito mais tempo, especialmente em dispositivos com recursos limitados.

Adiar manualmente a execução do código

Um método que os desenvolvedores usaram para dividir as tarefas em outras 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 várias funções que precisam ser executadas em sequência.

No entanto, o código nem sempre é organizado dessa forma. Por exemplo, é possível ter uma grande quantidade de dados que precisa ser processada 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 toda a matriz de dados pode levar muito tempo para ser processada, mesmo que cada iteração individual seja executada rapidamente. Tudo faz sentido, e setTimeout() não é a ferramenta certa para o job, pelo menos não quando usada dessa maneira.

Use async/await para criar pontos de rendimento

Para garantir que tarefas importantes do usuário aconteçam antes das tarefas de menor prioridade, você pode gerar receita 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 gerar a linha de execução principal. No entanto, para conveniência e melhor legibilidade, você pode chamar setTimeout em uma Promise e transmitir o método resolve como o callback.

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

A vantagem da função yieldToMain() é que você pode usar await em qualquer função async. Com base no exemplo anterior, você poderia criar uma matriz de funções para executar e produzir para a linha de execução principal depois que cada uma for executada:

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

A mesma função saveSettings retratada no criador de perfil de desempenho do Chrome, mas com rendimento. O resultado é que a tarefa que antes era 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.

Uma API dedicada de programador

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

Se você controla todo o código da sua página, é possível criar seu próprio agendador com a capacidade de priorizar tarefas, mas scripts de terceiros não usarão seu programador. Na verdade, não é possível priorizar o trabalho nesses ambientes. Você só pode dividi-lo em partes ou renderizá-lo explicitamente para as interações do usuário.

Compatibilidade com navegadores

  • 94
  • 94
  • x

Origem

A API scheduler oferece a função postTask(), que 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 entregues à linha de execução principal. 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 será o padrão se nenhum priority estiver definido.
  • 'user-blocking' para tarefas críticas que precisam ser executadas em alta prioridade.

Considere 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 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 é agendada de modo que as tarefas priorizadas pelo navegador, como as interações do usuário, possam ser seguidas conforme necessário.

A função saveSettings é mostrada no criador de perfil de desempenho do Chrome, mas o uso de postTask. postTask divide cada função saveSettings executada e as prioriza de modo que uma interação do usuário tenha a chance de ser executada sem ser bloqueada.
Quando saveSettings() é executada, a função programa as funções individuais usando postTask(). O trabalho crítico voltado ao usuário é programado para alta prioridade, enquanto o trabalho que o usuário não conhece está 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 adequadamente.

Este é um exemplo simples 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 próxima API scheduler.yield()

Uma adição proposta à API do programador é a scheduler.yield(), uma API projetada especificamente para produzir a linha de execução principal no navegador. Seu uso é semelhante à 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 é amplamente conhecido, mas, em vez de usar yieldToMain(), ele usa await scheduler.yield().

Três diagramas que representam tarefas sem produção, produção, rendimento e continuação. Sem a produção, há tarefas longas. Com o rendimento, há mais tarefas 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 a ordem de execução delas é preservada.
Ao usar scheduler.yield(), a execução da tarefa continua 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 continuarão na mesma ordem após o ponto de rendimento. Isso evita que o código de scripts de terceiros interrompa a ordem de execução do seu código.

O uso de scheduler.postTask() com priority: 'user-blocking' também tem uma alta probabilidade de continuação devido à alta prioridade de user-blocking. Portanto, essa abordagem pode ser usada como alternativa.

O uso de setTimeout() (ou scheduler.postTask() com priority: 'user-visibile' ou sem priority explícito) programa a tarefa no fim da fila, permitindo que outras tarefas pendentes sejam executadas antes da continuação.

Não usar isInputPending()

Compatibilidade com navegadores

  • 87
  • 87
  • x
  • x

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

Isso permite que o JavaScript continue se nenhuma entrada estiver pendente, em vez de retornar e acabar no fim da fila de tarefas. Isso pode resultar em melhorias de desempenho impressionantes, conforme detalhado na seção Intent to Ship, para sites que poderiam não retornar à linha de execução principal.

No entanto, desde o lançamento dessa API, nossa compreensão do rendimento aumentou, especialmente com a introdução do INP. Não é mais recomendado usar essa API. Em vez disso, recomendamos o rendimento independentemente da entrada estar pendente ou não, por vários motivos:

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

Conclusão

Gerenciar tarefas é um desafio, mas fazer 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 várias técnicas diferentes. Para reiterar, estes são os principais itens que você precisará considerar ao gerenciar tarefas:

  • Renda à linha de execução principal para tarefas críticas voltadas ao usuário.
  • Priorize tarefas com postTask().
  • Teste scheduler.yield().
  • 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 no seu aplicativo para que ele priorize as necessidades do usuário e, ao mesmo tempo, garanta que trabalhos menos importantes ainda sejam feitos. Isso criará uma melhor experiência do usuário, mais responsiva e mais agradável de usar.

Um agradecimento especial a Philip Walton pela aprovação técnica deste guia.

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