Programación usando VueJS (Vue Router). Parte 7

Vue Router

Una de las ventajas que ofrece VueJS, es que según necesitemos nuevas características este nos permitirá mediante módulos ir ampliando y aumentado su potencia.

Una de esos módulo que ofrece VueJS es el Vue Router, dotando a VueJS de un sistema de rutas en el cliente pudiendo crear SPA (Single Page Aplicactión) conocidas como aplicaciones de una sola página.

Algunas de las características que nos ofrece Vue Router son las siguientes:

  • Permite crear rutas anidadas.
  • Es modular, haciendo que cada ruta sea un componente.
  • Permite crear rutas con parámetros.
  • Permite aplicar transiciones a las rutas.
  • Permite trabajar con el modo historial HTML5

Ahora ya sabemos que es Vue Router, así que procedemos a realizar la instalación y la configuración para nuestro proyecto.

Para instalar este módulo usaremos npm con el siguiente comando: npm i -S vue-router.

Comenzamos con el código

Cuando la instalación termine, abriremos el archivo main.js en nuestro proyecto para realizar la importación y configuración de Vue Router.

main.js

import Vue from 'vue'
// Importamos Vue router
import VueRouter from 'vue-router'
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'
// Importamos las rutas
import routes from '@/routes'

/*
 * 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)
Vue.use(VueRouter)

// Declaramos una instancia de Vue-router con las rutas
const router = new VueRouter({ routes })

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

// Pasamos Vuerouter a la instancia global
new Vue({
  el: '#app',
  render: h => h(App),
  router
})

Lo siguiente que debemos hacer es crear un fichero que se llame por ejemplo routes.js, donde definiremos las rutas y asociaremos los componentes (páginas) a esas rutas.

import ScreenHome from '@/components/pages/home.vue'

const routes = [
  { path: '/', component: ScreenHome, name: 'home' }
]

export default routes

Con la instalación de Vue Router y la definición de nuestras rutas, ahora crearemos una nueva página que, sera la que hemos definido en el fichero routes.js.

Para ello y no extendernos mucho, lo que haremos es copiar el contenido de App.vue y crearemos dentro de components una nueva carpeta llamada pages con un fichero llamado home.vue que contendrá gran parte del código de App.vue.

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>
      <router-view></router-view>
    </header>
  </div>
</template>
<script>
export default {
  name: 'app',
  data () {
    return {
      header: {
        title: 'OMDb API',
        subtitle: 'The Open Movie & TV Show Database'
      }
    }
  }
}
</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>

home.vue

<template>
  <main v-cloak>
    <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>
  </main>
</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 {
      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>

En este momento y con el código modificado nuestra aplicación funciona y muestra el contenido aunque no este en App.vue.

Para que esto suceda y se muestre el contenido de esa página deberemos usar el router-view, que no es mas que un componente que forma parte de Vue Router y que nos permite renderizar dentro de un componente, componentes dinámicos en base a una url.

<router-view></router-view>

Añadiendo páginas para la navegación

Bien, con lo todo lo anterior ya estamos viendo como funciona el sistema de rutas de VueJS, y acabamos de crear nuestra primera SPA, pero es un ejemplo muy sencillo sin ningún tipo de programación, salvo por la primera carga de home.vue que por defecto le dijimos que para la ruta ‘/‘ use el componente home.vue.

Para probar la navegación, vamos a crear una nueva página que llamaremos contact.vue, y la usaremos para añadir unos datos de contacto.

contact.vue

<template>
  <div class="mapouter">
    <h1>Donde estamos</h1>
    <div class="gmap_canvas">
      <iframe id="gmap_canvas" src="https://maps.google.com/maps?q=madrid&t=k&z=19&ie=UTF8&iwloc=&output=embed" frameborder="0" scrolling="no"></iframe>
    </div>
  </div>
</template>
<script>
</script>
<style lang="scss" scoped>
  h1 {
    text-align: center;
  }
  iframe {
    width: 100%;
    height: 500px;
  }
  .mapouter {
    text-align: right;
    height: 500px;
    width: 100%;
  }
  .gmap_canvas {
    overflow: hidden;
    background: none;
    height: 500px;
    width: 100%;
  }
</style>

routes.js (nueva ruta)

import ScreenHome from '@/components/pages/home.vue'
import ScreenContact from '@/components/pages/contact.vue'

const routes = [
  { path: '/', component: ScreenHome, name: 'home' },
  { path: '/contact', component: ScreenContact, name: 'contact' }
]

export default routes

Con la página creada y la nueva ruta añadida en nuestro routes.js podemos comprobar si funciona escribiendo en la url lo siguiente: http://localhost:8081/#/contact

Con esta sencilla prueba hemos comprobado como funciona la nueva ruta y se carga el contenido. Esta forma de navegar no es practica así que ahora vamos a ver de que forma podemos realizar esa navegación.

Como navegar usando Vue router

Para realizar la navegación podemos usar html o Javascript.

Primero vamos hacer la forma mas sencilla, que es usando html. 

Dentro del router-view existe un elemento llamado router-link, pues ese elemento sera el que usemos para realizar la navegación

Aprovecharemos el fichero App.vue que tiene el header, y allí añadiremos unos elementos html para realizar la navegación.

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>
      <nav>
        <router-link to="/">Home</router-link>
        <router-link to="contact">Contacto</router-link>
      </nav>
    </header>
    <router-view></router-view>
  </div>
</template>
<script>
export default {
  name: 'app',
  data () {
    return {
      header: {
        title: 'OMDb API',
        subtitle: 'The Open Movie & TV Show Database'
      }
    }
  }
}
</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;
  }
  header nav a {
    margin: 10px;
    text-decoration: none;
    color: #000000;
    transition: 300ms;
    padding: 11px;
  }
  header nav {
    margin: 10px;
    padding: 10px;
    box-shadow: 0 0px 10px rgba(0,0,0,0.3);
  }
  header nav a:hover {
    background: #50baec;
    transition: 300ms;
  }
</style>

Ahora que sabemos crear una navegación mediante elementos html, probaremos como realizar la navegación con Javascript.

Lo primero que haremos es crear un componente nuevo llamado showPoster.vue, el cual mostrara solo la portada de la película.

showPoster.vue

<template>
  <div v-show="img" class="container-poster">
    <img :src="img" alt="">
  </div>
</template>
<script>
import API from '@/servicios.js'
  export default {
    data() {
      return {
        img: {
          type: String,
          default: null
        }
      }
    },
    created() {
      const params = {
        id: this.$route.params.idPelicula
      }
      API.getDetailFilmData(params).then(data => {
        this.img = data.Poster;
      });
    }
  }
</script>
<style lang="scss" scoped>
  .container-poster {
    box-shadow: 0px 0px 10px rgba(0,0,0,0.3);
    margin: 10px 20px 0 20px;
    padding: 10px;
  }
  img {
    box-shadow: 0px 0px 5px rgba(0,0,0,0.3);
  }
</style>

También deberemos añadir una nueva ruta a nuestro fichero routes.js

routes.js

import ScreenHome from '@/components/pages/home.vue'
import ScreenContact from '@/components/pages/contact.vue'
import ShowPoster from '@/components/pages/showPoster.vue'

const routes = [
  { path: '/', component: ScreenHome, name: 'home' },
  { path: '/contact', component: ScreenContact, name: 'contact' },
  { path: '/poster/:idPelicula', component: ShowPoster, name: 'poster' }
]

export default routes

Con el código anterior lo único que hemos creado es el componente y la lógica necesaria para mostrar el componente, ahora toca realizar la navegación mediante código.

En el siguiente código veremos que la navegación mediante código es muy simple, usaremos el objeto $router para realizar navegación.

Es importante no confundir con el otro objeto $route, ya que este objeto almacena la información de la ruta, parámetros, etc..

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 class="btn-download" :href="film.Poster" target="_blank">
                  <font-awesome-icon icon="download" /> Poster
                </a>
                <a class="btn-download" @click="searchInfoWikipedia(film.Title)">
                  <font-awesome-icon icon="wifi" /> Wikipedia
                </a>
                <a class="btn-download" @click="seePoster(film.imdbID)">
                  <font-awesome-icon icon="eye"/> Poster
                </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);
      },
      seePoster(id) {
        this.$router.push({
          name: 'poster', params: { idPelicula: id }
        })
      },
      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>

History mode de HTML 5

Para terminar esta entrada sobre Vue router, veremos el concepto de history mode de html5. Vue router no usa por defecto el history mode html5, lo que hace es emularlo pero no lo activa.

La forma de activarlo es bastante sencilla, solo tenemos que añadir mode: ‘history’ a la instancia del Vue router dentro del main.js. 

Con esto ya tendrías activado el history mode de html 5 y ademas desaparece la almohadilla (#) de la ruta.

import Vue from 'vue'
// Importamos Vue router
import VueRouter from 'vue-router'
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'
// Importamos las rutas
import routes from '@/routes'

/*
 * 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)
Vue.use(VueRouter)

// Declaramos una instancia de Vue-router con las rutas
const router = new VueRouter({
  routes,
  mode: 'history'
})

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

// Pasamos Vuerouter a la instancia global
new Vue({
  el: '#app',
  render: h => h(App),
  router
})

Como el proyecto esta bastante avanzado voy a añadir un enlace al repositorio en github y el enlace a la aplicación funcionando aquí.