Partilhar
Engenharia

Melhores Práticas de Vue.js e Nuxt para Aplicações Empresariais

Melhores Práticas de Vue.js e Nuxt para Aplicações Empresariais

De 11 Segundos para 1,2: Uma Transformação de Performance de Dashboard

O dashboard de uma plataforma SaaS era um desastre de performance. Os utilizadores olhavam para um spinner de carregamento durante 11 segundos antes de ver qualquer conteúdo. O bundle era 4,2MB. Cada navegação de página disparava um re-render completo. As reclamações de clientes estavam a acumular.

Seis semanas de otimização sistemática depois, o first contentful paint acontece em 1,2 segundos. O bundle inicial é 180KB. A navegação parece instantânea. A mesma codebase, completamente transformada através de melhores práticas Vue.js e Nuxt.

Eis exatamente o que mudou.

Arquitetura de Componentes Que Escala

O Contrato de Single-File Component

Cada componente numa aplicação Vue bem arquitetada deve seguir esta estrutura:

<script setup>
// 1. Imports
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'

// 2. Definição de Props
const props = defineProps({
userId: {
type: String,
required: true
},
showDetails: {
type: Boolean,
default: false
}
})

// 3. Definição de Emits
const emit = defineEmits(['update', 'delete'])

// 4. Composables
const userStore = useUserStore()

// 5. Estado reativo
const isLoading = ref(false)
const userData = ref(null)

// 6. Computed properties
const displayName = computed(() => {
return userData.value?.name || 'Utilizador Desconhecido'
})

// 7. Métodos
async function fetchUser() {
isLoading.value = true
try {
userData.value = await userStore.fetchById(props.userId)
} finally {
isLoading.value = false
}
}

// 8. Lifecycle hooks
onMounted(() => {
fetchUser()
})
</script>

<template>
<div class="user-card">
<LoadingSpinner v-if="isLoading" />
<template v-else>
<h2>{{ displayName }}</h2>
<!-- Conteúdo do componente -->
</template>
</div>
</template>

<style scoped>
.user-card {
/ Estilos scoped /
}
</style>

Esta ordem não é arbitrária. Espelha o modelo mental de compreender um componente: do que depende, o que aceita, o que emite, que estado gere, e que efeitos secundários executa.

Diretrizes de Tamanho de Componente

Máximo 300 linhas por componente. Se um componente cresce além disto, está a fazer demasiado.

Sinais de split:

  • Múltiplas peças de estado não relacionadas

  • Secções de template que poderiam renderizar independentemente

  • Lógica que poderia ser reutilizada noutro lugar

Padrão de extração:

UserDashboard.vue (300+ linhas)

├── UserHeader.vue (50 linhas)
├── UserStats.vue (80 linhas)
├── UserActivity.vue (120 linhas)
└── useUserDashboard.js (composable com lógica partilhada)

O Padrão Composables

Extrai lógica reutilizável para composables. Nem tudo precisa de estar num componente.

// composables/useAsyncData.js
import { ref, readonly } from 'vue'

export function useAsyncData(fetchFn) {
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)

async function execute(...args) {
isLoading.value = true
error.value = null
try {
data.value = await fetchFn(...args)
} catch (e) {
error.value = e
} finally {
isLoading.value = false
}
}

return {
data: readonly(data),
error: readonly(error),
isLoading: readonly(isLoading),
execute
}
}

// Uso no componente
const { data: users, isLoading, execute: fetchUsers } = useAsyncData(api.getUsers)

Técnicas de Otimização de Performance

Lazy Loading de Componentes

Nunca carregues o que não precisas. Nuxt torna isto trivial:

<script setup>
// Lazy load de componentes pesados
const HeavyChart = defineAsyncComponent(() =>
import('@/components/HeavyChart.vue')
)
</script>

<template>
<div>
<Suspense>
<HeavyChart v-if="showChart" :data="chartData" />
<template #fallback>
<ChartSkeleton />
</template>
</Suspense>
</div>
</template>

No Nuxt, prefixa componentes com Lazy para lazy loading automático:

<template>
<!-- Automaticamente lazy loaded -->
<LazyHeavyChart v-if="showChart" />
</template>

Virtualização para Listas Longas

A renderizar 10.000 itens? Não faças. Virtualiza.

<script setup>
import { useVirtualList } from '@vueuse/core'

const { list, containerProps, wrapperProps } = useVirtualList(
allItems,
{ itemHeight: 50 }
)
</script>

<template>
<div v-bind="containerProps" class="list-container">
<div v-bind="wrapperProps">
<div
v-for="{ data, index } in list"
:key="index"
class="list-item"
>
{{ data.name }}
</div>
</div>
</div>
</template>

O nosso feed de atividade passou de 2,3 segundos para renderizar 5.000 itens para 16ms.

Otimização de Estado Reativo

// MAU: Objeto inteiro é reativo, dispara updates em qualquer mudança
const user = reactive({
profile: { name: '', email: '' },
settings: { theme: 'dark', notifications: true },
activity: [] // Array grande, frequentemente atualizado
})

// BOM: Refs separadas para dados não relacionados
const userProfile = ref({ name: '', email: '' })
const userSettings = ref({ theme: 'dark', notifications: true })
const userActivity = shallowRef([]) // shallowRef para arrays grandes

// Atualizar sem disparar reatividade profunda
userActivity.value = [...newActivityItems]

Caching de Computed Properties

Computed properties guardam os seus resultados em cache. Usa-as.

// MAU: Recalcula em cada render
const filteredItems = items.value.filter(i => i.status === 'active')

// BOM: Só recalcula quando items ou filtro mudam
const filteredItems = computed(() =>
items.value.filter(i => i.status === activeFilter.value)
)

Best Practices Específicas de Nuxt

Padrões de Data Fetching

<script setup>
// Para dados necessários imediatamente no carregamento da página
const { data: products, pending, error } = await useFetch('/api/products', {
key: 'products',
transform: (data) => data.items // Transformar resposta
})

// Para dados que podem carregar após render inicial
const { data: recommendations, refresh } = await useLazyFetch('/api/recommendations')

// Para dados em cache com comportamento tipo SWR
const { data: user } = await useAsyncData('user', () => $fetch('/api/user'), {
getCachedData: (key) => nuxtApp.payload.data[key] // Usar cache se disponível
})
</script>

Code Splitting ao Nível da Rota

Nuxt 3 automaticamente faz code-split por rota. Melhora com chunking explícito:

// nuxt.config.ts
export default defineNuxtConfig({
vite: {
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-charts': ['chart.js', 'd3'],
'vendor-editor': ['monaco-editor'],
}
}
}
}
}
})

Server Routes para Agregação de API

Não faças os clientes chamar múltiplas APIs. Agrega no servidor:

// server/api/dashboard.get.ts
export default defineEventHandler(async (event) => {
const userId = event.context.user.id

// Fetches paralelos no servidor
const [stats, activity, notifications] = await Promise.all([
fetchUserStats(userId),
fetchUserActivity(userId),
fetchUserNotifications(userId)
])

return { stats, activity, notifications }
})

Um pedido do cliente, uma resposta do servidor, três fetches internos paralelos.

Gestão de Estado em Escala

Estrutura de Pinia Store

// stores/products.js
import { defineStore } from 'pinia'

export const useProductStore = defineStore('products', () => {
// Estado
const items = ref([])
const isLoading = ref(false)
const filter = ref({ category: null, priceRange: null })

// Getters
const filteredItems = computed(() => {
return items.value.filter(item => {
if (filter.value.category && item.category !== filter.value.category) {
return false
}
if (filter.value.priceRange) {
const [min, max] = filter.value.priceRange
if (item.price < min || item.price > max) return false
}
return true
})
})

const totalValue = computed(() =>
items.value.reduce((sum, item) => sum + item.price, 0)
)

// Ações
async function fetchProducts() {
isLoading.value = true
try {
items.value = await $fetch('/api/products')
} finally {
isLoading.value = false
}
}

function setFilter(newFilter) {
filter.value = { ...filter.value, ...newFilter }
}

return {
items: readonly(items),
isLoading: readonly(isLoading),
filter,
filteredItems,
totalValue,
fetchProducts,
setFilter
}
})

Quando Usar Store vs. Composable

Usa uma store quando:

  • Estado é partilhado entre múltiplos componentes não relacionados

  • Estado precisa de persistir através de navegações de rota

  • Precisas de integração com devtools para debugging

Usa um composable quando:

  • Lógica é reutilizável mas estado é local ao componente

  • Estado não precisa de persistir através de navegações

  • Requisitos de testing mais simples

Tratamento de Erros e Resiliência

Global Error Handler

// plugins/error-handler.client.js
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.config.errorHandler = (error, instance, info) => {
// Log para serviço de monitorização
console.error('Erro Vue:', error, info)

// Mostrar erro amigável ao utilizador
const toast = useToast()
toast.error('Algo correu mal. Por favor tenta novamente.')
}

// Tratar promise rejections não tratadas
window.addEventListener('unhandledrejection', (event) => {
console.error('Promise rejection não tratada:', event.reason)
})
})

Error Boundaries de Componente

<script setup>
import { onErrorCaptured, ref } from 'vue'

const error = ref(null)

onErrorCaptured((err) => {
error.value = err
return false // Prevenir propagação
})
</script>

<template>
<div v-if="error" class="error-boundary">
<p>Algo correu mal nesta secção.</p>
<button @click="error = null">Tentar novamente</button>
</div>
<slot v-else />
</template>

Estratégia de Testes

Unit Testing de Componentes

// components/__tests__/UserCard.spec.js
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import UserCard from '../UserCard.vue'

describe('UserCard', () => {
it('mostra nome do utilizador quando carregado', async () => {
const wrapper = mount(UserCard, {
props: { userId: '123' },
global: {
stubs: ['LoadingSpinner']
}
})

// Esperar por operações async
await wrapper.vm.$nextTick()

expect(wrapper.text()).toContain('João Silva')
})

it('emite evento delete quando botão clicado', async () => {
const wrapper = mount(UserCard, {
props: { userId: '123' }
})

await wrapper.find('[data-test="delete-btn"]').trigger('click')

expect(wrapper.emitted('delete')).toBeTruthy()
})
})

E2E Testing com Playwright

// e2e/dashboard.spec.js
import { test, expect } from '@playwright/test'

test('dashboard carrega e mostra dados', async ({ page }) => {
await page.goto('/dashboard')

// Esperar que loading complete
await expect(page.locator('[data-test="loading"]')).toBeHidden()

// Verificar que dados são mostrados
await expect(page.locator('[data-test="stats-card"]')).toBeVisible()
await expect(page.locator('[data-test="activity-feed"]')).toHaveCount(10)
})

O tempo de carregamento de 11 segundos não foi causado por uma coisa. Foram cem pequenas decisões a acumular. E o resultado de 1,2 segundos não foi alcançado através de uma otimização. Foram cem pequenas melhorias, cada uma guiada por medição e as práticas descritas aqui.

Performance não é uma feature que adicionas no fim. É uma disciplina que manténs desde o início.

Ricardo Mendes

Sobre o Autor

Ricardo Mendes

Cofundador da AIOBI. Engenheiro Informático com experiência em análise de dados, software e gestão financeira.