Capítulo 4. Interacciones entre componentes

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

En el Capítulo 3, profundizamos en la composición de un componente con ganchos de ciclo de vida, propiedades computadas, observadores, métodos y otras características. También conocimos la potencia de las ranuras y cómo recibir datos externos de otros componentes mediante props.

Partiendo de esa base, este capítulo te guía sobre cómo construir las interacciones entre componentes utilizando eventos personalizados y patrones proporcionar/inyectar. También presenta la API Teleport, que te permite mover elementos por el árbol DOM manteniendo su orden de aparición dentro de un componente Vue.

Componentes anidados y flujo de datos en Vue

Los componentes Vue pueden anidar otros componentes Vue en su interior. Esta característica es útil para permitir a los usuarios organizar su código en piezas más pequeñas, manejables y reutilizables en un proyecto de interfaz de usuario complejo. Llamamos componentes hijos a los elementos anidados y componente padre al componente que los contiene .

El flujo de datos en una aplicación Vue es unidireccional por defecto, lo que significa que el componente padre puede pasar datos a su componente hijo, pero no al revés. El componente padre puede pasar datos al componente hijo utilizando props (que se explica brevemente en "Exploración de la API de opciones"), y el componente hijo puede emitir eventos de vuelta al componente padre utilizando eventos personalizados emits. La Figura 4-1 muestra el flujo de datos entre componentes .

A diagram shows the one-way data flow between components
Figura 4-1. Flujo de datos unidireccional en componentes Vue

Pasar funciones como apoyo

A diferencia de otros frameworks, Vue no te permite pasar una función como prop al componente hijo. En su lugar, puedes vincular la función como un emisor de eventos personalizado (consulta "Comunicación entre componentes coneventos personalizados") .

Utilizar apoyos para pasar datos a componentes hijos

En forma de objeto o matriz, el campo props de un componente Vue contiene todas las propiedades de datos disponibles que el componente puede recibir de su padre. Cada propiedad de props es una prop del componente de destino. Para empezar a recibir datos del padre, tienes que declarar el campo props en el objeto de opciones del componente, como se muestra en el Ejemplo 4-1.

Ejemplo 4-1. Definir accesorios en un componente
export default {
  name: 'ChildComponent',
  props: {
    name: String
  }
}

En el Ejemplo 4-1, el componente ChildComponent acepta una proposición name de tipo String. El componente padre puede entonces pasar datos al componente hijo utilizando esta proposición name, como se muestra en el Ejemplo 4-2 .

Ejemplo 4-2. Pasar datos estáticos como accesorios a un componente hijo
<template>
  <ChildComponent name="Red Sweater" />
</template>
<script lang="ts">
import ChildComponent from './ChildComponent.vue'
export default {
  name: 'ParentComponent',
  components: {
    ChildComponent
  },
}
</script>

En el ejemplo anterior, ChildComponent recibe un valor estático "Suéter rojo" como name. Si quieres pasar y vincular una variable de datos dinámica a name, como el primer elemento de la lista children, puedes utilizar el atributo v-bind, denotado por :, como se muestra en el Ejemplo 4-3.

Ejemplo 4-3. Pasar variables dinámicas como accesorios a un componente hijo
<template>
  <ChildComponent :name="children[0]" />
</template>
<script lang="ts">
import ChildComponent from './ChildComponent.vue'
export default {
  //...
  data() {
    return {
      children: ['Red Sweater', 'Blue T-Shirt', 'Green Hat']
    }
  }
}
</script>

La salida del código anterior es la misma que pasar una cadena estática, Red Sweater, a la proposición name.

Nota

Si la proposición name no es del tipo String, aún tendrás que utilizar el atributov-bind (o :) para pasar datos estáticos al componente hijo, como :name="true" para Boolean, o :name="["hello", "world"]" para el tipo Array.

En el Ejemplo 4-3, cada vez que cambie el valor de children[0], Vue actualizará también la proposición name en ChildComponent, y el componente hijo volverá a renderizar su contenido si es necesario.

Si tienes más de una prop en el componente hijo, puedes seguir el mismo planteamiento y pasar cada dato a la prop correspondiente. Por ejemplo, para pasar name y price de un producto al componente ProductComp, puedes realizar lo siguiente(Ejemplo 4-4).

Ejemplo 4-4. Pasar varios accesorios a un componente hijo
/** components/ProductList.vue */
<template>
  <ProductComp :name="product.name" :price="product.price" />
</template>
<script lang="ts">
import ProductComp from './ProductComp.vue'
export default {
  name: 'ProductList',
  components: {
    ProductComp
  },
  data() {
    return {
      product: {
        name: 'Red Sweater',
        price: 19.99
      }
    }
  }
}
</script>

Y podemos definir el componente ProductComp como en el Ejemplo 4-5.

Ejemplo 4-5. Definir varios accesorios en ProductComp
<template>
  <div>
    <p>Product: {{ name }}</p>
    <p>Price: {{ price }}</p>
  </div>
</template>
<script lang="ts">
export default {
  name: 'ProductComp',
  props: {
    name: String,
    price: Number
  }
}
</script>

El resultado será el siguiente:

Product: Red Sweater
Price: 19.99

Alternativamente, puedes utilizar v-bind (no :) para pasar el objeto completo user y que sus propiedades se vinculen a los accesorios del componente hijo correspondiente:

<template>
  <ProductComp v-bind="product" />
</template>

Ten en cuenta que sólo el componente hijo recibirá los accesorios declarados correspondientes. Por lo tanto, si tienes otro campo, product.description, en el componente padre, no estará disponible para su acceso en el componente hijo.

Nota

Otra forma de declarar el componente props es utilizar una matriz de cadenas, cada una de las cuales representa el nombre de la propiedad que acepta, como props: ["name", "price"]. Este enfoque es práctico cuando quieres crear un prototipo de un componente rápidamente. Sin embargo, te recomiendo encarecidamente que utilices la forma de objeto de props y que declares todos tus props con tipos, como buena práctica para la legibilidad del código y la prevención de errores.

Hemos aprendido a declarar props con tipos, pero ¿cómo validamos los datos pasados a las props del niño cuando es necesario? ¿Cómo podemos establecer un valor alternativo para una proposición cuando no se pasa ningún valor? Averigüémoslo a continuación.

Declarar tipos prop con validación y valores por defecto

En el Ejemplo 4-1, declaramos la propiedad name como un tipo String. Vue avisará si el componente padre pasa un valor que no sea cadena a la propiedad name durante el tiempo de ejecución. Sin embargo, para poder disfrutar de las ventajas de la validación de tipos de Vue, debemos utilizar la sintaxis de declaración completa :

{
  type: String | Number | Boolean | Array | Object | Date | Function | Symbol,
  default?: any,
  required?: boolean,
  validator?: (value: any) => boolean
}

En el que:

  • type es el tipo de prop. Puede ser una función del constructor (o clase personalizada) o uno de los tipos incorporados.

  • default es el valor por defecto de la proposición si no se pasa ningún valor. Para los tipos Object,Functiony Array, el valor por defecto debe ser una función que devuelva el valor inicial.

  • required es un valor booleano que indica si la prop es obligatoria. Si required es true, el componente padre debe pasar un valor a la utilería. Por defecto, todas las props son opcionales.

  • validator es una función que valida el valor pasado a la prop, principalmente para depuración de desarrollo.

Podemos declarar la prop name para que sea más específica, incluyendo un valor por defecto, como se muestra en el Ejemplo 4-6.

Ejemplo 4-6. Definir prop como una cadena con un valor por defecto
export default {
  name: 'ChildComponent',
  props: {
    name: {
      type: String,
      default: 'Child component'
    }
  }
}

Si el componente padre no pasa un valor, el componente hijo volverá al valor por defecto "Componente hijo" para la proposición name.

También podemos establecer name como prop obligatoria para el componente hijo y añadir un validador para sus datos recibidos, como se muestra en el Ejemplo 4-7.

Ejemplo 4-7. Definir el nombre como obligatorio con un validador prop
export default {
  name: 'ChildComponent',
  props: {
    name: {
      type: String,
      required: true,
      validator: value => value !== "Child component"
    }
  }
}

En este escenario, si el componente padre no pasa un valor a la prop name, o el valor dado coincide con el componente hijo, Vue lanzará una advertencia en modo desarrollo(Figura 4-2) .

Screenshot of console warning for failed name prop validation
Figura 4-2. Advertencia de la consola en desarrollo por validación fallida del puntal
Nota

Para el campo default, el tipo Function es una función que devuelve el valor inicial de la prop. No puedes utilizarla para devolver datos al componente padre ni para provocar cambios de datos en el nivel padre.

Además de los tipos incorporados y la validación que proporciona Vue, puedes combinar un JavaScript Class o un constructor de función y TypeScript para crear tu tipo prop personalizado. Los trataré en la siguiente sección .

Declarar objetos con comprobación de tipos personalizada

Utilizar tipos primitivos como Array, String, o Object es adecuado para el caso de uso esencial. Sin embargo, a medida que crece tu aplicación, los tipos primitivos pueden ser demasiado genéricos para mantener a salvo el tipo de tu componente. Toma un PizzaComponent con el siguiente código de plantilla:

<template>
  <header>Title: {{ pizza.title }}</header>
  <div class="pizza--details-wrapper">
    <img :src="pizza.image" :alt="pizza.title" width="300" />
    <p>Description: {{ pizza.description }}</p>
    <div class="pizza--inventory">
      <div class="pizza--inventory-stock">Quantity: {{pizza.quantity}}</div>
      <div class="pizza--inventory-price">Price: {{pizza.price}}</div>
    </div>
  </div>
</template>

Este componente acepta una prop obligatoria pizza, que es un Object que contiene algunos detalles sobre el pizza:

export default {
  name: 'PizzaComponent',
  props: {
    pizza: {
      type: Object,
      required: true
    }
  }
}

Bastante sencillo. Sin embargo, al declarar pizza como un tipo Object, asumimos que el padre siempre pasará el objeto adecuado con los campos apropiados (title, image, description, quantity, y price) necesarios para que se renderice un pizza.

Esta suposición puede dar lugar a un problema. Puesto que pizza acepta datos del tipo Object, cualquier componente que utilice PizzaComponent puede pasar cualquier dato de objeto a la prop pizza sin los campos reales necesarios para un pizza, como en el Ejemplo 4-8.

Ejemplo 4-8. Utilizar el componente Pizza con datos erróneos
<template>
  <div>
    <h2>Bad usage of Pizza component</h2>
    <pizza-component :pizza="{ name: 'Pinia', description: 'Hawaiian pizza' }" />
  </div>
</template>

El código anterior da como resultado una representación rota de la interfaz de usuario de PizzaComponent, donde sólo está disponible description, y el resto de los campos están vacíos (con una imagen rota), como se muestra en la Figura 4-3.

Screenshot of a pizza without title, price, quantity and image rendered
Figura 4-3. Interfaz de usuario rota sin enlace de imagen y sin campos para una pizza

TypeScript tampoco podrá detectar aquí la discordancia de tipos de datos, ya que realiza la comprobación de tipos según el tipo declarado de pizza: el genérico Object. Otro problema potencial es que pasar pizza en el formato incorrecto de las propiedades del nido puede hacer que la aplicación se bloquee. Por tanto, para evitar estos accidentes, utilizamos declaraciones de tipo personalizadas.

Podemos definir la clase Pizza y declarar la prop pizza de tipo Pizza como se muestra en el Ejemplo 4-9.

Ejemplo 4-9. Declarar un tipo personalizado Pizza
class Pizza {
  title: string;
  description: string;
  image: string;
  quantity: number;
  price: number;

  constructor(
    title: string,
    description: string,
    image: string,
    quantity: number,
    price: number
  ) {
    this.title = title
    this.description = description
    this.image = image
    this.quantity = quantity
    this.price = price
  }
}

export default {
  name: 'PizzaComponent',
  props: {
    pizza: {
      type: Pizza, 1
      required: true
    }
  }
}
1

Declara directamente el tipo de pizza props como Pizza

Alternativamente, puedes utilizar interface o type de TypeScript para definir tu tipo personalizado en lugar de Class. Sin embargo, en tales escenarios, debes utilizar el tipo PropType del paquete vue, con la siguiente sintaxis, para mapear el tipo declarado al prop objetivo :

type: Object as PropType<Your-Custom-Type>

En su lugar, reescribamos la clase Pizza como interface (Ejemplo 4-10).

Ejemplo 4-10. Declarar un tipo personalizado Pizza utilizando la API de interfaz de TypeScript
import type { PropType } from 'vue'

interface Pizza {
  title: string;
  description: string;
  image: string;
  quantity: number;
  price: number;
}

export default {
  name: 'PizzaComponent',
  props: {
    pizza: {
      type: Object as PropType<Pizza>, 1
      required: true
    }
  }
}
1

Declara el tipo de pizza props como Pizza interfaz con PropType ayuda.

Cuando utilices PizzaComponent con un formato de datos incorrecto, TypeScript detectará y resaltará el error adecuadamente.

Nota

Vue realiza la validación de tipos durante el tiempo de ejecución, mientras que TypeScript realiza la comprobación de tipos durante el tiempo de compilación. Por lo tanto, es una buena práctica utilizar tanto la comprobación de tipos de Vue como la de TypeScript para asegurarte de que tu código está libre de errores .

Declarar objetos utilizando defineProps() y withDefaults()

Como aprendimos en "configuración" , a partir de Vue 3.x, Vue ofrece la sintaxis <script setup> para declarar un componente funcional sin la clásica API de Opciones. Dentro de este bloque <script setup>, puedes utilizar defineProps() para declarar props, como se muestra en el Ejemplo 4-11 .

Ejemplo 4-11. Declaración de accesorios con defineProps() y <script setup>
<script setup>
import { defineProps } from 'vue'

const props = defineProps({
  name: {
    type: String,
    default: "Hello from the child component."
  }
})
</script>

Gracias a TypeScript, también podemos declarar el tipo aceptado para defineProps() por componente con validación de tipo en tiempo de compilación, como se muestra en el Ejemplo 4-12.

Ejemplo 4-12. Declaración de accesorios con defineProps() y TypeScript type
<script setup >
import { defineProps } from 'vue'

type ChildProps = {
  name?: string
}

const props = defineProps<ChildProps>()
</script>

En este caso, para declarar el valor por defecto de la proposición message, tenemos que envolver la llamada a defineProps() con withDefaults(), como en el Ejemplo 4-13.

Ejemplo 4-13. Declaración de accesorios con defineProps() y withDefaults()
import { defineProps, withDefaults } from 'vue'

type ChildProps = {
  name?: string
}

const props = withDefaults(defineProps<ChildProps>(), {
  name: 'Hello from the child component.'
})

Uso de defineProps() con TypeScript Type Checking

No podemos combinar la comprobación de tipos en tiempo de ejecución y en tiempo de compilación si utilizamos defineProps(). Recomiendo utilizar defineProps() en el enfoque del Ejemplo 4-11, para una mejor legibilidad y una combinación de la comprobación de tipos de Vue y TypeScript.

Hemos aprendido a declarar props para pasar datos sin procesar en un componente Vue, con comprobación de tipos y validación. A continuación, exploraremos cómo pasar funciones como emisores de eventos personalizados a un componente hijo .

Comunicación entre componentes coneventos personalizados

Vue trata los datos pasados a un componente hijo mediante props como datos de sólo lectura y sin procesar. El flujo de datos unidireccional garantiza que el componente padre es el único que puede actualizar el dato prop. A menudo queremos actualizar un dato prop específico y sincronizarlo con el componente padre. Para ello, utilizamos el campo emits de las opciones del componente para declarar eventos personalizados .

Por ejemplo, una lista de tareas pendientes o el componente ToDoList. Este ToDoList utilizaráToDoItem como componente hijo para mostrar una lista de tareas con el código del Ejemplo 4-14.

Ejemplo 4-14. ComponenteToDoList
<template>
  <ul style="list-style: none;">
    <li v-for="task in tasks" :key="task.id">
      <ToDoItem :task="task" />
    </li>
  </ul>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import ToDoItem from './ToDoItem.vue'
import type { Task } from './ToDoItem'

export default defineComponent({
  name: 'ToDoList',
  components: {
    ToDoItem
  },
  data() {
    return {
      tasks: [
        { id: 1, title: 'Learn Vue', completed: false },
        { id: 2, title: 'Learn TypeScript', completed: false },
        { id: 3, title: 'Learn Vite', completed: false },
      ] as Task[]
    }
  }
})
</script>

Y ToDoItem es un componente que recibe un atributo task y muestra un input como casilla de verificación para que el usuario marque la tarea como completada o no. Este elemento input recibe task.completed como valor inicial del atributo checked. Veamos el Ejemplo 4-15.

Ejemplo 4-15. ComponenteToDoItem
<template>
  <div>
    <input
      type="checkbox"
      :checked="task.completed"
    />
    <span>{{ task.title }}</span>
  </div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'

export interface Task {
  id: number;
  title: string;
  completed: boolean;
}

export default defineComponent({
  name: 'ToDoItem',
  props: {
    task: {
      type: Object as PropType<Task>,
      required: true,
    }
  },
})
</script>

Cuando un usuario active esta casilla input, queremos emitir un evento llamado task-completed-toggle para informar del valor task.completed de la tarea específica al componente padre. Podemos hacerlo declarando primero el evento en el campo emits de las opciones del componente(Ejemplo 4-16).

Ejemplo 4-16. ComponenteToDoItem con emits
/** ToDoItem.vue */
export default defineComponent({
  //...
  emits: ['task-completed-toggle']
})

A continuación, creamos un nuevo método onTaskCompleted para emitir el evento task-completed-toggle con el nuevo valor de task.completed de la casilla de verificación y task.id como carga útil del evento(Ejemplo 4-17).

Ejemplo 4-17. ComponenteToDoItem con un método para emitir el evento task-completed-toggle
/** ToDoItem.vue */
export default defineComponent({
  //...
  methods: {
    onTaskCompleted(event: Event) {
      this.$emit("task-completed-toggle", {
        ...this.task,
        completed: (event.target as HTMLInputElement)?.checked,
      });
    },
  }
})
Nota

Utilizamos defineComponent para envolver las opciones del componente y crear un componente compatible con TypeScript. Utilizar defineComponent no es necesario para los componentes simples, pero debes utilizarlo para acceder a otras propiedades de datos de this dentro de los métodos, ganchos o propiedades computadas de los componentes. De lo contrario, TypeScript lanzará un error .

A continuación, vinculamos el método onTaskCompleted al evento change del elemento input, como se muestra en el Ejemplo 4-18.

Ejemplo 4-18. Plantilla actualizada del componenteToDoItem
<div>
  <input
    type="checkbox"
    :checked="task.completed"
    @change="onTaskCompleted"
  />
  <span>{{ task.title }}</span>
</div>

Ahora, en el componente padre <ToDoList> de ToDoItem, podemos vincular el evento task-completed-toggle a un método utilizando la notación @, con la plantilla del Ejemplo 4-19.

Ejemplo 4-19. Plantilla actualizada del componenteToDoList
<template>
  <ul style="list-style: none;">
    <li v-for="task in tasks" :key="task.id">
      <ToDoItem
        :task="task"
        @task-completed-toggle="onTaskCompleted"
      />
    </li>
  </ul>
</template>

El método onTaskCompleted del componente padre <ToDoList> recibirá la carga útil del evento task-completed-toggle, y actualizará el valor task.completed de la tarea específica en la matriz tasks, como en el Ejemplo 4-20.

Ejemplo 4-20. Script del componenteToDoList con un método para gestionar el evento task-completed-toggle
//...

export default {
  //...
  methods: {
    onTaskCompleted(payload: { id: number; completed: boolean }) {
      const index = this.tasks.findIndex(t => t.id === payload.id)

      if (index < 0) return

      this.tasks[index].completed = payload.completed
    }
  }
}

Estos bloques de código mostrarán la página de la Figura 4-4.

Screenshot of a to-do list with three tasks, each with a checkbox and task's title
Figura 4-4. ComponenteToDoList con tres elementos

Vue actualizará los datos relacionados en ToDoList y, en consecuencia, mostrará la instancia del componente ToDoItem correspondiente. Puedes activar la casilla para marcar una tarea como completada. La Figura 4-5 muestra que podemos detectar el evento del componente utilizandolas Devtools de Vue.

A screenshot of the Vue Devtools showing the event emitted by the +ToDoItem+ component
Figura 4-5. Marcar un elemento pendiente como completado y depurar el evento emitido utilizandoVue Devtools

Definir eventos personalizados con defineEmits()

De forma similar a "Declarar Props utilizando defineProps() y withDefaults()", dentro de un bloque de código <script setup>, puedes utilizar defineEmits() para definir eventos personalizados. La función defineEmits() acepta el mismo tipo de parámetro de entrada que emits acepta :

const emits = defineEmits(['component-event'])

A continuación, devuelve una instancia de función que podemos utilizar para invocar un evento concreto del componente:

emits('component-event', [...arguments])

Así podemos escribir la sección de script de ToDoItem como en el Ejemplo 4-21.

Ejemplo 4-21. ComponenteToDoItem con el evento personalizado utilizando defineEmits()
<script lang="ts" setup>
//...
const props = defineProps({
  task: {
    type: Object as PropType<Task>,
    required: true,
  }
});

const emits = defineEmits(['task-completed-toggle'])

const onTaskCompleted = (event: Event) => {
  emits("task-completed-toggle", {
    id: props.task.id,
    completed: (event.target as HTMLInputElement)?.checked,
  });
}
</script>

Ten en cuenta que aquí no necesitamos utilizar defineComponent, ya que no hay ninguna instancia de this disponible dentro del bloque de código <script setup>.

Para mejorar la comprobación de tipos, puedes utilizar una declaración de tipo para el evento task-completed-toggle en lugar de una cadena simple. Mejoremos la declaración emits del Ejemplo 4-21 para utilizar el tipo EmitEvents como se muestra en el Ejemplo 4-22.

Ejemplo 4-22. Evento personalizado utilizando defineEmits() y declaración de sólo tipo
// Declare the emit type
type EmitEvents = {
  (e: 'task-completed-toggle', task: Task): void;
}

const emits = defineEmits<EmitEvents>()

Este enfoque ayuda a garantizar que vinculas el método correcto al evento declarado. Como se ha visto para el evento task-complete-toggle, cualquier declaración de evento debe seguir el mismo patrón:

(e: 'component-event', [...arguments]): void

En la sintaxis anterior, e es el nombre del evento, y arguments son todas las entradas pasadas al emisor del evento. En el caso del evento task-completed-toggle, el argumento de su emisor es task de tipo Task.

emits es una potente función que te permite habilitar la comunicación bidireccional entre un componente padre y uno hijo sin romper el mecanismo de flujo de datos de Vue. Sin embargo, props y emits sólo son beneficiosos cuando quieres una comunicación directa de datos.

Debes utilizar un enfoque diferente para pasar datos de un componente a su nieto o descendiente. En la siguiente sección, veremos cómo utilizar las API provide y inject para pasar datos de un componente padre a su componente hijo o nieto .

Comunicarse entre componentes conel patrón proporcionar/inyectar

Para establecer la comunicación de datos entre un componente antepasado y sus descendientes, la API provide/inject es una opción razonable. El campo provide pasa datos desde el ancestro, mientras que inject se asegura de que Vue inyecte los datos proporcionados en cualquier descendiente objetivo .

Utilizar provide para pasar datos

El campo de opción del componente provide acepta dos formatos: un objeto de datos o unafunción.

provide puede ser un objeto que contenga datos a inyectar, en el que cada propiedad represente un tipo de dato (clave, valor). En el siguiente ejemplo, ProductList proporciona un valor de datos, selectedIds, con el valor [1] a todos sus descendientes(Ejemplo 4-23).

Ejemplo 4-23. Pasar selectedIds utilizando provide en el componente ProductList
export default {
  name: 'ProductList',
  //...
  provide: {
    selectedIds: [1]
  },
}

Otro tipo de formato para provide es una función que devuelve un objeto que contiene los datos disponibles para inyectar a los descendientes. Una ventaja de este tipo de formato es que podemos acceder a la instancia this y asignar datos dinámicos o un método del componente a los campos relevantes del objeto devuelto. A partir del Ejemplo 4-23, podemos reescribir el campo provide como una función, tal y como se muestra en el Ejemplo 4-24.

Ejemplo 4-24. Pasar selectedIds utilizando provide en el componente ProductList como una función
export default {
//...
  provide() {
    return {
      selectedIds: [1]
    }
  },
//...
}
</script>
Nota

A diferencia de props, puedes pasar una función y hacer que el descendiente de destino la active utilizando el campo provide. Hacerlo así permite enviar datos de vuelta al componente padre. Sin embargo, Vue considera este enfoque un antipatrón, y debes utilizarlo con precaución.

En este punto, nuestro ProductList pasa algunos valores de datos a su descendiente utilizandoprovide. A continuación, debemos inyectar los valores proporcionados para que operen dentro de un descendiente.

Utilizar inject para recibir datos

Al igual que props, el campo inject puede aceptar una matriz de cadenas, cada una de las cuales representa la clave de datos proporcionada (inject: [selectedId]) o un objeto.

Cuando se utiliza inject como campo de objeto, cada una de sus propiedades es un objeto, con la clave que presenta la clave de datos local utilizada dentro del componente y las siguientespropiedades:

{
  from?: string;
  default: any
}

Aquí, from es opcional si la clave de la propiedad es la misma que la clave proporcionada por el antepasado. Tomemos el Ejemplo 4-23 con el selectedIds como dato proporcionado por ProductList a sus descendientes, por ejemplo. Podemos calcular un ProductComp que reciba los datos proporcionados, selectedIds, de ProductList y renombrarlo a currentSelectedIds para utilizarlo localmente, como se muestra en el Ejemplo 4-25.

Ejemplo 4-25. Inyectar datos proporcionados en ProductComp
<script lang='ts'>
export default {
  //...
  inject: {
    currentSelectedIds: {
      from: 'selectedIds',
      default: []
    },
  },
}
</script>

En este código, Vue tomará el valor de selectedIds inyectado y lo asignará a un campo de datos local, currentSelectedIds, o utilizará su valor por defecto [] si no hay ningún valor inyectado.

Dentro de la sección Componentes de la pestaña Vue de las Herramientas de desarrollo del navegador, al seleccionar el ProductComp del árbol de componentes (el panel de la izquierda), puedes depurar la indicación del cambio de nombre de los datos inyectados (el panel de la derecha), como se muestra en la Figura 4-6.

A screenshot shows the Component tab of the Vue tab in the browser's Develop tools with information about a component's provided and injected data.
Figura 4-6. Depura los datos proporcionados e inyectados utilizando Vue Devtools
Nota

Los ganchos equivalentes en la API de composición para provide/inject son provide() y inject(), respectivamente .

Ahora ya sabemos cómo utilizar provide y inject para pasar datos entre componentes de forma eficaz, sin perforar props. Exploremos cómo podemos renderizar una sección de contenido específica de un elemento a otra ubicación en el DOM con el componente <Teleport> .

API de teletransporte

Debido a restricciones de estilo, a menudo necesitamos implementar un componente que contiene elementos que Vue debería renderizar en una ubicación diferente en el DOM real para conseguir un efecto visual completo. En tales casos, solemos tener que "teletransportar" esos elementos al lugar deseado desarrollando una solución compleja, lo que conlleva un pésimo impacto en el rendimiento, consumo de tiempo, etc. Para resolver este reto del "teletransporte", Vue ofrece elcomponente <Teleport> .

El componente <Teleport> acepta una prop to, que indica el contenedor de destino, ya sea el selector de consulta de un elemento o el elemento HTML deseado. Supongamos que tenemos un componente House que tendrá una sección de Cielo y nubes que necesita que el motor Vue la teletransporte a un elemento DOM designado #sky, como en el Ejemplo 4-26.

Ejemplo 4-26. Componente casa con Teleport
<template>
  <div>
    This is a house
  </div>
  <Teleport to="#sky">
    <div>Sky and clouds</div>
  </Teleport>
</template>

En nuestro App.vue, añadimos un elemento section con el id de destino sky encima del componente House, como en el Ejemplo 4-27.

Ejemplo 4-27. Plantilla de App.vue con el componente House
<template>
  <section id="sky" />
  <section class="wrapper">
      <House />
  </section>
</template>

La Figura 4-7 muestra las salidas del código.

Screenshot displaying two texts in the reverse order
Figura 4-7. Orden real de visualización al utilizar el componente Teleport

Cuando inspeccionas el árbol DOM utilizando la pestaña Elementos de las Herramientas de desarrollo del navegador, "Cielo y nubes" aparece como anidado dentro de <section id="sky"> en su lugar(Figura 4-8).

Screenshot displaying the DOM tree
Figura 4-8. Árbol DOM real al utilizar el componente Teletransporte

También puedes desactivar temporalmente el desplazamiento del contenido dentro de una instancia del componente <Teleport> con su prop booleana disabled. Este componente es útil cuando quieres mantener la estructura de árbol DOM, y que Vue mueva sólo el contenido deseado a la ubicación de destino cuando sea necesario. Un caso de uso cotidiano para Teleport es un modal, que implementaremos a continuación.

Envolver ambas secciones bajo un padre

El componente de destino para el teletransporte debe existir en el DOM antes de montar <Teleport>. En el Ejemplo 4-27, si envuelves ambas instancias de section bajo un elemento main, el componente <Teleport> no funcionará como se espera. Consulta el apartado "Problema de renderización mediante teletransporte" para obtener más detalles.

Implementación de un modal con teletransporte y el elemento <diálogo>.

Un modal es una ventana de diálogo que aparece en la parte superior de una pantalla y bloquea la interacción del usuario con la página principal. El usuario debe interactuar con el modal para descartarlo y luego vuelve a la página principal .

Un modal es muy útil para mostrar notificaciones esenciales que requieren toda la atención del usuario y que sólo deben aparecer una vez.

Diseñemos un modal básico. Al igual que un diálogo, un modal debe contener los siguientes elementos(Figura 4-9):

  • Un fondo que cubre toda la pantalla donde el modal aparece en la parte superior y bloquea las interacciones del usuario con la página actual.

  • Una ventana modal que contiene el contenido del modal, incluyendo un header con un título y un botón de cierre, una sección de contenido main y una sección footer con un botón de cierre predeterminado. Estas tres secciones deben ser personalizables mediante las ranuras .

Screenshot displaying the design of a basic modal.
Figura 4-9. Diseño de un modal básico

Basándonos en el diseño anterior, implementamos una plantilla de componentes Modal utilizando el elemento HTML <dialog> en el Ejemplo 4-28.

Ejemplo 4-28. ComponenteModal
<template>
  <dialog :open="open">
    <header>
      <slot name="m-header"> 1
        <h2>{{ title }}</h2>
        <button>X</button>
      </slot>
    </header>
    <main>
      <slot name="m-main" /> 2
    </main>
    <footer>
      <slot name="m-footer"> 3
        <button>Close</button>
      </slot>
    </footer>
  </dialog>
</template>

En el código anterior, utilizamos tres secciones de ranura para que el usuario pueda personalizarlas:

1

La cabecera del modal (m-header)

2

El contenido principal (m-main)

3

El pie de página del modal (m-footer)

También vinculamos el atributo open del elemento <dialog> a una propiedad de datos local open para controlar la visibilidad del modal (visible/oculta). Además, mostramos la propiedad title como título predeterminado del modal . Ahora vamos a implementar las opciones del componente Modal, que recibe dos propiedades: open y title, como en el Ejemplo 4-29.

Ejemplo 4-29. Añadir accesorios al componente Modal
<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'Modal',
  props: {
    open: {
      type: Boolean,
      default: false,
    },
    title: {
      type: String,
      default: 'Dialog',
    },
  },
})
</script>

Cuando un usuario haga clic en el botón de cierre del modal o en el botón "X" de la cabecera, debe cerrarse solo. Como controlamos la visibilidad del modal mediante la propiedad open, necesitamos emitir un evento closeDialog con el nuevo valor de open desde el componente Modal al padre. Declaremos emits y un método close que emita el evento objetivo como en el Ejemplo 4-30 .

Ejemplo 4-30. Declarar el evento closeDialog para que lo emita Modal
<script lang="ts">
/** Modal.vue */
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'Modal',
  //...
  emits: ["closeDialog"], 1
  methods: {
    close() { 2
      this.$emit("closeDialog", false);
    },
  },
})
</script>
1

emits con un evento, closeDialog

2

close que emite el evento closeDialog con el nuevo valor de open como false

A continuación, lo vinculamos a los elementos de acción pertinentes en el elemento <dialog> utilizando la notación @, como se muestra en el Ejemplo 4-31.

Ejemplo 4-31. Vinculación de un receptor de eventos a eventos de clic
<template>
  <dialog :open="open" >
    <header>
      <slot name="m-header" >
        <h2>{{ title }}</h2>
        <button @click="close" >X</button> 1
      </slot>
    </header>
    <main>
      <slot name="m-main" />
    </main>
    <footer>
      <slot name="m-footer" >
        <button @click="close" >Close</button> 2
      </slot>
    </footer>
  </dialog>
</template>
1

@click controlador de eventos para el botón "X" de la cabecera

2

@click controlador de eventos para el botón de cierre por defecto del pie de página

A continuación, tenemos que envolver el elemento dialog con un componente <Teleport> para moverlo fuera del árbol DOM del componente padre. También pasamos la propiedad to al componente<Teleport> para especificar la ubicación de destino: un elemento HTML con un id, modal. Por último, vinculamos la proposición disabled al valor open del componente para asegurarnos de que Vue mueva sólo el contenido del componente modal a la ubicación deseada cuando sea visible(Ejemplo 4-32).

Ejemplo 4-32. Utilizando el componente <Teleport>
<template>
  <teleport 1
    to="#modal" 2
    :disabled="!open" 3
  >
    <dialog ref="dialog" :open="open" >
      <header>
      <slot name="m-header">
        <h2>{{ title }}</h2>
        <button @click="close" >X</button>
      </slot>
      </header>
      <main>
        <slot name="m-main" />
      </main>
      <footer>
        <slot name="m-footer">
          <button @click="close" >Close</button>
        </slot>
      </footer>
    </dialog>
  </teleport>
</template>
1

<Teleport> componente

2

to prop con la ubicación de destino con selector id modal

3

disabled prop con la condición cuando el valor open del componente es falso

Ahora vamos a probar nuestro componente Modal en un WithModalComponent añadiendo el siguiente código del Ejemplo 4-33 al WithModalComponent.

Ejemplo 4-33. Uso del componente modal en WithModalComponent
<template>
  <h2>With Modal component</h2>
  <button @click="openModal = true">Open modal</button>
  <Modal :open="openModal" title="Hello World" @closeDialog="toggleModal"/>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import Modal from "./Modal.vue";

export default defineComponent({
  name: "WithModalComponent",
  components: {
    Modal,
  },
  data() {
    return {
      openModal: false,
    };
  },
  methods: {
    toggleModal(newValue: boolean) {
      this.openModal = newValue;
    },
  },
});
</script>

Por último, añade un elemento <div> con id modal al elemento body del archivo index.html:

<body>
  <div id="app"></div>
  <div id="modal"></div> 1
  <script type="module" src="/src/main.ts"></script>
</body>
1

div elemento con id modal

Al hacerlo, Vue renderiza el contenido del componente Modal en este div con id modal siempre que la prop open se establezca en true (Figura 4-10).

Modal component rendered to the +div+ with id +modal+ when visible
Figura 4-10. Componente modal renderizado en div con id modal cuando es visible

La Figura 4-11 muestra su aspecto en pantalla:

Output of the +WithModalComponent+ when modal is visible
Figura 4-11. Salida del WithModalComponent cuando el modal es visible

Y cuando el prop open es false, el div con id modal está vacío(Figura 4-12), y el modal es invisible en pantalla(Figura 4-13).

Modal component not rendered to the +div+ with id +modal+ when hidden
Figura 4-12. Componente modal no mostrado en div con id modal cuando está oculto
Modal component not visible when hidden
Figura 4-13. Componente modal no visible cuando está oculto

Llegados a este punto, tienes un componente modal que funciona. Sin embargo, el aspecto visual del modal no es exactamente tan bueno como queríamos; debería haber una superposición oscura sobre el contenido de la página principal cuando el modal está visible. Arreglemos este problema utilizando estilos CSS para el selector ::backdrop en la sección <style> del elemento modal:

<style scoped>
  dialog::backdrop {
    background-color: rgba(0, 0, 0, 0.5);
  }
</style>

Sin embargo, esto no cambiará la apariencia del fondo del modal. Este comportamiento se debe a que el navegador aplica las reglas del selector CSS ::backdrop al diálogo sólo cuando abrimos el diálogo utilizando el método dialog.showModal(), y no cambiando el atributo open. Para solucionar este problema, debemos realizar las siguientes modificaciones en nuestro componente Modal :

  • Añade una referencia directa al elemento <dialog> asignando un valor "diálogo" al atributo ref:

    <dialog :open="open" ref="dialog">
      <!--...-->
    </dialog>
  • Dispara $refs.dialog.showModal() o $refs.dialog.close() en el elemento dialog cada vez que el puntal open cambie respectivamente con watch:

    watch: {
      open(newValue) {
        const element = this.$refs.dialog as HTMLDialogElement;
        if (newValue) {
          element.showModal();
        } else {
          element.close();
        }
      },
    },
  • Elimina el enlace original del atributo open del elemento <dialog>:

    <dialog ref="dialog">
      <!--...-->
    </dialog>
  • Elimina el uso del atributo disabled en el componente <teleport>:

    <teleport to="#modal">
      <!--...-->
    </teleport>

Al abrir el modal utilizando el método incorporado showModal(), el navegador añadirá un pseudoelemento ::backdrop al elemento real <dialog> en el DOM, y al mover dinámicamente el contenido del elemento a la ubicación de destino se desactivará esta funcionalidad, dejando el modal sin el telón de fondo deseado.

También reposicionamos el modal al centro de la página y encima de otros elementos añadiendo las siguientes reglas CSS al selector dialog:

dialog {
  position: fixed;
  z-index: 999;
  inset-block-start: 30%;
  inset-inline-start: 50%;
  width: 300px;
  margin-inline-start: -150px;
}

La salida será como se muestra en la Figura 4-14 cuando el modal esté visible.

Modal component with backdrop and stylings
Figura 4-14. Componente modal con fondo y estilos

Hemos aprendido a implementar un componente reutilizable Modal utilizando Teleport y hemos explorado diferentes casos de uso con cada una de las características de los elementos incorporados <dialog>. También hemos aprendido a utilizar el selector CSS ::backdrop para dar estilo al telón de fondo del modal.

Como habrás observado, hemos establecido la ubicación de destino div para que el modal sea hijo directo de body, fuera del elemento de entrada <div id="app"> de la aplicación Vue. ¿Qué ocurre si queremos mover el destino del modal div al interior del componente de entrada App.vue de la aplicación Vue? Averigüémoslo en la siguiente sección.

Problema de renderizado con teletransporte

Para entender el problema que supone utilizar Teleport para representar el modal dentro de un componente hijo del componente App.vue, movamos primero el <div id="modal"></div> de index.html a App.vue, después la instancia WithModalComponent :

<template>
  <section class="wrapper">
    <WithModalComponent />
  </section>
  <div id="modal"></div>
</template>

Después de ejecutar tu aplicación, puedes ver que el navegador no renderiza el modal a pesar de las veces que haces clic en el botón Open modal. Y la consola muestra el siguiente error

Error message when rendering modal inside App.vue
Figura 4-15. Mensaje de error de la consola al renderizar el modal dentro de App.vue

Debido al mecanismo de orden de renderizado de Vue, el padre espera a que se rendericen los hijos antes de renderizarse a sí mismo. Los hijos se muestran en el orden en que aparecen en la sección template del padre. En este caso, WithModalComponent se renderiza primero. Así, Vue renderiza el elemento <dialog> y empieza a mover el contenido del componente a la ubicación de destino antes de renderizar el elemento ParentComponent. Sin embargo, como ParentComponent aún está esperando a que WithModalComponent termine de renderizarse, el elemento <div id="modal"> aún no existe en el DOM. Como resultado, Vue no puede localizar la ubicación de destino y realizar el movimiento correcto, y no puede renderizar el elemento <dialog> dentro del elemento <div id="modal">, de ahí el error.

Una solución para eludir esta limitación es poner el elemento de destino<div id="modal"> antes de WithModalComponent:

<template>
  <div id="modal"></div>
  <section class="wrapper">
    <WithModalComponent />
  </section>
</template>

Esta solución garantiza que el div de destino esté disponible antes de que Vue renderice el elemento Modal y mueva el contenido. Otro enfoque consiste en utilizar el atributo disabled para posponer el proceso de desplazamiento del contenido de Modal durante la renderización hasta que el usuario haga clic en el botón Open modal. Ambas opciones tienen pros y contras, y debes elegir la que mejor se adapte a tus necesidades.

La solución más habitual es insertar el elemento de destino como hijo directo del elemento body y aislarlo del contexto de representación de Vue.

Una ventaja importante de utilizar <Teleport> es conseguir el máximo efecto visual (como el modo de pantalla completa, modal, barra lateral, etc.) manteniendo la estructura jerárquica del código, el aislamiento de los componentes y la legibilidad .

Resumen

En este capítulo se ha explorado el concepto de diferentes enfoques en la comunicación de componentes utilizando las funciones integradas de Vue, como props, emits y provide/inject. Aprendimos a utilizar estas funciones para pasar datos y eventos entre componentes manteniendo intacto el mecanismo de flujo de datos de Vue. También aprendimos a utilizar la API Teleport para renderizar un elemento fuera del árbol DOM del componente padre manteniendo su orden de aparición en el <template> del componente padre. <Teleport> es beneficioso para construir componentes que requieren una visualización alineada con el elemento principal de la página, como ventanas emergentes, diálogos, modales, etc.

En el próximo capítulo, exploraremos más a fondo la API de Composición y cómo utilizarla para componer componentes Vue entre sí.

Get Aprender Vue now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.