Tutoriais

MQTT no Vue 3 com MQTT.js: um composable para dados em tempo real

Use MQTT no Vue 3 com MQTT.js 5: um composable useMqtt compartilhado, assinaturas reativas com shallowRef, limpeza automática e WebSockets seguros.

Atualizado · 8 min de leitura

MQTT no Vue 3 com MQTT.js: um composable para dados em tempo real

O jeito limpo de usar MQTT no Vue 3 é um composable: um único cliente MQTT.js compartilhado sobre WebSockets seguros, uma função useMqtt() que expõe o status da conexão de forma reativa e um método publish, e uma função useTopic() que assina enquanto o componente está vivo e cancela a assinatura automaticamente quando ele é destruído. As mensagens vão para estado shallowRef, para que o Vue não crie proxies profundos de cada payload.

Este tutorial monta esse composable, usa-o em um componente <script setup> e mostra como testá-lo contra um broker real.

Instalação e configurações de conexão

Instalação
npm i mqtt

O MQTT.js 5 inclui tipos TypeScript e um build para navegador que funciona com o Vite sem configuração. Navegadores só falam MQTT sobre WebSockets, então a URL é sempre ws:// ou wss://:

ConfiguraçãoValor
URLwss://broker.connectmq.com:8084/mqtt
ProtocoloMQTT 3.1.1 ou 5
Credenciaisuma conta de navegador com leitura nos topics que a UI exibe

Use wss:// em qualquer página servida por HTTPS; ws:// é bloqueado como conteúdo misto. MQTT sobre WebSockets explica o transporte, e testar uma conexão WebSocket ajuda quando o handshake falha.

O composable

O cliente vive no escopo do módulo, então todo componente que chama useMqtt() compartilha uma conexão. Um contador simples fecha a conexão quando o último usuário sai:

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 que está escrito assim:

  • O cliente nunca é reativo. Ele fica em um let comum. Envolver um MqttClient em ref() ou reactive() criaria um proxy profundo de um event emitter grande sem nenhum benefício. Se for guardá-lo em uma store Pinia, envolva com markRaw().
  • shallowRef para as mensagens. O Vue rastreia só a referência, não cada campo de cada payload. Cada atualização atribui um array ou objeto novo, que é exatamente o que dispara o re-render.
  • A limpeza é ligada ao effect scope. onScopeDispose e o onCleanup do watcher rodam quando o componente desmonta (e, no caso do onCleanup, quando o filtro de topic muda), então handlers e assinaturas no broker nunca vazam.
  • As assinaturas contam referências por filtro. Dois componentes exibindo o mesmo topic compartilham uma assinatura MQTT. O MQTT.js reassina automaticamente após uma reconexão, e chamadas feitas antes da conexão subir ficam em fila e são enviadas assim que ela conecta.

Como useTopic aceita um ref ou getter, um componente pode trocar de topic de forma reativa: o watcher cancela a assinatura do filtro antigo e assina o novo. Confira seus filtros com o topic matcher quando houver wildcards.

Usando em um 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>

Mudar o <select> troca a assinatura de testmqtt/vue/lab/+ para testmqtt/vue/office/+ sem mexer na conexão.

Em topics muito movimentados, cada mensagem ainda causa um render. Se dezenas de mensagens por segundo chegam ao mesmo painel, acumule-as em um array comum e despeje no shallowRef uma vez por requestAnimationFrame, ou limite a frequência com um helper como o useThrottleFn do VueUse.

Credenciais em uma app Vue

Variáveis VITE_* são compiladas no bundle e ficam visíveis para qualquer pessoa que abra o DevTools. Trate credenciais de navegador como públicas:

  • Crie um usuário dedicado ao navegador que possa assinar os topics do dashboard e, se necessário, publicar em um único topic de comandos.
  • Para acesso por usuário, faça o backend emitir credenciais de curta duração após o login e passe-as ao mqtt.connect (mova as opções de getClient() para um parâmetro).
  • Nunca distribua a conta usada pelos seus serviços de backend.

Autenticação de clientes no navegador compara as opções em detalhes.

Testando o composable

  1. Inicie o servidor de desenvolvimento e confira que o painel mostra Broker: connected. Se aparecer error, compare a mensagem: Connection refused: Not authorized aponta para as credenciais; uma falha de WebSocket, para a URL, a porta 8084 ou o path /mqtt.
  2. Abra o cliente MQTT online, conecte ao mesmo broker e publique {"t": 22.1} em testmqtt/vue/lab/sensor-1. A linha aparece na hora.
  3. Mude o select para office e publique de novo em testmqtt/vue/lab/sensor-1: nada deve mudar. Publique em testmqtt/vue/office/sensor-9 e a nova linha aparece. Isso prova que a assinatura antiga foi limpa.
  4. Assine testmqtt/vue/# no cliente online e clique em Ping para verificar a publicação.
  5. Navegue para uma rota sem o painel e confirme no log do broker ou na lista de clientes que a conexão fecha quando nenhum componente a usa.

Envie mensagens de teste para a sua app Vue

Para testes automatizados, mantenha matches() e o parsing como funções puras e teste-as com Vitest; para a conexão em si, um pequeno teste de integração contra um broker real pega muito mais do que um cliente mockado. Se a app reconecta em loop, leia reconexão de MQTT sobre WebSockets em JavaScript e a referência de erros do MQTT.js.

Solução de problemas

SintomaCausaCorreção
A UI não atualiza mesmo com mensagens chegandoMutar um array shallowRef no lugar (push)Atribua um array novo ou chame triggerRef()
Cada aba desconecta as outrasclientId fixoGere um client ID aleatório por aba
Mensagens continuam sendo tratadas após sair da páginaHandler não removidoUse onScopeDispose / onCleanup como acima
WebSocket connection to 'wss://...' failedPorta ou path errados, ou rede bloqueadaTeste a mesma URL no cliente online

Um composable deixa o MQTT onde o Vue espera que o estado more: uma conexão, status reativo e assinaturas que seguem o ciclo de vida do componente. Teste com um segundo cliente publicando mensagens reais e o mesmo código vai funcionar sem mudanças quando você apontá-lo para o broker de produção.

Perguntas frequentes

Devo guardar o cliente MQTT em um ref ou objeto reactive do Vue?

Não. O cliente é um event emitter grande com estado interno, e envolvê-lo em um proxy reativo profundo adiciona overhead e pode quebrar comparações de identidade. Guarde-o em uma variável de módulo comum, ou use shallowRef ou markRaw se ele precisar viver em estado reativo.

Como uso MQTT no Nuxt?

Rode o cliente só no navegador: coloque o composable atrás de um plugin .client.ts, chame-o no onMounted ou envolva o componente em <ClientOnly>. Use uma URL wss://, porque o navegador não abre conexões TCP nas portas 1883 ou 8883.

Por que meu componente Vue continua recebendo mensagens MQTT depois de destruído?

O listener ou a assinatura nunca foram removidos. Registre a limpeza com onScopeDispose ou com o onCleanup de um watcher para remover o handler e cancelar a assinatura do topic quando o effect scope do componente parar.