设计实现一个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
    })
  }
}

参考资料

相关推荐
Bigger1 天前
告别版本焦虑:如何为 Hugo 项目定制专属构建环境
前端·架构·go
代码匠心1 天前
AI 自动编程:一句话设计高颜值博客
前端·ai·ai编程·claude
_AaronWong1 天前
Electron 实现仿豆包划词取词功能:从 AI 生成到落地踩坑记
前端·javascript·vue.js
cxxcode1 天前
I/O 多路复用:从浏览器到 Linux 内核
前端
用户5433081441941 天前
AI 时代,前端逆向的门槛已经低到离谱 — 以 Upwork 为例
前端
JarvanMo1 天前
Flutter 版本的 material_ui 已经上架 pub.dev 啦!快来抢先体验吧。
前端
恋猫de小郭1 天前
AI 可以让 WIFI 实现监控室内人体位置和姿态,无需摄像头?
前端·人工智能·ai编程
哀木1 天前
给自己整一个 claude code,解锁编程新姿势
前端
程序员鱼皮1 天前
GitHub 关注突破 2w,我总结了 10 个涨星涨粉技巧!
前端·后端·github
UrbanJazzerati1 天前
Vue3 父子组件通信完全指南
前端·面试