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:
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.
Los parámetros se pasan como variables definidas en la etiqueta del componente hijo:
<ul>
<todo-item title="Aprender Vue" done="false" ></todo-item>
</ul>
En Options API debemos declarar en el componente hijo los parámetros que vamos a recibir en la opción props:
export default {
props: ['title', 'done'],
};
Una vez declarados podemos acceder a ellos en el template como title y done y en el script this.title y this.done.
En Composition API se hace de forma similar pero usando defineProps:
<script setup>
defineProps(['title', 'done']);
</script>
Y en el template se accede igual que en Options API como title y done. Si necesitamos acceder a ellos en el script los asignamos a una variable:
<script setup>
const props = defineProps(['title', 'done']);
</script>
y en el script se accede como props.title y props.done.
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 title="Aprender Vue" :done="false" ></todo-item>
</ul>
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:
type: [Boolean, Number]Ejemplos:
defineProps({
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
}
}
});
Si queremos pasar varios parámetros a un componente hijo podemos pasarle cada uno como en el ejemplo anterior:
<ul>
<todo-item title="Aprender Vue" :done="false" ></todo-item>
</ul>
o bien un único 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 setup>
...
const propsObject = ref({
title: 'Aprender Vue',
done: false
})
...
</script>
y en el componente se reciben sus parámetros separadamente:
// todo-item.vue
...
defineProps({
title: String,
done: Boolean
})
...
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 |
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:
const props = defineProps(['initialValue']);
const myValue = ref(props.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:
const props = defineProps(['cadenaSinFormato']);
const cadenaFormateada = computed(() => {
return props.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 sí se cambiará en el padre, cosa que debemos evitar.
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 setup>
...
const showAttributes = () => {
console.log('Id: ' + $attrs.id + ', Data: ' + $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.
También podemos hacer que esos atributos no 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.
Y si el componente hijo tiene varios elementos raíz también deberemos bindear los attrs a uno de ellos como acabamos de ver.
En ocasiones necesitamos pasar datos desde un componente padre a un componente que no es su hijo directo sino descendiente del mismo. En estos casos podemos usar provide/inject.
Podemos ampliar la información en la documentación oficial de Vue.
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. El componente hijo puede emitir un evento desde el su template o desde su script. Para emitir el evento desde el template el hijo hace:
<button @click="$emit('nombreEvento', parametro)">Haz algo</button>
Para hacerlo desde el script, en sintaxis Options API:
this.$emit('nombreEvento', parametro)
y en sintaxis Composition API:
const emit = defineEmits(['nombreEvento'])
emit('nombreEvento', parametro)
El padre debe capturar el evento como cualquier otro. En su HTML hará:
<my-component @nombre-evento="fnManejadora" ... />
y en su script tendrá la función para manejar ese evento. En Options API:
methods: {
fnManejadora(param) {
...
},
}
...
o en Composition API:
const 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. En Composition API se hace como hemos visto con defineEmits:
<script setup>
defineEmits(['nombreEvento']);
</script>
En Options API 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() {
emit('delItem')
},
y en el componente todo-list lo escuchamos y llamamos al método que borre el item:
<script setup>
const todos = ref([...]) // array de tareas
const delTodo = (index) => {
todos.value.splice(index, 1)
}
</script>
<template>
<div>
<ul>
<todo-item
v-for="(item, index) in todos"
:key="item.id"
:todo="item"
@del-item="delTodo(index)">
</todo-item>
</ul>
</div>
</template>
| Haz el ejercicio del tutorial de Vue.js |
Como hemos dicho, los eventos que emite un componente pueden (y se recomienda) definirse con defineEmits. Y al igual que con las props es posible definirlos usando sintaxis de objeto para validar los parámetros que se emiten. 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:
<script setup>
const emits = defineEmits({
// No validation
click: null,
// Validate submit event
submit: ({ email, password }) => {
if (email && password) {
return true
} else {
console.warn('Invalid submit event payload!')
return false
}
}
});
const submitForm = (email, password) => {
emit('submit', { email, password })
}
</script>
En este ejemplo el componente emite click que no se valida y submit donde se valida que reciba 2 parámetros.
En ocasiones 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.
Por ejemplo, en nuestra aplicación de tareas, si en vez de usar el botón de ‘Borrar’ para eliminar una tarea queremos que se borre cuando hacemos doble click sobre ella, en lugar de capturar el evento dblclick en el componente todo-item y emitir un evento al padre para que lo borre, podemos hacer que el padre capture directamente el evento dblclick sobre el hijo y llame a su método para borrar la tarea:
Componente todo-list.vue
<template>
...
<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.
Una forma sencilla de acceder a los mismos datos desde distintos componentes que no son padre-hijo es compartiendolos datos entre ellos. Para ello creamos en un fichero .js (no .vue) un objeto que contendrá todos los datos a compartir entre componentes y en cada componente que queramos usarlo lo importamos y lo registramos. Ejemplo:
Fichero /src/store/index.js
import { reactive } from 'vue'
export const store = reactive({
message: '',
myData: [],
...
})
NOTA: Recordad que 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 array, 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.
En cada componente que necesite acceder a datos del store lo importamos y definimos dentro de computed las variables a las que queramos acceder. No lo hacemos en data porque allí declaro las variables locales del componente y estas está en el store.:
<script setup>
import { store } from '../store/'
const message = computed(() => store.message)
</script>
<template>
<p>Mensaje: { { message }} </p>
</template>
Y en cualquier método del componente puedo cambiar el valor de las variables del store directamente:
const updateMessage = (newValue) => {
store.message = newValue
}
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 usaremos un patrón de programación llamado Store pattern que veremos en el siguiente apartado.
Es una mejora sobre lo que hemos visto de compartir datos. Para evitar que todos los componentes puedan 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 = ''
}
}
Para acceder a los datos del store desde un componente accedemos a las variables dentro de store.state:
import { store } from '../store/'
import { computed } from 'vue'
const message = computed(() => store.state.message)
...
Fijaos que lo declaramos como computed porque es una variable calculada: una variable que está en otro sitio.
Y en sintaxis de Options API:
import { store } from '../store/'
export default {
computed: {
message() {
return store.state.message,
}
},
...
}
Y para modificar los datos del store desde un componente usaremos los métodos definidos en el propio store:
import { store } from '../store/'
...
const delMessage = () => {
store.clearMessageAction()
}
...
Y en sintaxis de Options API:
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.
Vamos a hacer que funcione la aplicación que tenemos hecha con SFC y Store pattern. Para ello vamos a crear un store que contendrá el array de tareas y los métodos para añadir, borrar y cambiar el estado de las tareas, así como para borrarlas todas.
Con lo que sabíamos hasta ahora podríamos hacer funcionar la aplicación declarando el array de tareas en el App.vue y pasando los datos entre componentes mediante props y eventos, pero sería un engorro y el código sería difícil de mantener.
Usando un store pattern centralizamos los datos de aplicación y las acciones que los modifican en un único sitio:
// /src/store/index.js
import { reactive } from "vue";
export const store = {
debug: true,
state: reactive({
todos: [
{ id: 1, title: "Learn JavaScript", done: false },
{ id: 2, title: "Learn Vue", done: false },
{ id: 3, title: "Play around in JSFiddle", done: true },
{ id: 4, title: "Build something awesome", done: true },
],
}),
addTodoAction(newTodo) {
if (this.debug) console.log("addTodoAction triggered with ", newTodo);
this.state.todos.push(newTodo);
},
removeTodoAction(todoToRemove) {
if (this.debug)
console.log("removeTodoAction triggered with ", todoToRemove);
this.state.todos = this.state.todos.filter((todo) => todo !== todoToRemove);
},
clearTodosAction() {
if (this.debug) console.log("clearTodosAction triggered");
this.state.todos = [];
},
};
En el componente todo_list debemos incluir el array todos lo que haremos en su computed. El resto de componentes no necesitan acceder al array, pero sí llamarán a los métodos para cambiarlo:
// /src/components/TodoList.vue
<script setup>
import { computed, ref } from "vue";
import { store } from "../store/index.js";
import TodoItem from "./TodoItem.vue";
const todos = computed(() => store.state.todos);
</script>
<template>
<ul v-if="todos.length">
<todo-item v-for="todo in todos" :key="todo.id" :item="todo" />
</ul>
<p v-else>No hay tareas que mostrar</p>
</template>
Respecto al todo-item debe cambiar los datos tanto para borrar una tarea como para marcarla como ‘Hecha’/’No hecha’ (se cambia el estado de la tarea).
Los componentes add-todo y del-all no necesitan las tareas, sólo tienen que acceder a los métodos del store para cambiarlas:
// /src/components/DelAll.vue
<script setup>
import { store } from '../store';
const delTodos = () => {
store.clearTodosAction();
};
</script>
<template>
<button @click="delTodos">Borrar toda la lista</button>
</template>
Tenéis el código en el repositorio.
Podemos ver la aplicación con sintaxis Composition API funcionando en la Vue Playground o con sintaxis Options API en el siguiente codesandbox:
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.
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 |
Un ejemplo más util de slot es el siguiente: queremos hacer un componente que renderice una fila de una tabla donde mostrar los datos de un usuario. Tendremos una última columna donde poner unos botones para realizar acciones sobre ese usuario pero esos botones variarán en función de la página donde se muestre la tabla. Para ello usaremos slots:
<template>
<tr>
<td>{ { user.name }}</td>
<td>{ { user.email }}</td>
<td>{ { user.age }}</td>
<td>
<slot></slot>
</td>
</tr>
</template>
Donde queremos mostrar un usuario con botones para editar y borrar haremos:
<user-row :user="user">
<button @click="editUser">Editar</button>
<button @click="deleteUser">Borrar</button>
</user-row>
y donde queremos mostrarlo sólo con un botón para ver más detalles haremos:
<user-row :user="user">
<button @click="showDetails">Detalles</button>
</user-row>
A veces nos interesa tener más de un 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 v-slot:header>
<h1>Here might be a page title</h1>
</template>
<template v-slot:default>
<p>A paragraph for the main content.</p>
<p>And another one.</p>
</template>
<template v-slot:footer>
<p>Here's some contact info</p>
</template>
</base-layout>
La directiva v-slot tiene una abreviatura que es # de forma que podríamos haber puesto <template #header>.
Podría no ponerse el template #default y funcionaría igual: lo que está dentro de un template con v-slot irá al slot del componente con ese nombre. El resto del innerHTML irá al slot por defecto (el que no tiene nombre).
La directiva v-slot podemos ponérsela a cualquier etiqueta (no tiene que ser <template>):
<base-layout>
<h1 v-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>
El componente hijo puede hacer accesibles sus variables al padre declarándolas en su etiqueta <slot>:
<!-- ChildComponent -->
<div>
<slot :text="greetingMessage" :count="1"></slot>
</div>
<!-- ParentComponent -->
<child-component v-slot={ text, count }>
{ { text }}: { { count }}
</child-component>
Esto es particularmente útil en componentes hijos que muestran un array de datos (con un v-for) si queremos acceder con el padre a cada dato.
Podéis profundizar en el uso de slots en la documentación oficial de Vue.