Buenas Practicas [VueJS]

Guía de mejores prácticas para desarrollar aplicaciones de escritorio performantes con Vue.js 3 y Tauri.

Arquitectura de Componentes

Componentes Pequeños y Enfocados

Mal ejemplo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!--  Componente monolítico -->
<template>
  <div class="control-center">
    <!-- Audio controls -->
    <div class="audio">
      <input v-model="volume" type="range" />
      <button @click="toggleMute">Mute</button>
      <select v-model="selectedDevice">
        <option v-for="device in devices">{{ device }}</option>
      </select>
    </div>
    <!-- Bluetooth controls -->
    <div class="bluetooth">...</div>
    <!-- Network controls -->
    <div class="network">...</div>
    <!-- Battery info -->
    <div class="battery">...</div>
  </div>
</template>

<script setup>
// 300+ líneas de lógica mezclada
const volume = ref(50)
const devices = ref([])
// ... muchísima más lógica
</script>

Buen ejemplo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<!--  Componente principal pequeño -->
<template>
  <div class="control-center">
    <AudioControl />
    <BluetoothControl />
    <NetworkControl />
    <BatteryInfo />
  </div>
</template>

<script setup lang="ts">
import AudioControl from './components/AudioControl.vue'
import BluetoothControl from './components/BluetoothControl.vue'
import NetworkControl from './components/NetworkControl.vue'
import BatteryInfo from './components/BatteryInfo.vue'
</script>

Props Tipadas y Documentadas

Mal ejemplo:

1
2
3
4
<script setup>
// ❌ Props sin tipos ni documentación
const props = defineProps(['title', 'data', 'callback'])
</script>

Buen ejemplo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script setup lang="ts">
interface Device {
  id: string
  name: string
  volume: number
}

interface Props {
  /** Título del componente de audio */
  title: string
  /** Lista de dispositivos de audio disponibles */
  devices: Device[]
  /** Volumen actual (0-100) */
  currentVolume: number
  /** Si el audio está silenciado */
  muted?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  muted: false
})
</script>

Eventos Bien Definidos

Mal ejemplo:

1
2
3
4
5
6
7
8
<script setup>
// ❌ Eventos sin tipo
const emit = defineEmits(['update', 'change', 'click'])

function handleClick() {
  emit('update', someData) // ¿Qué formato tiene someData?
}
</script>

Buen ejemplo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<script setup lang="ts">
interface Emits {
  /** Se emite cuando el volumen cambia */
  (e: 'volume-changed', volume: number): void
  /** Se emite cuando se cambia de dispositivo */
  (e: 'device-selected', deviceId: string): void
  /** Se emite cuando se alterna mute */
  (e: 'mute-toggled', muted: boolean): void
}

const emit = defineEmits<Emits>()

function handleVolumeChange(newVolume: number) {
  emit('volume-changed', newVolume)
}
</script>

Performance y Optimización

Lazy Loading de Componentes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'

// ✅ Cargar componentes pesados solo cuando se necesitan
const SettingsDialog = defineAsyncComponent(
  () => import('./components/SettingsDialog.vue')
)

const FileManager = defineAsyncComponent(
  () => import('./components/FileManager.vue')
)
</script>

<template>
  <SettingsDialog v-if="showSettings" />
  <FileManager v-if="showFileManager" />
</template>

Virtual Scrolling para Listas Grandes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<script setup lang="ts">
import { useVirtualList } from '@vueuse/core'

const allApps = ref<App[]>([]) // 1000+ aplicaciones

// ✅ Renderizar solo los items visibles
const { list, containerProps, wrapperProps } = useVirtualList(
  allApps,
  {
    itemHeight: 50,
    overscan: 5
  }
)
</script>

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

Debounce en Búsquedas

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import { invoke } from '@tauri-apps/api/tauri'

const searchQuery = ref('')
const results = ref<SearchResult[]>([])

// ✅ Debounce para evitar llamadas excesivas al backend
const debouncedSearch = useDebounceFn(async (query: string) => {
  if (!query.trim()) {
    results.value = []
    return
  }
  
  try {
    results.value = await invoke<SearchResult[]>('global_search', { 
      query 
    })
  } catch (error) {
    console.error('Search failed:', error)
  }
}, 300)

watch(searchQuery, (newQuery) => {
  debouncedSearch(newQuery)
})
</script>

Memoización de Computadas Costosas

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script setup lang="ts">
import { computed } from 'vue'

const apps = ref<App[]>([])
const searchQuery = ref('')
const selectedCategory = ref('all')

// ✅ Computada memoizada - solo se recalcula cuando cambian las dependencias
const filteredApps = computed(() => {
  return apps.value.filter(app => {
    const matchesSearch = app.name
      .toLowerCase()
      .includes(searchQuery.value.toLowerCase())
    
    const matchesCategory = selectedCategory.value === 'all' || 
                           app.category === selectedCategory.value
    
    return matchesSearch && matchesCategory
  })
})

// ❌ Evitar recalcular en cada render
// const getFilteredApps = () => apps.value.filter(...)
</script>

v-show vs v-if

1
2
3
4
5
6
7
<template>
  <!--  v-show para componentes que se alternan frecuentemente -->
  <AudioApplet v-show="showAudioApplet" />
  
  <!--  v-if para componentes que raramente se muestran -->
  <SettingsDialog v-if="showSettings" />
</template>

Gestión de Estado

Usar Pinia para Estado Global

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// stores/audio.ts
import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/tauri'
import { listen } from '@tauri-apps/api/event'

export const useAudioStore = defineStore('audio', () => {
  const volume = ref(50)
  const muted = ref(false)
  const devices = ref<AudioDevice[]>([])
  const selectedDevice = ref<string | null>(null)

  // ✅ Acciones claramente definidas
  async function setVolume(newVolume: number) {
    try {
      await invoke('set_audio_volume', { volume: newVolume })
      volume.value = newVolume
    } catch (error) {
      console.error('Failed to set volume:', error)
      throw error
    }
  }

  async function toggleMute() {
    try {
      await invoke('toggle_audio_mute')
      muted.value = !muted.value
    } catch (error) {
      console.error('Failed to toggle mute:', error)
      throw error
    }
  }

  // ✅ Escuchar eventos del backend
  function initializeListeners() {
    listen<number>('audio_volume_changed', (event) => {
      volume.value = event.payload
    })

    listen<boolean>('audio_mute_changed', (event) => {
      muted.value = event.payload
    })
  }

  return {
    volume,
    muted,
    devices,
    selectedDevice,
    setVolume,
    toggleMute,
    initializeListeners
  }
})

Composables para Lógica Reutilizable

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// composables/useBackendCommand.ts
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/tauri'

export function useBackendCommand<T, P = void>(
  command: string
) {
  const loading = ref(false)
  const error = ref<string | null>(null)
  const data = ref<T | null>(null)

  async function execute(params?: P): Promise<T | null> {
    loading.value = true
    error.value = null

    try {
      const result = await invoke<T>(command, params)
      data.value = result
      return result
    } catch (err) {
      error.value = err as string
      console.error(`Command ${command} failed:`, err)
      return null
    } finally {
      loading.value = false
    }
  }

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

// Uso en componente
const { loading, error, data, execute } = useBackendCommand<SystemInfo>(
  'get_system_info'
)

onMounted(() => {
  execute()
})

Comunicación con Backend

Manejo Robusto de Comandos Tauri

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<script setup lang="ts">
import { invoke } from '@tauri-apps/api/tauri'

const brightness = ref(50)
const isUpdating = ref(false)
const updateError = ref<string | null>(null)

// ✅ Manejo completo de async con loading y errores
async function updateBrightness(newValue: number) {
  isUpdating.value = true
  updateError.value = null

  try {
    await invoke('set_brightness_info', { brightness: newValue })
    brightness.value = newValue
  } catch (error) {
    updateError.value = 'Failed to update brightness'
    console.error('Brightness update failed:', error)
    
    // Revertir al valor anterior
    // brightness permanece sin cambios
  } finally {
    isUpdating.value = false
  }
}

// ❌ Evitar esto
// async function badUpdate(value: number) {
//   await invoke('set_brightness_info', { brightness: value })
//   brightness.value = value // ¿Y si falla?
// }
</script>

Listeners de Eventos con Cleanup

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script setup lang="ts">
import { listen, UnlistenFn } from '@tauri-apps/api/event'

const notifications = ref<Notification[]>([])
let unlistenNotification: UnlistenFn | null = null

onMounted(async () => {
  // ✅ Guardar función de cleanup
  unlistenNotification = await listen<Notification>(
    'notification_received',
    (event) => {
      notifications.value.push(event.payload)
    }
  )
})

onUnmounted(() => {
  // ✅ Siempre limpiar listeners
  if (unlistenNotification) {
    unlistenNotification()
  }
})
</script>

Timeout para Operaciones Largas

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async function fetchWithTimeout<T>(
  command: string,
  params: any,
  timeoutMs = 5000
): Promise<T> {
  return Promise.race([
    invoke<T>(command, params),
    new Promise<T>((_, reject) =>
      setTimeout(() => reject(new Error('Operation timeout')), timeoutMs)
    )
  ])
}

// Uso
try {
  const devices = await fetchWithTimeout<Device[]>(
    'scan_bluetooth_devices',
    {},
    10000 // 10 segundos
  )
} catch (error) {
  console.error('Scan timeout or failed:', error)
}

Manejo de Errores

Error Boundaries y Feedback

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<script setup lang="ts">
import { ref } from 'vue'

const errorMessage = ref<string | null>(null)
const showError = ref(false)

function handleError(error: unknown, context: string) {
  const message = error instanceof Error 
    ? error.message 
    : String(error)
  
  console.error(`Error in ${context}:`, error)
  
  errorMessage.value = message
  showError.value = true
  
  // Auto-ocultar después de 5 segundos
  setTimeout(() => {
    showError.value = false
  }, 5000)
}

async function loadData() {
  try {
    await invoke('load_data')
  } catch (error) {
    handleError(error, 'loadData')
  }
}
</script>

<template>
  <div class="error-toast" v-if="showError">
    {{ errorMessage }}
  </div>
</template>

Memory Management

Cleanup Completo

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<script setup lang="ts">
import { onUnmounted } from 'vue'

const intervalId = ref<number | null>(null)
const observers = ref<ResizeObserver[]>([])
const unlisteners = ref<UnlistenFn[]>([])

onMounted(() => {
  // Interval para actualizar datos
  intervalId.value = setInterval(updateSystemInfo, 2000)
  
  // Observer para resize
  const observer = new ResizeObserver(handleResize)
  observer.observe(element.value!)
  observers.value.push(observer)
  
  // Event listeners
  setupEventListeners()
})

onUnmounted(() => {
  // ✅ Limpiar interval
  if (intervalId.value) {
    clearInterval(intervalId.value)
  }
  
  // ✅ Limpiar observers
  observers.value.forEach(obs => obs.disconnect())
  observers.value = []
  
  // ✅ Limpiar event listeners
  unlisteners.value.forEach(unlisten => unlisten())
  unlisteners.value = []
})
</script>

Prevenir Memory Leaks en Watchers

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<script setup lang="ts">
import { watch, WatchStopHandle } from 'vue'

const stopWatchers: WatchStopHandle[] = []

onMounted(() => {
  // ✅ Guardar la función stop
  const stopVolumeWatch = watch(volume, async (newVal) => {
    await invoke('set_audio_volume', { volume: newVal })
  })
  
  stopWatchers.push(stopVolumeWatch)
})

onUnmounted(() => {
  // ✅ Detener todos los watchers
  stopWatchers.forEach(stop => stop())
})
</script>

Accesibilidad

ARIA Labels y Keyboard Navigation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<template>
  <div class="volume-control">
    <label for="volume-slider" class="sr-only">
      Volume Control
    </label>
    
    <input
      id="volume-slider"
      type="range"
      min="0"
      max="100"
      :value="volume"
      @input="handleVolumeChange"
      aria-label="Volume level"
      aria-valuemin="0"
      aria-valuemax="100"
      :aria-valuenow="volume"
      :aria-valuetext="`${volume}%`"
    />
    
    <button
      @click="toggleMute"
      :aria-label="muted ? 'Unmute' : 'Mute'"
      :aria-pressed="muted"
    >
      <Icon :name="muted ? 'volume-mute' : 'volume'" />
    </button>
  </div>
</template>

<style scoped>
/* ✅ Screen reader only text */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}
</style>

Focus Management

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<script setup lang="ts">
import { ref, nextTick } from 'vue'

const showDialog = ref(false)
const firstFocusableElement = ref<HTMLElement | null>(null)
const previousActiveElement = ref<HTMLElement | null>(null)

async function openDialog() {
  previousActiveElement.value = document.activeElement as HTMLElement
  showDialog.value = true
  
  await nextTick()
  firstFocusableElement.value?.focus()
}

function closeDialog() {
  showDialog.value = false
  previousActiveElement.value?.focus()
}

// ✅ Trap focus dentro del diálogo
function handleKeydown(event: KeyboardEvent) {
  if (event.key === 'Escape') {
    closeDialog()
  }
}
</script>

Testing

Test Unitarios con Vitest

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// AudioControl.test.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import AudioControl from './AudioControl.vue'

// Mock Tauri
vi.mock('@tauri-apps/api/tauri', () => ({
  invoke: vi.fn()
}))

describe('AudioControl', () => {
  it('renders volume slider', () => {
    const wrapper = mount(AudioControl, {
      props: {
        currentVolume: 50
      }
    })
    
    expect(wrapper.find('input[type="range"]').exists()).toBe(true)
  })
  
  it('emits volume-changed event', async () => {
    const wrapper = mount(AudioControl)
    const slider = wrapper.find('input[type="range"]')
    
    await slider.setValue(75)
    
    expect(wrapper.emitted('volume-changed')).toBeTruthy()
    expect(wrapper.emitted('volume-changed')?.[0]).toEqual([75])
  })
})

Mejores Prácticas - Resumen

graph LR Practicas["✅ Mejores Prácticas"] Evitar["❌ Evitar"] Practicas --> P1["✓ Componentes pequeños y enfocados"] Practicas --> P2["✓ Props tipadas y documentadas"] Practicas --> P3["✓ Manejo explícito de errores"] Practicas --> P4["✓ Cleanup en onUnmounted"] Practicas --> P5["✓ Composables para lógica compartida"] Practicas --> P6["✓ Eventos bien definidos"] Practicas --> P7["✓ Lazy loading de componentes"] Practicas --> P8["✓ Virtual scrolling para listas"] Practicas --> P9["✓ Debounce en búsquedas"] Practicas --> P10["✓ Timeout en operaciones largas"] Evitar --> E1["✗ Componentes monolíticos"] Evitar --> E2["✗ Props sin tipos"] Evitar --> E3["✗ Ignorar errores async"] Evitar --> E4["✗ Memory leaks por listeners"] Evitar --> E5["✗ Código hardcoded"] Evitar --> E6["✗ Efectos secundarios en render"] Evitar --> E7["✗ Renderizar 1000+ items sin virtual scroll"] Evitar --> E8["✗ Llamadas sin debounce"] Evitar --> E9["✗ Operaciones sin timeout"] Evitar --> E10["✗ Olvidar cleanup de watchers"] style Practicas fill:#43e97b,stroke:#38f9d7,color:#fff style Evitar fill:#f093fb,stroke:#f5576c,color:#fff style P1 fill:#4facfe,stroke:#00f2fe,color:#fff style P2 fill:#4facfe,stroke:#00f2fe,color:#fff style P3 fill:#4facfe,stroke:#00f2fe,color:#fff style P4 fill:#4facfe,stroke:#00f2fe,color:#fff style P5 fill:#4facfe,stroke:#00f2fe,color:#fff style P6 fill:#4facfe,stroke:#00f2fe,color:#fff style P7 fill:#4facfe,stroke:#00f2fe,color:#fff style P8 fill:#4facfe,stroke:#00f2fe,color:#fff style P9 fill:#4facfe,stroke:#00f2fe,color:#fff style P10 fill:#4facfe,stroke:#00f2fe,color:#fff style E1 fill:#fa709a,stroke:#f5576c,color:#fff style E2 fill:#fa709a,stroke:#f5576c,color:#fff style E3 fill:#fa709a,stroke:#f5576c,color:#fff style E4 fill:#fa709a,stroke:#f5576c,color:#fff style E5 fill:#fa709a,stroke:#f5576c,color:#fff style E6 fill:#fa709a,stroke:#f5576c,color:#fff style E7 fill:#fa709a,stroke:#f5576c,color:#fff style E8 fill:#fa709a,stroke:#f5576c,color:#fff style E9 fill:#fa709a,stroke:#f5576c,color:#fff style E10 fill:#fa709a,stroke:#f5576c,color:#fff

Checklist de Componente

  • Crear Componente Vue 🚀
  • Nombre claro y descriptivo 📝
  • Props documentadas con tipos 📋
  • Eventos bien definidos 📡
  • Manejo de errores robusto ⚠️
  • Cleanup en onUnmounted 🧹
  • Estilos scoped 🎨
  • Tests unitarios 🧪
  • Accesibilidad (ARIA) ♿
  • Optimizado (lazy load, virtual scroll) ⚡
  • Listo para producción ✅

Performance Checklist Específico para Desktop

  • Virtual scrolling implementado en listas grandes (>100 items)
  • Debounce en búsquedas y inputs de alta frecuencia
  • Lazy loading para componentes pesados (Settings, File Manager)
  • Memoización de computadas costosas
  • Timeout en todas las llamadas al backend (5-10s)
  • Loading states visibles para operaciones async
  • Error recovery con reintentos automáticos
  • Cleanup de todos los listeners y watchers
  • v-show para componentes que se alternan frecuentemente
  • v-if para componentes que raramente se muestran

Recursos Adicionales

Recuerda

Vasak group © Todos los derechos reservados