Se você incorpora um script de terceiros, está gastando a banda dos seus usuários com o julgamento de outra pessoa. Eu não tinha tomado esse cuidado com o meu.
O widget do BugDump era entregue como um único arquivo de 450 KB (117 KB com gzip) a cada visitante de cada página que o incorporava. Leitores anônimos de uma página de marketing pagavam esse preço. Pessoas que nunca notaram o botão pagavam esse preço. É muito para cobrar por um recurso que a maioria dos visitantes nunca usa.
Então medi o que havia lá dentro de fato.
A medição
O esbuild diz exatamente para onde foram os bytes de um bundle se você pedir um metafile. O meu:
Bytes minificados | Participação | |
|---|---|---|
| 248.965 | 55,5% |
Meu próprio código | 117.162 | 26,1% |
| 75.064 | 16,7% |
| 7.077 | 1,6% |
Setenta e dois por cento do bundle eram duas bibliotecas, e ambas pertenciam a recursos que só rodam quando alguém faz algo específico. O html2canvas renderiza uma captura de tela da página. O rrweb grava o replay de sessão. Nenhum dos dois tem trabalho a fazer enquanto um visitante lê um post do blog.
A ideia óbvia é oferecer dois builds: um «completo» e um «leve», sem os recursos pesados. Eu a descartei, por um motivo que vale dizer com todas as letras: essa é uma resposta de build para uma pergunta de runtime. O html2canvas é necessário no momento em que o usuário clica em «Captura», e nenhuma flag de build prevê isso. Um cliente no build completo continuaria pagando 249 KB a cada carregamento de página por um botão que a maioria dos visitantes nunca aperta. E um cliente no build leve cujo plano inclui replay de sessão receberia um recurso que silenciosamente não faz nada, sem que o código tivesse como detectar a incompatibilidade.
A resposta melhor é manter um build só e mudar quando as coisas carregam.
Dividir um build que não pode ser dividido
Aqui a coisa ficou interessante. O build para a tag de script é um IIFE: um único bundle autoexecutável, o formato de que você precisa quando não há carregador de módulos na página. O esbuild só faz code splitting para saída ESM. splitting: true não faz nada para IIFE.
Mas splitting e import dinâmico são coisas diferentes. Um import() dinâmico funciona bem dentro de um script clássico; só as declarações import/export exigem type="module". O problema é que o esbuild resolve o que consegue em tempo de build:
// esbuild resolves this and folds the module back into the bundle
const mod = await import('./heavy');
// esbuild cannot resolve this, so it survives as a real runtime import
const mod = await import(someUrl);
O especificador precisa ser um valor de runtime. Esse é o truque todo, e é por isso que ninguém percebe que a opção existe: todo mundo escreve a versão literal, vê o módulo ser embutido no bundle e conclui que um IIFE não pode ser dividido.
Sobra uma pergunta: em runtime, de que URL importar? Um script de terceiros não sabe de onde foi servido, a menos que pergunte:
const BASE = document.currentScript.src; // https://bugdump.com/sdk/latest.js
const url = new URL('./bugdump-replay.js', BASE).href;
const { record } = await import(url);
document.currentScript é a parte que pede cuidado. Ele só está definido enquanto o código de nível superior do script está rodando. Leia-o dentro de um handler de clique e você recebe null. Por isso ele é capturado uma vez, no escopo do módulo, numa constante, e tudo o que vem depois usa essa constante.
O resultado, do mesmo build de três arquivos:
Arquivo | Com gzip | Carrega quando |
|---|---|---|
Entrada | 28 KB | Em todas as páginas |
Chunk de captura de tela | 62 KB | Alguém tira uma captura de tela do DOM |
Chunk de replay | 27 KB | O plano permite replay de sessão |
A entrada caiu de 117 KB para 28 KB com gzip. Nada foi removido.
O bug que a divisão revelou
Enquanto ligava o chunk de replay, encontrei algo que estava errado sem ninguém perceber.
O replay de sessão é um recurso pago. O código antigo iniciava o gravador logo no init, depois perguntava à API se o plano do projeto incluía replay e chamava stop() se não incluísse. Funcionalmente correto. Mas isso significava que todo cliente sem replay de sessão baixava 82 KB de rrweb, iniciava um gravador de DOM e depois o desligava.
Tornar o download condicional a essa mesma resposta resolveu os dois problemas de uma vez. Agora o chunk só é pedido depois que o servidor diz sim.
Há uma troca real: o replay começa uma ida e volta de rede mais tarde do que antes. Aceitei, porque o gravador mantém uma janela circular com snapshots completos periódicos, e as primeiras centenas de milissegundos de mutações do DOM não são aquilo em que um relatório de bug se apoia. Os coletores de console, rede e ações são baratos, ficam na entrada e continuam cobrindo desde o primeiro momento.
O que eu entendi errado sobre o defer
Eu supunha que a solução para uma tag de script bloqueante era o defer. É o conselho padrão. E, para este caso, está errado.
Montei uma página parecida com uma de produção, limitada a 2 Mbps com 100 ms de latência, e medi as três variantes. Mediana de três execuções:
Tag de script |
|
|
|---|---|---|
Sem atributo | 290 ms | 290 ms |
| 56 ms | 287 ms |
| 58 ms | 58 ms |
O defer libera o parser: o domInteractive despenca. Mas a especificação manda um script adiado rodar antes de o DOMContentLoaded disparar, então o DCL continua esperando o download inteiro. 287 ms contra 290 ms não é uma vitória.
O async libera os dois, porque um script assíncrono pode rodar assim que chega, sem promessa de ordem. É exatamente a troca certa para um widget autocontido: nada na página precisa que ele tenha rodado num momento específico.
Muitos apps penduram a própria inicialização no DOMContentLoaded. Se você incorpora um script de terceiros com defer e acha que o deixou não bloqueante, você mudou o custo de lugar, não o removeu.
O meu snippet agora fica assim:
<script src="https://bugdump.com/sdk/latest.js" async data-api-key="your-api-key"></script>
Isso vem com uma ressalva: se você inicializa manualmente a partir de um script inline, async significa que o SDK pode ainda não existir. Envolva a chamada num listener de load; esse evento só dispara depois que os scripts assíncronos foram executados.
O que eu diria a mim mesmo antes
Meça antes de teorizar. Eu poderia ter passado uma semana adivinhando quais partes eram pesadas. O metafile respondeu com um comando, e a resposta (72% em duas bibliotecas opcionais) apontou direto para a solução.
Prefira decisões de runtime às de build. Sempre que você se pegar desenhando uma matriz de variantes de build, verifique se aquilo que define o ramo é de fato conhecido em tempo de build. No meu caso, não era.
Uma feature flag que chega depois do download não é uma feature flag. Se o servidor já sabe se um cliente tem direito a algo, essa resposta deve controlar os bytes, não só o comportamento.
Verifique os conselhos, inclusive os seus. defer é um bom conselho para muitos scripts. Não era para este, e só descobri porque medi em vez de confiar no senso comum, inclusive no meu.
O novo SDK já está no ar. Quem carrega o SDK de bugdump.com já está usando a versão nova; acrescente async à sua tag quando puder. Se você hospeda o SDK por conta própria, saiba que agora são três arquivos em vez de um, e eles precisam ficar no mesmo diretório: a referência de configuração do widget traz os detalhes.
Leia também
Como adicionar um botão para reportar bugs no seu site — três formas de instalar o widget e as configurações que importam em produção.
Como capturar logs do console e requisições de rede do navegador do usuário — como instrumentar console, erros, fetch e XHR sem quebrar a página que você está monitorando.
Replay de sessão sem vigilância — como o replay funciona no BugDump: limitado a um relatório, com dados que o usuário pode ocultar e desligável com uma linha.