Las aplicaciones web progresivas son una solución al problema de crear apps multiplataforma, logrando hacer que nuestras soluciones web se adapten a entornos móviles y de ordenador sin inconvenientes por el sistema operativo, en este blog te dejo unos códigos que logres esta transición en tu desarrollo.
Aplicaciones Web Progresivas (PWA). Como el mismo nombre lo dicen son aplicacion web que progresan, avanzan a comportarse como aplicaciones móviles y de computadora, adoptando características de las apps nativas, tales como una instalación, splash screen, icono en pantalla de inicio y funcionales aunque no tengamos internet, solamente que dependen del navegador, siendo los más ideales Chrome y Safari, aunque dependiendo las funcionalidades a implementar algunas pueden no funcionar al 100%, también al usar un medio como el navegador pierden acceso o están muy limitadas al hardware, detalles muy importantes a tener en cuenta. Mencionado esto te dejo los códigos necesarios y notas esenciales a tener en cuenta para convertir a una PWA.
Diseño de iconos
Es importante que diseñemos un paquete de iconos de nuestra aplicación web, puede que ya tengas este punto, pero si acaso no, considera tener PNGs, uno de 500×500, uno redimensionado 512×512, una versión maskable puedes hacerlo en esta herramienta https://progressier.com/maskable-icons-editor, y el maskable resultante conviertelo a formato ico.
Añadir manifest.json
Añade en un proyecto un archivo manifest.json el cual contenga lo siguiente:
c:/xampp/htdocs/pwa/manifest.json
{
"name": "PWA App",
"short_name": "PWA App",
"icons": [
{
"src": "/pwa/favicon.png",
"sizes": "500x500",
"type": "image/png"
},
{
"src": "/pwa/favicon-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/pwa/favicon-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"id": "/pwa/?source=pwa",
"start_url": "/pwa/?source=pwa",
"display": "standalone",
"orientation": "portrait",
"background_color": "#04a3eb",
"theme_color": "#04a3eb"
}
Tal como se aprecia en el código anterior, debemos colocar la ruta completa, ubicar nuestros iconos describiendo sus dimensiones, en id y start_url incluimos un search ?source=pwa, y tomamos el código hexadecimal del color principal del icono, este código hexadecimal será utilizado posteriormente.
Agregar el icono y manifest en nuestros archivos HTML
Añade las siguiente etiquetas meta en los head de cada archivo html de tu proyecto:
c:/xampp/htdocs/pwa/*.html
<meta name="theme-color" content="#04a3eb">
<link rel="manifest" href="/pwa/manifest.json">
<link rel="icon" href="/pwa/favicon.ico" sizes="256x256">
<link rel="shortcut icon" href="/pwa/favicon.ico">
<link rel="apple-touch-icon" href="/pwa/favicon.ico">
No es obligatorio usar rutas completas, sin embargo lo recomiendo si estás usando varios HTML y están ubicados en diferentes puntos o carpetas del proyecto.
Añadir offline.html
Este tipo de páginas pueden personalizar más la experiencia, en lugar de encontrarnos con un crash de caída de internet o el dinosaurio clásico de Chrome, creamos una experiencia diferente, les dejo el siguiente ejemplo:
c:/xampp/htdocs/pwa/offline.html
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#04a3eb">
<link rel="icon" href="/pwa/favicon.ico" sizes="256x256">
<link rel="shortcut icon" href="/pwa/favicon.ico">
<link rel="apple-touch-icon" href="/pwa/favicon.ico">
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css">
<title>Se necesita internet</title>
</head>
<body>
<div class="alert alert-info" style="position: absolute; top: 37.5%; left: 12.5%; right: 12.5%; text-align: center;">
<i class="bi bi-wifi-off text-center" style="font-size: 2em;"></i>
<p class="text-center fw-bolder">Se necesita una conexión de internet estable en este momento.</p>
<div class="text-end">
<button class="btn btn-link" onclick="history.back();">Regresar</button>
<button class="btn btn-primary while-waiting" onclick="window.location = location.href;">Reintentar</button>
</div>
</div>
<script>
window.addEventListener("online", function (event) {
window.location = location.href;
})
</script>
</body>
</html>
Aquí es requerido el uso de rutas completas, porque una caída offline puede darse en cualquier búsqueda ya sea del proyecto o un 404 sin internet.
Añadir pwa-sw.js
El service worker es el que logrará la descarga de recursos del proyecto, para luego usarlos en momentos offline o sencillamente reducir la carga al servidor, en este código nos encontramos con el agregado del offline.html, definición de constante en espacios de la PWA y la definición de elementos que necesitamos funcionen offline, les dejos comentado solo lo relevante y demás código lo emplearemos en próximos posts:
c:/xampp/htdocs/pwa/pwa-sw.js
// Creamos una función para mandar a llamar la ruta completa del proyecto y poder pasar como parámetro el archivo para conformar la ruta, esto para evitar redundancia.
function asset(file) {
return `/pwa/${(file ? file : "")}`
}
function syncNotifications(reg) {}
function periodicSyncNotifications(reg) {}
function pushNotification(reg, title, body) {
if (Notification.permission !== "granted") {
console.log("info")
console.info("Sin permisos para enviar notificaciones.")
return false
}
reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: "GENERA_TU_APPLICATION_SERVER_KEY"
})
.then(function (pushSubscription) {
console.log("info")
console.info("Yey!", pushSubscription)
let data = new FormData();
data.append("sub", JSON.stringify(pushSubscription))
data.append("title", title)
data.append("body", body)
fetch(asset("web-push-push-server.php"), {
method: "POST",
body: data
})
.then(function (res) {
res.text()
})
.then(function (txt) {
console.log("log")
console.log(txt)
})
.catch(function (err) {
console.log("error")
console.error("Boo!", err)
})
})
.catch(function (err) {
console.log("error")
console.error("Boo!", err)
})
}
// Usamos un sufijo de acuerdo al nombre de nuestra aplicación web, yo he usado pwa, pero ustedes pueden considerar otro, estas constantes se volverán a utilizar.
const PRECACHENAME = "pwa-precache-v1"
const SYNCEVENTNAME = "pwa-sync-notifications"
const PERIODICSYNCEVENTNAME = "pwa-periodic-sync-notifications"
// Definición de la página offline de nuestra aplicación web.
const OFFLINEURL = asset("offline.html")
// Definimos los archivos que queremos que funcionen offline.
const PRECACHEFILES = [
// Sí o sí tenemos que incluir los iconos, manifest, index.html, ruta manifest del index.html y la página offline.
asset("favicon.ico"),
asset("favicon.png"),
asset("favicon-512x512.png"),
asset("favicon-maskable.png"),
asset("manifest.json"),
asset(),
asset("?source=pwa"),
OFFLINEURL,
// Aquí ya puedes añadir los archivos y CDNs del proyecto.
]
self.addEventListener("message", function (event) {
console.log("info")
console.info("Mensaje recibido del cliente:", event.data)
})
self.addEventListener("install", function (event) {
event.waitUntil(
(async function () {
const cache = await caches.open(PRECACHENAME)
const length = PRECACHEFILES.length
for (let x in PRECACHEFILES) {
const file = PRECACHEFILES[x]
try {
await cache.add(file)
}
catch (error) {
console.log("error")
console.error(`Error al cachear: ${file}`, error)
}
self.clients.matchAll({
includeUncontrolled: true,
type: "window"
}).then(function (clients) {
clients.forEach(function (client) {
client.postMessage({
subject: "installationProgress",
message: {
length: length,
x: x
}
})
})
})
}
})()
)
})
self.addEventListener("activate", function (event) {
// event.waitUntil()
})
self.addEventListener("fetch", function (event) {
event.respondWith(
caches.match(event.request)
.then(function (cachedResponse) {
return cachedResponse || fetch(event.request).catch(async function (err) {
if (event.request.mode === "navigate") {
const cache = await caches.open(PRECACHENAME)
const cachedResponse = await cache.match(OFFLINEURL)
return cachedResponse
}
})
})
.catch(function (err) {
console.log("error")
console.error("Boo!", err)
})
)
})
self.addEventListener("sync", function (event) {
console.log("info")
console.info("sync event", event)
if (event.tag === SYNCEVENTNAME) {
event.waitUntil(syncNotifications(registration))
}
})
self.addEventListener("periodicsync", function (event) {
console.log("info")
console.info("periodic sync event", event)
if (event.tag === PERIODICSYNCEVENTNAME) {
event.waitUntil(periodicSyncNotifications(registration))
}
})
self.addEventListener("push", function (event) {
console.log("info")
console.info(event.data)
const data = event.data.json()
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon,
image: data.image
})
})
Añadir ping.php
Puedes añadir un archivo php en blanco o puede ser un endpoint de la API de tu aplicación web, este archivo tendrá el objetivo de verificar por medio del servidor si hay conexión, así se podrán emplear los eventos javascript online y offline.
Añadir javascript con funcionalidades principales
En algun script o en el script principal añade el siguiente código, algunas funciones pueden ser utiles para próximas entregas, de momento solo les dejo comentado lo relevante.
/**
* Configuraci.n Navegaci.n y localStorage
*/
function beforeUnloadHandler(event) {
event.preventDefault()
event.returnValue = "Seguro que quieres abandonar este sitio?"
return "Seguro que quieres abandonar este sitio?"
}
function addBeforeUnloadListener() {
window.addEventListener("beforeunload", beforeUnloadHandler)
}
function removeBeforeUnloadListener() {
window.removeEventListener("beforeunload", beforeUnloadHandler)
}
function asset(file) {
return `/pwa/${(file ? file : "")}`
}
function reload(redir) {
window.location = asset((redir ? redir : ""))
}
/**
* Manejo de eventos online y offline
*/
function sincronizarOffline() {
}
function disableAll() {
const elements = document.querySelectorAll(".while-waiting")
elements.forEach(function (el, index) {
el.setAttribute("disabled", "true")
el.classList.add("disabled")
})
}
function enableAll() {
const elements = document.querySelectorAll(".while-waiting")
elements.forEach(function (el, index) {
el.removeAttribute("disabled")
el.classList.remove("disabled")
})
}
function validateOnline() {
disableAll()
const xhr = (XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject("Microsoft.XMLHttp"))
xhr.onload = function () {
enableAll()
sincronizarOffline()
}
xhr.onerror = disableAll
xhr.open("GET", PINGURL, true)
xhr.send()
}
window.addEventListener("online", function (event) {
console.log("Se reestableció la conexión")
validateOnline()
})
window.addEventListener("offline", function (event) {
console.log("Se perdió la conexión")
validateOnline()
})
/**
* Configuraci.n Service Worker
*/
function regAll(fun) {
if ("serviceWorker" in navigator) {
console.log("info")
console.info("Compatible con serviceWorker.")
regWorker(fun)
}
if ("PushManager" in window) {
console.log("info")
console.info("Compatible con PushManager.")
regSyncNotifications()
regPeriodicSyncNotifications()
}
}
async function regWorker(fun) {
// Registramos el service worker, sin este paso no funcionará nuestra aplicación web progresiva.
navigator.serviceWorker.register(asset("pwa-sw.js")).then(function (reg) {
if (typeof fun == "function") {
fun()
}
if (!("sync" in reg)) {
console.log("error")
console.error("Error registering background sync")
syncNotifications(reg)
}
console.log("info")
console.info("Yey!", reg)
navigator.serviceWorker.addEventListener("message", function (event) {
const data = event.data
const subject = data.subject
const message = data.message
if (subject == "installationProgress") {
const length = message.length
const x = parseInt(message.x) + 1
const porcentaje = x/length*100
console.log(`${x} de ${length} archivo(s) ${porcentaje.toFixed(2)} %`)
}
})
const installingWorker = reg.installing
if (installingWorker) {
addBeforeUnloadListener()
installingWorker.addEventListener("statechange", function () {
const state = installingWorker.state
if (state == "installed") {
removeBeforeUnloadListener()
}
})
}
})
}
async function regSyncNotifications() {
navigator.serviceWorker.ready.then(function (reg) {
if ("sync" in reg) {
reg.sync.register(SYNCEVENTNAME)
.then(function () {
console.log("info")
console.info("Registered background sync")
})
.catch(function (err) {
console.log("error")
console.error("Error registering background sync", err)
syncNotifications(reg)
})
}
})
}
async function regPeriodicSyncNotifications() {
navigator.serviceWorker.ready.then(function (reg) {
if ("periodicSync" in reg) {
navigator.permissions.query({
name: "periodic-background-sync"
}).then(function (status) {
console.log("info")
if (status.state === "granted") {
console.info("Periodic background sync can be used.")
reg.periodicSync.register(PERIODICSYNCEVENTNAME, {
minInterval: 24 * 60 * 60 * 1000
})
.then(function () {
console.log("info")
console.info("Registered periodic background sync")
})
.catch(function (err) {
console.log("error")
console.error("Periodic background sync cannot be used.", err)
})
}
else {
console.info("Periodic background sync cannot be used.")
}
})
}
})
}
function syncNotifications(reg) {}
function periodicSyncNotifications(reg) {}
async function unregWorker(redir, /** fun */ ) {
if (!("serviceWorker" in navigator)) {
console.log("info")
console.info("Sin soporte a serviceWorker.")
return
}
navigator.serviceWorker.ready
.then(function (reg) {
reg.unregister()
if ("periodicSync" in reg) {
reg.periodicSync.unregister(PERIODICSYNCEVENTNAME)
}
caches.delete(PRECACHENAME)
.then(function () {
if (typeof fun == "function") {
// No soportado en celular el cacheado sin recargar.
// regAll(fun)
// return
}
reload(redir)
})
.catch(function (err) {
console.log("error")
console.error("Boo!", err)
})
})
.catch(function (err) {
console.log("error")
console.error("Boo!", err)
})
}
function reinstall(redir) {
if (redir) {
}
unregWorker(redir, /** function () {
// cacheado sin recargar.
} */ )
}
regAll()
// Definimos las constantes como venían en el pwa-sw.js .
const PRECACHENAME = "pwa-precache-v1"
const SYNCEVENTNAME = "pwa-sync-notifications"
const PERIODICSYNCEVENTNAME = "pwa-periodic-sync-notifications"
// Definimos el ping.php de nuestro proyecto.
const PINGURL = asset("ping.php")
Conclusión
En este artículo les he compartido código que funcionará para una serie de publicaciones en la que buscaremos armar una aplicación web progresiva, a pesar de código que nos puede dejar con dudas si se siguen los pasos y se toman en cuenta las consideraciones tendremos nuestra aplicación web convertida a aplicación web progresiva. Espero este post les sea de utilidad y estaré al tanto de cualquier comentario, gracias por su tiempo.