Implementación de una barra de navegación inferior con componentes personalizados en Vue.js

Fundamentos de los Componentes y Slots en Vue.js

Los componentes son una de las funcionalidades más robsutas de Vue.js, ya que permiten extender los elementos HTML y encapsular código reutilizable. En esencia, un componente es un elemento personalizado al que el compilador de Vue añade comportamientos específicos. En ciertos escenarios, también pueden extender elementos HTML nativos utilizando la directiva is.

La comunicación entre componentes sigue un flujo unidireccional claro: los datos descienden (props down) y los eventos ascienden (events up). El componente padre envía información a sus hijos mediante props, mientras que los hijos notifican cambios al padre emitiendo eventos personalizados.

Distribución de contenido mediante Slots

Los slots (ranuras) permiten inyectar contenido desde el componente padre hacia la plantilla del componente hijo. Existen dos tipos principales:

  • Slots con nombre (Named slots): El contenido del padre que especifique un atributo slot con un nombre específico reemplazará al <slot name="..."> correspondiente en el hijo.
  • Slot por defecto (Default/Anonymous slot): Cualquier contenido del padre que no tenga un atributo slot asignado reemplazará al <slot> aónimo del hijo.

A continuación, se ilustra cómo el padre define el contenido para las ranuras:

<nav-item tab-key="home">                
    <img src="assets/icons/home-default.svg" alt="" slot="icon-default"> 
    <img src="assets/icons/home-selected.svg" alt="" slot="icon-selected"> 
    Inicio
</nav-item>

Y así es como el componente hijo estructura su plantilla para recibir ese contenido:

<a class="nav-item" :class="{ 'active': isActive }" @click="handleClick">
    <span class="icon-wrapper" v-show="!isActive"><slot name="icon-default"></slot></span>
    <span class="icon-wrapper" v-show="isActive"><slot name="icon-selected"></slot></span>
    <span class="label"><slot></slot></span>
</a>

El HTML resultante tras la compilación de Vue integrará las imágenes y el texto en sus respectivas ubicaciones:

<a class="nav-item">
    <span class="icon-wrapper">
        <img src="assets/icons/home-default.svg" alt="">
    </span>
    <span class="icon-wrapper" style="display: none;">
        <img src="assets/icons/home-selected.svg" alt="">
    </span>
    <span class="label">Inicio</span>
</a>

Implementación Práctica: Barra de Navegación Inferior

Para demostrar estos conceptos, construiremos una barra de navegación inferior (comúnmente llamada tabbar). En lugar de utilizar patrones obsoletos como acceder directamente al padre ($parent), implementaremos una arquitectura limpia basada en la propagación estándar de eventos.


<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Navegación Inferior Vue</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
    <style>
        .bottom-nav {
            display: flex;
            position: fixed;
            bottom: 0;
            left: 0;
            right: 0;
            height: 56px;
            background: #ffffff;
            border-top: 1px solid #e0e0e0;
        }
        .nav-item {
            flex: 1;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            text-decoration: none;
            color: #757575;
            transition: color 0.2s;
        }
        .nav-item.active {
            color: #1976d2;
        }
        .icon-wrapper img {
            width: 24px;
            height: 24px;
        }
        .label {
            font-size: 12px;
            margin-top: 4px;
        }
    </style>
</head>
<body>
    <div id="app">
        <bottom-nav v-model="activeTab">
            <nav-item tab-key="home">
                <img src="assets/icons/home-default.svg" slot="icon-default">
                <img src="assets/icons/home-selected.svg" slot="icon-selected">
                Inicio
            </nav-item>
            <nav-item tab-key="search">
                <img src="assets/icons/search-default.svg" slot="icon-default">
                <img src="assets/icons/search-selected.svg" slot="icon-selected">
                Buscar
            </nav-item>
            <nav-item tab-key="profile">
                <img src="assets/icons/profile-default.svg" slot="icon-default">
                <img src="assets/icons/profile-selected.svg" slot="icon-selected">
                Perfil
            </nav-item>
        </bottom-nav>
    </div>

    <script>
        Vue.component('bottom-nav', {
            props: ['value'],
            template: '<nav class="bottom-nav" @update-active="val => $emit(\'input\', val)"><slot></slot></nav>'
        });

        Vue.component('nav-item', {
            props: ['tabKey', 'currentValue'],
            computed: {
                isActive() {
                    return this.tabKey === this.currentValue;
                }
            },
            methods: {
                handleClick() {
                    this.$emit('update-active', this.tabKey);
                }
            },
            template: `
                <a class="nav-item" :class="{ active: isActive }" @click="handleClick">
                    <span class="icon-wrapper" v-show="!isActive"><slot name="icon-default"></slot></span>
                    <span class="icon-wrapper" v-show="isActive"><slot name="icon-selected"></slot></span>
                    <span class="label"><slot></slot></span>
                </a>
            `
        });

        new Vue({
            el: '#app',
            data: {
                activeTab: 'home'
            }
        });
    </script>
</body>
</html>

Entendiendo v-model en Componentes Personalizados

En el ejemplo anterior, el componente bottom-nav utiliza v-model="activeTab". En Vue 2, esta directiva es azúcar sintáctico. La siguiente línea:

<bottom-nav v-model="activeTab">

Es exactamente equivalente a:

<bottom-nav :value="activeTab" @input="activeTab = $event">

Para que v-model funcione correctamente en un componente personalizado, este debe cumplir dos requisitos fundamentales:

  1. Aceptar una prop llamada value.
  2. Emitir un evento llamado input con el nuevo valor cuando sea necesario actualizarlo.

En nuestra implementación, nav-item emite update-active, el cual es capturado por bottom-nav, y este último se encarga de emitir el evento input requerido para cerrar el ciclo de v-model.

Consideraciones sobre la Nomenclatura de Eventos

Al diseñar eventos personalizados, es crucial evitar nombres que colisionen con eventos nativos del DOM. Si intentamos nombrar un evento personalizado como select o index, es probable que el componente padre no logre escucharlo correctamente.

La razón técnica radica en que select es un evento nativo del DOM (se dispara, por ejemplo, cuando un usuario selecciona texto dentro de un campo <input>). Cuando Vue procesa los eventos, los nombres que coinciden con eventos nativos pueden ser interceptados por el sistema de eventos del navegador o entrar en conflicto con los modificadores internos de Vue.

Como regla de oro en el desarrollo frontend con Vue:

  • Utiliza siempre kebab-case para los nombres de eventos personalizados (ej. update-active, item-clicked).
  • Nunca utilices nombres de eventos nativos del DOM (como click, select, focus, change) para eventos personalizados, a menos que estés creando un componente de formulario que requiera imitar el comportamiento nativo específico.

Etiquetas: vuejs componentes-vue slots-vue v-model arquitectura-frontend

Publicado el 9-5 01:12