Uso práctico de scoped slots con GoogleMaps

Ejemplo Base

Hay situaciones en las que usted querrá que el template dentro del slot acceda a los datos del componente hijo, que es responsable de renderizar el contenido del slot. Esto es particularmente útil cuando usted necesita libertad al crear templates personalizados que utilizen los datos y propiedades del componente hijo. Este es un caso típico para el uso de scoped slots.

Imagine un componente que configura y prepara una API externa para ser usada en otro componente, pero no está fuertemente acoplada a un template específico. Tal componente puede ser reusado en muchos lugares, renderizando diferentes templates pero usando el mismo objeto con la API específica.

Crearemos un componente (GoogleMapLoader.vue) que:

  1. Inicializará la API de Google Maps
  2. Creará los objetos google y map
  3. Expondrá estos objetos a su componente padre, donde GoogleMapLoader es utilizado

Debajo hay un ejemplo de cómo se puede lograr esto. En la siguiente sección, analizaremos el código pieza por pieza y veremos qué es lo que realmente está sucediendo.

Pero, primero, declaramos el template para GoogleMapLoader.vue:

<template>
<div>
<div class="google-map" ref="googleMap"></div>
<template v-if="Boolean(this.google) && Boolean(this.map)">
<slot
:google="google"
:map="map"
/>
</template>
</div>
</template>

Ahora, nuestro script necesita pasar algunas props al componente que nos permitan setear la API de Google Maps y el objeto Map:

import GoogleMapsApiLoader from 'google-maps-api-loader'

export default {
props: {
mapConfig: Object,
apiKey: String,
},

data() {
return {
google: null,
map: null
}
},

async mounted() {
const googleMapApi = await GoogleMapsApiLoader({
apiKey: this.apiKey
})
this.google = googleMapApi
this.initializeMap()
},

methods: {
initializeMap() {
const mapContainer = this.$refs.googleMap
this.map = new this.google.maps.Map(
mapContainer, this.mapConfig
)
}
}
}

Este es simplemente un ejemplo funcional, usted puede encontrar el ejemplo completo abajo.

Ejemplo en el mundo real: creando un componente que inicialize Google Map

1. Crear un componente que inicialize nuestro mapa

GoogleMapLoader.vue

En este template, creamos un contenedor para el mapa, que será utilizado para montar el objeto Map extraído desde la API de Google Maps.

<template>
<div>
<div class="google-map" ref="googleMap"></div>
</div>
</template>

Luego, nuestro script necesita recibir props desde su componente padre, esto nos permitirá setear el Google Map. Estas props consisten en:

import GoogleMapsApiLoader from 'google-maps-api-loader'

export default {
props: {
mapConfig: Object,
apiKey: String,
},

Después, inicializamos google y map como null:

data() {
return {
google: null,
map: null
}
},

En el método mounted instanciemos los objetos googleMapApi y Map desde googleMapsApi y asignemos nuestras propiedades google y map con los de la instancia creada:

  async mounted() {
const googleMapApi = await GoogleMapsApiLoader({
apiKey: this.apiKey
})
this.google = googleMapApi
this.initializeMap()
},

methods: {
initializeMap() {
const mapContainer = this.$refs.googleMap
this.map = new this.google.maps.Map(mapContainer, this.mapConfig)
}
}
}

Hasta ahora todo bien. Podríamos continuar añandiendo objetos al mapa (Marcadores, Líneas Polígonales, etc.) y usarlo como si fuera un componente ordinario.

Pero lo que queremos es usar nuestro componente GoogleMapLoader solamente como un inicializador que prepara el mapa - no queremos renderizar nada en él.

Para lograrlo, tenemos que permitir que el componente padre que utilizará GoogleMapLoader acceda a this.google y this.map, que se encuentran definidos dentro de nuestro componente GoogleMapLoader. Aquí es donde los scoped slots realmente brillan. Los scoped slots nos permiten exponer al componente padre propiedades definidas en el componente hijo. Esto podria parecer magia, pero no lo es; a continuación veremos como funciona.

2. Crear un componente que use nuestro componente inicializador

TravelMap.vue

En el template, renderizamos el componente GoogleMapLoader y le pasamos las props requeridas para inicializar el mapa.

<template>
<GoogleMapLoader
:mapConfig="mapConfig"
apiKey="yourApiKey"
/>
</template>

Nuestro script tag se verá de la siguiente forma:

<script>
import GoogleMapLoader from './GoogleMapLoader'
import { mapSettings } from '@/constants/mapSettings'

export default {
components: {
GoogleMapLoader
},
computed: {
mapConfig () {
return {
...mapSettings,
center: { lat: 0, lng: 0 }
}
},
},
}
</script>

Aun no hay scoped slots, así que añadamos uno.

3. Exponer las propiedades google y map a el componente padre añadiendo un scoped slot

Finalmente, podemos añadir un scoped slot que nos permitirá acceder a las propiedades del componente hijo desde el componente padre. Logramos esto añadiendo una tag <slot> en el componente hijo y pasándole las props que queremos exponer (usando la directiva v-bind o el modo abreviado :propName). Es equivalente a pasar props a un componente hijo, pero realizándolo en el tag <slot> revertimos la dirección del flujo de información.

GoogleMapLoader.vue

<template>
<div>
<div class="google-map" ref="googleMap"></div>
<template v-if="Boolean(this.google) && Boolean(this.map)">
<slot
:google="google"
:map="map"
/>
</template>
</div>
</template>

Ahora, cuando tenemos el slot en el componente hijo, en el componente padre tenemos que recibir y consumir las propiedades expuestas.

4. Recibir en el componente padre las propiedades expuestas, utilizando el atributo slot-scope

Para recibir las propiedad en el componente padre, declaramos un template y utilizamos el atributo slot-scope. Este atributo tiene acceso al objeto que contiene todas las propiedades expuestas por el componente hijo. Podemos acceder a todo el objeto o podemos desestructurar este objeto y solo acceder a lo que necesitamos.

Desestructurémoslo y obtengamos lo que necesitamos.

TravelMap.vue

<GoogleMapLoader
:mapConfig="mapConfig"
apiKey="yourApiKey"
>
<template slot-scope="{ google, map }">
{{ map }}
{{ google }}
</template>
</GoogleMapLoader>

Aunque las propiedades google y map no existen en el el scope de TravelMap, el componente tiene acceso a ellas y pueden ser usadas en el template.

Puede estar pensando ¿por qué haría cosas como esta? y ¿cuál es la utilidad de todo esto?

Los scoped slots nos permiten pasar un template al slot en vez de un elemento renderizado. Se le llama scoped slot porque tendrá acceso a ciertos datos del componente hijo, aunque este template sea renderizado en el scope del componente padre. Eso nos da la libertad de elegir el contenido del template desde el componente padre.

5. Crear un componente factory para Marcadores y Líneas Poligonales

Ahora que nuestro mapa está pronto, crearemos dos componentes factory que usaremos para añadir elementos a TravelMal.

GoogleMapMarker.vue

import { POINT_MARKER_ICON_CONFIG } from '@/constants/mapSettings'

export default {
props: {
google: {
type: Object,
required: true
},
map: {
type: Object,
required: true
},
marker: {
type: Object,
required: true
}
},

mounted() {
new this.google.maps.Marker({
position: this.marker.position,
marker: this.marker,
map: this.map,
icon: POINT_MARKER_ICON_CONFIG
})
}
}

GoogleMapLine.vue

import { LINE_PATH_CONFIG } from '@/constants/mapSettings'

export default {
props: {
google: {
type: Object,
required: true
},
map: {
type: Object,
required: true
},
path: {
type: Array,
required: true
}
},

mounted() {
new this.google.maps.Polyline({
path: this.path,
map: this.map,
...LINE_PATH_CONFIG
})
}
}

Ambos reciben la propiedad google que usamos para extraer el objeto deseado (Marcadores o Líneas Poligonales) así como map, la cual da referencia al mapa en el que queremos poner nuestro elemento.

Cada componente también acepta una prop extra para crear el elemento correspondiente. En este caso tenemos marker y path, respectivamente.

En el método mounted del ciclo de vida, creamos un element (Marcador/Línea Polígonal) y lo añadimos a nuestro mapa pasando la propiedad map al constructor del objeto.

Aún queda un paso más por hacer…

6. Añadir elementos al mapa

Utilicemos nuestros componentes factory para añadir elementos al mapa. Debemos renderizar el componente factory y pasarle los objetos google y map así la información fluye al lugar correcto.

También debemos proveer la información que requiere el elemento. En nuestro caso, este es el objeto marker con la posición del marcador y el objeto path con las coordenadas de la Línea Poligonal.

Integremos la información directamente en el template:

<GoogleMapLoader
:mapConfig="mapConfig"
apiKey="yourApiKey"
>
<template slot-scope="{ google, map }">
<GoogleMapMarker
v-for="marker in markers"
:key="marker.id"
:marker="marker"
:google="google"
:map="map"
/>
<GoogleMapLine
v-for="line in lines"
:key="line.id"
:path.sync="line.path"
:google="google"
:map="map"
/>
</template>
</GoogleMapLoader>

Necesitamos importar los componentes factory en nuestro script y preparar la información que luego le pasaremos a los marcadores y líneas:

import { mapSettings } from '@/constants/mapSettings'

export default {
components: {
GoogleMapLoader,
GoogleMapMarker,
GoogleMapLine
},

data () {
return {
markers: [
{ id: 'a', position: { lat: 3, lng: 101 } },
{ id: 'b', position: { lat: 5, lng: 99 } },
{ id: 'c', position: { lat: 6, lng: 97 } }
],
lines: [
{ id: '1', path: [{ lat: 3, lng: 101 }, { lat: 5, lng: 99 }] },
{ id: '2', path: [{ lat: 5, lng: 99 }, { lat: 6, lng: 97 }] }
],
}
},

computed: {
mapConfig () {
return {
...mapSettings,
center: this.mapCenter
}
},

mapCenter () {
return this.markers[1].position
}
},
}

Cuándo evitar este patrón

Puede parecer muy tentador crear una solución muy compleja basada en este ejemplo, pero en cierto punto podemos llegar a la situación en la que esta abstracción se convierte en una parte independiente de código viviendo en el proyecto. Si llegamos a tal punto, puede valer la pena considerar extraer esta abstracción a un componente que no utilize scoped slots.

Resumiendo

Esto es todo. Ahora podemos reusar el componente GoogleMapLoader como base para todos nuestros mapas, pasando diferentes templates a cada uno de ellos. Imagine que necesita crear otro mapa con diferentes marcadores o simplemente marcadores sin líneas poligonales. Usando este patrón resulta muy fácil, ya que sólo se requiere pasar un contenido diferente al componente GoogleMapLoader.

Este patrón no está estrictamente conectado con Google Maps; puede ser utilizado en un componente base con cualquier librería para luego exponer la API de la misma, que luego puede ser utilizada por el componente que invoca dicho componente base.