Ouvimos dizer "não bloqueie a linha de execução principal" e "divida suas tarefas longas". Mas o que significa fazer isso?
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.
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.
É necessário dividir as tarefas. Isso significa dividir uma única tarefa longa em tarefas menores, que levam menos tempo para serem executadas individualmente.
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.
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.
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 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.
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 nenhumpriority
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.
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()
.
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.