Programação Por Equipe DevBras

Capa do artigo: Vue dentro de uma página Razor: uma ilha interativa sem virar SPA

Vue dentro de uma página Razor: uma ilha interativa sem virar SPA

Coloque um componente Vue numa Razor Page existente: dados iniciais em JSON, token antiforgery no POST, conflito do @ resolvido e script carregado só onde é usado.

O sistema de reservas de salas da empresa é todo em ASP.NET Core com Razor Pages. Funciona bem, mas a tela de nova reserva recarrega a página inteira cada vez que a pessoa muda o número de participantes ou marca "precisa de projetor". Reescrever tudo como SPA levaria meses. A resposta curta: monte o Vue só naquele pedaço da página. A Razor Page continua dona da rota, do layout, da autenticação e dos dados iniciais; ela renderiza um contêiner e entrega o estado inicial em JSON. O Vue monta nesse contêiner, filtra na hora e chama um handler da própria página para gravar, enviando o token antiforgery.

Esse desenho, às vezes chamado de "ilha", resolve a tela que precisa de interação sem mudar a arquitetura do resto do sistema.

Por que a ilha funciona e onde ela quebra

O navegador não se importa com quem gerou o HTML. A Razor Page entrega a página pronta, com menu, rodapé e um div com um id estável. Quando o script do Vue carrega, ele assume só aquele div. Tudo fora dele continua sendo HTML comum, com links e formulários que funcionam como sempre.

O desenho quebra em três pontos previsíveis. O primeiro é ter dois donos para o mesmo HTML: a Razor gera a lista de salas e o Vue gera de novo, e as duas versões brigam. O segundo é o caractere @, que a Razor interpreta como código e que o Vue usa como atalho de v-on. O terceiro é o POST: Razor Pages valida token antiforgery por padrão, e um fetch sem o token recebe 400 sem explicação clara.

Como montar a ilha passo a passo

  1. Carregue os dados no OnGetAsync e exponha uma lista de DTOs, só com o que a tela usa.
  2. Renderize o contêiner e o JSON inicial numa tag script do tipo application/json.
  3. Escreva o template do Vue dentro do contêiner usando v-on: e v-bind: por extenso, para não brigar com a Razor.
  4. Inclua o token antiforgery na página e envie-o no cabeçalho do fetch.
  5. Crie um handler nomeado na mesma página para receber o POST em JSON.
  6. Carregue o script do Vue só nessa página, pela seção Scripts, com versão no nome ou na URL para o cache não servir arquivo velho.

O exemplo completo

A PageModel

public sealed record SalaDto(int Id, string Nome, int Capacidade, bool TemProjetor);
public sealed record ReservaInput(int SalaId, int Pessoas);

public class NovaReservaModel : PageModel
{
    private readonly ReservasDb _db;
    public NovaReservaModel(ReservasDb db) => _db = db;

    public List<SalaDto> Salas { get; private set; } = new();

    public async Task OnGetAsync()
    {
        Salas = await _db.Salas
            .AsNoTracking()
            .OrderBy(s => s.Nome)
            .Select(s => new SalaDto(s.Id, s.Nome, s.Capacidade, s.TemProjetor))
            .ToListAsync();
    }

    public async Task<IActionResult> OnPostReservarAsync([FromBody] ReservaInput input)
    {
        var sala = await _db.Salas.FindAsync(input.SalaId);
        if (sala is null || input.Pessoas < 1 || input.Pessoas > sala.Capacidade)
            return BadRequest(new { erro = "Essa sala não comporta esse número de pessoas." });

        _db.Reservas.Add(new Reserva { SalaId = sala.Id, Pessoas = input.Pessoas });
        await _db.SaveChangesAsync();
        return new JsonResult(new { ok = true });
    }
}

O nome do método define o handler: OnPostReservarAsync responde a um POST com ?handler=Reservar na URL. A validação de capacidade fica aqui, no servidor, mesmo que o Vue já filtre as salas na tela. O filtro do navegador é conforto; a regra é esta.

A página Razor

@page
@model NovaReservaModel
@{
    ViewData["Title"] = "Nova reserva";
}
<h1>Nova reserva</h1>

<div id="busca-salas">
  <label for="pessoas">Quantas pessoas</label>
  <input id="pessoas" type="number" min="1" v-model.number="pessoas">

  <label><input type="checkbox" v-model="precisaProjetor"> Precisa de projetor</label>

  <p v-if="filtradas.length === 0">Nenhuma sala atende a esses critérios.</p>
  <ul>
    <li v-for="sala in filtradas" v-bind:key="sala.id">
      {{ sala.nome }}, {{ sala.capacidade }} lugares
      <button type="button" v-on:click="reservar(sala)">Reservar</button>
    </li>
  </ul>
  <p role="status">{{ mensagem }}</p>
</div>

@Html.AntiForgeryToken()
<script type="application/json" id="dados-salas">@Json.Serialize(Model.Salas)</script>

@section Scripts {
  <script type="module" src="~/js/busca-salas.js" asp-append-version="true"></script>
}

O @Json.Serialize usa as opções de JSON do MVC, que por padrão geram nomes em camelCase: Capacidade vira capacidade no JavaScript. Colocar o JSON numa tag script do tipo application/json evita que ele seja executado e deixa a leitura explícita. Mesmo assim, não use Html.Raw com texto montado à mão nesse lugar: um nome de sala contendo </script> fecharia a tag. O serializador padrão do .NET escapa os caracteres sensíveis de HTML, e é por isso que ele deve gerar esse conteúdo.

Repare que o template não tem nenhum @click nem :key. Dentro de um arquivo .cshtml, a Razor tentaria interpretar @click como código C#. As formas por extenso, v-on:click e v-bind:key, passam intactas. Se preferir o atalho, a Razor aceita @@click, que vira @click na saída, mas a forma longa é mais fácil de revisar.

O script do componente

Para uma ilha pequena, dá para usar o build ESM do Vue que inclui o compilador de templates, copiado para dentro do projeto com a versão no nome da pasta:

// wwwroot/js/busca-salas.js
import { createApp, ref, computed } from '/lib/vue-3.5/vue.esm-browser.prod.js'

const salas = JSON.parse(document.getElementById('dados-salas').textContent)
const token = document.querySelector('input[name="__RequestVerificationToken"]').value

createApp({
  setup() {
    const pessoas = ref(1)
    const precisaProjetor = ref(false)
    const mensagem = ref('')

    const filtradas = computed(() =>
      salas.filter((s) =>
        s.capacidade >= pessoas.value && (!precisaProjetor.value || s.temProjetor)))

    async function reservar(sala) {
      mensagem.value = 'Reservando ' + sala.nome + '...'
      try {
        const resp = await fetch('?handler=Reservar', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'RequestVerificationToken': token
          },
          body: JSON.stringify({ salaId: sala.id, pessoas: pessoas.value })
        })
        mensagem.value = resp.ok
          ? 'Sala ' + sala.nome + ' reservada.'
          : 'Não foi possível reservar essa sala. Escolha outra ou ajuste o número de pessoas.'
      } catch (e) {
        mensagem.value = 'Sem conexão com o servidor. Tente de novo.'
      }
    }

    return { pessoas, precisaProjetor, filtradas, mensagem, reservar }
  }
}).mount('#busca-salas')

O @Html.AntiForgeryToken() gera um input oculto chamado __RequestVerificationToken. O script lê esse valor e o envia no cabeçalho RequestVerificationToken, que é o nome que o ASP.NET Core procura por padrão. Sem isso, o handler recebe a requisição e devolve 400 antes de executar sua primeira linha.

O asp-append-version acrescenta um hash do arquivo à URL, então o navegador baixa a versão nova a cada publicação. Ele vale para o arquivo referenciado na tag, não para os imports internos; por isso a versão do Vue está no nome da pasta. Quando a ilha crescer e precisar de componentes em arquivos .vue, troque esse arranjo por um build com Vite gerando os arquivos em wwwroot, como descrito em app Vue sem webpack. Os limites de carregar o Vue direto, sem build, estão em Vue por CDN tem limite.

Quando a ilha deixa de ser a resposta

A ilha é boa enquanto o Vue cuida de partes isoladas. Se três ou quatro telas passam a ter componentes que precisam trocar estado entre si, ou se quase toda página já depende do Vue, você está mantendo duas arquiteturas ao mesmo tempo. Nesse ponto, um front separado consumindo uma API costuma sair mais barato. Até lá, mantenha a regra: um div, um dono.

Erros comuns

  • Usar @click dentro do .cshtml. A Razor tenta compilar como C# e a página quebra com erro de compilação ou gera HTML estranho.
  • Esquecer o token antiforgery no fetch. Todo POST volta 400 e parece problema de rota.
  • Gerar a lista no servidor e de novo no Vue. A tela pisca, os eventos se perdem e ninguém sabe qual versão vale.
  • Montar o JSON inicial com Html.Raw e concatenação. Um dado com aspas ou </script> quebra a página ou abre espaço para XSS.
  • Carregar o Vue no layout de todas as páginas. Telas que não usam nada do Vue baixam o pacote mesmo assim.
  • Confiar no filtro do navegador. Um POST manual reserva uma sala para mais pessoas do que ela comporta.

Checklist

  • O componente monta num único contêiner com id estável.
  • O estado inicial vem de um script do tipo application/json gerado pelo serializador.
  • O template dentro do .cshtml usa v-on: e v-bind: ou @@.
  • O fetch envia o token no cabeçalho RequestVerificationToken.
  • O handler valida a regra de negócio no servidor.
  • O script só é incluído nas páginas que montam o componente.
  • Uma publicação nova não exige limpar o cache do navegador.

Abra a tela mais lenta de usar do seu sistema Razor. Qual parte dela precisa de interação de verdade, e cabe num único div?

Avalie este artigo

Referência: https://limelightmarketing.com/blogs/vue-asp-net-core-razor-pages/