Tutoriales

MQTT en Vue 3 con MQTT.js: un composable para datos en tiempo real

Usa MQTT en Vue 3 con MQTT.js 5: un composable useMqtt compartido, suscripciones reactivas con shallowRef, limpieza automática y WebSockets seguros.

Actualizado · 8 min de lectura

MQTT en Vue 3 con MQTT.js: un composable para datos en tiempo real

La forma limpia de usar MQTT en Vue 3 es un composable: un único cliente MQTT.js compartido sobre WebSockets seguros, una función useMqtt() que expone el estado de conexión reactivo y un método publish, y una función useTopic() que se suscribe mientras el componente está vivo y cancela la suscripción automáticamente cuando se destruye. Los mensajes van a estado shallowRef, para que Vue no cree proxies profundos de cada payload.

Este tutorial construye ese composable, lo usa en un componente <script setup> y muestra cómo probarlo contra un broker real.

Instalación y ajustes de conexión

Instalación
npm i mqtt

MQTT.js 5 incluye tipos de TypeScript y un build para navegador que funciona con Vite sin configuración. Los navegadores solo hablan MQTT sobre WebSockets, así que la URL siempre es ws:// o wss://:

AjusteValor
URLwss://broker.connectmq.com:8084/mqtt
ProtocoloMQTT 3.1.1 o 5
Credencialesuna cuenta de navegador con acceso de lectura a los topics que muestra la UI

Usa wss:// en cualquier página servida por HTTPS; ws:// se bloquea como contenido mixto. MQTT sobre WebSockets explica el transporte, y probar una conexión WebSocket te ayuda cuando falla el handshake.

El composable

El cliente vive en el ámbito del módulo, así que todos los componentes que llaman a useMqtt() comparten una conexión. Un contador sencillo cierra la conexión cuando se va el último usuario:

src/composables/mqtt.ts
import { onScopeDispose, readonly, ref, shallowRef, toValue, watch, type MaybeRefOrGetter } from 'vue';
import mqtt, { type MqttClient } from 'mqtt';

type Status = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'error';
type Handler = (topic: string, payload: Uint8Array) => void;

const URL = import.meta.env.VITE_MQTT_URL ?? 'wss://broker.connectmq.com:8084/mqtt';

let client: MqttClient | null = null;
let users = 0;
const status = ref<Status>('idle');
const lastError = ref<string | null>(null);
const handlers = new Map<string, Set<Handler>>();

export function matches(filter: string, topic: string): boolean {
  const f = filter.split('/');
  const t = topic.split('/');
  for (let i = 0; i < f.length; i++) {
    if (f[i] === '#') return true;
    if (i >= t.length || (f[i] !== '+' && f[i] !== t[i])) return false;
  }
  return f.length === t.length;
}

function getClient(): MqttClient {
  if (client) return client;
  status.value = 'connecting';
  client = mqtt.connect(URL, {
    username: import.meta.env.VITE_MQTT_USER,
    password: import.meta.env.VITE_MQTT_PASS,
    clientId: `vue-${crypto.randomUUID().slice(0, 8)}`,
    reconnectPeriod: 2000,
    connectTimeout: 10_000,
  });
  client.on('connect', () => { status.value = 'connected'; lastError.value = null; });
  client.on('reconnect', () => { status.value = 'reconnecting'; });
  client.on('offline', () => { status.value = 'offline'; });
  client.on('error', (err) => { status.value = 'error'; lastError.value = err.message; });
  client.on('message', (topic, payload) => {
    handlers.forEach((set, filter) => {
      if (matches(filter, topic)) set.forEach((h) => h(topic, payload));
    });
  });
  return client;
}

export function useMqtt() {
  const c = getClient();
  users++;

  onScopeDispose(() => {
    if (--users === 0) {
      c.end(true);
      client = null;
      status.value = 'idle';
    }
  });

  function subscribe(filter: string, handler: Handler): () => void {
    let set = handlers.get(filter);
    if (!set) {
      set = new Set();
      handlers.set(filter, set);
      c.subscribe(filter, { qos: 1 });
    }
    set.add(handler);
    return () => {
      set!.delete(handler);
      if (set!.size === 0) {
        handlers.delete(filter);
        c.unsubscribe(filter);
      }
    };
  }

  function publish(topic: string, message: string, retain = false) {
    return c.publishAsync(topic, message, { qos: 1, retain });
  }

  return { status: readonly(status), error: readonly(lastError), subscribe, publish };
}

export interface TopicMessage<T> { topic: string; data: T; at: number }

export function useTopic<T = unknown>(filter: MaybeRefOrGetter<string>, max = 50) {
  const { subscribe } = useMqtt();
  const messages = shallowRef<TopicMessage<T>[]>([]);
  const latest = shallowRef<Record<string, T>>({});

  watch(
    () => toValue(filter),
    (f, _old, onCleanup) => {
      messages.value = [];
      latest.value = {};
      const off = subscribe(f, (topic, payload) => {
        const text = new TextDecoder().decode(payload);
        let data: T;
        try { data = JSON.parse(text) as T; } catch { data = text as T; }
        messages.value = [{ topic, data, at: Date.now() }, ...messages.value].slice(0, max);
        latest.value = { ...latest.value, [topic]: data };
      });
      onCleanup(off);
    },
    { immediate: true },
  );

  return { messages, latest };
}

Por qué está escrito así:

  • El cliente nunca es reactivo. Está en un let normal. Envolver un MqttClient en ref() o reactive() crearía un proxy profundo de un event emitter grande sin ningún beneficio. Si lo guardas en un store de Pinia, envuélvelo con markRaw().
  • shallowRef para los mensajes. Vue solo rastrea la referencia, no cada campo de cada payload. Cada actualización asigna un array u objeto nuevo, que es justo lo que dispara el re-render.
  • La limpieza va ligada al effect scope. onScopeDispose y el onCleanup del watcher se ejecutan cuando el componente se desmonta (y, en el caso de onCleanup, cuando cambia el filtro de topic), así que ni los handlers ni las suscripciones del broker se quedan colgando.
  • Las suscripciones cuentan referencias por filtro. Dos componentes que muestran el mismo topic comparten una suscripción MQTT. MQTT.js se vuelve a suscribir solo tras una reconexión, y las llamadas hechas antes de conectar se encolan y se envían en cuanto conecta.

Como useTopic acepta un ref o un getter, un componente puede cambiar de topic de forma reactiva: el watcher cancela la suscripción al filtro anterior y se suscribe al nuevo. Comprueba tus filtros con el topic matcher cuando haya wildcards.

Uso en un componente

src/components/SensorPanel.vue
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useMqtt, useTopic } from '@/composables/mqtt';

interface Reading { t: number; h?: number }

const room = ref('lab');
const { status, error, publish } = useMqtt();
const { latest, messages } = useTopic<Reading>(() => `testmqtt/vue/${room.value}/+`);

const rows = computed(() =>
  Object.entries(latest.value).map(([topic, r]) => ({ device: topic.split('/').pop(), t: r.t })),
);

function ping() {
  publish(`testmqtt/vue/${room.value}/cmd/ping`, String(Date.now()));
}
</script>

<template>
  <section>
    <p>Broker: {{ status }} <span v-if="error">({{ error }})</span></p>
    <select v-model="room">
      <option value="lab">lab</option>
      <option value="office">office</option>
    </select>
    <table>
      <tr v-for="row in rows" :key="row.device">
        <td>{{ row.device }}</td>
        <td>{{ row.t.toFixed(1) }} °C</td>
      </tr>
    </table>
    <button :disabled="status !== 'connected'" @click="ping">Ping</button>
    <p>{{ messages.length }} messages received</p>
  </section>
</template>

Cambiar el <select> pasa la suscripción de testmqtt/vue/lab/+ a testmqtt/vue/office/+ sin tocar la conexión.

En topics muy activos, cada mensaje sigue provocando un render. Si llegan decenas de mensajes por segundo al mismo panel, acumúlalos en un array normal y vuélcalos al shallowRef una vez por requestAnimationFrame, o limita la frecuencia con un helper como useThrottleFn de VueUse.

Credenciales en una app Vue

Las variables VITE_* se compilan dentro del bundle y cualquiera que abra DevTools puede verlas. Trata las credenciales del navegador como públicas:

  • Crea un usuario dedicado al navegador que pueda suscribirse a los topics del dashboard y, si hace falta, publicar en un único topic de comandos.
  • Para acceso por usuario, haz que tu backend emita credenciales de corta duración tras el login y pásalas a mqtt.connect (saca las opciones de getClient() a un parámetro).
  • Nunca publiques la cuenta que usan tus servicios backend.

Autenticación de clientes en el navegador compara las opciones en detalle.

Probar el composable

  1. Arranca el servidor de desarrollo y comprueba que el panel muestra Broker: connected. Si ves error, compara el mensaje: Connection refused: Not authorized apunta a las credenciales; un fallo de WebSocket, a la URL, al puerto 8084 o a la ruta /mqtt.
  2. Abre el cliente MQTT online, conéctate al mismo broker y publica {"t": 22.1} en testmqtt/vue/lab/sensor-1. La fila aparece al momento.
  3. Cambia el select a office y vuelve a publicar en testmqtt/vue/lab/sensor-1: no debería cambiar nada. Publica en testmqtt/vue/office/sensor-9 y aparece la fila nueva. Esto demuestra que la suscripción anterior se limpió.
  4. Suscríbete a testmqtt/vue/# en el cliente online y pulsa Ping para verificar la publicación.
  5. Navega a una ruta sin el panel y confirma en el log del broker o en la lista de clientes que la conexión se cierra cuando ningún componente la usa.

Envía mensajes de prueba a tu app Vue

Para tests automatizados, mantén matches() y el parseo como funciones puras y pruébalas con Vitest; para la conexión, un pequeño test de integración contra un broker real detecta mucho más que un cliente mockeado. Si la app reconecta en bucle, lee reconexión de MQTT sobre WebSockets en JavaScript y la referencia de errores de MQTT.js.

Solución de problemas

SíntomaCausaSolución
La UI no se actualiza aunque llegan mensajesMutar un array shallowRef en sitio (push)Asigna un array nuevo o llama a triggerRef()
Cada pestaña desconecta a las demásclientId fijoGenera un client ID aleatorio por pestaña
Se siguen procesando mensajes al salir de la páginaEl handler no se eliminóUsa onScopeDispose / onCleanup como arriba
WebSocket connection to 'wss://...' failedPuerto o ruta incorrectos, o red bloqueadaPrueba la misma URL en el cliente online

Un composable deja MQTT donde Vue espera que viva el estado: una conexión, estado reactivo y suscripciones que siguen el ciclo de vida del componente. Pruébalo con un segundo cliente que publique mensajes reales y el mismo código funcionará sin cambios cuando lo apuntes a tu broker de producción.

Preguntas frecuentes

¿Debo guardar el cliente MQTT en un ref o un objeto reactive de Vue?

No. El cliente es un event emitter grande con estado interno, y envolverlo en un proxy reactivo profundo añade overhead y puede romper comparaciones de identidad. Guárdalo en una variable de módulo normal, o usa shallowRef o markRaw si tiene que vivir en estado reactivo.

¿Cómo uso MQTT en Nuxt?

Ejecuta el cliente solo en el navegador: pon el composable tras un plugin .client.ts, llámalo desde onMounted o envuelve el componente en <ClientOnly>. Usa una URL wss://, porque el navegador no puede abrir conexiones TCP a los puertos 1883 u 8883.

¿Por qué mi componente Vue sigue recibiendo mensajes MQTT después de destruirse?

El listener o la suscripción nunca se eliminaron. Registra la limpieza con onScopeDispose o con el onCleanup de un watcher para quitar el handler y cancelar la suscripción al topic cuando se detenga el effect scope del componente.