Uma arquitetura fora da linha de execução principal pode melhorar significativamente a confiabilidade e a experiência do usuário do app.
Nos últimos 20 anos, a Web evoluiu muito, de documentos estáticos com alguns estilos e imagens para aplicativos complexos e dinâmicos. No entanto, uma coisa permaneceu praticamente inalterada: temos apenas uma linha de execução por guia do navegador (com algumas exceções) para fazer o trabalho de renderizar nossos sites e executar nosso JavaScript.
Como resultado, a linha de execução principal ficou incrivelmente sobrecarregada. E, à medida que os apps da Web aumentam em complexidade, a linha de execução principal se torna um gargalo significativo para o desempenho. Para piorar, o tempo necessário para executar o código na linha de execução principal de um determinado usuário é quase completamente imprevisível, porque os recursos do dispositivo têm um efeito enorme no desempenho. Essa imprevisibilidade só vai aumentar à medida que os usuários acessarem a Web em um conjunto cada vez mais diversificado de dispositivos, desde smartphones com recursos limitados até máquinas sofisticadas com alta taxa de atualização.
Se quisermos que apps da Web sofisticados atendam de maneira confiável às diretrizes de performance, como as Core Web Vitals, que se baseiam em dados empíricos sobre a percepção e a psicologia humanas, precisamos de maneiras de executar nosso código fora da linha de execução principal (OMT).
Por que usar web workers?
Por padrão, o JavaScript é uma linguagem de linha de execução única que executa tarefas na linha de execução principal. No entanto, os web workers oferecem uma espécie de saída da linha de execução principal, permitindo que os desenvolvedores criem linhas de execução separadas para processar o trabalho fora da linha de execução principal. Embora o escopo dos web workers seja limitado e não ofereça acesso direto ao DOM, eles podem ser muito úteis se houver um trabalho considerável que precise ser feito e que, de outra forma, sobrecarregaria a linha de execução principal.
No caso das Core Web Vitals, executar o trabalho fora da linha de execução principal pode ser benéfico. Em particular, descarregar o trabalho da linha de execução principal para web workers pode reduzir a disputa por ela, o que pode melhorar a métrica de responsividade Interaction to Next Paint (INP) de uma página. Quando a linha de execução principal tem menos trabalho para processar, ela pode responder mais rapidamente às interações do usuário.
Menos trabalho na linha de execução principal, principalmente durante a inicialização, também traz um benefício potencial para o Largest Contentful Paint (LCP) ao reduzir tarefas longas. A renderização de um elemento LCP exige tempo da linha de execução principal, seja para renderizar texto ou imagens, que são elementos LCP frequentes e comuns. Ao reduzir o trabalho da linha de execução principal, você garante que o elemento LCP da sua página tenha menos chances de ser bloqueado por um trabalho caro que um service worker da Web poderia processar.
Como usar linhas de execução com workers da Web
Outras plataformas geralmente oferecem suporte ao trabalho paralelo, permitindo que você atribua uma função a uma linha de execução, que é executada em paralelo com o restante do programa. Você pode acessar as mesmas variáveis das duas linhas de execução, e o acesso a esses recursos compartilhados pode ser sincronizado com mutexes e semáforos para evitar condições de disputa.
Em JavaScript, podemos ter uma funcionalidade semelhante com os web workers, que existem desde 2007 e são compatíveis com todos os principais navegadores desde 2012. Os web workers são executados em paralelo com a linha de execução principal, mas, ao contrário das linhas de execução do SO, não podem compartilhar variáveis.
Para criar um service worker, transmita um arquivo ao construtor do worker, que começa a executar esse arquivo em uma linha de execução separada:
const worker = new Worker("./worker.js");
Comunique-se com o service worker da Web enviando mensagens usando a API postMessage. Transmita o valor da mensagem como um parâmetro na chamada postMessage e adicione um listener de evento de mensagem ao worker:
main.js
const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);
worker.js
addEventListener('message', event => {
const [a, b] = event.data;
// Do stuff with the message
// ...
});
Para enviar uma mensagem de volta à linha de execução principal, use a mesma API postMessage no service worker da Web e configure um listener de eventos na linha de execução principal:
main.js
const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
console.log(event.data);
});
worker.js
addEventListener('message', event => {
const [a, b] = event.data;
// Do stuff with the message
postMessage(a + b);
});
É verdade que essa abordagem é um pouco limitada. Historicamente, os web workers eram usados principalmente para mover uma única parte de um trabalho pesado da linha de execução principal. Tentar processar várias operações com um único service worker da Web rapidamente se torna complicado: é necessário codificar não apenas os parâmetros, mas também a operação na mensagem, e fazer o controle para corresponder respostas a solicitações. Essa complexidade provavelmente é o motivo pelo qual os web workers não foram adotados de forma mais ampla.
Mas se pudéssemos remover parte da dificuldade de comunicação entre a linha de execução principal e os service workers, esse modelo seria uma ótima opção para muitos casos de uso. Felizmente, existe uma biblioteca que faz exatamente isso.
Comlink: tornando os workers da Web menos trabalhosos
O Comlink é uma biblioteca que permite usar web workers sem precisar pensar nos detalhes do postMessage. O Comlink permite compartilhar variáveis entre web workers e a linha de execução principal quase como outras linguagens de programação que oferecem suporte a linhas de execução.
Para configurar o Comlink, importe-o em um service worker e defina um conjunto de funções para expor à linha de execução principal. Em seguida, importe o Comlink na linha de execução principal, encapsule o worker e acesse as funções expostas:
worker.js
import {expose} from 'comlink';
const api = {
someMethod() {
// ...
}
}
expose(api);
main.js
import {wrap} from 'comlink';
const worker = new Worker('./worker.js');
const api = wrap(worker);
A variável api na linha de execução principal se comporta da mesma forma que no service worker, exceto que cada função retorna uma promessa de um valor em vez do valor em si.
Qual código você deve mover para um service worker?
Os web workers não têm acesso ao DOM e a muitas APIs, como WebUSB, WebRTC ou Web Audio. Portanto, não é possível colocar em um worker partes do app que dependem desse acesso. No entanto, cada pequeno trecho de código movido para um worker aumenta a capacidade da linha de execução principal para itens que precisam estar lá, como a atualização da interface do usuário.
Um problema para desenvolvedores da Web é que a maioria dos apps da Web depende de um framework de interface, como Vue ou React, para orquestrar tudo no app. Tudo é um componente do framework e, portanto, está inerentemente vinculado ao DOM. Isso parece dificultar a migração para uma arquitetura OMT.
No entanto, se mudarmos para um modelo em que as questões da interface são separadas de outras questões, como gerenciamento de estado, os service workers podem ser muito úteis, mesmo com apps baseados em frameworks. Essa é exatamente a abordagem adotada com o PROXX.
PROXX: um estudo de caso da OMT
A equipe do Google Chrome desenvolveu o PROXX como um clone do Campo Minado que atende aos requisitos de Progressive Web App, incluindo funcionamento off-line e uma experiência do usuário envolvente. Infelizmente, as primeiras versões do jogo tiveram um desempenho ruim em dispositivos limitados, como feature phones, o que fez a equipe perceber que a linha de execução principal era um gargalo.
A equipe decidiu usar web workers para separar o estado visual do jogo da lógica:
- A linha de execução principal processa a renderização de animações e transições.
- Um service worker processa a lógica do jogo, que é puramente computacional.
A OMT teve efeitos interessantes na performance do smartphone comum do PROXX. Na versão não OMT, a interface fica congelada por seis segundos depois que o usuário interage com ela. Não há feedback, e o usuário precisa esperar os seis segundos completos antes de fazer outra coisa.
Na versão OMT, o jogo leva doze segundos para concluir uma atualização da interface. Embora isso pareça uma perda de performance, na verdade, aumenta o feedback para o usuário. A lentidão ocorre porque o app está enviando mais frames do que a versão não OMT, que não envia nenhum frame. Assim, o usuário sabe que algo está acontecendo e pode continuar jogando enquanto a interface é atualizada, o que melhora muito a experiência.
Essa é uma troca consciente: oferecemos aos usuários de dispositivos com restrições uma experiência que parece melhor sem penalizar os usuários de dispositivos de ponta.
Implicações de uma arquitetura OMT
Como o exemplo do PROXX mostra, o OMT faz com que seu app seja executado de maneira confiável em uma variedade maior de dispositivos, mas não o torna mais rápido:
- Você está apenas movendo o trabalho da linha de execução principal, não reduzindo o trabalho.
- A sobrecarga extra de comunicação entre o worker da Web e a linha de execução principal às vezes pode deixar as coisas um pouco mais lentas.
Considere as vantagens e desvantagens
Como a linha de execução principal está livre para processar interações do usuário, como rolagem, enquanto o JavaScript está em execução, há menos frames descartados, mesmo que o tempo total de espera seja marginalmente maior. É melhor fazer o usuário esperar um pouco do que descartar um frame, porque a margem de erro é menor para frames descartados: o descarte acontece em milissegundos, enquanto você tem centenas de milissegundos antes que o usuário perceba o tempo de espera.
Devido à imprevisibilidade do desempenho em diferentes dispositivos, o objetivo da arquitetura OMT é reduzir o risco, tornando seu app mais robusto diante de condições de execução altamente variáveis, e não os benefícios de desempenho da paralelização. O aumento na resiliência e as melhorias na experiência do usuário valem mais do que qualquer pequena troca na velocidade.
Observação sobre ferramentas
Os web workers ainda não são comuns, então a maioria das ferramentas de módulo, como webpack e Rollup, não oferece suporte a eles de imediato. (mas o Parcel faz!) Felizmente, há plug-ins para fazer os web workers funcionarem com webpack e Rollup:
- worker-plugin para webpack
- rollup-plugin-off-main-thread para Rollup
Resumindo
Para garantir que nossos apps sejam o mais confiáveis e acessíveis possível, especialmente em um mercado cada vez mais globalizado, precisamos oferecer suporte a dispositivos com restrições. É assim que a maioria dos usuários acessa a Web no mundo todo. A OMT oferece uma maneira promissora de aumentar o desempenho nesses dispositivos sem afetar negativamente os usuários de dispositivos de alta qualidade.
Além disso, o OMT tem benefícios secundários:
- Ele move os custos de execução do JavaScript para uma linha de execução separada.
- Ele move os custos de análise, o que significa que a interface pode ser inicializada mais rapidamente. Isso pode reduzir o First Contentful Paint ou até mesmo o Tempo até a interação, o que pode aumentar sua pontuação do Lighthouse.
Web workers não precisam ser assustadores. Ferramentas como o Comlink estão facilitando o trabalho dos workers e tornando-os uma opção viável para uma ampla variedade de aplicativos da Web.