Use web workers para executar JavaScript fora da thread principal do navegador

Uma arquitetura fora da linha de execução principal pode melhorar significativamente a confiabilidade do app e a experiência do usuário.

Nos últimos 20 anos, a Web evoluiu consideravelmente de documentos estáticos com alguns estilos e imagens para aplicativos dinâmicos e complexos. No entanto, uma coisa permaneceu praticamente inalterada: temos apenas um tópico por guia do navegador (com algumas exceções) para fazer o trabalho de renderização de nossos sites e execução de JavaScript.

Como resultado, a linha de execução principal ficou incrivelmente sobrecarregada. Conforme a complexidade dos apps da Web aumenta, a linha de execução principal se torna um gargalo significativo de desempenho. Para piorar, a quantidade de tempo que um usuário leva para executar o código na linha de execução principal é quase completamente imprevisível, porque as capacidades do dispositivo afetam muito o desempenho. Essa imprevisibilidade só aumentará à medida que os usuários acessarem a Web a partir de um conjunto de dispositivos cada vez mais diversificado, desde celulares com recursos hiperrestritos a máquinas emblemáticas de alta potência e alta taxa de atualização.

Para que apps da Web sofisticados atendam às diretrizes de desempenho de maneira confiável, como as Core Web Vitals, que são baseadas em dados empíricos sobre percepção e psicologia humana, precisamos de maneiras de executar nosso código fora da linha de execução principal (OMT, na sigla em inglês).

Por que usar os 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 um tipo de saída da linha de execução principal, permitindo que os desenvolvedores criem linhas de execução separadas para lidar com o trabalho fora da linha de execução principal. Embora o escopo dos web workers seja limitado e não ofereçam acesso direto ao DOM, eles podem ser extremamente benéficos se houver a necessidade de um trabalho considerável que 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 os web workers pode reduzir a contenção da linha de execução principal, o que pode melhorar a métrica de capacidade de resposta 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, especialmente durante a inicialização, também traz um possível benefício para a Largest Contentful Paint (LCP) ao reduzir tarefas longas. Renderizar um elemento da LCP requer o tempo da linha de execução principal, seja para renderizar textos ou imagens, que são elementos LCP frequentes e comuns, e ao reduzir o trabalho geral da linha de execução principal, para garantir que o elemento LCP da sua página tenha menos probabilidade de ser bloqueado por trabalhos caros que um worker da Web poderia realizar.

Linhas de execução com web workers

Outras plataformas normalmente são compatíveis com trabalho paralelo, permitindo que você atribua uma função a uma linha de execução, que será executada em paralelo com o restante do programa. É possível acessar as mesmas variáveis em ambas as linhas de execução, e o acesso a esses recursos compartilhados pode ser sincronizado com exclusões múltiplas e semáforos para evitar disputas.

Em JavaScript, podemos obter funcionalidade mais ou menos semelhante de web workers, que existem desde 2007 e suportado em 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, eles não podem compartilhar variáveis.

Para criar um web worker, transmita um arquivo para o construtor dele, que começará a executar esse arquivo em uma linha de execução separada:

const worker = new Worker("./worker.js");

Comunicar-se com o 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 eventos 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 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);
});

Evidentemente, essa abordagem é um pouco limitada. Historicamente, os web workers são usados principalmente para mover um único trabalho pesado da linha de execução principal. Tentar processar várias operações com um único web worker é muito difícil de administrar rapidamente: é preciso codificar não apenas os parâmetros, mas também a operação da mensagem, além de fazer registros para corresponder as respostas às solicitações. Essa complexidade provavelmente é o motivo pelo qual os web workers não foram adotados de forma mais ampla.

No entanto, se pudermos eliminar um pouco da dificuldade de comunicação entre a linha de execução principal e os web workers, esse modelo poderá ser uma ótima opção para muitos casos de uso. E, felizmente, existe uma biblioteca que faz exatamente isso!

O comlink (em inglês) é uma biblioteca cujo objetivo é permitir que você use web workers sem precisar pensar nos detalhes do postMessage. O Comlink permite que você compartilhe variáveis entre web workers e a linha de execução principal de forma semelhante a outras linguagens de programação compatíveis com linhas de execução.

Para configurar o Comlink, importe-o em um web 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, una 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 worker da Web, com a exceção de que cada função retorna uma promessa para um valor em vez do valor em si.

Qual código você deve mover para um web 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 seu app que dependem desse acesso. Cada pequeno trecho de código movido para um worker ainda tem mais espaço na linha de execução principal para itens que precisam estar lá, como atualizar a interface do usuário.

Um problema para os desenvolvedores Web é que a maioria dos apps da Web depende de uma estrutura de interface, como o Vue ou o React, para orquestrar tudo no aplicativo. tudo é um componente da estrutura e, portanto, está inerentemente vinculado ao DOM. Isso pode dificultar a migração para uma arquitetura OMT.

No entanto, se mudarmos para um modelo em que as questões de IU são separadas de outras questões, como gerenciamento de estado, os web workers podem ser bastante úteis até mesmo com aplicativos baseados em framework. 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 do Progressive Web App (link em inglês), incluindo o trabalho off-line e uma experiência do usuário envolvente. Infelizmente, as primeiras versões do jogo tiveram um desempenho ruim em dispositivos restritos, como feature phone, o que levou a equipe a 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 dele:

  • A linha de execução principal processa a renderização de animações e transições.
  • Um web worker lida com a lógica do jogo, que é puramente computacional.
.

A OMT teve efeitos interessantes no desempenho dos telefones básicos do PROXX. Na versão não OMT, a IU fica congelada por seis segundos após o usuário interagir com ela. Não há feedback, e o usuário precisa esperar os seis segundos completos antes de poder fazer outra coisa.

Tempo de resposta da interface na versão não OMT do PROXX.

No entanto, na versão da OMT, o jogo leva doze segundos para concluir uma atualização da interface. Embora isso pareça uma perda de desempenho, na verdade ele leva a um maior feedback para o usuário. A lentidão ocorre porque o aplicativo está enviando mais frames do que a versão não OMT, que não está enviando nenhum frame. Portanto, o usuário sabe que algo está acontecendo e pode continuar jogando à medida que a IU é atualizada, tornando o jogo consideravelmente melhor.

Tempo de resposta da interface na versão OMT do PROXX.

Essa é uma compensação consciente: oferecemos aos usuários de dispositivos restritos uma experiência que parece melhor sem penalizar os usuários de dispositivos de última geração.

Implicações de uma arquitetura OMT

Como mostrado no exemplo da PROXX, a OMT faz com que o app seja executado de maneira confiável em uma variedade maior de dispositivos, mas não deixa o app mais rápido:

  • Você está apenas movendo o trabalho da linha de execução principal, não reduzindo o trabalho.
  • O overhead de comunicação extra entre o web worker e a linha de execução principal pode, às vezes, tornar as coisas ligeiramente mais lentas.

Considere as vantagens e desvantagens

Como a linha de execução principal é livre para processar interações do usuário, como a rolagem enquanto o JavaScript está em execução, há menos frames descartados, mesmo que o tempo de espera total seja ligeiramente maior. Fazer o usuário esperar um pouco é melhor do que descartar um frame, porque a margem de erro é menor para frames descartados: o descarte de frames acontece em milissegundos, enquanto o usuário tem centenas de milissegundos até perceber o tempo de espera.

Devido à imprevisibilidade do desempenho em todos os dispositivos, o objetivo da arquitetura de OMT é realmente reduzir o risco, tornar seu app mais robusto diante de condições de tempo de execução altamente variáveis, e não os benefícios de desempenho do carregamento em paralelo. O aumento na resiliência e as melhorias na experiência do usuário são mais do que pequenas compensações em termos de velocidade.

Observação sobre as ferramentas

Os Web workers ainda não são muito usados, por isso a maioria das ferramentas de módulo, como webpack e Rollup, não oferecem suporte imediato. Mas Parcel faz isso. Felizmente, existem plug-ins que fazem os web workers funcionarem com o webpack e o Rollup:

Resumo

Para garantir que nossos apps sejam os mais confiáveis e acessíveis possível, especialmente em um mercado cada vez mais globalizado, precisamos oferecer suporte a dispositivos restritos. Eles são a forma como 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 desses dispositivos.

Além disso, a OMT tem benefícios secundários:

  • Move os custos de execução do JavaScript para uma linha de execução separada.
  • Ela move os custos de análise, o que significa que a interface pode inicializar mais rapidamente. Isso pode reduzir a First Contentful Paint ou até mesmo Tempo para interação, o que pode aumentar Pontuação do Lighthouse.

Os web workers não precisam ser assustadores. Ferramentas como o Comlink estão facilitando o trabalho dos funcionários e tornando-os uma escolha viável para uma ampla gama de aplicativos da Web.

Imagem principal do Unsplash, de James Peacock.