Programação Por Equipe DevBras

Capa do artigo: JavaScript isolado no Blazor: módulo por componente, sem função global

JavaScript isolado no Blazor: módulo por componente, sem função global

Coloque o script ao lado do componente, importe como módulo ES pelo IJSRuntime, passe ElementReference em vez de id e libere tudo no DisposeAsync.

A tela do painel tem um mapa e um gráfico. Cada componente veio com seu script, e os dois declaram function iniciar() no escopo global. O gráfico carrega por último, sobrescreve o window.iniciar e o mapa para de funcionar, mas só nessa tela. A resposta curta: no Blazor, coloque o JavaScript num arquivo ao lado do componente (Mapa.razor.js), exporte as funções como módulo ES, importe pelo IJSRuntime com "import" no primeiro render, passe o elemento por ElementReference e libere o módulo no DisposeAsync.

JavaScript solto na página é espaço compartilhado: tudo que vai para o window pode colidir com outra coisa. Um módulo ES tem escopo próprio, e só quem importou enxerga o que ele exporta. É o mesmo princípio do componente, aplicado ao script.

Por que o script global quebra componentes

O modelo antigo de interop do Blazor chamava funções pelo nome a partir do window: você incluía um <script> no layout e chamava JS.InvokeVoidAsync("iniciar"). Isso tem três problemas que crescem com o projeto. Nomes colidem entre componentes e bibliotecas. Todo script carrega em todas as páginas, mesmo onde não é usado. E nada amarra o script ao ciclo de vida do componente, então quando o componente sai da tela, os listeners e timers que o script criou continuam vivos.

O último é o mais traiçoeiro. Navegue para fora do painel e volte cinco vezes: se o script registra um listener de resize a cada entrada e nunca remove, agora são cinco listeners, quatro deles apontando para elementos que não existem mais. Juntar todos os scripts num arquivo grande, aliás, agrava o primeiro e o segundo problema, como descrito em juntar todos os scripts do kit no mesmo arquivo.

Como isolar o script em módulo

  1. Crie o arquivo JavaScript com o mesmo nome do componente e a extensão .razor.js, na mesma pasta. O Blazor publica esse arquivo junto com os estáticos.
  2. Exporte com export só as funções que o C# precisa chamar.
  3. No componente, importe o módulo no OnAfterRenderAsync, apenas no primeiro render.
  4. Passe o elemento com @ref e ElementReference, nunca por id fixo.
  5. Faça a função de início devolver um objeto com um método de limpeza.
  6. Implemente IAsyncDisposable e chame a limpeza antes de liberar o módulo.

Exemplo completo: componente com módulo e limpeza

O exemplo é um componente que acompanha a largura do próprio area, o tipo de coisa que uma biblioteca de gráfico ou de mapa precisa para se redesenhar. O script fica em Components/Painel/Largura.razor.js:

export function iniciar(elemento, dotnet) {
  const aoRedimensionar = () => {
    dotnet.invokeMethodAsync('AtualizarLargura', elemento.clientWidth);
  };

  window.addEventListener('resize', aoRedimensionar);
  aoRedimensionar();

  return {
    destruir() {
      window.removeEventListener('resize', aoRedimensionar);
    }
  };
}

Nada vai para o window além do listener, e o próprio módulo devolve a forma de removê-lo. O componente em Components/Painel/Largura.razor:

@implements IAsyncDisposable
@inject IJSRuntime JS

<div @ref="area">
    <p>Largura disponível: @largura px</p>
</div>

@code {
    private ElementReference area;
    private IJSObjectReference? modulo;
    private IJSObjectReference? instancia;
    private DotNetObjectReference<Largura>? referencia;
    private int largura;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;

        modulo = await JS.InvokeAsync<IJSObjectReference>(
            "import", "./Components/Painel/Largura.razor.js");
        referencia = DotNetObjectReference.Create(this);
        instancia = await modulo.InvokeAsync<IJSObjectReference>(
            "iniciar", area, referencia);
    }

    [JSInvokable]
    public void AtualizarLargura(int valor)
    {
        largura = valor;
        StateHasChanged();
    }

    public async ValueTask DisposeAsync()
    {
        try
        {
            if (instancia is not null)
            {
                await instancia.InvokeVoidAsync("destruir");
                await instancia.DisposeAsync();
            }
            if (modulo is not null)
                await modulo.DisposeAsync();
        }
        catch (JSDisconnectedException)
        {
        }

        referencia?.Dispose();
    }
}

Alguns detalhes que fazem diferença. O caminho do import é relativo à raiz do site e espelha a pasta do componente no projeto; num Razor Class Library, o caminho começa com ./_content/NomeDaBiblioteca/. A importação acontece no OnAfterRenderAsync porque durante a pré-renderização no servidor ainda não existe navegador para executar JavaScript. E a JSDisconnectedException é esperada no Blazor Server quando o circuito já caiu, por exemplo ao fechar a aba: nesse caso não há JavaScript do outro lado para limpar.

Por que ElementReference e não id

Se o componente usa document.getElementById('grafico') e aparece duas vezes na mesma página, as duas instâncias pegam o mesmo elemento. O @ref entrega ao JavaScript o elemento exato daquela instância, sem depender de id único. Isso também evita colisão com outro componente que tenha escolhido o mesmo id.

O que fica no C# e o que vai para o JavaScript

O JavaScript cuida do que o navegador faz melhor: foco, medida de elemento, biblioteca visual, APIs do navegador como área de transferência. Regra de negócio, validação que importa e decisão de permissão ficam no C#, e o que protege dado fica no servidor. "É mais fácil desenhar em JS" não é motivo para levar o cálculo do desconto para lá.

Se o módulo falhar ao carregar, por um erro de caminho ou de rede, o componente deve dizer isso em vez de mostrar um botão mudo. Envolva o import em try/catch de JSException e exiba uma mensagem. Para interações que precisam funcionar com teclado, como reordenar listas, veja arrastar no Blazor com teclado.

Erros comuns

  • Declarar funções no window: dois componentes com o mesmo nome de função quebram um ao outro, e só em algumas telas.
  • Importar no OnInitializedAsync: com pré-renderização ativa, a chamada de interop falha porque ainda não há navegador.
  • Não implementar DisposeAsync: listeners e timers continuam vivos depois que o componente sai, e se acumulam a cada navegação.
  • Esquecer de liberar o DotNetObjectReference: o componente fica preso na memória enquanto o JavaScript mantiver a referência.
  • Usar id fixo para achar o elemento: com duas instâncias do componente, ambas mexem no mesmo elemento.
  • Mover regra de negócio para o script: a regra fica duplicada, difícil de testar e editável por qualquer um no DevTools.

Checklist

  • Cada script de componente está num .razor.js ao lado do componente.
  • O script não cria nada global além de listeners que ele mesmo remove.
  • A importação acontece no primeiro OnAfterRenderAsync.
  • Elementos chegam ao JavaScript por ElementReference.
  • O componente implementa IAsyncDisposable e chama a limpeza do script.
  • DotNetObjectReference é liberado no descarte.
  • Falha ao carregar o módulo aparece como mensagem na tela.

Abra a tela do seu sistema que tem mais componentes com JavaScript e navegue para fora e de volta cinco vezes. Os listeners se multiplicaram, e algum script ainda declara função no window?

Avalie este artigo

Referência: https://www.macoratti.net/21/01/blz_isolajs1.htm