Função de render no Vue: quando o template não dá conta da estrutura
Saiba quando trocar o template pela função de render no Vue 3, como usar h() e slots, e veja um componente que transforma conteúdo em JSON em HTML seguro, sem v-html.
A loja de móveis passou a montar as páginas de ajuda num CMS headless. A API devolve o conteúdo como uma árvore em JSON: parágrafos, títulos, listas, links e, no meio do texto, blocos de produto. O primeiro impulso da equipe foi converter tudo em HTML e jogar num v-html. O segundo foi escrever um template com dez v-if aninhados. A resposta curta: quando a tag, a profundidade ou o tipo dos filhos dependem de dados que só chegam em tempo de execução, a função de render resolve melhor que o template. Ela devolve a árvore de nós em JavaScript com h(), pode chamar a si mesma para cada nível e continua oferecendo slots para quem usa o componente. Para todo o resto, o template continua sendo a escolha certa.
Este guia usa a API do Vue 3. No Vue 2, a ideia é a mesma, mas h chegava como argumento de render e os slots com escopo ficavam em this.$scopedSlots.
O que o template faz por baixo
O compilador do Vue transforma cada template numa função de render. Quando você escreve <p>{{ texto }}</p>, o resultado é algo equivalente a h('p', texto). A função de render não é um recurso mais poderoso escondido; é o mesmo destino, escrito à mão.
A diferença está no que cada forma expressa bem. O template descreve uma estrutura conhecida, com variações pontuais (v-if, v-for). A função de render é JavaScript comum: você usa if, switch, map, recursão e objetos de configuração sem truque. Quando a estrutura em si é o dado, como numa árvore de conteúdo, o JavaScript fica mais legível do que um template tentando prever todas as formas.
Antes de escrever uma função de render
Muitos casos que parecem pedir função de render se resolvem no template. Um título cujo nível varia é um exemplo:
<component :is="'h' + nivel"><slot /></component>
O elemento component com :is troca a tag ou o componente conforme um valor. Um formulário com três tipos de campo também cabe num v-if, v-else-if e v-else legível. A função de render vale a pena quando:
- a estrutura é recursiva e de profundidade desconhecida, como árvore de conteúdo ou menu vindo de configuração;
- a lista de tipos é grande e cresce, e cada tipo é melhor descrito como uma linha numa tabela de correspondência;
- o componente precisa inspecionar ou envolver os nós que recebeu por slot.
Como escrever o componente passo a passo
- Defina o formato de entrada. Na loja, cada nó tem
tipo, e pode terfilhos,valor,nivel,hrefousku. - Crie uma tabela de tipos permitidos. Só os tipos conhecidos viram HTML; o resto é ignorado.
- Escreva uma função recursiva que recebe um nó e devolve um nó virtual, um texto ou nada.
- Trate os casos especiais: título com nível limitado, link com destino validado e bloco de produto entregue a um slot.
- Retorne a função de render do
setup, para que ela rode de novo quando as props mudarem. - Ofereça um conteúdo padrão quando o slot não foi passado ou a lista veio vazia.
O componente ConteudoCms
O JSON que a API devolve para a página de prazo de entrega:
[
{ "tipo": "titulo", "nivel": 2, "filhos": [{ "tipo": "texto", "valor": "Prazo de entrega" }] },
{ "tipo": "paragrafo", "filhos": [
{ "tipo": "texto", "valor": "Pedidos pagos até 14h são separados no mesmo dia. " },
{ "tipo": "link", "href": "/frete", "filhos": [{ "tipo": "texto", "valor": "Consulte a tabela de frete" }] }
] },
{ "tipo": "produto", "sku": "MESA-CARVALHO-160" },
{ "tipo": "lista", "filhos": [
{ "tipo": "item", "filhos": [{ "tipo": "texto", "valor": "Capitais: até 5 dias úteis" }] },
{ "tipo": "item", "filhos": [{ "tipo": "texto", "valor": "Interior: até 10 dias úteis" }] }
] }
]
E o componente:
// src/components/ConteudoCms.js
import { h } from 'vue'
const TAGS = {
paragrafo: 'p',
negrito: 'strong',
italico: 'em',
lista: 'ul',
item: 'li'
}
function hrefSeguro(href) {
return typeof href === 'string' && /^(\/(?![\/\\])|https:\/\/)/.test(href)
}
function renderizar(no, slots) {
if (no.tipo === 'texto') return String(no.valor ?? '')
const filhos = (no.filhos || []).map((f) => renderizar(f, slots))
switch (no.tipo) {
case 'titulo': {
const nivel = Math.min(Math.max(Number(no.nivel) || 2, 2), 4)
return h('h' + nivel, filhos)
}
case 'link':
return hrefSeguro(no.href) ? h('a', { href: no.href }, filhos) : h('span', filhos)
case 'produto':
return slots.produto ? slots.produto({ sku: no.sku }) : null
default: {
const tag = TAGS[no.tipo]
return tag ? h(tag, filhos) : null
}
}
}
export default {
name: 'ConteudoCms',
props: {
blocos: { type: Array, required: true }
},
setup(props, { slots }) {
return () => {
if (props.blocos.length === 0) {
return slots.vazio ? slots.vazio() : h('p', 'Conteúdo indisponível no momento.')
}
return h('div', props.blocos.map((b) => renderizar(b, slots)))
}
}
}
O que cada parte garante
O setup devolve uma função, e não o resultado de chamá-la. É isso que mantém a reatividade: quando blocos muda, o Vue executa a função de novo e compara a árvore nova com a anterior.
A tabela TAGS funciona como lista de permissão. Um nó com tipo: "script" ou tipo: "iframe" simplesmente não vira nada. O nível do título fica entre 2 e 4, porque o h1 já é o título da página. E o texto entra como texto: o Vue cria um nó de texto, sem interpretar HTML, então um valor como <img onerror=...> aparece na tela como caracteres, não executa.
O link merece cuidado à parte. A função hrefSeguro aceita caminhos internos começando com uma barra e endereços https://. Ela recusa javascript:, e também recusa //outro-site.com e /\outro-site.com, que os navegadores tratam como endereços de outro domínio, um truque clássico para burlar a checagem de "começa com barra". O mesmo raciocínio aparece no guia sobre URL aberta que não pode redirecionar.
Slots continuam sendo o contrato
Quem usa o componente não precisa saber que existe uma função de render por dentro. Na função, cada slot chega como uma função: você chama slots.produto({ sku }) no ponto exato da árvore em que o bloco deve aparecer, passando os dados como props do slot. Se o slot não foi passado, o componente decide o que mostrar. Do lado de fora, o uso é template comum:
<ConteudoCms :blocos="pagina.blocos">
<template #produto="{ sku }">
<CartaoProduto :sku="sku" />
</template>
<template #vazio>
<p>Esta página de ajuda ainda não foi publicada.</p>
</template>
</ConteudoCms>
O componente de conteúdo não conhece o cartão de produto, e o cartão não conhece o CMS. Cada um pode mudar sem quebrar o outro.
Por que não usar v-html
Converter a árvore em string HTML e usar v-html parece mais curto, mas entrega ao navegador um HTML que você não controla. Qualquer erro de escape no conversor, ou um editor do CMS colando um trecho com script, vira XSS na sua página. Com a função de render, só existe o que a tabela permite. Além disso, v-html não consegue colocar um componente Vue, como o cartão de produto, no meio do conteúdo.
Se a equipe acha h() difícil de ler, o Vue suporta JSX com o plugin @vitejs/plugin-vue-jsx. É a mesma função de render com outra sintaxe; as regras de segurança e de slots não mudam.
Erros comuns
- Converter um template estável em função de render. O código fica mais longo, mais difícil de revisar e ninguém ganha nada.
- Retornar
h(...)direto dosetup, sem envolver numa função. O componente renderiza uma vez e para de reagir às props. - Aceitar qualquer
tipocomo nome de tag. Um nó malicioso criaiframeouscriptna sua página. - Validar link só com "começa com barra". Um
//dominio-alheio.compassa e leva a pessoa para fora. - Chamar um slot sem verificar se ele existe. O componente quebra com erro de função indefinida quando o pai não passa o slot.
- Esquecer acessibilidade ao montar elementos à mão. Sem o template à vista, é fácil esquecer o
forde um rótulo ou o texto de um botão.
Checklist
- Você tentou primeiro
component :isouv-ife a estrutura não coube. - O
setupretorna uma função que chamah(). - Tipos de nó passam por uma lista de permissão.
- Texto entra como texto; não há
v-htmlno caminho. - Links passam por validação que recusa
javascript:e endereços com duas barras. - Todo slot chamado tem verificação de existência e conteúdo padrão.
- O componente tem teste com um nó desconhecido e com lista vazia.
Procure v-html no seu projeto Vue. De onde vem o HTML que ele recebe, e quem garante que nele não existe um script?
Referência: https://v3.vuejs.org/guide/render-function.html#slots