a página ganha vida
this post is only available in português
No fim do post passado os botões acordaram - mas com um efeito colateral que não passou despercebido: cada clique pisca a página. A jhujuba clicou no café, viu a tela branquear por uma fração de segundo e reclamou: "ué, mudou um quadradinho. por que a página inteira recarrega?". Minha nossa, ela não perdoa.
Mas... ela tem razão. E a pergunta dela é o post de hoje: como a página atualiza sozinha, sem recarregar? A resposta tem nome, tem framework e é a coisa mais Elixir do mundo, como eu tinha prometido: LiveView.
o problema da piscada
Recap de dez segundos: o clique vira um POST, o POST vira um 303, o 303 vira um GET, e o GET devolve a página inteira desenhada do zero. Cada conversa HTTP é independente - o servidor responde e esquece que você existe. Pra tela mudar sem esse ciclo todo, a conversa precisa continuar aberta.
websocket num parágrafo
Um parágrafo só, prometido: se HTTP é troca de cartas (escreveu, mandou, esperou, recebeu), o websocket é um telefonema que ninguém desliga. Abre uma conexão e ela fica ali, ida e volta, o dia todo: o navegador avisa "clicaram no café" na hora, e o servidor empurra a mudança sem ninguém pedir. Só isso. Todo o resto do post é o que a gente faz com um telefonema aberto.
mix phx.new aparado
Dava pra ligar um websocket na mão no nosso Plug do post 3 - e seria dor sem aprendizado proporcional. Quem já resolveu isso é o Phoenix, o framework web do mundo Elixir, e a parte que nos interessa é o LiveView. Projeto novo, gerado do zero:
mix phx.new fuba_web --no-ecto --no-mailer --no-dashboard --no-gettext --no-assets
Olha essas flags com carinho, porque elas são metade da lição do post. Um gerador assume um monte de coisa por você - banco de dados, e-mail, painel administrativo, tradução, pipeline de assets (esbuild, tailwind - bibliotecas JS/CSS). Cada --no-* é uma assumida recusada:
--no-ecto- sem banco de dados. A Fubá mora em memória e pronto (pelo menos or enquanto...);--no-mailer- sem e-mail. A coelhinha não manda newsletter;--no-dashboard- sem painel de métricas;--no-gettext- sem sistema de tradução;--no-assets- sem bundler de JS/CSS, muita complexidade pra um projeto tao simples.
Ler o que um framework assume é tão importante quanto ler o que ele gera.
Atenção que o phx.new cria um projeto novo, com vida própria - a Fubá que a gente construiu até aqui não vem junto por osmose. A migração é manual e cabe num copia-e-cola: os três módulos do coração (coelhinha.ex, cuidado.ex, humor.ex) vão pra lib/fuba/ do projeto novo, e os testes do post 2 vão pra test/fuba/. Rode mix test no projeto novo: verde? O coração atravessou a rua inteiro.
Aproveita e repara na estrutura de pastas que o Phoenix organiza, porque ela é a regra de ouro desenhada em diretório:
lib/fuba/- o coração, como sempre: nem HTTP, nem HTML, nem internet;lib/fuba_web/- a casquinha: roteador, endpoint, layouts, e é aqui que a LiveView vai morar;lib/fuba_web.ex- o "saguão" da casquinha, com osuse FubaWeb, :live_viewe afins que deixam os módulos web arrumados;config/- um.exspor ambiente (dev,test,prod), em vez de um config só;priv/static/- os arquivos parados: JS, CSS, imagens. Conhecemos do post 4!
E a Guarda, o Agent do post 5? Fica pra trás, aposentada. Já explico pra onde foi o emprego dela.
o js na unha (culpa do --no-assets)
Primeira surpresa depois do mix deps.get: a página até abre, mas os botões... não fazem nada. De novo?! Calma, respira. Abre o priv/static/assets/js/app.js gerado:
// For Phoenix.LiveView support, copy the following scripts
// into your javascript bundle:
// * deps/phoenix_live_view/priv/static/phoenix_live_view.js
O app.js é só comentário. Sem bundler, ninguém empacota o JavaScript do LiveView por você - o framework entrega as peças e um bilhete: "copie os scripts". Pois bem, copiados:
cp deps/phoenix/priv/static/phoenix.js priv/static/assets/js/
cp deps/phoenix_live_view/priv/static/phoenix_live_view.js priv/static/assets/js/
Esses dois arquivos são o lado JavaScript do telefonema, e expõem dois globais: Phoenix e LiveView. O nosso app.js inteiro vira isto:
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveView.LiveSocket("/live", Phoenix.Socket, {
params: {_csrf_token: csrfToken},
})
liveSocket.connect()
window.liveSocket = liveSocket
Quatro acontecimentos: pega o token anti-falsificação que o layout deixa num <meta> (o protect_from_forgery do roteador - servidor que confia em requisição alheia não dura um dia, lembra do post 6?), cria o LiveSocket apontando pro /live (o telefonema, declarado no endpoint), conecta, e pendura o bicho no window pra gente brincar no console depois. O layout ganha as três tags de script - phoenix.js, phoenix_live_view.js, app.js, nessa ordem - e pronto.
É a única vez na série inteira que a gente escreve JavaScript, e eu diria que tá de bom tamanho kk
a fubá vira liveview
Agora o prato principal. A migração inteira cabe num arquivo, lib/fuba_web/live/fuba_live.ex, dividido em três funções. A primeira é o nascimento:
def mount(_params, _session, socket) do
{:ok, assign(socket, fuba: %Coelhinha{})}
end
mount/3 roda quando alguém abre a página, e devolve um socket - a struct que carrega tudo dessa conexão - com a coelhinha morando nos assigns, os "campos" dele. Agora a coelhinha agora mora no socket, entao pense no socket como uma "variavel", viva. Cada conexão nasce com uma %Coelhinha{} novinha, medidores em 3.
A segunda função é a que cuida:
def handle_event("cuidar", %{"acao" => acao}, socket) do
acao = String.to_existing_atom(acao)
{:noreply, update(socket, :fuba, &Cuidado.aplicar(&1, acao))}
end
Compara com a rota do post 6, do lado:
# post 6, no roteador
%{"acao" => acao} = Plug.Conn.Query.decode(corpo)
Fuba.Guarda.atualizar(&Fuba.Cuidado.aplicar(&1, String.to_existing_atom(acao)))
É a mesma linha. O segurança to_existing_atom continua na porta, e quem decide se o cuidado vale continua sendo o Cuidado.aplicar/2 do post 2 - telinha nenhuma passa por cima da coelhinha! O que mudou é a volta: em vez de mandar um 303 pro navegador recarregar tudo, a gente devolve {:noreply, socket_atualizado} e o LiveView se vira com o resto.
E a terceira é o desenho (HTML + CSS):
def render(assigns) do
~H"""
<main>
<p class="carinha">{Humor.carinha(Cuidado.humor(@fuba))}</p>
<h1>{@fuba.nome}</h1>
<p class="humor">{frase(Cuidado.humor(@fuba))}</p>
...
<button phx-click="cuidar" phx-value-acao="cafe">☕ café</button>
...
</main>
"""
end
O template é o mesmo da página viva do post 5 - mesmos buracos, mesma cara - com três novidades de sintaxe. O ~H é um sigil que escreve HEEx, HTML com Elixir dentro (e verificação de bônus: tag aberta sem fechar vira erro de compilação!). Nos buracos, { } no lugar do <%= %>. E os botões perderam o formulário: phx-click="cuidar" diz "ao clicar, grita 'cuidar' no telefonema", e phx-value-acao="cafe" é o bilhetinho que vai junto - é daí que sai o %{"acao" => acao} do handle_event. Ah, e o CSS sobreviveu inteiro, só mudou de casa: agora mora no layout raiz, o root.html.heex.
o que acontece num clique
Junta tudo: você clica no café. O JS do LiveView manda o evento "cuidar" com acao=cafe pelo websocket. O servidor chama handle_event, que aplica o cuidado e devolve o socket novo. O LiveView roda render de novo, compara o HTML novo com o que a página já tem, e manda de volta pelo telefonema só o que mudou - uns bytes do tipo "o texto daquela barrinha agora é ■■■■□". O navegador troca o quadradinho e mais nada.
Sem piscada. Sem reload. Sem F5 de café duplo: o 303 e o "deseja reenviar o formulário?" morreram de morte morrida, porque não existe mais POST de formulário nenhum. E detalhe delicioso: a primeira vez que a página abre, ela chega como um GET normal, renderizada no servidor igualzinho ao post 5 - o telefonema só assume depois que a página já tá no ar. SSR de graça e interatividade. Chique demais.
o diff vazio, de novo
Lá no post 2 eu disse que a série inteira existe pra provar uma tese: coração limpo, casquinha na borda. O post 5 deu a primeira prova - SSR sem tocar no Cuidado. Hoje a segunda:
git diff post-6 main -- lib/fuba/
Resultado: uma coelhinha, um cuidado e um humor intactos - e um guarda.ex a menos, porque o socket assumiu o posto de caixinha. Do terminal ao SSR ao websocket, três casquinhas, e o coração nem piscou (só a página piscava kk). E o roteador encolheu pra uma linha que conta tudo:
live "/", FubaLive
duas abas, duas fubás
Antes de comemorar, sobe com mix phx.server (o Phoenix tem task própria - e iex -S mix phx.server continua valendo pra quem quer o terminal junto) e faz o teste que a jhujuba fez: abre http://localhost:4000 em duas abas. Dá café numa. Olha a outra.
Nada. A outra Fubá continua com cafeína em 3 - porque cada aba é uma conexão, cada conexão é um socket, e cada socket tem a sua coelhinha. No post 5 a Guarda era uma só e as abas dividiam a mesma Fubá. E tem mais: fecha tudo, volta amanhã, e ela tá exatamente como você deixou - cheia, feliz, sem fome nenhuma. O tempo não passa pra ela.
Isso é bug ou feature? As duas coisas, e proposital: você acabou de sentir por que "uma coelhinha por conexão" não fecha a história. Pra Fubá viver sozinha - sentir fome enquanto você não tá olhando, e ser a mesma em toda aba - a gente precisa de um processo que seja dela, com relógio próprio e um jeito de avisar todo mundo. GenServer, timer e PubSub. Próximo post.
fechou?
Quatro critérios:
mix phx.serversobe ehttp://localhost:4000mostra a Fubá feliz - e as três tags de script carregam (aba Network do navegador, tudo 200).- Clicar no ☕ café enche a cafeína sem a página piscar - na aba Network, nenhum documento novo, só os quadros do websocket indo e voltando.
- Duas abas = duas Fubás com cafeínas diferentes. E você consegue explicar, em voz alta, por quê.
- Você consegue apontar onde a coelhinha mora agora (assigns do socket), o que aposentou a Guarda, e por que o
Cuidadonão mudou uma linha.
O código completo do post tá na main do repositório - os posts anteriores têm cada um a sua branch, então dá pra comparar casquinha por casquinha.
Fechou? A página ganha vida, o botão fala pelo telefonema e o coração segue intacto. Só falta ela viver sozinha - e é isso o post 💜