Una de las cosas mas complicadas de entender y manejar en una aplicación es el estado de una aplicación. Aunque tenemos herramientas como Redux, Zustand o cualquier otra biblioteca JS que salga en los próximos minutos que te tome leer este post.
Pero este post no se trata de bibliotecas, se trata de la plataforma y como sincronizamos datos entre las diferentes tabs que pueden estar abiertas en el navegador.
El navegador administra la memoria de una tab de manera individual pero la capa de datos se administra por dominio. Tenemos varias alternativas para almacenar datos:
- LocalStorage
- Cookies
- Cache API
- IndexedDB
- File System Access API
Hay muchas formas para almacenar datos y cada una de estas tiene un propósito. Ejemplo, la Cache API esta hecha para almacenar la respuesta de requests de red, lo cual nos sirve features como el modo offline.
Antes de hablarles de como sincronizar datos entre tabs, me gustaría dejar claro algunos puntos.
- No hay mecanismo que el browser brinde de manera nativa una sincronización entre tabs.
- No toda la información debe sincronizarse.
- Normalmente usamos el servidor como fuente de la verdad y esta bien pero hacer multiples requests para tener la misma información que ya tienes en una tab me parece innecesario.
Para ello exploraremos 2 approaches:
- LocalStorage
- Cualquier Storage que uno quiera + Service Workers
LocalStorage
Esta opción es la menos recomendada porque es síncrona y ocupa el thread principal de ejecución de JS, pero siendo honestos es uno de los storages mas utilizados.
El objeto window tiene asociado un evento "storage", el cual se ejecuta cada vez que el storage cambia. Este se ejecutará en cada uno de nuestros tabs entonces con simplemente suscribirnos al evento podemos saber si algo cambió (se ejecuta en todos menos en el tab donde se ejecutó el cambio).
Vamos a hacer una página simple donde tengamos un contador y un botón para incrementar. El requerimiento es que cada vez que de click el contador se incremente se refleje en todas las tabs que tengamos abiertas.
Nota: Sugiero usar un folder y serve como servidor estático
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sync local storage</title>
</head>
<body>
<h1 id="counter">0</h1>
<button id="increase">Increase</button>
<script>
document.getElementById("increase").addEventListener("click", () => {
const counter = +localStorage.getItem("counter", 0) + 1;
document.getElementById("counter").textContent = counter;
localStorage.setItem("counter", counter);
});
window.addEventListener("storage", (event) => {
if (event.key !== "counter") return;
document.getElementById("counter").textContent = +localStorage.getItem(
"counter",
0
);
});
window.addEventListener("load", () => {
document.getElementById("counter").textContent = +localStorage.getItem(
"counter",
0
);
});
</script>
</body>
</html>
La parte importante de esta página es suscribirse al evento de cambio del localStorage.
window.addEventListener('storage', (event) => {
if (event.key !== 'counter') return;
document.getElementById('counter').textContent = +localStorage.getItem('counter', 0);
});
El evento storage tiene una propiedad llamada key, mediante la cual podemos saber que cambió, este paso es crucial porque el storage es solo uno. Entonces esta al alcance de extensiones o cualquier otro script que se ejecute en el contexto de la aplicación. En este evento podríamos poner dispatchers o lo que queramos que reflejo el cambio que queremos a nuestro state manager.
La ventaja de este approach es que es fácil, ya tenemos un sistema de eventos incluido que evita la redundancia (evita disparar el evento en la tab donde se ejecutó)
La desventaja ... es el localStorage, todos tienen acceso y es síncrono, además de que solo puedes guardar strings así que debes hacer parseos para guardar estados complejos y si quieres ejecutar acciones solo para ciertas partes de tu state manager tendrás que verificar que solo se disparen cambios si la data cambió (algunos state managers ya incluyen esta funcionalidad).
Cualquier Storage + Service Workers
Este approach es un poco más complicado pero en lo personal lo prefiero, por la simple y sencilla razón de que no es el local storage.
Para esto necesitamos una pieza de software centralizada que actue como un pubsub para la administración de eventos. En este caso usaremos un service worker ya que es una instancia compartida por todas las tabs (clients).
Las tareas del service worker serán las siguientes:
- Suscribirse a un evento que nos diga que se quiere hacer un incremento
- Hacer un update en nuestra base de datos (en este caso usaremos indexeddb)
- Mandar un evento que le indique a todos los tabs(clients) que hubo un incremento.
Este service worker cumple con ese objetivo
self.addEventListener('message', (event) => {
if (event.data === 'request_increment') {
incrementValueInDB();
}
});
function incrementValueInDB() {
const request = indexedDB.open('APPDB', 1);
request.onupgradeneeded = function(event) {
const db = event.target.result;
db.createObjectStore('count', { keyPath: 'id' });
};
request.onsuccess = function(event) {
const db = event.target.result;
const transaction = db.transaction(['count'], 'readwrite');
const objectStore = transaction.objectStore('count');
const request = objectStore.get(1);
request.onsuccess = function(event) {
let count = event.target.result?.count || 0;
count++;
const requestUpdate = objectStore.put({ count, id: 1 }, 1);
requestUpdate.onsuccess = function() {
self.clients.matchAll().then((clients) => {
clients.forEach((client) => client.postMessage('has_increment'));
});
};
};
};
}
El primer paso consiste en suscribirse a los mensajes y filtrarlos, ya que al ser una instancia compartida muchos mensajes pueden llegar.
self.addEventListener('message', (event) => {
if (event.data === 'request_increment') {
incrementValueInDB();
}
});
La segunda parte es el código donde se actualizará la base de datos. Los service workers tienen acceso a IndexedDB así que ahí hacemos el incremento (aquí depende de la API que quieran usar, por eso no hay código)
La tercera parte consiste en avisar a los demas que hubo un incremento.
self.clients.matchAll().then((clients) => {
clients.forEach((client) => client.postMessage('has_increment'));
});
Listo, con esto terminamos el service worker y ahora les comparto el código del HTML/JS
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sync local storage</title>
</head>
<body>
<h1 id="counter">0</h1>
<button id="increase">Increase</button>
<script>
document.getElementById("increase").addEventListener("click", () => {
navigator.serviceWorker.controller.postMessage('request_increment');
});
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
const request = indexedDB.open('APPDB', 1);
request.onupgradeneeded = function(event) {
const db = event.target.result;
db.createObjectStore('count', { keyPath: 'id' });
};
request.onsuccess = function(event) {
const db = event.target.result;
const transaction = db.transaction('count', 'readwrite');
const store = transaction.objectStore('count');
const getRequest = store.get(1);
getRequest.onsuccess = function(event) {
const data = event.target.result;
const count = data ? data.count : 0;
document.getElementById('counter').textContent = count;
};
};
navigator.serviceWorker.register("/sw.js").then(
function (registration) {
navigator.serviceWorker.addEventListener('message', function(event) {
if (event.data === 'has_increment') {
const request = indexedDB.open('APPDB', 1);
request.onsuccess = function(event) {
const db = event.target.result;
const transaction = db.transaction('count', 'readwrite');
const store = transaction.objectStore('count');
const getRequest = store.get(1);
getRequest.onsuccess = function(event) {
const data = event.target.result;
const count = data ? data.count : 0;
document.getElementById('counter').textContent = count;
};
};
}
});
},
function (err) {
console.log("ServiceWorker registration failed: ", err);
}
);
});
}
</script>
</body>
</html>
En la aplicación el código mas importante es la parte de enviar el evento de incrementar
document.getElementById("increase").addEventListener("click", () => {
navigator.serviceWorker.controller.postMessage('request_increment');
});
Y suscribirnos al evento "has_increment" para tomar el dato del local storage
navigator.serviceWorker.addEventListener('message', function(event) {
if (event.data === 'has_increment') {
const request = indexedDB.open('APPDB', 1);
request.onsuccess = function(event) {
const db = event.target.result;
const transaction = db.transaction('count', 'readwrite');
const store = transaction.objectStore('count');
const getRequest = store.get(1);
getRequest.onsuccess = function(event) {
const data = event.target.result;
const count = data ? data.count : 0;
document.getElementById('counter').textContent = count;
};
};
}
})
Nota: Si quieres hacer alguna modificación se cauteloso porque los service workers son difíciles de depurar
La ventaja de este enfoque es que no se bloquea el main thread de JS ya que la parte de actualización de datos se hace directamente en el worker y que es hasta cierto punto agnóstica ya que podemos usar cualquier store accesible por un worker.
La desventaja es que tiende a ser un poco complicado
Conclusión
Ambos enfoques funcionan, dependerá de la arquitectura de la aplicación el decidir cual usar
Top comments (2)
Que hay del Broadcast Channel API?
También serviría