
Vamos a preparar una aplicación parecida a la extensión de chrome que exploramos otro curso con vanilla JS
, pero esta vez en React
Prólogo
Se trata de conseguir una herramienta que nos muestre fondos de pantalla para inspirarnos, parecida a la que exploramos en este otro curso, pero esta vez en un entorno React
En mi caso esta herramienta la utilizo como fondo de una segunda pantalla que bastantes veces está vacía y me sirve como marco
La herramienta final la puedes encontrar kuworking.com/wallpapers, hecha en Gatsby
Aquí lo vamos a explorar en React
, y es que al final integrar cualquier aplicación React
en Gatsby
es bastante trivial
Vamos allá
Código
Básicamente, lo que quiero es lo siguiente:
- Pedir una imagen
- Después rellenar un pool de imágenes
- Mostrar las imágenes
- Que al clicar la imagen esta se cambie y se utilicen los keywords del
<input>
La mejor manera es visualizar un custom hook que nos proporcione lo que queremos
import React from 'react'
import styled from 'styled-components'
export const Wallpaper = () => {
const [background, change_background, inputRef] = useChangeBackground()
return (
<>
<Text>
<input type="text" ref={inputRef} />
</Text>
<WallpaperDiv background={background} onClick={change_background} />
</>
)
}
Con esto lo tendríamos todo
- Un
<input>
para gestionar las keywords - Un
<Wallpaper>
que nos muestre el fondo de pantalla - Y que ese mismo wallpaper nos sirva de botón para cambiar el fondo de pantalla
Ahora vamos a definir el hook, que el elefante de la habitación
Con una salvedad, y es que cuando pedimos la imagen random a unsplash ésta no nos da la imagen sino que nos devuelve la url definitiva y luego tenemos que volver a pedir esa url
const useChangeBackground = () => {
// el background actual
const [background, setBackground] = useState([])
// la lista de backgrounds
const [backgroundPool, setBackgroundPool] = useState([])
// la lista de backgrounds segunda parte (para evitar un race condition)
const [backgroundPoolTemp, setBackgroundPoolTemp] = useState([])
// los keywords utilizados
const [keywords, setKeywords] = useState('coworking')
// La referencia para el input
const inputRef = useRef()
// función para forzar un cambio de imagen
const change_background = async () => {
// creo una copia del array y si está vacío volco el contenido del array segunda parte
// .. justo en este momento si el useEffect que rellena este array segunda parte está activo
// .. puede darse un race condition que me dé imágenes repetidas
const pool = backgroundPool.length === 0 ? [...backgroundPoolTemp] : [...backgroundPool]
if (backgroundPool.length === 0) {
setBackgroundPool(backgroundPoolTemp);
setBackgroundPoolTemp([]);
}
// para saber si las keywords son nuevas o no, si lo son necesito volver a precargar las listas de imágenes
const [changed] = get_keywords()
// función para encontrar la primera imagen que no sea la actual
// siempre debería devolver la primera, pero un race condition puede evitarlo
const find_new_image = (img, arr) =>
(img === arr[0] ? find_new_image(img,[...arr.slice(1)]) : arr[0])
// si no ha cambiado cojo la primera imagen de la lista de imágenes percargadas
const image = changed ? await get_images(true) : find_new_image(background, pool)
if (changed) {
// si ha cambiado vacío las dos listas
setBackgroundPool([])
setBackgroundPoolTemp([])
} else {
// si no ha cambiado quito de la lista la imagen que he cargado
const num = pool.findIndex(el => el === image)
setBackgroundPool(pool.slice(num + 1))
}
// pongo la imagen actual en su estado
setBackground(image)
}
// Al principio necesito cargar una imagen
useEffect(() => {
;(async () => {
const image = await get_images()
setBackground(image)
})()
}, [])
// función para recuperar los keywords del <input> i de paso ver si han cambiado o no
const get_keywords = () => {
const newKeywords = inputRef.current.value.replace(/\s/g, '') || keywords
const changed = keywords !== newKeywords
if (changed) setKeywords(newKeywords)
return [changed, newKeywords]
}
// funcion para bajar una imagen, si no es forzada mira si es el mismo día para mostrar la misma durante ese día)
const get_images = async (force = false) => {
const update = force ? true : is_this_a_new_day()
const image = update ? await get_image() : window.localStorage.getItem('kw-wallpaper') || (await get_image())
return image
}
// función para conseguir la url de unsplash y ver si ya la tenemos en el cache o no
// ..por qué no utilizo la keywords del estado? Porque aquí aún no se me habrá actualizado
const get_image = async () => {
const [, keywords] = get_keywords()
const different_number = new Date().getMilliseconds()
const random_url = `https://source.unsplash.com/random/?sig=${different_number}&${keywords}`
const { url } = await get_response([fetch(random_url), wait(5000)], 5)
const request = new Request(url)
const cache = await caches.open('kw-cache')
const cached_image = await cache.match(request)
if (!cached_image) cache.add(request)
return cached_image ? cached_image.url : url
}
// rellenar el pool de imágenes
useEffect(()=>{
if (backgroundPoolTemp.length >= 10) return
(async () => {
// rellenamos una imagen cada medio segundo
await wait(500)
let temp_pool = [...backgroundPoolTemp]
const response = await get_image()
// nos aseguramos que la imagen no esté repetida dentro del pool
if (!temp_pool.includes(response) && !response.endsWith('undefined')) {
temp_pool.push(response)
temp_pool = [...new Set(temp_pool)] // eliminamos duplicados
}
setBackgroundPoolTemp([...temp_pool])
})()
},[background, backgroundPoolTemp])
// retornamos lo dicho antes
return [background, change_background, inputRef]
}
Y un par de funciones (helpers) que nos dan funcionalidades concretas
// función para esperar un tiempo con promesas
const wait = ms => new Promise((res, rej) => setTimeout(() => res('timed'), ms))
// función para determinar si el día ha cambiado o qué
const is_this_a_new_day = () => {
const last_date = Number(window.localStorage.getItem('kw-date')) || ''
const today = Number(new Date().getDay())
window.localStorage.setItem('kw-date', today)
return today === Number(last_date) ? false : true
}
// función para pedir la imagen durante un tiempo limitado
// ..para no quedarnos eternamente esperando
const get_response = async (functions, times) => {
// to state a time limit for an await function
let response = await Promise.race(functions)
let counter = 0
while (response === 'timed') {
response = await Promise.race(functions)
counter++
if (counter > times) break
}
if (response !== 'timed') return response
return false
}
En total, una estructura muy simple
Vamos a comentarlo
El componente principal <Wallpaper>
- Simplemente nos devuelve un
<input>
y un<div>
que tendrá la variable debackground
como fondo
Las funciones "extra" o helpers nos encapsulan la lógica para
wait
nos permite esperar un tiempo bloqueando la ejecuciónis_this_a_new_day
nos devuelve un booleano que nos indica si estamos en un nuevo día o no (y si es que no, repetimos la imagen)get_response
nos hace unfetch
controlado en el tiempo, porque a veces una petición se encalla y vale la pena terminarla y volver a repetir el tema
El hook useChangeBackground
se encarga de toda la lógica restante
-
Devuelve
[background, change_background, inputRef]
, es decir el estado donde se guarda el wallpaper, el método para cambiar el wallpaper, y la referencia del input -
Trabaja con tres estados principales: el
background
en sí, una lista paraalmacenar
los backgrounds pre-cargados, y otra lista para realmenteagregar
los backgrounds pre-cargados
La razón de tener dos listas aparentemente redundantes es porque la del medio me sirve como lista neutral
Es decir, cuando cambio el background con un click, este mueve un background de la lista neutral al background en sí
Mientras tanto, la lista donde se van pre-cargando backgrounds está contínuamente manteniéndose "llena" con 10 imágenes
El problema lo tengo cuando muevo ese background de la lista, ya que si en lugar de ser una lista independiente fuera la misma lista con los backgrounds pre-cargados entonces entraríamos en race conditions con lo que la misma lista se editaría desde dos sitios al mismo tiempo
En la práctica, esto quiere decir que repetiríamos imágenes básicamente porque estaríamos viendo listas previas
Total, con un array intermedio esto lo suavizo ya que sólo hago el volcado cuando ésta está vacía, y sólo es en ese momento donde hay riesgo de race conditions (lo tienes en los comentarios del código)
-
La función
change_background
se ocupa precisamente de eso, de gestionar el que se carge una nueva imagen ya sea nueva porque los keywords han cambiado, o una de las imágenes pre-cargadas -
Luego tenemos un
useEffect
con[]
que simplemente se encarga de cargar la primera imagen de todas -
Y luego tenemos el segundo
useEffect
con[background, backgroundPoolTemp]
Es decir, este useEffect
se ejecuta cada vez que background
cambia, lo cual pasa a cada click para cambiar el background
Y también cada vez que backgroundPoolTemp
cambia, que pasa siempre en ese mismo bloque excepto cuando tiene más de 10 elementos
En la práctica, esta función se ocupa de mantener siempre la lista de imágenes precargadas llena hasta 10 imágenes, y cuando ésta se vuelva a la lista neutral entonces tendríamos 20 imágenes precargadas
Listos
Lo puedes probar en el codesandbox con el código final aquí abajo
import React, { useState, useEffect, useRef } from 'react'
import styled from 'styled-components'
export const Wallpaper = () => {
const [background, change_background, inputRef] = useChangeBackground()
return (
<>
<Text>
<input type="text" ref={inputRef} />
</Text>
<WallpaperDiv background={background} onClick={change_background} />
</>
)
}
export default Wallpaper
const WallpaperDiv = styled.div`
position: absolute;
cursor: pointer;
width: 100%;
height: 100vh;
background-size: cover;
background-repeat: no-repeat;
background-position-x: center;
background-position-y: center;
background-attachment: fixed;
background-image: url('${props => props.background}');
`
const Text = styled.div`
padding-bottom: 5px;
z-index: 1;
& > input {
font-size: 1.2em;
font-weight: 700;
width: 200px;
padding: 5px;
background-color: #eed0ff;
border-radius: 3px;
border: unset;
color: #fff;
&::placeholder {
color: #fff;
}
}
`
const wait = ms => new Promise((res, rej) => setTimeout(() => res('timed'), ms))
const is_this_a_new_day = () => {
const last_date = Number(window.localStorage.getItem('kw-date')) || ''
const today = Number(new Date().getDay())
window.localStorage.setItem('kw-date', today)
return today === Number(last_date) ? false : true
}
const get_response = async (functions, times) => {
// to state a time limit for an await function
let response = await Promise.race(functions)
let counter = 0
while (response === 'timed') {
response = await Promise.race(functions)
counter++
if (counter > times) break
}
if (response !== 'timed') return response
return false
}
const useChangeBackground = () => {
// el background actual
const [background, setBackground] = useState([])
// la lista de backgrounds
const [backgroundPool, setBackgroundPool] = useState([])
// la lista de backgrounds segunda parte (para evitar un race condition)
const [backgroundPoolTemp, setBackgroundPoolTemp] = useState([])
// los keywords utilizados
const [keywords, setKeywords] = useState('coworking')
// La referencia para el input
const inputRef = useRef()
// función para forzar un cambio de imagen
const change_background = async () => {
// creo una copia del array y si está vacío volco el contenido del array segunda parte
// .. justo en este momento si el useEffect que rellena este array segunda parte está activo
// .. puede darse un race condition que me dé imágenes repetidas
const pool = backgroundPool.length === 0 ? [...backgroundPoolTemp] : [...backgroundPool]
if (backgroundPool.length === 0) {
setBackgroundPool(backgroundPoolTemp);
setBackgroundPoolTemp([]);
}
// para saber si las keywords son nuevas o no, si lo son necesito volver a precargar las listas de imágenes
const [changed] = get_keywords()
// función para encontrar la primera imagen que no sea la actual
// siempre debería devolver la primera, pero un race condition puede evitarlo
const find_new_image = (img, arr) =>
(img === arr[0] ? find_new_image(img,[...arr.slice(1)]) : arr[0])
// si no ha cambiado cojo la primera imagen de la lista de imágenes percargadas
const image = changed ? await get_images(true) : find_new_image(background, pool)
if (changed) {
// si ha cambiado vacío las dos listas
setBackgroundPool([])
setBackgroundPoolTemp([])
} else {
// si no ha cambiado quito de la lista la imagen que he cargado
const num = pool.findIndex(el => el === image)
setBackgroundPool(pool.slice(num + 1))
}
// pongo la imagen actual en su estado
setBackground(image)
}
// Al principio necesito cargar una imagen
useEffect(() => {
;(async () => {
const image = await get_images()
setBackground(image)
})()
}, [])
// función para recuperar los keywords del <input> i de paso ver si han cambiado o no
const get_keywords = () => {
const newKeywords = inputRef.current.value.replace(/\s/g, '') || keywords
const changed = keywords !== newKeywords
if (changed) setKeywords(newKeywords)
return [changed, newKeywords]
}
// funcion para bajar una imagen, si no es forzada mira si es el mismo día para mostrar la misma durante ese día)
const get_images = async (force = false) => {
const update = force ? true : is_this_a_new_day()
const image = update ? await get_image() : window.localStorage.getItem('kw-wallpaper') || (await get_image())
return image
}
// función para conseguir la url de unsplash y ver si ya la tenemos en el cache o no
// ..por qué no utilizo la keywords del estado? Porque aquí aún no se me habrá actualizado
const get_image = async () => {
const [, keywords] = get_keywords()
const different_number = new Date().getMilliseconds()
const random_url = `https://source.unsplash.com/random/?sig=${different_number}&${keywords}`
const { url } = await get_response([fetch(random_url), wait(5000)], 5)
const request = new Request(url)
const cache = await caches.open('kw-cache')
const cached_image = await cache.match(request)
if (!cached_image) cache.add(request)
return cached_image ? cached_image.url : url
}
// rellenar el pool de imágenes
useEffect(()=>{
if (backgroundPoolTemp.length >= 10) return
(async () => {
// rellenamos una imagen cada medio segundo
await wait(500)
let temp_pool = [...backgroundPoolTemp]
const response = await get_image()
// nos aseguramos que la imagen no esté repetida dentro del pool
if (!temp_pool.includes(response) && !response.endsWith('undefined')) {
temp_pool.push(response)
temp_pool = [...new Set(temp_pool)] // eliminamos duplicados
}
setBackgroundPoolTemp([...temp_pool])
})()
},[background, backgroundPoolTemp])
// retornamos lo dicho antes
return [background, change_background, inputRef]
}
Comentario
Qué le falta al código?
Hay un par de warnings avisándonos que a los useEffect
les falta la dependencia get_images
y get_image
Estas funciones no van a cambiar nunca, por lo que puedes ignorar el warning, añadirlas como dependencias, o añadir la regla eslint
para ignorar este tipo de warnings
Qué más?
Estamos utilizando useEffect
con funciones asíncronas
Esto qué quiere decir?
Que si estamos en medio de la ejecución y el componente se cancela (cuando el usuario cambia de página por ejemplo), si el código intenta cambiar un estado esto dará una fuga de memoria
Solución? Cancelar ese código
useEffect
nos da la posibilidad de ejecutar código cuando el componente se desmonte
Pues se trata simplemente de utilizar una variable para comprobar si el componente sigue activo o no
Por ejemplo
useEffect(() => {
let alive = true
;(async () => {
const image = await get_images()
if (!alive) return
setBackground(image)
})()
return () => (alive = false)
}, [])
Así evitamos el setBackground
si ya no hay componente
🙋♂️
Lista de correo: escribo algo de tanto en cuanto