DNS-SD(DNS-Based Service Discovery)是一套基于 DNS 记录的服务发现协议,通常与 mDNS 组播配合使用,典型实现就是苹果生态中的 Bonjour。当我们在 Vue 3 项目中需要构建局域网设备管理面板、投屏控制台或者智能家居前端时,服务发现能力往往是整个应用的地基。浏览器出于安全考虑不开放原生 UDP 组播,因此纯前端无法直接实现 mDNS,但这并不意味着 DNS-SD 与前端工程无关——恰恰相反,把 DNS-SD 的能力通过后端网关桥接给 Vue 3 前端,并在此基础上做一套工程化的封装,是非常有价值的架构实践。

一、理解 DNS-SD 的协议模型与前端的角色
DNS-SD 的核心思路是:服务提供者通过 DNS SRV、TXT、PTR 三类记录描述自己的存在。PTR 记录列举某个服务类型下的所有实例,例如_http._tcp.local下挂着多少个设备;SRV 记录给出实例对应的主机名、端口;TXT 记录则承载额外的元数据,比如设备型号、固件版本。客户端查询这三类记录,就能拼出完整的服务清单。
在传统 C/S 架构中,这套查询由操作系统或专用库完成。而浏览器里的 JavaScript 没有 UDP Socket 权限,无法加入 mDNS 组播组 224.0.0.251,所以前端的角色应当定位为“展示层与消费方”。真正扫描局域网的职责交给一个轻量网关服务:它可以基于 Node.js 的 bonjour 或 multicast-dns 库实现,把 DNS-SD 的浏览结果通过 WebSocket 推送给浏览器。
这种分层带来的好处是明显的:前端不需要关心组播细节,只需要处理推送过来的结构化数据;网关可以独立部署、独立扩容,甚至复用给多个前端应用。在 Vue 3 工程中,我们要做的是把这份数据变成响应式状态,并围绕它构建健壮的生命周期管理。
二、搭建服务发现网关并定义数据契约
首先用 Node.js 实现一个网关。下面这段代码用 bonjour 库浏览局域网内的 HTTP 服务,并通过 WebSocket 广播服务的上线与下线事件:
import bonjour from 'bonjour';
import { WebSocketServer } from 'ws';
const bj = bonjour();
const wss = new WebSocketServer({ port: 8801 });
// 浏览所有 _http._tcp 类型的服务
const browser = bj.find({ type: 'http', protocol: 'tcp' });
const broadcast = (event, payload) => {
const msg = JSON.stringify({ event, payload, ts: Date.now() });
wss.clients.forEach((client) => {
if (client.readyState === 1) client.send(msg);
});
};
browser.on('up', (svc) => {
// 只推送前端需要的字段,屏蔽内部细节
broadcast('service:up', {
id: svc.fqdn,
name: svc.name,
host: svc.host,
port: svc.port,
addresses: svc.addresses,
txt: svc.txt || {}
});
});
browser.on('down', (svc) => broadcast('service:down', { id: svc.fqdn }));
注意这里刻意做了一层字段裁剪,只把 id、name、host、port、addresses 和 TXT 记录推给前端。这一步是工程化的关键:数据契约一旦确定,前端与后端就可以并行开发。建议把契约抽取成 TypeScript 类型文件,供网关和 Vue 项目共同引用,避免两边字段名漂移。
三、在 Vue 3 中封装类型安全的服务发现模块
前端侧的核心是一个组合式函数(Composable)。利用 Vue 3 的 reactive 与 shallowRef,我们可以把服务列表变成响应式数据,任何组件引用它都会自动更新视图:
import { reactive, onUnmounted } from 'vue';
export interface DiscoveredService {
id: string;
name: string;
host: string;
port: number;
addresses: string[];
txt: Record<string, string>;
lastSeen: number;
}
export function useServiceDiscovery(wsUrl: string) {
const services = reactive(new Map<string, DiscoveredService>());
let ws: WebSocket | null = null;
let reconnectTimer: number | undefined;
const connect = () => {
ws = new WebSocket(wsUrl);
ws.onmessage = (e) => {
const { event, payload } = JSON.parse(e.data);
if (event === 'service:up') {
services.set(payload.id, { ...payload, lastSeen: Date.now() });
} else if (event === 'service:down') {
services.delete(payload.id);
}
};
ws.onclose = () => {
// 指数退避重连,避免网关抖动时打爆连接
reconnectTimer = window.setTimeout(connect, 3000);
};
};
connect();
onUnmounted(() => {
clearTimeout(reconnectTimer);
ws?.close();
});
return { services };
}
这里有几个值得展开的细节。第一,用 Map 而不是数组存储服务,靠 id(即 DNS-SD 的 FQDN)天然去重,避免 mDNS 环境中设备重复广播导致列表闪烁。第二,记录 lastSeen 时间戳,为后续的心跳超时清理提供依据——mDNS 的 down 事件在网络异常时可能丢失,前端必须有自己的兜底逻辑。第三,重连放在 onclose 而不是 onerror 中,可以避免双重定时器。
四、组件层消费与生产级健壮性设计
组件中使用这个 Composable 非常简单,配合计算属性即可派生出按名称排序的服务列表:
<script setup lang="ts">
import { computed } from 'vue';
import { useServiceDiscovery } from '@/composables/useServiceDiscovery';
const { services } = useServiceDiscovery('ws://192.168.0.1:8801');
const sortedList = computed(() =>
[...services.values()].sort((a, b) => a.name.localeCompare(b.name))
);
</script>
<template>
<ul>
<li v-for="svc in sortedList" :key="svc.id">
{{ svc.name }} — {{ svc.addresses[0] }}:{{ svc.port }}
</li>
</ul>
</template>
进入生产环境前,还有三件事必须处理。其一是心跳超时:网关应每 10 秒推送一次 ping,前端若超过 30 秒没有收到任何消息,就主动清空过期的 lastSeen 条目并重建连接。其二是 TXT 记录的解析健壮性:某些设备的 TXT 值可能是二进制或带等号的键值对,前端应做防御性解析而不是直接信任。其三是 Vite 的开发代理配置,把 WebSocket 转发给网关,避免开发时跨域问题,配置项是 server.proxy 中加上 ws: true。
最后要提醒的是,mDNS 解析出的地址可能是 IPv6 本地链路地址(fe80 开头),这类地址在浏览器中必须带上网卡 scope 才能访问,实践中建议优先使用 IPv4 地址。把这些细节都封装进 Composable 内部,上层组件就能专注于业务展示,这正是工程化 DNS-SD 的价值所在。