Programación usando VueJS (Comunicación entre componentes). Parte 6

Comunicación entre componentes (Padre e hijo)

Para continuar con el curso de VueJS y avanzar nuestra aplicación, ahora aprenderemos a comunicar los componentes entre si.

En esta parte vamos a ver como realizar la comunicación entre un componente padre a un componente hijo y de un componente hijo a un componente padre.

Si la comunicación se realiza desde el padre hacia el hijo usaremos las propiedades, pero si la comunicación es desde el hijo hacia el padre usaremos los eventos.

Sabiendo como se realiza la comunicación entre padres e hijos, ahora vamos a poner en practica este mecanismo.

Vamos abrir el fichero filmCard.vue de nuestro proyecto:

<template>
  <ul>
    <li v-for="(film, index) in dataFilm" :key="index">
      <div :class="film.Poster !== 'N/A' ? 'image-film' : 'image-film border'"><img :src="film.Poster !== 'N/A' ? film.Poster : noImage" alt=""></div>
      <div class="image-data">
        <table>
          <tr>
            <th>ID<font-awesome-icon icon="info-circle" @click="searchDetailFilm(film.imdbID, 'id')" class="icon-right"/></th>
            <td v-text="film.imdbID"></td>
          </tr>
          <tr><th>Title</th><td v-text="film.Title"></td></tr>
          <tr><th>Year</th><td v-text="film.Year"></td></tr>
          <tr><th>Type</th><td v-text="film.Type"></td></tr>
          <tr><th>Poster</th><td><a :href="film.Poster" target="_blank" class="btn-download"><font-awesome-icon icon="download" /> Poster</a></td></tr>
        </table>
      </div>
    </li>
  </ul>
</template>
<!-- -->
<script>
  export default {
    props: ['dataFilm','noImage'],
    data () {
      return {
      }
    }
  }
</script>
<!-- -->
<style scoped lang="scss">
  ul {
    li {
      text-align: left;
      img {
        box-shadow: 0px 0px 30px rgba(0,0,0,1);
        height: 450px;
        width: 300px;
      }
    }
  }
  ul {
    list-style-type: none;
    padding: 0;
  }
  li {
    display: inline-block;
    margin: 0 10px;
  }
  a {
    color: #42b983;
  }
  .image-data {
    border: 1px solid #cccccc;
    float: left;
    width: calc(100% - 332px);
    height: 448px;
    overflow-y: scroll;
  }
  table {
    width: 100%;
    tr {
      td, th {
        border: 1px solid #cccccc;
        padding: 5px;
        margin: 0;
        text-align: left;
      }
    }
  }
  .border {
    border: 1px solid #cccccc;
    background-image: url('../../assets/no-image.png');
    background-repeat: no-repeat;
    background-position: center;
    width: 298px !important;
  }
  .image-film {
    width: 300px;
    float: left;
    margin: 0 20px 20px 0;
    height: 450px;
  }
  a {
    cursor: pointer;
    text-decoration: none;
  }
  .icon-right {
    float: right;
    cursor: pointer;
  }
  .btn-download {
    background: #0095ff;
    padding: 5px 20px 4px 20px;
    font-size: 11px;
    text-align: center;
    color: #ffffff;
    border-radius: 3px;
  }
</style>

Ahora mismo existe una comunicación desde el padre App.vue hacia el hijo filmCard.vue usando las propiedades

<film-card v-show="showFilm" :data-film="dataFilm" :no-image="noImage"></film-card>

Desde el padre le estamos pasando la respuesta del API usando la propiedad data-film.

Comunicación desde el hijo hacia el padre

Bien, ahora añadiremos un ejemplo realizando la comunicación desde el hijo hacia el padre usando eventos.

Usaremos el fichero filmCard.vue y añadiremos el código necesario para lanzar un evento.

<template>
  <ul>
    <li v-for="(film, index) in dataFilm" :key="index">
      <div :class="film.Poster !== 'N/A' ? 'image-film' : 'image-film border'"><img :src="film.Poster !== 'N/A' ? film.Poster : noImage" alt=""></div>
      <div class="image-data">
        <table>
          <tr>
            <th>ID<font-awesome-icon icon="info-circle" @click="searchDetailFilm(film.imdbID, 'id')" class="icon-right"/></th>
            <td v-text="film.imdbID"></td>
          </tr>
          <tr><th>Title</th><td v-text="film.Title"></td></tr>
          <tr><th>Year</th><td v-text="film.Year"></td></tr>
          <tr><th>Type</th><td v-text="film.Type"></td></tr>
          <tr>
            <th>Poster</th>
            <td>
              <a :href="film.Poster" target="_blank" class="btn-download">
                <font-awesome-icon icon="download" /> Poster
              </a>
              <a class="btn-download" @click="searchInfoWikipedia(film.Title)">
                <font-awesome-icon icon="wifi" /> Wikipedia
              </a>
            </td>
          </tr>
        </table>
      </div>
    </li>
  </ul>
</template>
<!-- -->
<script>
  export default {
    props: ['dataFilm','noImage'],
    data () {
      return {
      }
    },
    methods: {
      searchInfoWikipedia(title) {
        this.$emit('search-in-wikipedia', title);
      }
    }
  }
</script>
<!-- -->
<style scoped lang="scss">
  ul {
    li {
      text-align: left;
      img {
        box-shadow: 0px 0px 30px rgba(0,0,0,1);
        height: 450px;
        width: 300px;
      }
    }
  }
  ul {
    list-style-type: none;
    padding: 0;
  }
  li {
    display: inline-block;
    margin: 0 10px;
  }
  a {
    color: #42b983;
  }
  .image-data {
    border: 1px solid #cccccc;
    float: left;
    width: calc(100% - 332px);
    height: 448px;
    overflow-y: scroll;
  }
  table {
    width: 100%;
    tr {
      td, th {
        border: 1px solid #cccccc;
        padding: 5px;
        margin: 0;
        text-align: left;
      }
    }
  }
  .border {
    border: 1px solid #cccccc;
    background-image: url('../../assets/no-image.png');
    background-repeat: no-repeat;
    background-position: center;
    width: 298px !important;
  }
  .image-film {
    width: 300px;
    float: left;
    margin: 0 20px 20px 0;
    height: 450px;
  }
  a {
    cursor: pointer;
    text-decoration: none;
  }
  .icon-right {
    float: right;
    cursor: pointer;
  }
  .btn-download {
    background: #0095ff;
    padding: 5px 20px 4px 20px;
    font-size: 11px;
    text-align: center;
    color: #ffffff;
    border-radius: 3px;
  }
</style>

Como puedes comprobar en el código anterior, hemos creado un método que se encarga de emitir un evento hacia el padre usando la función $emit.

Ahora volveremos al fichero App.vue para añadir la capacidad de escuchar ese evento.

Para esa función usaremos la directiva v-on (@) que junto al nombre del evento que añadimos en el hijo, permitirá crear la comunicación.

A continuación el código nuevo de App.vue:

<template>
  <div id="app" v-cloak>
    <header>
      <div v-text="header.title" class="title"></div>
      <div v-text="header.subtitle" class="subtitle"></div>
    </header>
    <section class="container">
      <div class="box-search">
        <input type="search" v-model="search">
        <a v-show="checkSearch" @click="searchFilm()"><font-awesome-icon icon="search" /></a>
        <div class="pagination" v-show="showPagination">
          <span class="reg-prev" @click="previous()">
            <font-awesome-icon icon="angle-left" />
          </span>
          <span class="total-pages" v-text="printPages"></span>
          <span class="reg-next" @click="next()">
            <font-awesome-icon icon="angle-right" />
          </span>
        </div>
      </div>
      <div class="search-internet" v-show="showSearchInternet">
        <iframe :src="queryComplete" frameborder="0"></iframe>
      </div>
      <div class="box-result">
        <film-card v-show="showFilm" :data-film="dataFilm" :no-image="noImage" @search-in-wikipedia="searchInfoWikipedia">
          <!-- slot con nombre -->
          <h1 slot="busqueda">
            Buscando.... {{search}}
          </h1>
          <!-- slot defecto -->
          <h3 slot>
            Busqueda de series y pelicuas
          </h3>
          <!-- slot defecto -->
          <h5 slot>
            Información y datos
          </h5>
        </film-card>
        <film-card-detail :data-detail-film="dataDetailFilm" show-detail-film="showDetailFilm"></film-card-detail>
      </div>
      <film-modal type-error="error" :msn-modal="msnError" :show-modal="showError"></film-modal>
      <film-loading :show-loading="showloading"></film-loading>
    </section>
  </div>
</template>

<script>
import API from '@/servicios.js'
/*
 * Importando un componente de forma local.
 * Solo lo usamos donde se importa
 */
import FilmCard from '@/components/local/filmCard.vue'
import FilmCardDetail from '@/components/local/filmCardDetail.vue'
export default {
  components: {
    FilmCard,
    FilmCardDetail
  },
  name: 'app',
  data () {
    return {
      header: {
        title: 'OMDb API',
        subtitle: 'The Open Movie & TV Show Database'
      },
      showButton: false,
      search: '',
      dataFilm: '',
      showError: false,
      msnError: '',
      dataDetailFilm: '',
      showPagination: false,
      pagination: {
        pagActual: 1,
        totalRecords: 0,
        totalPages: 0
      },
      showloading: false,
      noImage: './dist/no-image.png',
      searchInternet: 'https://es.wikipedia.org/wiki/',
      showSearchInternet: false,
      query: ''
    }
  },
  methods: {
    searchInfoWikipedia (title) {
      this.query = `${this.searchInternet}${title}`;
      this.showSearchInternet = true;
    },
    searchFilm () {
      this.showSearchInternet = false;
      this.showloading = true
      const params = {
        title: this.search,
        page: this.pagination.pagActual
      }
      API.getFilmData(params)
        .then(data => {
          this.showloading = false
          this.dataDetailFilm = ''
          if (data.Response === 'True') {
            this.showError = false
            this.msnError = ''
            this.dataFilm = data.Search
            this.pagination.totalRecords = data.totalResults
            this.pagination.totalPages = (data.totalResults / 10) % 1 === 0 ? parseInt((data.totalResults / 10)) : parseInt((data.totalResults / 10)) + 1
            this.showPagination = true
          } else {
            this.dataFilm = ''
            this.showError = true
            this.msnError = data.Error
            this.showPagination = false
          }
        })
        .catch(err => {
          this.showloading = false
          console.log(`Error App.vue ${err}`)
        })
    },
    searchDetailFilm (value, type) {
      this.showloading = true
      const params = {
        [type]: value
      }
      API.getDetailFilmData(params)
        .then(data => {
          this.showloading = false
          this.dataFilm = ''
          this.showPagination = false
          if (data.Response === 'True') {
            this.showError = false
            this.msnError = ''
            this.dataDetailFilm = data
          } else {
            this.dataDetailFilm = ''
            this.showError = true
            this.msnError = data.Error
          }
        })
        .catch(err => {
          this.showloading = false
          console.log(`Error App.vue ${err}`)
        })
    },
    previous () {
      this.pagination.pagActual = --this.pagination.pagActual < 1 ? 1 : this.pagination.pagActual--
      this.searchFilm()
    },
    next () {
      this.pagination.pagActual = ++this.pagination.pagActual > this.pagination.totalPages ? this.pagination.totalPages : this.pagination.pagActual++
      this.searchFilm()
    }
  },
  computed: {
    queryComplete () {
      return this.query;
    },
    checkSearch () {
      return this.search.length === 0 ? false : true
    },
    showFilm () {
      return this.dataFilm === '' ? false : true
    },
    showDetailFilm () {
      return this.dataDetailFilm === '' ? false : true
    },
    printPages () {
      return `${this.pagination.pagActual} de ${this.pagination.totalPages}`
    }
  }
}
</script>

<style lang="scss">
  @import url('../node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css');
  @media (min-width: 1200px) {
    ul {
      li {
        width: 48%;
      }
    }
  }
  @media (max-width: 1199px) {
    ul {
      li {
        width: 100%;
      }
    }
  }
  .box-search {
    border: 1px solid #cccccc;
    padding: 10px;
    text-align: left;
    input[type='search'] {
      width: 60%;
    }
    .box-search button {
      float: right;
    }
  }
  [v-cloak] {
    display: none;
  }
  .pagination {
    float: right;
    width: 100px;
    text-align: center;
    padding: 3px;
    span {
      cursor: pointer;
    }
  }
  header {
    box-shadow: 0px 0px 30px rgba(0,0,0,0.15);
    padding: 10px;
  }
  .container {
    box-shadow: 0px 0px 30px rgba(0, 0, 0, 0.15);
    margin-top: 20px;
    padding: 30px;
    min-height: 500px;
  }
  .title {
    font-size: 3em;
  }
  .subtitle {
    font-size: 1em;
  }
  #app {
    font-family: 'Avenir', Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-align: center;
    color: #2c3e50;
    min-width: 850px;
  }
  h1, h2 {
    font-weight: normal;
  }
  iframe {
    width: 100%;
    height: 500px;
    box-shadow: 0px 0px 3px;
    margin-top: 20px;
  }
</style>

En el nuevo código notaras algunos cambios como un iframe para cargar la búsqueda en la wikipedia, métodos, computed properties y sobre todo el encargado de escuchar el evento entre el hijo y el padre, @search-in-wikipedia.

Slots en Vue

Pero…, ¿no estábamos hablando de comunicación entre componentes?.

Bien, los slots nos permiten inyectar html desde el componente padre al componente hijo.

En la versión 2 de Vue podemos crear los slots con datos que son pasados a través de los atributos del componente y así hacerlos más dinámicos.

Para usar los slot en Vue, vamos a usar el componente que tiene Vue llamado slot.

El componente slot que ofrece Vue se puede usar sin nombre o añadiendo la propiedad name asignado un nombre a ese slot.

App.vue

<template>
  <div id="app" v-cloak>
    <header>
      <div v-text="header.title" class="title"></div>
      <div v-text="header.subtitle" class="subtitle"></div>
    </header>
    <section class="container">
      <div class="box-search">
        <input type="search" v-model="search">
        <a v-show="checkSearch" @click="searchFilm()"><font-awesome-icon icon="search" /></a>
        <div class="pagination" v-show="showPagination">
          <span class="reg-prev" @click="previous()">
            <font-awesome-icon icon="angle-left" />
          </span>
          <span class="total-pages" v-text="printPages"></span>
          <span class="reg-next" @click="next()">
            <font-awesome-icon icon="angle-right" />
          </span>
        </div>
      </div>
      <div class="search-internet" v-show="showSearchInternet">
        <iframe :src="queryComplete" frameborder="0"></iframe>
      </div>
      <div class="box-result">
        <film-card v-show="showFilm" :data-film="dataFilm" :no-image="noImage" @search-in-wikipedia="searchInfoWikipedia">
          <!-- slot con nombre -->
          <h1 slot="busqueda">
            Buscando.... {{search}}
          </h1>
          <!-- slot defecto -->
          <h3 slot>
            Busqueda de series y pelicuas
          </h3>
          <!-- slot defecto -->
          <h5 slot>
            Información y datos
          </h5>
        </film-card>
        <film-card-detail :data-detail-film="dataDetailFilm" show-detail-film="showDetailFilm"></film-card-detail>
      </div>
      <film-modal type-error="error" :msn-modal="msnError" :show-modal="showError"></film-modal>
      <film-loading :show-loading="showloading"></film-loading>
    </section>
  </div>
</template>

<script>
import API from '@/servicios.js'
/*
 * Importando un componente de forma local.
 * Solo lo usamos donde se importa
 */
import FilmCard from '@/components/local/filmCard.vue'
import FilmCardDetail from '@/components/local/filmCardDetail.vue'
export default {
  components: {
    FilmCard,
    FilmCardDetail
  },
  name: 'app',
  data () {
    return {
      header: {
        title: 'OMDb API',
        subtitle: 'The Open Movie & TV Show Database'
      },
      showButton: false,
      search: '',
      dataFilm: '',
      showError: false,
      msnError: '',
      dataDetailFilm: '',
      showPagination: false,
      pagination: {
        pagActual: 1,
        totalRecords: 0,
        totalPages: 0
      },
      showloading: false,
      noImage: './dist/no-image.png',
      searchInternet: 'https://es.wikipedia.org/wiki/',
      showSearchInternet: false,
      query: ''
    }
  },
  methods: {
    searchInfoWikipedia (title) {
      this.query = `${this.searchInternet}${title}`;
      this.showSearchInternet = true;
    },
    searchFilm () {
      this.showSearchInternet = false;
      this.showloading = true
      const params = {
        title: this.search,
        page: this.pagination.pagActual
      }
      API.getFilmData(params)
        .then(data => {
          this.showloading = false
          this.dataDetailFilm = ''
          if (data.Response === 'True') {
            this.showError = false
            this.msnError = ''
            this.dataFilm = data.Search
            this.pagination.totalRecords = data.totalResults
            this.pagination.totalPages = (data.totalResults / 10) % 1 === 0 ? parseInt((data.totalResults / 10)) : parseInt((data.totalResults / 10)) + 1
            this.showPagination = true
          } else {
            this.dataFilm = ''
            this.showError = true
            this.msnError = data.Error
            this.showPagination = false
          }
        })
        .catch(err => {
          this.showloading = false
          console.log(`Error App.vue ${err}`)
        })
    },
    searchDetailFilm (value, type) {
      this.showloading = true
      const params = {
        [type]: value
      }
      API.getDetailFilmData(params)
        .then(data => {
          this.showloading = false
          this.dataFilm = ''
          this.showPagination = false
          if (data.Response === 'True') {
            this.showError = false
            this.msnError = ''
            this.dataDetailFilm = data
          } else {
            this.dataDetailFilm = ''
            this.showError = true
            this.msnError = data.Error
          }
        })
        .catch(err => {
          this.showloading = false
          console.log(`Error App.vue ${err}`)
        })
    },
    previous () {
      this.pagination.pagActual = --this.pagination.pagActual < 1 ? 1 : this.pagination.pagActual--
      this.searchFilm()
    },
    next () {
      this.pagination.pagActual = ++this.pagination.pagActual > this.pagination.totalPages ? this.pagination.totalPages : this.pagination.pagActual++
      this.searchFilm()
    }
  },
  computed: {
    queryComplete () {
      return this.query;
    },
    checkSearch () {
      return this.search.length === 0 ? false : true
    },
    showFilm () {
      return this.dataFilm === '' ? false : true
    },
    showDetailFilm () {
      return this.dataDetailFilm === '' ? false : true
    },
    printPages () {
      return `${this.pagination.pagActual} de ${this.pagination.totalPages}`
    }
  }
}
</script>

<style lang="scss">
  @import url('../node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css');
  @media (min-width: 1200px) {
    ul {
      li {
        width: 48%;
      }
    }
  }
  @media (max-width: 1199px) {
    ul {
      li {
        width: 100%;
      }
    }
  }
  .box-search {
    border: 1px solid #cccccc;
    padding: 10px;
    text-align: left;
    input[type='search'] {
      width: 60%;
    }
    .box-search button {
      float: right;
    }
  }
  [v-cloak] {
    display: none;
  }
  .pagination {
    float: right;
    width: 100px;
    text-align: center;
    padding: 3px;
    span {
      cursor: pointer;
    }
  }
  header {
    box-shadow: 0px 0px 30px rgba(0,0,0,0.15);
    padding: 10px;
  }
  .container {
    box-shadow: 0px 0px 30px rgba(0, 0, 0, 0.15);
    margin-top: 20px;
    padding: 30px;
    min-height: 500px;
  }
  .title {
    font-size: 3em;
  }
  .subtitle {
    font-size: 1em;
  }
  #app {
    font-family: 'Avenir', Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-align: center;
    color: #2c3e50;
    min-width: 850px;
  }
  h1, h2 {
    font-weight: normal;
  }
  iframe {
    width: 100%;
    height: 500px;
    box-shadow: 0px 0px 3px;
    margin-top: 20px;
  }
</style>

filmCard.vue

<template>
  <div>
    <h1 class="busqueda">
      <slot name="busqueda">
        No se ha realizado ninguna búsqueda
      </slot>
      <slot>
        Sin información
      </slot>
    </h1>
    <ul>
      <li v-for="(film, index) in dataFilm" :key="index">
        <div :class="film.Poster !== 'N/A' ? 'image-film' : 'image-film border'"><img :src="film.Poster !== 'N/A' ? film.Poster : noImage" alt=""></div>
        <div class="image-data">
          <table>
            <tr>
              <th>ID<font-awesome-icon icon="info-circle" @click="searchDetailFilm(film.imdbID, 'id')" class="icon-right"/></th>
              <td v-text="film.imdbID"></td>
            </tr>
            <tr><th>Title</th><td v-text="film.Title"></td></tr>
            <tr><th>Year</th><td v-text="film.Year"></td></tr>
            <tr><th>Type</th><td v-text="film.Type"></td></tr>
            <tr>
              <th>Poster</th>
              <td>
                <a :href="film.Poster" target="_blank" class="btn-download">
                  <font-awesome-icon icon="download" /> Poster
                </a>
                <a class="btn-download" @click="searchInfoWikipedia(film.Title)">
                  <font-awesome-icon icon="wifi" /> Wikipedia
                </a>
              </td>
            </tr>
          </table>
        </div>
      </li>
    </ul>
  </div>
</template>
<!-- -->
<script>
  export default {
    props: ['dataFilm','noImage'],
    data () {
      return {
      }
    },
    methods: {
      searchInfoWikipedia(title) {
        this.$emit('search-in-wikipedia', title);
      }
    }
  }
</script>
<!-- -->
<style scoped lang="scss">
  h1.busqueda {
    text-align: left;
  }
  ul {
    li {
      text-align: left;
      img {
        box-shadow: 0px 0px 30px rgba(0,0,0,1);
        height: 450px;
        width: 300px;
      }
    }
  }
  ul {
    list-style-type: none;
    padding: 0;
  }
  li {
    display: inline-block;
    margin: 0 10px;
  }
  a {
    color: #42b983;
  }
  .image-data {
    border: 1px solid #cccccc;
    float: left;
    width: calc(100% - 332px);
    height: 448px;
    overflow-y: scroll;
  }
  table {
    width: 100%;
    tr {
      td, th {
        border: 1px solid #cccccc;
        padding: 5px;
        margin: 0;
        text-align: left;
      }
    }
  }
  .border {
    border: 1px solid #cccccc;
    background-image: url('../../assets/no-image.png');
    background-repeat: no-repeat;
    background-position: center;
    width: 298px !important;
  }
  .image-film {
    width: 300px;
    float: left;
    margin: 0 20px 20px 0;
    height: 450px;
  }
  a {
    cursor: pointer;
    text-decoration: none;
  }
  .icon-right {
    float: right;
    cursor: pointer;
  }
  .btn-download {
    background: #0095ff;
    padding: 5px 20px 4px 20px;
    font-size: 11px;
    text-align: center;
    color: #ffffff;
    border-radius: 3px;
  }
</style>

En el código anterior podemos comprobar el uso de slot, defiendo en el padre los siguientes slots:

<film-card v-show="showFilm" :data-film="dataFilm" :no-image="noImage" @search-in-wikipedia="searchInfoWikipedia">
  <!-- slot con nombre -->
  <h1 slot="busqueda">
    Buscando.... {{search}}
  </h1>
  <!-- slot defecto -->
  <h3 slot>
    Busqueda de series y pelicuas
  </h3>
  <!-- slot defecto -->
  <h5 slot>
    Información y datos
  </h5>
</film-card>

Cuando usamos el componente slot sin el atributo name, usara por defecto en el hijo el slot que encuentre.

De hecho en este ejemplo que tiene el componente slot sin nombre, el contenido h3 h5 se inyectara en el slot sin nombre.

<template>
  <div>
    <h1 class="busqueda">
      <slot name="busqueda">
        No se ha realizado ninguna búsqueda
      </slot>
      <slot name="busqueda">
        No se ha realizado ninguna búsqueda
      </slot>
      <slot>
        Sin información
      </slot>
    </h1>
    <ul>
      <li v-for="(film, index) in dataFilm" :key="index">
        <div :class="film.Poster !== 'N/A' ? 'image-film' : 'image-film border'"><img :src="film.Poster !== 'N/A' ? film.Poster : noImage" alt=""></div>
        <div class="image-data">
          <table>
            <tr>
              <th>ID<font-awesome-icon icon="info-circle" @click="searchDetailFilm(film.imdbID, 'id')" class="icon-right"/></th>
              <td v-text="film.imdbID"></td>
            </tr>
            <tr><th>Title</th><td v-text="film.Title"></td></tr>
            <tr><th>Year</th><td v-text="film.Year"></td></tr>
            <tr><th>Type</th><td v-text="film.Type"></td></tr>
            <tr>
              <th>Poster</th>
              <td>
                <a :href="film.Poster" target="_blank" class="btn-download">
                  <font-awesome-icon icon="download" /> Poster
                </a>
                <a class="btn-download" @click="searchInfoWikipedia(film.Title)">
                  <font-awesome-icon icon="wifi" /> Wikipedia
                </a>
              </td>
            </tr>
          </table>
        </div>
      </li>
    </ul>
  </div>
</template>
<!-- -->
<script>
  export default {
    props: ['dataFilm','noImage'],
    data () {
      return {
      }
    },
    methods: {
      searchInfoWikipedia(title) {
        this.$emit('search-in-wikipedia', title);
      }
    }
  }
</script>
<!-- -->
<style scoped lang="scss">
  h1.busqueda {
    text-align: left;
  }
  ul {
    li {
      text-align: left;
      img {
        box-shadow: 0px 0px 30px rgba(0,0,0,1);
        height: 450px;
        width: 300px;
      }
    }
  }
  ul {
    list-style-type: none;
    padding: 0;
  }
  li {
    display: inline-block;
    margin: 0 10px;
  }
  a {
    color: #42b983;
  }
  .image-data {
    border: 1px solid #cccccc;
    float: left;
    width: calc(100% - 332px);
    height: 448px;
    overflow-y: scroll;
  }
  table {
    width: 100%;
    tr {
      td, th {
        border: 1px solid #cccccc;
        padding: 5px;
        margin: 0;
        text-align: left;
      }
    }
  }
  .border {
    border: 1px solid #cccccc;
    background-image: url('../../assets/no-image.png');
    background-repeat: no-repeat;
    background-position: center;
    width: 298px !important;
  }
  .image-film {
    width: 300px;
    float: left;
    margin: 0 20px 20px 0;
    height: 450px;
  }
  a {
    cursor: pointer;
    text-decoration: none;
  }
  .icon-right {
    float: right;
    cursor: pointer;
  }
  .btn-download {
    background: #0095ff;
    padding: 5px 20px 4px 20px;
    font-size: 11px;
    text-align: center;
    color: #ffffff;
    border-radius: 3px;
  }
</style>
<h1 class="busqueda">
  <slot name="busqueda">
    No se ha realizado ninguna búsqueda
  </slot>
  <slot>
    Sin información
  </slot>
</h1>

Slots en funcionamiento

A continuación un vídeo probando los slots, donde comprobaremos como funcionan los slots y los fallos que se pueden mostrar en la consola.

En el vídeo anterior podemos comprobar que cuando usamos el componente slot por defecto o con el mismo nombre mas de una vez, el navegador nos informa de que existen duplicados.

Como hemos podido comprobar el uso de slot en Vue abre un mundo de posibilidades a la personalización de nuestros componentes, ya que con un solo componente podemos cambiar el contenido por completo y adaptarlo a nuestras necesidades.

De hecho, el uso mas común de los slot suele ser para crear ventanas modales, ventanas emergentes, listados, etc.., donde realmente el contenedor es el mismo pero queremos cambiar el contenido.

Comunicación con cualquier componente (sin vinculo)

Como hemos podido comprobar, la comunicación entre padres e hijos en Vue es fácil, pero…¿que ocurre cuando no existe ningún vínculo?.

Vue no tiene ningún mecanismo directo para comunicar entre componentes que no tengan un vinculo, pero si nos ofrece una alternativa mediante Plugins Event Bus.

Bien, ¿y que es un plugin?. Lo podemos definir como una funcionalidad que permite extender el comportamiento de la instancia de Vue.

Para crear un plugin, mi recomendación es crear una carpeta llamada plugin, donde añadiremos los plugin.

eventBus.js

const EB = {}

EB.install = Vue => {
  Vue.prototype.$bus = new Vue()
}

export default EB

Con el código anterior acabamos de crear la estructura básica de un plugin para Vue, que en nuestro ejemplo se encargara de la gestión de los eventos.

Realmente no tiene mucha complicación el código anterior, pero me gustaría remarcar dos cosas interesantes:

  • Cuando creamos un plugin en Vue.js tenemos que usar el método install, ya que este recibirá como parámetro la instancia de Vue.
  • Usaremos el prototype en Vue para extender y añadir comportamientos, que junto al objeto $bus ($ para saber que es un plugin y diferenciar de las propiedades y/o métodos del modelo) declararemos una nueva instancia de Vue, aprovechando las funcionalidades que ofrece Vue.

Usando el event bus

Con nuestra base del plugin creado y el event bus declarado, vamos a usarlo en nuestra aplicación.

Vamos a ir hasta el fichero main.js y lo vamos a importar y usar desde allí.

import Vue from 'vue'
import App from '@/App.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { fas } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import FilmLoading from '@/components/global/filmLoading.vue'
import FilmModal from '@/components/global/filmModal.vue'
import EventBus from '@/plugin/eventBus'

/*
 * Importar componente de forma global
 * Vue.component(tag-html, componente)
 */

Vue.component('film-loading', FilmLoading)
Vue.component('film-modal', FilmModal)

/*
 * Vue.use nos sirve para usar plugins, librerias externas, etc...
 */
Vue.use(EventBus)

library.add(fas)
Vue.component('font-awesome-icon', FontAwesomeIcon)

new Vue({
  el: '#app',
  render: h => h(App)
})

Ahora para usar el event bus, vamos a definir en nuestra aplicación todos los sitios donde necesitamos hacer $emit y $on para que funcione.

A continuación los ficheros modificados y funcionando correctamente usando el event bus:

App.vue

<template>
  <div id="app" v-cloak>
    <header>
      <div v-text="header.title" class="title"></div>
      <div v-text="header.subtitle" class="subtitle"></div>
    </header>
    <section class="container">
      <div class="box-search">
        <input type="search" v-model="search">
        <a v-show="checkSearch" @click="searchFilm()"><font-awesome-icon icon="search" /></a>
        <div class="pagination" v-show="showPagination">
          <span class="reg-prev" @click="previous()">
            <font-awesome-icon icon="angle-left" />
          </span>
          <span class="total-pages" v-text="printPages"></span>
          <span class="reg-next" @click="next()">
            <font-awesome-icon icon="angle-right" />
          </span>
        </div>
      </div>
      <div class="search-internet" v-show="showSearchInternet">
        <iframe :src="queryComplete" frameborder="0"></iframe>
      </div>
      <div class="box-result">
        <film-card v-show="showFilm" :data-film="dataFilm" :no-image="noImage" @search-in-wikipedia="searchInfoWikipedia">
          <!-- slot con nombre -->
          <h1 slot="busqueda">
            Buscando.... {{search}}
          </h1>
        </film-card>
        <film-card-detail :data-detail-film="dataDetailFilm" :show-detail-film="showDetailFilm"></film-card-detail>
      </div>
      <film-modal type-error="error" :msn-modal="msnError" :show-modal="showError"></film-modal>
      <film-loading :show-loading="showloading"></film-loading>
    </section>
  </div>
</template>

<script>
import API from '@/servicios.js'
/*
 * Importando un componente de forma local.
 * Solo lo usamos donde se importa
 */
import FilmCard from '@/components/local/filmCard.vue'
import FilmCardDetail from '@/components/local/filmCardDetail.vue'
export default {
  components: {
    FilmCard,
    FilmCardDetail
  },
  name: 'app',
  data () {
    return {
      header: {
        title: 'OMDb API',
        subtitle: 'The Open Movie & TV Show Database'
      },
      showButton: false,
      search: '',
      dataFilm: '',
      showError: false,
      msnError: '',
      dataDetailFilm: '',
      showPagination: false,
      pagination: {
        pagActual: 1,
        totalRecords: 0,
        totalPages: 0
      },
      showloading: false,
      noImage: './dist/no-image.png',
      searchInternet: 'https://es.wikipedia.org/wiki/',
      showSearchInternet: false,
      query: ''
    }
  },
  created() {
    this.$bus.$on('show-loading', value => this.showloading = value)
    this.$bus.$on('set-data-film', value => this.dataFilm = value)
    this.$bus.$on('show-error', value => this.showError = value)
    this.$bus.$on('msn-error', value => this.msnError = value)
    this.$bus.$on('data-detail-film', value => this.dataDetailFilm = value)
    this.$bus.$on('show-pagination', value => this.showPagination = value)
  },
  methods: {
    searchInfoWikipedia (title) {
      this.query = `${this.searchInternet}${title}`
      this.showSearchInternet = true
    },
    searchFilm () {
      this.showSearchInternet = false
      this.showloading = true
      const params = {
        title: this.search,
        page: this.pagination.pagActual
      }
      API.getFilmData(params)
        .then(data => {
          this.showloading = false
          this.dataDetailFilm = ''
          if (data.Response === 'True') {
            this.showError = false
            this.msnError = ''
            this.dataFilm = data.Search
            this.pagination.totalRecords = data.totalResults
            this.pagination.totalPages = (data.totalResults / 10) % 1 === 0 ? parseInt((data.totalResults / 10)) : parseInt((data.totalResults / 10)) + 1
            this.showPagination = true
          } else {
            this.dataFilm = ''
            this.showError = true
            this.msnError = data.Error
            this.showPagination = false
          }
        })
        .catch(err => {
          this.showloading = false
          console.log(`Error App.vue ${err}`)
        })
    },
    previous () {
      this.pagination.pagActual = --this.pagination.pagActual < 1 ? 1 : this.pagination.pagActual--
      this.searchFilm()
    },
    next () {
      this.pagination.pagActual = ++this.pagination.pagActual > this.pagination.totalPages ? this.pagination.totalPages : this.pagination.pagActual++
      this.searchFilm()
    }
  },
  computed: {
    queryComplete () {
      return this.query
    },
    checkSearch () {
      return this.search.length === 0 ? false : true
    },
    showFilm () {
      return this.dataFilm === '' ? false : true
    },
    showDetailFilm () {
      return this.dataDetailFilm === '' ? false : true
    },
    printPages () {
      return `${this.pagination.pagActual} de ${this.pagination.totalPages}`
    }
  }
}
</script>

<style lang="scss">
  @import url('../node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css');
  @media (min-width: 1200px) {
    ul {
      li {
        width: 48%;
      }
    }
  }
  @media (max-width: 1199px) {
    ul {
      li {
        width: 100%;
      }
    }
  }
  .box-search {
    border: 1px solid #cccccc;
    padding: 10px;
    text-align: left;
    input[type='search'] {
      width: 60%;
    }
    .box-search button {
      float: right;
    }
  }
  [v-cloak] {
    display: none;
  }
  .pagination {
    float: right;
    width: 100px;
    text-align: center;
    padding: 3px;
    span {
      cursor: pointer;
    }
  }
  header {
    box-shadow: 0px 0px 30px rgba(0,0,0,0.15);
    padding: 10px;
  }
  .container {
    box-shadow: 0px 0px 30px rgba(0, 0, 0, 0.15);
    margin-top: 20px;
    padding: 30px;
    min-height: 500px;
  }
  .title {
    font-size: 3em;
  }
  .subtitle {
    font-size: 1em;
  }
  #app {
    font-family: 'Avenir', Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-align: center;
    color: #2c3e50;
    min-width: 850px;
  }
  h1, h2 {
    font-weight: normal;
  }
  iframe {
    width: 100%;
    height: 500px;
    box-shadow: 0px 0px 3px;
    margin-top: 20px;
  }
</style>

filmCard.vue

<template>
  <div>
    <h1 class="busqueda">
      <slot name="busqueda">
        No se ha realizado ninguna búsqueda
      </slot>
    </h1>
    <ul>
      <li v-for="(film, index) in dataFilm" :key="index">
        <div :class="film.Poster !== 'N/A' ? 'image-film' : 'image-film border'"><img :src="film.Poster !== 'N/A' ? film.Poster : noImage" alt=""></div>
        <div class="image-data">
          <table>
            <tr>
              <th>ID<font-awesome-icon icon="info-circle" @click="searchDetailFilm(film.imdbID, 'id')" class="icon-right"/></th>
              <td v-text="film.imdbID"></td>
            </tr>
            <tr><th>Title</th><td v-text="film.Title"></td></tr>
            <tr><th>Year</th><td v-text="film.Year"></td></tr>
            <tr><th>Type</th><td v-text="film.Type"></td></tr>
            <tr>
              <th>Poster</th>
              <td>
                <a :href="film.Poster" target="_blank" class="btn-download">
                  <font-awesome-icon icon="download" /> Poster
                </a>
                <a class="btn-download" @click="searchInfoWikipedia(film.Title)">
                  <font-awesome-icon icon="wifi" /> Wikipedia
                </a>
              </td>
            </tr>
          </table>
        </div>
      </li>
    </ul>
  </div>
</template>
<!-- -->
<script>
  import API from '@/servicios.js'
  export default {
    props: ['dataFilm','noImage'],
    data () {
      return {
      }
    },
    methods: {
      searchInfoWikipedia(title) {
        this.$emit('search-in-wikipedia', title);
      },
      imagePreview() {
        /*
         * this = instancia de Vue, y en nuestro plugin en el prototype definimos $bus
         * $bus = tenemos una instancia de Vue diferente y la aprovechamos para los eventos
         */
        this.$bus.$emit('image-preview')
      },
      searchDetailFilm (value, type) {
        this.$bus.$emit('show-loading', true)
        const params = {
          [type]: value
        }
        API.getDetailFilmData(params)
          .then(data => {
            this.$bus.$emit('show-loading', false)
            this.$bus.$emit('set-data-film', '')
            this.$bus.$emit('show-pagination', false)
            if (data.Response === 'True') {
              this.$bus.$emit('show-error', false)
              this.$bus.$emit('msn-error', '')
              this.$bus.$emit('data-detail-film', data)
            } else {
              this.$bus.$emit('data-detail-film', '')
              this.$bus.$emit('show-error', true)
              this.$bus.$emit('msn-error', data.Error)
            }
          })
          .catch(err => {
            this.$bus.$emit('show-loading', false)
            console.log(`Error App.vue ${err}`)
          })
      }
    }
  }
</script>
<!-- -->
<style scoped lang="scss">
  h1.busqueda {
  text-align: left;
}
ul {
  li {
    text-align: left;
    img {
      box-shadow: 0px 0px 30px rgba(0,0,0,1);
      height: 450px;
      width: 300px;
    }
  }
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
.image-data {
  border: 1px solid #cccccc;
  float: left;
  width: calc(100% - 332px);
  height: 448px;
  overflow-y: scroll;
}
table {
  width: 100%;
  tr {
    td, th {
      border: 1px solid #cccccc;
      padding: 5px;
      margin: 0;
      text-align: left;
    }
  }
}
.border {
  border: 1px solid #cccccc;
  background-image: url('../../assets/no-image.png');
  background-repeat: no-repeat;
  background-position: center;
  width: 298px !important;
}
.image-film {
  width: 300px;
  float: left;
  margin: 0 20px 20px 0;
  height: 450px;
}
a {
  cursor: pointer;
  text-decoration: none;
}
.icon-right {
  float: right;
  cursor: pointer;
}
.btn-download {
  background: #0095ff;
  padding: 5px 20px 4px 20px;
  font-size: 11px;
  text-align: center;
  color: #ffffff;
  border-radius: 3px;
}
</style>

filmCardDetail.vue

<template>
  <ul v-show="show">
    <div :class="dataDetailFilm.Poster !== 'N/A' ? 'image-film' : 'image-film border'">
      <img :src="dataDetailFilm.Poster" alt="">
    </div>
    <div class="image-data">
      <table>
        <tr><th>Title</th><td v-text="dataDetailFilm.Title"></td></tr>
        <tr><th>Year</th><td v-text="dataDetailFilm.Year"></td></tr>
        <tr><th>Rated</th><td v-text="dataDetailFilm.Rated"></td></tr>
        <tr><th>Released</th><td v-text="dataDetailFilm.Released"></td></tr>
        <tr><th>Runtime</th><td v-text="dataDetailFilm.Runtime"></td></tr>
        <tr><th>Genre</th><td v-text="dataDetailFilm.Genre"></td></tr>
        <tr><th>Director</th><td v-text="dataDetailFilm.Director"></td></tr>
        <tr><th>Writer</th><td v-text="dataDetailFilm.Writer"></td></tr>
        <tr><th>Actors</th><td v-text="dataDetailFilm.Actors"></td></tr>
        <tr><th>Plot</th><td v-text="dataDetailFilm.Plot"></td></tr>
        <tr><th>Language</th><td v-text="dataDetailFilm.Language"></td></tr>
        <tr><th>Country</th><td v-text="dataDetailFilm.Country"></td></tr>
        <tr><th>Awards</th><td v-text="dataDetailFilm.Awards"></td></tr>
        <tr>
          <th>Ratings</th>
          <td>
            <tr>
              <td v-for="(rating, index) in dataDetailFilm.Ratings" :key="index">
                <b>Source:</b>&nbsp;{{rating.Source}}<br>
                <b>Value:</b>&nbsp;{{rating.Value}}<br>
              </td>
            </tr>
          </td>
        </tr>
        <tr><th>Metascore</th><td v-text="dataDetailFilm.Metascore"></td></tr>
        <tr><th>imdbRating</th><td v-text="dataDetailFilm.imdbRating"></td></tr>
        <tr><th>imdbVotes</th><td v-text="dataDetailFilm.imdbVotes"></td></tr>
        <tr><th>imdbID</th><td v-text="dataDetailFilm.imdbID"></td></tr>
        <tr><th>Type</th><td v-text="dataDetailFilm.Type"></td></tr>
        <tr><th>DVD</th><td v-text="dataDetailFilm.DVD"></td></tr>
        <tr><th>BoxOffice</th><td v-text="dataDetailFilm.BoxOffice"></td></tr>
        <tr><th>Production</th><td v-text="dataDetailFilm.Production"></td></tr>
        <tr><th>Website</th><td><a :href="dataDetailFilm.Website" target="_blank">{{dataDetailFilm.Website}}</a></td></tr>
      </table>
    </div>
  </ul>
</template>
<script>
  export default {
    props: ['dataDetailFilm','showDetailFilm'],
    data () {
      return {
        showDetail: false
      }
    },
    computed: {
      show () {
        return this.showDetail ? true : false;
      }
    },
    watch: {
      showDetailFilm(newVal) {
        this.showDetail = newVal
      }
    },
    created() {
      this.$bus.$on('image-preview', () => {
        if (typeof this.showDetail === 'undefined') {
          this.showDetail = false;
        }
         this.showDetail = !this.showDetail;
      })
    }
  }
</script>
<style scoped lang="scss">
  h1.busqueda {
  text-align: left;
}
ul {
  li {
    text-align: left;
    img {
      box-shadow: 0px 0px 30px rgba(0,0,0,1);
      height: 450px;
      width: 300px;
    }
  }
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
.image-data {
  border: 1px solid #cccccc;
  float: left;
  width: calc(100% - 332px);
  height: 448px;
  overflow-y: scroll;
}
table {
  width: 100%;
  tr {
    td, th {
      border: 1px solid #cccccc;
      padding: 5px;
      margin: 0;
      text-align: left;
    }
  }
}
.border {
  border: 1px solid #cccccc;
  background-image: url('../../assets/no-image.png');
  background-repeat: no-repeat;
  background-position: center;
  width: 298px !important;
}
.image-film {
  width: 300px;
  float: left;
  margin: 0 20px 20px 0;
  height: 450px;
}
a {
  cursor: pointer;
  text-decoration: none;
}
.icon-right {
  float: right;
  cursor: pointer;
}
.btn-download {
  background: #0095ff;
  padding: 5px 20px 4px 20px;
  font-size: 11px;
  text-align: center;
  color: #ffffff;
  border-radius: 3px;
}
</style>

Como hemos podido comprobar, esta forma de comunicar los componentes nos permite ir un paso mas allá en la comunicación entre componentes.

Aunque es una buena forma para comunicar los componentes no es la forma mas recomendable si el proyecto empieza a crecer, ya que el mantenimiento usando el event bus acabaría demandando mucho tiempo, y para un mantenimiento mas controlado usaremos Vuex ( es una implementación de Flux, que es un patrón de diseño para controlar el estado de nuestras aplicaciones).

Para finalizar un vídeo con aplicación funcionando: