现在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. 终端实例管理
在多终端环境下,我们需要妥善管理每个终端实例的生命周期。特别是在分屏、关闭、重连等场景下,需要确保:
- 终端实例的正确创建和销毁
- WebSocket 连接的管理
- 切换窗口时终端状态的保持与关闭窗口时资源的释放
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
})
}
}