https://d226lax1qjow5r.cloudfront.net/blog/blogposts/how-javascript-service-workers-increased-our-site-speed-by-97-5/js_service-workers.png

Cómo Javascript Service Workers aumentó la velocidad de nuestro sitio web en un 97,5

Publicado el August 10, 2023

Tiempo de lectura: 8 minutos

A continuación te explicamos cómo hemos conseguido que nuestro sitio web cargue un 97,5% más rápido utilizando service workers, cómo nos aseguramos de que los usuarios reciban siempre la versión más reciente y cómo puedes hacerlo tú también.

El sitio web del sistema de diseño de Vivid ha recorrido un largo camino en sólo un año. Empezó con unas pocas páginas básicas y ahora se ha convertido en un sitio de documentación en toda regla, que muestra ejemplos en vivo de cómo utilizar nuestro producto.

Utilizamos la división de código de forma extensiva en nuestro código, y nuestro producto incluye varios recursos estáticos como iconos y archivos CSS. Por si fuera poco, nuestra documentación utiliza iFrames para presentar nuestros componentes, cada uno llamando a sus dependencias por separado (sí, como una arquitectura Microfrontend).

Pero con el paso del tiempo, las cosas empezaron a romperse.

Comenzó con nuestro entorno de desarrollo. Después de unas cuantas recargas, se quedaba atascado.

Vivid Docs Loading Errorvivid-docs-loading-error.gif

Podíamos vivir con ello. Con lo que no podíamos vivir era con un ticket de nuestros usuarios mostrándonos lo grave que era la situación.

Chrome Performance User Ticketvivid-user-ticket.png

Sabíamos que teníamos que hacer algo al respecto. Teníamos que acelerar nuestro sitio web. He aquí cómo depuramos y arreglamos el rendimiento de nuestro sitio web.

Aumento del rendimiento

Empecemos por el final. Nuestro objetivo principal era reducir el número de llamadas al servidor. Un objetivo secundario (o extra, si se quiere) era acelerar el tiempo de carga de las páginas de nuestra aplicación. Puedes ver los resultados y juzgar por ti mismo:

Antes de

Vivid Performance Before Service Workersvivid-performance-before.png

En la imagen superior, podemos ver el perfil de las peticiones de red antes del cambio. En algunos momentos, la carga de la página tardaba 860 ms, ¡una cantidad de tiempo enorme! El tiempo medio de carga de una página era de unos 400ms. En la sección de más a la derecha, se puede ver la barra gris del sitio tratando de cargar el all.css que no se detiene. Esto hace que el sitio deje de cargarse y si el usuario intenta actualizarlo, ¡en realidad empeora la situación!

Vivid Performance Before Service Workersvivid-performance-after.png

¿Cómo lo hicimos?

Problema: El límite de conexión de Chrome

Chrome ofrece un límite de 6 conexiones simultáneas a un servidor. Lo que nos sorprendió fue el largo tiempo de espera y que las peticiones siguieran "vivas" incluso al actualizar o navegar por una página diferente.

Como teníamos casi un centenar de peticiones en cada página (dividimos el código hasta el extremo), Chrome cerraba la conexión a nuestro sitio web para todos los usuarios tras unos minutos de navegación. Así que teníamos una misión: reducir el número de archivos que Chrome necesitaba cargar.

Problema: demasiados archivos

Intento nº 1: Eliminar Prefetch

¿Cómo estábamos seguros de que teníamos demasiados archivos? Cuando abrimos el inspector de Chrome, nuestro código se veía así:

Chrome Inspector Loading Too Many Fileschrome-inspector-before-service-workers.png

Estos archivos son sólo una pequeña parte de nuestro manifiesto de archivos. Vea que también precargamos los archivos para acelerar el tiempo de carga de las páginas siguientes.

Nuestro primer paso fue detener la precarga. Aunque esto redujo el número de peticiones, no ayudó porque el prefetch sólo se producía cuando la red estaba inactiva, por lo que no tenía mucho efecto. Necesitábamos eliminar el número de scripts solicitados.

Intento número 2: Agrupar todos los archivos en un gran paquete

En nuestro proyecto, utilizamos rollup para empaquetar nuestros archivos. Nuestra configuración está pensada para dividir en código todo y dejar que los consumidores utilicen sus propios bundlers para dividir en código, empaquetar y agitar en árbol.

En este paso, sólo repasamos todos los componentes, creamos un archivo barrel, y los agrupamos todos en un gran archivo `vivid-components.js` que usamos en lugar de todas las otras etiquetas de script. Echa un vistazo a este confirmar para ver cómo lo hicimos.

Ayudó en la mayoría de las páginas pero - ¿recuerdas los iFrames? Todavía traían un montón de cosas - duplicados de los archivos ya traídos por la página superior.

Así llegamos a nuestra solución final y más eficaz: trabajadores de servicios.

Solución: Trabajadores de servicios

Un trabajador de servicio es una capa entre nuestra aplicación y la red. Puede escuchar todas las peticiones que entran y salen de la aplicación y gestionarlas.

En nuestro caso, queríamos gestionar las peticiones, almacenar en caché la respuesta y devolver la respuesta en las peticiones subsiguientes.

Cómo registrar un trabajador de servicios

El primer paso es registrar el trabajador de servicios en el cliente:

(async function() {
	const registration = await navigator.serviceWorker.register(
		'/sw.js',
		{
			scope: '/',
		}
	);
})();

El trabajador de servicio puede obtener solicitudes de acuerdo a la carpeta que lo contiene. Esta es la razón por la que lo puse en la raíz de mi proyecto.

En nuestro proyecto, el archivo real no está en la raíz. Se mueve allí en nuestro proceso de construcción, por lo que nos da una experiencia de desarrollo agradable al tiempo que nos permite obtener las solicitudes de la raíz. Puede utilizar la opción servicio-trabajador-permitido http-cabecera para servirlo en una carpeta diferente, pero usando el truco "build to root", no lo necesitamos.

Funcionalidad del Service Worker

Nuestro trabajador de servicio tiene este aspecto:

const addResourcesToCache = async (resources) => {
	const cache = await caches.open('vivid-cache');
	await cache.addAll(resources);
};

const putInCache = async (request, response) => {
	const cache = await caches.open('vivid-cache');
	await cache.put(request, response);
};

const cacheFirst = async ({ request, preloadResponsePromise, fallbackUrl }) => {
	const responseFromCache = await caches.match(request);
	if (responseFromCache) {
		return responseFromCache;
	}

	const preloadResponse = await preloadResponsePromise;
	if (preloadResponse) {
		console.info('using preload response', preloadResponse);
		await putInCache(request, preloadResponse.clone());
		return preloadResponse;
	}

	try {
		const responseFromNetwork = await fetch(request);
		await putInCache(request, responseFromNetwork.clone());
		return responseFromNetwork;
	} catch (error) {
		const fallbackResponse = await caches.match(fallbackUrl);
		if (fallbackResponse) {
			return fallbackResponse;
		}
		return new Response('Network error happened', {
			status: 408,
			headers: { 'Content-Type': 'text/plain' },
		});
	}
};

const enableNavigationPreload = async () => {
	if (self.registration.navigationPreload) {
		await self.registration.navigationPreload.enable();
	}
};

self.addEventListener('activate', (event) => {
	event.waitUntil(enableNavigationPreload());
});

self.addEventListener('install', (event) => {
	event.waitUntil(
		addResourcesToCache([
			'./',
			'./index.html',
			'/assets/styles/core/all.css',
			'/assets/scripts/vivid-components.js',
			'/assets/scripts/live-sample.js',
		])
	);
});

self.addEventListener('fetch', (event) => {
	event.respondWith(
		cacheFirst({
			request: event.request,
			preloadResponsePromise: event.preloadResponse,
			fallbackUrl: './assets/images/vivid-logo.jpeg',
		})
	);
});

Tenemos dos funciones de utilidad addResourcesToCache para añadir un recurso a la caché y putInCache para poner una petición y su respuesta en la caché.

Ambos utilizan las cachés que nos da acceso al CacheStorage .

cacheFirst es donde ocurre la magia. Intenta obtener la respuesta de la caché. Si la encuentra, devuelve la respuesta almacenada. (Líneas 12-15)

Si no, intenta obtener la respuesta de una precarga. Si funciona, estamos bien - lo almacenamos en caché y devolvemos la respuesta precargada. (Líneas 17-22)

Si esto falla, pasamos a solicitarlo a la red (por ejemplo, al servidor), obtenemos la respuesta y la almacenamos en caché. (Líneas 24-27)

Si todo falla, simplemente devolvemos un error con una imagen. (Líneas 29-35)

Ciclo de vida del trabajador de servicios

El ciclo de vida del trabajador de servicios:

  1. Registro (ya hemos pasado por eso)

  2. Instalación

  3. Activación

Nuestro trabajador de servicios escucha la fase de instalación y añade nuestros recursos principales a la caché.

self.addEventListener('install', (event) => {
	event.waitUntil(
		addResourcesToCache([
			'./',
			'./index.html',
			'/assets/styles/core/all.css',
			'/assets/scripts/vivid-components.js',
			'/assets/scripts/live-sample.js',
		])
	);
});

Fíjate en la utilidad waitUntil que obtenemos en el objeto de evento. Esta utilidad nos ayuda a evitar condiciones de carrera mientras espera a que terminen las operaciones asíncronas.

A continuación, en la fase activate activamos la precarga de contenidos (con waitUntil).

const enableNavigationPreload = async () => {
	if (self.registration.navigationPreload) {
		await self.registration.navigationPreload.enable();
	}
};

self.addEventListener('activate', (event) => {
	event.waitUntil(enableNavigationPreload());
});

El último paso es añadir un listener a fetch. Este listener intercepta las peticiones y nos permite manejarlas usando nuestra cacheFirst :

self.addEventListener('fetch', (event) => {
	event.respondWith(
		cacheFirst({
			request: event.request,
			preloadResponsePromise: event.preloadResponse,
			fallbackUrl: './assets/images/vivid-logo.jpeg',
		})
	);
});

Fíjese en la respondWith utilidad. Literalmente hace exactamente lo que dice - dada la petición, podemos devolver cualquier respuesta. En este caso, devolvemos el resultado de cacheFirst.

Cómo gestionar las versiones en un Service Worker

Puede que en algún momento quieras actualizar la versión de un service worker. En nuestro caso, es necesario para la actualización de nuestra librería. Para ello, debemos indicar la versión en el archivo de nuestro Service Worker, crear una caché versionada y borrar la caché antigua.

Cómo crear una caché versionada en un Service Worker

Añadir una versión es muy sencillo: const VERSION = ‘3.17.0’;

Esto puede cambiarse manualmente en cada versión.

En nuestro proyecto, por ejemplo, utilizamos rollup para agrupar, así que hicimos el siguiente "truco":

  1. Fijamos la versión de esta manera: const VERSION = ‘SW_VERSION’;

  2. Durante la compilación, extraemos la versión de nuestro package.json

  3. Utilizamos el plugin de reemplazo del rollup para establecer la versión en cada compilación.

Puede ver nuestra configuración aquí.

Cómo crear una caché versionada en un Service Worker

Si te has fijado, el método caches.open acepta una cadena:

const cache = await caches.open(VERSION);

Espera un nombre o id de caché que podamos consultar más tarde.

A menudo necesitarás actualizar el service worker o la caché de tu sitio web. Por ejemplo, cuando bump una versión de la biblioteca, añadir una nueva sección a la página, y cada cambio que realice en la respuesta, y quiere que se muestre sin esperar a la expiración de la caché.

Utilizar la versión como id nos permite dirigirnos a la caché de cada versión y así mostrar la versión actual y borrar las antiguas.

Cómo eliminar la caché obsoleta en un Service Worker

Ahora que tenemos la caché versionada, podemos borrarla.

Creemos una función removeOldCache:

async function removeOldCache(event) {
	await caches.keys().then(function (keys) {
		return Promise.all(keys.filter(function (key) {
			return key !== VERSION;
		}).map(function (key) {
			return caches.delete(key);
		}));
	}).then(function () {
		return self.clients.claim();
	});
}

La función repasa todas las claves de la caché (línea 2), y para cada clave que no sea la nueva versión activada, la borramos (línea 6). Una vez hecho esto, usamos el método self.clients.claim para decirle al navegador que nuestro nuevo Service Worker controla ahora todas las pestañas (línea 9).

Llamamos a esta función durante la activación:

self.addEventListener('activate', (event) => {
	event.waitUntil(removeOldCache(event));
	event.waitUntil(enableNavigationPreload());
});

Observa que la caché es la caché global para todos los service workers. En nuestro caso, podemos eliminarlos con seguridad, pero en tu caso, es posible que tengas más de una caché y por lo tanto podrías considerar el uso de un sufijo a la versión para eliminar sólo la caché que deseas.

Un ejemplo es un caso en el que querrías separar la caché HTML de la caché de peticiones al servidor. La caché HTML se borrará cuando cambie las áreas relacionadas con el cliente, mientras que la caché de peticiones al servidor variará en algún otro término.

Actualización de los Service Workers

Los nuevos Trabajadores de Servicios tienen un periodo de espera. Puede llevar unas horas reemplazarlos. Podemos saltarnos este periodo de espera añadiendo self.skipWaiting() en nuestra fase de instalación.

Ahora nuestro Service Worker se actualizará inmediatamente cuando lo cambiemos (por ejemplo, cargando una nueva versión de la librería).

Resumen

Los Service Workers son una herramienta muy potente para los desarrolladores web. Nos permiten controlar la comunicación entre el servidor y el cliente. Aquí vimos el ejemplo clásico de almacenamiento en caché de contenido, pero existen muchos más casos de uso.

Por ejemplo, si almacenamos en caché las respuestas del servidor y las devolvemos, el usuario puede seguir utilizando nuestra aplicación mientras está desconectado.

Este ejemplo de almacenamiento en caché muestra la solución a nuestro problema - y de hecho lo resuelve. Pero los Service Workers responden a muchas otras necesidades en el desarrollo. Me encantaría conocer las tuyas 🙂 Cuéntame qué estás construyendo con Service Workers en Twitter o en Slack de la comunidad de Vonage¡!

Muchas gracias a Oria Biton y Miki Ezra Stanger por la amable y minuciosa revisión de este artículo

Compartir:

https://a.storyblok.com/f/270183/400x400/7bf76cb05c/yonatankra.png
Yonatan KraArquitecto de software de Vonage

Yonatan ha estado involucrado en algunos proyectos impresionantes en la academia y la industria - desde C / C ++ a través de Matlab a PHP y javascript. Fue director de tecnología en Webiks y arquitecto de software en WalkMe. Actualmente es arquitecto de software en Vonage e instructor de egghead.