tutoriales.com

Web Workers en JavaScript: Liberando el Poder Multihilo para Mejorar el Rendimiento

Descubre cómo los Web Workers pueden transformar la capacidad de respuesta de tus aplicaciones web al permitir la ejecución de tareas intensivas sin bloquear la interfaz de usuario. Este tutorial te guiará desde los conceptos básicos hasta la implementación práctica, liberando el verdadero potencial del procesamiento paralelo en el navegador.

Intermedio15 min de lectura7 views
Reportar error

🚀 Introducción a los Web Workers: ¿Por qué son tan importantes?

JavaScript, por diseño, es un lenguaje de un solo hilo. Esto significa que todo el código JavaScript en una página web se ejecuta en un único hilo, conocido como el hilo principal (main thread). Este hilo es responsable de muchas tareas cruciales, como:

  • Manipulación del DOM
  • Manejo de eventos de usuario
  • Renderizado de la interfaz de usuario (UI)
  • Ejecución de scripts

Cuando una tarea JavaScript requiere mucho tiempo de procesamiento (por ejemplo, cálculos complejos, procesamiento de grandes volúmenes de datos o peticiones de red síncronas), el hilo principal se bloquea. Mientras el hilo principal está bloqueado, la interfaz de usuario deja de responder: los botones no funcionan, las animaciones se detienen y la página parece "congelada". Esto lleva a una mala experiencia de usuario y frustración.

💡 La Solución: Web Workers

Los Web Workers son una tecnología que permite ejecutar scripts en segundo plano, en hilos de ejecución separados del hilo principal. Esto significa que puedes realizar tareas intensivas computacionalmente sin bloquear la interfaz de usuario de tu aplicación web, manteniéndola fluida y reactiva. Los Web Workers se ejecutan en su propio contexto global, lo que les da una serie de características y limitaciones importantes.

🔥 Importante: Los Web Workers no tienen acceso directo al DOM ni a algunas API del navegador como `window` o `document`. Se comunican con el hilo principal a través de mensajes.

🛠️ Tipos de Web Workers

Existen varios tipos de Web Workers, cada uno diseñado para escenarios específicos:

  1. Dedicated Workers: Son los más comunes. Un Dedicated Worker es instanciado por un único script en el hilo principal y solo puede comunicarse con ese script. Es ideal para tareas aisladas y específicas.
  2. Shared Workers: Permiten que múltiples scripts (incluso desde diferentes ventanas, iframes o pestañas del mismo origen) se comuniquen con el mismo worker. Son útiles para gestionar una única fuente de datos o una tarea que debe ser coordinada entre múltiples partes de la aplicación.
  3. Service Workers: No son un tipo de Worker para computación intensiva, sino una capa programable entre el navegador y la red. Se utilizan para offline-first experiences, caching de recursos y notificaciones push. Están fuera del alcance principal de este tutorial, pero es bueno conocer su existencia.
  4. Audio Workers (Worklets): Diseñados para procesamiento de audio de baja latencia. También fuera del alcance.

En este tutorial, nos centraremos en los Dedicated Workers, ya que son el punto de entrada más común y potente para mejorar el rendimiento de tus aplicaciones.


⚙️ Conceptos Clave de los Web Workers

Antes de sumergirnos en el código, es crucial entender algunos conceptos:

🌐 Comunicación por Mensajes

Dado que los Workers se ejecutan en un hilo separado y no pueden acceder directamente al DOM, la comunicación entre el hilo principal y un Worker se realiza mediante un sistema de mensajes. Esto se logra con los métodos postMessage() y el evento onmessage.

  • postMessage(data): Envía un mensaje a otro hilo (del hilo principal al Worker o viceversa). data puede ser cualquier valor o objeto serializable por el algoritmo de clonación estructurada (Structured Clone Algorithm).
  • onmessage: Es un event handler que se dispara cuando el otro hilo envía un mensaje. El mensaje recibido se encuentra en event.data.

📦 Alcance Global de un Worker

Un Worker tiene su propio alcance global, que es diferente al de la ventana principal. En lugar de window, el objeto global dentro de un Worker es self (o simplemente this). Este contexto tiene acceso a:

  • XMLHttpRequest para hacer peticiones HTTP.
  • setTimeout() y setInterval() para temporizadores.
  • importScripts() para cargar scripts adicionales en el Worker.
  • La API self.close() para terminar el Worker desde dentro.

⚠️ Limitaciones

Ya mencionamos que los Workers no tienen acceso directo al DOM. Otras limitaciones incluyen:

  • No pueden acceder a la API alert() o confirm().
  • No pueden acceder a la API localStorage ni sessionStorage directamente (aunque pueden hacerlo si el hilo principal les pasa la información).
  • No pueden acceder a la API window (incluyendo document, parent, etc.).
⚠️ Advertencia: Pasar objetos complejos a través de `postMessage()` implica la clonación de esos objetos. Si los objetos son muy grandes, esto puede ser costoso en sí mismo. Para datos grandes, considera usar `transferable objects` como `ArrayBuffer` para una transferencia más eficiente (sin copia).

🧑‍💻 Implementación de un Dedicated Worker: Paso a Paso

Vamos a crear un ejemplo práctico para calcular números primos de manera intensiva sin bloquear la UI.

Paso 1: Crear el Archivo JavaScript del Worker

Primero, necesitamos un archivo JavaScript separado que contendrá la lógica de nuestro Worker. Llamémoslo worker.js.

// worker.js

function esPrimo(numero) {
    if (numero <= 1) return false;
    if (numero <= 3) return true;
    if (numero % 2 === 0 || numero % 3 === 0) return false;
    for (let i = 5; i * i <= numero; i = i + 6) {
        if (numero % i === 0 || numero % (i + 2) === 0) return false;
    }
    return true;
}

// El 'self' es el objeto global dentro del Worker
self.onmessage = function(event) {
    const limite = event.data;
    const primosEncontrados = [];

    console.log(`Worker: Iniciando búsqueda de primos hasta ${limite}...`);

    for (let i = 2; i <= limite; i++) {
        if (esPrimo(i)) {
            primosEncontrados.push(i);
        }
    }

    console.log('Worker: Búsqueda de primos finalizada.');
    // Envía el resultado de vuelta al hilo principal
    self.postMessage(primosEncontrados);
};

console.log('Worker cargado y listo para recibir mensajes.');

Explicación de worker.js:

  • esPrimo(numero): Es una función auxiliar que verifica si un número es primo. Esta es la tarea computacionalmente intensiva.
  • self.onmessage = function(event): Este es el event handler que escucha los mensajes del hilo principal. Cuando el hilo principal envía un mensaje, este código se ejecuta.
  • const limite = event.data;: event.data contiene los datos enviados desde el hilo principal (en este caso, el límite hasta donde buscar primos).
  • El bucle for realiza la búsqueda de números primos.
  • self.postMessage(primosEncontrados);: Una vez que se encuentran todos los primos, el Worker los envía de vuelta al hilo principal.

Paso 2: Crear el Archivo JavaScript del Hilo Principal

Ahora, necesitamos el código en nuestra página HTML que creará e interactuará con el Worker. Llamémoslo main.js.

// main.js

const resultadoDiv = document.getElementById('resultado');
const botonCalcular = document.getElementById('botonCalcular');
const botonUI = document.getElementById('botonUI');
const entradaLimite = document.getElementById('limite');

let worker = null;

botonCalcular.addEventListener('click', () => {
    const limite = parseInt(entradaLimite.value, 10);
    if (isNaN(limite) || limite <= 0) {
        resultadoDiv.textContent = 'Por favor, introduce un número válido y positivo.';
        return;
    }

    resultadoDiv.textContent = 'Calculando primos... por favor espera.';
    
    // Si ya existe un worker, lo terminamos y creamos uno nuevo
    // Esto es opcional, podrías reutilizarlo o incluso enviarle más mensajes
    if (worker) {
        worker.terminate();
        console.log('Worker anterior terminado.');
    }

    // 1. Crear una nueva instancia del Worker
    worker = new Worker('worker.js');
    console.log('Hilo principal: Worker creado.');

    // 2. Escuchar mensajes del Worker
    worker.onmessage = function(event) {
        const primos = event.data;
        resultadoDiv.textContent = `Encontrados ${primos.length} números primos hasta ${limite}.`;
        console.log('Hilo principal: Primos recibidos:', primos.slice(0, 10), '...'); // Mostrar solo los primeros 10
        
        // Opcional: Terminar el worker una vez que ha completado su tarea
        worker.terminate();
        worker = null; // Liberar la referencia
        console.log('Hilo principal: Worker terminado después de recibir resultado.');
    };

    // Manejo de errores del Worker (muy importante)
    worker.onerror = function(error) {
        console.error('Error en el Worker:', error);
        resultadoDiv.textContent = '¡Ocurrió un error en el cálculo!';
        worker.terminate();
        worker = null;
    };

    // 3. Enviar un mensaje al Worker para iniciar el cálculo
    worker.postMessage(limite);
    console.log(`Hilo principal: Mensaje enviado al Worker con límite: ${limite}`);
});

// Este botón simula una acción de la UI para demostrar que la página sigue activa
let contadorUI = 0;
botonUI.addEventListener('click', () => {
    contadorUI++;
    document.getElementById('estadoUI').textContent = `Acciones UI realizadas: ${contadorUI}`;
});

console.log('Script main.js cargado.');

Explicación de main.js:

  • new Worker('worker.js'): Esta es la línea clave que crea una nueva instancia de Dedicated Worker, cargando el script worker.js.
  • worker.onmessage = function(event): Este event handler se ejecuta cuando el Worker envía un mensaje de vuelta. event.data contendrá los números primos calculados.
  • worker.onerror = function(error): Es fundamental manejar errores. Si hay un error dentro del Worker, este handler en el hilo principal lo capturará.
  • worker.postMessage(limite): Envía el valor limite al Worker, iniciando la tarea.
  • worker.terminate(): Finaliza el Worker de forma inmediata. Esto libera los recursos asociados. Es una buena práctica terminar Workers cuando ya no son necesarios.
  • El botonUI y estadoUI demuestran que, incluso cuando el Worker está calculando, la UI sigue siendo interactiva.

Paso 3: Crear el Archivo HTML

Finalmente, necesitamos una página HTML para cargar nuestros scripts y mostrar la interfaz de usuario. Llamémosla index.html.

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Web Workers Demo</title>
    <style>
        body { font-family: sans-serif; margin: 20px; background-color: #f4f4f4; }
        .container { max-width: 600px; margin: auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
        h1 { color: #333; }
        input[type="number"] { padding: 8px; border: 1px solid #ccc; border-radius: 4px; width: 100%; max-width: 200px; }
        button { background-color: #007bff; color: white; padding: 10px 15px; border: none; border-radius: 4px; cursor: pointer; margin-right: 10px; }
        button:hover { background-color: #0056b3; }
        .resultado-box { background-color: #e9ecef; padding: 15px; border-radius: 4px; margin-top: 20px; min-height: 50px; display: flex; align-items: center; justify-content: center; text-align: center; color: #333; font-weight: bold; }
        .ui-status { margin-top: 20px; padding: 10px; background-color: #d4edda; border-left: 5px solid #28a745; color: #155724; }
    </style>
</head>
<body>
    <div class="container">
        <h1>✨ Demostración de Web Workers ✨</h1>
        <p>Introduce un número límite para encontrar primos. Mientras el cálculo se realiza en segundo plano, la interfaz de usuario permanecerá interactiva.</p>

        <div>
            <label for="limite">Calcular primos hasta:</label>
            <input type="number" id="limite" value="1000000" min="1">
            <button id="botonCalcular">Calcular Primos</button>
        </div>

        <div class="resultado-box" id="resultado">
            Esperando tu entrada...
        </div>

        <hr>

        <h2>Prueba la interactividad de la UI</h2>
        <p>Haz clic en este botón mientras el cálculo se está ejecutando para ver que la UI no se bloquea.</p>
        <button id="botonUI">Hacer algo en la UI</button>
        <div class="ui-status" id="estadoUI">
            Acciones UI realizadas: 0
        </div>
    </div>

    <script src="main.js"></script>
</body>
</html>

Para ejecutar este ejemplo:

  1. Guarda los tres archivos (index.html, main.js, worker.js) en la misma carpeta.
  2. Abre index.html en tu navegador. Si lo abres directamente desde el sistema de archivos (file://), es posible que encuentres restricciones de seguridad (CORS) para Web Workers. Lo ideal es servirlo a través de un servidor web local (puedes usar Live Server en VS Code o simplemente python -m http.server en tu terminal).
  3. Introduce un número grande (por ejemplo, 10,000,000) en el campo y haz clic en "Calcular Primos".
  4. Mientras el mensaje "Calculando primos..." está visible, haz clic en "Hacer algo en la UI" varias veces. Verás que el contador de acciones UI se actualiza inmediatamente, demostrando que el hilo principal no está bloqueado por el cálculo intensivo.

🔄 Transferencia de Datos Eficiente: transferable objects

Cuando pasas datos entre el hilo principal y un Worker usando postMessage(), los datos son clonados. Esto significa que se crea una copia independiente. Para objetos grandes como ArrayBuffer, MessagePort, ImageBitmap o OffscreenCanvas, copiar puede ser muy ineficiente.

Los transferable objects permiten transferir la propiedad de un objeto de un contexto a otro sin realizar una copia. Una vez que el objeto ha sido transferido, ya no está disponible en el contexto original. Esto es mucho más rápido para grandes volúmenes de datos.

Ejemplo de transferable objects con ArrayBuffer

Supongamos que queremos procesar un gran ArrayBuffer.

worker_transfer.js:

// worker_transfer.js
self.onmessage = function(event) {
    const buffer = event.data;
    const view = new Uint8Array(buffer);

    console.log('Worker: Recibido ArrayBuffer con', view.length, 'bytes.');

    // Simula procesamiento intensivo
    for (let i = 0; i < view.length; i++) {
        view[i] = view[i] * 2; // Duplicar cada byte
    }

    console.log('Worker: Procesamiento de ArrayBuffer completado.');

    // Envía el buffer modificado de vuelta, transfiriendo la propiedad
    self.postMessage(buffer, [buffer]);
};

main_transfer.js (parte del hilo principal):

// main_transfer.js - Adaptación de main.js

// ... (otras variables y listeners)

let bufferWorker = null;

document.getElementById('botonTransfer').addEventListener('click', () => {
    const tamano = 10 * 1024 * 1024; // 10 MB
    const buffer = new ArrayBuffer(tamano);
    const view = new Uint8Array(buffer);

    for (let i = 0; i < tamano; i++) {
        view[i] = Math.floor(Math.random() * 256);
    }

    console.log('Hilo principal: ArrayBuffer original listo. Tamaño:', buffer.byteLength, 'bytes.');

    // Importante: verificar que el buffer todavía existe en el hilo principal antes de transferir
    try {
        console.log('Hilo principal: Primer byte del buffer ANTES de enviar:', view[0]);
    } catch (e) {
        console.error('Error al acceder al buffer antes de transferir:', e);
    }
    
    if (bufferWorker) {
        bufferWorker.terminate();
    }

    bufferWorker = new Worker('worker_transfer.js');

    bufferWorker.onmessage = function(event) {
        const receivedBuffer = event.data;
        const receivedView = new Uint8Array(receivedBuffer);
        console.log('Hilo principal: Recibido ArrayBuffer transferido. Primer byte modificado:', receivedView[0]);
        console.log('Hilo principal: Tamaño del buffer recibido:', receivedBuffer.byteLength, 'bytes.');
        
        // Intentar acceder al buffer original aquí lanzaría un error
        try {
            console.log('Intento acceder al buffer original después de la transferencia:', view[0]);
        } catch (e) {
            console.warn('¡Correcto! No se puede acceder al buffer original después de la transferencia.');
        }

        bufferWorker.terminate();
        bufferWorker = null;
    };

    // Envía el buffer al Worker, *transfiriendo* la propiedad
    bufferWorker.postMessage(buffer, [buffer]); 
    console.log('Hilo principal: ArrayBuffer transferido al Worker.');
    
    // Después de esta línea, el 'buffer' original en el hilo principal ya no es accesible
    try {
        console.log('Hilo principal: Intentando acceder al buffer original después de postMessage:', view[0]);
    } catch (e) {
        console.warn('Hilo principal: ¡El buffer original ha sido transferido y ya no es accesible aquí!');
    }
});

Cambios clave:

  • El segundo argumento de postMessage() es un array de objetos transferibles. [buffer] indica que buffer debe ser transferido, no copiado.
  • Después de postMessage(buffer, [buffer]), el buffer en el hilo principal se vacía y ya no puede ser utilizado. Intentar acceder a él generará un error.

📝 Carga de Scripts en el Worker: importScripts()

Los Workers pueden cargar scripts externos utilizando la función síncrona importScripts(). Esto es útil para organizar el código del Worker en módulos o para incluir librerías de terceros.

// worker_utils.js
function calcularAreaCirculo(radio) {
    return Math.PI * radio * radio;
}

function calcularPerimetroCirculo(radio) {
    return 2 * Math.PI * radio;
}
// worker_main_con_imports.js
importScripts('worker_utils.js', 'another_utility.js'); // Puedes pasar múltiples URLs

self.onmessage = function(event) {
    const radio = event.data;
    const area = calcularAreaCirculo(radio);
    const perimetro = calcularPerimetroCirculo(radio);
    self.postMessage({ area: area, perimetro: perimetro });
};
📌 Nota: `importScripts()` es síncrono. Los scripts se cargarán y ejecutarán en el orden especificado antes de que el código restante del Worker continúe.

❌ Manejo de Errores en Web Workers

El manejo de errores es crucial para la robustez de cualquier aplicación. Los errores que ocurren dentro de un Web Worker pueden ser capturados en el hilo principal utilizando el evento onerror.

// worker_error.js
self.onmessage = function(event) {
    const data = event.data;
    if (data === 'generarError') {
        throw new Error('¡Este es un error intencional desde el Worker!');
    }
    // Si no hay error, enviar un mensaje normal
    self.postMessage('Operación completada sin errores.');
};
// main_error.js (adaptación del hilo principal)
let errorWorker = null;

document.getElementById('botonError').addEventListener('click', () => {
    if (errorWorker) {
        errorWorker.terminate();
    }
    errorWorker = new Worker('worker_error.js');

    errorWorker.onmessage = function(event) {
        console.log('Hilo principal: Mensaje del Worker:', event.data);
        errorWorker.terminate();
    };

    errorWorker.onerror = function(error) {
        console.error('Hilo principal: Error capturado del Worker:', error.message);
        // `error` objeto contiene: `message`, `filename`, `lineno`
        document.getElementById('resultadoError').textContent = `Error del Worker: ${error.message} en ${error.filename}:${error.lineno}`;
        errorWorker.terminate();
        errorWorker = null;
    };

    errorWorker.postMessage('generarError'); // Enviar el mensaje que dispara el error
});

El objeto error que se pasa al event handler onerror en el hilo principal contiene propiedades útiles como message (el mensaje de error), filename (el nombre del archivo del Worker donde ocurrió el error) y lineno (el número de línea).


🗺️ Patrones Comunes de Uso de Web Workers

Los Web Workers son ideales para tareas que no necesitan interactuar directamente con el DOM, pero requieren un procesamiento intensivo. Aquí hay algunos patrones de uso comunes:

📊 Procesamiento de Datos Complejos

  • Filtrar, ordenar o agregar grandes conjuntos de datos.
  • Cálculos matemáticos o científicos intensivos.
  • Procesamiento de imágenes o audio.

📦 Cifrado / Descifrado de Datos

  • Operaciones de seguridad que requieren mucho CPU.

📡 Peticiones de Red Asíncronas (con XMLHttpRequest o fetch)

  • Aunque el hilo principal ya tiene fetch y XMLHttpRequest asíncronos, realizar peticiones y el parseo de JSON de grandes respuestas en un Worker puede liberar el hilo principal de ese overhead.

🎮 Lógica de Juego o Simulaciones

  • Cálculos de física, inteligencia artificial o generación de mundos en juegos complejos.

Hilo Principal UI Responsiva Web Worker Tareas Intensivas Ejecución en Segundo Plano Mensaje de Tarea Mensaje de Resultado

✅ Ventajas y Desventajas de Usar Web Workers

👍 Ventajas

  • Rendimiento Mejorado: La interfaz de usuario permanece fluida y receptiva, incluso durante tareas intensivas.
  • Experiencia de Usuario Superior: Evita bloqueos y congelamientos de la aplicación.
  • Utilización Eficiente de Recursos: Aprovecha mejor los núcleos de CPU disponibles, aunque sea de forma limitada al paralelismo de un único Worker por hilo.
  • Separación de Preocupaciones: Permite mantener la lógica intensiva separada de la lógica de la UI.

👎 Desventajas

  • Sin Acceso Directo al DOM: Requiere comunicación por mensajes para interactuar con la UI.
  • Overhead de Comunicación: La serialización y deserialización de mensajes puede introducir un costo, especialmente con objetos grandes (a menos que se usen transferable objects).
  • Depuración Más Compleja: Depurar código que se ejecuta en un Worker puede ser un poco más complicado que depurar código en el hilo principal.
  • Recursos Adicionales: Cada Worker consume recursos de memoria y CPU adicionales.
  • Soporte del Navegador: Aunque el soporte es amplio, siempre es bueno verificar (aunque es un estándar bastante maduro hoy en día).
¿Cómo verificar la compatibilidad con Web Workers? Puedes verificar si el navegador del usuario soporta Web Workers con una simple comprobación:
if (window.Worker) {
    console.log('Web Workers son soportados en este navegador.');
    // Tu código para usar Workers
} else {
    console.warn('Este navegador no soporta Web Workers.');
    // Proporciona una solución alternativa o degrada la experiencia
}

🚀 Buenas Prácticas y Consejos Avanzados

  • Terminar Workers Cuando No Son Necesarios: Usa worker.terminate() para liberar recursos. Un Worker no terminado seguirá consumiendo memoria y CPU.
  • Manejo de Errores Exhaustivo: Siempre implementa worker.onerror para diagnosticar y manejar problemas.
  • Modulariza tu Código: Usa importScripts() para cargar diferentes módulos en tu Worker, manteniéndolo organizado.
  • Prioriza transferable objects: Para grandes cantidades de datos (especialmente ArrayBuffer), utiliza transferable objects para optimizar la transferencia de datos.
  • Evita Crear Demasiados Workers: Cada Worker es un nuevo hilo con su propio overhead. Úsalos con moderación para tareas que realmente lo necesiten.
  • Comunicación Bidireccional: Diseña tu Worker para que pueda recibir y enviar mensajes, permitiendo una comunicación fluida y un control de estado si es necesario.
  • Web Workers y Módulos ES6: Los Workers también pueden ser creados como módulos ES6 (new Worker('module.js', { type: 'module' })), lo que te permite usar import y export dentro del Worker, mejorando la modularidad y el rendimiento al cargar módulos grandes.
💡 Consejo: Para depurar Web Workers, la mayoría de las herramientas de desarrollador de navegadores (Chrome DevTools, Firefox Developer Tools) te permiten ver los hilos de Worker en una sección separada (generalmente bajo "Sources" o "Application" -> "Service Workers" / "Workers"), e incluso establecer puntos de interrupción dentro de ellos.

🎯 Ejercicio Propuesto

Para consolidar tus conocimientos, te propongo el siguiente ejercicio:

Modifica el ejemplo de los números primos para que el Worker no solo envíe la lista de primos, sino que también envíe actualizaciones de progreso al hilo principal. Por ejemplo, cada vez que encuentre 1000 primos o cada cierto porcentaje de progreso, debería enviar un mensaje con la cantidad de primos encontrados hasta el momento y el porcentaje completado.

Pistas:

  • En worker.js, dentro del bucle de búsqueda de primos, añade una condición para enviar un postMessage() periódicamente.
  • En main.js, modifica el worker.onmessage para que pueda manejar tanto los mensajes de progreso como el mensaje final con la lista completa.
  • Podrías enviar un objeto { type: 'progress', data: { count: N, percent: P } } o { type: 'result', data: [...] } para diferenciar los tipos de mensajes.
¡Tu camino hacia el dominio de Web Workers!

Conclusión

Los Web Workers son una herramienta increíblemente poderosa en el arsenal de cualquier desarrollador web moderno. Al permitirnos mover tareas computacionalmente intensivas fuera del hilo principal, podemos construir aplicaciones web que no solo son ricas en funcionalidades, sino también increíblemente rápidas y reactivas. Entender cómo funcionan, sus capacidades y sus limitaciones es clave para diseñar arquitecturas de aplicaciones web eficientes. ¡Empieza a integrarlos en tus proyectos y experimenta la diferencia!

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!