Creación y Uso de Componentes en Vue.js: MainBlock y TopHeroes

Clase 17 de 27Curso Avanzado de Vue.js 2

Como anunciamos en la lección anterior, ya tenemos todo el material para crear nuestros componentes.

MainBlock

El primer componente que vamos a crear se llama MainBlock y como va a ser un componente que a su vez va a tener muchos componentes hijos, vamos a crear una carpeta y dentro el componente (Index.vue, como siempre). Creamos el componente en /views/Profile/MainBlock/Index.vue. La prop que le vamos a pasar la llamaremos profileData y será de tipo objeto y requerido.
Con esto en mente, el contenido debería ser el siguiente:

<template> <div> <h1>Main Block</h1> </div> </template> <script> export default { name: 'MainBlock', props: { profileData: { type: Object, required: true } } } </script> <style lang="stylus"> </style>

Ahora desde el componente padre (es decir /views/Profile/Index.vue) podemos pasarle los datos como props. En este caso le vamos a pasar el valor de profileData, que es dónde acabamos de guardar la información traída de la API.
Como siempre los pasos serán:

  • Importar componente
  • Dar de alta
  • Usar el componente
import MainBlock from './MainBlock/Index' ... components: { BaseLoading, MainBlock }, ... <MainBlock :profile-data="profileData"/>

> 📗 Puedes leer más acerca de las props de Vue desde aquí: https://vuejs.org/v2/guide/components-props.html

En el componente MainBlock hemos definido una propiedad llamada profileData. Desde el padre se puede usar de 2 formas:

  • :profile-data="profileData"
  • :profileData="profileData"

Elije la forma que más te guste e intenta no mezclar ambos estilos, para mantener la consistencia a lo largo del proyecto. En este caso, usaremos la primera:

<MainBlock :profile-data="profileData"/>

Comprobemos que funciona y que el hijo tiene los datos correspondientes. Vamos al navegador, y, si abrimos la consola, vemos que tenemos un error. Seguramente te pase a menudo y vamos a ver como solucionarlo. El error es el siguiente:

error-if

> Invalid prop: type check failed for prop "profileData". Expected Object, got Null

Esto ocurre porque nuestro componente tiene definida la propiedad (profileData) que espera un valor de tipo objeto y le está llegando un null. Nos está avisando.
Para resolverlo lo que vamos a hacer es indicarle a nuestro componente que se renderice cuando tenga los datos de la API, de esta forma no deberíamos ver el error en consola.

El HTML completo del componente Profile (/views/Profile/Index.vue) se vería así:

<template> <div> <BaseLoading v-if="isLoading"/> <template v-if="profileData !== null"> <MainBlock :profile-data="profileData"/> </template> </div> </template>

En Vue, podemos usar la etiqueta <template></template> para agrupar contenido dentro. La etiqueta template no renderiza nada en el HTML, por lo que es perfecta para este caso, cuando no queremos añadir una capa (div) extra a nuestro código.

main-block-1

Ya tenemos los datos cargados en nuestro componente hijo y no tenemos ningún error en consola.

MainBlock: Componentes Hijos y CSS

Dentro de este componente, como ya vimos en una imagen anteriormente, vamos a crear varios componentes que van a convivir en un Grid de CSS que vamos a crear ahora mismo.

Nuestro grid se divide en 2 bloques principales: bloque izquierdo y bloque derecho.

  • En el izquierdo vamos a tener un listado de héroes, los últimos héroes jugados y el progreso en la historia del juego.
  • En el derecho mostraremos algunas de las estadísticas del jugador como: número de bichos normales y jefes asesinados, tiempo de juego por personaje, etc.

Empecemos con el CSS:

.grid-container display grid grid-template-columns 1fr .grid-item &.item-left grid-column span 1 &.item-right grid-column span 1 @media (min-width: 992px) .grid-container display grid grid-template-columns repeat(6, 1fr) .grid-item &.item-left grid-column span 4 &.item-right grid-column span 2

Lo que me gusta de Stylus es que el código te queda muy limpio, sin puntos o llaves, solo trabajando con la tabulación.

A modo resumen, lo que estamos haciendo es crear una vista para pantallas pequeñas (hasta 992px) con una sola columna, donde el contenido de la 'izquierda' se verá primero y debajo el del bloque de la 'derecha'. A partir de 992px tendremos 6 columnas, de las cuales 4 irán para el bloque de la izquierda y 2 para el bloque de la derecha.

> 📗 Si te interesa el tema de CSS Grid, te recomiendo que uses el navegador de Firefox para desarrolladores (https://www.mozilla.org/es-ES/firefox/developer/) que tiene muy buenas herramientas para trabajar con CSS Grid. Además tienen una guía muy chula de CSS Grid.
> firefox-dev

El HTML de nuestro Grid quedaría así:

<template> <div class="grid-container"> <div class="grid-item item-left"> <h1>Izquierda</h1> </div> <div class="grid-item item-right"> <h1>Derecha</h1> </div> </div> </template>

Y deberías verlo de esta forma:

> grid-1

Empecemos con el primer componente del bloque de la derecha.

Top Heroes

En este componente vamos a pintar los 3 primeros elementos (en caso de que haya alguno) que nos lleguen en el array de héroes de profileData. Para ver el potencial de nuestro CSS global, vamos a crear unas clases de CSS globales para pintar la cara de nuestros posibles personajes. Son 7 tipos de personajes (bárbaro, médico brujo, cazador de demonios, mago, monje, nigromante y cruzado) y hay 2 géneros (masculino y femenino), por lo tanto vamos a tener 14 imágenes distintas que procesar con CSS.

Para hacerlo más profesional, (porque esto es lo que somos, profesionales 😏), vamos a usar Sprites CSS.
Aunque no está dentro del scope del curso, vamos a explicar brevemente que es eso de los Sprites CSS.

Un sprite de imagen es una colección de imágenes puestas en una sola imagen. Es bastante habitual usar esta técnica. Seguro que, si inspeccionas alguna web conocida, te des cuenta de que estan usando sprites CSS en alguna parte de su sitio web.

> Te propongo buscar ejemplos de sprites de sitios web

Para nuestra app lo vamos a usar en varios casos, y este es uno de ellos. Es decir, en vez de tener 14 imágenes con las caras de los héroes, vamos a tener una sola imagen con las 14 caras.
¿Qué ganamos con esto? Por un lado reducir el número de peticiones al servidor, por otro lado agrupar contenido. Piensa en qué es lo que haces con el JavaScript y con el CSS cuando lo compilas o minimizas: lo sueles dejar en un solo archivo. Esto es similar, pero con imágenes.

Vamos a cargar la imagen correspondiente a las caras de los personajes y despues vamos a jugar con la propiedad de background-position para ir mostrando el contenido que nos interesa.
La imagen que nos interesa está en: /src/assets/img/heroes-faces.png.

Sprites CSS

Volvamos al fichero de CSS global en /src/assets/css/main.styl y agreguemos, a lo que ya teníamos, el siguiente código:

// --------------------- // Sprite image · Heroes faces // --------------------- .hero-barbarian, .hero-witch-doctor, .hero-demon-hunter, .hero-wizard, .hero-monk, .hero-necromancer, .hero-crusader background-image: url('../img/heroes-faces.png') background-size 203% width 136px height 106px .hero-barbarian &.male background-position 0 0 &.female background-position 100% 0 .hero-witch-doctor &.male background-position 0 16.538462% &.female background-position 100% 16.538462% .hero-demon-hunter &.male background-position 0 33.247754% &.female background-position 100% 33.247754% .hero-wizard &.male background-position 0 50% &.female background-position 100% 50% .hero-monk &.male background-position 0 66.666667% &.female background-position 100% 66.666667% .hero-necromancer &.male background-position 0 83.333333% &.female background-position 100% 83.333333% .hero-crusader &.male background-position 0 100% &.female background-position 100% 100% // Small devices (landscape phones, 576px and up) @media (min-width: 576px) .hero-barbarian, .hero-witch-doctor, .hero-demon-hunter, .hero-wizard, .hero-monk, .hero-necromancer, .hero-crusader background-size 200% width 100% height 115px // Medium devices (tablets, 768px and up) @media (min-width: 768px) .hero-barbarian, .hero-witch-doctor, .hero-demon-hunter, .hero-wizard, .hero-monk, .hero-necromancer, .hero-crusader height 161px // Large devices (desktops, 992px and up) @media (min-width: 992px) .hero-barbarian, .hero-witch-doctor, .hero-demon-hunter, .hero-wizard, .hero-monk, .hero-necromancer, .hero-crusader height 143px // Extra large devices (large desktops, 1200px and up) @media (min-width: 1200px) .hero-barbarian, .hero-witch-doctor, .hero-demon-hunter, .hero-wizard, .hero-monk, .hero-necromancer, .hero-crusader height 174px

Aquí hemos definido unas clases CSS principales (.hero-barbarian, .hero-witch-doctor, .hero-demon-hunter, .hero-wizard, .hero-monk, .hero-necromancer, .hero-crusader) para después poder agregarle modificadores (.male o .female).
Aparte, hemos añadido un par de media queries para que las caras se vean bien en todos los tamaños de pantalla.


Vamos a crear un par de componentes más para pintar nuestros Top Heroes. Creamos un directorio llamado /TopHeroes y dentro creamos 2 nuevos componentes Vue: Index.vue y TopHero.vue. Tal que así:

# src/views/Profile/MainBlock/TopHeroes/Index.vue 📂 /src └──📂 /views └──📂 /Profile └──📂 /MainBlock └──📂 /TopHeroes ├── Index.vue └── TopHero.vue

En /TopHeroes, Index.vue va a ser el listado de héroes y TopHero.vue un solo elemento de ese listado, es decir, un héroe a pintar, que se repetirá 3 veces.

TopHero

En el componente TopHero.vue, de momento, vamos a tener este contenido:

<template> <div> <h1>TopHero</h1> </div> </template> <script> export default { name: 'TopHero', props: { hero: { type: Object, required: true } } } </script>

Al componente TopHeroes, que va a pintar el listado de tres héroes, le dotamos del contenido siguiente:

<template> <div class="top-heroes"> <h1>Top Heroes</h1> </div> </template> <script> export default { name: 'TopHeroes', props: { heroes: { required: true, type: Array } } } </script>

Hemos definido una propiedad obligatoria de tipo Array llamada heroes. Por lo tanto, desde el padre (MainBlock) deberemos filtrar y sacar los tres primeros elementos del array y pasársela bajo este nombre.

Actualizamos /views/Profile/MainBlock/Index.vue:

// Importamos import TopHeroes from './TopHeroes/Index' export default { name: 'MainBlock', components: { TopHeroes }, props: { profileData: { type: Object, required: true } }, computed: { // Comprobamos que hay héroes hasHeroes () { return this.profileData.heroes.length > 0 }, // Devolvemos los 3 primeros topHeroes () { return this.profileData.heroes.slice(0, 3) } } }

>📗 Si no te queda claro que son las computed properties o propiedades computadas, piensa en que son expresiones JavaScript.
>Por ejemplo 2 + 2 es una expresión. Esto lo puedes poner en un componente o puedes crear una computed para no dejar la lógica en el HTML y llevarla al JavaScript.
>La documentación lo explica muy bien: https://vuejs.org/v2/guide/computed.html

  • Comprobamos que, en el array de héroes que nos llega del componente padre, haya elementos.
  • En caso de que haya elementos, nos quedamos con los tres primeros y se los pasamos al componente hijo para que los pinte.
<TopHeroes v-if="hasHeroes" :heroes="topHeroes"/>

Ahora tenemos que dotar de contenido a nuestro componente TopHeroes.

/views/Profile/MainBlock/TopHeroes/Index.vue

<template> <div> <h1>TopHeroes</h1> <div>{{ heroes }}</div> </div> </template> <script> export default { name: 'TopHeroes', props: { heroes: { required: true, type: Array } } } </script>

Por ahora, nuestra app web se vería así: preview-1Y la estructura de carpetas debería ser esta: folders-1Y los componentes, de abajo hacia arriba, por el momento tienen este contenido:

  • /views/Profile/MainBlock/TopHeroes/TopHero.vue
<template> <div> <h1>TopHero</h1> </div> </template> <script> export default { name: 'TopHero', props: { hero: { type: Object, required: true } } } </script>
  • /views/Profile/MainBlock/TopHeroes/Index.vue
<template> <div> <h1>TopHeroes</h1> <div>{{ heroes }}</div> </div> </template> <script> export default { name: 'TopHeroes', props: { heroes: { required: true, type: Array } } } </script>
  • /views/Profile/MainBlock/Index.vue
<template> <div class="grid-container"> <div class="grid-item item-left"> <TopHeroes v-if="hasHeroes" :heroes="topHeroes"/> </div> <div class="grid-item item-right"> <h1>Derecha</h1> </div> </div> </template> <script> import TopHeroes from './TopHeroes/Index' export default { name: 'MainBlock', components: { TopHeroes }, props: { profileData: { type: Object, required: true } }, computed: { hasHeroes () { return this.profileData.heroes.length > 0 }, topHeroes () { return this.profileData.heroes.slice(0, 3) } } } </script> <style lang="stylus"> .grid-container display grid grid-template-columns 1fr .grid-item &.item-left grid-column span 1 &.item-right grid-column span 1 @media (min-width: 992px) .grid-container display grid grid-template-columns repeat(6, 1fr) .grid-item &.item-left grid-column span 4 &.item-right grid-column span 2 </style>

Ya tenemos los tres héroes que necesitábamos en nuestro componente TopHeroes. Analicemos que datos le estamos pasando desde el padre:

preview-2El objeto que tenemos por cada uno de los tres héroes es similar a este. Vamos a estar utilizando principalmente el id, el nombre, el nivel, la clase y el género (donde 0 representa el personaje masculino y 1 al femenino).

{ "id": 129570533, "name": "ElMicroYyo", "class": "crusader", "classSlug": "crusader", "gender": 0, "level": 70, "kills": { "elites": 3529 }, "paragonLevel": 828, "hardcore": false, "seasonal": false, "dead": false, "last-updated": 1579274331 }

Ahora que tenemos claro qué es lo que podemos mostrar, vamos a iterar con la directiva v-for de Vue para recorrer el array de héroes y pintarlos por pantalla.

Vamos a traer y a dar de alta el componente hijo TopHero.vue y también vamos a hacer uso de algunas directivas (clases) de bootstrap-vue. La parte del HTML de /TopHeroes/Index.vuese vería así:

<template> <div class="top-heroes"> <!-- Título con nuestra fuente Diablo-like --> <h2 class="my-4 font-diablo">Top Heroes</h2> <!-- 3 columnas, una para cada TopHero --> <b-row> <!-- No te olvides de poner el `key` cuando uses v-for --> <b-col sm="4" v-for="hero in heroes" :key="hero.id"> <!-- TopHero con datos como prop --> <TopHero :hero="hero"/> </b-col> </b-row> </div> </template>

> 📗 ¿Por qué debes usar key?: https://vuejs.org/v2/guide/list.html#Maintaining-State

El JavaScript de este componente se vería así de simple:

import TopHero from './TopHero' export default { name: 'TopHeroes', components: { TopHero }, props: { heroes: { required: true, type: Array } } }

Y nuestro árbol de componentes, en las DevTools del navegador, se vería como esto:

preview-3Ahora queda terminar de implementar nuestro componente TopHero.vue en el cual pintaremos la cara de nuestro personaje y el nombre.
Indicaremos también que si el personaje es de temporada será representado con una hoja verde, y si está en modo Hardcore, lo representaremos con un fondo rojo.

Este componente va a tener un poco de CSS bastante simple: el nombre y la capa circular que muestre el nivel. Creamos el bloque style de nuestro componente /TopHeroes/TopHero.vue con el siguiente contenido:

.hero-portrait-wrapper .title-name color white font-weight 900 .level-circle width 26px height 26px padding 4px font-weight bold text-align center border-radius 13px background-color #15202b box-shadow 0 0 0 2px #6c757d

Para que tengas una idea, esto es lo que queremos conseguir:

hero-season-hardcore.pngEn esta imagen se ven los tres Top Heroes de los cuales dos son de temporada (🍃) y uno de ellos es Hardcore (en rojo).

> Los personajes hardcore son exactamente los mismos que los personajes normales, excepto que son mortales. > Si no has jugado a Diablo III, cuando un personaje Hardcore (HC) muere, ya no puedes volver a jugar con el: la muerte del personaje es definitiva.

Seguimos con nuestro componente, esta vez con el HTML. Copiamos el siguiente contenido:

<template> <!-- Contenedor principal --> <div class="hero-portrait-wrapper mb-5 mb-sm-0"> <!-- Avatar --> <div class="bg-secondary d-flex justify-content-center p-3 p-sm-0"> <!-- Imagen de fondo, según la clase y el género --> <div :class="heroClass"></div> </div> <div class="p-2 bg-dark"> <!-- Nombre héroe --> <!-- Si es hardcore, pintamos el fondo rojo --> <h5 class="text-truncate m-0 text-center title-name font-diablo" :class="{'bg-danger': hero.hardcore}"> {{ hero.name }} <!-- Si es condicional, pintamos la hoja verde --> <img v-if="hero.seasonal" src="@/assets/img/leaf.png" width="12px" class=""> </h5> <div class="d-flex justify-content-between border-top border-secondary pt-2 align-items-center mt-2"> <small class="elite-kills"> <!-- Jefes (Élites) asesinados --> <span class="text-monospace">{{ hero.kills.elites }}</span> Elite kills </small> <!-- Nivel. De color rojo si el héroe está muerto --> <small class="level-circle" :class="{'text-danger': hero.dead}"> {{ hero.level }} </small> </div> </div> </div> </template>

> 📗 La mayoría de clases que estamos usando en este componente (y a lo largo del proyecto) las puedes encontrar aquí: https://getbootstrap.com/docs/4.4/utilities/spacing/ > Son un conjunto de utilidades. Por ejemplo mb-0 significa margin-bottom: 0. Son clases CSS que tenemos disponibles gracias a Bootstrap.

Hasta aquí nada complicado. Como ya sabrás, todos los atributos de un elemento HTML (como el id, una class, el src de una imagen) pueden ser estáticos o dinámicos. Para hacerlos dinámicos lo único que tenemos que hacer es poner : delante del atributo. Es decir, para tener un id dinámico sería así: :id. Para una clase de CSS sería :class.
Esto nos permite pasarle una expresión (por ejemplo, el resultado de sumar 30 + 2), un método (:id="getRandomValue()" que tomará como valor el resultado retornado por dicha función) o una computed property, que ya sabes lo que son, entre otros.

> 📗 Existen varias formas de trabajar con clases. > Te dejo el enlace a la documentación oficial que lo explica muy bien y de manera sencilla.

Excepto <div :class="heroClass"></div>, lo único que estamos haciendo en nuestro componente TopHero.vue es mostrar los datos que nos manda el padre (hero.name, hero.level, etc.). Para mostrar en la web el rostro adecuado de nuestro personaje tenemos que asignarle la clase CSS correspondiente (recuerda que las clases CSS ya las creamos hace un rato).

Según lo que hemos definido en el CSS global, si queremos mostrar la cara del Bárbaro hombre tendríamos que tener la siguiente clase CSS en nuestro div:.hero-barbarian.male. Es decir, se vería así:
<div class="hero-barbarian male"></div>.
Para controlar esto, vamos a crear una propiedad computada que nos diga qué clase es la que nos corresponde según el héroe que tengamos. Necesitamos dos cosas para poder generar la clase CSS correcta:

  • El género (masculino o femenino)
  • Tipo de personaje (mago, cruzado, monje, etc.)

Podríamos crear algo como esto y añadirlo al componente:

export default { computed: { heroClass () { const gender = this.hero.gender === 0 ? 'male' : 'female' return `hero-${this.hero.classSlug} ${gender}` } } }

Este es el código del componente TopHero.vue, es decir, del componente /views/Profile/MainBlock/TopHeroes/TopHero.vue:

<template> <div class="hero-portrait-wrapper mb-5 mb-sm-0"> <div class="bg-secondary d-flex justify-content-center p-3 p-sm-0"> <!-- Bg Img --> <div :class="heroClass"></div> </div> <div class="p-2 bg-dark"> <h5 class="text-truncate m-0 text-center title-name font-diablo" :class="{'bg-danger': hero.hardcore}"> {{ hero.name }} <img v-if="hero.seasonal" src="@/assets/img/leaf.png" width="12px"> </h5> <div class="d-flex justify-content-between border-top border-secondary pt-2 align-items-center mt-2"> <small class="elite-kills"> <span class="text-monospace">{{ hero.kills.elites }}</span> Elite kills </small> <small class="level-circle" :class="{'text-danger': hero.dead}"> {{ hero.level }} </small> </div> </div> </div> </template> <script> export default { name: 'TopHero', props: { hero: { type: Object, required: true } }, computed: { heroClass () { const gender = this.hero.gender === 0 ? 'male' : 'female' return `hero-${this.hero.classSlug} ${gender}` } } } </script> <style lang="stylus"> .hero-portrait-wrapper .title-name color white font-weight 900 .level-circle width 26px height 26px padding 4px font-weight bold text-align center border-radius 13px background-color #15202b box-shadow 0 0 0 2px #6c757d </style>

Y se vería así de sensacional 😏: preview-4Prueba a cambiar los estilos de background position de las caras de los héroes desde el navegador a ver qué pasa

Este tema ha sido un poco largo, pero hemos trabajado los estilos CSS globales de los rostros de nuestros personajes, hemos visto como trabajar con Sprites CSS, CSS Grid, como asociar clases y estilos dinámicos, etc.
Además, estamos creando una buena estructura de carpetas y componentes y lo más importante, ¡estamos pintando nuestro primer componente con datos reales del juego!

Continuamos en la siguiente lectura.