
1. 把远程桌面塞进 Vue 页面这件事到底在图什么做运维平台、云桌面控制台、设备调试面板这类项目的人早晚都会碰到同一个需求在一个已经写好的 Vue 后台里点一下某个设备右边直接弹出这块屏幕的实时画面鼠标键盘还能直接操作。不是截图轮询不是录屏回放是真真正正的远程桌面控制。我最早接触这个需求是在一个工业设备管理项目上前端一堆 Vue 写的图表和工单页面但现场那几十台工控机出了问题只能人跑过去或者让现场师傅开个屏幕共享软件体验非常割裂。后来方案定成了在 Vue 里嵌 noVNC用户在同一套权限体系下点开设备列表就能直接接管画面。整个链路跑通之后日常排障时间从平均二十分钟压到了两分钟以内。这篇东西就是把这套东西从零到一讲清楚noVNC 到底是什么服务端要准备什么Vue 里怎么封装成一个能复用的组件参数怎么调路由切换怎么不炸以及我在打包上线之后踩过的那些坑。适合已经会写 Vue、但对 RFB 协议和 WebSocket 转发完全没概念的人看也适合已经接了但效果不理想想优化的人看。核心关键词就三个Vue、noVNC、远程桌面控制。下面所有的内容都围绕这三个词展开。1.1 noVNC 的本质一个跑在浏览器里的 VNC 客户端很多人第一次听到 noVNC 会以为它是个服务端软件其实它就是一个纯前端库。VNC 协议本身叫 RFBRemote Framebuffer是一个基于 TCP 的二进制协议核心逻辑是服务端把屏幕切成一堆矩形区域rectangle只推送发生变化的那些像素块客户端负责拼回一张完整的画面。传统上你得装一个 VNC Viewer 客户端软件去连 5900 端口。noVNC 做的事情是用 JavaScript 把 RFB 协议在浏览器里实现了一遍画面渲染在canvas上鼠标键盘事件通过协议回传给服务端。浏览器不能直接开 TCP 连接所以中间必须有个把 WebSocket 转成 TCP 的桥这个桥就是 websockify。所以完整链路是浏览器 noVNCWebSocket→ websockify协议转换→ VNC ServerRFB over TCP→ 目标桌面。理解这条链路非常重要因为后面 90% 的问题排查都发生在到底是哪一段断了。前端白屏可能是 noVNC 没连上 websockify也可能是 websockify 连不上 5901还可能是 VNC Server 根本没起来。分清楚层次排查速度能快十倍。1.2 选型对比为什么不是自己写个 WebSocket 转发有人会想不就是传画面吗我自己用 WebSocket 传 JPEG 不就行了。我试过也见过别人试过结论是小规模演示可以真上生产基本会放弃。自己传图片的方案帧率、带宽、增量更新、鼠标坐标映射、键盘编码、剪贴板、多显示器每一样都要自己处理。而 noVNC 已经把 RFB 协议的各种编码Raw、CopyRect、RRE、Hextile、Tight、ZRLE全部实现好了TigerVNC 服务端还支持 Tight 编码加 JPEG 压缩实际带宽表现相当能打。几种常见方案的横向对比方案原理优点缺点适合场景noVNC websockifyRFB over WebSocket协议成熟、增量更新、几乎零延迟感需要服务端额外组件通用远程桌面、云桌面定时截图推流后端截图 WebSocket 推 JPEG实现简单卡顿明显、带宽浪费大、只能看不能控状态监控非交互WebRTC 方案屏幕采集 实时传输延迟极低需要信令服务、复杂、浏览器兼容有坑高实时性场景商用远程桌面 SDK完整封装开箱即用授权费用、私有化部署麻烦预算充足的产品从投入产出比看noVNC 是绝大多数自研后台的合理选择。它足够成熟用的人多遇到问题网上能搜到答案而且完全开源没有授权成本。1.3 整体链路拆解与关键技术点地图在动手之前先把技术点列出来心里有个地图后面按图施工就不会迷路。前端侧要搞定的事情有noVNC 库的引入方式这玩意儿在不同版本里导入路径不一样坑很深、canvas 容器尺寸控制、连接生命周期管理、密码认证失败的重试、剪贴板双向同步、CtrlAltDel 这类特殊按键的发送、以及 Vue 响应式系统对 RFB 实例的干扰。服务端侧要搞定的是VNC Server 的启动与密码设置、websockify 的端口映射、多台目标机的 token 路由、systemd 常驻、以及 Nginx 反代的 WebSocket 升级头。安全侧要搞定的是wss 加密、VNC 密码本身的 8 位限制、以及不要把 5901 端口直接暴露出去。这些点我会一个一个拆开讲每个都给出具体命令和代码。2. 先把服务端跑通再谈前端我强烈建议的顺序是先服务端后前端。理由很简单服务端能用vncviewer本地验证问题边界清晰前端一旦白屏你连是网络问题还是代码问题都分不清。2.1 VNC Server 与 websockify 的关系再说明这两个东西经常被搞混。VNC Server 装在被控机器上它监听 5900 display number比如 display :1 就是 5901 端口。它负责把本机桌面的画面按照 RFB 协议发出去。websockify 可以装在任何一台能访问到被控机器的服务器上它监听一个 WebSocket 端口默认 6080把收到的 WebSocket 二进制帧剥掉封装转成纯 TCP 流打给 5901。一个常见的部署选择是把 websockify 放在有公网入口的那台跳板机上内网的几十台机器都开 VNC Server通过 token 文件做映射。这样只暴露一个端口安全面小很多。注意websockify 本身不做任何鉴权谁能连上它的端口谁就能连到背后的 VNC。所以它前面必须有一层认证后面会讲怎么做。2.2 从零起一套可测试的环境先说被控机的部分。以常见的 Linux 桌面环境为例装 TigerVNC# Debian/Ubuntu 系 sudo apt update sudo apt install -y tigervnc-standalone-server tigervnc-common # 设置 VNC 密码 vncpasswd # 会提示输入并确认密码还会问是否设置 view-only 密码一般选 n然后创建启动配置。TigerVNC 默认会读~/.vnc/xstartupmkdir -p ~/.vnc cat ~/.vnc/xstartup EOF #!/bin/sh unset SESSION_MANAGER unset DBUS_SESSION_BUS_ADDRESS exec startxfce4 EOF chmod x ~/.vnc/xstartup启动一个 display 号为 1 的会话vncserver :1 -geometry 1440x900 -depth 24 -localhost no参数说明一下-geometry是虚拟桌面的分辨率直接影响前端 canvas 的初始尺寸-depth 24是色深24 位够用16 位能省带宽但会有色带-localhost no是允许非本机连接如果 websockify 和 VNC Server 不在同一台机器上就必须加。验证一下端口ss -lntp | grep 5901能听到 5901 就说明 VNC Server 起来了。这时候用本地 vncviewer 连127.0.0.1:5901能出画面说明前半段没问题。接下来装 websockifysudo apt install -y websockify最简启动websockify 6080 127.0.0.1:5901这行的意思是在 6080 上监听 WebSocket把流量转发到本机的 5901。如果你想用 Docker 快速体验社区有现成的 xfce vnc websockify 一体化镜像起一个就能直接连适合验证前端代码但不建议直接拿去上生产因为密码和端口策略基本都是写死的。2.3 用 systemd 把 websockify 常驻命令行直接起的方式一关终端就没了生产环境必须托管。写一个 unit 文件# /etc/systemd/system/websockify.service [Unit] Descriptionwebsockify for VNC bridge Afternetwork.target [Service] Typesimple Userroot ExecStart/usr/bin/websockify --web/usr/share/novnc 6080 127.0.0.1:5901 Restartalways RestartSec3 StandardOutputjournal StandardErrorjournal [Install] WantedBymulti-user.targetsudo systemctl daemon-reload sudo systemctl enable --now websockify sudo systemctl status websockify有个坑要提醒--web参数指向的是 noVNC 自带的静态页面目录那个页面是给直接用浏览器访问 6080 看远程桌面这种场景用的。如果你前端是自己用 npm 装 noVNC 库其实不需要这个参数去掉更干净。很多人看到 6080 能打开一个页面就以为接好了其实那是 websockify 自带的 demo跟你的 Vue 项目没有任何关系。2.4 多目标机路由与反向代理一台 websockify 对一台 VNC 显然不够用。websockify 支持 token 插件用文件做映射cat /etc/websockify/tokens.conf EOF desktop-a: 10.0.0.11:5901 desktop-b: 10.0.0.12:5901 desktop-c: 10.0.0.13:5901 EOF启动时加参数websockify --token-pluginTokenFile --token-source/etc/websockify/tokens.conf 6080这时候前端连接的地址就变成ws://host:6080/desktop-a路径部分就是 token。这个机制非常实用因为你可以让后端在用户点击设备时动态生成一个带签名的路径前端拿到直接连。前置 Nginx 反代要记得处理 WebSocket 升级头这是新手最容易漏的地方location /ws/vnc/ { proxy_pass http://127.0.0.1:6080/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; }proxy_buffering off这行别省远程桌面是长连接持续推流开了缓冲会导致画面延迟越来越大。proxy_read_timeout也要调大默认 60 秒会把空闲连接掐掉用户切出去喝杯水回来就断了。注意如果主站是 https那么 WebSocket 必须走wss://浏览器会拦截混合内容。这是上线后最常见的本地好好的部署就白屏的原因。3. Vue 项目接入 noVNC 的完整实操服务端跑通之后前端其实代码量不大但细节非常多。3.1 依赖安装与导入路径的坑npm install novnc/novnc装完之后你会发现这个包的导入路径在不同版本之间差别很大。老版本是novnc/core/rfb.js新版本改成了novnc/novnc/core/rfb.js// 新版推荐 import RFB from novnc/novnc/core/rfb.js // 老版本写法现在会报模块找不到 // import RFB from novnc/core/rfb我实际遇到过的一个问题是某些打包器会因为这个包没有正确的exports字段而解析失败。解决办法是写全.js后缀如果还不行就在构建配置里显式加别名// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue import path from path export default defineConfig({ plugins: [vue()], resolve: { alias: { novnc/novnc: path.resolve(__dirname, node_modules/novnc/novnc), }, }, })另外这个包装完之后体积大概是 200KB 出头未压缩如果在意首屏体积建议把引入 VNC 组件的路由做成异步加载别打进主 chunk。3.2 封装一个可复用的 VncScreen 组件我不建议把连接逻辑直接写在页面里抽成组件好处是连接生命周期好管样式好隔离多个地方用不会互相干扰。template div classvnc-wrapper div classvnc-toolbar button clicksendCad发送 CtrlAltDel/button button clicktoggleScale{{ scaled ? 关闭自适应 : 开启自适应 }}/button span classstatus :classstatusClass{{ statusText }}/span /div div refscreenRef classvnc-screen/div /div /template script setup import { ref, onMounted, onBeforeUnmount, shallowRef } from vue import RFB from novnc/novnc/core/rfb.js const props defineProps({ wsUrl: { type: String, required: true }, password: { type: String, default: }, viewOnly: { type: Boolean, default: false }, }) const emit defineEmits([connected, disconnected, error]) const screenRef ref(null) const rfb shallowRef(null) // 关键必须是 shallowRef const statusText ref(未连接) const statusClass ref(idle) const scaled ref(true) /script这里shallowRef是关键中的关键我在 3.5 节展开讲为什么。3.3 连接参数逐项拆解连接只是new RFB(target, url, options)但那几个布尔属性才是决定体验的地方function connect() { if (!screenRef.value) return rfb.value new RFB(screenRef.value, props.wsUrl, { credentials: { password: props.password }, wsProtocols: [binary], }) const conn rfb.value // 画面自适应canvas 跟随容器大小缩放推荐开 conn.scaleViewport true // 会话自适应反过来让远端改分辨率对工控机不友好建议关 conn.resizeSession false // 只读模式看监控用 conn.viewOnly props.viewOnly // 画面超出容器时是否裁剪配合 scaleViewport 用 conn.clipViewport false // 显示本地光标远程画面刷新慢时体验更好 conn.showDotCursor true // 背景色加载过程中显示的底色 conn.background #1e1e1e // 画质等级0-9数字越大越清晰也越费带宽 conn.qualityLevel 6 // 压缩等级0-9越大越省带宽但越吃 CPU conn.compressionLevel 2 bindEvents(conn) }这几个参数里scaleViewport和resizeSession最容易搞混。前者是前端本地缩放 canvas远端分辨率不变画面可能有点糊但连接稳定后者是发消息让远端真的改分辨率方便但要求远端有对应的支持在 Windows 的被控端上经常失效。我的实践经验是固定远端分辨率比如统一设成 1440x900前端开scaleViewport这样行为最可控不同屏幕尺寸的同事看到的画面比例也一致。qualityLevel和compressionLevel是一对需要权衡的参数。内网环境可以把 quality 拉到 8、compression 降到 1画面锐利跨机房或者弱网就反过来quality 4 到 6 之间compression 提到 5 到 7。这两个值在运行时也能改你完全可以做一个画质切换按钮。3.4 事件、剪贴板与快捷键处理事件绑定是体验的关键部分function bindEvents(conn) { conn.addEventListener(connect, () { statusText.value 已连接 statusClass.value ok emit(connected) }) conn.addEventListener(disconnect, (e) { statusText.value e.detail.clean ? 已断开 : 连接异常中断 statusClass.value warn emit(disconnected, e.detail) }) // 服务端要求密码但我们没传或者传错了 conn.addEventListener(credentialsrequired, () { conn.sendCredentials({ password: props.password }) }) conn.addEventListener(securityfailure, (e) { statusText.value 认证失败${e.detail.reason || e.detail.status} statusClass.value error emit(error, e.detail) }) // 远端剪贴板内容变化 conn.addEventListener(clipboard, (e) { navigator.clipboard.writeText(e.detail.text).catch(() {}) }) }securityfailure事件一定要处理否则密码错的时候前端只会一直转圈用户完全不知道发生了什么。e.detail.status是数字状态码e.detail.reason有时为空建议直接提示密码错误请重新输入更友好。本地复制到远端需要一个手动触发因为浏览器出于安全考虑不允许监听全局剪贴板async function syncClipboard() { try { const text await navigator.clipboard.readText() rfb.value?.clipboardPasteFrom(text) } catch (err) { console.warn(读取剪贴板失败可能是非 https 或未授权, err) } } function sendCad() { rfb.value?.sendCtrlAltDel() }注意navigator.clipboard.readText()在非安全上下文http下直接不可用必须是 https 或者 localhost。这也是为什么强烈建议本地开发也用 https 隧道或者直接用 localhost 访问。3.5 Vue 生命周期与响应式陷阱这是我最想强调的一节因为这个问题我见过至少三个人踩。绝对不要把 RFB 实例放进reactive或者ref里直接存。Vue 3 的ref和reactive会对对象做深度代理Proxy而 noVNC 内部有大量基于this的判断和私有字段访问一旦被 Proxy 包住会出现莫名其妙的报错比如属性读不到、方法执行上下文错乱、性能断崖式下降因为每一帧的画面更新都在触发响应式依赖收集。正确做法是用shallowRef或者干脆用一个模块级的普通变量// 推荐 const rfb shallowRef(null) // 也可以只要不出现在模板里 let rfbInstance null // 千万别这么写 const state reactive({ rfb: null }) // 会炸同样的道理Vue 2 里不要把实例放data()返回的对象里data会做递归响应式转换。应该挂在this.$options或者组件外部的模块作用域变量上。生命周期这边onMounted里连接onBeforeUnmount里必须断开并且清理 DOMonMounted(() { connect() }) onBeforeUnmount(() { destroy() }) function destroy() { const conn rfb.value if (!conn) return // 先摘掉事件避免断开时回调里访问已卸载的组件 conn.removeEventListener(connect, () {}) try { conn.disconnect() } catch (e) { console.warn(断开时异常, e) } rfb.value null // noVNC 会在容器里插入 canvas手动清一次更保险 if (screenRef.value) screenRef.value.innerHTML }不清理的后果是什么路由切走了WebSocket 还开着服务端那边连接一直挂着用户的会话数量就一直在涨。切回来再连一次就有两条连接同时往一个 canvas 上写画面会闪。这个坑排查起来很隐蔽因为浏览器开发者工具里看到的是多个 ws 连接很多人第一反应是为什么会重连。4. 多实例、路由切换与连接复用4.1 动态切换目标主机设备列表点击切换是很常见的交互。最简单的做法是销毁旧连接再建新的async function switchTarget(newUrl) { destroy() await nextTick() // 等 DOM 清干净 props.wsUrl newUrl // 实际项目里用 watch 监听 connect() }如果你用watch监听wsUrl记得加flush: post保证在 DOM 更新之后再建连接否则新 canvas 还没挂上去就创建 RFB会渲染到旧节点上。4.2 keep-alive 下的连接处理后台系统经常用keep-alive缓存页面。被缓存时组件不销毁onBeforeUnmount不触发连接不会断——这其实正是很多场景想要的用户切走再回来画面还在体验非常好。但有两点要注意。第一是网络状态可能已经变了连接其实已经死了但前端不知道需要在onActivated里加个探活或者干脆重连。第二是缓存多个标签页会同时保持多条 VNC 连接对服务端是实打实的资源占用建议加个上限或者做后台断连。import { onActivated, onDeactivated } from vue onActivated(() { if (!rfb.value) connect() // 也可以在这里判断连接是否还活着 }) onDeactivated(() { // 视业务决定是断开还是保持 // 对资源敏感的场景建议断开 })我的做法是在设备详情页这种临时用一下的场景断开连接在值班台这种需要长期盯着的场景保持连接用路由 meta 配置来控制。5. 踩坑速查与经验交接5.1 常见问题速查表下面这张表是我这几年攒下来的基本都是真实遇到的现象可能原因定位方法解决前端白屏控制台无报错ws 地址写错或 wsServer 未启动Network 面板看 ws 请求是否 101检查 websockify 是否存活、端口是否放行握手返回 502Nginx 缺少 Upgrade 头看 Nginx error.log补Upgrade和Connection配置页面 httpsws 连接被阻断混合内容拦截控制台有 Mixed Content 提示改走 wss配好证书密码正确但反复弹认证密码超过 8 位被截断用 8 位以内密码测试VNC 传统认证只取前 8 位画面卡顿、延迟持续增大Nginx 开了 buffering 或 quality 过高对比直连和反代的表现关 buffering降 quality画面比例拉伸变形容器尺寸变化但没通知 RFB缩放窗口观察监听 resize 调用scaleViewport重设输入法打字丢失字符中文输入法组合键事件未正确上报切英文测试输入完再回车或在本地编辑好粘贴vue 控制台报 Proxy 相关错误RFB 实例被响应式代理打印实例看是否是 Proxy改用 shallowRef路由切回后画面闪一下旧连接未断开ws 面板看连接数onBeforeUnmount 里 disconnect打包后布局错乱canvas 无约束撑破父容器本地 dev 正常、build 异常容器固定宽高 overflow hidden5.2 打包后布局异常到底是哪儿出了问题这个现象太典型了值得单独说。本地npm run dev一切正常npm run build之后页面就崩了工具栏跑到屏幕外或者整个容器被撑到几千像素高。根本原因是 noVNC 会在容器里插入一个 canvas并且给 canvas 设置内联的width/height属性等于远端分辨率。开发环境下 CSS 加载顺序和构建后不一样可能侥幸没暴露打包后 CSS 被提取合并某些样式覆盖关系就变了。稳定的解法是三条第一容器给死尺寸不要靠内容撑.vnc-screen { width: 100%; height: calc(100vh - 120px); overflow: hidden; background: #1e1e1e; } .vnc-screen canvas { display: block; max-width: 100%; }第二确保容器有明确的定位上下文canvas 是绝对定位的时候不会跑偏.vnc-screen { position: relative; }第三窗口 resize 时要主动通知function onResize() { if (rfb.value?.scaleViewport) { // 重新赋值触发重算 rfb.value.scaleViewport false rfb.value.scaleViewport true } } onMounted(() window.addEventListener(resize, onResize)) onBeforeUnmount(() window.removeEventListener(resize, onResize))还有一个隐蔽问题如果用了transform: scale()或者zoom做整体缩放鼠标坐标和实际画面会错位因为 noVNC 拿到的坐标是变换之后的。这种情况建议不要对 VNC 容器做缩放要适配小屏就调远端分辨率。5.3 性能与安全上的几点补充性能方面几个实际有效的做法。一是分辨率别贪大1440x900 足够看清单个窗口1920x1080 在弱网下体验明显变差。二是色深用 24 位就够16 位在有大面积渐变的时候会有色带但如果是纯文字界面完全可以接受。三是内网场景可以把qualityLevel拉到 8 以上视觉上和本地桌面几乎没区别。带宽估算给个参考1440x900 分辨率下静止画面基本不消耗流量因为 RFB 是增量推送滚动页面或者拖窗口这种大范围变化瞬间会冲到几 Mbps长时间平均值在 200Kbps 到 1Mbps 之间具体取决于画面变化频率。所以一条 2M 的专线带十几台同时在线是可行的但要是十几个人都在同时拖窗口就会互相抢带宽。安全方面几条硬性建议。第一5901 绝对不要暴露到公网只允许 websockify 所在机器访问用防火墙或者安全组限制。第二VNC 密码只有 8 位有效长度单独用它做认证太弱正确做法是在 websockify 前面加一层应用鉴权比如 Nginx 用auth_request转发到一个鉴权接口或者后端签发带时效的 token 拼在 ws 路径上websockify 用 token 文件或 token 插件校验。第三全链路走 wss证书别嫌麻烦。第四给每个用户开独立的 VNC 会话不要多人共用一个 display不然鼠标会打架。最后分享一个小技巧。调试阶段如果怀疑是 websockify 的问题可以用websockify --verbose看详细日志它会打印每个连接的目标地址和字节数。再配合服务器上的tcpdump -i any port 5901一眼就能看出流量到底有没有打到 VNC Server 上。这招帮我定位过一次前端连上了但画面全黑的问题最后发现是 VNC Server 起来了但 xstartup 里没写 exec 桌面环境远端压根没有桌面可显示。