- Mantém a renderização no servidor baseada em
html/template do Go enquanto adiciona interações dinâmicas com HTMX, retornando seletivamente páginas completas e fragmentos HTML a partir de um único renderizador
- Divide a marcação em
base.tmpl, templates de página e fragmentos reutilizáveis, e simplifica a implantação e a estrutura de renderização com embed.FS do Go 1.16 e um conjunto compartilhado de templates
- Dependendo de
HX-Request, retorna resultados de busca como página completa ou como linhas de tabela, evitando erros de cache e de restauração ao voltar com Vary: HX-Request e historyRestoreAsHxRequest: false
- Ao redirecionar requisições HTMX para outra página, usa
HX-Redirect e respostas 2xx em vez de 3xx comuns, e controla o escopo de substituição e a exibição de erros com responseHandling para 204, 422, 4xx e 5xx
- Usa como ponto de partida uma configuração que desativa cache em armazenamento local e herança de atributos, além de definir tempo limite das requisições; conforme a escala aumenta, pode adicionar templates de layout intermediários para suportar estruturas de tela separadas, como uma área administrativa
Por que combinar a renderização no servidor do Go com HTMX
- O HTMX permite adicionar interações dinâmicas com pouco JavaScript, mantendo a consistência e a segurança da renderização de HTML no servidor oferecida por
html/template do Go
- A implementação trata de três frentes
- estrutura de templates que dá suporte tanto a páginas completas quanto a respostas HTML parciais
- redirecionamento e tratamento de erros no ambiente HTMX
- configuração que altera os valores padrão do HTMX e o motivo disso
- Começa com uma funcionalidade básica de trocar um botão por uma imagem e evolui para uma tela de busca que filtra a lista enquanto o usuário digita nome de usuário ou e-mail
- O mesmo padrão de template pode ser aplicado não só ao HTMX, mas também a ferramentas HTML-over-the-wire como Unpoly e Hotwire
- Se você ainda não conhece o uso básico do HTMX, vale a pena consultar primeiro a documentação oficial
Estrutura de diretórios do projeto e arquivos estáticos
- O projeto é dividido nas seguintes áreas conforme a função
assets/html/base.tmpl: estrutura HTML compartilhada por todas as páginas
assets/html/pages/: conteúdo de páginas individuais
assets/html/partials/: fragmentos HTML reutilizados em vários pontos
assets/static/css, assets/static/img, assets/static/js: ativos estáticos
cmd/web/: inicialização do servidor, handlers e código do renderizador HTML
- A estrutura básica é criada com os comandos a seguir
go mod init example.com/htmx
mkdir -p assets/static/css assets/static/img assets/static/js assets/html/partials assets/html/pages cmd/web
touch assets/efs.go assets/html/base.tmpl assets/html/partials/images.tmpl assets/html/pages/home.tmpl cmd/web/main.go cmd/web/handlers.go cmd/web/html.go
- O HTMX pode ser instalado por CDN ou NPM, mas aqui se usa principalmente a abordagem de baixar uma cópia e servi-la diretamente como arquivo estático da aplicação
- a configuração fica mais simples e evita as desvantagens do uso de CDN
- são usados
htmx.org@2.0.10 e o framework CSS sem classes bamboo.css@1.4.0
wget -P assets/static/js https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js
wget -P assets/static/css https://cdn.jsdelivr.net/npm/bamboo.css@1.4.0/dist/bamboo.min.css
Separando templates base, de página e parciais
base.tmpl define a estrutura completa do documento, como <head>, título comum e <main>, e insere page:title e page:content
- O Bamboo CSS e o script do HTMX são carregados no template base, e o script do HTMX recebe o atributo
defer
- o navegador busca o script em paralelo enquanto faz o parsing do HTML
- o script é executado depois que o parsing do HTML e a construção do DOM terminam
- Todos os templates recebem nomes explícitos com
{{define}}...{{end}}, sem depender do nome do arquivo
- o código Go pode referenciar os templates de forma consistente usando apenas os nomes definidos
- os
: em page:title são apenas um separador arbitrário, e nomes como page_title, page-title, pageTitle ou title também seriam possíveis
- O botão da página inicial define dois comportamentos
hx-get="/gopher": envia uma requisição GET /gopher ao clicar
hx-swap="outerHTML": substitui o próprio botão pelo HTML da resposta
partial:image:gopher é um fragmento de imagem reutilizável, e recebe a largura da imagem no momento da chamada por meio de width="{{.}}"
- Fragmentos HTML usados apenas em uma página específica podem ficar no próprio arquivo da página, em vez do diretório compartilhado
partials
- a ideia é separar fragmentos usados em várias páginas no diretório comum e colocar os fragmentos exclusivos de uma página ao lado do conteúdo relacionado
Incluindo HTML e ativos estáticos no binário Go
- Desde que o Go 1.16 adicionou incorporação de arquivos, é possível incluir HTML e ativos estáticos no binário Go em vez de lê-los do disco em tempo de execução
- A diretiva
//go:embed "html" "static" coloca os dois diretórios em um embed.FS, e fs.Sub() os separa em dois subsistemas de arquivos
HTMLFiles: sistema de arquivos de templates com html como raiz
StaticFiles: sistema de arquivos de arquivos estáticos com static como raiz
- Há duas vantagens nessa separação
- o código de HTML e o código de arquivos estáticos não acessam desnecessariamente os arquivos um do outro
- não é preciso adicionar os prefixos
html/ ou static/ ao abrir arquivos
- Se você não quiser usar
panic() em sub(), também pode retornar um erro e inicializar as duas variáveis em main()
- Na implementação atual, para
fs.Sub() falhar, o valor do diretório teria de ser um caminho inválido; como as strings fixas "html" e "static" passam nessa verificação, o risco de pânico em tempo de execução é muito baixo
Conjunto compartilhado de templates e htmlRenderer
htmlRenderer gerencia o fluxo de renderização da seguinte forma
- ao iniciar, faz o parse de um conjunto compartilhado de templates uma única vez
- a cada renderização, clona o conjunto compartilhado
- faz o parse adicional dos templates de página necessários para a requisição
- executa o template com o nome especificado e o envia como resposta HTTP
newHTMLRenderer() registra um template.FuncMap e então faz o parse dos templates comuns
- conecta
now a time.Now
- outras funções personalizadas de template também podem ser adicionadas no mesmo ponto
render() cria um conjunto de templates por requisição com sharedTemplates.Clone() e, se houver additionalTemplateFiles, o expande com ParseFS()
- Os templates são executados primeiro em um
bytes.Buffer, e só depois o código de status é gravado e o corpo da resposta é enviado
- Na inicialização do servidor,
"base.tmpl" e "partials/*.tmpl" são parseados no conjunto compartilhado
- o template base e todos os fragmentos comuns ficam sempre disponíveis no renderizador
- apenas os templates específicos de página são adicionados na chamada do handler
- Os arquivos estáticos são servidos com
http.FileServerFS(assets.StaticFiles) na rota /static/, e o servidor roda em :5051
Renderizando páginas completas e fragmentos HTML
- O handler da página inicial retorna o documento completo com a chamada abaixo
app.html.render(w, 200, nil, "base", "pages/home.tmpl")
- Essa chamada adiciona
pages/home.tmpl ao conjunto compartilhado e executa o template base com 200 OK
- O handler de
/gopher não faz parse de arquivos adicionais; ele executa apenas partial:image:gopher, que já está incluído no conjunto compartilhado
width := 100
app.html.render(w, http.StatusOK, width, "partial:image:gopher")
- O HTMX substitui o botão pela imagem recebida de
/gopher, de acordo com a configuração hx-swap="outerHTML"
- Essa estrutura oferece as seguintes vantagens
- Templates e assets estáticos ficam embutidos no binário, facilitando o deploy
- O mesmo
render() pode retornar tanto a página HTML completa quanto fragmentos específicos de HTML
- Templates base e parciais reduzem a duplicação de markup
- Templates parciais podem ser reutilizados no template base, no conteúdo da página e em outros templates parciais
Busca de usuários com atualização durante a digitação
- A funcionalidade de busca é dividida em duas rotas
GET /users: página HTML completa com as informações de todos os usuários
GET /users/search: fragmento HTML que retorna apenas as linhas da tabela dos usuários cujo nome ou e-mail correspondem ao termo buscado
- O campo de busca combina os seguintes atributos do HTMX
hx-get="/users/search" envia a requisição de busca
hx-trigger="input changed delay:500ms, keyup[key=='Enter']" faz a requisição após 500 ms de uma mudança na entrada ou ao pressionar Enter
- O termo da busca é enviado como query string no formato
GET /users/search?query=foo
hx-target="#search-results" insere a resposta dentro de <tbody id="search-results">
hx-push-url="true" atualiza a barra de endereço a cada requisição e adiciona um item ao histórico do navegador
users:rows é um template separado que renderiza apenas as linhas de usuário
- Cada linha exibe nome e e-mail
- Se
IsGopher for verdadeiro, renderiza partial:image:gopher com largura 24
listUsers e searchUsers adicionam pages/users.tmpl ao conjunto compartilhado, mas executam templates diferentes
listUsers: executa base e retorna a página completa
searchUsers: executa users:rows e retorna apenas as linhas correspondentes
- Se o termo de busca estiver vazio, usa-se a lista completa de usuários; se houver valor,
strings.Contains() verifica nome e e-mail
- Em uma aplicação real, vale considerar os seguintes aprimoramentos
- Unificar os handlers de listagem e busca em um só
- Centralizar helpers de tratamento de erro e logging
- Adicionar, via middleware, cabeçalhos de Content Security Policy e recuperação de pânico
- Configurar timeouts adequados no servidor
Distinguir página completa e resposta parcial com HX-Request
- Se o usuário abrir
/users/search?query=leo diretamente no navegador ou acessar por um link compartilhado, ele poderá ver apenas o HTML parcial correspondente às linhas da tabela
- Requisições do HTMX sempre incluem o cabeçalho
HX-Request: true, então ele é usado como critério para decidir o formato da resposta
func isHTMXRequest(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
- O template padrão executado pelo handler de busca passa a ser
base, e só muda para users:rows em requisições HTMX
- Em acesso direto, o usuário recebe a página completa com os resultados da busca
- Em requisições HTMX, retorna apenas o fragmento de linhas para inserir no
<tbody>
Cabeçalho Vary e restauração ao voltar
- Como a mesma URL retorna respostas diferentes conforme o valor de
HX-Request, o cache também precisa ser informado dessa diferença
- Adiciona-se
Vary: HX-Request a todas as respostas renderizadas
- Definir isso apenas nos handlers que precisarem seria, em tese, menos desperdiçador
- Configurar tudo de forma centralizada no renderer evita bugs causados por omissões
- Quando
hx-push-url ou hx-boost adicionam entradas ao histórico do navegador, o HTMX armazena em cache o HTML completo da página no armazenamento local do navegador
- O cache padrão é de até 10 páginas
- Ao voltar para um ponto que não está no cache, o HTMX faz nova requisição ao servidor para aquela URL
- Por padrão, a requisição de restauração inclui
HX-Request: true, então o servidor pode retornar HTML parcial em vez da página completa
- Se você escolhe o tipo de resposta com base em
HX-Request, defina historyRestoreAsHxRequest: false
- Isso remove
HX-Request: true das requisições de restauração com cache miss
- Assim, o servidor retorna a página HTML completa para essa requisição
- A configuração do HTMX vai na meta tag
htmx-config de base.tmpl
Redirecionar requisições HTMX para outra página
- O HTMX consegue substituir imediatamente fragmentos HTML, como mensagens de sucesso, o que reduz a necessidade do fluxo tradicional Post/Redirect/Get
- Quando for necessário enviar o usuário para uma página completamente diferente, como ir para o perfil após login bem-sucedido, não dá para resolver isso apenas com uma resposta 3xx comum
- O navegador segue a resposta 3xx antes do HTMX
- O HTMX vê apenas a resposta final após o redirecionamento e troca seu conteúdo no alvo existente
- Em requisições HTMX, usa-se uma resposta 2xx junto com o cabeçalho
HX-Redirect
HX-Redirect: /foo/bar faz o navegador navegar para essa URL e recarregar a página inteira
- A requisição para o destino não inclui
HX-Request: true
- Para também dar suporte a ambientes com JavaScript desativado ou sem o HTMX carregado, requisições que não são HTMX devem continuar usando resposta 3xx normal
func redirect(w http.ResponseWriter, r *http.Request, url string, code int) {
if isHTMXRequest(r) {
w.Header().Set("HX-Redirect", url)
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, url, code)
}
- Para ir para
/profile após o login, a chamada pode ser feita assim
redirect(w, r, "/profile", http.StatusSeeOther)
Diferença entre HX-Redirect e HX-Location
HX-Location executa um comportamento parecido com redirecionamento sem recarregar a página inteira
- Busca o HTML da URL de destino
- Substitui a resposta dentro de
<body>
- Adiciona uma nova entrada ao histórico do navegador
- Ao requisitar a URL de destino,
HX-Request: true é incluído
- Isso pode causar problemas se a rota de destino retornar HTML parcial com base em
HX-Request
- O handler não consegue distinguir uma requisição vinda de
HX-Location de uma requisição HTMX comum
- A primeira precisa de página completa, enquanto a segunda precisa de resposta parcial
- Diferentemente da restauração de histórico, não há configuração para desativar
HX-Request: true em navegações via HX-Location
- Na maioria dos casos, usar
HX-Redirect é mais simples e seguro, mesmo com o custo de recarregar a página inteira
HX-Location também pode ser usado se a rota de destino estiver configurada para sempre retornar apenas HTML completo
Tratamento de respostas 204·422·4xx·5xx
- Por padrão, o HTMX não troca o DOM com respostas 4xx ou 5xx
- A tela atual permanece como está
- O erro é registrado no console das ferramentas de desenvolvedor
- Mesmo que o servidor retorne
500 Internal Server Error por causa de um nome de template inexistente, na configuração padrão o usuário pode não perceber o erro na tela
- Para exibir mensagens de erro ao usuário, respostas 4xx e 5xx devem ser configuradas para substituir todo o
<body>
- A exceção é
422 Unprocessable Content
- Ele é usado ao retornar um formulário com erros de validação
- A resposta deve substituir normalmente o alvo HTMX existente
responseHandling é configurado assim, de acordo com o status code
204 No Content: não altera o DOM
422 Unprocessable Content: substitui normalmente o alvo existente
- Demais 4xx e 5xx: substituem a resposta no
<body>
- Outros casos: substituem normalmente o alvo existente
"responseHandling":[
{"code":"204", "swap": false},
{"code":"422", "swap": true},
{"code":"[45]..", "swap": true, "target": "body"},
{"code":"...", "swap": true}
]
- Se, em determinadas interações, o erro precisar ser colocado em outro alvo em vez do
<body>, é possível sobrescrever o padrão com a extensão response target
Verificando a URL atual na barra de endereços do navegador
- O HTMX não altera a URL do navegador durante o processo de requisição AJAX e substituição da resposta, a menos que
hx-boost, hx-push-url ou hx-replace-url sejam usados
- Por isso,
r.URL no handler Go e a URL que o usuário vê na barra de endereços podem ser diferentes
- O HTMX envia a URL atualmente exibida pelo navegador no cabeçalho
HX-Current-URL de cada requisição
- A função auxiliar faz o parse desse cabeçalho como
url.URL e, se o cabeçalho não existir, retorna r.URL
func browserURL(r *http.Request) (*url.URL, error) {
cu := r.Header.Get("HX-Current-URL")
if cu != "" {
return url.Parse(cu)
}
return r.URL, nil
}
Configuração que altera os padrões do HTMX
historyCacheSize: 0 desativa completamente o cache de páginas no armazenamento local do HTMX
- O cache no armazenamento local pode ser uma fonte de bugs e problemas de segurança
- Ao clicar em voltar, o HTML é buscado novamente do servidor em vez de vir do cache
- Em versões futuras do HTMX, o cache no armazenamento local também deverá vir desativado por padrão pelo mesmo motivo
disableInheritance: true desativa a herança de atributos do HTMX
- Declarar os atributos sempre de forma explícita é mais claro
- Reduz o risco de bugs ou comportamentos não intencionais
- Em versões futuras do HTMX, a herança de atributos também deverá vir desativada por padrão
includeIndicatorStyles: false faz com que o HTMX não injete estilos de indicador
- Os estilos de indicador são definidos manualmente junto com outras regras de CSS para manter a consistência
- As requisições do HTMX, por padrão, não têm limite de tempo e esperam até o servidor responder
- Dependendo da forma como a aplicação Go trata timeout e deadlines, é possível definir
timeout em milissegundos
- Como ponto de partida, usa-se
timeout: 5000
- A configuração padrão também inclui
historyRestoreAsHxRequest: false e responseHandling por código de status
<meta
name="htmx-config"
content='{
"includeIndicatorStyles": false,
"historyCacheSize": 0,
"historyRestoreAsHxRequest": false,
"responseHandling":[
{"code":"204", "swap": false},
{"code":"422", "swap": true},
{"code":"[45]..", "swap": true, "target": "body"},
{"code":"...", "swap": true}
],
"timeout": 5000
}'
>
Layouts por página para aplicações grandes
- Se apenas o template base e os templates por página não forem suficientes, é possível adicionar um template de layout entre essas duas camadas
- Isso é adequado para áreas como páginas administrativas, que precisam de um título, menu de navegação e estrutura de conteúdo diferentes das páginas normais
base chama layout em vez de chamar page:content diretamente
<body>
{{template "layout" .}}
</body>
layouts/admin.tmpl define a estrutura comum da área administrativa e chama page:content internamente
- Título da área administrativa
- Menu de navegação para usuários e pedidos
<main> onde entra o conteúdo específico da página
- O handler passa para
render() tanto o layout quanto o template da página a ser usado
app.html.render(
w,
200,
nil,
"base",
"layouts/admin.tmpl",
"pages/admin-orders.tmpl",
)
- Mantendo o template base compartilhado, é possível combinar layouts por rota e conteúdo de página, permitindo estender o mesmo padrão de renderização para telas que precisam de uma estrutura própria, como a área administrativa
1 comentários
Comentários do Hacker News
Gosta de Go + HTMX e usa junto com a-h/templ para aumentar a segurança de tipos de templates, componentes e HTML parcial Montou toda a caixa de ferramentas com Go·Unix·SQLite, chamou de stack GUS e publicou: https://housecat.com/blog/the-gus-stack-go-unix-sqlite Foi fortemente inspirado pela stack GUTS do exe.dev, mas usa HTMX no lugar de TypeScript: https://exe.dev/docs/guts Para erros com rastreamento de stack usa cockroachdb/errors, para HTML type-safe usa templ, para geração de especificações OpenAPI usa fuego, para geração de código SQL usa sqlc, para SQLite puro em Go usa modernc.org/sqlite, para migrations usa goose, para workflows persistentes usa dbos, e para testes e automação com Chrome/CDP usa rod Essa combinação é produtiva tanto para programar diretamente ou com ajuda de agentes quanto para o processo de build e deploy de um binário único
go generate ./...como parte da etapa de build, há muito a ganhar, e com goverter também é possível gerar conversões entre modelos do sqlc e objetos/valores de retorno de templates Cerca de 50% do boilerplate é gerado automaticamente, então a segurança de tipos fica excelenteHTMX é excelente para vários usos, mas é muito difícil adotá-lo se os colegas de equipe não concordarem Continua havendo resistência de que não é uma tecnologia séria, e todo tipo de bug primeiro é atribuído ao HTMX; mesmo quando depois se descobre que foi um mal-entendido, a confiança já foi abalada Isso aconteceu até na melhor equipe em que já trabalhou, e a lição foi que no fim é preciso escolher quais batalhas vale a pena travar Também acha que a interface de html/template do Go é especialmente pouco natural. Como recomenda A Philosophy of Software Design, a complexidade deveria ser empurrada para dentro, mas uma estrutura em que é preciso se preocupar em duplicar templates toda vez que se renderiza HTML é desnecessariamente complexa
base.tmplemBASE_BEGINeBASE_ENDpara usar no template final O título específico de cada página ainda precisa ser passado em tempo de execução, mas a abordagem do texto original também acabaria quebrando quando se começasse a dar suporte a múltiplos idiomasEm um projeto recente, usou HTMX com grande satisfação Como alguém que conhece a web de antes de AngularJS e React, gosta especialmente de poder construir páginas reais e minimizar a quantidade de JavaScript Dá para fazer também com JavaScript puro, mas HTMX substitui aquele boilerplate repetitivo de tratamento de eventos Se você não gosta da filosofia moderna de frontend, vale a pena experimentar; os primeiros exemplos no site oficial são bem básicos, mas ao aprender um pouco mais ele se torna muito mais poderoso
Já tentei criar um projeto relativamente grande com HTMX + Go, mas ainda não foi suficiente, e não tenho certeza se algum dia vai chegar ao nível que eu quero É excelente para apps CRUD simples e painéis administrativos, mas quando aumentam os componentes interligados, o estado compartilhado e as interações complexas, a manutenção rapidamente fica difícil Escolhi HTMX porque não gostava de React, mas no fim mantive o backend em Go e troquei o frontend para SvelteKit em modo SPA, conseguindo separar bem os dois lados e desenvolver/manter uma UI complexa com muito mais facilidade O Svelte parece mais uma extensão natural do HTML do que outra linguagem como JSX, o gerenciamento de estado e o modelo de componentes são simples, e a nova sintaxe
$stateé especialmente boaHoje em dia, quase todos os meus side projects são feitos com Go + HTMX, com ou sem ajuda de LLM Tanto o Opus quanto o GPT lidam muito bem com essa combinação, dá para criar e começar rápido, e por ser um binário único, deploy e hospedagem também ficam convenientes É uma stack muito boa para desenvolvimento iterativo rápido
Se for usar HTMX, recomendo fortemente uma forma de gerar HTML no backend que facilite extrair trechos HTML comuns em funções, componentizando como se faz com React Com HTMX, o backend precisa de flexibilidade no HTML que gera, porque dependendo do caso é necessário adicionar ou remover tags ao lado de um certo HTML específico. Por exemplo, se ele encontrar uma tag `` no topo, atualiza o título da página Em mecanismos de template tradicionais baseados em string, esse tipo de coisa é difícil, mas em bibliotecas de HTML embutidas na linguagem isso é simples, e como também permitem usar todos os recursos de abstração da linguagem, combinam especialmente bem com HTMX Em backends JavaScript, você pode usar funções de tagged template literal como https://github.com/WebReflection/uhtml-ssr; em Go, https://www.gomponents.com/; em Scala, ScalaTags
renderToStaticMarkup, mas provavelmente é melhor escolher uma biblioteca JSX feita especificamente para backendConheci Go pela primeira vez pelos livros do Alex Edwards e pelo Learn Go with Tests, e ainda recomendo ambos Fiquei com vontade de tentar migrar alguns projetos antigos para HTMX
Criei para uso pessoal um framework baseado em Kotlin + HTMX: https://github.com/reubenfirmin/zoned O objetivo era ver se dava para tornar todo o app web type-safe de ponta a ponta, e usei o Kotlinx.html, que permite escrever HTML com uma DSL parecida com JSX Fiz os primeiros 90% por conta própria e contei com a ajuda do Claude para os 10% finais e o README, para concluir a publicação
Gosto tanto de Go quanto de Alex Edwards, mas meus resultados com HTMX sempre foram decepcionantes Parece que a complexidade da base de código cresce duas vezes mais rápido do que a do próprio app, e sempre acabo encontrando casos de borda dos quais não dá para sair sem algum workaround esquisito Entendo por que tanta gente não gosta de pacotes Node, mas HTMX me parece uma compensação exagerada para isso. O tempo que economizei por não brigar com JSON foi mais do que anulado por gastar 3 vezes mais tempo tentando fazer o app parecer e se comportar direito Com o template do Mantine, em 2 minutos dá para configurar e aproveitar ótimos componentes de UI, e incluindo os assets estáticos gerados também dá para manter tudo como um único binário Go: https://github.com/mantinedev/vite-min-template
Prefiro muito mais Go + Datastar por ser mais simples