materials

Comunicación entre componentes

Introducción

Cada componente tiene sus propios datos que son datos de nivel de componente, pero hay ocasiones en que varios componentes necesitan acceder a los mismos datos. Es lo que nos sucede en nuestra aplicación de ejemplo donde varios componentes necesitan acceder a la lista de tareas (variable todos) para mostrarla (todo-list), añadir items (todo-add) o borrarla (todo-del-all).

Estos datos se consideran datos de nivel de aplicación y hay varias formas de tratarlos.

Ya hemos visto que podemos pasar información a un componente hijo mediante props. Esto permite la comunicación de padres a hijos, pero queda por resolver cómo comunicarse los hijos con sus padres para informarles de cambios o eventos producidos y cómo comunicarse otros componentes entre sí.

Nos podemos encontrar las siguientes situaciones:

Props (de padre a hijo)

Ya hemos visto que podemos pasar parámetros del padre al componente hijo. Si el valor del parámetro cambia en el padre automáticamente se reflejan esos cambios en el hijo.

NOTA: Cualquier parámetro que pasemos sin v-bind se considera texto. Si queremos pasar un número, booleano, array u objeto hemos de pasarlo con v-bind igual que hacemos con las variables para que no se considere texto:

<ul>
  <todo-item todo="Aprender Vue" :done="false" ></todo-item>
</ul>

Si queremos pasar varios parámetros a un componente hijo podemos pasarle un objeto en un atributo v-bind sin nombre y lo que recibirá el componente hijo son sus propiedades:

<template>
  <ul>
    <todo-item v-bind="propsObject" ></todo-item>
  </ul>
</template>

<script>
  ...
  data() {
    return {
      propsObject: { 
        todo: 'Aprender Vue', 
        done: false
      }
    }
  }
  ...
</script>

y en el componente se reciben sus parámetros separadamente:

// todo-item.vue
  ...
  props: ['todo', 'done'],
  ...

También es posible que el nombre de un parámetro que queramos pasar sea una variable:

<child-component :[paramName]="valorAPasar" ></child-component>
Haz el ejercicio del tutorial de Vue.js

Nunca cambiar el valor de una prop

Al pasar un parámetro mediante una prop su valor se mantendrá actualizado en el hijo si su valor cambiara en el padre, pero no al revés por lo que no debemos cambiar su valor en el componente hijo (de hecho Vue3 no nos lo permite).

Si tenemos que cambiar su valor porque lo que nos pasan es sólo un valor inicial podemos crear una variable local a la que le asignamos como valor inicial el parámetro pasado:

props: ['initialValue'],
data(): {
  return {
    myValue: this.initialValue
  }
}

Y en el componente usaremos la nueva variable myValue.

Si no necesitamos cambiarla sino sólo darle determinado formato a la variable pasada lo haremos creando una nueva variable (en este caso mejor una computed), que es con la que trabajaremos:

props: ['cadenaSinFormato'],
computed(): {
  cadenaFormateada() {
    return this.cadenaSinFormato.trim().toLowerCase()
  }
}

OJO: Si el parámetro es un objeto o un array éste se pasa por referencia por lo que si lo cambiamos en el componente hijo se cambiará en el padre, cosa que debemos evitar.

Validación de props

Al recibir los parámetros podemos usar sintaxis de objeto en lugar de sintaxis de array y en ese caso podemos indicar algunas cosas como:

Ejemplos:

props: {
  nombre: String,
  apellidos: {
    type: String,
    required: true
  },
  idPropietario: {
    type: [Boolean, Number],
    default: false
  },
  products: {
    type: Object,
    default(): { 
      return {id:0, units: 0} 
    }  // Si es un objeto o array _default_ debe ser una función que devuelva el valor
  },
  nifGestor: {
    type: String,
    required: true,
    validator(value): {
      return /^[0-9]{8}[A-Z]$/.test(value)   // Si devuelve *true* será válido
    }
  }

Pasar otros atributos de padre a hijo

Además de los parámetros, que se reciben en props, el componente padre puede poner cualquier otro atributo en la etiqueta del hijo, quien lo recibirá y se aplicará a su elemento raíz. A esos atributos se puede acceder a través de $attr. Por ejemplo:

<!-- componente padre -->
<date-picker id="now" data-status="activated" class="fecha"></date-picker>
// Componente hijo date-picker.vue
<template>
  <div class="date-picker">
    <input type="datetime" />
  </div>
</template>

<script>
  ...
  methods: {
    showAttributes() {
      console.log('Id: ' + this.$attrs.id + ', Data: ' + this.$attrs['data-status'])
    }
  }
  ...
</script>

El subcomponente se renderizará como:

<div class="fecha date-picker" id="now" data-status="activated">
  <input type="datetime" />
</div>

y al ejecutar el método showAttributes mostrará en la consola Id: now, Data: activated.

A veces no queremos que esos atributos se apliquen al elemento raíz del subcomponente sino a alguno interno (habitual si le pasamos escuchadores de eventos). En ese caso podemos deshabilitar la herencia de parámetros definiendo el atributo del componente inheritAttrs a false y aplicándolos nosotros manualmente:

<!-- componente padre -->
<date-picker id="now" data-status="activated" @input="dataChanged"></date-picker>
// Componente hijo date-picker.vue
<template>
    <div class="date-picker">
      <input type="datetime" v-bind="$attrs" />
    </div>
</template>

<script>
  ...
  inheritAttrs: false,
  ...
</script>

En este caso se renderizará como:

<div class="date-picker">
  <input type="datetime" class="fecha" id="now" data-status="activated" @input="dataChanged" />
</div>

El componente padre está escuchando el evento input sobre el <INPUT> del componente hijo.

En Vue3, si el componente hijo tiene varios elementos raíz deberemos bindear los attrs a uno de ellos como acabamos de ver.

Emitir eventos (de hijo a padre)

Si un componente hijo debe pasarle un dato a su padre o informarle de algo puede emitir un evento que el padre capturará y tratará convenientemente. Para emitir el evento el hijo hace:

  this.$emit('nombreEvento', parametro)

El padre debe capturar el evento como cualquier otro. En su HTML hará:

<my-component @nombre-evento="fnManejadora" ... />

y en su JS tendrá la función para manejar ese evento:

  methods: {
    fnManejadora(param) {
      ...
    },
  }
  ...

El componente hijo puede emitir cualquiera de los eventos estándar de JS (‘click’, ‘change’, …) o un evento personalizado (‘cambiado’, …).

Igual que un componente declara las props que recibe, también puede declarar los eventos que emite. Esto es opcional pero muy recomendable ya que proporciona mayor claridad al código:

// TodoItem.vue
...
props: {
  todo: Object
},
emits: ['nombreEvento'],
...

Ejemplo: continuando con la aplicación de tareas que dividimos en componentes, en el componente todo-item en lugar de hacer un alert emitiremos un evento al padre:

delTodo() {
  this.$emit('delItem')
},

y en el componente todo-list lo escuchamos y llamamos al método que borre el item:

export default {
  template: `
    <div>
      <h2></h2>
      <ul>
       <todo-item 
         v-for="(item, index) in todos" 
         :key="item.id"
         :todo="item"
         @del-item="delTodo(index)">
       </todo-item>
      </ul>
      <add-item></add-item>
      <br>
      <del-all></del-all>
    </div>`,
  methods: {
    delTodo(index){
      this.todos.splice(index,1)
    },
  }
}
Haz el ejercicio del tutorial de Vue.js

Definir y validar eventos

Como hemos dicho, los eventos que emite un componente pueden (y se recomienda) definirse en la opción emits:

// component todo-item.vue
  ...
  emits: ['toogle-done', 'dblclick'],
  props: ['todo'],
  ...

Es recomendable definir los argumentos que emite usando sintaxis de objeto en vez de array, similar a como hacemos con las props. Para ello el evento se asigna a una función que recibe como parámetro los parámetros del evento y devuelve true si es válido o false si no lo es: custom-form.vue

<script>
  emits: {
    // No validation
    click: null,
    // Validate submit event
    submit: ({ email, password }) => {
      if (email && password) {
        return true
      } else {
        console.warn('Invalid submit event payload!')
        return false
      }
    }
  },
</script>

En este ejemplo el componente emite click que no se valida y submit donde se valida que reciba 2 parámetros.

Capturar el evento en el padre

En ocasiones (como en este caso) el componente hijo no hace nada más que informar al padre de que se ha producido un evento sobre él. En estos casos podemos hacer que el evento se capture directamente en el padre en lugar de en el hijo:

Componente todo-list.vue

<template>
    <div>
      <h2></h2>
      <ul>
       <todo-item 
         v-for="(item, index) in todos" 
         :key="item.id"
         :todo="item"
         @dblclick="delTodo(index)">
        </todo-item>
    ...
</template>

Le estamos indicando a Vue que el evento dblclick se capture en todo-list directamente por lo que el componente todo-item no tiene que capturarlo ni hacer nada:

Componente todo-item.vue

<template>
    <li>
      <label>
    ...
</template>

Compartir datos

Una forma más sencilla de modificar datos de un componente desde otros es compartiendo los datos entre ellos. Definimos en un fichero .js aparte un objeto que contendrá todos los datos a compartir entre componentes, lo importamos y lo registramos en el data de cada componente que tenga que acceder a él. Ejemplo:

Fichero /src/store/index.js

import { reactive } from 'vue'

export const store = reactive({
  message: '',
  newData: {},
  ...
})

NOTA: En Vue3 para que la variable store sea reactiva (que la vista reaccione a los cambios que se produzcan en ella) hay que declararla con reactive si es un objeto o con ref si es un tipo primitivo (string, number, …).

Fijaos que se declara el objeto store como una constante porque NO puedo cambiar su valor para que pueda ser usado por todos los componentes, pero sí el de sus propiedades.

Componente compA.vue

import { store } from '../store/'

export default {
  template: `<p>Mensaje: { { message}} </p>`,
  data() {
    return {
      store,  // recordad que equivale a store: store,
      // y a continuación el resto de data del componente
      ...
    }
  },
  ...
}

Componente compB.vue

import { store } from '../store/'

export default {
  template: `<button @click="delMessage">Borrar mensaje`,
  data() {
    return {
      store,
      // y a continuación el resto de data del componente
      ...
    }
  },
  methods: {
    delMessage() {
      this.store.message=''
    }
  },
  ...
}

Desde cualquier componente podemos modificar el contenido de store y esos cambios se reflejarán automáticamente tanto en la vista de todos ellos.

Esta forma de trabajar tiene un grave inconveniente: como el valor de cualquier dato puede ser modificado desde cualquier parte de la aplicación es difícilmente mantenible y se convierte en una pesadilla depurar el código y encontrar errores.

Para evitarlo podemos usar un patrón de almacén (store pattern) que veremos en el siguiente apartado.

$root y $parent

Todos los componentes tienen acceso a:

Por ejemplo:

Vue.createApp({
  data: {
    message: 'Hola',
  },
  methods: {
    getInfo() {
  ...
}).mount('#app')

Desde cualquier componente podemos hacer cosas como:

console.log(this.$root.message)
this.$root.message='Adios'
this.$root.getInfo()

También es posible acceder a los datos y métodos del componente padre del actual usando $parent en lugar de $root.

De esta manera podríamos acceder directamente a datos del padre o usar la instancia de Vue como almacén (evitando crear el objeto store para compartir datos). Sin embargo, aunque esto puede ser útil en aplicaciones pequeñas, es difícil de mantener cuando nuestra aplicación crece por lo que se recomienda usar un Store pattern como veremos a continuación o Pinia si nuestra aplicación va a ser grande.

Store pattern

Es una mejora sobre lo que hemos visto de compartir datos. Para evitar que cualquier componente pueda modificar los datos compartidos en el almacén, las acciones que modifican dichos datos están incluidas dentro del propio almacén, lo que facilita su seguimiento:

Fichero /src/store/index.js

import { reactive } from 'vue'

export const store = {
  debug: true,
  state: reactive({
    message: '',
    ...
  }),
  setMessageAction (newValue) {
    if (this.debug) console.log('setMessageAction triggered with ', newValue)
    this.state.message = newValue
  },
  clearMessageAction () {
    if (this.debug) console.log('clearMessageAction triggered')
    this.state.message = ''
  }
}

Componente compA.vue

import { store } from '../store/'

export default {
  template: `<p>Mensaje: { { message}} </p>`,
  computed: {
    message() {
      return store.state.message,
    }
  },
  ...
}

Fijaos que lo declaramos como computed porque es una varable calculada: una variable que está en otro sitio. También funcionaría si lo declaramos dentro de data pero así es más lógico.

Componente compB.vue

import { store } from '/src/datos.js'
  ...
  methods: {
    delMessage() {
      store.clearMessageAction()
    }
  },
  ...

NOTA: no debemos guardar todos los datos en el store sólo los datos de aplicación (aquellos que utiliza más de un componente). Los datos privados de cada componente seguiremos declarándolos en su data.

Pinia

Hemos visto que con un store pattern se simplifica mucho la gestión de los datos de aplicación y al centralizar los métodos que modifican los datos tengo control sobre los cambios producidos. Sin embargo en un componente puedo seguir escribiendo código que manipule los datos del almacén directamente, sin usar los métodos del almacén. Pinia básicamente es un store pattern donde parte del trabajo de definirlo ya está hecho y que me obliga a usarlo para mainular los datos de aplicación (con él no puedo cambiarlos directamente desde un componente). Además se integra perfectamente con las DevTools por lo que es muy sencillo seguir los cambios producidos.

Se trata de una librería para gestionar los estados en una aplicación Vue. Ofrece un almacenamiento centralizado para todos los componentes con unas reglas para asegurar que un estado sólo cambia de determinada manera. Es el método a utilizar en aplicaciones medias y grandes y le dedicaremos todo un tema más adelante. En Vue2 y anteriores la librería que se usaba es Vuex.

Lo veremos en detalle en la unidad dedicada a esta librería.

Slots

Otra forma en que un componente hijo puede mostrar información del padre es usando slots. Un slot es un hueco en un componente que, al renderizarse, se rellena con lo que le pasa el padre en el innerHTML de la etiqueta del componente. El slot tiene acceso al contexto del componente padre, no al del componente donde se renderiza. Los slots son una herramienta muy potente. Podemos obtener toda la información en la documentación de Vue.

Ejemplo: Tenemos un componente llamado my-component con un slot:

<template>
  <div>
    <h3>Componente con un slot</h3>
    <slot><p>Esto se verá si no se pasa nada al slot</p></slot>
  </div>
</template>

Si llamamos al componente con:

<my-component>
  <p>Texto del slot</p>
</my-component>

se renderizará como:

<div>
  <h3>Componente con un slot</h3>
  <p>Texto del slot</p>
</div>

Pero si lo llamamos con:

<my-component>
</my-component>

se renderizará como:

<div>
  <h3>Componente con un slot</h3>
  <p>Esto se verá si no se pasa nada al slot</p>
</div>
Haz el ejercicio del tutorial de Vue.js

Slots con nombre

A veces nos interesa tener más de 1 slot en un componente. Para saber qué contenido debe ir a cada slot se les da un nombre.

Vamos a ver un ejemplo de un componente llamado base-layout con 3 slots, uno para la cabecera, otro para el pie y otro principal:

<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

A la hora de llamar al componente hacemos:

<base-layout>
  <template slot="header">
    <h1>Here might be a page title</h1>
  </template>

  <p>A paragraph for the main content.</p>
  <p>And another one.</p>

  <template slot="footer">
    <p>Here's some contact info</p>
  </template>
</base-layout>

Lo que está dentro de un template con atributo slot irá al_slot_ del componente con ese nombre. El resto del innerHTML irá al slot por defecto (el que no tiene nombre).

El atributo slot podemos ponérselo a cualquier etiqueta (no tiene que ser <template>):

<base-layout>
  <h1 slot="header">Here might be a page title</h1>

  <p>A paragraph for the main content.</p>
  <p>And another one.</p>

  <p slot="footer">Here's some contact info</p>
</base-layout>

Aplicación de ejemplo

Vamos a hacer que funcione la aplicación que tenemos hecha con SFC.

Solución con Store pattern

Creamos el store para el array de cosas a hacer que debe ser accesible desde varios componentes. En él incluimos métodos para añadir un nuevo todo, para borrar uno, para cambiar el estado de un todo y para borrarlos todos.

En el componente todo_list debemos incluir el array todos lo que haremos en su data. El resto de componentes no necesitan acceder al array, por lo que no lo incluimos en su data, pero sí llamarán a los métodos para cambiarlo.

Respecto al todo-item debe cambiar los datos tanto al hacer doble click (se borra la tarea) como al marcar/desmarcar el checkbox (se cambia el estado de la tarea).