设计实现一个Web 终端:基于 Vue 3 和 Xterm.js 的实践

现在Web 终端已经成为了云服务器管理的标配工具,那么如果让你设计一个web终端,应该具备哪些功能呢,又有哪些难点?正好最近接到了一个终端改造的项目需求,我在调研了市面上几大云服务器厂商的产品后,设计实现了一款Web Terminal,可以给有类似需求的小伙伴一个参考。

项目介绍

🔗 在线预览 (仅前端界面预览,需要本地启动后端服务才能体验完整功能)

🔗 Github

核心功能实现

1. 终端管理器的设计

终端管理是整个项目的核心,采用单例模式实现了一个功能完整的终端管理器,负责处理终端的生命周期、连接状态和事件管理:

javascript 复制代码
class TerminalManager {
  constructor() {
    this.terminals = new Map() // 存储终端连接信息
  }

  createTerminal(terminalId, config = {}) {
    const terminalData = {
      id: terminalId,
      ip: config.ip,
      role: config.role,
      terminal: null,
      socket: null,
      fitAddon: null,
      status: TerminalStatus.NoConnected,
      theme: config.theme || {
        background: '#1e1e1e',
        foreground: '#ffffff',
        cursor: '#ffffff'
      },
      eventHandlers: {
        statusChange: [],
        focus: []
      }
    }

    this.terminals.set(terminalId, terminalData)
    return terminalData
  }

  async initTerminal(terminalId, containerElement, shouldConnect = true) {
    const terminalData = this.terminals.get(terminalId)
    if (!terminalData) return null

    // 创建终端实例
    terminalData.terminal = new Terminal({
      cursorBlink: true,
      fontFamily: 'JetBrainsMono, monaco, Consolas, Lucida Console, monospace',
      theme: terminalData.theme,
      allowProposedApi: true,
      scrollback: 1000,
      fontSize: 14,
      rendererType: 'canvas',
      convertEol: true
    })

    // 加载必要的插件
    terminalData.fitAddon = new FitAddon()
    const searchAddon = new SearchAddon()
    const webLinksAddon = new WebLinksAddon()

    terminalData.terminal.loadAddon(terminalData.fitAddon)
    terminalData.terminal.loadAddon(searchAddon)
    terminalData.terminal.loadAddon(webLinksAddon)

    // 初始化终端
    terminalData.terminal.open(containerElement)
    
    // 自动调整大小
    const resizeObserver = new ResizeObserver(() => {
      setTimeout(() => this.fitTerminal(terminalId), 0)
    })
    resizeObserver.observe(containerElement)
    terminalData.resizeObserver = resizeObserver

    return terminalData.terminal
  }
}

2. WebSocket 连接管理

一个健壮的 WebSocket 连接管理系统,包括自动重连、心跳检测和错误处理:

javascript 复制代码
async initSocket(terminalId) {
  const terminalData = this.terminals.get(terminalId)
  if (!terminalData || !terminalData.terminal) return

  const terminal = terminalData.terminal
  const cols = terminal.cols || 80
  const rows = terminal.rows || 24
  
  const url = `ws://${terminalData.ip}:8025?rows=${rows}&cols=${cols}&sys_user=${terminalData.role}`

  terminalData.socket = new WebSocket(url)

  terminalData.socket.onopen = () => {
    terminalData.status = TerminalStatus.Connected
    this.emitEvent(terminalId, 'statusChange', TerminalStatus.Connected)
    
    // 注册终端事件
    terminal.onData(data => {
      this.sendData(terminalId, { type: 2, msg: data })
    })

    // 心跳检测
    terminalData.timer = setInterval(() => {
      this.sendData(terminalId, { type: 3, msg: 'ping' })
    }, 80250)
  }

  terminalData.socket.onmessage = (e) => {
    try {
      const data = JSON.parse(e.data)
      
      if (data.type === 'init') {
        terminalData.terminalId = data.terminalId
        return
      }
      
      if (data.type === 'pong') return
      
      if (data.terminalId && data.terminalId !== terminalData.terminalId) {
        return
      }
      
      terminal.write(data.content || e.data)
    } catch (err) {
      terminal.write(e.data)
    }
  }
}

3. 终端状态管理

状态枚举和事件系统来管理终端状态:

javascript 复制代码
// 终端状态枚举
export const TerminalStatus = {
  Error: -1,
  NoConnected: 0,
  Connected: 1,
  Disconnected: 2
}

// 事件管理
class TerminalManager {
  addEventListener(terminalId, eventType, callback) {
    const terminalData = this.terminals.get(terminalId)
    if (terminalData && terminalData.eventHandlers[eventType]) {
      terminalData.eventHandlers[eventType].push(callback)
    }
  }

  emitEvent(terminalId, eventType, ...args) {
    const terminalData = this.terminals.get(terminalId)
    if (terminalData && terminalData.eventHandlers[eventType]) {
      terminalData.eventHandlers[eventType].forEach(callback => {
        callback(...args)
      })
    }
  }
}

4. 终端面板组件

终端面板组件实现了分屏、标签页管理等功能,由于需要嵌套循环节点,因此采用了递归渲染:

vue 复制代码
<template>
  <div class="terminal-panel" :class="{ 'has-children': panel.children.length > 0 }">
    <!-- 子面板递归渲染 -->
    <template v-if="panel.children.length > 0">
      <div class="panel-container" :class="{ 'vertical': panel.direction === 'vertical' }">
        <div v-for="child in panel.children" class="panel-wrapper">
          <terminal-panel :panel="child" />
          <div class="panel-resizer" @mousedown="startResize"></div>
        </div>
      </div>
    </template>
    
    <!-- 终端内容 -->
    <template v-else>
      <div class="terminal-content">
        <div class="terminal-tabs">
          <el-tabs v-model="activeTerminalId" type="card" closable>
            <el-tab-pane v-for="term in panel.terminals" :key="term.id" :label="term.title">
            </el-tab-pane>
          </el-tabs>
          
          <!-- 终端操作按钮 -->
          <div class="operation-buttons">
            <div class="operation-icon" @click="handleSplitVertical">
              <img :src="splitVerticalIcon" />
            </div>
            <div class="operation-icon" @click="handleSplitHorizontal">
              <img :src="splitHorizontalIcon" />
            </div>
          </div>
        </div>
        
        <div class="terminals-container">
          <h-terminal v-for="term in panel.terminals" :key="term.id" :ip="term.ip" />
        </div>
      </div>
    </template>
  </div>
</template>

技术难点

1. 终端实例管理

在多终端环境下,我们需要妥善管理每个终端实例的生命周期。特别是在分屏、关闭、重连等场景下,需要确保:

  1. 终端实例的正确创建和销毁
  2. WebSocket 连接的管理
  3. 切换窗口时终端状态的保持与关闭窗口时资源的释放

2. 终端大小自适应

xterm提供了xterm-addon-fit插件,终端窗口在resize的时候,它能够自动调整终端的行数和列数来盛满父容器,另外还需要将"行"和"列"通过socket连接告知后端,另外可通过 ResizeObserver API来提高性能:

javascript 复制代码
// 监听容器大小变化
const resizeObserver = new ResizeObserver(() => {
  setTimeout(() => {
    this.fitTerminal(terminalId)
  }, 0)
})

resizeObserver.observe(containerElement)

// 调整终端大小
fitTerminal(terminalId) {
  const terminalData = this.terminals.get(terminalId)
  if (terminalData?.fitAddon) {
    terminalData.fitAddon.fit()
    // 通知服务器终端大小变化
    this.sendData(terminalId, {
      type: 1,
      cols: terminalData.terminal.cols,
      rows: terminalData.terminal.rows
    })
  }
}

参考资料

相关推荐
崔庆才丨静觅9 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606110 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了10 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅10 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅10 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅11 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment11 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅11 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊11 小时前
jwt介绍
前端
爱敲代码的小鱼11 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax