Vue 3 集成 noVNC:WebSocket 远程桌面嵌入与排错实战
上周运维群里弹出一条需求把机房里几台工控机的操作界面搬到浏览器里让值班同事在网页上点两下就能看到桌面不用再抱着笔记本跑到现场插显示器。第一反应是上个 RDP 网关但那些机器上跑的是老旧的图形化采集程序只认 VNC 协议也不可能为了这件事给每台机器重装系统。链路就这么定了机器上继续跑 VNC Server中间架一层协议转换的桥前端在 Vue 项目里用 noVNC 把画面渲染到 canvas 上。这套东西拆开看原理不复杂真正落地时会撞上一堆细节鼠标坐标偏了半个屏幕、键盘敲进去没反应、打包上线之后画布尺寸抽风、wss 握手被反向代理拦下、Vue 的响应式系统把 RFB 实例包了一层导致行为诡异……每一个都是那种文档里不会写、但一定会遇到的问题。这篇就把从链路原理到可复制组件代码的完整过程摆出来适合已经会写 Vue、但第一次碰远程桌面嵌入的前端同学和负责部署的运维同学。1. noVNC 在这条链路里的位置以及它替代不了什么很多人第一次接触 noVNC以为它是一个远程桌面软件。其实它更像一个客户端库——把 VNC 的 RFB 协议翻译成浏览器能理解的东西然后画在 canvas 上。搞清楚它在链路里站哪一格后面所有的配置和排错才有依据。1.1 浏览器只认 WebSocketVNC 服务端只认 TCP这是整个方案存在的根本原因。浏览器里的 JavaScript 没有权限开一条原始 TCP 连接能做的长连接只有 WebSocket、WebRTC 这几种。而 VNC Server 老老实实监听在 5900 端口上说的是 RFB 协议纯 TCP 字节流。两边对不上就必须有人来当翻译。这个翻译官就是websockify它对浏览器开一个 WebSocket 端点同时作为 TCP 客户端去连 VNC Server然后把两边的字节流原样搬运。注意是原样搬运——websockify 不做协议解析它不关心 RFB 报文里是什么只负责帧的封包解包。真正的 RFB 协议解析和画面渲染全部发生在浏览器侧也就是 noVNC 干的活。理解这一点很关键noVNC 不产生画面它只是 RFB 报文的解析者和 canvas 的绘制者。远端发来的是这里有一块 32×32 的区域像素数据是这些这样的增量更新指令noVNC 把它们一块块贴到画布上。所以画面卡不卡一半取决于网络和 VNC Server 的编码压缩一半取决于浏览器的 canvas 绘制性能跟 Vue 关系不大。1.2 一次按键从浏览器到远端的完整数据流把数据流走一遍排错的时候就能快速定位是哪一段断了阶段发生位置关键动作1浏览器 canvas 容器用户按下键盘noVNC 捕获事件2noVNC RFB 实例把键码转成 RFB KeyEvent 报文3WebSocket二进制帧发到 websockify4websockify拆掉 WS 帧头原样 TCP 转发到 59005VNC Server解析 RFB把按键注入到 X11 会话6反向路径画面增量按同样的路回来落到 canvas第 1 步是最容易出问题的地方。noVNC 的键盘捕获依赖它内部的Keyboard模块而这个模块需要焦点落在它管理的元素上。如果你的 Vue 布局里有个遮罩层、或者外面套了个带tabindex的容器把焦点抢走了键盘就会打不进去。我在第一次集成时就被这个坑了半小时实测下来把焦点问题排掉之后 90% 的键盘失灵都能解决。1.3 什么场景该用它什么场景别硬上noVNC 的定位决定了它有明确的边界。适合的场景需要把已有的 VNC 服务快速搬到 Web 界面、机器数量可控、对画面流畅度要求中等看图、点按钮、做配置。像我们的工控机值班场景主要动作是看一眼状态、点几个按钮noVNC 完全够用。不太适合的场景需要精细操作比如图形设计、视频编辑、对延迟极度敏感比如实时操作类、或者远端根本没有 VNC Server。后一种情况要考虑的是换协议而不是硬把 noVNC 往上套。另外要注意 VNC 传统协议在认证和加密上比较薄弱不要把它直接暴露到公网 5900 端口必须走反代加鉴权这一点在第 4 节会细讲。2. 把 noVNC 缝进 Vue 组件的最小闭环原理清楚之后动手其实很快。但 Vue 的响应式系统和组件生命周期会给你埋几个特有的坑这部分是很多教程一笔带过、实际最容易翻车的地方。2.1 依赖版本与引入路径的坑noVNC 现在以 npm 包novnc/novnc的形式发布但它的包结构和路径在不同大版本之间改过。早期版本的核心实现在core/rfb.js后来有些发布把入口挪到了lib/下。所以你会看到网上的教程写的是两种不同的 import// 常见写法 A import RFB from novnc/novnc/core/rfb.js // 常见写法 B import RFB from novnc/novnc/lib/rfb.js别照抄先打开node_modules/novnc/novnc/package.json看一眼main、exports字段或者直接看目录里到底有没有core/rfb.js。我遇到过一次升级之后 import 路径失效构建直接报模块找不到排查半天发现是包结构变了。安装用npm install novnc/novnc它本身不依赖 Vue纯 JS 库没有额外的 peer 依赖这点比较省心。2.2 先搭一个能自测的 VNC 环境前端开发最容易卡住的地方是没有可连的服务端。别急着去动生产机器本地起一个就行。思路是在一台 Linux物理机、虚拟机都行上装一个轻量的桌面环境和 VNC Server常见组合是 Xfce TigerVNC然后加一层 websockify 把 5900 转成 WebSocket。安装完之后先手动验证两步本机用 VNC 客户端能连上127.0.0.1:5900说明 VNC Server 正常浏览器直接访问 websockify 自带的静态页面能出画面说明桥也通了。这两步都过了再去写 Vue 代码。顺序反了的话出了问题你根本分不清是前端写错了还是服务端没起来。用容器跑会更省事社区里有一些把桌面环境和 websockify 打包好的开源镜像起一个容器把 6080 端口映射出来就能有一个可连的测试目标这种方式特别适合只想调前端、不想折腾系统配置的同学。本地测试时 websockify 的典型启动命令是websockify --web/usr/share/novnc 6080 localhost:5900--web指的是顺便把 noVNC 自带的静态页面托管出来方便你对照它自己的实现。2.3 一个可以直接抄的 Vue 3 组件下面这个组件我在项目里用了很久砍掉了业务逻辑保留完整骨架。重点看几个注释——那些都是踩出来的template div classvnc-wrap refwrapRef div classvnc-screen refscreenRef/div div v-ifstatus ! connected classvnc-mask {{ statusText }} /div /div /template script setup import { ref, shallowRef, onMounted, onBeforeUnmount } from vue import RFB from novnc/novnc/core/rfb.js const props defineProps({ url: { type: String, required: true }, password: { type: String, default: }, viewOnly: { type: Boolean, default: false }, }) const emit defineEmits([connected, disconnected, error]) const wrapRef ref(null) const screenRef ref(null) // 关键用 shallowRef别用 ref 包 RFB 实例 const rfb shallowRef(null) const status ref(idle) const statusText ref(正在连接) let ro null function connect () { if (!screenRef.value) return status.value connecting statusText.value 正在连接 const r new RFB(screenRef.value, props.url, { credentials: { password: props.password }, }) r.background #000 r.viewOnly props.viewOnly r.scaleViewport true // 远端画面按比例塞进容器 r.resizeSession false // 不请求远端改分辨率 r.clipViewport false r.qualityLevel 6 // 0~9越大越清晰也越吃带宽 r.compressionLevel 2 // 0~9越大越省带宽越吃 CPU r.addEventListener(connect, () { status.value connected emit(connected) }) r.addEventListener(disconnect, (e) { status.value disconnected statusText.value e.detail.clean ? 连接已断开 : 连接异常断开 emit(disconnected, e.detail) }) r.addEventListener(securityfailure, (e) { status.value error statusText.value 鉴权失败 e.detail.reason emit(error, e.detail) }) rfb.value r } function fitCanvas () { // 容器尺寸变了要让 noVNC 重新计算缩放 rfb.value?.scaleViewport rfb.value?.clipViewport ! undefined // 实际触发重排改一下 viewport 相关属性再切回来 if (rfb.value) { const keep rfb.value.viewOnly rfb.value.viewOnly keep } } onMounted(() { connect() ro new ResizeObserver(() fitCanvas()) ro.observe(wrapRef.value) }) onBeforeUnmount(() { ro?.disconnect() ro null // 必须显式断开否则 WebSocket 会挂着页面切走还在收数据 rfb.value?.disconnect() rfb.value null }) /script style scoped .vnc-wrap { position: relative; width: 100%; height: 100%; /* 前提上层链路高度必须算得出来 */ background: #000; } .vnc-screen { width: 100%; height: 100%; } .vnc-mask { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #ddd; background: rgba(0, 0, 0, 0.6); } /style有几个地方值得单独强调。第一rfb用shallowRef而不是ref。RFB 实例内部挂着 canvas、事件监听、定时器一大堆东西如果被 Vue 的深层响应式代理包一层不仅创建 proxy 的开销大还可能因为 proxy 拦截导致某些内部对象比较比如失效行为变得难以预测。所有外来对象进 Vue 都建议shallowRef或markRaw。第二onBeforeUnmount里必须显式disconnect()。我见过最典型的事故是用户切走路由组件销毁了但 WebSocket 还开着VNC Server 那边的会话也没释放几轮切换下来把服务端的会话数占满了。这个在开发阶段不容易发现因为刷新页面会强制断开但用了路由缓存keep-alive之后问题就出来了。2.4 挂载、卸载与 keep-alive 的时序问题Vue 的onMounted触发时ref绑定的 DOM 一定已经存在了这一点没问题。真正的坑在 keep-alive 和Transition上。如果这个 VNC 组件被keep-alive缓存用户第一次进来连上切走时onBeforeUnmount不会触发只会触发deactivated切回来触发activated。这时候如果你按照进组件就 connect、出组件就 disconnect的思路写会出现两种极端要么切回来画面还停留在半小时前的状态连接没断但没刷新要么重复创建 RFB 实例导致一个容器里叠了两个 canvas。我现在的做法是在activated里检查rfb.value是否存在且处于连接态是的话不重连只做一次尺寸重算在deactivated里主动disconnect()并把实例置空。等于把 keep-alive 当普通销毁处理牺牲一点重连速度换取状态干净。对于远程桌面这种每次进来都希望看到最新画面的场景重连反而是好事。3. 画面有了但操作不听话三个高频故障的排查链路组件跑起来、画面出来了接下来八成会撞上三个问题。我把当时的排查过程完整写下来因为这些坑的处理方式不是改个参数就行而是要先定位到根因。3.1 鼠标位置整体偏移的根因定位第一次连上时画面正常但鼠标点 A 位置远端响应的是 B 位置而且偏移量随窗口大小变化——窗口越大偏得越多。这个现象非常有指向性偏移量跟缩放比例相关说明问题出在坐标映射而不是事件丢失。noVNC 需要知道 canvas 实际显示尺寸和远端画面尺寸之间的比例才能把浏览器里的鼠标坐标换算成远端坐标。它默认是通过读取 canvas 元素的clientWidth/clientHeight来算的。如果外层有 CSS transform 缩放、或者某个祖辈元素有zoom、scale读到的尺寸就和真实渲染尺寸对不上坐标自然就偏。排查链路我是这么走的先确认容器是不是干净。打开开发者工具选中 canvas看它的计算样式里有没有transform往上逐层父元素看。确认 canvas 的实际显示尺寸和 CSS 声明尺寸是否一致。用getBoundingClientRect()打一下和clientWidth对比不一致就说明有缩放干扰。把 VNC 组件从有动画的容器里挪出来单独放到一个没有任何 transform 的父元素下看偏移是否消失。最后定位到问题出在一个页面级的入场动画容器上它给内容加了transform: scale()。解决办法是让 VNC 容器完全脱离这个动画层动画只作用于其他区域。另外还有个次要因素scaleViewport true时 noVNC 靠 CSS 缩放 canvas如果你的全局 CSS 里有一条canvas { max-width: 100% }之类的规则也会干扰它自己的尺寸计算。给 VNC 的 canvas 留干净的环境是这类问题最省事的解法。3.2 键盘输入丢失与组合键异常键盘问题的表现有好几种得分开判断。第一种完全没反应。八成是焦点问题。noVNC 拿到画面之后需要主动把焦点拉到它的键盘捕获元素上但这个动作在 Vue 的组件树里可能被别的东西打断。可以在连接成功后手动调一次rfb.value.focus()或者在容器上加个点击事件用户点一下画面就把焦点交还给它。用户习惯上也会点一下再操作所以这个体验是可以接受的。第二种普通字母能打但 Ctrl、Alt、Shift 组合键失效。这类问题通常跟操作系统的按键约定有关。noVNC 提供了直接发送按键的接口可以绕过浏览器的事件捕获自己做按钮// 发送 CtrlAltDelete很多远程场景要用 rfb.value.sendCtrlAltDel() // 手动发一个组合键按下 Ctrl按 A松开 Ctrl rfb.value.sendKey(0xffe3, ControlLeft, true) // 按下 rfb.value.sendKey(0x41) // A rfb.value.sendKey(0xffe3, ControlLeft, false) // 松开这个技巧在需要做工具栏快捷按钮时特别有用比如界面上放一个重启锁屏按钮底层就是发一个组合键。第三种中文输入法打不出字。这个不是 bug而是 VNC 的按键协议本身只传按键事件不传输入法组合结果。远端要正常输入中文需要远端系统自己装了输入法你在浏览器里敲的是英文按键序列远端输入法去拼。如果你的场景是往远端粘贴一段中文文本用剪贴板走会更靠谱这在 5.2 会讲。3.3 打包上线后画布尺寸和布局异常本地开发一切正常npm run build之后上线VNC 区域要么高度塌成 0要么画布撑破了整个页面布局。这个问题的根因基本只有一个高度链路断了。本地开发时你可能会给容器写height: 100vh或者上层有个固定高度打包后进了真实的页面框架通常外面套了导航栏、侧边栏、内容区这些祖先元素的height没有明确值100%一路算下来就是auto最后容器高度为 0。canvas 拿到 0 高度noVNC 算出的缩放比例就是异常值画面就乱。处理办法是打通高度链最外层到 VNC 容器每一层都要有能算出具体值的尺寸。用 flex 布局时给内容区加min-height: 0是个关键技巧——flex 子项默认min-height: auto内容会把它顶开加上min-height: 0才能让它正确收缩并撑满。还有一个容易忽略的点打包时scoped样式和全局样式的冲突。某些 UI 框架会给全局canvas加样式或者给div设默认行高这些在开发环境被覆盖了、生产环境没覆盖住就会出现本地好、线上偏的情况。我的习惯是给 VNC 容器加一个专属类名在里面把可能被污染的属性显式重置一遍比如line-height: 0、font-size: 0、max-width: none虽然粗暴但稳定。另外建议在连接成功后做一次尺寸校正用ResizeObserver监听容器变化容器变了就通知 noVNC 重新算缩放。这个在侧边栏能折叠的布局里几乎是必需的否则用户折叠侧边栏之后画面就一直停留在旧比例。4. 上线必须过的一关鉴权、反向代理与 wss本地跑通只能算完成一半。真实环境要面对的是怎么让浏览器在 HTTPS 页面里连上 WebSocket、怎么防止别人随便连、怎么在一台服务器上管理多台机器的连接。4.1 凭证怎么传才不会裸奔先说一个基本约束如果页面是 HTTPS那 WebSocket 必须是wss://混合内容会被浏览器直接拦掉连报错都在控制台里一闪而过特别难查。所以先确认你的部署环境有证书、有域名。然后是凭证。noVNC 的 RFB 构造函数接受一个credentials对象传 VNC 密码但注意这个密码是明文放进 WebSocket 连接流程里的VNC 传统协议的认证本身也不是强加密的。所以绝对不能把密码写在打包后的前端代码里——那等于把密码公开。正确做法是密码由后端在建立连接时下发或者干脆不用 VNC 密码改用 websockify 层的 token 认证。websockify 支持 token 模式你准备一个映射文件每行是token : 目标地址machine-a: 192.168.1.10:5900 machine-b: 192.168.1.11:5900然后用参数启动websockify --token-pluginTokenFile \ --token-source/etc/websockify/tokens \ 6080前端这边连接地址就变成带 token 的形式比如wss://your-host/vnc/?tokenmachine-a。这样前端只知道一个不敏感的 token真实机器地址和映射关系全在服务端安全性和可维护性都上来了。要给某台机器换地址改配置文件就行前端不用动。4.2 Nginx 反代 WebSocket 的完整配置WebSocket 走反向代理和普通 HTTP 请求不一样必须显式转发升级头否则握手会停在 101 之前表现为连接超时或一直转圈。完整的配置大概是location /vnc/ { proxy_pass http://127.0.0.1:6080/; proxy_http_version 1.1; # 这两行是 WebSocket 升级的关键缺一不可 proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 远程桌面是长连接超时时间要放大默认 60s 会被掐断 proxy_read_timeout 3600s; proxy_send_timeout 3600s; # 关闭缓冲否则画面更新会被攒着一起发延迟明显 proxy_buffering off; }这里面最容易漏的是proxy_read_timeout。默认 60 秒如果用户只是看着画面不动手60 秒后连接就被 Nginx 掐了用户会看到画面突然断开。我一开始就是被这个坑了日志里满是连接重置查了半天才想到是超时。proxy_buffering off也很重要开着的话 Nginx 会攒够一批画面数据再发交互延迟肉眼可见地变高。4.3 一页多会话与连接复用如果需求是一个页面里同时看多台机器的桌面那每个 VNC 容器就是一个独立的 RFB 实例各自维护一条 WebSocket。这时候要注意两个资源问题一是浏览器对同一域名的 WebSocket 并发连接数有限制虽然通常够用但如果你要一次开十几个就得考虑布局上做标签页切换而不是全部平铺二是每路连接都在持续收画面数据CPU 和带宽是叠加的。我的经验是多于 4 路时非当前激活的会话主动降低画质或者干脆断开只保留当前可见的那一路高画质运行。具体怎么取舍取决于场景但永远不要让用户在一个页面里同时点亮八路全画质连接浏览器和服务器都扛不住。5. 把体验从能用推到好用的调优项功能通了之后剩下的都是体验问题。这些参数看起来都是小数值但调整前后的体感差别很大。5.1 画质、压缩与带宽的三方拉扯noVNC 暴露了几个关键参数理解它们才能调出合适的组合参数取值范围作用调大的影响qualityLevel0–9JPEG 编码质量更清晰带宽上升compressionLevel0–9压缩强度更省带宽CPU 上升可能变糊scaleViewport布尔本地缩放适配容器开启后画面自动适应不请求远端改分辨率resizeSession布尔请求远端改分辨率需要 VNC Server 支持改完更清晰但可能卡顿clipViewport布尔裁剪并允许拖拽查看适合需要看原始像素的大屏场景我的实际配置分两种场景。办公类操作点按钮、看日志用qualityLevel 5、compressionLevel 4画面够看且延迟低。图像审查类要看细节才把qualityLevel拉到 8 以上同时接受延迟上升。这里有个经验办公场景里用户感知最强的其实是响应速度而不是画质与其追求清晰不如把压缩调低让延迟小一点点击之后立刻有反馈体验反而更好。scaleViewport和resizeSession的关系也值得说清楚。前者是本地把画布缩放到容器大小远端分辨率不变画面里的字会变小但不会触发远端重排最稳。后者是反过来请求远端真的改成容器尺寸的分辨率字更清楚但很多老旧的 VNC Server 不支持而且每次改都有一两秒的黑屏。默认我建议scaleViewport true、resizeSession false等确认服务端支持了再考虑开。5.2 剪贴板、全屏与移动端适配剪贴板这块noVNC 能双向同步文本。远端复制的内容会通过clipboard事件传到浏览器你可以监听它然后写到浏览器剪贴板r.addEventListener(clipboard, (e) { navigator.clipboard?.writeText(e.detail.text).catch(() {}) })从浏览器往远端发则是rfb.value.clipboardPasteFrom(text)。注意浏览器对剪贴板写入有权限限制通常需要用户有交互动作比如点过按钮才允许所以别指望自动同步静默生效最好给一个粘贴到远端的按钮。前面提到的中文输入问题用这个按钮走剪贴板就是最稳的方案。全屏方面noVNC 的 canvas 可以直接配合浏览器的全屏接口。给一个按钮点击时对容器调requestFullscreen()退出时监听fullscreenchange事件再把尺寸校正一遍。全屏切换会引起容器尺寸突变如果不重新算缩放画面会保持在全屏前的大小看着很别扭。移动端要老实说一句noVNC 的手势支持有限触屏上的右键、拖拽、双击这些操作体验都不好。如果场景真的要求移动端可用通常要自己在 canvas 上叠一层手势识别把长按映射成右键、双指映射成滚轮。这块工作量不小建议先确认需求真实性再投入。5.3 断线重连与状态可见性远程桌面最忌讳断了没提示。用户可能对着一个冻住的画面操作半天以为卡了其实连接早就掉了。所以状态可视化是必须做的连接中、已连接、已断开、鉴权失败四种状态都要给明确的界面反馈。我在组件模板里放了个遮罩层非连接态盖在上面用户一眼就知道现在是什么情况。重连策略要区分意外断开和主动断开。判断依据是disconnect事件的detail.clean字段——它是布尔值主动断开为true意外断开为false。只有意外断开才值得自动重连而且要有退避别死循环let retry 0 r.addEventListener(disconnect, (e) { if (!e.detail.clean retry 3) { retry 1 const delay Math.min(1000 * Math.pow(2, retry), 8000) setTimeout(() connect(), delay) } })这个退避的意义在于如果服务端是真的挂了你一秒重连十次只会加重它的负担。指数退避给了服务端恢复的时间也让日志干净。最后分享一个我自己在项目里加的小东西在容器右下角做一个隐藏的调试信息条按特定组合键才显示里面打上当前连接的 URL、qualityLevel、最近一次断开的clean值和耗时。线上出问题的时候让值班同事按一下快捷键截个图发过来比远程让他念配置快得多。这个东西开发阶段花十分钟后面每次排查都能省下半小时。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →