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

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

Nos últimos 20 anos, a Web evoluiu drasticamente, 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 renderizar nossos sites e executar o JavaScript.

Como resultado, a linha de execução principal ficou sobrecarregada. À medida que os apps da Web ficam mais complexos, a linha de execução principal se torna um gargalo significativo para o desempenho. Para piorar a situação, 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 acessam a Web em um conjunto cada vez mais diversificado de dispositivos, desde feature phones com recursos limitados até máquinas de alto desempenho e taxa de atualização alta.

Se quisermos que apps da Web sofisticados atendam às diretrizes de desempenho com confiabilidade, como as Core Web Vitals, que se baseiam 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 workers da Web?

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 de emergência 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 extremamente benéficos se houver um trabalho considerável que precisa ser feito e que, de outra forma, sobrecarregaria a linha de execução principal.

No que diz respeito às Core Web Vitals, executar o trabalho fora da linha de execução principal pode ser benéfico. Em particular, o descarregamento do 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 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, especialmente durante a inicialização, também traz um benefício potencial para a maior exibição de conteúdo (LCP), reduzindo tarefas longas. A renderização de um elemento LCP requer tempo de 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 página tenha menos probabilidade de ser bloqueado por um trabalho caro que um worker da Web poderia processar.

Como usar threads com workers da Web

Outras plataformas geralmente oferecem suporte a trabalhos paralelos, permitindo que você atribua uma função a uma linha de execução, que é 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 mutexes e semáforos para evitar condições de corrida.

No JavaScript, podemos ter uma funcionalidade semelhante com os workers da Web, 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 da linha de execução do SO, eles não podem compartilhar variáveis.

Para criar um worker da Web, transmita um arquivo para o 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 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 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);
});

Essa abordagem é um pouco limitada. Historicamente, os Web Workers eram usados principalmente para mover uma única parte de trabalho pesado da linha de execução principal. Tentar processar várias operações com um único worker da Web se torna inviável rapidamente: é preciso codificar não apenas os parâmetros, mas também a operação na mensagem, além de fazer a contabilidade para corresponder as respostas às solicitações. Essa complexidade é provavelmente o motivo pelo qual os workers da Web não foram adotados de forma mais ampla.

No entanto, se pudéssemos remover parte da dificuldade de comunicação entre a linha de execução principal e os workers da Web, esse modelo seria uma ótima opção para muitos casos de uso. Felizmente, há uma biblioteca que faz exatamente isso.

O Comlink é uma biblioteca que permite usar workers da Web sem precisar pensar nos detalhes de postMessage. O Comlink permite compartilhar variáveis entre os 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 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, envolva o worker e tenha acesso às 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 a do worker da Web, exceto pelo fato de que cada função retorna uma promessa para um valor em vez do valor em si.

Qual código você precisa mover para um worker da Web?

Os workers da Web não têm acesso ao DOM e a muitas APIs, como WebUSB, WebRTC ou Web Audio. Portanto, não é possível colocar partes do app que dependem desse acesso em um worker. Ainda assim, cada pequena parte do código movida para um worker compra mais espaço na linha de execução principal para coisas que precisam estar lá, como atualizar a interface do usuário.

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

No entanto, se mudarmos para um modelo em que as questões de interface sejam separadas de outras, como gerenciamento de estado, os workers da Web podem ser muito úteis mesmo com apps baseados em framework. Essa é exatamente a abordagem adotada com o PROXX.

PROXX: um estudo de caso de OMT

A equipe do Google Chrome desenvolveu o PROXX como uma versão clonada do Minesweeper que atende aos requisitos de Progressive Web App, incluindo o funcionamento 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 phones, o que levou a equipe a perceber que a linha de execução principal era um gargalo.

A equipe decidiu usar workers da Web para separar o estado visual do jogo da lógica dele:

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

O OMT teve efeitos interessantes no desempenho do telefone básico do PROXX. Na versão sem OMT, a interface fica congelada por seis segundos após a interação do usuário. Não há feedback, e o usuário precisa esperar os seis segundos completos antes de fazer outra coisa.

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

Na versão OMT, no entanto, 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 aumento do feedback para o usuário. A lentidão ocorre porque o app está enviando mais frames do que a versão sem OMT, que não envia frames. Assim, o usuário sabe que algo está acontecendo e pode continuar jogando à medida que a interface é atualizada, o que melhora consideravelmente a experiência.

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

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

Implicações de uma arquitetura de OMT

Como mostra o exemplo do PROXX, 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 de comunicação extra entre o worker da Web e a linha de execução principal às vezes pode deixar as coisas um pouco mais lentas.

Considere os prós e contras

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 de espera total seja um pouco maior. Fazer o usuário esperar um pouco é preferível a descartar um frame porque a margem de erro é menor para frames descartados: descartar um frame acontece em milissegundos, enquanto você tem centenas de milissegundos antes que um usuário perceba o tempo de espera.

Devido à imprevisibilidade do desempenho em vários dispositivos, o objetivo da arquitetura OMT é reduzir o risco, tornando o app mais robusto em condições de execução altamente variáveis, e não sobre os benefícios de desempenho da paralelização. O aumento da resiliência e as melhorias na UX compensam qualquer pequena troca de velocidade.

Observação sobre as ferramentas

Os workers da Web ainda não são populares, então a maioria das ferramentas de módulo, como webpack e Rollup, não oferece suporte a eles. (Mas o Parcel tem!) Felizmente, há plug-ins para fazer com que os workers da Web funcionem com o Webpack e o Rollup:

Resumo

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 restritos, que são a forma como a maioria dos usuários acessa a Web em todo o mundo. O OMT é uma forma promissora de aumentar a performance nesses dispositivos sem afetar negativamente os usuários de dispositivos de última geração.

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 a First Contentful Paint ou até mesmo o Time to Interactive, o que pode aumentar a pontuação do Lighthouse.

Os Web Workers não precisam ser assustadores. Ferramentas como a Comlink estão tirando o trabalho dos trabalhadores e tornando-as uma opção viável para uma ampla gama de aplicativos da Web.