上周运维群里弹出一条需求:把机房里几台工控机的操作界面搬到浏览器里,让值班同事在网页上点两下就能看到桌面,不用再抱着笔记本跑到现场插显示器。第一反应是上个 RDP 网关,但那些机器上跑的是老旧的图形化采集程序,只认 VNC 协议,也不可能为了这件事给每台机器重装系统。链路就这么定了:机器上继续跑 VNC Server,中间架一层协议转换的桥,前端在 Vue 项目里用 noVNC 把画面渲染到 canvas 上。
这套东西拆开看原理不复杂,真正落地时会撞上一堆细节:鼠标坐标偏了半个屏幕、键盘敲进去没反应、打包上线之后画布尺寸抽风、wss 握手被反向代理拦下、Vue 的响应式系统把 RFB 实例包了一层导致行为诡异……每一个都是那种"文档里不会写、但一定会遇到"的问题。这篇就把从链路原理到可复制组件代码的完整过程摆出来,适合已经会写 Vue、但第一次碰远程桌面嵌入的前端同学和负责部署的运维同学。
1. noVNC 在这条链路里的位置,以及它替代不了什么
很多人第一次接触 noVNC,以为它是一个"远程桌面软件"。其实它更像一个客户端库——把 VNC 的 RFB 协议翻译成浏览器能理解的东西,然后画在 canvas 上。搞清楚它在链路里站哪一格,后面所有的配置和排错才有依据。
1.1 浏览器只认 WebSocket,VNC 服务端只认 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 捕获事件 |
| 2 | noVNC RFB 实例 | 把键码转成 RFB KeyEvent 报文 |
| 3 | WebSocket | 二进制帧发到 websockify |
| 4 | websockify | 拆掉 WS 帧头,原样 TCP 转发到 5900 |
| 5 | VNC 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 class="vnc-wrap" ref="wrapRef"> <div class="vnc-screen" ref="screenRef"></div> <div v-if="status !== 'connected'" class="vnc-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 提供了直接发送按键的接口,可以绕过浏览器的事件捕获自己做按钮:
// 发送 Ctrl+Alt+Delete,很多远程场景要用 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-plugin=TokenFile \ --token-source=/etc/websockify/tokens \ 6080前端这边连接地址就变成带 token 的形式,比如wss://your-host/vnc/?token=machine-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 暴露了几个关键参数,理解它们才能调出合适的组合:
| 参数 | 取值范围 | 作用 | 调大的影响 |
|---|---|---|---|
qualityLevel | 0–9 | JPEG 编码质量 | 更清晰,带宽上升 |
compressionLevel | 0–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值和耗时。线上出问题的时候,让值班同事按一下快捷键截个图发过来,比远程让他念配置快得多。这个东西开发阶段花十分钟,后面每次排查都能省下半小时。