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

参考资料

相关推荐
pe7er9 分钟前
使用RealFaviconGenerator.net一站式生成各平台兼容 Favicon
前端
用户25191624271111 分钟前
Canvas之贪吃蛇
前端·javascript·canvas
一枚前端小能手12 分钟前
🔥 TypeScript高手都在用的4个类型黑科技
前端·typescript
Elieal17 分钟前
深入浅出:Ajax 与 Servlet 实现前后端数据交互
前端·ajax·servlet
王者鳜錸27 分钟前
VUE+SPRINGBOOT从0-1打造前后端-前后台系统-文章详情、评论、点赞
前端·vue.js·spring boot
乐予吕27 分钟前
别再乱用箭头函数了!JavaScript 三种函数写法的终极指南
前端·javascript·代码规范
雨绸缪28 分钟前
编程之路:我为什么要编程
前端·程序员
一大树32 分钟前
Vue 3 中 `ref` 的“浅监听”行为解析:是误解还是真相?
前端·vue.js
海天胜景33 分钟前
vue3 el-select 加载内容后 触发事件
前端·javascript·vue.js
小高0071 小时前
🚀Promise 全家桶:原理、实现、API、实战,一篇搞定
前端·javascript·面试