
La librería Nprogress nos permite emular el efecto que en su día ciertos sitios punteros (Google, YouTube, Medium) desarrollaron, que no es más que un elemento gráfico que nos da una respuesta visual cuando la transición entre páginas es demasiado lenta
Prólogo
La librería nprogress nos permite añadir un efecto gráfico a la transición entre páginas que nos permite "notar" que ha habido un cambio en nuestra web
Y cómo funciona?
Vamos a verlo y a implementar una alternativa más ligera, con Gatsby
Se trata de ver y reformular la adaptación de nprogress en Gatsby
, adaptación hecha con el plugin gatsby-plugin-nprogress
El resultado será éste
Vamos allá
Implementación en Gatsby
Mirando el gatsby-plugin-nprogress, el código es sencillo (sinónimo de bueno), elegante y corto, y si lo exploramos aquí vemos que se trata de
-
Utilizar
nprogress
tal y como nos dicen en su página de instrucciones -
E implementarlo utilizando la API de Gatsby de un modo muy concreto
export const onRouteUpdateDelayed = () => {
NProgress.start()
}
export const onRouteUpdate = () => {
NProgress.done()
}
Esto se ubica en el archivo gatsby-browser.js
En gatsby tenemos dos archivos en esa categoría, el gatsby-browser.js
y el gatsby-ssr.js
La diferencia entre los dos?
Uno se ejecuta en el cliente, otro en el servidor
Como en Gatsby no tenemos servidor, esto implica que se ejecuta cuando compilamos el código con gatsby build
Pues leer acerca de esto aquí y aquí, y en el artículo de Chris Biscardi
En este caso queremos ejecutar código cuando las rutas cambian, y esto lo podemos hacer en gatsby-browser.js
En concreto utilizamos onRouteUpdateDelayed
y onRouteUpdate
(podemos consultar la API aquí)
- El primero se ejecuta cuando el loading de la página se demora por más de 1 segundo
- El segundo se ejecuta siempre
Y con esto ya tenemos la manera de ejecutar nuestro código cada vez que una página se demore
Reformulando nprogress
Si ahora nos vamos al archivo central de nprogress vemos que lo que hace grosso modo es cargar un archivo css
en el documento
Esto es fantástico ya que nos permite simplemente modificar el archivo css
y ya lo tenemos (casi sin necesidad de tocar nada más)
Pero por otro lado podemos ver el trabajo necesario para poder entender e inyectar ese css
, un trabajo que se delega a la librería css, y eso no deja de ser algo que tendrá cierta factura computacional (aunque posiblemente insignificante)
El equilibrio eterno entre eficiencia y facilidad de uso
Aquí me planteo hacer una estructura de mínimos, más sencilla, y que me dé un resultado parecido y satisfactorio
Css
Lo primero es representar el mismo efecto, que no es más que una fila con height
mínimo que va aumentando de width
con el tiempo, simulando una especie de relación con la carga de la página
Eso nprogress
lo hace con una suerte de función random
(la tienes a continuación)
NProgress.inc = function (amount) {
var n = NProgress.status
if (!n) {
return NProgress.start()
} else if (n > 1) {
return
} else {
if (typeof amount !== 'number') {
if (n >= 0 && n < 0.2) {
amount = 0.1
} else if (n >= 0.2 && n < 0.5) {
amount = 0.04
} else if (n >= 0.5 && n < 0.8) {
amount = 0.02
} else if (n >= 0.8 && n < 0.99) {
amount = 0.005
} else {
amount = 0
}
}
n = clamp(n + amount, 0, 0.994)
return NProgress.set(n)
}
}
function clamp(n, min, max) {
if (n < min) return min
if (n > max) return max
return n
}
Y lo implementa ejecutando periódicamente esto
transform: translate3d('variable-que-cambia', 0px, 0px);
transition: all 200ms ease 0s;
background: #000;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 2px;
En definitiva necesitamos añadir un elemento div
con un css
similar a este, y hacerlo sólo cuando tengamos un evento onRouteUpdateDelayed
La manera de hacerlo? Podríamos simplemente añadirlo en nuestro website gatsby
O podríamos hacerlo vía plugin
y así hacerlo más reutilizable (tienes la documentación de los plugins aquí)
Me quedo con lo primero, y lo intentaré hacer con un simple hook
Construyendo un hook
Qué quiero que haga el hook?
Quiero utilizarlo en mi
gatsby-browser
, y que se encargue de todo
Empiezo creando un archivo al que llamo useProgress.js
Y tiene que hacer las siguientes cosas:
- Devolver un objeto con las funciones
start()
ydone()
- Añadir un elemento
div
que le pasaré como argumento
Y para usarlo, algo así
const ProgressBar = styled.div`
transition: all 200ms ease 0s;
background: #000;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 2px;
`
const progress = useProgress(progressBar)
progress.start()
progress.done()
Con lo cual el esqueleto del hook sería algo así
export const useProgress = element => {
const start = () => {}
const done = () => {}
return {
start,
done,
}
}
Lo primero entonces es plantearse cómo puedo añadir el element que recibo en el virtual DOM(?)
Con gatsby
puedo hacerlo con el ancla onRenderBody
, y con el argumento setPostBodyComponents
, puedes leerlo aquí
Esta ancla sólo existe en el servidor, esto es en gatsby-ssr.js
Por lo tanto, esto nos invita a utilizar el ' hook' en ese mismo archivo
Algo así
// gatsby-ssr.js
export const onRenderBody = ({ setPostBodyComponents }) => {
const progress = useProgress(progressBar)
setPostBodyComponents([progress.element()])
}
Y la manera de cambiar ese elemento será con un useRef
En nprogress
utilizan la propiedad css transform
y translate3d
(documentación), que nos permite modificar la posición de un elemento en un espacio tridimensional
transform: translate3d('variable-que-cambia', 0px, 0px);
Pero para qué queremos 3D cuando aquí simplemente se trata de una barra que se va llenando de color?
En su lugar, vamos a cambiar simplemente el width
de la barra, y listos
Eso sí, voy a añadirle un componente css
base al hook
para que podamos tener algo por defecto y simplificar el uso del mismo
Añadiendo el elemento
Vamos a probar a ver si podemos añadir el elemento con éxito
// useProgress.js
import React, { useRef } from 'react'
import styled from 'styled-components'
const ProgressBar = styled.div`
transition: all 200ms ease 0s;
background: #000;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 2px;
& > div {
width: ${props => props.width}px;
}
`
export const useProgress = props => {
const Bar = props => {
const barRef = useRef()
return (
<ProgressBar {...props} ref={barRef}>
<div></div>
</ProgressBar>
)
}
const start = () => {}
const done = () => {}
const element = () => <Bar {...props} />
return {
start,
done,
element,
}
}
// gatsby-ssr.js
import { useProgress } from './src/components/useProgress'
export const onRenderBody = ({ setPostBodyComponents }) => {
const progress = useProgress()
setPostBodyComponents([progress.element()])
}
Y vemos que sí, que así estamos añadiendo el componente <Bar />
al documento
Ahora lo que queremos es modificar este componente cada vez que cambiemos de página, algo que hacemos como se hace con el plugin de gatsby que he comentado antes
// gatsby-ssr.js
import { useProgress } from './src/components/useProgress'
export const onRenderBody = ({ setPostBodyComponents }) => {
const progress = useProgress()
setPostBodyComponents([progress.element()])
}
// gatsby-browser.js
export const onRouteUpdateDelayed = () => progress.start()
export const onRouteUpdate = () => progress.done()
Pero esto no funcionará
El problema? que el objeto progress
no está (evidentemente) accesible
Cómo podemos hacerlo?
Lo suyo es, en lugar de utilizar onRenderBody
, utilizar wrapRootElement
, una ancla que está en gatsby-ssr
y también en gatsby-browser
Este hook, el wrapRootElement
nos permite añadir componentes que no se re-escribirán cuando el usuario cambie de ruta
Esto es perfecto por ejemplo para añadir lógicas de autentificación, o dicho de otro modo, viene a ser una suerte de useEffect
en todo nuestro website
Todo lo que esté en wrapRootElement
se ejecutará sólo una vez y quedará persistente en la aplicación, a no ser que se haga un refresco con el navegador
Pues vamos allá
// gatsby-ssr.js y gatsby-browser.js
import { useProgress } from './src/components/useProgress'
const progress = useProgress()
export const wrapRootElement = ({ element }) => (
<>
{element}
{progress.element()}
</>
)
export const onRouteUpdateDelayed = () => progress.start()
export const onRouteUpdate = () => progress.done()
Y para que esté disponible he movido progress
fuera del componente
Y ahora sólo nos falta añadir la lógica de la estética
Pero esto no funciona
Por qué no?
Perque cuando hago const progress = useProgress()
Pienso que estoy utilizando un custom hook (lógico)
Pero si quiero utilizar un useState
, entonces veo que nos salta el error de que estamos violando las normas de los hooks
Es decir, si queremos utilizar un hook
, tenemos que hacerlo dentro de un componente React
, sea un hook oficial o un custom hook
Esto es algo que no podemos hacer en gatsby-browser.js
, por lo que necesitamos repensarlo todo
- Estamos añadiendo una barra siempre con
wrapRootElement
(podríamos hacerlo sólo cuando tengamos un eventoonRouteUpdateDelayed
- Estamos queriendo eliminar la barra cuando tengamos el evento
onRouteUpdate
- Pero al no poder definir estados ni hooks en
gatsby-browser.js
, cómo podemos comunicarnos entre los dos eventos?
La solución es volver a los clásicos, al DOM original, algo que por otro lado también es lo que hace nprogress
Modificando el DOM
- Primero, añadir el elemento con
wrapRootElement
- Segundo, modificarlo con
onRouteUpdateDelayed
- Tercero, terminarlo de modificar con
onRouteUpdate
Vamos allá, y le cambio el nombre de useProgress
ya que ya no es un hook, aunque en espíritu lo sigue siendo
// DOMProgress.js
import React from 'react'
import styled from 'styled-components'
export const progress = {
element: props => (
<Bar id="kuworking_nprogress" {...props}>
<div></div>
</Bar>
),
start: () => (document.getElementById('kuworking_nprogress').firstElementChild.style.width = '50px'),
done: () => (document.getElementById('kuworking_nprogress').firstElementChild.style.width = '100%'),
}
const Bar = styled.div`
position: fixed;
top: 0;
left: 0;
width: 100%;
& > div {
transition: all 200ms ease 0s;
width: 200px;
height: 2px;
background: #000;
}
`
// gatsby-browser.js
import React from 'react'
import { progress } from './src/components/DOMProgress'
export const wrapRootElement = ({ element }) => {
return (
<>
{element}
{progress.element()}
</>
)
}
export const onRouteUpdateDelayed = () => progress.start()
//export const onRouteUpdate = () => progress.done()
export const onRouteUpdate = () => {
const wait = ms => new Promise((r, j) => setTimeout(r, ms))
;(async () => {
progress.start()
await wait(4000)
progress.done()
})()
}
Fíjate que he incluido una rutina en onRouteUpdate
para poder simular cierto delay de 4 segundos y así poder visualizar mejor lo que hacemos
Bien, pues con esto ya lo tenemos, modificamos el width
de la barra una vez accedemos a ella con el clásico getElementById
, le modificamos el estilo, y listos
Ahora nos queda pulirlo
Afinando la estética
Necesitamos dos cosas
- Que haya un incremento del
width
- Que la barra desaparezca después de cargarse la página
Lo conseguimos así
// DOMProgress.js
import React from 'react'
import styled from 'styled-components'
const percentage = 10
const ref = { value: '' }
export const progress = {
element: props => (
<Bar id="kuworking_nprogress" {...props}>
<div style={{ width: '0%' }}></div>
</Bar>
),
start: () => {
const increasing = () => {
const temp_width =
parseInt(document.getElementById('kuworking_nprogress').firstElementChild.style.width || 0) + percentage
const width = temp_width > 100 ? 100 : temp_width
document.getElementById('kuworking_nprogress').firstElementChild.style.width = width + '%'
}
ref.value = setInterval(increasing, 200)
},
done: () => {
clearInterval(ref.value)
document.getElementById('kuworking_nprogress').firstElementChild.style.width = '100%'
document.getElementById('kuworking_nprogress').firstElementChild.style.opacity = '0'
},
}
const Bar = styled.div`
position: fixed;
top: 0;
left: 0;
width: 100%;
& > div {
transition: all 1000ms ease 0s;
height: 2px;
background: #000;
}
`
Aquí utilizamos setInterval
como funciona clásicamente
Si esto fuera React
(si lo estuviéramos ahora estaría utilizando el useState
) el uso de setInterval
tiene su miga, si te interesa aquí lo tienes muy bien explicado
Ahora lo único que nos queda es darle ese matiz random ya que de momento la progresión es totalmente simétrica
Pero yo en lugar de darle ese efecto trompicones, prefiero jugar con las inercias para tener un movimiento más dinámico
Al final, mi código queda como ves
Aquí para conseguir que el navegador se "entere" de los cambios tengo que jugar con paradas en la ejecución obligatorias
Esto es así ya que sino, lo que hacen los navegadores es agrupar instrucciones y ejecutarlas todas de golpe. Esto lo evito "esperando" con el await wait
Y para sacar las curvas cubic-bezier
he utilizado herramientas como ésta o con el mismo developer tools de Chrome
// DOMProgress.js
import React from 'react'
import styled from 'styled-components'
const wait = ms => new Promise((r, j) => setTimeout(r, ms))
export const progress = {
element: props => (
<Bar id="kuworking_nprogress" {...props}>
<div></div>
</Bar>
),
start: async () => {
const st = document.getElementById('kuworking_nprogress').firstElementChild.style
st.transition = 'none' // reset para que las posiciones retornen al estado original de inmediato
await wait(100)
st.width = '0%'
st.opacity = '1'
await wait(100)
st.transition = 'width 10000ms cubic-bezier(0.010, 0.880, 0.000, 1.005)'
await wait(100)
st.width = '60%' // se mueve lentamente
},
done: async () => {
const st = document.getElementById('kuworking_nprogress').firstElementChild.style
st.width = parseInt(st.width) + 20 + '%' // necesito cambiarle el width para que se cambie la velocidad, y aprovecho para dar un brinco
st.transition = 'width 500ms cubic-bezier(1.000, -0.030, 0.990, 0.045), opacity 2000ms ease'
await wait(100)
st.width = '100%'
st.opacity = '0'
},
}
const Bar = styled.div`
position: fixed;
top: 0;
left: 0;
width: 100%;
& > div {
height: 2px;
background: #000;
}
`
// gatsby-ssr.js y gatsby-browser.js
import React from 'react'
import { progress } from './src/components/DOMProgress'
export const wrapRootElement = ({ element }) => {
return (
<>
{element}
{progress.element()}
</>
)
}
export const onRouteUpdateDelayed = () => progress.start()
export const onRouteUpdate = () => progress.done()
Y para terminar, decido que también quiero que haya un efecto visual cuando se cambia de página (no sólo cuando haya una demora)
import React from 'react'
import styled from 'styled-components'
const wait = ms => new Promise((r, j) => setTimeout(r, ms))
const reset = async st => {
st.transition = 'none'
await wait(100)
st.width = '0%'
st.opacity = '1'
await wait(100)
}
const increment = (st, i) => {
const width = parseInt(st.width) + i
st.width = (width > 100 ? 100 : width) + '%'
}
const transition_slow = async st => {
st.transition = 'width 10000ms cubic-bezier(0.010, 0.880, 0.000, 1.005)'
await wait(100)
}
const transition_fast = async st => {
st.transition = 'width 500ms cubic-bezier(1.000, -0.030, 0.990, 0.045), opacity 2000ms ease'
await wait(100)
}
export const progress = {
element: props => (
<Bar id="kuworking_nprogress" {...props}>
<div></div>
</Bar>
),
start: async () => {
const st = document.getElementById('kuworking_nprogress').firstElementChild.style
await reset(st)
await transition_slow(st)
st.width = '60%'
},
done: async () => {
const st = document.getElementById('kuworking_nprogress').firstElementChild.style
await reset(st)
increment(st, 20)
await transition_fast(st)
st.width = '100%'
st.opacity = '0'
},
}
const Bar = styled.div`
position: fixed;
top: 0;
left: 0;
width: 100%;
& > div {
height: 2px;
background: #000;
}
`
Y listos
🙋♂️
Lista de correo: escribo algo de tanto en cuanto