Como criar linhas de execução na Web com workers do módulo

Agora é mais fácil transferir tarefas pesadas para encadeamentos em segundo plano com módulos JavaScript em workers da Web.

O JavaScript é de encadeamento único, o que significa que ele só pode realizar uma operação por vez. Isso é intuitivo e funciona bem em muitos casos na Web, mas pode se tornar problemático quando precisamos realizar tarefas trabalhosas, como processamento de dados, análise, computação ou análise. À medida que mais e mais aplicativos complexos são entregues na Web, há uma maior necessidade de processamento multithread.

Na plataforma da Web, o principal primitivo para linhas de execução e paralelismo é a API Web Workers (link em inglês). Os workers são uma abstração leve sobre as linhas de execução do sistema operacional que expõem uma API de transmissão de mensagens para comunicação entre linhas de execução. Isso pode ser muito útil ao realizar cálculos caros ou operar em grandes conjuntos de dados, permitindo que a linha de execução principal seja executada sem problemas ao realizar as operações caras em uma ou mais linhas de execução em segundo plano.

Confira um exemplo típico de uso de workers, em que um script de worker ouve mensagens da linha de execução principal e responde enviando mensagens próprias:

page.js:

const worker = new Worker('worker.js');
worker.addEventListener('message', e => {
  console.log(e.data);
});
worker.postMessage('hello');

worker.js:

addEventListener('message', e => {
  if (e.data === 'hello') {
    postMessage('world');
  }
});

A API Web Worker está disponível na maioria dos navegadores há mais de 10 anos. Embora isso signifique que os workers têm excelente suporte a navegadores e são bem otimizados, também significa que eles são anteriores aos módulos JavaScript. Como não havia um sistema de módulos quando os workers foram criados, a API para carregar código em um worker e escrever scripts permaneceu semelhante às abordagens de carregamento síncrono de scripts comuns em 2009.

Histórico: workers clássicos

O construtor Worker usa um URL de script clássico, que é relativo ao URL do documento. Ele retorna imediatamente uma referência à nova instância do worker, que expõe uma interface de mensagens e um método terminate() que interrompe e destrói o worker imediatamente.

const worker = new Worker('worker.js');

Uma função importScripts() está disponível em workers da Web para carregar código adicional, mas ela pausa a execução do worker para buscar e avaliar cada script. Ele também executa scripts no escopo global como uma tag <script> clássica, o que significa que as variáveis em um script podem ser substituídas pelas variáveis em outro.

worker.js:

importScripts('greet.js');
// ^ could block for seconds
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

// global to the whole worker
function sayHello() {
  return 'world';
}

Por esse motivo, os workers da Web sempre tiveram um efeito exagerado na arquitetura de um aplicativo. Os desenvolvedores tiveram que criar ferramentas e soluções inteligentes para permitir o uso de workers da Web sem abrir mão das práticas modernas de desenvolvimento. Por exemplo, bundlers, como o webpack, incorporam uma pequena implementação de carregador de módulo ao código gerado que usa importScripts() para o carregamento de código, mas envolve módulos em funções para evitar colisões de variáveis e simular importações e exportações de dependência.

Inserir trabalhadores do módulo

Um novo modo para workers da Web com os benefícios de ergonomia e desempenho dos módulos JavaScript será lançado no Chrome 80, chamado de workers do módulo. O construtor Worker agora aceita uma nova opção {type:"module"}, que muda o carregamento e a execução do script para corresponder a <script type="module">.

const worker = new Worker('worker.js', {
  type: 'module'
});

Como os workers de módulo são módulos JavaScript padrão, eles podem usar instruções de importação e exportação. Como em todos os módulos JavaScript, as dependências são executadas apenas uma vez em um determinado contexto (linha de execução principal, worker etc.), e todas as importações futuras fazem referência à instância de módulo já executada. O carregamento e a execução de módulos JavaScript também são otimizados pelos navegadores. As dependências de um módulo podem ser carregadas antes da execução do módulo, o que permite que árvores de módulos inteiras sejam carregadas em paralelo. O carregamento de módulos também armazena em cache o código analisado, o que significa que os módulos usados na linha de execução principal e em um worker só precisam ser analisados uma vez.

A mudança para módulos JavaScript também permite o uso de importação dinâmica para carregar código de forma lenta sem bloquear a execução do worker. A importação dinâmica é muito mais explícita do que usar importScripts() para carregar dependências, já que as exportações do módulo importado são retornadas em vez de depender de variáveis globais.

worker.js:

import { sayHello } from './greet.js';
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

import greetings from './data.js';
export function sayHello() {
  return greetings.hello;
}

Para garantir um bom desempenho, o método importScripts() antigo não está disponível nos workers do módulo. Alternar workers para usar módulos JavaScript significa que todo o código é carregado no modo estrito. Outra mudança importante é que o valor de this no escopo de nível superior de um módulo JavaScript é undefined, enquanto em workers clássicos o valor é o escopo global do worker. Felizmente, sempre houve um self global que fornece uma referência ao escopo global. Ele está disponível em todos os tipos de workers, incluindo service workers, e no DOM.

Pré-carregar workers com modulepreload

Uma melhoria de desempenho substancial que vem com os workers de módulo é a capacidade de pré-carregar workers e suas dependências. Com os workers do módulo, os scripts são carregados e executados como módulos JavaScript padrão, o que significa que eles podem ser pré-carregados e até mesmo pré-analisados usando modulepreload:

<!-- preloads worker.js and its dependencies: -->
<link rel="modulepreload" href="worker.js">

<script>
  addEventListener('load', () => {
    // our worker code is likely already parsed and ready to execute!
    const worker = new Worker('worker.js', { type: 'module' });
  });
</script>

Os módulos pré-carregados também podem ser usados pela linha de execução principal e pelos workers de módulo. Isso é útil para módulos importados em ambos os contextos ou nos casos em que não é possível saber com antecedência se um módulo será usado na linha de execução principal ou em um worker.

Anteriormente, as opções disponíveis para pré-carregar scripts de worker da Web eram limitadas e não necessariamente confiáveis. Os workers clássicos tinham o próprio tipo de recurso "worker" para pré-carregar, mas nenhum navegador implementou <link rel="preload" as="worker">. Como resultado, a técnica principal disponível para pré-carregamento de workers da Web era usar <link rel="prefetch">, que dependia inteiramente do cache HTTP. Quando usada em combinação com os cabeçalhos de armazenamento em cache corretos, foi possível evitar que a instanciação do worker tivesse que esperar para fazer o download do script dele. No entanto, ao contrário de modulepreload, essa técnica não oferece suporte ao pré-carregamento de dependências ou à pré-análise.

E os workers compartilhados?

Os workers compartilhados foram atualizados com suporte a módulos JavaScript a partir do Chrome 83. Assim como os workers dedicados, a criação de um worker compartilhado com a opção {type:"module"} agora carrega o script do worker como um módulo em vez de um script clássico:

const worker = new SharedWorker('/worker.js', {
  type: 'module'
});

Antes do suporte a módulos JavaScript, o construtor SharedWorker() esperava apenas um URL e um argumento name opcional. Isso continuará funcionando para o uso de workers compartilhados clássicos. No entanto, para criar workers compartilhados de módulos, é necessário usar o novo argumento options. As opções disponíveis são as mesmas de um worker dedicado, incluindo a opção name, que substitui o argumento name anterior.

E quanto ao service worker?

A especificação do worker de serviço já foi atualizada para aceitar um módulo JavaScript como ponto de entrada, usando a mesma opção {type:"module"} que os workers de módulo. No entanto, essa mudança ainda não foi implementada nos navegadores. Depois disso, será possível instanciar um worker de serviço usando um módulo JavaScript com o seguinte código:

navigator.serviceWorker.register('/sw.js', {
  type: 'module'
});

Agora que a especificação foi atualizada, os navegadores estão começando a implementar o novo comportamento. Isso leva tempo porque há algumas complicações extras associadas à transferência de módulos JavaScript para o service worker. O registro de service workers precisa comparar scripts importados com as versões anteriores em cache ao determinar se uma atualização precisa ser acionada. Isso precisa ser implementado em módulos JavaScript quando usados para service workers. Além disso, os workers de serviço precisam ser capazes de ignorar o cache para scripts em determinados casos ao procurar por atualizações.

Outros recursos e leituras