1. 为什么要在 Vue 里引入 noVNC先把场景说清楚。你手上有一个跑在服务器上的图形界面程序,比如一套工业组态软件、一台云主机的桌面环境、一块开发板的 X11 界面,或者某个只能在 Windows 或 Linux 图形界面下运行的老工具。你想在自己的 Vue 后台管理系统里直接看到这块屏幕、点鼠标、敲键盘,而不是再开一个 VNC 客户端软件去连。这时候 noVNC 就是最顺手的选择。noVNC 本质是一个纯前端的 VNC 客户端。它把 VNC 协议用 JavaScript 重新实现了一遍,底层靠 HTML5 Canvas 画屏幕、靠 WebSocket 传输数据,所以浏览器打开就能用,不需要装插件、不需要装 ActiveX。而 Vue 负责的是外层这层壳:登录鉴权、连接参数下发、多屏切换、全屏控制、工具栏按钮、状态提示,这些逻辑交给 Vue 处理是最合适的。我自己的判断是,这套组合最适合三类人:做后台管理系统的前端,需要把远程桌面作为一个功能页嵌进去,而不是让运维人员跳出去用第三方工具;做工业或物联网相关项目,现场设备有图形界面,但只开放了内网端口,希望统一在 Web 端管控;做云桌面 / 云主机管理,需要给不同用户分配不同机器的画面,并且要控制权限。它解决的问题很直接:把原生桌面画面,变成 Vue 应用里的一个可交互组件。读这篇文章你不需要精通 VNC 协议,只要会写 Vue、能跑起一个 Node 环境,后面的步骤基本可以照着走。我会把踩过的坑、参数怎么选、为什么这么选都讲透,而不是只丢一段能跑的代码给你。需要提前说明的是,noVNC 只是一个客户端,它必须有一个服务器端的 VNC 服务 一个 WebSocket 代理配合才跑得起来。这个链路是整件事的骨架,下一节我会先把骨架拆开。2. 整条链路是怎么串起来的2.1 从屏幕到浏览器,数据经过了哪些手很多人第一次接触 noVNC,以为装个 npm 包就能连远程桌面,结果连不上就开始怀疑代码。其实真正要理解的是这条链路:远程主机的图形界面 ↓ (VNC 协议,TCP 5900 端口) VNC Server(x11vnc / TigerVNC / TightVNC) ↓ (TCP 5900) WebSocket 代理(websockify) ↓ (WebSocket,通常 ws://host:6080) noVNC 前端(在 Vue 里 new RFB) ↓ 浏览器 Canvas关键点在中间那个WebSocket 代理。因为浏览器的原生 WebSocket 只能连 ws/wss,没法直接开一条裸 TCP 去连 5900 端口,所以必须有一个中间人把 WebSocket 的帧拆开、转成 TCP 字节流发给 VNC Server,反过来也一样。这个中间人最常用的就是websockify。理解了这一层,你就能明白为什么会出现这些典型问题:画面能连上但一片黑 → 多半是 VNC Server 没起,或者代理连错了目标端口;连不上、报 1006 → 多半是 WebSocket 地址写错,或者代理没启动;能连但延迟巨大 → 多半是编码方式没优化,或者网络链路绕远了。Vue 在这个架构里处于最上层,它不关心底层字节怎么走,只关心我该连哪个 ws 地址、用什么密码、连上之后怎么把画面塞进 DOM。所以你在 Vue 里写的代码其实很薄,真正复杂的是环境搭建和参数调优。2.2 为什么用 noVNC 而不是别的方案市面上做 Web 远程桌面还有几条路,简单对比一下,方便你做选型判断:方案原理优点缺点noVNC纯前端 VNC 客户端轻量、开源、嵌入简单、Vue 友好依赖 VNC Server,画质受编码影响Guacamole服务端转协议,前端只收图支持 RDP/SSH/VNC 多协议需要部署 Java 网关,较重自研 Canvas 推流服务端截图推流完全可控工作量大,交互延迟高商业 Web 终端厂商封装开箱即用授权成本高,定制受限如果你只是想在 Vue 里嵌一个 VNC 画面,noVNC 的性价比最高。它的 npm 包novnc/novnc直接import就能用,不像 Guacamole 那样要先搭一整套网关。当然,如果你的场景还要同时支持 RDP 和 SSH,那 Guacamole 更省事。选型没有绝对对错,取决于你后面要不要扩展协议。我这里选 noVNC,还有一个很实际的原因:它的核心类RFB的 API 非常干净。连、断、发按键、发剪贴板、改画质,都是几个方法调用,包一层 Vue 组件就很舒服。3. 环境准备:服务端这台机器要先弄好3.1 装一个 VNC Server这一步是在被控的那台机器上做的,不是在你的 Vue 项目里。以常见的 Linux 桌面为例,先装一个 VNC 服务端。TigerVNC 和 x11vnc 是两个最常用的选择:TigerVNC:自带独立会话,适合多用户、每个用户一个桌面;x11vnc:直接共享当前物理屏幕,适合我就要看现在这台机器屏幕上是什么。如果你是要接管一块已经登录的桌面,选 x11vnc 更合适,因为它共享的是:0这块真实屏幕:# 安装 x11vnc sudo apt-get update sudo apt-get install -y x11vnc # 启动,监听 5900,设置密码 x11vnc -display :0 -rfbport 5900 -rfbauth ~/.vnc/passwd -forever -shared第一次跑之前要先设密码:x11vnc -storepasswd # 会提示你输入密码,默认写到 ~/.vnc/passwd-forever表示客户端断开后服务继续监听,-shared表示允许多个客户端同时连同一个屏幕。这两个参数在实际项目里几乎必加,否则你调试的时候断一次就要重启一次服务。如果你用的是 TigerVNC,启动方式类似:vncserver :1 -geometry 1920x1080 -depth 24 # 默认监听 5901(5900 显示号)注意5900 显示号这个规则:显示号是 1,端口就是 5901;显示号是 2,端口就是 5902。这个点很多人第一次会算错,连半天连不上。提示:生产环境别用 5900 这种默认端口直接暴露在公网。VNC 协议本身是老协议,认证强度有限。正确做法是让它只监听内网,通过 websockify 再套一层,后端做鉴权,前端只连你的 WebSocket 地址。3.2 起一个 websockify 代理装好 VNC Server 后,下一步是把 TCP 5900 转成 WebSocket。websockify 是最标准的工具:# 安装 sudo apt-get install -y websockify # 把本机 5900 的 VNC 转成 6080 的 WebSocket websockify 6080 localhost:5900跑起来后,WebSocket 地址就是ws://你的服务器IP:6080。前端 noVNC 连的就是这个地址,而不是 5900。如果你想让前端直接用/websockify这种路径走反向代理(生产环境更常见),可以用 Nginx 转发:location /websockify { 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_read_timeout 3600s; proxy_send_timeout 3600s; }这两个Upgrade和Connection头是 WebSocket 升级握手的关键,漏了的话浏览器会报握手失败。proxy_read_timeout也建议调大,不然长时间没数据交互时连接会被 Nginx 掐掉。注意:websockify 启动时可以用--web参数顺便托管 noVNC 的静态文件,但既然我们的画面是嵌在 Vue 里,这个参数一般不需要,让 websockify 只做纯粹的协议转换更干净。4. 在 Vue 项目里把 noVNC 嵌进去4.1 安装依赖与最小可跑示例到这一步才进入 Vue 的地盘。先装包:npm install novnc/novnc然后在需要一个组件来承载画面。noVNC 的官方推荐用法是new RFB(target, url, options),其中target是一个 DOM 元素,它会自动在里面创建一个 Canvas。写一个最小的 Vue 3 组件大概是这个结构:template div refscreenRef classvnc-screen/div /template script setup import { ref, onMounted, onBeforeUnmount } from vue import RFB from novnc/novnc/core/rfb const screenRef ref(null) let rfb null onMounted(() { // 注意:一定要用 core/rfb,而不是包的默认入口 rfb new RFB(screenRef.value, ws://your-host:6080, { credentials: { password: your-vnc-password } }) rfb.scaleViewport true rfb.resizeSession false rfb.viewOnly false rfb.addEventListener(connect, () { console.log(远程桌面已连接) }) rfb.addEventListener(disconnect, (e) { console.log(连接断开, e.detail.clean) }) rfb.addEventListener(credentialsrequired, () { // 密码错或未提供时会触发,可以在这里弹框让用户输入 }) }) onBeforeUnmount(() { if (rfb) { rfb.disconnect() rfb null } }) /script style scoped .vnc-screen { width: 100%; height: 100%; background: #000; } /style这里有几个新手最容易踩的点,我一个个拆:第一,引入路径。novnc/novnc的包入口是一个 UMD 打包文件,很多构建环境(尤其是 Vite ESM)下直接import RFB from novnc/novnc会报错或者行为诡异。稳妥的做法是精确引到core/rfb,即import RFB from novnc/novnc/core/rfb。我自己在两个项目上都验证过,这样引最稳。第二,screenRef容器必须有明确宽高。noVNC 内部是根据容器尺寸算 Canvas 大小的,如果父元素高度是 0,画面就是一条线。常规做法是给容器width:100%; height:100%,并且保证它的父级也有高度(比如父级用 flex 撑满)。第三,别忘了在onBeforeUnmount里disconnect。Vue 组件销毁了但 WebSocket 还挂着,时间一长服务器端会堆积一堆僵尸连接。这个坑我在一个多标签页的后台里遇到过,页签切来切去最后服务端被拖垮。4.2 把连接参数做成组件 props实际项目里不可能把 ws 地址和密码写死在组件里,更合理的做法是父组件下发。改成 props 之后大致长这样:script setup const props defineProps({ wsUrl: { type: String, required: true }, password: { type: String, default: }, viewOnly: { type: Boolean, default: false }, shared: { type: Boolean, default: true } }) /script然后new RFB时把这些参数带进去。这样你就能在列表页点不同机器,传不同的wsUrl进来,复用一个组件。这里有个设计上的选择要讲清楚:密码到底应该由前端传,还是后端在代理层处理?两种做法各有取舍:前端传:实现简单,credentials直接给 noVNC,一个组件搞定。缺点是密码会出现在前端代码或接口返回里,安全性依赖 HTTPS 和接口鉴权;后端代理层管理:websockify 侧配置好目标机器的密码,前端只连 WebSocket,不接触任何 VNC 密码。安全,但需要你在 websockify 启动时指定--auth-plugin或者用支持密码转发的代理方式。我个人的习惯是,内部系统、可信网络下前端传就够了;一旦要开放给外部用户,一定走第二种,让密码永远不出服务端。这一点在选型阶段就要定下来,免得后面重构。5. 画面显示与交互的细节调优5.1 缩放、自适应与高清屏适配noVNC 有三个和画面显示强相关的属性,很多画面糊画面被裁掉鼠标对不上的问题都是这几个没设对:scaleViewport:把远程画面缩放到容器大小。开启后画面完整显示,但会变形或缩小;resizeSession:反过来,让远程桌面分辨率去适配容器大小(需要服务端支持);clipViewport:画面比容器大时,允许滚动查看。对大多数后台系统来说,scaleViewport true是最稳的,用户在一个固定大小的卡片里就能看到整个桌面。如果用户的屏幕尺寸差异很大,可以考虑resizeSession true,让远程桌面本身去改变分辨率,这样不会因为缩放而变糊。// 铺满容器,不变形 rfb.scaleViewport true // 让远程桌面跟随容器改分辨率(服务端支持时) rfb.resizeSession true // 大画面允许滚动 rfb.clipViewport false提示:resizeSession true依赖服务端的 RandR 扩展支持,不是所有 VNC Server 都实现了。开启前先在目标机器上测一下,否则可能出现容器变了但桌面没变的情况。还有一个容易被忽略的点:高分屏(devicePixelRatio 大于 1)下的画面会偏糊。因为 noVNC 画的是位图,缩放后会有插值损失。如果用户对清晰度要求高,建议关闭scaleViewport,让画面按 1:1 显示,配合clipViewport滚动查看。这是清晰度和完整性的取舍,没有银弹。5.2 鼠标、键盘、剪贴板怎么接noVNC 默认就会处理鼠标和键盘事件,不需要你手动绑。但有几种情况需要额外处理:剪贴板。用户在远程桌面里复制,想粘到本地,或者反过来。noVNC 需要显式开启:rfb.addEventListener(clipboard, (e) { // e.detail.text 是远程桌面发过来的文本 navigator.clipboard.writeText(e.detail.text) }) // 把本地文本送到远程 rfb.clipboardPasteFrom(要粘贴的文本)注意浏览器出于安全限制,navigator.clipboard只能在 HTTPS 或 localhost 下用,而且需要用户有交互。所以剪贴板同步这个功能,建议做成一个工具栏按钮,点了才读。特殊按键。CtrlAltDel 这类组合键会被操作系统截走,noVNC 提供了一个sendCtrlAltDel()方法专门发这个:rfb.sendCtrlAltDel()类似的还有sendKey(keysym, code, down)这种底层方法,一般用不到,但要做自定义快捷键映射时会用到。焦点问题。用户点了画面之后键盘才生效,如果画面没获得焦点,打字无反应。这个可以通过给容器加tabindex并在点击时focus()来改善体验。6. 常见问题与排查技巧实录6.1 连不上、黑屏、断连的排查顺序这类问题最考验排查思路,我整理成一个速查表,按出现频率从高到低排:现象可能原因排查方法连接立即失败,报 1006WebSocket 地址错、代理没启动用wscat -c ws://host:6080测代理是否活着连上但黑屏VNC Server 没起或连错端口在服务器上vncviewer localhost:5900本地验证画面出现但鼠标点不准缩放比例不对关掉scaleViewport用 1:1 对比用一会儿就断开Nginx 超时或代理超时调大proxy_read_timeout,加心跳提示需要密码但输了没反应密码错或认证方式不匹配检查 VNC Server 的认证配置画面卡顿严重编码方式或网络带宽问题改用 Tight 编码,降低色深排查顺序建议这样走:先在服务器本机用 VNC 客户端直连 5900,确认 VNC Server 是好的;再用wscat或浏览器控制台测 6080 代理是否正常;最后才看前端代码。这个顺序能帮你快速定位问题出在哪一层,而不是盲目改前端。我印象最深的一次,前端连不上,查了两小时代码,最后发现是服务器防火墙把 6080 端口挡了。所以每加一层,就单独验证一层,是排查这类链路问题最省时间的方法。6.2 性能与稳定性上的经验几个实际项目里总结出来的调优点:色深别贪高。24 位真彩好看,但带宽是 16 位或 8 位的两三倍。如果是运维用途,8 位或 16 位完全够用,画面刷新会明显更跟手。这个在服务启动参数里设(TigerVNC 是-depth 16)。编码方式影响很大。Tight 编码对静态画面压缩率高,适合办公桌面;Hextile 更省 CPU,适合低性能服务器。这个通常由服务端和客户端协商,noVNC 侧不用手动设,但你要知道它存在,调优时才有方向。加心跳防断连。长时间没人操作时,WebSocket 可能被中间的网关掐掉。可以在 Vue 侧定时判断连接状态,断了就自动重连:let retryTimer null function connect() { rfb new RFB(screenRef.value, props.wsUrl, { credentials: { password: props.password } }) rfb.addEventListener(disconnect, (e) { if (!e.detail.clean) { // 非正常断开,3 秒后重连 retryTimer setTimeout(connect, 3000) } }) }注意重连前要先清理旧的实例,不然会叠加多个连接。自动重连要做好次数限制,不然服务器真挂了会变成死循环。注意:自动重连只在意外断开时触发。如果是用户主动关闭页面或切换机器导致的断开,不应该重连,否则会跟用户的预期打架。多实例场景要注意资源释放。如果页面上可能同时显示多个远程桌面,每个 RFB 实例都会占独立的 WebSocket 和 Canvas,内存占用会线性增长。我给的建议是同一时间只保留一个活跃连接,切换时先断旧的再连新的。7. 从能跑到好用,还差哪些工程化处理7.1 权限控制与连接鉴权一个能上生产的功能,鉴权是绕不开的。理想的做法是:Vue 侧点击打开远程桌面时,先调你自己的后端接口,后端校验这个用户有没有权限访问这台机器,然后返回一个临时的、带时效的 WebSocket 地址或者一次性 token。前端拿这个地址去连 noVNC。这样做的价值在于:用户看不到真实的 VNC 密码,密码永远在服务端;每次连接都要经过一次后端授权,能记录审计日志;token 有过期时间,泄露了影响也有限。配合前面说的密码由代理层管理,整条链路就闭环了。这部分代码因后端框架而异,思路是一致的,我就不展开具体实现了。7.2 全屏、工具栏与用户体验noVNC 显示的画面默认在容器里,但用户往往想要全屏。RFB的容器元素有requestFullscreen()可以用:function toggleFullscreen() { const el screenRef.value if (document.fullscreenElement) { document.exitFullscreen() } else { el.requestFullscreen() } }配合一个工具栏,放上全屏发送 CtrlAltDel断开重连粘贴这几个按钮,体验就完整了。工具栏用普通的 Vue 组件写即可,noVNC 不干涉外层 UI。还有一个细节:加载状态和错误提示。远程桌面连接是异步的,尤其是跨网络时可能要等一两秒。建议在连接期间显示一个 loading,连接失败时显示明确的错误文案,而不是一片黑屏让用户干等。这个体验上的小改进,在实际使用中的好评度非常高。7.3 打包与部署的注意事项Vue 项目打包(比如 Vitenpm run build)时,novnc/novnc里的某些代码可能被优化掉或者处理不当,导致线上跟本地表现不一致。我遇到过打包后画面出不来的情况,最后定位是构建工具把某些动态引用处理掉了。几个预防措施:确认 ws 地址在打包后是运行时读取(走接口或环境变量),而不是编译时写死;如果用了按需加载,注意 noVNC 的引入路径不要被 tree-shaking 误伤;部署后一定要用真实环境复测一遍连接流程,不能只信本地开发环境。另外,noVNC 的静态资源(如果需要用到某些内置资源)路径是相对路径,项目部署在子目录时要注意 base 配置,否则会出现资源 404。7.4 一个可复用的组件设计思路最后聊聊怎么把这块做成一个像样的组件。我的建议是把职责拆成两层:底层VncScreen.vue:只负责接收 wsUrl 和密码,渲染 RFB,向外抛 connect / disconnect / error 事件;上层业务组件:负责调接口拿连接信息、处理鉴权、显示工具栏、管理多台机器的切换。这样底层组件可以在多个页面复用,坏处是抽象多一点,但长远看维护成本更低。我自己在一个有几十台设备的项目里就是这么分的,后面加全屏剪贴板自动重连这些功能都只在底层改,上层业务不受影响。具体实现时,底层组件对外暴露几个方法(通过defineExpose)会很有用:connect()、disconnect()、sendCtrlAltDel()、pasteText()。上层通过 ref 调用,交互逻辑和内层实现就彻底解耦了。在写这个组件的过程中,我最大的体会是:容易出问题的地方永远不在 Vue 这一层,而在 VNC Server 和 WebSocket 代理这两层。前端代码几十行就能跑起来,但一旦画面出问题,八成的排查工作都在服务器环境中。所以如果你打算上这个功能,前期一定要在某台机器上把整条链路手动跑通一遍,确认 VNC Server 稳定、代理不超时、端口不被拦,再动手写组件。这个顺序看起来笨,但能帮你省掉后面大量代码明明没问题却连不上的困惑。